diff --git a/backend/__tests__/services/contractService.test.js b/backend/__tests__/services/contractService.test.js new file mode 100644 index 00000000..55268afe --- /dev/null +++ b/backend/__tests__/services/contractService.test.js @@ -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(), + ); + }); +}); diff --git a/backend/__tests__/services/customerAccountsService.passive.test.js b/backend/__tests__/services/customerAccountsService.passive.test.js new file mode 100644 index 00000000..a38f3f11 --- /dev/null +++ b/backend/__tests__/services/customerAccountsService.passive.test.js @@ -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: 'test@example.com', + prefill: { first_name: 'Anna', company_name: 'ACME GmbH' }, + createdByAdminId: 5, + }); + expect(result.id).toBeDefined(); + const inserted = insertResults.customer_accounts[0]; + expect(inserted.email).toBe('test@example.com'); + 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: 'de@example.com', + 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: 'fr@example.com', + 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: 'dup@example.com', password_hash: 'whatever' }; + await expect(customerAccountsService.createDirect({ + email: 'dup@example.com', + 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: 'silent@example.com', + 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: 'active@example.com', password_hash: 'hash' }; + await expect(customerAccountsService.createInvitation({ + email: 'active@example.com', + 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: 'passive@example.com', 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: 'passive@example.com', + invitedById: 9, + prefill: { first_name: 'Anna' }, + }); + expect(out.id).toBeDefined(); + expect(out.token).toMatch(/^[0-9a-f]{64}$/); + expect(mockQueueEmail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/__tests__/services/customerHoursService.test.js b/backend/__tests__/services/customerHoursService.test.js new file mode 100644 index 00000000..23302408 --- /dev/null +++ b/backend/__tests__/services/customerHoursService.test.js @@ -0,0 +1,165 @@ +/** + * Unit tests for the pure helpers in customerHoursService (migration + * 129). The CRUD paths themselves are exercised end-to-end via the + * admin/customers routes during manual QA; this file covers the + * deterministic logic so regressions in the rate / duration / lock + * resolution show up before they hit a real invoice. + */ + +const path = require('path'); +const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService'); + +// The service imports invoiceService which pulls in the DB. We don't +// need either for the pure helpers — stub the DB layer so the +// require chain doesn't try to connect to anything. +jest.mock('../../src/database/db', () => ({ + db: jest.fn(), + logActivity: jest.fn(), + withRetry: (fn) => fn(), +})); +jest.mock('../../src/services/invoiceService', () => ({})); + +const { _internal } = require(servicePath); +const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal; + +describe('computeDurationMinutes', () => { + it('returns minute count for a basic window', () => { + expect(computeDurationMinutes('09:00', '11:30')).toBe(150); + }); + + it('handles single-minute precision', () => { + expect(computeDurationMinutes('09:30', '11:00')).toBe(90); + expect(computeDurationMinutes('14:15', '14:30')).toBe(15); + }); + + it('rejects malformed input', () => { + expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/); + expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/); + }); + + it('rejects zero or negative duration', () => { + expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/); + expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/); + }); +}); + +describe('resolveEffectiveRate', () => { + it('prefers the per-entry override when set', () => { + expect(resolveEffectiveRate( + { hourly_rate_minor_override: 20000 }, + { hourly_rate_minor: 15000 }, + )).toBe(20000); + }); + + it('falls back to the customer default when no override', () => { + expect(resolveEffectiveRate( + { hourly_rate_minor_override: null }, + { hourly_rate_minor: 15000 }, + )).toBe(15000); + }); + + it('throws when both override and customer rate are unset', () => { + expect(() => resolveEffectiveRate( + { hourly_rate_minor_override: null }, + { hourly_rate_minor: null }, + )).toThrow(/No hourly rate/); + }); + + it('treats override=0 as "explicitly zero" (not null)', () => { + // Override === 0 is unusual but legal — pro bono blocks, internal + // tracking. Must NOT fall through to the customer default. + expect(resolveEffectiveRate( + { hourly_rate_minor_override: 0 }, + { hourly_rate_minor: 15000 }, + )).toBe(0); + }); +}); + +describe('isEntryLocked', () => { + it('unbilled entry → not locked', () => { + expect(isEntryLocked({ invoice_id: null }, null)).toBe(false); + }); + + it('monthly draft → not locked (still accumulating)', () => { + expect(isEntryLocked( + { invoice_id: 42 }, + { id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null }, + )).toBe(false); + }); + + it('standalone draft with no send time → not locked', () => { + expect(isEntryLocked( + { invoice_id: 42 }, + { id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null }, + )).toBe(false); + }); + + it('future-scheduled draft → not locked', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + expect(isEntryLocked( + { invoice_id: 42 }, + { id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future }, + )).toBe(false); + }); + + it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + expect(isEntryLocked( + { invoice_id: 42 }, + { id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past }, + )).toBe(true); + }); + + it('sent / paid / overdue / cancelled → locked', () => { + for (const status of ['sent', 'paid', 'overdue', 'cancelled']) { + expect(isEntryLocked( + { invoice_id: 42 }, + { id: 42, is_monthly_draft: false, status, scheduled_send_at: null }, + )).toBe(true); + } + }); + + it('entry references a deleted invoice (null) → treat as unbilled', () => { + expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false); + }); +}); + +describe('buildLineItemFromEntry', () => { + const baseEntry = { + entry_date: '2026-05-20', + start_time: '09:00', + end_time: '11:30', + duration_minutes: 150, + description: 'Editing wedding photos', + }; + + it('formats the description per spec', () => { + const li = buildLineItemFromEntry(baseEntry, 15000); + expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h): Editing wedding photos'); + }); + + it('omits the colon when no description', () => { + const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000); + expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)'); + }); + + it('quantity is decimal hours with 2 places', () => { + const li = buildLineItemFromEntry(baseEntry, 15000); + expect(li.quantity).toBeCloseTo(2.5, 5); + }); + + it('line_total rounds correctly for non-clean durations', () => { + // 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor + const li = buildLineItemFromEntry( + { ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 }, + 10000, + ); + expect(li.line_total_minor).toBe(2500); + }); + + it('zero-rate line items produce a zero total without exploding', () => { + const li = buildLineItemFromEntry(baseEntry, 0); + expect(li.line_total_minor).toBe(0); + expect(li.unit_price_minor).toBe(0); + }); +}); diff --git a/backend/__tests__/services/eventService.calendar.test.js b/backend/__tests__/services/eventService.calendar.test.js new file mode 100644 index 00000000..ef15a034 --- /dev/null +++ b/backend/__tests__/services/eventService.calendar.test.js @@ -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, + }); + }); +}); diff --git a/backend/__tests__/services/invoiceService.hierarchy.test.js b/backend/__tests__/services/invoiceService.hierarchy.test.js new file mode 100644 index 00000000..a41e08fe --- /dev/null +++ b/backend/__tests__/services/invoiceService.hierarchy.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/invoiceService.installmentPlan.test.js b/backend/__tests__/services/invoiceService.installmentPlan.test.js new file mode 100644 index 00000000..53df8002 --- /dev/null +++ b/backend/__tests__/services/invoiceService.installmentPlan.test.js @@ -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(); + }); +}); diff --git a/backend/__tests__/services/invoiceService.locks.test.js b/backend/__tests__/services/invoiceService.locks.test.js new file mode 100644 index 00000000..5ab507a8 --- /dev/null +++ b/backend/__tests__/services/invoiceService.locks.test.js @@ -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: 'admin@example.com', username: 'admin' }; + pickChainFor('business_profile')._firstValue = null; + pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' }; + + const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true }); + expect(res.sent).toBe(true); + expect(res.token).toMatch(/^[a-f0-9]{64}$/); + }); +}); diff --git a/backend/__tests__/services/pdfService.baseDocument.test.js b/backend/__tests__/services/pdfService.baseDocument.test.js new file mode 100644 index 00000000..e4ba52de --- /dev/null +++ b/backend/__tests__/services/pdfService.baseDocument.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/pdfService.helpers.test.js b/backend/__tests__/services/pdfService.helpers.test.js new file mode 100644 index 00000000..7327bbc6 --- /dev/null +++ b/backend/__tests__/services/pdfService.helpers.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/services/quoteService.hierarchy.test.js b/backend/__tests__/services/quoteService.hierarchy.test.js new file mode 100644 index 00000000..71ed01f4 --- /dev/null +++ b/backend/__tests__/services/quoteService.hierarchy.test.js @@ -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'); + }); +}); diff --git a/backend/__tests__/services/quoteService.locks.test.js b/backend/__tests__/services/quoteService.locks.test.js new file mode 100644 index 00000000..1fe3c886 --- /dev/null +++ b/backend/__tests__/services/quoteService.locks.test.js @@ -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: 'c@example.com', 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(); + } + }); +}); diff --git a/backend/__tests__/services/taxReportPdf.test.js b/backend/__tests__/services/taxReportPdf.test.js new file mode 100644 index 00000000..9838a7da --- /dev/null +++ b/backend/__tests__/services/taxReportPdf.test.js @@ -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: 'hello@example.com', + 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); + }); +}); diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js new file mode 100644 index 00000000..a6fcb5b7 --- /dev/null +++ b/backend/__tests__/services/taxReportService.test.js @@ -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: 'anna@example.com', + })).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: 'anna@example.com' })).toBe('anna@example.com'); + }); + + 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); + }); +}); diff --git a/backend/__tests__/utils/iban.test.js b/backend/__tests__/utils/iban.test.js new file mode 100644 index 00000000..26c2b027 --- /dev/null +++ b/backend/__tests__/utils/iban.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/utils/pdfFilename.test.js b/backend/__tests__/utils/pdfFilename.test.js new file mode 100644 index 00000000..5a762b44 --- /dev/null +++ b/backend/__tests__/utils/pdfFilename.test.js @@ -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"gi|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: 'luca@bresch.cc', + })).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 "_.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$/); + }); +}); diff --git a/backend/__tests__/utils/resolveLogoFile.test.js b/backend/__tests__/utils/resolveLogoFile.test.js new file mode 100644 index 00000000..0d8d2c58 --- /dev/null +++ b/backend/__tests__/utils/resolveLogoFile.test.js @@ -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'); + }); +}); diff --git a/backend/assets/fonts/Comic-Neue/400.ttf b/backend/assets/fonts/Comic-Neue/400.ttf new file mode 100644 index 00000000..5106d718 Binary files /dev/null and b/backend/assets/fonts/Comic-Neue/400.ttf differ diff --git a/backend/assets/fonts/Comic-Neue/700.ttf b/backend/assets/fonts/Comic-Neue/700.ttf new file mode 100644 index 00000000..30296e62 Binary files /dev/null and b/backend/assets/fonts/Comic-Neue/700.ttf differ diff --git a/backend/assets/fonts/IBM-Plex-Sans/400.ttf b/backend/assets/fonts/IBM-Plex-Sans/400.ttf new file mode 100644 index 00000000..27bd7ff9 Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/400.ttf differ diff --git a/backend/assets/fonts/IBM-Plex-Sans/600.ttf b/backend/assets/fonts/IBM-Plex-Sans/600.ttf new file mode 100644 index 00000000..111aa750 Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/600.ttf differ diff --git a/backend/assets/fonts/IBM-Plex-Sans/700.ttf b/backend/assets/fonts/IBM-Plex-Sans/700.ttf new file mode 100644 index 00000000..32360595 Binary files /dev/null and b/backend/assets/fonts/IBM-Plex-Sans/700.ttf differ diff --git a/backend/assets/fonts/Inter/400.ttf b/backend/assets/fonts/Inter/400.ttf new file mode 100644 index 00000000..6d53192d Binary files /dev/null and b/backend/assets/fonts/Inter/400.ttf differ diff --git a/backend/assets/fonts/Inter/600.ttf b/backend/assets/fonts/Inter/600.ttf new file mode 100644 index 00000000..663ee543 Binary files /dev/null and b/backend/assets/fonts/Inter/600.ttf differ diff --git a/backend/assets/fonts/Inter/700.ttf b/backend/assets/fonts/Inter/700.ttf new file mode 100644 index 00000000..aecc5b4b Binary files /dev/null and b/backend/assets/fonts/Inter/700.ttf differ diff --git a/backend/assets/fonts/Jost/400.ttf b/backend/assets/fonts/Jost/400.ttf new file mode 100644 index 00000000..d222f4cf Binary files /dev/null and b/backend/assets/fonts/Jost/400.ttf differ diff --git a/backend/assets/fonts/Jost/600.ttf b/backend/assets/fonts/Jost/600.ttf new file mode 100644 index 00000000..af13d369 Binary files /dev/null and b/backend/assets/fonts/Jost/600.ttf differ diff --git a/backend/assets/fonts/Jost/700.ttf b/backend/assets/fonts/Jost/700.ttf new file mode 100644 index 00000000..9f4dc798 Binary files /dev/null and b/backend/assets/fonts/Jost/700.ttf differ diff --git a/backend/assets/fonts/Montserrat/400.ttf b/backend/assets/fonts/Montserrat/400.ttf new file mode 100644 index 00000000..e95165bd Binary files /dev/null and b/backend/assets/fonts/Montserrat/400.ttf differ diff --git a/backend/assets/fonts/Montserrat/600.ttf b/backend/assets/fonts/Montserrat/600.ttf new file mode 100644 index 00000000..a265b018 Binary files /dev/null and b/backend/assets/fonts/Montserrat/600.ttf differ diff --git a/backend/assets/fonts/Montserrat/700.ttf b/backend/assets/fonts/Montserrat/700.ttf new file mode 100644 index 00000000..016d0cd3 Binary files /dev/null and b/backend/assets/fonts/Montserrat/700.ttf differ diff --git a/backend/assets/fonts/Noto-Sans/400.ttf b/backend/assets/fonts/Noto-Sans/400.ttf new file mode 100644 index 00000000..5ad62d14 Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/400.ttf differ diff --git a/backend/assets/fonts/Noto-Sans/600.ttf b/backend/assets/fonts/Noto-Sans/600.ttf new file mode 100644 index 00000000..35eae6b8 Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/600.ttf differ diff --git a/backend/assets/fonts/Noto-Sans/700.ttf b/backend/assets/fonts/Noto-Sans/700.ttf new file mode 100644 index 00000000..1faf2181 Binary files /dev/null and b/backend/assets/fonts/Noto-Sans/700.ttf differ diff --git a/backend/assets/fonts/Playfair-Display/400.ttf b/backend/assets/fonts/Playfair-Display/400.ttf new file mode 100644 index 00000000..eaf6b450 Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/400.ttf differ diff --git a/backend/assets/fonts/Playfair-Display/600.ttf b/backend/assets/fonts/Playfair-Display/600.ttf new file mode 100644 index 00000000..91db3710 Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/600.ttf differ diff --git a/backend/assets/fonts/Playfair-Display/700.ttf b/backend/assets/fonts/Playfair-Display/700.ttf new file mode 100644 index 00000000..373ea2e8 Binary files /dev/null and b/backend/assets/fonts/Playfair-Display/700.ttf differ diff --git a/backend/assets/fonts/Poppins/400.ttf b/backend/assets/fonts/Poppins/400.ttf new file mode 100644 index 00000000..1b278084 Binary files /dev/null and b/backend/assets/fonts/Poppins/400.ttf differ diff --git a/backend/assets/fonts/Poppins/600.ttf b/backend/assets/fonts/Poppins/600.ttf new file mode 100644 index 00000000..d61ff4b6 Binary files /dev/null and b/backend/assets/fonts/Poppins/600.ttf differ diff --git a/backend/assets/fonts/Poppins/700.ttf b/backend/assets/fonts/Poppins/700.ttf new file mode 100644 index 00000000..7a4bbd2a Binary files /dev/null and b/backend/assets/fonts/Poppins/700.ttf differ diff --git a/backend/package-lock.json b/backend/package-lock.json index fd80080d..47878f76 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 490a373f..506732db 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/server.js b/backend/server.js index cc3d382e..8fae56a5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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,6 +770,10 @@ 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(); diff --git a/backend/src/routes/adminBusinessProfile.js b/backend/src/routes/adminBusinessProfile.js new file mode 100644 index 00000000..5ddfd613 --- /dev/null +++ b/backend/src/routes/adminBusinessProfile.js @@ -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; diff --git a/backend/src/routes/adminCalendar.js b/backend/src/routes/adminCalendar.js new file mode 100644 index 00000000..d0c3633d --- /dev/null +++ b/backend/src/routes/adminCalendar.js @@ -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; diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js new file mode 100644 index 00000000..7a571635 --- /dev/null +++ b/backend/src/routes/adminContracts.js @@ -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; diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 54d7a8ae..16926681 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -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; diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index a5302f3e..7169e264 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/adminDeals.js b/backend/src/routes/adminDeals.js new file mode 100644 index 00000000..e4b8ccb8 --- /dev/null +++ b/backend/src/routes/adminDeals.js @@ -0,0 +1,83 @@ +/** + * Admin → deals lineage endpoint. + * + * One UUID per customer engagement spans every quote, contract, and + * invoice (migration 140). This route exposes the union: given a + * deal_uuid, return every related document so the frontend's + * DocumentLineageCard can render the full chain with a single query + * instead of walking the legacy point-to-point FKs in JS. + * + * Read-only. The same `customers.view` permission used elsewhere for + * lineage display is the gate here — anyone who can read a quote or + * invoice detail page can read its deal lineage. + * + * Sibling routes (`/api/admin/quotes/:id/lineage`, + * `/api/admin/contracts/:id/lineage`, `/api/admin/invoices/:id/lineage`) + * also exist as conveniences so the frontend doesn't have to fetch + * the deal_uuid first; they resolve and delegate to the same service. + */ + +const express = require('express'); +const { param, body } = require('express-validator'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const dealsService = require('../services/dealsService'); +const invoiceService = require('../services/invoiceService'); +const { db } = require('../database/db'); + +const router = express.Router(); +router.use(adminAuth); + +router.get( + '/:uuid/documents', + requirePermission('customers.view'), + // UUID v4 format check — adminCalendar uses a similar pattern. + // Length window 32–36 covers both hyphenated and non-hyphenated + // forms; the service does the actual lookup. + [param('uuid').isString().isLength({ min: 32, max: 36 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await dealsService.getDealDocuments(req.params.uuid); + return successResponse(res, result); + }), +); + +/** + * Atomically reshape an installment plan after siblings have spawned. + * Delegates to invoiceService.updateInstallmentPlan inside a transaction. + * See that service function for the guard/reuse/grow/trim semantics. + * + * 400 — invalid input (validator or service-side percent sum / unknown + * trigger / single-invoice deal). + * 404 — deal_uuid owns no invoices. + * 409 — at least one sibling is past `scheduled`/`pending_delivery`, or + * the deal contains a Storno. + */ +router.put( + '/:uuid/installment-plan', + requirePermission('bills.manage'), + [ + param('uuid').isString().isLength({ min: 32, max: 36 }), + body('installments').isArray({ min: 1 }), + body('installments.*.percent').isFloat({ min: 0, max: 100 }), + body('installments.*.trigger').isIn([ + 'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date', + ]), + body('installments.*.offset_days').isInt(), + body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 200 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const adminId = req.admin?.id; + const result = await db.transaction((trx) => invoiceService.updateInstallmentPlan({ + trx, + dealUuid: req.params.uuid, + installments: req.body.installments, + adminId, + })); + return successResponse(res, result); + }), +); + +module.exports = router; diff --git a/backend/src/routes/adminDev.js b/backend/src/routes/adminDev.js new file mode 100644 index 00000000..b5732d16 --- /dev/null +++ b/backend/src/routes/adminDev.js @@ -0,0 +1,436 @@ +/** + * Admin → Dev tools + * + * Internal-use endpoints surfaced via the "Development" sub-tab + * under Clients. Strictly gated behind THREE layers: + * - admin auth + `settings.edit` permission + * - the `crmDevelopment` feature flag (defense-in-depth — the + * frontend hides the tab when off, this check stops API + * callers from poking endpoints that aren't supposed to fire) + * - the `PICPEAK_ENABLE_DEV_TOOLS=1` environment variable + * (production-safety hard gate — a stray feature flag flip in + * a real install can't enable these endpoints) + * + * Currently exposes: + * POST /send-test-email queue any CRM email template to the + * currently-logged-in admin's mailbox, + * with SYNTHETIC data only (PDFs are + * rendered from hard-coded sample data, + * never from real customer records) + * + * Security history: a prior version of this route queried real + * customer records (`SELECT … FROM quotes ORDER BY id DESC LIMIT 1`) + * to source the sample PDFs, which leaked one customer's invoice to + * a different admin's inbox in multi-admin installs. The synthetic- + * only data path closes that leak; the env gate prevents accidental + * production exposure if the feature flag is ever flipped on by + * mistake. + */ + +const express = require('express'); +const { body } = require('express-validator'); +const path = require('path'); +const fs = require('fs'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { db } = require('../database/db'); +const emailProcessor = require('../services/emailProcessor'); +const pdfService = require('../services/pdfService'); +const { AppError } = require('../utils/errors'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +router.use(adminAuth); + +/** + * Production-safety gate. Even if the crmDevelopment feature flag is + * accidentally enabled in a production install (one wrong DB toggle), + * this env check prevents the endpoints from doing anything. Operators + * who genuinely want dev tools in a non-production environment set + * PICPEAK_ENABLE_DEV_TOOLS=1 in the env file. + */ +router.use(handleAsync(async (req, res, next) => { + if (process.env.PICPEAK_ENABLE_DEV_TOOLS !== '1') { + return res.status(403).json({ + error: 'CRM development tools are disabled (PICPEAK_ENABLE_DEV_TOOLS env var not set)', + code: 'CRM_DEV_ENV_DISABLED', + }); + } + next(); +})); + +/** + * Gate every endpoint below the crmDevelopment feature flag. + * Mirrors the parent /admin/clients/development route guard. + */ +router.use(handleAsync(async (req, res, next) => { + const row = await db('feature_flags').where({ key: 'crmDevelopment' }).first(); + const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); + if (!enabled) { + return res.status(403).json({ + error: 'CRM development tools are disabled', + code: 'CRM_DEV_DISABLED', + }); + } + next(); +})); + +const TEMPLATES_KEYS = [ + 'quote_sent', + 'quote_accepted_customer', + 'quote_accepted_admin', + 'quote_declined_admin', + 'invoice_sent', + 'invoice_reminder_first', + 'invoice_reminder_second', + 'invoice_payment_check_admin', + // Contracts (migration 130). All three flows are exercised: + // - contract_sent: admin → customer, with a sample contract PDF + // - contract_signed_admin_notification: customer-signed ping back + // to the admin (no attachment in the real flow either) + // - contract_fully_signed: dual-party send when both signatures + // are in. Real flow attaches the stamped contract + audit cert; + // the dev tester attaches the stamped contract only (the audit + // cert is reproducible from contract data so its absence here + // doesn't change what's being tested — the template body). + 'contract_sent', + 'contract_signed_admin_notification', + 'contract_fully_signed', +]; + +router.get( + '/email-templates', + requirePermission('settings.edit'), + handleAsync(async (_req, res) => { + // Return the keys + whether each template exists in the DB so + // the UI can grey out missing ones (e.g. on an install that + // hasn't run migration 116 yet). + const rows = await db('email_templates') + .whereIn('template_key', TEMPLATES_KEYS) + .select('template_key'); + const present = new Set(rows.map((r) => r.template_key)); + return successResponse(res, { + templates: TEMPLATES_KEYS.map((k) => ({ key: k, present: present.has(k) })), + }); + }) +); + +const FRONTEND_URL_FALLBACK = 'https://app.example.com'; +const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test'); + +function fakeMoney(major, currency, locale = 'de') { + return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', { + style: 'currency', currency: (currency || 'CHF').toUpperCase(), + }).format(major); +} +function fakeShortDate(d) { + const date = d instanceof Date ? d : new Date(d); + return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`; +} + +/** + * Keep the dev-test PDF directory bounded: retain only the 7 newest + * files per cleanup pass. Each test-email render writes a fresh file; + * without cleanup the directory grows unbounded. + * + * Best-effort: failures are logged and swallowed (cleanup never blocks + * the test-email flow). + */ +function pruneDevTestDir() { + try { + const dir = DEV_TEST_DIR(); + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith('.pdf')) + .map((e) => { + const full = path.join(dir, e.name); + return { full, mtime: fs.statSync(full).mtimeMs }; + }) + .sort((a, b) => b.mtime - a.mtime); // newest first + for (const old of entries.slice(7)) { + try { fs.unlinkSync(old.full); } catch (_) { /* best-effort */ } + } + } catch (err) { + logger.warn('dev send-test-email: cleanup of dev-test dir failed', { err: err.message }); + } +} + +/** + * Shared synthetic issuer + recipient blocks used by all three + * sample-PDF builders. The issuer pulls from `business_profile` so + * the admin sees their own brand on the test PDF (logo, address, + * fonts) — that's the operator's own data, safe to render. The + * recipient block is fully synthetic so no customer PII is ever + * embedded. + * + * Returning `null` for issuer is acceptable; pdfService's + * normaliseContext defaults each missing field. We still fetch the + * profile when available to make the test render look realistic. + */ +async function buildSyntheticParties() { + let profile = {}; + try { + const businessProfileService = require('../services/businessProfileService'); + profile = (await businessProfileService.getProfile()).profile || {}; + } catch (_) { + // Fresh install with no business_profile row: render with + // generic defaults below. + } + const issuer = { + companyName: profile.company_name || 'Sample Studio', + addressLine1: profile.address_line1 || 'Beispielstrasse 1', + addressLine2: profile.address_line2, + postalCode: profile.postal_code || '8000', + city: profile.city || 'Zürich', + state: profile.state, + countryCode: profile.country_code || 'CH', + phone: profile.phone, + mobile: profile.mobile, + email: profile.email || 'studio@example.test', + website: profile.website, + footerLine: profile.footer_line, + vatId: profile.vat_id, + logoPath: null, // skip logo file lookup for the synthetic render + pdfFontTtfPath: profile.pdf_font_ttf_path, + pdfFontFamily: profile.pdf_font_family || null, + countryName: profile.country_name || null, + showLogo: false, + showCompanyName: true, + logoHeight: 56, + companyNameInline: false, + foldingMarks: 'none', + quoteShowNetDays: false, + quoteShowSkonto: false, + }; + const recipient = { + issuerLine: profile.company_name + ? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}` + : '', + companyName: 'Sample Customer GmbH', + hasCompany: true, + attentionLine: 'z. Hd. Maria Sample', + salutation: 'Frau', + lastName: 'Sample', + addressLine1: 'Musterstrasse 1', + addressLine2: null, + postalCode: '8000', + city: 'Zürich', + country: null, + countryCodeIso: 'CH', + }; + return { issuer, recipient }; +} + +const SYNTHETIC_LINE_ITEMS = [ + { quantity: 1, description: 'Photo session (sample)', unitPriceMinor: 80000, discountPercent: 0, lineTotalMinor: 80000, parentLineItemId: null, parentPosition: null, detailsText: null }, + { quantity: 2, description: 'Photo prints A4 (sample)', unitPriceMinor: 1500, discountPercent: 0, lineTotalMinor: 3000, parentLineItemId: null, parentPosition: null, detailsText: null }, +]; +const SYNTHETIC_TOTALS = { netAmountMinor: 83000, vatRate: 7.7, vatAmountMinor: 6391, shippingAmountMinor: 0, totalAmountMinor: 89391 }; + +/** + * Render a sample QUOTE PDF from synthetic data — no DB read of real + * quotes. Uses pdfService.renderQuoteToBuffer directly with a render + * context matching the shape produced by quoteService.buildRenderContext. + */ +async function renderSyntheticQuotePdf(adminId) { + try { + const { issuer, recipient } = await buildSyntheticParties(); + const today = new Date(); + const ctx = { + locale: 'de', + currency: 'CHF', + qrFormat: 'none', + issuer, + recipient, + lineItems: SYNTHETIC_LINE_ITEMS, + totals: SYNTHETIC_TOTALS, + doc: { + quoteNumber: 'Q-DEV-0001', + issueDate: today, + validUntil: new Date(today.getTime() + 14 * 86400000), + introText: 'Sample quote — synthetic data only. Not a real customer record.', + outroText: null, + totalAmountMinor: SYNTHETIC_TOTALS.totalAmountMinor, + }, + bank: null, + paymentTerm: null, + }; + const buffer = await pdfService.renderQuoteToBuffer(ctx); + return writeSyntheticPdf(buffer, `quote-sample-${adminId}-${Date.now()}.pdf`, 'Q-DEV-0001-sample.pdf'); + } catch (err) { + logger.warn('dev send-test-email: synthetic quote PDF render failed', { err: err.message }); + return null; + } +} + +async function renderSyntheticInvoicePdf(adminId) { + try { + const { issuer, recipient } = await buildSyntheticParties(); + const today = new Date(); + const ctx = { + locale: 'de', + currency: 'CHF', + qrFormat: 'none', + issuer, + recipient, + lineItems: SYNTHETIC_LINE_ITEMS, + totals: SYNTHETIC_TOTALS, + doc: { + invoiceNumber: 'R-DEV-0001', + issueDate: today, + dueDate: new Date(today.getTime() + 30 * 86400000), + introText: 'Sample invoice — synthetic data only. Not a real customer record.', + outroText: null, + kind: 'invoice', + lateFeeMinor: 0, + }, + bank: null, + paymentTerm: null, + }; + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + return writeSyntheticPdf(buffer, `invoice-sample-${adminId}-${Date.now()}.pdf`, 'R-DEV-0001-sample.pdf'); + } catch (err) { + logger.warn('dev send-test-email: synthetic invoice PDF render failed', { err: err.message }); + return null; + } +} + +async function renderSyntheticContractPdf(adminId) { + try { + const { issuer, recipient } = await buildSyntheticParties(); + const today = new Date(); + const ctx = { + locale: 'de', + dateFormat: null, + issuer, + recipient, + today, + doc: { + contractNumber: 'C-DEV-0001', + title: 'Sample contract — synthetic data only', + issueDate: today, + validUntil: new Date(today.getTime() + 30 * 86400000), + introText: null, + outroText: null, + }, + sections: [{ + section: 'basics', + blocks: [{ + slug: 'basics_service', + name: 'Subject of contract (sample)', + section: 'basics', + body: 'This is a synthetic dev-test contract. Not a real customer agreement.', + }], + }], + signatures: { customer: null, admin: null }, + }; + const buffer = await pdfService.renderContractToBuffer(ctx); + return writeSyntheticPdf(buffer, `contract-sample-${adminId}-${Date.now()}.pdf`, 'C-DEV-0001-sample.pdf'); + } catch (err) { + logger.warn('dev send-test-email: synthetic contract PDF render failed', { err: err.message }); + return null; + } +} + +function writeSyntheticPdf(buffer, onDiskName, attachmentName) { + const dir = DEV_TEST_DIR(); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, onDiskName); + fs.writeFileSync(filePath, buffer); + pruneDevTestDir(); + return { path: filePath, filename: attachmentName }; +} + +/** + * Build a payload tailored to each template. All variables map back + * to the `{{tokens}}` the seeded templates reference, so the email + * the admin sees is identical to what the real flow would send. + */ +async function buildPayloadFor(key, adminId, frontendUrl) { + const dummyToken = 'dev-test-token-' + Math.random().toString(16).slice(2, 12).padEnd(64, '0').slice(0, 64); + const total = 1234.56; + const lateFee = 25.00; + const today = new Date(); + const dueDate = new Date(today.getTime() - 5 * 86400000); + const validUntil = new Date(today.getTime() + 14 * 86400000); + + const common = { + customer_name: 'Sample Customer', + customer_email: 'sample.customer@example.test', + event_name: 'Sample Event', + invoice_number: 'R-DEV-0001', + quote_number: 'Q-DEV-0001', + total_amount: fakeMoney(total, 'CHF'), + new_total_amount: fakeMoney(total + lateFee, 'CHF'), + late_fee_amount: fakeMoney(lateFee, 'CHF'), + late_fee_due: true, + due_date: fakeShortDate(dueDate), + valid_until: fakeShortDate(validUntil), + days_overdue: 5, + installment_label: 'Anzahlung', + installment_index: 1, + installment_total: 2, + admin_dashboard_url: `${frontendUrl}/admin/clients/bills`, + response_url: `${frontendUrl}/quote/${dummyToken}`, + accept_url: `${frontendUrl}/quote/${dummyToken}?action=accept`, + decline_url: `${frontendUrl}/quote/${dummyToken}?action=decline`, + paid_url: `${frontendUrl}/payment-check/${dummyToken}?action=paid_full`, + partial_url: `${frontendUrl}/payment-check/${dummyToken}?action=partial`, + unpaid_url: `${frontendUrl}/payment-check/${dummyToken}?action=unpaid`, + accepted_on_behalf: true, + // Contract-specific variables. Title + contract_number stand in + // for the matching {{tokens}} in the seeded contract templates. + contract_number: 'C-DEV-0001', + title: 'Sample contract — synthetic data only', + signed_customer_name: 'Sample Customer', + }; + + // Templates with PDF attachments get a SYNTHETIC sample PDF. Never + // pulls from real records on disk — every render builds from + // hardcoded sample data via the renderSynthetic*Pdf helpers above. + let attachments; + if (key === 'quote_sent' || key === 'quote_accepted_customer') { + const pdf = await renderSyntheticQuotePdf(adminId); + if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }]; + } else if (key === 'invoice_sent' || key === 'invoice_reminder_first' || key === 'invoice_reminder_second') { + const pdf = await renderSyntheticInvoicePdf(adminId); + if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }]; + } else if (key === 'contract_sent' || key === 'contract_fully_signed') { + const pdf = await renderSyntheticContractPdf(adminId); + if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }]; + } + + return attachments ? { ...common, attachments } : common; +} + +router.post( + '/send-test-email', + requirePermission('settings.edit'), + [body('templateKey').isString().isIn(TEMPLATES_KEYS)], + handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where({ id: req.admin.id }).first(); + if (!admin?.email) throw new AppError('Logged-in admin has no email on file', 400); + + const template = await db('email_templates') + .where({ template_key: req.body.templateKey }).first(); + if (!template) { + throw new AppError(`Template "${req.body.templateKey}" not seeded yet — run migrations`, 409, 'TEMPLATE_MISSING'); + } + + const frontendUrl = (process.env.FRONTEND_URL || FRONTEND_URL_FALLBACK).replace(/\/$/, ''); + const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl); + + await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload); + + return successResponse(res, { + sent: true, + to: admin.email, + template: req.body.templateKey, + }, 200, 'Test email queued'); + }) +); + +module.exports = router; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 29064649..24b5177d 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -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_`). 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 { diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 4ce28d14..690d3276 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -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, diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 6202b32c..f76b2ceb 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -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; } diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js new file mode 100644 index 00000000..51ce2334 --- /dev/null +++ b/backend/src/routes/adminInvoices.js @@ -0,0 +1,882 @@ +/** + * Admin → Invoices Routes + * + * Endpoint mounted at /api/admin/invoices. Surface: + * GET / list (filter + sort + paginate) + * POST / create (status=scheduled or sent) + * GET /:id detail incl. line items + payments + * PUT /:id update (only when not paid/cancelled) + * POST /:id/send render PDF + queue email now + * POST /:id/mark-paid record a payment + * POST /:id/send-reminder manually trigger reminder ladder + * POST /:id/cancel cancel a non-paid invoice + * GET /:id/pdf preview / download PDF + * GET /:id/payment-log list payment log entries + * POST /preview render PDF from unsaved payload + * + * Permissions: `bills.view` for reads, `bills.manage` for writes. + * Global `bills` feature flag enforced at the route layer. + */ + +const express = require('express'); +const { body, param, query } = require('express-validator'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs').promises; +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { getStoragePath } = require('../config/storage'); +const invoiceService = require('../services/invoiceService'); +const { db } = require('../database/db'); + +const router = express.Router(); + +// Multer config for "import historical invoice" PDF uploads. Stored +// under storage/business-docs/invoice-imports// so +// imported files don't collide with the renderer's own output under +// storage/business-docs/invoice//. PDF-only, 10MB cap. +const importedInvoiceStorage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const year = new Date().getFullYear(); + const dir = path.join(getStoragePath(), 'business-docs', 'invoice-imports', String(year)); + await fs.mkdir(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname) || '.pdf'; + cb(null, `imported-${Date.now()}${ext}`); + }, +}); +const importedInvoiceUpload = multer({ + storage: importedInvoiceStorage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype === 'application/pdf') cb(null, true); + else cb(new Error('Only PDF files are allowed for imported invoices')); + }, +}); + +async function requireBillsFlag(req, res, next) { + try { + const row = await db('feature_flags').where({ key: 'bills' }).first(); + const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); + if (!enabled) return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' }); + next(); + } catch (err) { next(err); } +} + +router.use(adminAuth); +router.use(requireBillsFlag); + +function transformInvoice(i) { + if (!i) return null; + return { + id: i.id, + invoiceNumber: i.invoice_number, + customerAccountId: i.customer_account_id, + customer: { + email: i.customer_email, + displayName: i.customer_display_name, + firstName: i.customer_first_name, + lastName: i.customer_last_name, + companyName: i.customer_company_name, + // Passive customers (admin-only, no portal access) are + // identified by a null password_hash. We expose just the + // boolean — the hash itself is dropped here. + isPassive: i.customer_password_hash == null, + }, + // Migration 140 — cross-document lineage UUID. See adminQuotes + // transform for the rationale; lets the lineage card pull the + // whole deal in one query. + dealUuid: i.deal_uuid || null, + sourceQuoteId: i.source_quote_id, + sourceQuoteNumber: i.source_quote_number || null, + // Migration 130 lineage: set by contractService.convertToInvoiceOnly + // so BillDetailPage can render a "From contract" badge. The number + // (e.g. LBM-C-2026-0010) comes from the src_contract JOIN; the id + // is kept as a fallback for invoices generated before the JOIN + // was wired in. + sourceContractId: i.source_contract_id || null, + sourceContractNumber: i.source_contract_number || null, + eventId: i.event_id, + language: i.language, + currency: i.currency, + issueDate: i.issue_date, + dueDate: i.due_date, + installmentIndex: i.installment_index, + installmentTotal: i.installment_total, + installmentLabel: i.installment_label, + installmentTrigger: i.installment_trigger, + status: i.status, + scheduledSendAt: i.scheduled_send_at, + sentAt: i.sent_at, + netAmountMinor: i.net_amount_minor, + vatRate: i.vat_rate == null ? null : Number(i.vat_rate), + vatAmountMinor: i.vat_amount_minor, + shippingAmountMinor: i.shipping_amount_minor, + totalAmountMinor: i.total_amount_minor, + paidAmountMinor: i.paid_amount_minor, + paidAt: i.paid_at, + paymentMethod: i.payment_method, + paymentReference: i.payment_reference, + reminderLevel: i.reminder_level, + lastReminderSentAt: i.last_reminder_sent_at, + lateFeeAmountMinor: i.late_fee_amount_minor, + ccPdfEmail: i.cc_pdf_email, + qrFormat: i.qr_format, + pdfPath: i.pdf_path, + businessBankAccountId: i.business_bank_account_id, + paymentTermTemplateId: i.payment_term_template_id || null, + // Split payment-term picker (migration 124). Two new FKs; the + // editor prefers these. Both must be present for the new path to + // engage server-side. + paymentNetDaysTemplateId: i.payment_net_days_template_id || null, + paymentTimingTemplateId: i.payment_timing_template_id || null, + // Migration 126 — per-invoice Skonto opt-out. Editor surfaces + // this as a checkbox so admin can suppress the discount for one + // invoice without touching the template or global default. + skontoDisabled: i.skonto_disabled === true || i.skonto_disabled === 1, + // Monthly billing (migration 128). isMonthlyDraft=true marks the + // accumulator the editor's banner + save-button-label react to. + // monthlyPeriodStart/End drive the period banner on the customer + // detail page and (later) the PDF header. + isMonthlyDraft: i.is_monthly_draft === true || i.is_monthly_draft === 1, + monthlyPeriodStart: i.monthly_period_start || null, + monthlyPeriodEnd: i.monthly_period_end || null, + // Storno wiring (migration 114). The four FK columns drive the + // admin UI's banners + action gating: + // - kind: 'invoice' | 'storno' — defaults to 'invoice' for rows + // seeded before the column existed (legacy installs). + // - replacesInvoiceId: on a reissued invoice → original cancelled id. + // - cancelsInvoiceId: on a Storno row → invoice it reverses. + // - cancellationStornoId: on a cancelled original → Storno that + // cancelled it (so the detail view can link forward). + kind: i.kind || 'invoice', + replacesInvoiceId: i.replaces_invoice_id || null, + cancelsInvoiceId: i.cancels_invoice_id || null, + cancelsInvoiceNumber: i.cancels_invoice_number || null, + cancellationStornoId: i.cancellation_storno_id || null, + cancellationStornoNumber: i.cancellation_storno_number || null, + // Inline event snapshot (migration 123). The editor binds to + // these, the list page shows event_name as a column, and email + // / tax-report rendering reads them in preference to the FK. + eventName: i.event_name || null, + eventDate: i.event_date || null, + eventTimeStart: i.event_time_start || null, + eventTimeEnd: i.event_time_end || null, + // `isImported` surfaces the historical-PDF flag to the admin UI + // so the list / detail page can hide line-item editing on rows + // that originated from a different billing system (migration 111). + isImported: !!i.imported_pdf_path, + createdAt: i.created_at, + updatedAt: i.updated_at, + }; +} + +function transformLineItem(li) { + return { + id: li.id, + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unitPriceMinor: li.unit_price_minor, + discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent), + lineTotalMinor: li.line_total_minor, + // Hierarchy (migration 119). parentPosition comes from the + // self-join in getInvoiceById; parentLineItemId is the raw FK. + // detailsText is the optional free-form notes block rendered + // below the description on the PDF and customer view. + parentLineItemId: li.parent_line_item_id || null, + parentPosition: li.parent_position == null ? null : Number(li.parent_position), + detailsText: li.details_text || null, + }; +} + +function transformPaymentLog(p) { + return { + id: p.id, + amountMinor: p.amount_minor, + paidAt: p.paid_at, + paymentMethod: p.payment_method, + reference: p.reference, + notes: p.notes, + recordedByAdminId: p.recorded_by_admin_id, + createdAt: p.created_at, + }; +} + +const INVOICE_BODY_VALIDATORS = [ + body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), + body('issueDate').optional({ values: 'falsy' }).isISO8601(), + body('dueDate').optional({ values: 'falsy' }).isISO8601(), + body('scheduledSendAt').optional({ values: 'falsy' }).isISO8601(), + body('installmentIndex').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('installmentTotal').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('installmentLabel').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), + body('installmentTrigger').optional({ values: 'falsy' }).isString().isLength({ max: 32 }), + body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('qrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']), + body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + // Split payment-term picker (migration 124). Both optional at the + // validator level so legacy clients still work; the editor will + // require them once it's updated. + body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + // Ad-hoc installments override (commit #6 of the deal_uuid PR). + // When the array has ≥2 rows with percent>0, createInvoice routes + // through spawnInstallmentInvoices (commit #4) and returns + // invoiceIds[]. + body('installments').optional().isArray(), + body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), + body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']), + body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(), + body('skontoDisabled').optional().isBoolean(), + // Inline event snapshot (migration 123). Mirrors quotes — kept + // optional because standalone invoices may not have an event yet. + body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('eventDate').optional({ values: 'falsy' }).isISO8601(), + body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('lineItems').optional({ values: 'falsy' }).isArray(), + body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }), + body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }), + body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + // Migration 119: sub-item + details support. Cross-row constraints + // (parent must exist, max 1 level deep) are enforced by the service + // (validateLineItemHierarchy). + body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }), +]; + +function mapPayloadToService(body) { + const out = {}; + const map = { + customerAccountId: 'customerAccountId', + sourceQuoteId: 'sourceQuoteId', + eventId: 'eventId', + language: 'language', currency: 'currency', + issueDate: 'issueDate', dueDate: 'dueDate', + scheduledSendAt: 'scheduledSendAt', + installmentIndex: 'installmentIndex', + installmentTotal: 'installmentTotal', + installmentLabel: 'installmentLabel', + installmentTrigger: 'installmentTrigger', + vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor', + ccPdfEmail: 'ccPdfEmail', businessBankAccountId: 'businessBankAccountId', + qrFormat: 'qrFormat', + eventName: 'eventName', + eventDate: 'eventDate', + eventTimeStart: 'eventTimeStart', + eventTimeEnd: 'eventTimeEnd', + paymentTermTemplateId: 'paymentTermTemplateId', + paymentNetDaysTemplateId: 'paymentNetDaysTemplateId', + paymentTimingTemplateId: 'paymentTimingTemplateId', + skontoDisabled: 'skontoDisabled', + // Ad-hoc installment plan from the InstallmentsPanel. When the + // array has ≥2 entries with percent > 0, createInvoice routes + // through spawnInstallmentInvoices (commit #4). + installments: 'installments', + }; + for (const [api, svc] of Object.entries(map)) { + if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api]; + } + if (Array.isArray(body.lineItems)) { + out.lineItems = body.lineItems.map((li, idx) => ({ + position: li.position == null ? idx + 1 : li.position, + quantity: li.quantity, + description: li.description, + unit_price_minor: li.unitPriceMinor, + discount_percent: li.discountPercent, + // Migration 119 sub-item + details support — same mapping as + // quotes so the editor's payload shape is identical for both. + parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition), + details_text: li.detailsText == null ? null : String(li.detailsText), + })); + } + return out; +} + +// ---- list + read ----------------------------------------------------- + +router.get( + '/', + requirePermission('bills.view'), + [ + query('status').optional({ values: 'falsy' }).isString(), + query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(), + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']), + query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const statusFilter = req.query.status + ? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean) + : []; + const { rows, total, page, pageSize } = await invoiceService.listInvoices({ + filters: { + status: statusFilter, + customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null, + sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null, + unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true, + q: req.query.q, + }, + sort: req.query.sort || 'newest', + page: req.query.page ? parseInt(req.query.page, 10) : 1, + pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25, + }); + return successResponse(res, { + invoices: rows.map(transformInvoice), + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 }, + }); + }) +); + +router.get( + '/:id', + requirePermission('bills.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const data = await invoiceService.getInvoiceById(id); + if (!data) return res.status(404).json({ error: 'Invoice not found' }); + // Resolve the effective Skonto percentage so the BillDetail + // "Record payment" dialog can render the "Paid with Skonto" + // checkbox + auto-fill the discounted amount (migration 126). + // Reuses the same resolver the payment-check email path uses so + // the two surfaces agree on whether the invoice qualifies. + const skontoPercent = await invoiceService.resolveSkontoPercentForInvoice(data.invoice); + const invoiceOut = transformInvoice(data.invoice); + invoiceOut.skontoPercent = skontoPercent || null; + return successResponse(res, { + invoice: invoiceOut, + lineItems: data.lineItems.map(transformLineItem), + payments: data.payments.map(transformPaymentLog), + }); + }) +); + +// ---- create + update ------------------------------------------------- + +router.post( + '/', + requirePermission('bills.manage'), + [body('customerAccountId').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS], + handleAsync(async (req, res) => { + validateRequest(req); + // createInvoice always returns `{ invoiceIds: number[] }` — + // single-installment / standalone case is a one-element array, + // multi-installment is N (auto-routed through + // spawnInstallmentInvoices). The response surfaces the first + // invoice's payload (the one the editor redirects to) plus the + // full id list so the editor can show "N invoices created". + const { invoiceIds } = await invoiceService.createInvoice(mapPayloadToService(req.body), req.admin.id); + const firstId = invoiceIds[0]; + const data = await invoiceService.getInvoiceById(firstId); + return successResponse(res, { + invoice: transformInvoice(data.invoice), + lineItems: data.lineItems.map(transformLineItem), + invoiceIds, + }, 201, 'Invoice created'); + }) +); + +// POST /import — attach a historical invoice PDF to a customer's +// account. Inserts a minimal invoice row whose `imported_pdf_path` +// points at the uploaded file. Every PDF endpoint (admin + customer) +// short-circuits the renderer when this column is populated, so the +// customer downloads the original document untouched. +// +// Use case: migrating from QuickBooks / Bexio / Xero — the admin +// keeps the legal records intact but the customer still sees a +// consolidated history in their portal. +// +// Form fields (multipart/form-data): +// pdf file (required, application/pdf, max 10MB) +// customerAccountId int (required) +// invoiceNumber string (required — admin types the original) +// issueDate ISO date (required) +// dueDate ISO date (optional, defaults to issueDate) +// totalAmountMinor int minor units (required) +// currency 3-letter ISO (optional, default profile/CHF) +// status 'sent' | 'paid' | 'overdue' (default 'sent') +// paidAmountMinor int (optional, for status='paid') +// language string (optional, default 'de') +router.post( + '/import', + requirePermission('bills.manage'), + importedInvoiceUpload.single('pdf'), + [ + body('customerAccountId').isInt({ min: 1 }), + body('invoiceNumber').isString().isLength({ min: 1, max: 64 }), + body('issueDate').isISO8601(), + body('dueDate').optional({ values: 'falsy' }).isISO8601(), + body('totalAmountMinor').isInt({ min: 0 }), + body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), + body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']), + body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + if (!req.file) return res.status(400).json({ error: 'PDF file is required' }); + + // Confirm the customer exists + has bills enabled (same gate as + // the regular createInvoice). + const customer = await db('customer_accounts').where({ id: req.body.customerAccountId }).first(); + if (!customer) { + // Clean up the uploaded file so failed imports don't leave + // orphans on disk. + try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ } + return res.status(404).json({ error: 'Customer not found' }); + } + if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') { + try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ } + return res.status(409).json({ + error: 'This customer has bills disabled', + code: 'CUSTOMER_FEATURE_DISABLED', + }); + } + + // Refuse duplicate invoice numbers — tax compliance requires + // uniqueness within the issuer's books. + const conflict = await db('invoices').where({ invoice_number: req.body.invoiceNumber }).first(); + if (conflict) { + try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ } + return res.status(409).json({ + error: `Invoice number "${req.body.invoiceNumber}" already exists`, + code: 'INVOICE_NUMBER_TAKEN', + }); + } + + const totalMinor = parseInt(req.body.totalAmountMinor, 10); + const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0; + const status = req.body.status || 'sent'; + const issueDate = req.body.issueDate; + const dueDate = req.body.dueDate || issueDate; + const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase(); + const language = req.body.language || customer.preferred_language || 'de'; + + const row = { + invoice_number: req.body.invoiceNumber, + customer_account_id: customer.id, + source_quote_id: null, + event_id: null, + language, + currency, + issue_date: issueDate, + due_date: dueDate, + installment_index: 0, + installment_total: 1, + installment_label: null, + installment_trigger: null, + status, + scheduled_send_at: null, + sent_at: status !== 'scheduled' ? new Date() : null, + net_amount_minor: totalMinor, // imported docs lack a breakdown + vat_rate: 0, // VAT info lives in the imported PDF + vat_amount_minor: 0, + shipping_amount_minor: 0, + total_amount_minor: totalMinor, + paid_amount_minor: paidMinor, + paid_at: status === 'paid' ? new Date() : null, + // Store the path RELATIVE to STORAGE_PATH so the value survives + // a host migration (Docker volume remount on a new host with a + // different absolute path). + imported_pdf_path: path.relative(getStoragePath(), req.file.path), + created_by_admin_id: req.admin.id, + created_at: new Date(), + updated_at: new Date(), + }; + + const inserted = await db('invoices').insert(row).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + return successResponse(res, { + invoice: transformInvoice(await db('invoices').where({ id: invoiceId }).first()), + }, 201, 'Invoice imported'); + }) +); + +// PUT — full re-save delegated through createInvoice's helper isn't +// straightforward (we keep the existing row). Implementing as a small +// inline shim that overrides scalars + replaces line items. +router.put( + '/:id', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const existing = await db('invoices').where({ id }).first(); + if (!existing) return res.status(404).json({ error: 'Invoice not found' }); + // Once an invoice has been sent to the customer it becomes a + // legal record under CH/LI/DE/AT tax rules ("Rechnung ist + // ausgestellt"). Modifying it in place would break the audit + // trail — the correct workflow is to cancel the original + + // issue a new one. Only `scheduled` (not yet sent) invoices + // remain editable. + if (existing.status !== 'scheduled') { + return res.status(409).json({ + error: `Cannot edit invoice with status '${existing.status}'. Sent invoices are locked — cancel and reissue if changes are needed.`, + code: 'INVOICE_LOCKED', + }); + } + const payload = mapPayloadToService(req.body); + + // Recompute totals if line items are present. + let updates = { updated_at: new Date() }; + const map = { + language: 'language', currency: 'currency', + issueDate: 'issue_date', dueDate: 'due_date', + scheduledSendAt: 'scheduled_send_at', + installmentIndex: 'installment_index', + installmentTotal: 'installment_total', + installmentLabel: 'installment_label', + installmentTrigger: 'installment_trigger', + vatRate: 'vat_rate', shippingAmountMinor: 'shipping_amount_minor', + ccPdfEmail: 'cc_pdf_email', businessBankAccountId: 'business_bank_account_id', + qrFormat: 'qr_format', + // Per-invoice Skonto opt-out (migration 126). + skontoDisabled: 'skonto_disabled', + // Inline event snapshot (migration 123) — editable as long as + // the invoice is still in 'scheduled' status (this route already + // gates on that above). + eventName: 'event_name', + eventDate: 'event_date', + eventTimeStart: 'event_time_start', + eventTimeEnd: 'event_time_end', + }; + for (const [api, col] of Object.entries(map)) { + if (Object.prototype.hasOwnProperty.call(payload, api)) updates[col] = payload[api]; + } + // Payment-term selection: re-snapshot the template when the admin + // changes it. Mirrors createInvoice — once the column is set the + // PDF renderer prefers it over the source-quote fallback. + if (Object.prototype.hasOwnProperty.call(payload, 'paymentTermTemplateId')) { + const id = parseInt(payload.paymentTermTemplateId, 10); + if (id) { + const tpl = await db('payment_term_templates').where({ id }).first(); + if (tpl) { + updates.payment_term_template_id = tpl.id; + updates.payment_term_snapshot = JSON.stringify({ + description: tpl.description || null, + net_days: tpl.net_days, + skonto_percent: tpl.skonto_percent, + skonto_within_days: tpl.skonto_within_days, + installments: typeof tpl.installments === 'string' + ? (() => { try { return JSON.parse(tpl.installments); } catch { return null; } })() + : tpl.installments || null, + }); + } + } else { + // Explicit clear — admin picked "no template". + updates.payment_term_template_id = null; + updates.payment_term_snapshot = null; + } + } + + // Migration 124 — split payment-term picker. When both new FKs are + // present, prefer them and re-compose the snapshot from the pair. + // The editor sends both together so we don't have to handle the + // half-set case; it stays a noop here when only one is supplied. + if ( + Object.prototype.hasOwnProperty.call(payload, 'paymentNetDaysTemplateId') + && Object.prototype.hasOwnProperty.call(payload, 'paymentTimingTemplateId') + ) { + const netDaysId = parseInt(payload.paymentNetDaysTemplateId, 10); + const timingId = parseInt(payload.paymentTimingTemplateId, 10); + if (netDaysId && timingId) { + const [netDays, timing] = await Promise.all([ + db('payment_net_days_templates').where({ id: netDaysId }).first(), + db('payment_timing_templates').where({ id: timingId }).first(), + ]); + if (netDays && timing) { + updates.payment_net_days_template_id = netDays.id; + updates.payment_timing_template_id = timing.id; + // Clear the legacy FK — the editor is moving off it. + updates.payment_term_template_id = null; + updates.payment_term_snapshot = JSON.stringify({ + description: timing.description || netDays.description || null, + net_days: netDays.net_days, + skonto_percent: netDays.skonto_percent, + skonto_within_days: netDays.skonto_within_days, + installments: typeof timing.installments === 'string' + ? (() => { try { return JSON.parse(timing.installments); } catch { return null; } })() + : timing.installments || null, + }); + } + } else { + // Explicit clear — admin emptied both. + updates.payment_net_days_template_id = null; + updates.payment_timing_template_id = null; + updates.payment_term_snapshot = null; + } + } + + if (Array.isArray(payload.lineItems)) { + // Recompute everything authoritatively. Migration 119 — sub- + // items don't roll into net directly; parent totals auto- + // resolve from priced sub-items via resolveParentTotalsFromSubItems + // (shared helper in quoteService._internal). + const items = payload.lineItems.map((li, idx) => { + const qty = Number(li.quantity || 1); + const unit = parseInt(li.unit_price_minor, 10) || 0; + const disc = Number(li.discount_percent || 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - disc / 100)); + const isSubItem = li.parent_position != null && li.parent_position !== ''; + return { + position: parseInt(li.position, 10) || (idx + 1), + quantity: qty, + description: String(li.description || ''), + unit_price_minor: unit, + discount_percent: disc, + line_total_minor: lineTotal, + parent_position: isSubItem ? parseInt(li.parent_position, 10) : null, + details_text: li.details_text || null, + }; + }); + const { resolveParentTotalsFromSubItems } = require('../services/quoteService')._internal; + resolveParentTotalsFromSubItems(items); + let net = 0; + for (const it of items) { + if (it.parent_position == null) net += parseInt(it.line_total_minor, 10) || 0; + } + const vatRate = Number(payload.vatRate ?? existing.vat_rate ?? 0); + const vatAmount = Math.round(net * vatRate / 100); + const shipping = parseInt(payload.shippingAmountMinor ?? existing.shipping_amount_minor ?? 0, 10); + updates.net_amount_minor = net; + updates.vat_amount_minor = vatAmount; + updates.vat_rate = vatRate; + updates.shipping_amount_minor = shipping; + updates.total_amount_minor = net + vatAmount + shipping; + + const quoteService = require('../services/quoteService'); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal; + await db.transaction(async (trx) => { + await trx('invoice_line_items').where({ invoice_id: id }).del(); + if (items.length > 0) { + validateLineItemHierarchy(items); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', id, items); + } + await trx('invoices').where({ id }).update(updates); + }); + } else { + await db('invoices').where({ id }).update(updates); + } + + const data = await invoiceService.getInvoiceById(id); + return successResponse(res, { + invoice: transformInvoice(data.invoice), + lineItems: data.lineItems.map(transformLineItem), + }, 200, 'Invoice updated'); + }) +); + +// ---- send / pay / remind / cancel ------------------------------------ + +router.post( + '/:id/send', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id); + return successResponse(res, { sent: true }); + }) +); + +router.post( + '/:id/mark-paid', + requirePermission('bills.manage'), + [ + param('id').isInt({ min: 1 }), + body('amountMinor').isInt({ min: 1 }), + body('paidAt').optional({ values: 'falsy' }).isISO8601(), + body('paymentMethod').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), + body('reference').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), + body('notes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('skontoApplied').optional().isBoolean(), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await invoiceService.markPaid(parseInt(req.params.id, 10), { + amountMinor: req.body.amountMinor, + paidAt: req.body.paidAt, + paymentMethod: req.body.paymentMethod, + reference: req.body.reference, + notes: req.body.notes, + skontoApplied: req.body.skontoApplied, + }, req.admin.id); + return successResponse(res, result); + }) +); + +router.post( + '/:id/send-reminder', + requirePermission('bills.manage'), + [ + param('id').isInt({ min: 1 }), + body('level').optional({ values: 'falsy' }).isInt({ min: 1, max: 2 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await invoiceService.sendReminder( + parseInt(req.params.id, 10), + req.body.level || null, + req.admin.id + ); + return successResponse(res, result, 200, 'Reminder sent'); + }) +); + +// Test the admin payment-check email manually — bypasses the 24h +// throttle so the admin can verify the full flow (email → token +// page → action recorded) without waiting for the invoice to age +// past its reminder threshold. Only operates on sent/overdue +// invoices (same gate as the scheduled path). +router.post( + '/:id/test-payment-check', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await invoiceService.queuePaymentCheckEmail( + parseInt(req.params.id, 10), + { skipThrottle: true } + ); + if (!result.sent) { + return res.status(409).json({ + error: `Payment-check email not sent: ${result.reason}`, + code: 'PAYMENT_CHECK_NOT_SENT', + reason: result.reason, + }); + } + return successResponse(res, result, 200, 'Test payment-check email queued'); + }) +); + +// Cancel + reissue — atomically cancels the existing invoice and +// creates a fresh scheduled duplicate with a new sequential number, +// linked via replaces_invoice_id (migration 114). The PDF renderer +// stamps "Bezug: Ersetzt Rechnung R-XXXX vom DATE" on the new +// invoice so the customer + auditors can trace the chain. +router.post( + '/:id/reissue', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await invoiceService.reissueInvoice(parseInt(req.params.id, 10), req.admin.id); + return successResponse(res, result, 201, 'Invoice reissued'); + }) +); + +// Release a pending_delivery invoice — photographer has confirmed +// delivery and wants the final installment to fire now. +router.post( + '/:id/release-for-delivery', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const result = await invoiceService.releaseForDelivery(parseInt(req.params.id, 10), req.admin.id); + return successResponse(res, result, 200, 'Delivery invoice released'); + }) +); + +router.post( + '/:id/cancel', + requirePermission('bills.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + // Service returns { cancelled, stornoId } — pass through so the + // frontend can show "Storno S-XXXX wurde erzeugt" feedback + // when the invoice was already issued (vs. silent soft-cancel + // on drafts). + const result = await invoiceService.cancelInvoice(parseInt(req.params.id, 10), req.admin.id); + return successResponse(res, result); + }) +); + +// ---- PDF ------------------------------------------------------------- + +router.get( + '/:id/pdf', + requirePermission('bills.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const buf = await invoiceService.renderInvoicePdfBuffer(id); + // Build a useful filename: `_.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; diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js new file mode 100644 index 00000000..4900c280 --- /dev/null +++ b/backend/src/routes/adminQuotes.js @@ -0,0 +1,820 @@ +/** + * Admin → Quotes Routes + * + * Endpoint mounted at /api/admin/quotes. Surface: + * GET / list (filter + sort + paginate) + * POST / create (status=draft) + * GET /:id detail + * PUT /:id update (line items + scalars) + * POST /:id/send render PDF + queue email + * POST /:id/duplicate clone as new draft + * POST /:id/convert convert accepted quote → event + * GET /:id/pdf preview / download persisted PDF + * POST /preview render PDF from unsaved payload + * GET /presets/line-items + * POST /presets/line-items + * PUT /presets/line-items/:id + * DELETE /presets/line-items/:id + * GET /presets/payment-terms + * POST /presets/payment-terms + * PUT /presets/payment-terms/:id + * DELETE /presets/payment-terms/:id + * + * Permissions: `quotes.view` for reads, `quotes.manage` for writes. + * The global `quotes` feature flag is checked at the route layer so a + * disabled installation returns 403 cleanly without the route bodies + * ever running. + */ + +const express = require('express'); +const { body, param, query } = require('express-validator'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const quoteService = require('../services/quoteService'); +const { db } = require('../database/db'); + +const router = express.Router(); + +// ----- feature flag gate (admin global) ------------------------------- +async function requireQuotesFlag(req, res, next) { + try { + const row = await db('feature_flags').where({ key: 'quotes' }).first(); + const enabled = row && (row.value === true || row.value === 1 || row.value === '1'); + if (!enabled) { + return res.status(403).json({ error: 'Quotes feature is disabled', code: 'QUOTES_DISABLED' }); + } + next(); + } catch (err) { + next(err); + } +} + +router.use(adminAuth); +router.use(requireQuotesFlag); + +// --------------------------------------------------------------------- +// Transforms (snake_case DB → camelCase API) +// --------------------------------------------------------------------- + +function transformQuote(q) { + if (!q) return null; + return { + id: q.id, + quoteNumber: q.quote_number, + customerAccountId: q.customer_account_id, + customer: { + email: q.customer_email, + displayName: q.customer_display_name, + firstName: q.customer_first_name, + lastName: q.customer_last_name, + companyName: q.customer_company_name, + // Passive customers (admin-only, no portal access) flagged + // by null password_hash. Hash itself is dropped here. + isPassive: q.customer_password_hash == null, + }, + status: q.status, + // Migration 140 — cross-document lineage UUID. Lets the frontend + // call /api/admin/deals/:uuid/documents in one shot to render the + // full lineage (quote + contract + N invoices + Storni). + dealUuid: q.deal_uuid || null, + language: q.language, + currency: q.currency, + issueDate: q.issue_date, + validUntil: q.valid_until, + eventName: q.event_name, + eventDate: q.event_date, + eventTimeStart: q.event_time_start, + eventTimeEnd: q.event_time_end, + expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours), + paymentTermTemplateId: q.payment_term_template_id, + // Split payment-term picker (migration 124). + paymentNetDaysTemplateId: q.payment_net_days_template_id || null, + paymentTimingTemplateId: q.payment_timing_template_id || null, + netAmountMinor: q.net_amount_minor, + vatRate: q.vat_rate == null ? null : Number(q.vat_rate), + vatAmountMinor: q.vat_amount_minor, + shippingAmountMinor: q.shipping_amount_minor, + totalAmountMinor: q.total_amount_minor, + introText: q.intro_text, + outroText: q.outro_text, + internalNotes: q.internal_notes, + ccPdfEmail: q.cc_pdf_email, + sentAt: q.sent_at, + respondedAt: q.responded_at, + responseLockedAt: q.response_locked_at, + acceptedAt: q.accepted_at, + declinedAt: q.declined_at, + convertedEventId: q.converted_event_id, + // Migration 130 lineage. Null until quoteService.createFromQuote + // sets it. Surfaced so QuoteDetailPage can render a "Linked + // contract" badge alongside the existing resulting-invoices list. + // contract_number comes from the conv_contract JOIN — falls back + // to null when the converted contract has been deleted (FK is + // ON DELETE SET NULL). + convertedContractId: q.converted_contract_id || null, + convertedContractNumber: q.converted_contract_number || null, + pdfPath: q.pdf_path, + businessBankAccountId: q.business_bank_account_id, + createdAt: q.created_at, + updatedAt: q.updated_at, + }; +} + +function transformLineItem(li) { + return { + id: li.id, + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unitPriceMinor: li.unit_price_minor, + discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent), + lineTotalMinor: li.line_total_minor, + // Hierarchy (migration 119). `parentPosition` is what the editor + // uses to thread sub-items in unsaved drafts; on existing rows + // we hydrate it from the actual parent's position via a join in + // getQuoteById (see service). NULL = top-level item. + parentLineItemId: li.parent_line_item_id || null, + parentPosition: li.parent_position == null ? null : Number(li.parent_position), + detailsText: li.details_text || null, + }; +} + +function transformPaymentTermTemplate(t) { + if (!t) return null; + return { + id: t.id, + name: t.name, + description: t.description, + netDays: t.net_days, + skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent), + skontoWithinDays: t.skonto_within_days, + installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments, + isSystem: t.is_system === 1 || t.is_system === true, + isActive: t.is_active === 1 || t.is_active === true, + displayOrder: t.display_order, + }; +} + +// Split payment-term templates (migration 124). Two transforms because +// the rows have different shapes — net-days carries Skonto, timing +// carries the installments array. +function transformPaymentNetDaysTemplate(t) { + if (!t) return null; + return { + id: t.id, + name: t.name, + description: t.description, + netDays: t.net_days, + skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent), + skontoWithinDays: t.skonto_within_days, + isSystem: t.is_system === 1 || t.is_system === true, + isActive: t.is_active === 1 || t.is_active === true, + displayOrder: t.display_order, + }; +} + +function transformPaymentTimingTemplate(t) { + if (!t) return null; + return { + id: t.id, + name: t.name, + description: t.description, + installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments, + isSystem: t.is_system === 1 || t.is_system === true, + isActive: t.is_active === 1 || t.is_active === true, + displayOrder: t.display_order, + }; +} + +function transformLineItemPreset(p) { + if (!p) return null; + return { + id: p.id, + name: p.name, + description: p.description, + unitPriceMinor: p.unit_price_minor, + currency: p.currency, + quantityDefault: Number(p.quantity_default), + displayOrder: p.display_order, + isActive: p.is_active === 1 || p.is_active === true, + }; +} + +// ----- payload conversion helpers ------------------------------------ + +function mapPayloadToService(body) { + const out = {}; + const map = { + customerAccountId: 'customerAccountId', + language: 'language', currency: 'currency', + issueDate: 'issueDate', validUntil: 'validUntil', + eventName: 'eventName', eventDate: 'eventDate', + eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd', + expectedDurationHours: 'expectedDurationHours', + paymentTermTemplateId: 'paymentTermTemplateId', + paymentNetDaysTemplateId: 'paymentNetDaysTemplateId', + paymentTimingTemplateId: 'paymentTimingTemplateId', + // Ad-hoc installments override (commit #6). Stored on quotes + // as payment_term_installments_override via migration 142. + installments: 'installments', + vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor', + introText: 'introText', outroText: 'outroText', + internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail', + businessBankAccountId: 'businessBankAccountId', + }; + for (const [api, svc] of Object.entries(map)) { + if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api]; + } + if (Array.isArray(body.lineItems)) { + out.lineItems = body.lineItems.map((li, idx) => ({ + position: li.position == null ? idx + 1 : li.position, + quantity: li.quantity, + description: li.description, + unit_price_minor: li.unitPriceMinor, + discount_percent: li.discountPercent, + // Migration 119 — sub-item + details support. parentPosition + // refers to another item's position in the same payload; the + // service resolves it to parent_line_item_id after inserting + // the parents. + parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition), + details_text: li.detailsText == null ? null : String(li.detailsText), + })); + } + return out; +} + +// --------------------------------------------------------------------- +// List + read +// --------------------------------------------------------------------- + +router.get( + '/', + requirePermission('quotes.view'), + [ + query('status').optional({ values: 'falsy' }).isString(), + query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + query('from').optional({ values: 'falsy' }).isISO8601(), + query('to').optional({ values: 'falsy' }).isISO8601(), + query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']), + query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), + query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const statusFilter = req.query.status + ? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean) + : []; + const { rows, total, page, pageSize } = await quoteService.listQuotes({ + filters: { + status: statusFilter, + customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null, + from: req.query.from, to: req.query.to, q: req.query.q, + }, + sort: req.query.sort || 'newest', + page: req.query.page ? parseInt(req.query.page, 10) : 1, + pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25, + }); + return successResponse(res, { + quotes: rows.map(transformQuote), + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 }, + }); + }) +); + +router.get( + '/:id', + requirePermission('quotes.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const data = await quoteService.getQuoteById(id); + if (!data) return res.status(404).json({ error: 'Quote not found' }); + return successResponse(res, { + quote: transformQuote(data.quote), + lineItems: data.lineItems.map(transformLineItem), + }); + }) +); + +// --------------------------------------------------------------------- +// Create + update +// --------------------------------------------------------------------- + +const QUOTE_BODY_VALIDATORS = [ + body('customerAccountId').isInt({ min: 1 }).withMessage('Customer is required'), + body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), + body('issueDate').optional({ values: 'falsy' }).isISO8601(), + body('validUntil').optional({ values: 'falsy' }).isISO8601(), + body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('eventDate').optional({ values: 'falsy' }).isISO8601(), + body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }), + body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + // Ad-hoc installments override (commit #6 of the deal_uuid PR). + // Each row carries { label, percent, trigger, offset_days }; the + // service validates internal consistency (percents sum to 100). + body('installments').optional().isArray(), + body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }), + body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']), + body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(), + body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('lineItems').optional({ values: 'falsy' }).isArray(), + body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }), + body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }), + body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + // Migration 119: sub-item + details support. Cross-row constraints + // (parent must exist, max 1 level deep) are enforced by the service + // (validateLineItemHierarchy); these per-field validators just keep + // bad data from reaching it. + body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }), +]; + +router.post( + '/', + requirePermission('quotes.manage'), + QUOTE_BODY_VALIDATORS, + handleAsync(async (req, res) => { + validateRequest(req); + const id = await quoteService.createQuote(mapPayloadToService(req.body), req.admin.id); + const data = await quoteService.getQuoteById(id); + return successResponse(res, { + quote: transformQuote(data.quote), + lineItems: data.lineItems.map(transformLineItem), + }, 201, 'Quote created'); + }) +); + +router.put( + '/:id', + requirePermission('quotes.manage'), + // PUT accepts partial updates. We declare the same fields as POST but + // every chain begins with `.optional({ values: 'falsy' })` so missing fields don't fail + // validation. Doing `.map(v => v.optional)` (without invoking it) was + // a bug that registered method references as middleware. + [ + param('id').isInt({ min: 1 }), + body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), + body('issueDate').optional({ values: 'falsy' }).isISO8601(), + body('validUntil').optional({ values: 'falsy' }).isISO8601(), + body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('eventDate').optional({ values: 'falsy' }).isISO8601(), + body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), + body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }), + body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), + body('lineItems').optional({ values: 'falsy' }).isArray(), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + await quoteService.updateQuote(id, mapPayloadToService(req.body), req.admin.id); + const data = await quoteService.getQuoteById(id); + return successResponse(res, { + quote: transformQuote(data.quote), + lineItems: data.lineItems.map(transformLineItem), + }, 200, 'Quote updated'); + }) +); + +// --------------------------------------------------------------------- +// Send / duplicate / convert +// --------------------------------------------------------------------- + +router.post( + '/:id/send', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const result = await quoteService.sendQuote(id, req.admin.id); + return successResponse(res, { sent: true, token: result.token }, 200, 'Quote sent'); + }) +); + +router.post( + '/:id/duplicate', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const newId = await quoteService.duplicateQuote(parseInt(req.params.id, 10), req.admin.id); + return successResponse(res, { id: newId }, 201, 'Quote duplicated'); + }) +); + +// Admin "accept on behalf of customer" — flips the quote straight +// to `accepted` without going through the public token + response +// window. For phone-call workflows where the customer verbally +// agrees and the admin wants to immediately convert. +router.post( + '/:id/accept', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const result = await quoteService.adminAcceptQuote(id, req.admin.id); + return successResponse(res, result, 200, 'Quote accepted'); + }) +); + +router.post( + '/:id/convert', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const result = await quoteService.convertToEvent(id, req.admin.id); + return successResponse(res, result, 200, result.alreadyConverted ? 'Already converted' : 'Quote converted'); + }) +); + +// Convert directly to invoice(s) — no event, no gallery. Used for +// engagements like consulting / equipment hire where there's no photo +// deliverable to ship. +router.post( + '/:id/convert-to-invoice', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const result = await quoteService.convertToInvoiceOnly(id, req.admin.id); + return successResponse(res, result, 200, 'Invoices created from quote'); + }) +); + +// Convert to a draft contract — the new middle step between accepted +// quote and event/invoice generation. The contracts feature flag is +// checked in contractService (it pulls the same db('feature_flags') +// row that the adminContracts router gates on); declining at the route +// layer here would force admins to flip TWO flags to use the workflow. +router.post( + '/:id/convert-to-contract', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + // Lazy require to keep the route file dep-light + avoid the + // quoteService ↔ contractService cycle bleeding through. + const contractService = require('../services/contractService'); + const id = parseInt(req.params.id, 10); + const result = await contractService.createFromQuote(id, req.admin.id); + return successResponse(res, result, 200, + result.alreadyConverted ? 'Already linked to a contract' : 'Contract drafted from quote'); + }) +); + +// --------------------------------------------------------------------- +// PDF — preview (unsaved payload) + download (persisted) +// --------------------------------------------------------------------- + +router.get( + '/:id/pdf', + requirePermission('quotes.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const buf = await quoteService.renderQuotePdfBuffer(id); + const { buildPdfFilename } = require('../utils/pdfFilename'); + const quote = await db('quotes').where({ id }).first(); + const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null; + const filename = buildPdfFilename({ + docNumber: quote?.quote_number, + customer, + fallback: `quote-${id}`, + }); + res.set('Content-Type', 'application/pdf'); + res.set('Content-Disposition', `inline; filename="${filename}"`); + res.send(buf); + }) +); + +router.post( + '/preview', + requirePermission('quotes.manage'), + QUOTE_BODY_VALIDATORS, + handleAsync(async (req, res) => { + validateRequest(req); + const payload = mapPayloadToService(req.body); + const buf = await quoteService.renderQuotePdfFromPayload(payload); + const { buildPdfFilename } = require('../utils/pdfFilename'); + const customer = payload.customerAccountId + ? await db('customer_accounts').where({ id: payload.customerAccountId }).first() + : null; + const filename = buildPdfFilename({ + docNumber: null, + customer, + fallback: 'quote-preview', + }); + res.set('Content-Type', 'application/pdf'); + res.set('Content-Disposition', `inline; filename="${filename}"`); + res.send(buf); + }) +); + +// --------------------------------------------------------------------- +// Presets — line items +// --------------------------------------------------------------------- + +router.get( + '/presets/line-items', + requirePermission('quotes.view'), + handleAsync(async (req, res) => { + const rows = await quoteService.listLineItemPresets(); + return successResponse(res, { presets: rows.map(transformLineItemPreset) }); + }) +); + +router.post( + '/presets/line-items', + requirePermission('quotes.manage'), + [ + body('name').isString().isLength({ min: 1, max: 128 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), + body('quantityDefault').optional({ values: 'falsy' }).isFloat({ min: 0 }), + body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await quoteService.createLineItemPreset({ + name: req.body.name, + description: req.body.description, + unit_price_minor: req.body.unitPriceMinor, + currency: req.body.currency, + quantity_default: req.body.quantityDefault, + display_order: req.body.displayOrder, + }); + return successResponse(res, { preset: transformLineItemPreset(row) }, 201); + }) +); + +router.put( + '/presets/line-items/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const row = await quoteService.updateLineItemPreset(id, { + name: req.body.name, + description: req.body.description, + unit_price_minor: req.body.unitPriceMinor, + currency: req.body.currency, + quantity_default: req.body.quantityDefault, + display_order: req.body.displayOrder, + is_active: req.body.isActive, + }); + return successResponse(res, { preset: transformLineItemPreset(row) }); + }) +); + +router.delete( + '/presets/line-items/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + await quoteService.deleteLineItemPreset(parseInt(req.params.id, 10)); + return successResponse(res, { deleted: true }); + }) +); + +// --------------------------------------------------------------------- +// Presets — payment terms +// --------------------------------------------------------------------- + +router.get( + '/presets/payment-terms', + requirePermission('quotes.view'), + handleAsync(async (req, res) => { + const rows = await quoteService.listPaymentTermTemplates(); + return successResponse(res, { templates: rows.map(transformPaymentTermTemplate) }); + }) +); + +router.post( + '/presets/payment-terms', + requirePermission('quotes.manage'), + [ + body('name').isString().isLength({ min: 1, max: 128 }), + body('installments').isArray({ min: 1 }), + body('netDays').optional({ values: 'falsy' }).isInt({ min: 1, max: 365 }), + body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }), + body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await quoteService.createPaymentTermTemplate({ + name: req.body.name, + description: req.body.description, + net_days: req.body.netDays, + skonto_percent: req.body.skontoPercent, + skonto_within_days: req.body.skontoWithinDays, + installments: req.body.installments, + display_order: req.body.displayOrder, + }); + return successResponse(res, { template: transformPaymentTermTemplate(row) }, 201); + }) +); + +router.put( + '/presets/payment-terms/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const row = await quoteService.updatePaymentTermTemplate(id, { + name: req.body.name, + description: req.body.description, + net_days: req.body.netDays, + skonto_percent: req.body.skontoPercent, + skonto_within_days: req.body.skontoWithinDays, + installments: req.body.installments, + display_order: req.body.displayOrder, + is_active: req.body.isActive, + }); + return successResponse(res, { template: transformPaymentTermTemplate(row) }); + }) +); + +router.delete( + '/presets/payment-terms/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + await quoteService.deletePaymentTermTemplate(parseInt(req.params.id, 10)); + return successResponse(res, { deleted: true }); + }) +); + +// --------------------------------------------------------------------- +// Presets — payment net-days (migration 124, half of the split) +// --------------------------------------------------------------------- + +router.get( + '/presets/payment-net-days', + requirePermission('quotes.view'), + handleAsync(async (req, res) => { + const rows = await quoteService.listPaymentNetDaysTemplates(); + return successResponse(res, { templates: rows.map(transformPaymentNetDaysTemplate) }); + }) +); + +router.post( + '/presets/payment-net-days', + requirePermission('quotes.manage'), + [ + body('name').isString().isLength({ min: 1, max: 128 }), + // net_days = 0 is "Sofort fällig" — valid. + body('netDays').isInt({ min: 0, max: 365 }), + body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }), + body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await quoteService.createPaymentNetDaysTemplate({ + name: req.body.name, + description: req.body.description, + net_days: req.body.netDays, + skonto_percent: req.body.skontoPercent, + skonto_within_days: req.body.skontoWithinDays, + display_order: req.body.displayOrder, + }); + return successResponse(res, { template: transformPaymentNetDaysTemplate(row) }, 201); + }) +); + +router.put( + '/presets/payment-net-days/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const row = await quoteService.updatePaymentNetDaysTemplate(id, { + name: req.body.name, + description: req.body.description, + net_days: req.body.netDays, + skonto_percent: req.body.skontoPercent, + skonto_within_days: req.body.skontoWithinDays, + display_order: req.body.displayOrder, + is_active: req.body.isActive, + }); + return successResponse(res, { template: transformPaymentNetDaysTemplate(row) }); + }) +); + +router.delete( + '/presets/payment-net-days/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + await quoteService.deletePaymentNetDaysTemplate(parseInt(req.params.id, 10)); + return successResponse(res, { deleted: true }); + }) +); + +// --------------------------------------------------------------------- +// Presets — payment timing (migration 124, other half of the split) +// --------------------------------------------------------------------- + +router.get( + '/presets/payment-timing', + requirePermission('quotes.view'), + handleAsync(async (req, res) => { + const rows = await quoteService.listPaymentTimingTemplates(); + return successResponse(res, { templates: rows.map(transformPaymentTimingTemplate) }); + }) +); + +router.post( + '/presets/payment-timing', + requirePermission('quotes.manage'), + [ + body('name').isString().isLength({ min: 1, max: 128 }), + body('installments').isArray({ min: 1 }), + body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), + body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await quoteService.createPaymentTimingTemplate({ + name: req.body.name, + description: req.body.description, + installments: req.body.installments, + display_order: req.body.displayOrder, + }); + return successResponse(res, { template: transformPaymentTimingTemplate(row) }, 201); + }) +); + +router.put( + '/presets/payment-timing/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const id = parseInt(req.params.id, 10); + const row = await quoteService.updatePaymentTimingTemplate(id, { + name: req.body.name, + description: req.body.description, + installments: req.body.installments, + display_order: req.body.displayOrder, + is_active: req.body.isActive, + }); + return successResponse(res, { template: transformPaymentTimingTemplate(row) }); + }) +); + +router.delete( + '/presets/payment-timing/:id', + requirePermission('quotes.manage'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + await quoteService.deletePaymentTimingTemplate(parseInt(req.params.id, 10)); + return successResponse(res, { deleted: true }); + }) +); + +module.exports = router; diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 8d20e1e7..1385a138 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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 => { diff --git a/backend/src/routes/adminTaxReport.js b/backend/src/routes/adminTaxReport.js new file mode 100644 index 00000000..af3a29fd --- /dev/null +++ b/backend/src/routes/adminTaxReport.js @@ -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; diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js index 6e97aa33..1db49791 100644 --- a/backend/src/routes/customer.js +++ b/backend/src/routes/customer.js @@ -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; diff --git a/backend/src/routes/publicContracts.js b/backend/src/routes/publicContracts.js new file mode 100644 index 00000000..f7f71d9d --- /dev/null +++ b/backend/src/routes/publicContracts.js @@ -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; diff --git a/backend/src/routes/publicPaymentCheck.js b/backend/src/routes/publicPaymentCheck.js new file mode 100644 index 00000000..63f49aaa --- /dev/null +++ b/backend/src/routes/publicPaymentCheck.js @@ -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; diff --git a/backend/src/routes/publicQuotes.js b/backend/src/routes/publicQuotes.js new file mode 100644 index 00000000..a84766ae --- /dev/null +++ b/backend/src/routes/publicQuotes.js @@ -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; diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index c5a65f96..dc1f7178 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -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', diff --git a/backend/src/services/_renderContext.js b/backend/src/services/_renderContext.js new file mode 100644 index 00000000..750b9475 --- /dev/null +++ b/backend/src/services/_renderContext.js @@ -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. " 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 + // /400.ttf + /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. " 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, +}; diff --git a/backend/src/services/businessProfileService.js b/backend/src/services/businessProfileService.js new file mode 100644 index 00000000..56e61e52 --- /dev/null +++ b/backend/src/services/businessProfileService.js @@ -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 /400.ttf as body + // and /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, +}; diff --git a/backend/src/services/contractBlocksService.js b/backend/src/services/contractBlocksService.js new file mode 100644 index 00000000..5f122de3 --- /dev/null +++ b/backend/src/services/contractBlocksService.js @@ -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, +}; diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js new file mode 100644 index 00000000..4eae5f6f --- /dev/null +++ b/backend/src/services/contractService.js @@ -0,0 +1,2297 @@ +/** + * contractService — orchestrates the lifecycle of `contracts`, their + * `contract_block_inclusions` (which blocks from the library make it + * onto a given contract), and the public `contract_action_tokens` used + * by the customer's signing link. + * + * Contracts are an INDEPENDENT document type alongside quotes and + * invoices. Composition model: + * - Admin picks blocks from the `contract_blocks` library and toggles + * them on/off per section (basics → scope → privacy → commercial → + * nda → closing). Order within a section is admin-controlled. + * - On send, every included block's body is FROZEN into + * `body_text_snapshot` on the inclusion row, so future edits to + * the source block don't mutate already-sent contracts. + * + * Signing: + * 1. Customer opens /contract/:token and either: + * a) Types name, optionally draws a signature on canvas, ticks + * "I have read and agree", submits → recordCustomerSignature + * stamps the signature into a re-rendered PDF and the system + * emails the admin. + * b) Uploads a wet-signed PDF → attachSignedPdfUpload sets the + * signed_pdf_path as the authoritative copy. + * 2. Admin counter-signs (in-browser or by re-uploading the + * double-signed PDF) → status flips to `fully_signed`. + * + * Bodies support {{placeholders}} resolved at PDF/preview render time + * using the same Handlebars-lite regex that emailProcessor.safeTemplateReplace + * uses. We rebuild it inline here (not exported from emailProcessor) to + * keep the dependency tree shallow and so contracts can render + * client-side previews in the future without pulling the email + * processor. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { db, withRetry, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { getAppSetting } = require('../utils/appSettings'); +const { AppError } = require('../utils/errors'); +const { claimNextSequence } = require('../utils/documentSequences'); +const { hasColumnCached } = require('../utils/schemaCache'); +const { formatShortDate } = require('../utils/dateFormatter'); +const businessProfileService = require('./businessProfileService'); +const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); +const pdfService = require('./pdfService'); +const pdfStampService = require('./pdfStampService'); +const emailProcessor = require('./emailProcessor'); +const { ensureContractEmailTemplatesSeeded } = require('./contractEmailTemplates'); +const { ensureSystemBlocksSeeded } = require('./contractBlocksService'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); + +const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing']; + +/** + * Build a proper {id, type, name} actor object for logActivity. The + * db.js helper silently downgrades string actors (e.g. 'admin:1') to + * actor_type='system' with null name, so the audit timeline showed + * "system" for every admin-driven event. Fetching the admin's name + * once per service call is a small read cost on a non-hot path. + * + * Pass `customerPublic()` for events triggered by the public token + * (customer signing, customer wet-signed PDF upload). + */ +async function adminActor(adminId) { + if (!adminId) return { type: 'system' }; + try { + // admin_users only carries username + email (no first/last/name + // columns — confirmed from db.js:265). Prefer username for the + // audit timeline because it's the operator-chosen identifier + // shown elsewhere in the admin UI; fall back to email when an + // older install seeded a row without a username. + const row = await db('admin_users') + .where({ id: adminId }) + .select('id', 'username', 'email') + .first(); + if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; + const displayName = row.username || row.email || `Admin #${adminId}`; + return { id: adminId, type: 'admin', name: displayName }; + } catch (_) { + return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; + } +} + +function customerPublicActor() { + return { type: 'customer', name: 'Customer (public link)' }; +} + +/** + * Privacy gate for the customer/admin IP captured at signing time. + * The `crm_contracts_store_ip` setting (default true) controls + * whether the IP is persisted into the DB. When off, this helper + * returns null regardless of what the route passed in — same shape + * the rest of the code expects, just with no IP data. + * + * Default-true means upgrades preserve current behaviour. Operators + * with strict data-minimisation requirements opt out in Settings → + * CRM-Settings → Contracts. + */ +async function maybeStoreIp(ip) { + if (!ip) return null; + const enabled = await getAppSetting('crm_contracts_store_ip'); + // Default true: only block when EXPLICITLY opted out. The audit + // flagged that `enabled === false` missed legacy installs where + // app_settings stored the toggle as a string ('false', '0') — those + // would slip through and the IP would still get persisted despite + // the operator's intent. Cover string/number/bool variants + // defensively. Anything else (null, undefined, true) preserves + // the default-on behavior. + if (enabled === false) return null; + if (enabled === 0 || enabled === '0') return null; + if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null; + return ip; +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +// D.2 — `ensureInt` consolidated into utils/numericHelpers. +const { ensureInt } = require('../utils/numericHelpers'); + +function formatNumberInTemplate(format, year, seq) { + return format + .replace(/\{YEAR\}/g, String(year)) + .replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0')) + .replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0')) + .replace(/\{SEQ\}/g, String(seq)); +} + +/** + * Gap-free per-year contract number sequence. See + * utils/documentSequences.js for the locking story; migration 132 + * created the underlying table. Atomic against concurrent admin + * creates — the previous SELECT-MAX-then-INSERT raced and could + * emit `C-2026-AB12C3` after 5 retries. + */ +async function nextContractNumber(trx) { + const format = (await getAppSetting('crm_contracts_number_format')) || 'C-{YEAR}-{SEQ:04d}'; + const year = new Date().getFullYear(); + const seq = await claimNextSequence('contract', year, trx); + return formatNumberInTemplate(format, year, seq); +} + +/** + * Handlebars-lite renderer: + * - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var]. + * - `{{var}}` substituted with the matching variable. Missing + * placeholders are left literally as `{{var}}` so the admin + * notices the unresolved field in preview. + * + * Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but + * without HTML escaping — contract bodies are rendered into PDF via + * pdfService.drawText, which doesn't need HTML safety. + */ +function renderTemplatedBody(template, variables) { + if (typeof template !== 'string' || template.length === 0) return template; + const conditionalsResolved = template.replace( + /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, key, inner) => { + const v = variables ? variables[key] : undefined; + const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0; + return truthy ? inner : ''; + } + ); + return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => { + if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match; + return String(variables[key]); + }); +} + +/** + * Build the variable bag used by renderTemplatedBody. Reads the + * customer record, business profile, and (when available) the + * customer's active payment-term defaults so block placeholders for + * net_days / skonto_percent / etc. resolve. Returns plain strings — + * dates formatted DD.MM.YYYY in DE-CH style, numbers as-is. + */ +async function buildPlaceholderContext(contract, customer) { + const profile = (await businessProfileService.getProfile()).profile || {}; + const issuerCompany = profile.company_name || ''; + const issuerAddress = [profile.address_line1, profile.postal_code, profile.city] + .filter(Boolean) + .join(', '); + + // Resolve net_days + skonto from app_settings defaults so the + // payment_terms_reference block has sensible numbers to substitute + // when the admin hasn't tied the contract to a specific quote. + const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; + const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default'); + const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5; + + // {{source_quote_number}} placeholder — substituted into the body of + // the `quote_line_items_table` system block (and any admin-authored + // block that wants to reference the quote). Empty string when the + // contract wasn't generated from a quote. + let sourceQuoteNumber = ''; + if (contract.source_quote_id) { + const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) + .select('quote_number').first(); + if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || ''; + } + + const customerName = customer + ? (customer.company_name + || [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.display_name + || customer.email + || '') + : ''; + const customerAddress = customer + ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] + .filter(Boolean) + .join(', ') + : ''; + + return { + customer_name: customerName, + customer_address: customerAddress, + event_name: contract.event_name || '', + event_date: formatShortDate(contract.event_date), + issue_date: formatShortDate(contract.issue_date), + contract_number: contract.contract_number || '', + title: contract.title || '', + net_days: String(netDaysDefault), + skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault), + skonto_within_days: String(skontoWithinDaysDefault), + cancellation_30d_percent: '25', + currency: (profile.default_currency || 'CHF').toUpperCase(), + issuer_company_name: issuerCompany, + issuer_address: issuerAddress, + source_quote_number: sourceQuoteNumber, + }; +} + +/** + * SHA-256 hex digest of a Buffer or file path. Used at every PDF + * write so we can persist a content hash alongside the path — + * either party can later re-hash the PDF they hold and prove (or + * disprove) it matches what we issued. + */ +function sha256OfBuffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} +function sha256OfFile(filePath) { + try { + return sha256OfBuffer(fs.readFileSync(filePath)); + } catch (_) { + return null; + } +} + +/** + * Write a contract PDF to disk and return both the path AND the + * SHA-256 hash of the buffer we just wrote. Callers persist BOTH on + * the contracts row so audit defence is single-query: SELECT + * pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the + * file on disk and compare. + * + * History-preserving (per requirement #6): every write appends a + * deterministic suffix so old versions stay on disk. The contract + * row's `pdf_path` / `signed_pdf_path` always points at the most + * recent one; earlier versions remain available for forensic + * comparison. + */ +async function persistContractPdf(contract, buffer, suffix = '') { + if (!contract.contract_number) return { filePath: null, sha256: null }; + const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); + fs.mkdirSync(root, { recursive: true }); + // Always append a millisecond timestamp to the filename so writes + // never overwrite an earlier version on disk. Forensic preservation. + // Example filenames: + // C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned) + // C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf + // C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = suffix + ? `${contract.contract_number}_${suffix}_${stamp}.pdf` + : `${contract.contract_number}_${stamp}.pdf`; + const filePath = path.join(root, fileName); + fs.writeFileSync(filePath, buffer); + return { filePath, sha256: sha256OfBuffer(buffer) }; +} + +// Maximum decoded signature image size. Defends against a customer +// (or attacker holding a captured signing token) POSTing a multi-MB +// signature data URL to fill the disk. A typical signature_pad PNG +// is 10–80 KB; even with retina upscaling we don't expect to see +// 1 MB. The cap is enforced on the BASE64 length before decoding so +// we never allocate the full Buffer for an oversized payload. +// +// The frontend (ContractResponsePage) downscales the canvas to a +// fixed max width before exporting via `toDataURL`, so well-behaved +// clients land well under this cap. This server-side check is the +// authoritative guard. +const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded + +async function persistSignatureImage(contract, role, dataUrl) { + if (!dataUrl || typeof dataUrl !== 'string') return null; + if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) { + throw new AppError( + `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, + 413, 'SIGNATURE_TOO_LARGE', + ); + } + const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); + if (!match) { + throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT'); + } + if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) { + throw new AppError( + `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, + 413, 'SIGNATURE_TOO_LARGE', + ); + } + const ext = match[1] === 'jpeg' ? 'jpg' : 'png'; + const root = path.join( + process.cwd(), + 'storage', + 'business-docs', + 'contract', + 'signatures', + String(contract.id), + ); + fs.mkdirSync(root, { recursive: true }); + // Filename already carries Date.now() so re-stamping a signature + // never overwrites an earlier capture — forensic preservation. + // Per role, the contract row's signed_*_signature_path always + // points at the most recent; older files stay alongside. + const filePath = path.join(root, `${role}-${Date.now()}.${ext}`); + fs.writeFileSync(filePath, Buffer.from(match[2], 'base64')); + return filePath; +} + +/** + * Build the stamp sequence the pdf-lib stamp service expects from a + * single contract row. Customer first, admin second — provenance + * order matches the visual order on the signature page. + * + * Used by the recovery paths (rerenderAndResend, restampSignatures). + * The hot path (recordCustomerSignature / recordAdminCountersignature) + * stamps incrementally so it constructs the stamp inline. + */ +function buildSignatureStamps(contract) { + const locale = contract.language || 'de'; + const nameLabel = 'Name'; + const dateLabel = locale === 'de' ? 'Datum' : 'Date'; + const stamps = []; + if (contract.signed_customer_signature_path) { + stamps.push({ + signaturePngPath: contract.signed_customer_signature_path, + role: 'customer', + caption: { + name: contract.signed_customer_name || '', + signedAt: contract.signed_by_customer_at, + nameLabel, + dateLabel, + }, + }); + } + if (contract.signed_admin_signature_path) { + stamps.push({ + signaturePngPath: contract.signed_admin_signature_path, + role: 'admin', + caption: { + name: contract.signed_admin_name || '', + signedAt: contract.signed_by_admin_at, + nameLabel, + dateLabel, + }, + }); + } + return stamps; +} + +/** + * Build the audit-certificate context expected by + * pdfStampService.renderAuditCertificate from a fully-signed + * contract row. Returns null when the contract isn't signed enough + * to warrant a certificate (no customer + no admin signature data). + */ +function buildAuditCertContext(contract) { + const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name; + const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name; + if (!hasCustomerSig && !hasAdminSig) return null; + return { + contract: { + contract_number: contract.contract_number, + sent_at: contract.sent_at, + pdf_sha256: contract.pdf_sha256 || null, + signed_pdf_sha256: contract.signed_pdf_sha256 || null, + }, + customer: hasCustomerSig ? { + name: contract.signed_customer_name, + signedAt: contract.signed_by_customer_at, + ip: contract.signed_customer_ip, + } : null, + admin: hasAdminSig ? { + name: contract.signed_admin_name, + signedAt: contract.signed_by_admin_at, + ip: contract.signed_admin_ip, + } : null, + locale: contract.language || 'de', + }; +} + +/** + * Generate the audit certificate PDF, write it to disk under the same + * year directory as the contract PDFs (suffix `audit`), and return + * its file path. Returns null when there's nothing to certify or when + * rendering fails (the email still goes out without the cert — the + * stamped PDF alone remains delivered). + */ +async function persistAuditCertificate(contract) { + const ctx = buildAuditCertContext(contract); + if (!ctx) return null; + try { + const { buffer } = await pdfStampService.renderAuditCertificate(ctx); + const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); + fs.mkdirSync(root, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`); + fs.writeFileSync(filePath, buffer); + return filePath; + } catch (err) { + logger.error('Failed to render audit certificate', { + contractId: contract.id, + contractNumber: contract.contract_number, + message: err.message, + }); + return null; + } +} + +function ensureCustomerActive(customer) { + if (!customer) throw new AppError('Customer not found', 404); + if (customer.is_active === false || customer.is_active === 0) { + throw new AppError('Customer is deactivated', 409); + } +} + +// --------------------------------------------------------------------- +// Render-context builder + PDF helpers +// --------------------------------------------------------------------- + +/** + * Build the data shape pdfService.renderContractToBuffer expects. + * Sections are emitted in canonical SECTIONS_ORDER; blocks within a + * section are emitted in `position` order. Bodies are run through + * renderTemplatedBody so {{placeholders}} are substituted. + * + * When the contract has been sent, `body_text_snapshot` is used (so + * later edits to the source block don't mutate the rendered document). + * Before send (preview from editor) the live `contract_blocks.body_text` + * is used so the admin can iterate on block bodies and see the result. + */ +async function buildRenderContext(contract, inclusions) { + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const placeholders = await buildPlaceholderContext(contract, customer); + + // Pull source-quote line items when this contract was generated from a + // quote. Surfaced on the render context so the renderer can draw a real + // table at the location of the `quote_line_items_table` system block. + // Sub-items keep their parent's position via the LEFT JOIN so the + // renderer can indent them with a `↳` prefix. + let quoteLineItems = []; + let quoteCurrency = null; + let quoteNumber = null; + if (contract.source_quote_id) { + const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) + .select('quote_number', 'currency').first(); + if (srcQuote) { + quoteCurrency = srcQuote.currency; + quoteNumber = srcQuote.quote_number; + quoteLineItems = await db('quote_line_items as li') + .leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.quote_id', contract.source_quote_id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + } + } + + const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de'; + + // Group inclusions by section + render each block body. + const blocksBySection = {}; + for (const section of SECTIONS_ORDER) blocksBySection[section] = []; + const sortedInclusions = [...inclusions] + .filter((row) => row.included === true || row.included === 1 || row.included === '1') + .sort((a, b) => { + const sa = SECTIONS_ORDER.indexOf(a.section); + const sb = SECTIONS_ORDER.indexOf(b.section); + if (sa !== sb) return sa - sb; + return (a.position || 0) - (b.position || 0); + }); + + for (const row of sortedInclusions) { + if (!blocksBySection[row.section]) continue; + // The inclusion row carries the JOINED block columns aliased with + // a `block_` prefix (see getContractById). Pre-send drafts have + // null snapshots, so fall through to the live block body. + // Migration 131 added ru/pt/nl/fr columns. The body resolver + // picks the locale-matching column first, falls back through + // DE → EN, so an admin can stage translations one locale at a + // time without breaking contracts in other languages. + const bodyEn = row.body_text_snapshot || row.block_body_text || ''; + const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || ''; + const bodyRu = row.block_body_text_ru || ''; + const bodyPt = row.block_body_text_pt || ''; + const bodyNl = row.block_body_text_nl || ''; + const bodyFr = row.block_body_text_fr || ''; + const localeBody = ({ + de: bodyDe, + ru: bodyRu, + pt: bodyPt, + nl: bodyNl, + fr: bodyFr, + })[locale] || ''; + const sourceBody = localeBody || bodyEn || bodyDe; + // Substitute placeholders, then strip any leading `**Title**\n` + // line — the block's `name` field is already rendered as a bold + // sub-heading by the PDF/public layouts, so a bold first line in + // the body produces a duplicated title. Inline `**bold**` markers + // elsewhere in the body are preserved (the PDF renders them as + // actual bold via renderBodyMarkdown; the public route strips + // them since the React page has no inline-bold UI). + const rendered = renderTemplatedBody(sourceBody, placeholders) + .replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, ''); + blocksBySection[row.section].push({ + slug: row.block_slug || null, + name: row.block_name, + section: row.section, + body: rendered, + }); + } + + // Use the same robust logo resolver quote/invoice use — checks + // business_profile.logo_path → app_settings.branding_logo_path → + // app_settings.branding_logo_url, with ~7 disk-location candidates + // before giving up. + const { resolveLogoFile } = require('../utils/resolveLogoFile'); + const resolvedLogoPath = await resolveLogoFile(profile); + + // Global date format from Settings → General (general_date_format). + 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 default */ } + + return { + locale, + dateFormat, + // Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock + // honours the same business-profile toggles (pdf_show_logo, + // pdf_show_company_name, pdf_logo_height, pdf_company_name_inline, + // pdf_folding_marks) across all three document types. Per maintainer: + // contracts reuse the same toggles — no contract-specific knobs. + // Shared issuer + recipient builders. Contracts use the base toggle + // set (no quote-only payment-block fields). The renderer-aware + // recipient gating means contractService's previously-drifted + // local attentionLine logic now matches quote + invoice exactly. + issuer: buildIssuerBlock(profile, resolvedLogoPath), + recipient: buildRecipientBlock(profile, customer), + doc: { + contractNumber: contract.contract_number, + title: contract.title || '', + issueDate: contract.issue_date, + validUntil: contract.valid_until, + introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null, + outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null, + }, + // Blocks grouped + ordered by canonical section order. + sections: SECTIONS_ORDER + .map((section) => ({ section, blocks: blocksBySection[section] })) + .filter((s) => s.blocks.length > 0), + // Source-quote line items, surfaced at the top level so the PDF + // renderer can draw a formatted table where the + // `quote_line_items_table` system block is included. Empty array + // when the contract has no source quote. + quoteLineItems, + quoteCurrency, + quoteSourceNumber: quoteNumber, + // Signature evidence (used by the PDF renderer to stamp signatures + // into the closing section when present). + signatures: { + customer: contract.signed_customer_name ? { + name: contract.signed_customer_name, + signedAt: contract.signed_by_customer_at, + ip: contract.signed_customer_ip, + signaturePath: contract.signed_customer_signature_path, + } : null, + admin: contract.signed_admin_name ? { + name: contract.signed_admin_name, + signedAt: contract.signed_by_admin_at, + ip: contract.signed_admin_ip, + signaturePath: contract.signed_admin_signature_path, + } : null, + }, + // Audit-trail evidence appended to the rendered PDF as a final + // page (issue #3). The renderer skips the page when this is null + // OR when the contract isn't signed yet, so unsigned PDFs stay + // unchanged. Hashes are best-effort: pdfSha256 may be null on + // installs that haven't migrated to the new schema column yet — + // the page still renders the rest of the evidence. + audit: (contract.signed_customer_name || contract.signed_admin_name) ? { + contractNumber: contract.contract_number, + issuedAt: contract.sent_at, + pdfSha256: contract.pdf_sha256 || null, + signedPdfSha256: contract.signed_pdf_sha256 || null, + } : null, + }; +} + +// --------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------- + +async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { + return await withRetry(async () => { + let query = db('contracts') + .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') + .select( + 'contracts.*', + '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', + ); + + if (Array.isArray(filters.status) && filters.status.length > 0) { + query = query.whereIn('contracts.status', filters.status); + } + if (filters.customerAccountId) { + query = query.where('contracts.customer_account_id', filters.customerAccountId); + } + if (filters.q && String(filters.q).trim()) { + const term = `%${String(filters.q).trim()}%`; + query = query.andWhere(function() { + this.where('contracts.contract_number', 'like', term) + .orWhere('contracts.title', 'like', term) + .orWhere('customer_accounts.email', 'like', term) + .orWhere('customer_accounts.company_name', 'like', term); + }); + } + + const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first(); + const totalRow = await countQuery; + const total = ensureInt(totalRow?.total || 0); + + switch (sort) { + case 'oldest': + query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('contracts.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); + break; + } + + const offset = Math.max(0, (page - 1) * pageSize); + query = query.offset(offset).limit(pageSize); + const rows = await query; + return { rows, total, page, pageSize }; + }); +} + +async function getContractById(id) { + return await withRetry(async () => { + const contract = await db('contracts') + .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') + .where('contracts.id', id) + .select( + 'contracts.*', + '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', + 'customer_accounts.preferred_language as customer_preferred_language', + ) + .first(); + if (!contract) return null; + + const inclusions = await db('contract_block_inclusions as inc') + .leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id') + .where('inc.contract_id', id) + .orderByRaw(` + CASE inc.section + WHEN 'basics' THEN 1 + WHEN 'scope' THEN 2 + WHEN 'privacy' THEN 3 + WHEN 'commercial' THEN 4 + WHEN 'nda' THEN 5 + WHEN 'closing' THEN 6 + ELSE 99 + END + `) + .orderBy('inc.position', 'asc') + .select( + 'inc.*', + 'blk.slug as block_slug', + 'blk.name as block_name', + 'blk.description as block_description', + 'blk.body_text as block_body_text', + 'blk.body_text_de as block_body_text_de', + // Migration 131 — locale variants. Pulled with column-existence + // guard so installs that haven't run migration 131 still load + // contracts (just without the new columns). + ...(await hasColumnCached('contract_blocks', 'body_text_ru') + ? ['blk.body_text_ru as block_body_text_ru'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_pt') + ? ['blk.body_text_pt as block_body_text_pt'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_nl') + ? ['blk.body_text_nl as block_body_text_nl'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_fr') + ? ['blk.body_text_fr as block_body_text_fr'] : []), + 'blk.is_system as block_is_system', + ); + return { contract, inclusions }; + }); +} + +/** + * Create a draft contract. Pre-populates `contract_block_inclusions` + * with every active system block toggled ON so the admin sees a + * sensible starting point and just toggles off what they don't need. + * + * Custom (non-system) blocks are NOT auto-included — admin opts in to + * those explicitly so a runaway block library doesn't pollute every + * new contract. + */ +async function createContract(payload, adminId) { + // Self-heal: ensure runtime-seeded system blocks (e.g. the + // quote_line_items_table added after migration 131 was deployed) + // exist before we copy active system blocks into the new contract's + // inclusion list. Idempotent — only fires if rows are missing. + await ensureSystemBlocksSeeded(); + + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + ensureCustomerActive(customer); + + const profile = (await businessProfileService.getProfile()).profile; + const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; + const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; + const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); + const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) + .toISOString().slice(0, 10); + + // Schema-drift guard for the event-snapshot columns added as + // in-place migration 130 edits. We only write them when the DB + // actually has them; older dev installs that haven't re-migrated + // simply skip these fields (contract still saves successfully). + const hasEventCols = await hasColumnCached('contracts', 'event_name'); + + return await db.transaction(async (trx) => { + const contractNumber = await nextContractNumber(); + const row = { + contract_number: contractNumber, + customer_account_id: payload.customerAccountId, + status: 'draft', + language, + issue_date: issueDate, + valid_until: validUntil, + title: payload.title || null, + intro_text: payload.introText || null, + outro_text: payload.outroText || null, + // Migration 140 — standalone contract is a deal root; mint a + // fresh UUID. The createFromQuote path (line ~1557) sets this + // from the source quote's deal_uuid instead. + deal_uuid: crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasEventCols) { + row.event_name = payload.eventName || null; + row.event_date = payload.eventDate || null; + row.event_time_start = payload.eventTimeStart || null; + row.event_time_end = payload.eventTimeEnd || null; + } + const inserted = await trx('contracts').insert(row).returning('id'); + const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Seed with every active system block, toggled on. Per-section + // position = display_order from the source block. + // + // D.3 — batched insert. Previously this loop fired one INSERT per + // block (12+ round-trips inside the transaction on a fresh contract). + // Batched into a single `.insert(rows)` since the row count is + // bounded (system block count) and the inserts are independent. + const systemBlocks = await trx('contract_blocks') + .where({ is_system: true, is_active: true }) + .orderBy(['section', 'display_order']); + const sectionCounters = {}; + const inclusionRows = systemBlocks.map((block) => { + sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; + return { + contract_id: contractId, + block_id: block.id, + section: block.section, + position: sectionCounters[block.section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: true, + created_at: new Date(), + updated_at: new Date(), + }; + }); + if (inclusionRows.length > 0) { + await trx('contract_block_inclusions').insert(inclusionRows); + } + + try { + await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + logger.info('Contract created', { adminId, contractId, contractNumber }); + return contractId; + }); +} + +/** + * Update a draft contract. Editing a sent contract is refused — admin + * must cancel + create a fresh one (avoids invalidating the customer's + * signed copy). + * + * payload.blocks is an array of `{ blockId, included, position }` + * tuples; the service rewrites the contract_block_inclusions rows + * accordingly. + */ +async function updateContract(id, payload, adminId) { + const existing = await db('contracts').where({ id }).first(); + if (!existing) throw new AppError('Contract not found', 404); + if (existing.status !== 'draft') { + throw new AppError( + `Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`, + 409, + 'CONTRACT_LOCKED', + ); + } + + const hasEventCols = await hasColumnCached('contracts', 'event_name'); + + return await db.transaction(async (trx) => { + const updates = { updated_at: new Date() }; + const map = { + title: 'title', + introText: 'intro_text', + outroText: 'outro_text', + language: 'language', + validUntil: 'valid_until', + issueDate: 'issue_date', + }; + // Event-snapshot fields only flow through when the DB has them + // (in-place migration 130 edit). Guarded so dev installs that + // haven't re-migrated don't crash the update. + if (hasEventCols) { + Object.assign(map, { + eventName: 'event_name', + eventDate: 'event_date', + eventTimeStart: 'event_time_start', + eventTimeEnd: 'event_time_end', + }); + } + for (const [api, col] of Object.entries(map)) { + if (api in payload) updates[col] = payload[api] || null; + } + await trx('contracts').where({ id }).update(updates); + + // Replace inclusions only when the caller sent an explicit list. + // (Editor's "save" sends every row; an inline "toggle" save could + // send a partial update — current frontend always sends full list.) + if (Array.isArray(payload.blocks)) { + await trx('contract_block_inclusions').where({ contract_id: id }).del(); + // Recompute per-section position so we don't trust caller order + // for ordering integrity; caller controls only the section + // sequence via the order of items in payload.blocks. + // + // Previously this loop did one SELECT per block to look up its + // section. On a contract with 12 included blocks that's 12 + // round-trips inside the transaction — pure N+1. Batch the + // lookup into a single WHERE…IN, build a Map, and read it in + // the loop. The insert itself stays sequential because the + // editor's payload size is bounded (<30 blocks in practice) and + // a single batch insert would lose row-by-row insert ordering + // guarantees we don't actually need. + const blockIds = [ + ...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))), + ]; + const blocksFound = blockIds.length > 0 + ? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section') + : []; + const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section])); + const sectionCounters = {}; + for (const entry of payload.blocks) { + const section = sectionByBlockId.get(entry.blockId); + if (!section) continue; + sectionCounters[section] = (sectionCounters[section] || 0) + 1; + await trx('contract_block_inclusions').insert({ + contract_id: id, + block_id: entry.blockId, + section, + position: ensureInt(entry.position) || sectionCounters[section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: entry.included === false ? false : true, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + + try { + await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return id; + }); +} + +/** + * Render PDF for a saved contract (preview before send, or re-render + * after signing). + */ +async function renderContractPdfBuffer(contractId) { + const data = await getContractById(contractId); + if (!data) throw new AppError('Contract not found', 404); + const ctx = await buildRenderContext(data.contract, data.inclusions); + return await pdfService.renderContractToBuffer(ctx); +} + +/** + * Send the contract: snapshot every included block's body, render PDF, + * persist, mint a signing token, queue the customer email. + */ +async function sendContract(id, adminId) { + // Self-heal: dev installs that ran migration 130 BEFORE we added + // contract_fully_signed to the seed list won't have all three + // contract templates in email_templates. Insert any missing rows + // before we queue the email. Idempotent + module-cached. + await ensureContractEmailTemplatesSeeded(db, logger); + + const data = await getContractById(id); + if (!data) throw new AppError('Contract not found', 404); + const { contract, inclusions } = data; + + if (!['draft'].includes(contract.status)) { + throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409); + } + + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + + // Snapshot every included block's body into the inclusion row so + // future block edits don't mutate the sent contract. + await db.transaction(async (trx) => { + for (const inc of inclusions) { + if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue; + await trx('contract_block_inclusions').where({ id: inc.id }).update({ + body_text_snapshot: inc.block_body_text || null, + body_text_de_snapshot: inc.block_body_text_de || null, + updated_at: new Date(), + }); + } + }); + + // Re-fetch with snapshots populated so the renderer uses the frozen + // bodies (matches post-send reads). + const refreshed = await getContractById(id); + const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions); + const buffer = await pdfService.renderContractToBuffer(ctx); + const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer); + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = contract.valid_until + ? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000) + : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + + // Schema-drift guard for the new pdf_sha256 column (migration 130 + // in-place edit). Dev installs that haven't re-migrated skip the + // hash write; the send still succeeds. + const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256'); + + await db.transaction(async (trx) => { + await trx('contract_action_tokens').insert({ + contract_id: id, + token, + expires_at: expiresAt, + created_at: new Date(), + }); + const updates = { + status: 'sent', + sent_at: new Date(), + pdf_path: pdfPath, + updated_at: new Date(), + }; + if (hasPdfSha) updates.pdf_sha256 = pdfSha256; + await trx('contracts').where({ id }).update(updates); + }); + + const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; + const responseUrl = `${frontendUrl}/contract/${token}`; + // Honour the admin's "Attach contract PDF to email" toggle. Default + // ON; an admin who prefers a link-only email turns it off and the + // customer reaches the PDF via the public sign page instead. + const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled'); + await emailProcessor.queueEmail(null, customer.email, 'contract_sent', { + contract_number: contract.contract_number, + customer_name: customer.display_name + || [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.email.split('@')[0], + response_url: responseUrl, + title: contract.title || '', + event_name: contract.event_name || '', + valid_until: formatShortDate(contract.valid_until), + attachments: (attachPdf !== false && pdfPath) ? [{ + filename: `${contract.contract_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }] : undefined, + }); + + try { + await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + logger.info('Contract sent', { adminId, contractId: id }); + return { token, pdfPath }; +} + +/** + * Record a customer's in-browser signature (canvas + typed name + + * "I accept" checkbox). Validates the token, persists the signature + * PNG, re-renders the PDF with the signature stamped, flips status + * to `signed_by_customer`, and queues the admin notification email. + */ +async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) { + // Self-heal contract email templates. The contract_signed_admin_notification + // email fires from this function — if its row is missing, the admin + // never learns the customer signed. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (accepted !== true) { + throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED'); + } + if (!name || !String(name).trim()) { + throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); + } + // Server-side guard for the "require drawn signature" admin toggle. + // The public sign page also enforces this client-side, but the + // server is the source of truth — a malicious caller posting + // directly to /sign with a blank signatureDataUrl would otherwise + // bypass the requirement. + const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature'); + if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) { + throw new AppError( + 'A drawn signature is required for this contract — typing your name alone is not sufficient.', + 400, 'SIGNATURE_REQUIRED', + ); + } + const tokenRow = await db('contract_action_tokens').where({ token }).first(); + if (!tokenRow) throw new AppError('Token not found', 404); + if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) { + throw new AppError('This signing link has expired', 410); + } + if (tokenRow.used_at) { + throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED'); + } + + const contract = await db('contracts').where({ id: tokenRow.contract_id }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['sent'].includes(contract.status)) { + throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409); + } + + const signaturePath = signatureDataUrl + ? await persistSignatureImage(contract, 'customer', signatureDataUrl) + : null; + + const now = new Date(); + // Resolve the IP gate ONCE before the transaction so both writes + // (contracts row + tokens row) agree. Setting flip mid-transaction + // can't happen anyway, but doing it upfront keeps the data + // consistent and saves a redundant read. + const persistedIp = await maybeStoreIp(ip); + try { + await db.transaction(async (trx) => { + await trx('contracts').where({ id: contract.id }).update({ + status: 'signed_by_customer', + signed_by_customer_at: now, + signed_customer_name: String(name).trim(), + signed_customer_ip: persistedIp, + signed_customer_signature_path: signaturePath, + updated_at: now, + }); + await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ + used_at: now, + used_action: 'signed_by_customer', + used_ip: persistedIp, + }); + }); + } catch (txErr) { + // C.7 — clean up the orphan signature PNG we wrote before the + // transaction. The DB rollback already undid the contract + + // token writes; the file would otherwise sit forever in + // storage/business-docs/contract/.../signatures/. Best-effort + // unlink — if the cleanup itself fails, log and re-throw the + // original transaction error so the caller still sees the real + // failure cause. + if (signaturePath) { + try { + if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); + } catch (cleanupErr) { + logger.warn('Orphan signature PNG cleanup failed', { + path: signaturePath, message: cleanupErr.message, + }); + } + } + throw txErr; + } + + // Stamp the customer's signature onto the UNSIGNED PDF on disk. + // Byte-immutable approach (see pdfStampService): we read pdf_path + // (the immutable as-sent PDF), stamp the customer's signature PNG + // at the fixed coordinates on the signature page, save as a new + // timestamped file, and update signed_pdf_path. Original file + // stays untouched on disk. + const refreshed = await getContractById(contract.id); + try { + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`); + } + const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stampedBuffer = await pdfStampService.stampSignature({ + pdfBuffer: originalPdfBuffer, + signaturePngPath: signaturePath, + role: 'customer', + caption: { + name: String(name).trim(), + signedAt: now, + nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', + dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', + }, + }); + const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf( + refreshed.contract, stampedBuffer, 'signed-by-customer', + ); + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — clear any pre-existing render-failed marker; the + // most recent stamp attempt just succeeded. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } catch (err) { + // Signature recorded; PDF re-render is best-effort. The admin can + // re-render manually from the detail page if this fails. Logged as + // error (not warn) so persistent failures surface in monitoring. + logger.error('Failed to re-render contract PDF after customer signature', { + contractId: contract.id, + message: err.message, + stack: err.stack, + }); + // Migration 136 — surface the failure on the contract row so the + // admin detail page can render a recovery banner instead of the + // admin only discovering this through monitoring. err.message is + // truncated to 2 KB; the full stack stays in server logs. + try { + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + await db('contracts').where({ id: contract.id }).update({ + signed_pdf_render_failed_at: new Date(), + signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), + updated_at: new Date(), + }); + } + } catch (markErr) { + // Marker write itself failed — log + swallow so the customer + // sign response still succeeds. The orphan stays orphan but + // we've at least surfaced both errors. + logger.error('Failed to record signed_pdf_render_failed marker', { + contractId: contract.id, message: markErr.message, + }); + } + } + + // Notify admin. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; + try { + await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', { + contract_number: contract.contract_number, + customer_email: customer?.email || '', + signed_customer_name: String(name).trim(), + admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`, + }); + } catch (err) { + logger.warn('Failed to queue admin notification after customer signature', { + contractId: contract.id, error: err.message, + }); + } + + try { + await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor()); + } catch (_) { /* logging is best-effort */ } + + return { status: 'signed_by_customer', signedAt: now }; +} + +/** + * Admin counter-signature. Bumps status to `fully_signed` (or + * `signed_by_admin` if the customer hasn't signed yet — edge case + * where admin signs first, e.g. issuer-side framework agreement). + */ +async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) { + // Self-heal: ensure the contract_fully_signed template exists + // before we counter-sign. The dual-party send fires from this + // function on the fully_signed transition; without the template + // it silently fails and the customer never receives the PDF. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (!name || !String(name).trim()) { + throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); + } + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['signed_by_customer', 'sent'].includes(contract.status)) { + throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409); + } + + const signaturePath = signatureDataUrl + ? await persistSignatureImage(contract, 'admin', signatureDataUrl) + : null; + + const now = new Date(); + const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin'; + const persistedAdminIp = await maybeStoreIp(ip); + try { + await db('contracts').where({ id: contract.id }).update({ + status: newStatus, + signed_by_admin_at: now, + signed_admin_name: String(name).trim(), + signed_admin_ip: persistedAdminIp, + signed_admin_signature_path: signaturePath, + updated_at: now, + }); + } catch (updateErr) { + // C.7 — clean up the orphan signature PNG if the contract row + // update threw. Best-effort; log on cleanup failure and re-throw + // the original update error. + if (signaturePath) { + try { + if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); + } catch (cleanupErr) { + logger.warn('Orphan admin signature PNG cleanup failed', { + path: signaturePath, message: cleanupErr.message, + }); + } + } + throw updateErr; + } + + // Stamp the admin's signature ON TOP of whatever signed_pdf_path + // currently holds (the customer-stamped PDF, in the normal flow) + // — or directly onto the unsigned pdf_path if the admin is the + // first to sign (edge case). Byte-immutable: each prior PDF stays + // on disk; the new file is a fresh timestamped version. + const refreshed = await getContractById(contract.id); + let signedPath = null; + let signedSha256 = null; + try { + const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path)) + ? refreshed.contract.signed_pdf_path + : refreshed.contract.pdf_path; + if (!baseFile || !fs.existsSync(baseFile)) { + throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`); + } + const baseBuffer = fs.readFileSync(baseFile); + const stampedBuffer = await pdfStampService.stampSignature({ + pdfBuffer: baseBuffer, + signaturePngPath: signaturePath, + role: 'admin', + caption: { + name: String(name).trim(), + signedAt: now, + nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', + dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', + }, + }); + const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin'; + const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix); + signedPath = persisted.filePath; + signedSha256 = persisted.sha256; + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } catch (err) { + logger.error('Failed to stamp contract PDF after admin signature', { + contractId: contract.id, + newStatus, + message: err.message, + stack: err.stack, + }); + // Migration 136 — mirror the customer-sign branch: persist a + // recovery marker so the admin detail page can surface a banner. + try { + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + await db('contracts').where({ id: contract.id }).update({ + signed_pdf_render_failed_at: new Date(), + signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), + updated_at: new Date(), + }); + } + } catch (markErr) { + logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', { + contractId: contract.id, message: markErr.message, + }); + } + } + + // When the admin's signature is what FINALISED the contract (i.e. + // status flipped to fully_signed), email a copy of the freshly + // re-rendered PDF to both parties. We send two separate queueEmail + // calls so each recipient gets the email rendered with their own + // greeting + name. The admin BCC is delivered as "to the issuer" + // so it lands in the same inbox the contract_sent email originated + // from. + if (newStatus === 'fully_signed') { + try { + // Pick the best available PDF as the attachment, in priority + // order: this counter-sign's freshly-rendered signed copy → + // the customer-only signed copy we wrote earlier → the + // original unsigned PDF. Falling all the way through to no + // attachment is acceptable; the email still goes out with the + // contract number so the customer knows it's binding. + const refetched = await db('contracts').where({ id: contract.id }).first(); + const attachmentPath = signedPath + || refetched?.signed_pdf_path + || refetched?.pdf_path + || null; + + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + // Generate the audit certificate as a SIBLING document (separate + // PDF) and attach it alongside the stamped contract. Audit cert + // captures timestamps, IPs, names, and SHA-256 hashes — the legal + // provenance record. Reproducible from contract data so safe to + // regenerate on demand; we still persist a copy to disk for the + // forensic trail. + const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract); + + const attachments = []; + if (attachmentPath) { + attachments.push({ + filename: `${refreshed.contract.contract_number}-signed.pdf`, + contentPath: attachmentPath, + contentType: 'application/pdf', + }); + } + if (auditCertPath) { + attachments.push({ + filename: `${refreshed.contract.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + const attachmentsArg = attachments.length > 0 ? attachments : undefined; + + // 1. Customer copy + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refreshed.contract.contract_number, + customer_name: customerName, + title: refreshed.contract.title || '', + attachments: attachmentsArg, + }); + } + // 2. Admin copy. Prefer business_profile.email (the inbox the + // contract was sent FROM); fall back to the counter-signing + // admin's account email so the audit trail still reaches a + // human even on installs where business_profile.email is blank. + const adminEmail = profile.email || adminRow?.email; + if (adminEmail && adminEmail !== customer?.email) { + await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { + contract_number: refreshed.contract.contract_number, + customer_name: profile.company_name || adminRow?.first_name || 'Team', + title: refreshed.contract.title || '', + attachments: attachmentsArg, + }); + } + } catch (err) { + logger.error('Failed to send contract_fully_signed emails', { + contractId: contract.id, + message: err.message, + stack: err.stack, + }); + } + } + + try { + await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { status: newStatus, signedAt: now }; +} + +/** + * Attach a wet-signed PDF as the authoritative signed copy. Either + * party can upload (admin via admin route, customer via public token + * route). When the customer uploads, status flips to `fully_signed` + * because the wet signature is treated as a full agreement (admin + * would normally also sign the wet copy before sending it to the + * customer). + */ +async function attachSignedPdfUpload(contractId, filePath, uploaderRole) { + // Self-heal contract email templates — same reason as the + // sendContract + recordAdminCountersignature paths. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (!filePath) throw new AppError('No file uploaded', 400); + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (['cancelled', 'draft'].includes(contract.status)) { + throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409); + } + + const now = new Date(); + const updates = { + signed_pdf_path: filePath, + status: 'fully_signed', + updated_at: now, + }; + // Migration 135 — durable wet-upload discriminator. Persists the + // "this row holds an authoritative wet upload, do not auto-overwrite" + // signal as a column rather than inferring from the file path. See + // the migration body for the full rationale. + if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) { + updates.signed_pdf_is_wet_upload = true; + } + // Hash the uploaded PDF on disk so we can later prove it wasn't + // tampered with after upload. Multer wrote the file synchronously + // before this handler runs, so reading it here is safe. + if (await hasColumnCached('contracts', 'signed_pdf_sha256')) { + updates.signed_pdf_sha256 = sha256OfFile(filePath); + } + if (uploaderRole === 'customer' && !contract.signed_by_customer_at) { + updates.signed_by_customer_at = now; + } + if (uploaderRole === 'admin' && !contract.signed_by_admin_at) { + updates.signed_by_admin_at = now; + } + await db('contracts').where({ id: contractId }).update(updates); + + // attachSignedPdfUpload always transitions to fully_signed (see + // updates.status above), so the dual-party send fires here too — + // same pattern as recordAdminCountersignature. The uploaded PDF + // IS the authoritative copy so we attach it directly. + try { + const refreshedContract = await db('contracts').where({ id: contractId }).first(); + const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + const attachments = [{ + filename: `${refreshedContract.contract_number}-signed.pdf`, + contentPath: filePath, + contentType: 'application/pdf', + }]; + // Sibling audit certificate — same legal-provenance record as the + // in-browser sign path. Best-effort; missing cert doesn't block the + // wet-signed PDF from reaching the parties. + const auditCertPath = await persistAuditCertificate(refreshedContract); + if (auditCertPath) { + attachments.push({ + filename: `${refreshedContract.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refreshedContract.contract_number, + customer_name: customerName, + title: refreshedContract.title || '', + attachments, + }); + } + if (profile.email && profile.email !== customer?.email) { + await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', { + contract_number: refreshedContract.contract_number, + customer_name: profile.company_name || 'Team', + title: refreshedContract.title || '', + attachments, + }); + } + } catch (err) { + logger.warn('Failed to send contract_fully_signed emails after PDF upload', { + contractId, error: err.message, + }); + } + + try { + await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null, + uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor()); + } catch (_) { /* logging is best-effort */ } + + return { status: 'fully_signed', signedPdfPath: filePath }; +} + +/** + * Convert an accepted quote into a fresh draft contract, pre-populating + * the customer, language, title, valid-until window, and source_quote_id + * back-pointer. Idempotent — if the quote already has a linked contract + * (quote.converted_contract_id set), returns that contract's id without + * creating a duplicate. + * + * Does NOT flip quote.status — the quote stays 'accepted' while the + * contract is the active deliverable. The quote→event / quote→invoice + * paths are gated against the converted_contract_id back-pointer so an + * admin can't accidentally double-spend the quote. + */ +async function createFromQuote(quoteId, adminId) { + // Same self-heal as createContract — the quote-conversion path seeds + // the contract with every active system block, and the new + // quote_line_items_table block needs to be present for it to land + // in the default inclusion list. + await ensureSystemBlocksSeeded(); + + const quote = await db('quotes').where({ id: quoteId }).first(); + if (!quote) throw new AppError('Quote not found', 404); + if (quote.status !== 'accepted') { + throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409, 'QUOTE_NOT_ACCEPTED'); + } + if (quote.converted_contract_id) { + return { contractId: quote.converted_contract_id, alreadyConverted: true }; + } + if (quote.converted_event_id) { + throw new AppError( + 'This quote was already converted to an event. Create the contract from the event instead.', + 409, 'ALREADY_CONVERTED_TO_EVENT', + ); + } + + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + ensureCustomerActive(customer); + + const profile = (await businessProfileService.getProfile()).profile; + const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; + const issueDate = new Date().toISOString().slice(0, 10); + const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) + .toISOString().slice(0, 10); + + const title = quote.event_name + ? `Contract — ${quote.event_name}` + : `Contract from quote ${quote.quote_number}`; + + // Schema-drift safety: the lineage columns landed in migration 130 + // as in-place edits. Dev installs that ran 130 BEFORE that edit + // won't have these columns yet. hasColumn() lets us skip the + // affected writes instead of crashing with a generic 500. + const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id'); + const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); + const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); + + return await db.transaction(async (trx) => { + const contractNumber = await nextContractNumber(); + const contractRow = { + contract_number: contractNumber, + customer_account_id: quote.customer_account_id, + status: 'draft', + language: quote.language || customer.preferred_language || profile?.default_locale || 'de', + issue_date: issueDate, + valid_until: validUntil, + title, + intro_text: quote.intro_text || null, + outro_text: quote.outro_text || null, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasContractSourceQuote) contractRow.source_quote_id = quote.id; + // Migration 140 — contract from quote inherits the quote's + // deal_uuid so both documents belong to the same deal chain. + // Falls back to a fresh UUID only if the source quote predates the + // backfill (shouldn't happen on a migrated install, but defensive). + contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID(); + // Propagate the quote's event snapshot — same fields the quote + // already carries (set by createQuote). Means contract-from-quote + // chains preserve "this contract is for the Wedding Doe / Müller" + // labelling all the way through to the resulting invoice's + // event_name field. + if (hasContractEventCols) { + contractRow.event_name = quote.event_name || null; + contractRow.event_date = quote.event_date || null; + contractRow.event_time_start = quote.event_time_start || null; + contractRow.event_time_end = quote.event_time_end || null; + } + const inserted = await trx('contracts').insert(contractRow).returning('id'); + const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Seed every active system block. Same shape as createContract. + // D.3 — batched insert (one DB round-trip vs N). + const systemBlocks = await trx('contract_blocks') + .where({ is_system: true, is_active: true }) + .orderBy(['section', 'display_order']); + const sectionCounters = {}; + const inclusionRows = systemBlocks.map((block) => { + sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; + return { + contract_id: contractId, + block_id: block.id, + section: block.section, + position: sectionCounters[block.section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: true, + created_at: new Date(), + updated_at: new Date(), + }; + }); + if (inclusionRows.length > 0) { + await trx('contract_block_inclusions').insert(inclusionRows); + } + + // Back-pointer so the quote detail page can deep-link to its + // resulting contract and the convert-to-event/invoice paths know + // to refuse double conversion. Skipped silently when the column + // hasn't migrated — the contract is still created cleanly. + if (hasQuoteContractBackPointer) { + await trx('quotes').where({ id: quote.id }).update({ + converted_contract_id: contractId, + updated_at: new Date(), + }); + } + + try { + await logActivity('contract_created_from_quote', + { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, + null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); + return { contractId, alreadyConverted: false }; + }); +} + +/** + * Convert a fully-signed contract into an event + scheduled invoices. + * Delegates to quoteService.convertToEvent using the contract's + * source_quote_id so the line items + payment plan come from the + * original quote. The quote MUST still be in 'accepted' status (i.e. + * not previously converted) — createFromQuote keeps it that way. + * + * On success the contract's converted_event_id is set (back-pointer) + * and the source quote flips to 'converted'. + */ +async function convertToEvent(contractId, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, + 409, 'CONTRACT_NOT_FULLY_SIGNED', + ); + } + if (contract.converted_event_id) { + return { eventId: contract.converted_event_id, alreadyConverted: true }; + } + + const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id'); + + // Path A: source quote present → delegate to quoteService which + // replays the full installment schedule into invoices alongside + // the event row. + if (contract.source_quote_id) { + const quoteService = require('./quoteService'); + const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true }); + if (hasContractConvertedEvent) { + await db('contracts').where({ id: contractId }).update({ + converted_event_id: result.eventId, + updated_at: new Date(), + }); + } + try { + await logActivity('contract_converted_to_event', + { contractId, eventId: result.eventId, quoteId: contract.source_quote_id }, + result.eventId, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return result; + } + + // Path B: standalone contract → mint an empty placeholder event + // row the admin fleshes out from the events admin page. Same + // column-introspection trick quoteService uses so installs with + // old/new host_*/customer_* column variants both work. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const today = new Date(); + const oneYearFromNow = new Date(today.getTime()); + oneYearFromNow.setFullYear(today.getFullYear() + 1); + + const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.display_name || customer.company_name || contract.contract_number; + const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`; + const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; + const placeholderHash = crypto.randomBytes(32).toString('hex'); + const shareToken = crypto.randomBytes(32).toString('hex'); + + const eventCols = await db('events').columnInfo(); + const candidate = { + slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, + // Prefer the contract's event_name snapshot (set on the contract + // editor or inherited from the source quote) over the contract + // title. Falls back to a deterministic placeholder so the event + // row never has a blank name. + event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`, + event_date: contract.event_date || contract.issue_date, + host_name: fullName, + host_email: customerEmail, + customer_name: fullName, + customer_email: customerEmail, + customer_phone: customer.phone, + admin_email: adminEmail, + event_type: 'wedding', + password_hash: placeholderHash, + share_link: shareToken, + share_token: shareToken, + expires_at: oneYearFromNow, + is_active: true, + is_archived: false, + is_draft: true, + created_by: adminId, + quote_id: null, + created_at: new Date(), + updated_at: new Date(), + }; + const eventRow = {}; + for (const [k, v] of Object.entries(candidate)) { + if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v; + } + const inserted = await db('events').insert(eventRow).returning('id'); + const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Link the customer so they see the event on their portal once + // the admin activates it. Best-effort — older installs without + // the junction table still get the event row. + try { + if (await db.schema.hasTable('event_customer_assignments')) { + await db('event_customer_assignments').insert({ + event_id: eventId, + customer_account_id: customer.id, + assigned_by_admin_id: adminId, + assigned_at: new Date(), + }); + } + } catch (_) { /* best-effort */ } + + if (hasContractConvertedEvent) { + await db('contracts').where({ id: contractId }).update({ + converted_event_id: eventId, + updated_at: new Date(), + }); + } + + try { + await logActivity('contract_converted_to_empty_event', + { contractId, eventId }, eventId, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { eventId, alreadyConverted: false }; +} + +/** + * Convert a fully-signed contract directly into invoice(s) without + * creating an event row. Same delegation pattern as convertToEvent. + */ +async function convertToInvoiceOnly(contractId, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, + 409, 'CONTRACT_NOT_FULLY_SIGNED', + ); + } + + // Schema-drift guard — the lineage columns are in-place edits to + // migration 130. Skip the back-pointer update silently when the + // column hasn't migrated yet. + const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id'); + + // Path A: contract has a source quote → replay its line items + + // payment plan via quoteService (full installment schedule). + if (contract.source_quote_id) { + const quoteService = require('./quoteService'); + const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true }); + if (hasInvoiceContractBackPointer) { + await db('invoices') + .where({ source_quote_id: contract.source_quote_id }) + .whereNull('source_contract_id') + .update({ source_contract_id: contractId }); + } + try { + await logActivity('contract_converted_to_invoices', + { contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated }, + null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return result; + } + + // Path B: standalone contract (no source quote) → direct DB insert + // of an empty draft. We deliberately bypass invoiceService.createInvoice + // because that runs ensureCustomerCanBill, which throws if the + // customer doesn't have feature_bills enabled. Admin clicking + // "Convert to invoice" on the contract detail page IS the + // authorisation; the admin will fill in line items manually before + // sending. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + + const invoiceService = require('./invoiceService'); + const profile = (await businessProfileService.getProfile()).profile || {}; + const currency = (profile.default_currency || 'CHF').toUpperCase(); + const language = contract.language || customer.preferred_language || profile.default_locale || 'de'; + const issueDate = new Date().toISOString().slice(0, 10); + const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; + const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + // Pre-resolve which event-snapshot columns the invoices table has + // (migration 123) so we can copy contract.event_name etc onto the + // new invoice. Falls back to contract.title when event_name is + // empty — gives standalone contracts a useful label even when + // the admin didn't fill out the event field. + const invoiceHasEventName = await hasColumnCached('invoices', 'event_name'); + const eventNameSnapshot = (contract.event_name || contract.title || null); + + const invoiceNumber = await invoiceService.nextInvoiceNumber(); + const invoiceRow = { + invoice_number: invoiceNumber, + customer_account_id: contract.customer_account_id, + source_quote_id: null, + event_id: null, + language, + currency, + issue_date: issueDate, + due_date: dueDate, + installment_index: 0, + installment_total: 1, + status: 'scheduled', + net_amount_minor: 0, + vat_rate: 0, + vat_amount_minor: 0, + shipping_amount_minor: 0, + total_amount_minor: 0, + paid_amount_minor: 0, + reminder_level: 0, + late_fee_amount_minor: 0, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId; + // Migration 140 — invoice inherits the contract's deal_uuid so the + // contract + invoice belong to the same deal chain. Fresh UUID if + // the contract predates the backfill (defensive). + invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID(); + // Snapshot the contract's event fields onto the invoice so the + // BillDetailPage + customer portal show the same "Wedding Doe / + // Müller" label that the contract carries. event_name is also the + // field the dunning emails reference in their templates. + if (invoiceHasEventName) { + invoiceRow.event_name = eventNameSnapshot; + invoiceRow.event_date = contract.event_date || null; + invoiceRow.event_time_start = contract.event_time_start || null; + invoiceRow.event_time_end = contract.event_time_end || null; + } + const inserted = await db('invoices').insert(invoiceRow).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + try { + await logActivity('contract_converted_to_empty_invoice', + { contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + // Match the result shape of the source-quote path so the frontend + // toast can use the same translation key. `installmentsCreated` is + // always 1 here (single empty invoice). + return { installmentsCreated: 1, invoiceId }; +} + +/** + * Recovery helper: re-render the signed PDF + resend the + * contract_fully_signed email to both parties. Used by the admin + * detail page when: + * - a previous render silently failed (signed_pdf_path is empty + * on a fully_signed contract) + * - the customer reports they didn't receive the email + * - the bodies of the seeded blocks were updated post-signing and + * the admin wants the latest text on file + * + * Only available on fully_signed contracts. The wet-signed PDF path + * is preserved: when signed_pdf_path already points at an uploaded + * file (not a re-render path) we DO NOT overwrite — the uploaded PDF + * is the authoritative copy. We still resend the email with that + * uploaded PDF as the attachment. + */ +async function rerenderAndResend(contractId, adminId) { + // Self-heal contract email templates. This is the most likely + // recovery path the admin reaches when a prior dual-party send + // failed silently — including when the failure was caused by the + // template being missing in the first place. + const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger); + if (newlySeeded.length > 0) { + logger.warn('rerenderAndResend self-healed missing email templates', { + contractId, seeded: newlySeeded, + }); + } + + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Re-send is only available on fully-signed contracts (status: ${contract.status})`, + 409, 'NOT_FULLY_SIGNED', + ); + } + + let attachmentPath = contract.signed_pdf_path || null; + // Migration 135 — `signed_pdf_is_wet_upload` is the durable + // authoritative-source discriminator. It's set TRUE only by + // attachSignedPdfUpload, so any non-wet path here is a system + // stamp safe to replace. We still null-check the path so missing + // (re-stamp recovery) cases trigger the re-stamp branch below. + const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); + const isWetSignedUpload = hasWetFlagColumn + ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) + // Fallback ONLY for installs where the migration hasn't applied yet: + // preserve the historical substring rule so we don't accidentally + // overwrite uploads on an un-migrated DB. + : !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed')); + if (!attachmentPath || !isWetSignedUpload) { + // Stamp signatures onto the immutable unsigned pdf_path using + // pdf-lib (NOT a full re-render). This preserves the exact bytes + // the customer originally agreed to and side-steps the silent re- + // render failure that left signed_pdf_path NULL on prior contracts. + const refreshed = await getContractById(contract.id); + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new AppError( + `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, + 500, 'UNSIGNED_PDF_MISSING', + ); + } + const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stamps = buildSignatureStamps(refreshed.contract); + const { buffer: stampedBuffer, sha256: signedSha256 } = + await pdfStampService.stampSignatures(originalBuffer, stamps); + const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed'); + attachmentPath = persisted.filePath; + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: attachmentPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — this branch is a recovery path; clear any + // existing failed-render marker. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } + + // Resend the dual-party email with the now-guaranteed attachment. + const refetched = await db('contracts').where({ id: contract.id }).first(); + const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + // Sibling audit certificate (timestamps + IPs + hashes). Best-effort: + // missing certificate doesn't block the email — the stamped contract + // alone is the primary attachment. + const auditCertPath = await persistAuditCertificate(refetched); + + const attachments = [{ + filename: `${refetched.contract_number}-signed.pdf`, + contentPath: attachmentPath, + contentType: 'application/pdf', + }]; + if (auditCertPath) { + attachments.push({ + filename: `${refetched.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refetched.contract_number, + customer_name: customerName, + title: refetched.title || '', + attachments, + }); + } + const adminEmail = profile.email || adminRow?.email; + if (adminEmail && adminEmail !== customer?.email) { + await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { + contract_number: refetched.contract_number, + customer_name: profile.company_name || adminRow?.first_name || 'Team', + title: refetched.title || '', + attachments, + }); + } + + try { + await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { signedPdfPath: attachmentPath, resent: true }; +} + +/** + * Recovery helper: admin re-stamps signatures (customer and/or admin) + * on a contract whose signature_path columns are null/broken because + * the original sign happened before the canvas worked correctly. + * + * The admin draws BOTH signatures on the detail page — the customer's + * signature is admin-attested in this flow (the customer already + * agreed via the original sign; this just makes the PDF show + * something). Original signed_by_*_at + signed_*_name + signed_*_ip + * stay untouched; only the *_signature_path columns + the rendered + * PDF get refreshed. + * + * Available on contracts in status: + * signed_by_customer (re-stamp customer, optionally admin too) + * signed_by_admin (re-stamp admin, optionally customer too) + * fully_signed (re-stamp either or both) + */ +async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) { + throw new AppError( + `Cannot re-stamp signatures on a contract in status '${contract.status}'.`, + 409, 'WRONG_STATUS', + ); + } + if (!customerSignatureDataUrl && !adminSignatureDataUrl) { + throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE'); + } + + const updates = { updated_at: new Date() }; + if (customerSignatureDataUrl) { + updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl); + } + if (adminSignatureDataUrl) { + updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl); + } + await db('contracts').where({ id: contract.id }).update(updates); + + // Re-stamp signature images onto the immutable unsigned pdf_path + // using pdf-lib (NOT a full re-render). This is the recovery path + // for contracts where signature images existed on disk but the + // earlier re-render approach failed silently and left signed_pdf_path + // NULL or pointing at a stale file. We always rebuild the stamp from + // pdf_path (the as-sent bytes) so the result is reproducible from + // the audit record. + // + // Wet-signed PDF uploads remain authoritative — if signed_pdf_path + // already points at an uploaded PDF we still produce a stamped copy + // on disk for the audit trail, but signed_pdf_path is not updated. + const refreshed = await getContractById(contract.id); + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new AppError( + `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, + 500, 'UNSIGNED_PDF_MISSING', + ); + } + const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stamps = buildSignatureStamps(refreshed.contract); + const { buffer: stampedBuffer, sha256: signedSha256 } = + await pdfStampService.stampSignatures(originalBuffer, stamps); + const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer, + contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed'); + + // Migration 135 — read the discriminator column. Fall back to the + // historical substring rule only when the column is absent (un- + // migrated install) so we never accidentally overwrite a wet upload. + const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); + const isWetSignedUpload = hasWetFlagColumn + ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) + : !!(contract.signed_pdf_path + && contract.signed_pdf_path.includes('uploads/contracts/signed')); + if (!isWetSignedUpload) { + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — restamp is a recovery path; clear the marker. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } + + try { + await logActivity('contract_signatures_restamped', { + contractId, + stamped: { + customer: !!customerSignatureDataUrl, + admin: !!adminSignatureDataUrl, + }, + }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { + signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath, + stamped: { + customer: !!customerSignatureDataUrl, + admin: !!adminSignatureDataUrl, + }, + }; +} + +/** + * Read the chronological audit trail for a contract from activity_logs. + * Matches every `contract_*` activity_type where metadata.contractId + * equals this contract's id. Ordered oldest → newest so the UI can + * render a vertical timeline. Read-only; used by the admin detail + * page's AuditTrailCard. + */ +async function getAuditTrail(contractId) { + if (!(await db.schema.hasTable('activity_logs'))) return []; + // Push the metadata.contractId filter into SQL instead of fetching + // every contract_* row and filtering in JS. The previous shape + // scanned the entire history every time the detail page loaded — + // O(rows-since-CRM-launch) per request. Both Postgres and SQLite + // store metadata as a JSON-encoded string here, so we match on + // a literal substring that covers either compact or whitespaced + // JSON encodings — `"contractId":` or `"contractId": ` — + // bounded by the activity_type prefix so the search hits the + // contract_* slice of the index. + // + // The substring patterns intentionally don't anchor on word + // boundaries; activity_logs.metadata never contains a contractId + // key collision with another id-shaped value because logActivity + // serialises only what callers pass. + const id = Number(contractId); + if (!Number.isFinite(id)) return []; + const rows = await db('activity_logs') + .where('activity_type', 'like', 'contract_%') + .andWhere(function () { + this.where('metadata', 'like', `%"contractId":${id}%`) + .orWhere('metadata', 'like', `%"contractId": ${id}%`); + }) + .orderBy('created_at', 'asc') + .select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at'); + + return rows.map((r) => { + let meta = r.metadata; + if (typeof meta === 'string') { + try { meta = JSON.parse(meta); } catch { meta = {}; } + } + return { ...r, metadata: meta || {} }; + }); +} + +/** + * Re-hash the two on-disk PDFs and compare against the stored hashes + * (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin + * confirm that backups, manual moves, or storage corruption haven't + * silently altered the issued document. + * + * Each leg of the response carries: + * - `path`: the stored path string (so the UI can show what was + * checked even when it's missing) + * - `present`: file exists on disk + * - `expected`: the SHA-256 column value (null if never persisted) + * - `actual`: the freshly-computed hash, or null when file missing + * - `match`: true iff both hashes exist AND they're equal + * + * The customer already has both expected hashes via the audit + * certificate the signing flow ships as a second email attachment, so + * they can verify independently with `shasum -a 256`. This endpoint + * is the admin-side equivalent — single click instead of dropping to + * a shell. + */ +async function verifyIntegrity(id) { + const contract = await db('contracts') + .where({ id }) + .select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256') + .first(); + if (!contract) throw new AppError('Contract not found', 404); + + const checkLeg = (filePath, expected) => { + const present = !!filePath && fs.existsSync(filePath); + const actual = present ? sha256OfFile(filePath) : null; + return { + path: filePath || null, + present, + expected: expected || null, + actual, + match: !!(expected && actual && expected === actual), + }; + }; + + return { + unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256), + signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256), + }; +} + +async function cancelContract(id, adminId) { + const contract = await db('contracts').where({ id }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['draft', 'sent'].includes(contract.status)) { + throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409); + } + await db('contracts').where({ id }).update({ + status: 'cancelled', + updated_at: new Date(), + }); + // Invalidate any outstanding tokens. + await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({ + expires_at: new Date(), + }); + try { + await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return { status: 'cancelled' }; +} + +module.exports = { + listContracts, + getContractById, + createContract, + updateContract, + sendContract, + renderContractPdfBuffer, + recordCustomerSignature, + recordAdminCountersignature, + attachSignedPdfUpload, + cancelContract, + createFromQuote, + convertToEvent, + convertToInvoiceOnly, + rerenderAndResend, + restampSignatures, + getAuditTrail, + verifyIntegrity, + // Exported for tests + the public-route preview endpoint. + _internal: { + nextContractNumber, + renderTemplatedBody, + buildPlaceholderContext, + buildRenderContext, + SECTIONS_ORDER, + }, +}; diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 96255c6d..9b32c759 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -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, diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js new file mode 100644 index 00000000..e07644e7 --- /dev/null +++ b/backend/src/services/customerHoursService.js @@ -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, + }, +}; diff --git a/backend/src/services/dealsService.js b/backend/src/services/dealsService.js new file mode 100644 index 00000000..ca6d04b9 --- /dev/null +++ b/backend/src/services/dealsService.js @@ -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, +}; diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 1d957ca0..9f695c5d 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -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; diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js new file mode 100644 index 00000000..74e4601b --- /dev/null +++ b/backend/src/services/eventReminderService.js @@ -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_` — 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, + }, +}; diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js index 2ca45013..3f7faea7 100644 --- a/backend/src/services/eventService.js +++ b/backend/src/services/eventService.js @@ -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 }; diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js index 7381701f..e5c59891 100644 --- a/backend/src/services/galleryOgService.js +++ b/backend/src/services/galleryOgService.js @@ -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(' — '); diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js new file mode 100644 index 00000000..181ab99e --- /dev/null +++ b/backend/src/services/invoiceSchedulerService.js @@ -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 }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js new file mode 100644 index 00000000..2f0f7266 --- /dev/null +++ b/backend/src/services/invoiceService.js @@ -0,0 +1,3295 @@ +/** + * invoiceService — lifecycle for `invoices`, line items, payment log. + * + * Layers on top of quoteService for the conversion path: quoteService + * .convertToEvent() calls into scheduleInvoicesForEvent() to fan out + * one row per installment with the right `scheduled_send_at` relative + * to the event date. + * + * Statuses (`invoices.status`): + * scheduled not yet sent; the scheduler picks it up when + * `scheduled_send_at <= now()` and flips to `sent` + * sent email + PDF delivered; awaiting payment + * paid fully paid (paid_amount_minor >= total_amount_minor) + * overdue past due_date + reminder_first_days; reminder fired + * cancelled admin cancelled; no further reminders + * + * Per-customer feature override (`customer_accounts.feature_bills`): + * when false, the service refuses to create or schedule invoices for + * that customer. + */ + +const crypto = require('crypto'); +const { db, withRetry, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { getAppSetting } = require('../utils/appSettings'); +const { AppError } = require('../utils/errors'); +const { formatBoolean } = require('../utils/dbCompat'); +const { claimNextSequence } = require('../utils/documentSequences'); +const { formatShortDate } = require('../utils/dateFormatter'); +const businessProfileService = require('./businessProfileService'); +const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); +const pdfService = require('./pdfService'); +const emailProcessor = require('./emailProcessor'); +// Migration 119 line-item hierarchy helpers, shared with quoteService. +// We import lazily inside the functions that use them to avoid a +// require-cycle warning (quoteService also imports invoiceService for +// the quote→invoice conversion path). +function getHierarchyHelpers() { + // eslint-disable-next-line global-require + return require('./quoteService')._internal; +} + +// D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers. +const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); + +function formatNumberInTemplate(format, year, seq) { + return format + .replace(/\{YEAR\}/g, String(year)) + .replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0')) + .replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0')) + .replace(/\{SEQ\}/g, String(seq)); +} + +// Atomic gap-free invoice number generator. See utils/documentSequences.js +// for the locking story; migration 132 created the underlying table. +// The previous SELECT-MAX-then-INSERT path raced under concurrent +// admin creates and emitted a random `R-2026-AB12C3` after 5 retries, +// breaking the §14 UStG single-sequence requirement. +async function nextInvoiceNumber(trx) { + const format = (await getAppSetting('crm_invoices_number_format')) || 'R-{YEAR}-{SEQ:04d}'; + const year = new Date().getFullYear(); + const seq = await claimNextSequence('invoice', year, trx); + return formatNumberInTemplate(format, year, seq); +} + +function ensureCustomerCanBill(customer) { + if (!customer) { throw new AppError('Customer not found', 404); } + if (customer.is_active === false || customer.is_active === 0) { + throw new AppError('Customer is deactivated', 409); + } + if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') { + throw new AppError('This customer has bills disabled', 409, 'CUSTOMER_FEATURE_DISABLED'); + } +} + +/** + * Resolve a trigger ('quote_accepted' | 'before_event' | ...) + + * offset_days into a concrete date relative to the event. + */ +function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new Date()) { + const ms = 24 * 60 * 60 * 1000; + const offset = ensureInt(offsetDays) * ms; + const eventTs = eventDate ? new Date(eventDate).getTime() : null; + switch (trigger) { + case 'quote_accepted': + return new Date(baseDate.getTime() + offset); + case 'before_event': + case 'after_event': + if (!eventTs) return new Date(baseDate.getTime() + offset); + return new Date(eventTs + offset); + case 'after_delivery': + // Treat as event_date + 14 days as a sensible default; admin can + // edit the scheduled_send_at on the invoice later. + if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); + return new Date(eventTs + 14 * ms + offset); + case 'fixed_date': + default: + return new Date(baseDate.getTime() + offset); + } +} + +function computeDueDate(scheduledSendAt, netDays = 30) { + return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000); +} + +/** + * Resolve the deal_uuid for a new invoice row (migration 140). Priority: + * + * 1. `payload.dealUuid` — explicit caller override. Used by + * spawnInstallmentInvoices (all siblings share one uuid), + * Storno (inherits from cancelled invoice), and reissue + * (inherits from the cancelled original). + * 2. The source quote's deal_uuid, if `payload.sourceQuoteId` is set. + * 3. The source contract's deal_uuid, if `payload.sourceContractId` + * is set. + * 4. Fresh mint — standalone invoices that aren't part of any chain. + * + * Returns a UUID string. Never returns null. + */ +async function resolveDealUuid(trx, payload) { + if (payload?.dealUuid) return payload.dealUuid; + if (payload?.sourceQuoteId) { + const q = await trx('quotes').where({ id: payload.sourceQuoteId }).first('deal_uuid'); + if (q?.deal_uuid) return q.deal_uuid; + } + if (payload?.sourceContractId) { + const c = await trx('contracts').where({ id: payload.sourceContractId }).first('deal_uuid'); + if (c?.deal_uuid) return c.deal_uuid; + } + return crypto.randomUUID(); +} + +/** + * Snap a baseline date to the next billing-cycle boundary for a + * customer on a fixed cadence. Used by scheduleInvoicesForEvent so + * monthly / quarterly customers don't get billed immediately on quote + * acceptance — instead the invoice fires on `billing_cycle_day` of the + * next period. + * + * `cycleDay` honours the sign-as-discriminator convention from + * migration 128: positive 1..28 = that day of the month; negative + * -1..-15 = that many days before end of month. Resolution is + * delegated to `computeMonthlyCadenceDate` so the two helpers can't + * disagree about what "-3 cycle day" means. + * + * Day numbers beyond the destination month's length are clamped + * (e.g. day 31 in February rolls back to Feb 28/29). Negative days + * are clamped to day 1 minimum (extreme values like -40 don't blow + * past the start of the month). + * + * History: a prior version of this function did + * `Math.max(1, Math.min(31, ensureInt(cycleDay) || 1))`, silently + * clamping every negative value to 1 — so a customer configured + * with cycle_day=-3 (last 3 days of month) got billed on day 1 + * instead. Audit finding: monthly cycle sign convention bug. + */ +function snapToNextBillingCycle(baseDate, cadence, cycleDay) { + if (!cadence || cadence === 'per_event') return baseDate; + const day = Number.isFinite(ensureInt(cycleDay)) ? ensureInt(cycleDay) : 1; + const d = new Date(baseDate.getTime()); + + if (cadence === 'monthly') { + // Move to the cycleDay in the next calendar month. If we're already + // before cycleDay this month and the base date is in the same month, + // we still move forward to NEXT month so accepting a quote on + // Jan 5 (cycleDay=1) fires on Feb 1, not Jan 5. + const nextMonth = d.getMonth() + 1; + return computeMonthlyCadenceDate(d.getFullYear(), nextMonth, day); + } + + if (cadence === 'quarterly') { + // First month of the next quarter. Quarter starts: Jan, Apr, Jul, Oct. + const month = d.getMonth(); + const nextQuarterMonth = (Math.floor(month / 3) + 1) * 3; // 0,3,6,9 + return computeMonthlyCadenceDate(d.getFullYear(), nextQuarterMonth, day); + } + + return baseDate; +} + +/** + * Compute the canonical "cadence day" for a given (year, month) using + * the customer's `billing_cycle_day`. Migration 128 introduced the + * sign-as-discriminator convention: + * positive 1..28 → that day of the month, clamped to month length + * negative -1..-15 → that many days before end of month + * Zero falls back to 1 (matches the service-layer clamp). + * + * Returns a JS Date at local-midnight on the resolved day. Callers + * compare against today's date with day-resolution math; the time + * component never matters for monthly-bill issuance. + */ +function computeMonthlyCadenceDate(year, month /* 0-based */, cycleDay) { + const day = Number.isFinite(cycleDay) ? Math.trunc(cycleDay) : 1; + const monthLen = new Date(year, month + 1, 0).getDate(); + let target; + if (day > 0) { + target = Math.min(day, monthLen); + } else if (day < 0) { + // Sign-as-discriminator: -N = N days before month end. Documented + // in the admin UI hint as "Use negative -1..-15 for 'N days before + // month end' (so -3 fires on the 28th of a 31-day month)". + // Formula: monthLen + day → -3 + 31 = 28 ✓. + // Clamped to day 1 minimum so extreme values (-40) don't blow + // past the start of the month. + target = Math.max(1, monthLen + day); + } else { + target = 1; + } + return new Date(year, month, target); +} + +/** + * Find or create the running "monthly draft" invoice for a customer. + * One draft per customer per current billing period (`monthly_period_end >= today`). + * Subsequent saves through createInvoice for the same monthly-mode + * customer append line items onto this draft instead of minting fresh + * invoices. + * + * Returns `{ id, row }` for the draft so the caller can append items + * + recompute totals without a second query. + * + * Period bounds: + * start = first calendar day of the month that contains today + * end = computeMonthlyCadenceDate(year, month, cycle_day) where + * year/month are picked so that the resolved date is in the + * future. If today is already PAST the cadence day for the + * current month, the period rolls to next month — admin + * authoring items after the cadence is "starting the next + * bill", not "appending to one that already fired". + */ +async function getOrCreateMonthlyDraft(customer, adminId, trx) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Resolve period_end: prefer the cadence in the current month, but + // if it has already passed, roll to next month so the new draft + // gathers items toward the NEXT bill. + const cycleDay = ensureInt(customer.billing_cycle_day) || 1; + let target = computeMonthlyCadenceDate(today.getFullYear(), today.getMonth(), cycleDay); + if (target.getTime() < today.getTime()) { + const nextMonth = today.getMonth() + 1; + target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay); + } + const periodStart = new Date(target.getFullYear(), target.getMonth(), 1); + const periodEnd = target; + + // Look up any existing open draft for this customer. We deliberately + // do NOT filter by monthly_period_end here — only one draft can be + // open per customer at a time (enforced by the partial unique index + // created in migration 133). If the scheduler hasn't yet promoted an + // expired draft, it's still the canonical landing spot for any new + // items the admin queues; promoting it is the scheduler's job, not + // ours. forUpdate() locks the row on Postgres so concurrent appenders + // serialize on totals recomputation; SQLite's transaction write-lock + // gives us the same guarantee implicitly. + const existing = await trx('invoices') + .where({ + customer_account_id: customer.id, + is_monthly_draft: true, + }) + .orderBy('id', 'desc') + .forUpdate() + .first(); + if (existing) { + return { id: existing.id, row: existing, created: false }; + } + + // None yet — mint one with zero line items + zero totals. The + // caller appends items + recomputes immediately after. + const profile = (await businessProfileService.getProfile()).profile; + const currency = (customer.preferred_currency || profile?.default_currency || 'CHF').toUpperCase(); + const language = customer.preferred_language || profile?.default_locale || 'de'; + const invoiceNumber = await nextInvoiceNumber(trx); + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, null); + + const row = { + invoice_number: invoiceNumber, + customer_account_id: customer.id, + source_quote_id: null, + event_id: null, + language, + currency, + issue_date: periodEnd.toISOString().slice(0, 10), + due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time + installment_index: 0, + installment_total: 1, + status: 'scheduled', + scheduled_send_at: null, // monthly pass sets this on cadence day + net_amount_minor: 0, + vat_rate: 0, + vat_amount_minor: 0, + shipping_amount_minor: 0, + total_amount_minor: 0, + business_bank_account_id: bank?.id || null, + qr_format: null, + is_monthly_draft: true, + monthly_period_start: periodStart.toISOString().slice(0, 10), + monthly_period_end: periodEnd.toISOString().slice(0, 10), + // Migration 140 — each monthly-draft cycle is its own deal (no + // quote/contract chain). Fresh UUID at creation; subsequent line + // appends just mutate this same row, so the uuid sticks. + deal_uuid: crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + try { + const inserted = await trx('invoices').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return { id, row: { ...row, id }, created: true }; + } catch (err) { + // Partial-unique-index violation: another transaction snuck a draft + // in between our SELECT and INSERT. Re-SELECT the winner and return + // it — concurrent callers converge on the same draft row instead + // of double-billing the customer. The error string varies by + // driver: Postgres → SQLSTATE 23505; better-sqlite3 → 'UNIQUE + // constraint failed'; node-sqlite3 → 'SQLITE_CONSTRAINT'. + const msg = String(err && err.message || ''); + const isUniqueViolation = + err && err.code === '23505' || + /unique/i.test(msg) || + /sqlite_constraint/i.test(msg); + if (!isUniqueViolation) throw err; + const winner = await trx('invoices') + .where({ customer_account_id: customer.id, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!winner) { + // No row to return despite the unique-violation — this would + // mean the winning transaction rolled back after we lost the + // race. Surface the original error so the caller can retry. + throw err; + } + return { id: winner.id, row: winner, created: false }; + } +} + +// --------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------- + +async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { + return await withRetry(async () => { + let query = db('invoices') + .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') + // Surface the source contract's human contract_number (mirror of + // the src_quote JOIN in getInvoiceById) so list rows + detail + // page can render "From contract LBM-C-2026-0010" instead of + // the bare DB id "#10". LEFT join — most invoices have no + // source contract. + .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') + .select( + 'invoices.*', + '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', + // Same isPassive-source as getInvoiceById — surfaced so list + // rows can render the Passive badge inline without an N+1 + // round-trip. + 'customer_accounts.password_hash as customer_password_hash', + 'customer_accounts.company_name as customer_company_name', + 'src_contract.contract_number as source_contract_number', + ); + + if (Array.isArray(filters.status) && filters.status.length > 0) { + query = query.whereIn('invoices.status', filters.status); + } + if (filters.customerAccountId) { + query = query.where('invoices.customer_account_id', filters.customerAccountId); + } + // Hide monthly drafts (migration 128) from the default list — they + // live on the customer detail page's "Monthly billing queue" card. + // Callers that explicitly want them (the customer-detail summary + // fetch) pass `includeMonthlyDrafts: true`. + if (!filters.includeMonthlyDrafts) { + query = query.where(function () { + this.where('invoices.is_monthly_draft', false) + .orWhereNull('invoices.is_monthly_draft'); + }); + } + if (filters.sourceQuoteId) { + query = query.where('invoices.source_quote_id', filters.sourceQuoteId); + } + if (filters.unpaidOnly) { + query = query.whereIn('invoices.status', ['scheduled', 'sent', 'overdue']); + } + if (filters.q && String(filters.q).trim()) { + const term = `%${String(filters.q).trim()}%`; + query = query.andWhere(function() { + this.where('invoices.invoice_number', 'like', term) + .orWhere('customer_accounts.email', 'like', term) + .orWhere('customer_accounts.company_name', 'like', term); + }); + } + const countRow = await query.clone().clearSelect().clearOrder().count('invoices.id as total').first(); + const total = ensureInt(countRow?.total || 0); + + switch (sort) { + // "Newest" / "Oldest" means newest/oldest by CREATION time, not + // by issue_date. Issue_date is admin-controlled (used for tax + // accruals, retro-dating, future-dating) so it can drift from + // actual chronology — sorting by it makes a just-created invoice + // disappear into the middle of the list whenever its issue_date + // is set to something other than today. created_at always + // reflects when the row landed in the DB. id is the tiebreaker + // for rows that share a created_at second. + case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; + case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; + case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; + case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; + case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('invoices.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc'); + break; + } + + const offset = Math.max(0, (page - 1) * pageSize); + query = query.offset(offset).limit(pageSize); + const rows = await query; + return { rows, total, page, pageSize }; + }); +} + +async function getInvoiceById(id) { + return await withRetry(async () => { + // LEFT JOIN customer_accounts so transformInvoice has populated + // customer_email / company etc. — mirrors getQuoteById. + const invoice = await db('invoices') + .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') + // Join the source quote so the detail view can display its + // human-readable number ("LBM-Q-2026-0006") instead of just + // the numeric id ("#6"). LEFT join — most invoices come from + // a quote conversion but standalone invoices don't have one. + .leftJoin('quotes as src_quote', 'invoices.source_quote_id', 'src_quote.id') + // Migration 130 lineage: source contract's human contract_number + // so the detail view shows "From contract LBM-C-2026-0010" + // instead of "#10". Same LEFT-join shape as src_quote. + .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') + // Self-joins for Storno lineage so the detail view can render + // "Cancelled by Stornorechnung S-XXXX" / "This Stornorechnung + // cancels invoice R-XXXX" using the human invoice_number rather + // than the bare DB row id. Same pattern as source_quote_number. + .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.id', id) + .select( + 'invoices.*', + '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', + // Surfaced so the route's transformInvoice can compute the + // customer.isPassive flag (passwordHash == null). The hash + // itself never leaves the API — transformInvoice drops it + // and only exposes the boolean. + 'customer_accounts.password_hash as customer_password_hash', + 'src_quote.quote_number as source_quote_number', + 'src_contract.contract_number as source_contract_number', + 'cancels_inv.invoice_number as cancels_invoice_number', + 'cancellation_storno.invoice_number as cancellation_storno_number', + ) + .first(); + if (!invoice) return null; + // Self-join so each row also carries `parent_position` (the position + // of its parent line item, when it's a sub-item). The editor needs + // position-based references to rebuild the hierarchy in the UI; + // parent_line_item_id is the DB-level relationship but isn't + // stable in the payload the editor sends back. Migration 119. + const lineItems = await db('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + const payments = await db('invoice_payment_log').where({ invoice_id: id }).orderBy('paid_at', 'asc'); + return { invoice, lineItems, payments }; + }); +} + +/** + * Append line items from a `createInvoice`-shaped payload onto the + * customer's running monthly-draft (migration 128). Used when the + * customer is billing_cadence='monthly': the admin's editor save + * lands here instead of minting a new invoice. + * + * Pulls the existing draft (or creates a fresh one for the current + * period), appends the new line items continuing the position + * sequence, recomputes totals across the merged set, and returns the + * draft's id so the route layer can fetch + return it. + */ +async function appendToMonthlyDraft(payload, customer, adminId, trx) { + const draft = await getOrCreateMonthlyDraft(customer, adminId, trx); + + // Load existing line items so we can compute the next `position` and + // re-sum totals across the merged set. The migration-119 hierarchy + // helpers operate on the merged array so parent_position pointers + // remain consistent. + const existing = await trx('invoice_line_items') + .where({ invoice_id: draft.id }) + .orderBy('position', 'asc'); + const nextPosition = existing.length + ? Math.max(...existing.map((li) => ensureInt(li.position))) + 1 + : 1; + + const incoming = Array.isArray(payload.lineItems) ? payload.lineItems : []; + const newItems = incoming.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + const isSubItem = li.parent_position != null && li.parent_position !== ''; + return { + position: nextPosition + idx, + quantity: qty, + description: String(li.description || ''), + unit_price_minor: unit, + discount_percent: discount, + line_total_minor: lineTotal, + parent_position: isSubItem ? ensureInt(li.parent_position) : null, + details_text: li.details_text || null, + }; + }); + + if (newItems.length > 0) { + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(newItems); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', draft.id, newItems); + } + + // Recompute totals across the entire draft so the running figures + // shown on the customer-detail "Monthly queue" card stay accurate + // as items accumulate. Mirrors createInvoice's totals path. + const allItems = await trx('invoice_line_items') + .where({ invoice_id: draft.id }); + let netMinor = 0; + for (const li of allItems) { + if (li.parent_line_item_id == null) netMinor += ensureInt(li.line_total_minor); + } + const vatRate = ensureNumber(payload.vatRate, draft.row.vat_rate || 0); + const vatMinor = Math.round(netMinor * Number(vatRate) / 100); + const shippingMinor = ensureInt(draft.row.shipping_amount_minor); + const totalMinor = netMinor + vatMinor + shippingMinor; + + await trx('invoices').where({ id: draft.id }).update({ + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + total_amount_minor: totalMinor, + updated_at: new Date(), + }); + + try { + await logActivity('monthly_billing_items_queued', + { invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length }, + null, `admin:${adminId}`); + } catch (_) {} + + return draft.id; +} + +/** + * Append a single, fully-formed line item to the customer's running + * monthly draft (migration 128 + 129). Used by customerHoursService + * when an hour entry is logged for a monthly-mode customer — we want + * the inserted `invoice_line_items.id` back so the entry can be + * stamped with the cross-reference. + * + * `lineItem` is the shape consumed by appendToMonthlyDraft's internal + * insertLineItemsHierarchical helper (description, quantity, + * unit_price_minor, discount_percent, line_total_minor, etc.). The + * `position` field is set internally — caller-supplied positions are + * ignored to keep the accumulator's sequence intact. + * + * Returns { invoiceId, lineItemId } — the draft id plus the id of the + * newly-appended row. + */ +async function appendOneLineItemToMonthlyDraft(customer, lineItem, adminId, trx) { + // Reuse the accumulator path — it handles get-or-create + totals + // recompute + activity log. We pass a single-item array. + await appendToMonthlyDraft({ + customerAccountId: customer.id, + lineItems: [lineItem], + vatRate: 0, // hours logging doesn't ship with VAT today + }, customer, adminId, trx); + + // Look up the draft we just appended onto + its tail line item. + // Newest insert wins by id desc; we filter by position match so + // concurrent appends in another tx don't return the wrong row. + const draft = await trx('invoices') + .where({ customer_account_id: customer.id, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) { + // Defensive — appendToMonthlyDraft would have created one. + throw new AppError('Monthly draft missing after append', 500); + } + const tail = await trx('invoice_line_items') + .where({ invoice_id: draft.id }) + .orderBy('position', 'desc') + .first(); + return { invoiceId: draft.id, lineItemId: tail?.id || null }; +} + +/** + * Create one invoice. Returns id. Used both manually (admin creates a + * standalone invoice) and by scheduleInvoicesForEvent (one per installment). + */ +async function createInvoice(payload, adminId, trx = db) { + const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); + ensureCustomerCanBill(customer); + + // Monthly-billing intercept (migration 128). For customers in + // billing_cadence='monthly' mode every createInvoice call APPENDS + // line items onto the running monthly-draft instead of minting a + // fresh invoice. Admin sees the editor flow exactly as before; the + // returned id is the draft's id so the UI can redirect to the + // accumulator. `_skipMonthlyRouting` is the escape hatch used by + // internal helpers that need to mint a non-draft row (e.g. the + // accumulator itself, or future test fixtures). + if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) { + const draft = await appendToMonthlyDraft(payload, customer, adminId, trx); + return { invoiceIds: draft?.id ? [draft.id] : [] }; + } + + const profile = (await businessProfileService.getProfile()).profile; + const currency = (payload.currency || profile?.default_currency || 'CHF').toUpperCase(); + const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; + + // Sequence number is claimed BELOW the installment auto-route so a + // multi-installment save doesn't waste a number. When installments + // are present, spawnInstallmentInvoices claims one number per + // sibling and we never reach the single-row insert that would have + // used `invoiceNumber` here. + const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); + const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null; + // Resolve the selected payment-term template's net_days BEFORE + // computing the due date so Net 60 / 90 templates actually push + // the due date out. Falls back to 30 when no template is set + // (matches the historical default). + let resolvedNetDays = 30; + if (payload.paymentTermTemplateId) { + const probe = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30; + } + const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays) + .toISOString().slice(0, 10); + + // Re-compute totals from line items. Migration 119 — items with a + // non-null `parent_position` are sub-items and their line totals do + // NOT roll into net directly. Parent totals AUTO-RESOLVE from + // priced sub-items: if any sub-item under a parent has unit_price > 0, + // the parent's effective line_total_minor becomes the sum of those + // sub-items, and the parent's own stored unit_price is ignored. + // Mental model matches the editor — pricing on sub-items implies + // "parent is a header, total derives from what's under it". + const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; + const items = lineItems.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + const isSubItem = li.parent_position != null && li.parent_position !== ''; + return { + position: ensureInt(li.position) || (idx + 1), + quantity: qty, + description: String(li.description || ''), + unit_price_minor: unit, + discount_percent: discount, + line_total_minor: lineTotal, + parent_position: isSubItem ? ensureInt(li.parent_position) : null, + details_text: li.details_text || null, + }; + }); + // Apply the migration-119 hierarchy resolver: rewrites parent + // line_total_minor to sum-of-priced-sub-items where applicable. + // Net is then summed across top-level (resolved) items. + const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); + resolveParentTotalsFromSubItems(items); + let netMinor = 0; + for (const li of items) { + if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); + } + const vatRate = ensureNumber(payload.vatRate, 0); + const vatMinor = Math.round(netMinor * vatRate / 100); + const shippingMinor = ensureInt(payload.shippingAmountMinor); + const totalMinor = netMinor + vatMinor + shippingMinor; + + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); + + // Snapshot the selected payment-term template (net days / Skonto / + // installment plan) onto the invoice itself. Mirrors how the quote + // editor handles this — once snapshotted, edits to the template + // don't retroactively change rendered invoices. Migration 113. + let paymentTermTemplateId = null; + let paymentTermSnapshot = null; + let paymentNetDaysTemplateId = null; + let paymentTimingTemplateId = null; + // Migration 124 — prefer the two split FKs. Compose a snapshot from + // them in the same shape pdfService + scheduler already consume. + // Fall back to the legacy single FK when the caller still uses it. + if (payload.paymentNetDaysTemplateId && payload.paymentTimingTemplateId) { + const [netDays, timing] = await Promise.all([ + trx('payment_net_days_templates').where({ id: payload.paymentNetDaysTemplateId }).first(), + trx('payment_timing_templates').where({ id: payload.paymentTimingTemplateId }).first(), + ]); + if (netDays && timing) { + paymentNetDaysTemplateId = netDays.id; + paymentTimingTemplateId = timing.id; + paymentTermSnapshot = 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 if (payload.paymentTermTemplateId) { + const tpl = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }).first(); + if (tpl) { + paymentTermTemplateId = tpl.id; + paymentTermSnapshot = 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, + }); + } + } + + // Multi-installment auto-route. Priority: + // 1. payload.installments (explicit override from the ad-hoc + // editor panel — wins over any saved template) + // 2. snapshot.installments (loaded from the picked payment-timing + // template above) + // If either yields ≥2 entries we delegate to spawnInstallmentInvoices + // (the same loop used by quote→invoice conversion) and return the + // array of created IDs. Single-installment plans fall through to + // the single-row insert below. + let installmentsForSpawn = null; + if (Array.isArray(payload.installments) && payload.installments.length > 1) { + installmentsForSpawn = payload.installments; + } else if (paymentTermSnapshot) { + const parsedSnap = typeof paymentTermSnapshot === 'string' + ? (() => { try { return JSON.parse(paymentTermSnapshot); } catch { return null; } })() + : paymentTermSnapshot; + if (parsedSnap && Array.isArray(parsedSnap.installments) && parsedSnap.installments.length > 1) { + installmentsForSpawn = parsedSnap.installments; + } + } + if (installmentsForSpawn) { + return await spawnInstallmentInvoices({ + trx, + eventId: payload.eventId || null, + quoteId: payload.sourceQuoteId || null, + customer, + currency, + language, + lineItems: items, + totals: { + net: netMinor, + vatRate, + vat: vatMinor, + shipping: shippingMinor, + total: totalMinor, + }, + installments: installmentsForSpawn, + eventDate: payload.eventDate || null, + adminId, + ccPdfEmail: payload.ccPdfEmail || null, + netDays: resolvedNetDays, + eventName: payload.eventName || null, + eventTimeStart: payload.eventTimeStart || null, + eventTimeEnd: payload.eventTimeEnd || null, + paymentNetDaysTemplateId, + paymentTimingTemplateId, + paymentTermSnapshot, + dealUuid: await resolveDealUuid(trx, payload), + }); + } + + // Claim the sequence number HERE — after the installment auto-route + // has been ruled out. Previously this was at the top of the function + // which leaked one number per multi-installment save (the spawner + // claims its own numbers and never used this one). + const invoiceNumber = await nextInvoiceNumber(); + const row = { + invoice_number: invoiceNumber, + customer_account_id: payload.customerAccountId, + source_quote_id: payload.sourceQuoteId || null, + event_id: payload.eventId || null, + // Inline event snapshot (migration 123). Mirrors quotes — the + // snapshot survives an event rename so an archived invoice keeps + // its original event label for accounting / audit. Optional; + // standalone invoices created without an event will have these + // as null and the renderer simply omits the for-clause. + event_name: payload.eventName || null, + event_date: payload.eventDate || null, + event_time_start: payload.eventTimeStart || null, + event_time_end: payload.eventTimeEnd || null, + language, + currency, + issue_date: issueDate, + due_date: dueDate, + installment_index: ensureInt(payload.installmentIndex), + installment_total: ensureInt(payload.installmentTotal) || 1, + installment_label: payload.installmentLabel || null, + installment_trigger: payload.installmentTrigger || null, + status: scheduledSendAt && scheduledSendAt.getTime() > Date.now() ? 'scheduled' : (payload.sendNow ? 'scheduled' : 'scheduled'), + scheduled_send_at: scheduledSendAt, + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + shipping_amount_minor: shippingMinor, + total_amount_minor: totalMinor, + cc_pdf_email: payload.ccPdfEmail || null, + business_bank_account_id: bank?.id || null, + qr_format: payload.qrFormat || null, + payment_term_template_id: paymentTermTemplateId, + payment_net_days_template_id: paymentNetDaysTemplateId, + payment_timing_template_id: paymentTimingTemplateId, + payment_term_snapshot: paymentTermSnapshot, + // Per-invoice Skonto opt-out (migration 126). Defaults to false + // — invoice inherits the snapshot/global Skonto config unless + // admin explicitly ticks "Disable Skonto" in the editor. + skonto_disabled: Boolean(payload.skontoDisabled), + // Migration 140 — deal_uuid lineage. Priority: explicit payload + // (used by spawnInstallmentInvoices and Storno/reissue callers to + // force a specific value), source quote, source contract, + // otherwise fresh mint. + deal_uuid: await resolveDealUuid(trx, payload), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await trx('invoices').insert(row).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (items.length > 0) { + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(items); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items); + } + + try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`); } catch (_) {} + return { invoiceIds: [invoiceId] }; +} + +/** + * Fan-out helper. Creates one invoice row per installment with the + * right `scheduled_send_at`, sequential invoice numbers, and per- + * slice totals. Used by: + * + * - quoteService.convertToEvent / convertToInvoiceOnly — quote + * conversion with multi-installment payment plans. + * - createInvoice (this file) — when the standalone editor path + * submits an installment array. + * + * Expects to be called inside an existing transaction. + * + * Returns `{ invoiceIds: number[] }` — ordered by installment_index + * so callers can navigate to the first or report N IDs. + * + * The legacy export name `scheduleInvoicesForEvent` is preserved as + * an alias for backward compatibility with quoteService callers; new + * code should reach for the clearer `spawnInstallmentInvoices`. + */ +async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language, + lineItems, totals, installments, eventDate, adminId, + ccPdfEmail, netDays, + eventName, eventTimeStart, eventTimeEnd, + paymentNetDaysTemplateId, paymentTimingTemplateId, + paymentTermSnapshot, dealUuid }) { + // Monthly-billing intercept (migration 128). Quote → invoice + // conversion for a monthly-mode customer doesn't fan out N + // installment invoices — the customer pays one consolidated bill + // per period. Append the line items to the running draft (creating + // it if needed) and return early. The installment / cadence math + // below is bypassed; the quote's payment timing is irrelevant once + // items flow into the monthly accumulator. + if (customer && customer.billing_cadence === 'monthly') { + const draft = await appendToMonthlyDraft({ + customerAccountId: customer.id, + lineItems: (lineItems || []).map((li) => ({ + position: li.position, + quantity: li.quantity, + unit_price_minor: li.unit_price_minor, + discount_percent: li.discount_percent, + description: li.description, + parent_position: li.parent_position, + details_text: li.details_text, + })), + vatRate: totals?.vatRate, + }, customer, adminId, trx); + return { invoiceIds: draft?.id ? [draft.id] : [] }; + } + + // netDays drives the due-date offset on every scheduled invoice + // created here. Defaults to 30 when the caller doesn't pass one; + // callers in quoteService now pass the converting quote's + // payment-term net_days so Net 60 / 90 templates flow through. + const resolvedNetDays = ensureInt(netDays) || 30; + const total = installments.length; + const acceptanceTime = new Date(); + const invoiceIds = []; + + for (let i = 0; i < total; i++) { + const inst = installments[i]; + const percent = ensureNumber(inst.percent, 0); + if (percent <= 0) continue; + + // Each installment carries its own slice of the totals. Round to + // minor units; last installment absorbs rounding drift so the + // total exactly equals the quote total. + let netSlice, vatSlice, shippingSlice, totalSlice; + if (i === total - 1) { + // We computed everything so far; remaining slice closes the gap. + const accNet = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.net) * ensureNumber(x.percent, 0) / 100), 0); + const accVat = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.vat) * ensureNumber(x.percent, 0) / 100), 0); + const accShipping = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.shipping) * ensureNumber(x.percent, 0) / 100), 0); + const accTotal = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.total) * ensureNumber(x.percent, 0) / 100), 0); + netSlice = ensureInt(totals.net) - accNet; + vatSlice = ensureInt(totals.vat) - accVat; + shippingSlice = ensureInt(totals.shipping) - accShipping; + totalSlice = ensureInt(totals.total) - accTotal; + } else { + netSlice = Math.round(ensureInt(totals.net) * percent / 100); + vatSlice = Math.round(ensureInt(totals.vat) * percent / 100); + shippingSlice = Math.round(ensureInt(totals.shipping) * percent / 100); + totalSlice = Math.round(ensureInt(totals.total) * percent / 100); + } + + let scheduledSendAt = computeScheduledSendAt(inst.trigger, inst.offset_days, eventDate, acceptanceTime); + // Per-customer billing cadence override: monthly / quarterly + // customers don't pay per-event — snap to the next period boundary. + if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { + scheduledSendAt = snapToNextBillingCycle(scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day); + } + + // `after_delivery` invoices wait for the admin to confirm photos + // have actually been delivered before they fire — we can't infer + // that automatically from a date. Mark them `pending_delivery` + // with no scheduled_send_at; the scheduler only picks rows in + // status `scheduled`, so they sit idle until the admin clicks + // "Release for delivery" on the invoice detail page. + const isDeliveryTrigger = inst.trigger === 'after_delivery'; + const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; + const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + + const invoiceNumber = await nextInvoiceNumber(); + const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); + + const row = { + invoice_number: invoiceNumber, + customer_account_id: customer.id, + source_quote_id: quoteId, + event_id: eventId, + // Inline event snapshot carried over from the source quote + // (migration 123). Mirrors how event_date is already carried — + // a converted invoice should keep the event reference even if + // the linked event is later renamed or deleted. + event_name: eventName || null, + event_date: eventDate || null, + event_time_start: eventTimeStart || null, + event_time_end: eventTimeEnd || null, + language, + currency, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + installment_index: i, + installment_total: total, + installment_label: inst.label || `Installment ${i + 1}/${total}`, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + net_amount_minor: netSlice, + vat_rate: ensureNumber(totals.vatRate, 0), + vat_amount_minor: vatSlice, + shipping_amount_minor: shippingSlice, + total_amount_minor: totalSlice, + cc_pdf_email: ccPdfEmail || null, + // Migration 124 — carry the split payment-term FKs over from + // the source quote so the converted invoice is editable (when + // it eventually unlocks) with the same orthogonal split. The + // snapshot itself is the legal record; the FKs are convenience. + payment_net_days_template_id: paymentNetDaysTemplateId || null, + payment_timing_template_id: paymentTimingTemplateId || null, + payment_term_snapshot: paymentTermSnapshot + ? (typeof paymentTermSnapshot === 'string' + ? paymentTermSnapshot + : JSON.stringify(paymentTermSnapshot)) + : null, + // Migration 140 — every installment sibling shares one deal_uuid + // (passed in from the converting caller, ultimately the source + // quote's value). Defensive fallback to a fresh UUID if the + // caller didn't pass one — shouldn't happen on a migrated + // install but keeps the column non-null. + deal_uuid: dealUuid || crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + + const inserted = await trx('invoices').insert(row).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Line items: copy from the quote so the customer sees what they + // actually agreed to, not a generic "Gesamtbetrag" placeholder. + // Two modes: + // - Single-installment (100%): clone every quote line item + // verbatim. The invoice totals already match the quote's. + // - Multi-installment (split payment): clone the quote lines + // but mark the invoice with the installment context. We pro- + // rate by inserting one extra line at the bottom that adjusts + // to the installment slice — keeps the per-line description + // visible while the total still equals the pro-rata amount. + const sourceLines = Array.isArray(lineItems) ? lineItems : []; + if (sourceLines.length === 0) { + // Fallback for the (rare) case where the quote has no line + // items — fall back to the legacy "Installment N/M" line so + // we still produce a sensible invoice. + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: 1, + quantity: 1, + description: inst.label || `Installment ${i + 1}/${total}`, + unit_price_minor: netSlice, + discount_percent: 0, + line_total_minor: netSlice, + created_at: new Date(), + updated_at: new Date(), + }); + } else { + // Clone each quote line as-is, preserving its original `position` + // so the sub-item hierarchy carries over. Source lines already + // have `parent_position` populated by getQuoteById's self-join, + // so the same value reused on the new invoice points at the + // correct (also-cloned) parent. insertLineItemsHierarchical + // resolves position → new parent_line_item_id during the + // two-phase insert. Migration 119. + const cloned = sourceLines.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, cloned); + + // For split payments add an explicit "Installment X/Y (Z%)" + // adjustment line that reconciles the cloned line totals to + // the actual invoice net (which is the pro-rata slice). The + // line carries the difference as a negative if the slice is + // less than the quote total (typical), or positive on the + // final installment if rounding nudged the other way. + // + // The adjustment ONLY considers top-level cloned lines — + // sub-items don't contribute to net so they can't appear in + // the reconciliation sum. + if (total > 1) { + const clonedSum = cloned + .filter((x) => x.parent_position == null) + .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); + const adjustment = netSlice - clonedSum; + if (adjustment !== 0) { + const installmentLabel = inst.label || `Installment ${i + 1}/${total}`; + const maxPosition = cloned.reduce((m, x) => Math.max(m, x.position), 0); + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: maxPosition + 1, + quantity: 1, + description: `${installmentLabel} (${percent}% — ${i + 1}/${total})`, + unit_price_minor: adjustment, + discount_percent: 0, + line_total_minor: adjustment, + parent_line_item_id: null, + details_text: null, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + } + + try { + await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, + eventId, `admin:${adminId}`); + } catch (_) {} + invoiceIds.push(invoiceId); + } + return { invoiceIds }; +} + +// Backward-compat alias — older callers reference this name. +const scheduleInvoicesForEvent = spawnInstallmentInvoices; + +// ---------------------------------------------------------------------- +// updateInstallmentPlan — atomic post-spawn plan edit +// ---------------------------------------------------------------------- + +// Statuses that are still pre-customer (no PDF has gone out the door). +// Both `scheduled` and `pending_delivery` are reshapable; anything else +// belongs to the audit trail and can't be silently mutated. +const EDITABLE_INSTALLMENT_STATUSES = new Set(['scheduled', 'pending_delivery']); + +const VALID_INSTALLMENT_TRIGGERS = new Set([ + 'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date', +]); + +/** + * Compute one slice of a plan total. Matches the rounding rule used by + * spawnInstallmentInvoices — every slice except the last is a rounded + * percent share; the last slice absorbs rounding drift so the per-slice + * sums exactly equal the plan total. + */ +function computeSliceTotals(installments, totals, i) { + const lastIndex = installments.length - 1; + const pct = ensureNumber(installments[i].percent, 0); + if (i < lastIndex) { + return { + net: Math.round(ensureInt(totals.net) * pct / 100), + vat: Math.round(ensureInt(totals.vat) * pct / 100), + shipping: Math.round(ensureInt(totals.shipping) * pct / 100), + total: Math.round(ensureInt(totals.total) * pct / 100), + }; + } + const acc = installments.slice(0, i).reduce((s, x) => { + const p = ensureNumber(x.percent, 0); + return { + net: s.net + Math.round(ensureInt(totals.net) * p / 100), + vat: s.vat + Math.round(ensureInt(totals.vat) * p / 100), + shipping: s.shipping + Math.round(ensureInt(totals.shipping) * p / 100), + total: s.total + Math.round(ensureInt(totals.total) * p / 100), + }; + }, { net: 0, vat: 0, shipping: 0, total: 0 }); + return { + net: ensureInt(totals.net) - acc.net, + vat: ensureInt(totals.vat) - acc.vat, + shipping: ensureInt(totals.shipping) - acc.shipping, + total: ensureInt(totals.total) - acc.total, + }; +} + +/** + * Throws AppError on invalid input. Exposed for the route layer to + * surface as 400 before opening a transaction. + */ +function validateInstallmentPlanInput(installments) { + if (!Array.isArray(installments) || installments.length === 0) { + throw new AppError('installments must be a non-empty array', 400); + } + let sum = 0; + for (let i = 0; i < installments.length; i++) { + const inst = installments[i] || {}; + const pct = ensureNumber(inst.percent, NaN); + if (!Number.isFinite(pct) || pct < 0 || pct > 100) { + throw new AppError(`Row ${i + 1}: percent must be between 0 and 100`, 400); + } + if (!VALID_INSTALLMENT_TRIGGERS.has(inst.trigger)) { + throw new AppError(`Row ${i + 1}: invalid trigger '${inst.trigger}'`, 400); + } + const off = ensureInt(inst.offset_days); + if (!Number.isFinite(off)) { + throw new AppError(`Row ${i + 1}: offset_days must be an integer`, 400); + } + sum += pct; + } + if (Math.abs(sum - 100) > 0.001) { + throw new AppError( + `Installment percents must sum to 100 (got ${sum})`, + 400, + 'PERCENT_SUM_INVALID', + ); + } +} + +/** + * Heuristic — spawnInstallmentInvoices appends a reconciliation line + * with a stable description shape like "Anzahlung (30% — 1/3)". The + * em-dash is U+2014 so the regex won't match plain hyphens used in + * admin-authored line descriptions. + * + * We could harden this with an `is_reconciliation_line` column, but + * the cost of a schema change isn't worth the residual edge (admins + * don't edit reconciliation lines today). + */ +function isReconciliationLineItem(li) { + if (!li || typeof li.description !== 'string') return false; + return / \(\d+(?:\.\d+)?% — \d+\/\d+\)$/.test(li.description); +} + +/** + * Replace (or insert) the reconciliation line on an invoice so its + * description matches the new label/percent and the line's amount + * closes the gap between the cloned-quote-line subtotal and the + * sibling's net slice. Symmetric with the inline logic in spawn. + * + * `topLineSubtotal` is the sum of non-reconciliation, top-level line + * items already on the invoice — passed in so callers reading the row + * once don't have to re-query. + */ +async function replaceReconciliationLine( + trx, invoiceId, { label, percent, index, total, netSlice, topLineSubtotal }, +) { + const all = await trx('invoice_line_items') + .where({ invoice_id: invoiceId }) + .orderBy('position', 'asc'); + for (const li of all) { + if (isReconciliationLineItem(li)) { + await trx('invoice_line_items').where({ id: li.id }).del(); + } + } + if (total <= 1) return; + + const nonRecon = all.filter((x) => !isReconciliationLineItem(x)); + const subtotal = topLineSubtotal != null + ? topLineSubtotal + : nonRecon.filter((x) => x.parent_position == null) + .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); + const adjustment = netSlice - subtotal; + if (adjustment === 0) return; + + const maxPosition = nonRecon.reduce( + (m, x) => Math.max(m, ensureInt(x.position)), 0, + ); + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: maxPosition + 1, + quantity: 1, + description: `${label} (${percent}% — ${index + 1}/${total})`, + unit_price_minor: adjustment, + discount_percent: 0, + line_total_minor: adjustment, + parent_line_item_id: null, + details_text: null, + created_at: new Date(), + updated_at: new Date(), + }); +} + +/** + * Atomically reshape an installment plan after siblings have spawned. + * The plan is the unit of edit: percents / count / triggers all change + * together in one transaction. Mutating individual siblings stays on + * the existing PUT /admin/invoices/:id path. + * + * Guards: + * - dealUuid must exist + own ≥1 invoice (else 404) + * - all siblings must be in EDITABLE_INSTALLMENT_STATUSES (else 409 + * `INVOICE_LOCKED`) + * - no Storno on the deal (else 409 `PLAN_HAS_STORNO`) + * - new plan validated by validateInstallmentPlanInput + * + * Algorithm: + * - Plan total = sum of existing siblings' totals (captures any + * per-sibling edits since spawn). + * - Reused siblings (i < min(old, new)): UPDATE in place — preserves + * id + invoice_number, so sequence numbers aren't burned. + * - Extra new rows (new > old): INSERT — claims a fresh invoice_number + * per row; clones canonical (non-reconciliation) line items from + * existing[0] so each new sibling carries the quote lines. + * - Trim rows (new < old): DELETE — claimed sequence numbers ARE lost + * (document_sequences has no release path, and that's intentional + * for §14 UStG continuity). + * + * Returns `{ invoiceIds, kept, created, deleted }`. + */ +async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) { + if (!dealUuid) throw new AppError('dealUuid is required', 400); + validateInstallmentPlanInput(installments); + + const existing = await trx('invoices') + .where({ deal_uuid: dealUuid }) + .orderBy('installment_index', 'asc'); + + if (existing.length === 0) { + throw new AppError('No invoices found for this deal', 404); + } + const isMultiInstallment = existing.some((r) => ensureInt(r.installment_total) > 1); + if (!isMultiInstallment) { + throw new AppError( + 'This deal is not an installment plan', + 400, + 'NOT_INSTALLMENT_PLAN', + ); + } + for (const row of existing) { + if (row.kind === 'storno') { + throw new AppError( + `Plan contains a Storno (${row.invoice_number}) — reshape refused`, + 409, + 'PLAN_HAS_STORNO', + ); + } + if (!EDITABLE_INSTALLMENT_STATUSES.has(row.status)) { + throw new AppError( + `Cannot reshape — invoice ${row.invoice_number} is '${row.status}'`, + 409, + 'INVOICE_LOCKED', + ); + } + } + + const totals = existing.reduce((acc, r) => ({ + net: acc.net + ensureInt(r.net_amount_minor), + vat: acc.vat + ensureInt(r.vat_amount_minor), + shipping: acc.shipping + ensureInt(r.shipping_amount_minor), + total: acc.total + ensureInt(r.total_amount_minor), + vatRate: ensureNumber(r.vat_rate, acc.vatRate), + }), { net: 0, vat: 0, shipping: 0, total: 0, vatRate: 0 }); + + const sample = existing[0]; // canonical event + customer + payment-term shape + + // netDays inferred from sample's issue → due gap so the new rows + // honour the same payment-term the customer agreed to. Falls back + // to 30 when either column is missing. + const inferredNetDays = sample.due_date && sample.issue_date + ? Math.round((new Date(sample.due_date) - new Date(sample.issue_date)) / (24 * 60 * 60 * 1000)) + : 30; + const netDays = Number.isFinite(inferredNetDays) && inferredNetDays > 0 ? inferredNetDays : 30; + + const eventDate = sample.event_date || null; + const customer = sample.customer_account_id + ? await trx('customer_accounts').where({ id: sample.customer_account_id }).first() + : null; + + // Cache canonical (non-reconciliation) line items from existing[0] + // for cloning into any newly-created siblings. + let canonicalLineItems = null; + const acceptanceTime = new Date(); + const newCount = installments.length; + const reusableCount = Math.min(existing.length, newCount); + + const kept = []; + const created = []; + const deleted = []; + + for (let i = 0; i < newCount; i++) { + const inst = installments[i]; + const slice = computeSliceTotals(installments, totals, i); + + let scheduledSendAt = computeScheduledSendAt( + inst.trigger, inst.offset_days, eventDate, acceptanceTime, + ); + if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { + scheduledSendAt = snapToNextBillingCycle( + scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day, + ); + } + const isDeliveryTrigger = inst.trigger === 'after_delivery'; + const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; + const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + const dueDate = computeDueDate(scheduledSendAt, netDays).toISOString().slice(0, 10); + const label = inst.label || `Installment ${i + 1}/${newCount}`; + + if (i < reusableCount) { + const existingRow = existing[i]; + await trx('invoices').where({ id: existingRow.id }).update({ + installment_index: i, + installment_total: newCount, + installment_label: label, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + net_amount_minor: slice.net, + vat_amount_minor: slice.vat, + shipping_amount_minor: slice.shipping, + total_amount_minor: slice.total, + updated_at: new Date(), + }); + await replaceReconciliationLine(trx, existingRow.id, { + label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, + }); + kept.push(existingRow.id); + continue; + } + + // New sibling — clone canonical lines from existing[0] on first + // use, then reuse the cached copy for any further new siblings. + if (canonicalLineItems === null) { + const sourceLines = await trx('invoice_line_items') + .where({ invoice_id: existing[0].id }) + .orderBy('position', 'asc'); + canonicalLineItems = sourceLines.filter((li) => !isReconciliationLineItem(li)); + } + + const invoiceNumber = await nextInvoiceNumber(trx); + const row = { + invoice_number: invoiceNumber, + customer_account_id: sample.customer_account_id, + source_quote_id: sample.source_quote_id, + event_id: sample.event_id, + event_name: sample.event_name, + event_date: sample.event_date, + event_time_start: sample.event_time_start, + event_time_end: sample.event_time_end, + language: sample.language, + currency: sample.currency, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + installment_index: i, + installment_total: newCount, + installment_label: label, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + net_amount_minor: slice.net, + vat_rate: ensureNumber(sample.vat_rate, 0), + vat_amount_minor: slice.vat, + shipping_amount_minor: slice.shipping, + total_amount_minor: slice.total, + cc_pdf_email: sample.cc_pdf_email || null, + payment_net_days_template_id: sample.payment_net_days_template_id || null, + payment_timing_template_id: sample.payment_timing_template_id || null, + payment_term_snapshot: sample.payment_term_snapshot || null, + deal_uuid: dealUuid, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await trx('invoices').insert(row).returning('id'); + const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (canonicalLineItems.length > 0) { + const cloned = canonicalLineItems.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', newId, cloned); + } + + await replaceReconciliationLine(trx, newId, { + label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, + }); + + try { + await logActivity('invoice_scheduled', { + invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape', + }, sample.event_id, `admin:${adminId}`); + } catch (_) {} + + created.push(newId); + } + + // Trim extras (only fires when newCount < existing.length). + for (let i = newCount; i < existing.length; i++) { + const oldRow = existing[i]; + await trx('invoice_line_items').where({ invoice_id: oldRow.id }).del(); + await trx('invoices').where({ id: oldRow.id }).del(); + deleted.push(oldRow.id); + } + + try { + await logActivity('installment_plan_updated', { + dealUuid, newCount, + kept: kept.length, created: created.length, deleted: deleted.length, + }, sample.event_id, `admin:${adminId}`); + } catch (_) {} + + return { + invoiceIds: [...kept, ...created], + kept, created, deleted, + }; +} + +async function buildInvoiceRenderContext(invoice, lineItems) { + const { profile } = await businessProfileService.getProfile(); + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + const bank = invoice.business_bank_account_id + ? await db('business_bank_accounts').where({ id: invoice.business_bank_account_id }).first() + : await businessProfileService.resolveBankAccountForCurrency(invoice.currency); + + // Resolve the PDF logo to a verified absolute disk path. The + // helper exhaustively tries: + // 1. business_profile.logo_path + // 2. app_settings.branding_logo_path (absolute multer path) + // 3. app_settings.branding_logo_url (URL path) + // …and for each, generates ~7 candidate disk locations before + // giving up. Returns null + logs a detailed warning when nothing + // resolves. Already-verified path means the renderer never has + // to second-guess. + const { resolveLogoFile } = require('../utils/resolveLogoFile'); + const resolvedLogoPath = await resolveLogoFile(profile); + + // QR format resolution order (per-invoice override → profile + // default → none) gated by the global enable toggle. The earlier + // version had an operator-precedence bug that effectively dropped + // the profile default; this rewrites it as plain if/else for + // readability + correctness. + const qrGloballyEnabled = (await getAppSetting('crm_invoices_qr_enabled')) !== false; + let resolvedQrFormat = 'none'; + if (qrGloballyEnabled) { + resolvedQrFormat = invoice.qr_format || profile?.default_qr_format || 'none'; + } + + // Resolve the payment-term snapshot to thread Skonto + net-days into + // the PDF's "Zahlungsbedingungen" block. Three sources, in priority + // order: + // 1. The invoice's OWN snapshot (migration 113 — set when admin + // picks a template directly in the New Invoice form). + // 2. The originating quote's snapshot, if this invoice was + // created from one. + // 3. The global CRM defaults (settings tab) — `crm_invoices_*`. + // Both layers above are wrapped in `paymentTerm` exactly as + // quoteService builds it so pdfService.drawPaymentBlock renders + // the same block on both document types. + let paymentTerm = null; + + // Invoice-level snapshot wins when set. + if (invoice.payment_term_snapshot) { + const snapshot = typeof invoice.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() + : invoice.payment_term_snapshot; + if (snapshot) { + paymentTerm = { + description: snapshot.description, + netDays: snapshot.net_days, + skontoPercent: snapshot.skonto_percent, + skontoWithinDays: snapshot.skonto_within_days, + }; + } + } + + // Load the source quote once — used for the payment-term snapshot + // fallback AND for the "Bezug: Angebot Q-..." reference line on + // the invoice PDF. We deliberately keep invoice numbers on a + // strict monotonic sequence (tax compliance) and surface the link + // as a text reference rather than mirroring the number. + let sourceQuote = null; + if (invoice.source_quote_id) { + sourceQuote = await db('quotes').where({ id: invoice.source_quote_id }).first(); + if (!paymentTerm && sourceQuote?.payment_term_snapshot) { + const snapshot = typeof sourceQuote.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(sourceQuote.payment_term_snapshot); } catch { return null; } })() + : sourceQuote.payment_term_snapshot; + if (snapshot) { + paymentTerm = { + description: snapshot.description, + netDays: snapshot.net_days, + skontoPercent: snapshot.skonto_percent, + skontoWithinDays: snapshot.skonto_within_days, + }; + } + } + } + // Globally-default Skonto values, always loaded. Used either to + // FILL a partial source-quote snapshot OR to seed the whole + // paymentTerm when there's no source quote. Both reads survive + // missing rows (returns null), unset values (NaN guarded), and + // string-encoded numbers from app_settings. + const defaultSkontoPercentRaw = await getAppSetting('crm_invoices_skonto_percent_default'); + const defaultSkontoDaysRaw = await getAppSetting('crm_invoices_skonto_business_days'); + const defaultSkontoPercent = Number.isFinite(Number(defaultSkontoPercentRaw)) && Number(defaultSkontoPercentRaw) > 0 + ? Number(defaultSkontoPercentRaw) : null; + const defaultSkontoDays = Number.isFinite(Number(defaultSkontoDaysRaw)) && Number(defaultSkontoDaysRaw) > 0 + ? parseInt(defaultSkontoDaysRaw, 10) : null; + + if (paymentTerm) { + // The source quote's snapshot may carry only some of the Skonto + // fields (e.g. when the template predates Skonto support); fill + // missing parts from the global defaults so the PDF still shows + // the row whenever there's enough info to render it. + if (paymentTerm.skontoPercent == null && defaultSkontoPercent != null) { + paymentTerm.skontoPercent = defaultSkontoPercent; + } + if (paymentTerm.skontoWithinDays == null && defaultSkontoDays != null) { + paymentTerm.skontoWithinDays = defaultSkontoDays; + } + } else { + // Ad-hoc invoice (no source quote). Build the paymentTerm from + // the global defaults. Renders only when BOTH percent + days are + // set + > 0 (pdfService.drawPaymentBlock guards on that). + paymentTerm = { + description: null, + netDays: 30, + skontoPercent: defaultSkontoPercent, + skontoWithinDays: defaultSkontoDays, + }; + } + + // Per-invoice Skonto opt-out (migration 126). The + // `resolveSkontoPercentForInvoice` helper above already respects + // this for payment-tracking surfaces, but the PDF render path was + // assembling `paymentTerm.skontoPercent/Days` from the snapshot or + // global defaults and ignoring the flag — so ticking "Disable + // Skonto" on the invoice cleared it from "Paid with Skonto" buttons + // but still printed the discount row on the PDF. Zero out both + // fields here so pdfService.drawPaymentBlock's + // `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays` + // guard suppresses the row. + if (invoice.skonto_disabled) { + paymentTerm.skontoPercent = null; + paymentTerm.skontoWithinDays = null; + } + + // Global date format from Settings → General (general_date_format). + // Stored as JSON `{ format, locale }`; missing or malformed entries + // fall back to DD.MM.YYYY in the renderer. + 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 default */ } + + return { + locale: invoice.language || profile?.default_locale || 'de', + currency: invoice.currency, + qrFormat: resolvedQrFormat, + dateFormat, + // Shared issuer + recipient builders. Invoices skip the quote-only + // payment-block toggles; the invoice PDF always shows the payment + // block. See backend/src/services/_renderContext.js. + issuer: buildIssuerBlock(profile, resolvedLogoPath), + recipient: buildRecipientBlock(profile, customer), + bank: bank ? { + accountHolder: bank.account_holder || profile?.company_name, + iban: bank.iban, bic: bank.bic, currency: bank.currency, + } : null, + paymentTerm, + lineItems: lineItems.map((li) => ({ + quantity: li.quantity, + description: li.description, + unitPriceMinor: li.unit_price_minor, + discountPercent: li.discount_percent, + lineTotalMinor: li.line_total_minor, + // Migration 119 — hierarchy + notes flow through to PDF. + parentLineItemId: li.parent_line_item_id || null, + parentPosition: li.parent_position == null ? null : Number(li.parent_position), + detailsText: li.details_text || null, + })), + totals: { + netAmountMinor: invoice.net_amount_minor, + vatRate: invoice.vat_rate, + vatAmountMinor: invoice.vat_amount_minor, + shippingAmountMinor: invoice.shipping_amount_minor, + totalAmountMinor: invoice.total_amount_minor, + // Mahngebühr surfaced to the totals box (renders a row + // between VAT and the grand-total divider) and folded + // into the displayed Grand Total when > 0. Reminder + // invoices after level 2 carry a non-zero value. + lateFeeAmountMinor: invoice.late_fee_amount_minor || 0, + }, + doc: { + // Document type discriminator. `'invoice'` (default) renders + // the standard invoice layout. `'storno'` switches the title + // to "Stornorechnung", forces the mandatory "Storno zu …" + // reference line, displays signed totals, and suppresses the + // payment terms / IBAN / QR-bill sections (cancellation + // documents aren't payment instruments). + kind: invoice.kind || 'invoice', + invoiceNumber: invoice.invoice_number, + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + totalAmountMinor: invoice.total_amount_minor, + lateFeeMinor: invoice.late_fee_amount_minor, + // Reminder level — drives Skonto suppression on second + // reminders (no early-payment discount once the customer + // is in dunning). + reminderLevel: invoice.reminder_level || 0, + // PDF renderer draws "Bezug: Angebot Q-..." under the title + // when set. Empty/null suppresses the line (standalone invoice). + sourceQuoteNumber: sourceQuote?.quote_number || null, + // When this invoice replaces a previously-cancelled one + // (migration 114, reissue workflow), the renderer stamps a + // second reference line: "Bezug: Ersetzt Rechnung R-XXXX vom + // DATE". + replacesInvoice: await (async () => { + if (!invoice.replaces_invoice_id) return null; + const prior = await db('invoices') + .where({ id: invoice.replaces_invoice_id }) + .select('invoice_number', 'issue_date').first(); + return prior + ? { number: prior.invoice_number, issueDate: prior.issue_date } + : null; + })(), + // Storno reference — populated only on `kind='storno'` rows. + // The renderer turns it into the mandatory "Storno zu Rechnung + // R-XXXX vom DATE" line under the title. Drives §14c-defensible + // traceability: the customer sees explicitly what was reversed. + cancelsInvoice: await (async () => { + if (!invoice.cancels_invoice_id) return null; + const prior = await db('invoices') + .where({ id: invoice.cancels_invoice_id }) + .select('invoice_number', 'issue_date').first(); + return prior + ? { number: prior.invoice_number, issueDate: prior.issue_date } + : null; + })(), + }, + }; +} + +async function renderInvoicePdfBuffer(invoiceId) { + const data = await getInvoiceById(invoiceId); + if (!data) throw new AppError('Invoice not found', 404); + // Imported (historical) invoices store the original PDF on disk + // — short-circuit the renderer and stream the file untouched so + // legal documents stay byte-identical to the source. Path is + // stored relative to STORAGE_PATH but we accept absolute too. + if (data.invoice.imported_pdf_path) { + const fs = require('fs'); + const path = require('path'); + const { getStoragePath } = require('../config/storage'); + const raw = String(data.invoice.imported_pdf_path).trim(); + const candidates = [ + path.isAbsolute(raw) ? raw : null, + path.join(getStoragePath(), raw.replace(/^\/+/, '')), + ].filter(Boolean); + const found = candidates.find((p) => { + try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } + }); + if (!found) { + throw new AppError('Imported invoice PDF is missing on disk', 410); + } + return fs.readFileSync(found); + } + const ctx = await buildInvoiceRenderContext(data.invoice, data.lineItems); + return await pdfService.renderInvoiceToBuffer(ctx); +} + +async function renderInvoicePdfFromPayload(payload) { + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; + // Migration 119 — preview must match the saved-invoice math: + // - Compute every row's raw line_total_minor (qty × unit × discount). + // - Then resolveParentTotalsFromSubItems rewrites each parent's + // line_total to the sum of its priced sub-items (parent's own + // unit_price is ignored when any sub-item has a price). + // - Net sums TOP-LEVEL items only (parent_position == null). + // Without these two steps, the preview shows the parent at 0 and + // double-counts sub-items into net, neither of which matches the + // values the renderer would produce for the persisted invoice. + const items = lineItems.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + return { ...li, position: li.position || idx + 1, line_total_minor: lineTotal }; + }); + const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); + resolveParentTotalsFromSubItems(items); + let netMinor = 0; + for (const it of items) { + if (it.parent_position == null || it.parent_position === '') { + netMinor += ensureInt(it.line_total_minor); + } + } + const vatRate = ensureNumber(payload.vatRate, 0); + const vatMinor = Math.round(netMinor * vatRate / 100); + const shippingMinor = ensureInt(payload.shippingAmountMinor); + const totalMinor = netMinor + vatMinor + shippingMinor; + const fakeInvoice = { + invoice_number: 'PREVIEW', + customer_account_id: payload.customerAccountId, + language: payload.language || customer?.preferred_language || 'de', + currency: (payload.currency || 'CHF').toUpperCase(), + issue_date: payload.issueDate || new Date().toISOString().slice(0, 10), + due_date: payload.dueDate || new Date(Date.now() + 30 * 86400e3).toISOString().slice(0, 10), + business_bank_account_id: payload.businessBankAccountId, + qr_format: payload.qrFormat, + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + shipping_amount_minor: shippingMinor, + total_amount_minor: totalMinor, + }; + const ctx = await buildInvoiceRenderContext(fakeInvoice, items); + return await pdfService.renderInvoiceToBuffer(ctx); +} + +/** + * Send an invoice email + PDF. Flips status scheduled → sent. + */ +async function sendInvoice(id, adminId) { + const data = await getInvoiceById(id); + if (!data) throw new AppError('Invoice not found', 404); + const { invoice, lineItems } = data; + // Stornorechnungen go through their own send path — different + // email template, different variables, different PDF render + // branch. The scheduler's flush loop hits this entry point for + // every row in status='scheduled', so the dispatch lives here. + if (invoice.kind === 'storno') { + return await sendStorno(id, adminId); + } + if (!['scheduled', 'sent', 'overdue'].includes(invoice.status)) { + throw new AppError(`Cannot send invoice with status '${invoice.status}'`, 409); + } + // Monthly-draft guard (migration 128). Rows flagged + // is_monthly_draft=true accumulate line items across the period + // and must ONLY be issued via triggerMonthlyBillNow / the scheduled + // monthly flush — both clear the flag before re-entering this + // function. Without this guard, admin clicks on a draft's Send + // button would ship the running accumulator early AND leave the + // flag set, so subsequent createInvoice calls would silently + // append onto the same already-sent row. + if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) { + throw new AppError( + 'This invoice is a monthly draft — use "Trigger invoice now" on the customer detail page, or wait for the scheduled cycle day.', + 409, 'MONTHLY_DRAFT_NOT_SENDABLE', + ); + } + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + ensureCustomerCanBill(customer); + + // Re-sync the invoice's language from the customer's current + // preferred_language at send time when the invoice has never been + // sent. Picks up admin language changes made between create and + // send (notable for monthly drafts that accumulate for ~30 days, + // and for any standalone scheduled invoice where admin updated the + // customer record after authoring). Sent / overdue invoices keep + // their existing language because they're legal records — the + // rendered PDF is the source of truth from the moment it ships. + if (invoice.status === 'scheduled' && customer.preferred_language + && customer.preferred_language !== invoice.language) { + await db('invoices').where({ id }).update({ + language: customer.preferred_language, + updated_at: new Date(), + }); + invoice.language = customer.preferred_language; + } + + const ctx = await buildInvoiceRenderContext(invoice, lineItems); + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + + // Persist PDF snapshot. + const fs = require('fs'); + const path = require('path'); + const year = new Date(invoice.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + fs.mkdirSync(root, { recursive: true }); + const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`); + fs.writeFileSync(pdfPath, buffer); + + const newStatus = invoice.status === 'overdue' ? 'overdue' : 'sent'; + await db('invoices').where({ id }).update({ + status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(), + }); + + await emailProcessor.queueEmail(invoice.event_id || null, customer.email, 'invoice_sent', { + invoice_number: invoice.invoice_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + event_name: invoice.event_name || '', + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale), + due_date: formatShortDate(invoice.due_date), + installment_label: invoice.installment_label || '', + installment_index: invoice.installment_index + 1, + installment_total: invoice.installment_total, + cc: invoice.cc_pdf_email || undefined, + attachments: [{ + filename: `${invoice.invoice_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + + try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + return { sent: true, pdfPath }; +} + +/** + * Record a payment against an invoice. Supports partial payments + * (multiple rows accumulate into `paid_amount_minor`). Status flips + * to `paid` once the running total meets or exceeds total_amount_minor. + */ +async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, notes, skontoApplied }, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.status === 'cancelled') { + throw new AppError('Cannot mark a cancelled invoice as paid', 409); + } + const amount = ensureInt(amountMinor); + if (amount <= 0) { + throw new AppError('amount must be > 0', 400); + } + // Skonto bookkeeping (migration 126). When the admin ticks "Paid + // with Skonto" we store both the flag AND the absolute discount + // in minor units. Computing the discount here (instead of in the + // renderer at report time) means the value is frozen against + // later template/percentage edits — the tax-report row stays + // accurate for years. + const skontoFlag = Boolean(skontoApplied); + const skontoAmountMinor = skontoFlag + ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) + : null; + + return await db.transaction(async (trx) => { + await trx('invoice_payment_log').insert({ + invoice_id: id, + amount_minor: amount, + paid_at: paidAt ? new Date(paidAt) : new Date(), + payment_method: paymentMethod || null, + reference: reference || null, + notes: notes || null, + recorded_by_admin_id: adminId, + skonto_applied: skontoFlag, + skonto_amount_minor: skontoAmountMinor, + created_at: new Date(), + }); + const sumRow = await trx('invoice_payment_log').where({ invoice_id: id }).sum('amount_minor as total').first(); + const total = ensureInt(sumRow?.total || 0); + // Consider the invoice paid when the recorded payments cover the + // invoice total. The late fee is NOT added to the threshold here + // — admins frequently waive it once the customer actually pays + // (and chasing the extra 25 CHF after a 1500 CHF invoice clears + // makes nobody happy). Admin can record a separate payment_log + // row if they did collect the fee; status flips to paid the + // moment the principal is covered. + // + // Skonto path (migration 126): when the admin flagged this + // payment as Skonto-applied, the discounted amount equals the + // expected payment — flip to 'paid' even though paid_amount_minor + // is strictly less than total_amount_minor. Without this branch + // the invoice would sit in 'sent' or 'overdue' forever despite + // being legitimately settled. + const skontoEffectiveTotal = skontoFlag + ? ensureInt(invoice.total_amount_minor) - (skontoAmountMinor || 0) + : ensureInt(invoice.total_amount_minor); + const isFull = total >= skontoEffectiveTotal; + + const update = { + paid_amount_minor: total, + payment_method: paymentMethod || invoice.payment_method, + payment_reference: reference || invoice.payment_reference, + updated_at: new Date(), + }; + if (isFull) { + update.status = 'paid'; + update.paid_at = paidAt ? new Date(paidAt) : new Date(); + } + await trx('invoices').where({ id }).update(update); + + try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment', + { invoiceId: id, amountMinor: amount, totalPaidMinor: total }, + invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Migration 127 — admin payment-received notification. Fires only + // on the transition into 'paid' so admins don't get duplicate + // emails when additional payment-log rows are recorded after the + // invoice already cleared (rare but possible — e.g. late-fee + // top-up). Queued after the transaction so a failed email never + // rolls back a recorded payment. Carried Skonto context lets the + // template show the discount line conditionally. + if (isFull && invoice.status !== 'paid') { + try { + await queueInvoicePaidAdminNotification({ + invoice, + paidTotalMinor: total, + paymentMethod: paymentMethod || invoice.payment_method || null, + paymentReference: reference || invoice.payment_reference || null, + paidAt: paidAt ? new Date(paidAt) : new Date(), + skontoApplied: skontoFlag, + skontoAmountMinor: skontoAmountMinor || 0, + }); + } catch (err) { + // Notification is best-effort — don't surface a 500 to the + // admin when the recorded payment itself succeeded. + logger.warn('invoice_paid admin notification failed to queue', { invoiceId: id, err: err.message }); + } + } + + return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; + }); +} + +/** + * Materialise a Stornorechnung (cancellation invoice) for an already- + * issued original. Atomic: + * 1. Insert a new `invoices` row with `kind='storno'`, totals + * negated, no due_date / payment terms / bank account / QR, + * and `cancels_invoice_id` pointing at the original. + * 2. Snapshot the original's line items at full positive amounts + * (the sign is carried by the row-level totals; the renderer + * flips line totals visually for `kind='storno'`). Preserves + * the migration-119 sub-item hierarchy via parent_position → + * parent_line_item_id resolution in `insertLineItemsHierarchical`. + * 3. Flip the original to `status='cancelled'` and pin its + * `cancellation_storno_id` so the admin detail view can render + * a "Cancelled by Storno S-XXXX" banner. + * + * Returns the Storno's id. The caller is responsible for actually + * sending it (sendStorno) — splitting the create/send seam means + * a failed PDF render or email queue doesn't roll back the + * cancellation itself; the storno sits in `status='scheduled'` + * and the cron picks it up. + */ +async function createStorno(originalId, adminId, trx = db) { + const original = await trx('invoices').where({ id: originalId }).first(); + if (!original) throw new AppError('Invoice not found', 404); + if (original.kind === 'storno') { + throw new AppError('Cannot Storno a Storno', 409, 'IS_STORNO'); + } + if (original.status === 'scheduled') { + throw new AppError( + 'This invoice has not been sent yet — Storno only applies to issued documents.', + 409, + 'USE_EDIT_INSTEAD', + ); + } + if (original.status === 'cancelled') { + throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); + } + + // Generate the Storno's sequence number from the same gap-free + // series as regular invoices (single sequence — decision locked + // with the maintainer; satisfies §14 (4) Nr. 4 UStG). + const stornoNumber = await nextInvoiceNumber(); + const now = new Date(); + const issueDate = now.toISOString().slice(0, 10); + + // Insert the Storno row. Totals negated for accounting integrity + // (tax report aggregates by row-level totals, so a Storno + // contributes correctly without the renderer needing to flip + // signs at report time). Line items below stay positive — the + // renderer applies the sign at presentation time. + const insertedRow = await trx('invoices').insert({ + kind: 'storno', + invoice_number: stornoNumber, + customer_account_id: original.customer_account_id, + event_id: original.event_id, + // Inline event snapshot — copy so the Storno carries the same + // event label as the invoice it reverses (migration 123). The + // bookkeeper expects to see both documents under the same event. + event_name: original.event_name || null, + event_date: original.event_date || null, + event_time_start: original.event_time_start || null, + event_time_end: original.event_time_end || null, + source_quote_id: null, + // Migration 124 — carry the split FKs through onto the Storno row + // so the lineage stays consistent if anyone audits the + // cancellation document and checks the picker state. + payment_net_days_template_id: original.payment_net_days_template_id || null, + payment_timing_template_id: original.payment_timing_template_id || null, + currency: original.currency, + language: original.language, + vat_rate: original.vat_rate, + shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0), + net_amount_minor: -ensureInt(original.net_amount_minor), + vat_amount_minor: -ensureInt(original.vat_amount_minor), + total_amount_minor: -ensureInt(original.total_amount_minor), + late_fee_amount_minor: 0, + paid_amount_minor: 0, + status: 'scheduled', + scheduled_send_at: now, + issue_date: issueDate, + // Storni have no payment due — mirror issue_date to satisfy the + // schema's NOT NULL constraint on due_date. The field is dead data + // for kind='storno' rows: the PDF renderer suppresses the due-date + // line, and the dunning scheduler filters kind='invoice'. + due_date: issueDate, + reminder_level: 0, + cc_pdf_email: original.cc_pdf_email, + // No payment block on a Storno — it's not a payment instrument. + business_bank_account_id: null, + qr_format: null, + payment_term_template_id: null, + // Lineage. + cancels_invoice_id: original.id, + replaces_invoice_id: null, + cancellation_storno_id: null, + // Migration 140 — Storno belongs to the same deal as the invoice + // it cancels; both render together in the lineage view. + deal_uuid: original.deal_uuid || crypto.randomUUID(), + created_at: now, + updated_at: now, + }).returning('id'); + const stornoId = Array.isArray(insertedRow) + ? (insertedRow[0]?.id ?? insertedRow[0]) + : insertedRow; + + // Snapshot the original's line items (positive amounts — the + // Storno's sign convention lives on the row-level totals + the + // renderer flip). + const lineItems = await trx('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', originalId) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + if (lineItems.length > 0) { + const cloned = lineItems.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', stornoId, cloned); + } + + // Flip the original to cancelled + link the Storno. + await trx('invoices').where({ id: originalId }).update({ + status: 'cancelled', + cancellation_storno_id: stornoId, + updated_at: now, + }); + + try { + await logActivity('invoice_cancelled_via_storno', + { invoiceId: originalId, stornoId, stornoNumber }, + original.event_id || null, `admin:${adminId}`); + } catch (_) {} + + return stornoId; +} + +/** + * Send a Stornorechnung — renders the PDF, persists it on disk, + * flips the row to `status='sent'`, and queues the `storno_issued` + * email to the customer with the PDF attached. + * + * Mirrors sendInvoice's shape so the scheduler's flush loop can + * delegate uniformly. The email template ships in Phase 3 + * (renames the dormant `invoice_cancelled` seed); if the worker + * picks up the job before the template lands it logs the missing + * template — the row stays in `sent` either way. + */ +async function sendStorno(stornoId, adminId) { + const data = await getInvoiceById(stornoId); + if (!data) throw new AppError('Storno not found', 404); + const { invoice: storno, lineItems } = data; + if (storno.kind !== 'storno') { + throw new AppError(`Expected kind='storno', got '${storno.kind}'`, 409); + } + if (storno.status === 'sent') return { status: 'sent' }; + + const customer = await db('customer_accounts').where({ id: storno.customer_account_id }).first(); + ensureCustomerCanBill(customer); + + const ctx = await buildInvoiceRenderContext(storno, lineItems); + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + + // Persist PDF snapshot alongside regular invoices. + const fs = require('fs'); + const path = require('path'); + const year = new Date(storno.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + fs.mkdirSync(root, { recursive: true }); + const pdfPath = path.join(root, `${storno.invoice_number}.pdf`); + fs.writeFileSync(pdfPath, buffer); + + await db('invoices').where({ id: stornoId }).update({ + status: 'sent', + sent_at: new Date(), + pdf_path: pdfPath, + updated_at: new Date(), + }); + + // Look up the original so we can include both numbers in the + // email body — customers' bookkeepers expect to see the pair. + const originalRow = storno.cancels_invoice_id + ? await db('invoices').where({ id: storno.cancels_invoice_id }) + .select('invoice_number', 'issue_date').first() + : null; + + await emailProcessor.queueEmail(storno.event_id || null, customer.email, 'storno_issued', { + storno_number: storno.invoice_number, + original_invoice_number: originalRow?.invoice_number || '', + original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '', + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale), + cc: storno.cc_pdf_email || undefined, + attachments: [{ + filename: `${storno.invoice_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + + try { + await logActivity('storno_sent', + { stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null }, + storno.event_id || null, `admin:${adminId || 'system'}`); + } catch (_) {} + + return { status: 'sent', stornoId }; +} + +/** + * Reissue an invoice — the legally-correct alternative to post-send + * editing. + * 1. If the original is still live (sent / overdue / paid), + * generate a Stornorechnung for it via `createStorno` and + * immediately send it to the customer (sendStorno). The + * original flips to `status='cancelled'` and its + * `cancellation_storno_id` is pinned. + * 2. Create a fresh `scheduled` invoice with a new sequence + * number, line items snapshotted from the original, and + * `replaces_invoice_id` pointing at the original so the + * renderer can stamp "Bezug: Ersetzt Rechnung R-XXXX". + * + * If the original is ALREADY cancelled (admin previously cancelled + * it via Storno on its own), the cancel step is skipped — only the + * replacement is created. `scheduled` originals are rejected + * (USE_EDIT_INSTEAD) since drafts don't need legal cancellation. + */ +async function reissueInvoice(id, adminId) { + const original = await db('invoices').where({ id }).first(); + if (!original) throw new AppError('Invoice not found', 404); + if (original.kind === 'storno') { + throw new AppError('Cannot reissue a Storno document', 409, 'IS_STORNO'); + } + if (original.status === 'scheduled') { + throw new AppError( + 'This invoice has not been sent yet — use Edit instead of Cancel & reissue.', + 409, + 'USE_EDIT_INSTEAD', + ); + } + + // Cancel via Storno first if still live. We deliberately commit + // the Storno BEFORE creating the replacement so a failed sendStorno + // doesn't roll back the cancellation; the storno sits in + // status='scheduled' and the cron picks it up. Same resiliency + // contract as cancelInvoice. + let stornoId = null; + if (original.status !== 'cancelled') { + stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); + try { await sendStorno(stornoId, adminId); } catch (err) { + logger.warn('sendStorno during reissue failed — scheduler will retry', { stornoId, err: err.message }); + } + } + + // Build the replacement. Same shape as the original — re-uses + // createInvoice so totals are recomputed authoritatively from + // line items (any rounding drift gets normalised). Self-join + // carries parent_position so migration-119 sub-items survive. + return await db.transaction(async (trx) => { + const lineItems = await trx('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + const liPayload = lineItems.map((li) => ({ + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unit_price_minor: Number(li.unit_price_minor), + discount_percent: Number(li.discount_percent || 0), + parent_position: li.parent_position == null ? null : Number(li.parent_position), + details_text: li.details_text || null, + })); + + const { invoiceIds: reissuedIds } = await createInvoice({ + customerAccountId: original.customer_account_id, + sourceQuoteId: original.source_quote_id || null, + eventId: original.event_id || null, + language: original.language, + currency: original.currency, + vatRate: original.vat_rate, + shippingAmountMinor: original.shipping_amount_minor, + ccPdfEmail: original.cc_pdf_email, + businessBankAccountId: original.business_bank_account_id, + qrFormat: original.qr_format, + paymentTermTemplateId: original.payment_term_template_id, + // Reissue always produces a standalone invoice even when the + // customer is on monthly billing — folding the reissued items + // into the current period's running draft would conflate two + // unrelated billing periods. The escape hatch keeps the + // standard createInvoice flow. + _skipMonthlyRouting: true, + // Carry the split picker (migration 124) + event snapshot + // (migration 123) onto the reissued draft so the admin doesn't + // have to re-set them after a Cancel & reissue. createInvoice + // already accepts these on both code paths. + paymentNetDaysTemplateId: original.payment_net_days_template_id || null, + paymentTimingTemplateId: original.payment_timing_template_id || null, + eventName: original.event_name || null, + eventDate: original.event_date || null, + eventTimeStart: original.event_time_start || null, + eventTimeEnd: original.event_time_end || null, + // No installment metadata — reissue defaults to a single + // standalone invoice. If the admin needs the same split they + // can run the original conversion again from the quote. + lineItems: liPayload, + // Migration 140 — reissue inherits the cancelled original's + // deal_uuid so Storno + replacement + cancelled all group + // under one deal lineage view. + dealUuid: original.deal_uuid || null, + }, adminId, trx); + // Reissue always produces a single invoice (no installments + // forced), so the array length is 1. + const newId = reissuedIds[0]; + + await trx('invoices').where({ id: newId }).update({ + replaces_invoice_id: id, + updated_at: new Date(), + }); + + try { + await logActivity('invoice_reissued', + { originalInvoiceId: id, newInvoiceId: newId, stornoId }, + original.event_id || null, `admin:${adminId}`); + } catch (_) {} + + return { id: newId, replaces: id, stornoId }; + }); +} + +/** + * Release a `pending_delivery` invoice for sending. Used when the + * photographer has actually delivered the photos and is ready to + * collect the final installment — flips the status to `scheduled` + * with `scheduled_send_at = now`, then immediately calls sendInvoice + * so the email goes out without waiting for the next scheduler tick. + * + * Refuses to act on rows that aren't pending — admins should use + * sendInvoice / sendReminder for the normal `scheduled`/`sent` flow. + */ +async function releaseForDelivery(id, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.status !== 'pending_delivery') { + throw new AppError( + `Invoice is not awaiting delivery (status: '${invoice.status}')`, + 409, + 'NOT_PENDING_DELIVERY', + ); + } + const now = new Date(); + await db('invoices').where({ id }).update({ + status: 'scheduled', + scheduled_send_at: now, + updated_at: now, + }); + try { + await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); + } catch (_) {} + // Fire immediately rather than waiting for the next scheduler + // tick — admin clicked the button because they want it out now. + return await sendInvoice(id, adminId); +} + +/** + * Cancel an invoice. The behaviour depends on whether the document + * was ever issued: + * + * - `scheduled` (draft, no PDF emitted): soft cancel — status + * flips to 'cancelled', nothing leaves the system. No Storno is + * generated because no document exists for the customer to + * reverse. + * + * - `sent` / `overdue` / `paid` (issued): generate a + * Stornorechnung (cancellation invoice) with its own sequence + * number, attach a signed PDF, and email it to the customer. + * Original flips to 'cancelled' and pins its + * `cancellation_storno_id` for the admin lineage view. This is + * the only §14c-defensible cancellation path under DACH tax law + * once an invoice has been delivered to the recipient. + * + * Note we allow `paid` here on purpose — bookkeepers cancel + * paid invoices when issuing refunds. The actual money + * movement (refund, carry-forward as Anzahlung) is handled + * separately; the Storno is the document leg. + * + * - `cancelled` (already): 409, `ALREADY_CANCELLED`. + * + * Returns `{ cancelled: true, stornoId? }` so the caller can + * surface "Storno S-XXXX wurde erzeugt" feedback when applicable. + */ +async function cancelInvoice(id, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.kind === 'storno') { + throw new AppError('Cannot cancel a Storno document', 409, 'IS_STORNO'); + } + if (invoice.status === 'cancelled') { + throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); + } + + // Draft path: nothing was issued, soft cancel and we're done. + if (invoice.status === 'scheduled') { + await db('invoices').where({ id }).update({ + status: 'cancelled', updated_at: new Date(), + }); + try { + await logActivity('invoice_cancelled', + { invoiceId: id, viaStorno: false }, + invoice.event_id || null, `admin:${adminId}`); + } catch (_) {} + return { cancelled: true, stornoId: null }; + } + + // Issued path: Storno required. Commit createStorno in its own + // transaction so a failed sendStorno doesn't roll back the + // cancellation; the scheduler picks up an unsent Storno on the + // next tick. + const stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); + try { await sendStorno(stornoId, adminId); } catch (err) { + logger.warn('sendStorno after cancelInvoice failed — scheduler will retry', { stornoId, err: err.message }); + } + return { cancelled: true, stornoId }; +} + +/** + * Manually trigger a reminder email. The scheduler does this + * automatically; this is the "Send reminder now" button on the + * invoice detail page. + */ +async function sendReminder(id, levelOverride, adminId) { + const data = await getInvoiceById(id); + if (!data) throw new AppError('Invoice not found', 404); + const { invoice, lineItems } = data; + if (invoice.status !== 'sent' && invoice.status !== 'overdue') { + throw new AppError(`Cannot remind on status '${invoice.status}'`, 409); + } + const newLevel = levelOverride || (invoice.reminder_level + 1); + if (newLevel > 2) { + throw new AppError('Reminder level exhausted', 409); + } + return await applyReminder(invoice, lineItems, newLevel, adminId); +} + +async function applyReminder(invoice, lineItems, level, adminId) { + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + let lateFeeMinor = invoice.late_fee_amount_minor || 0; + if (level === 2) { + const enabled = await getAppSetting('crm_invoices_late_fee_enabled'); + if (enabled !== false) { + const fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + lateFeeMinor = fee; + } + } + const newTotal = invoice.total_amount_minor + lateFeeMinor; + + await db('invoices').where({ id: invoice.id }).update({ + status: 'overdue', + reminder_level: level, + last_reminder_sent_at: new Date(), + late_fee_amount_minor: lateFeeMinor, + updated_at: new Date(), + }); + + // Re-render PDF so the late fee shows up. + const fresh = await db('invoices').where({ id: invoice.id }).first(); + const ctx = await buildInvoiceRenderContext(fresh, lineItems); + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + const fs = require('fs'); + const path = require('path'); + const year = new Date(fresh.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + fs.mkdirSync(root, { recursive: true }); + const pdfPath = path.join(root, `${fresh.invoice_number}.pdf`); + fs.writeFileSync(pdfPath, buffer); + + await db('invoices').where({ id: invoice.id }).update({ pdf_path: pdfPath, updated_at: new Date() }); + + // days_overdue floors at 1 — a reminder that fires with "0 days + // overdue" reads as broken to the customer ("Why am I getting this + // already?"). The scheduler only triggers the row once + // due_date <= now - reminder_first_days, so the natural minimum is + // the configured threshold; for the manual "Send reminder now" + // path the admin's intent is "this customer is late", so 1 is the + // sensible lower bound even if the calendar arithmetic disagrees. + const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000); + const daysOverdue = Math.max(1, rawDaysOverdue); + const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second'; + + // Outstanding = gross total + late fee − already paid. Reminder + // templates use this for the "outstanding is X" line so partial + // payments are reflected in the reminder amount. + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + + Number(lateFeeMinor || 0) + - Number(invoice.paid_amount_minor || 0)); + + await emailProcessor.queueEmail(invoice.event_id || null, customer.email, templateKey, { + invoice_number: invoice.invoice_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale), + new_total_amount: formatMajor(newTotal, invoice.currency, ctx.locale), + outstanding_amount: formatMajor(outstandingMinor, invoice.currency, ctx.locale), + paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, ctx.locale), + late_fee_amount: formatMajor(lateFeeMinor, invoice.currency, ctx.locale), + // Format dates as DD.MM.YYYY for the customer-facing email + // (matches the quote_sent + invoice_sent templates). + due_date: formatShortDate(invoice.due_date), + days_overdue: daysOverdue, + cc: invoice.cc_pdf_email || undefined, + attachments: [{ + filename: `${invoice.invoice_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + + try { + await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor }, + invoice.event_id || null, `admin:${adminId || 'system'}`); + } catch (_) {} + + return { level, lateFeeMinor }; +} + +// --------------------------------------------------------------------- +// Payment-check workflow (admin-confirmed reminders) +// --------------------------------------------------------------------- + +/** + * Resolve the admin email address that should receive the payment- + * check prompt. Priority: + * 1. created_by_admin_id's email (the admin who issued the invoice) + * 2. First admin user with bills.manage permission + * 3. business_profile.email as a last resort + * Returns null when nothing usable is found — caller logs + skips. + */ +/** + * Resolve the effective Skonto percentage for an invoice at the + * current moment. Resolution chain (matches pdfService rendering): + * 1. invoice.payment_term_snapshot.skonto_percent + * 2. source quote's payment_term_snapshot.skonto_percent + * 3. global crm_invoices_skonto_percent_default + * Returns null when nothing is configured. + * + * Lifted into a helper so the payment-check action and the email + * template (which both need to know "does this invoice qualify for a + * Paid-with-Skonto button?") share one source of truth. + */ +async function resolveSkontoPercentForInvoice(invoice) { + // Per-invoice opt-out (migration 126) wins over every other source. + // Admin sets this on Storni / replacement invoices / payment-plan + // installments that shouldn't qualify for the discount even when + // the global default offers it. + if (invoice.skonto_disabled) return null; + const parseSnap = (raw) => { + if (!raw) return null; + if (typeof raw === 'object') return raw; + try { return JSON.parse(raw); } catch { return null; } + }; + const invSnap = parseSnap(invoice.payment_term_snapshot); + if (invSnap?.skonto_percent != null && Number(invSnap.skonto_percent) > 0) { + return Number(invSnap.skonto_percent); + } + if (invoice.source_quote_id) { + const q = await db('quotes').where({ id: invoice.source_quote_id }).select('payment_term_snapshot').first(); + const qSnap = parseSnap(q?.payment_term_snapshot); + if (qSnap?.skonto_percent != null && Number(qSnap.skonto_percent) > 0) { + return Number(qSnap.skonto_percent); + } + } + const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default')); + return Number.isFinite(defaultPct) && defaultPct > 0 ? defaultPct : null; +} + +async function resolveAdminEmailForInvoice(invoice) { + if (invoice.created_by_admin_id) { + const admin = await db('admin_users').where({ id: invoice.created_by_admin_id }).first(); + if (admin?.email) return { email: admin.email, name: admin.username || admin.email }; + } + // Fallback: business_profile.email. + const profile = await db('business_profile').where({ id: 1 }).first(); + if (profile?.email) return { email: profile.email, name: profile.company_name || profile.email }; + return null; +} + +/** + * Generate a fresh payment-check token for an invoice and queue the + * admin email with three signed action buttons. Throttled to once + * per 24h per invoice via invoices.last_payment_check_at. + * + * Returns { token, sent: bool, reason? } so callers can log / + * surface the outcome. + */ +/** + * Queue the admin "payment received" notification (migration 127). + * Called from markPaid the first time an invoice transitions into + * `status='paid'`. Resolves the admin's address via the same chain + * the payment-check email uses (created_by_admin_id → business + * profile fallback). Silently no-ops when no admin email can be + * resolved — caller logs the warn line. + */ +async function queueInvoicePaidAdminNotification({ + invoice, paidTotalMinor, paymentMethod, paymentReference, + paidAt, skontoApplied, skontoAmountMinor, +}) { + const adminContact = await resolveAdminEmailForInvoice(invoice); + if (!adminContact?.email) { + logger.warn('invoice_paid notification skipped — no admin email resolved', + { invoiceId: invoice.id }); + return; + } + + const profile = await db('business_profile').where({ id: 1 }).first(); + const locale = invoice.language || profile?.default_locale || 'de'; + + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + // Resolve the Skonto percentage at notification time so the + // template can render "Paid with Skonto X%" without a second query. + // Same resolver the rest of the Skonto surfaces use — null when + // skonto_disabled is true or no Skonto is configured. + const skontoPercent = skontoApplied + ? await resolveSkontoPercentForInvoice(invoice) + : null; + + await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, + 'invoice_paid_admin_notification', { + invoice_number: invoice.invoice_number, + customer_name: customer?.company_name + || customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email || '', + event_name: invoice.event_name || '', + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale), + payment_method: paymentMethod || '', + payment_reference: paymentReference || '', + paid_at: formatShortDate(paidAt), + skonto_applied: !!skontoApplied, + skonto_percent: skontoApplied && skontoPercent ? skontoPercent : '', + skonto_discount_amount: skontoApplied + ? formatMajor(skontoAmountMinor, invoice.currency, locale) + : '', + }); + + try { + await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id }, + invoice.event_id || null, 'system'); + } catch (_) {} +} + +async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) { + const invoice = await db('invoices').where({ id: invoiceId }).first(); + if (!invoice) return { sent: false, reason: 'not_found' }; + if (!['sent', 'overdue'].includes(invoice.status)) { + return { sent: false, reason: `wrong_status_${invoice.status}` }; + } + const now = new Date(); + if (!skipThrottle && invoice.last_payment_check_at) { + const last = new Date(invoice.last_payment_check_at).getTime(); + if (now.getTime() - last < 24 * 60 * 60 * 1000) { + return { sent: false, reason: 'throttled_24h' }; + } + } + + const adminContact = await resolveAdminEmailForInvoice(invoice); + if (!adminContact?.email) { + logger.warn('Payment-check email skipped — no admin email resolved', { invoiceId }); + return { sent: false, reason: 'no_admin_email' }; + } + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + await db('invoice_payment_check_tokens').insert({ + invoice_id: invoiceId, + token, + expires_at: expiresAt, + created_at: now, + }); + await db('invoices').where({ id: invoiceId }).update({ + last_payment_check_at: now, + updated_at: now, + }); + + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + const profile = await db('business_profile').where({ id: 1 }).first(); + const locale = invoice.language || profile?.default_locale || 'de'; + + // Determine whether the customer reminder will include a Mahngebühr + // if the admin selects "Not paid" / "Partial" — surfaced to the + // email so the admin sees the consequence before clicking. + const reminderLateFeeEnabled = (await getAppSetting('crm_invoices_late_fee_enabled')) !== false; + const reminderFeeMinor = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + const nextLevel = (invoice.reminder_level || 0) + 1; + const willChargeFee = reminderLateFeeEnabled && nextLevel >= 2; + + const baseUrl = process.env.FRONTEND_URL + || (await getAppSetting('app_frontend_url')) + || 'https://app.example.com'; + const buildUrl = (action) => + `${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`; + + // Outstanding = gross total + late fee − already paid. The admin + // is being asked about what's STILL OWED, not the original gross + // figure — so surface outstanding + paid in the email context. + // Partial payments logged earlier (e.g. via a previous admin + // payment-check click) are reflected, so the admin doesn't get + // asked "did the customer pay CHF 234?" when they already paid + // CHF 134 of it. + const paidMinor = Number(invoice.paid_amount_minor || 0); + const lateFeeAlreadyMinor = Number(invoice.late_fee_amount_minor || 0); + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + lateFeeAlreadyMinor - paidMinor); + const hasPartial = paidMinor > 0; + + // Resolve Skonto for the optional 4th button (migration 126). Only + // surface the button when (a) Skonto is configured for this invoice + // AND (b) the customer paid within the Skonto window — past the + // window the discount is moot. Both checks are visible to the + // template so the email can hide the button conditionally. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + const hasSkonto = !!skontoPercent && skontoPercent > 0; + const skontoDiscountedTotalMinor = hasSkonto + ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) + : null; + + await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, + 'invoice_payment_check_admin', { + invoice_number: invoice.invoice_number, + customer_name: customer?.company_name + || customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email || '', + event_name: invoice.event_name || '', + due_date: formatShortDate(invoice.due_date), + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + paid_amount: formatMajor(paidMinor, invoice.currency, locale), + outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), + has_partial_payment: hasPartial, + paid_url: buildUrl('paid_full'), + partial_url: buildUrl('partial'), + unpaid_url: buildUrl('unpaid'), + // Skonto button — template uses {{#if has_skonto}} to render the + // fourth button only when the invoice qualifies. + has_skonto: hasSkonto, + skonto_percent: hasSkonto ? skontoPercent : '', + skonto_amount: hasSkonto + ? formatMajor(skontoDiscountedTotalMinor, invoice.currency, locale) + : '', + skonto_url: hasSkonto ? buildUrl('paid_with_skonto') : '', + late_fee_due: willChargeFee, + late_fee_amount: formatMajor(reminderFeeMinor, invoice.currency, locale), + }); + + try { + await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) }, + invoice.event_id || null, 'scheduler'); + } catch (_) {} + + return { token, sent: true }; +} + +/** + * Validate a payment-check token and return the invoice context + * the public page needs. Token must exist, not be expired, not + * already used. + */ +async function getPaymentCheckByToken(token) { + const row = await db('invoice_payment_check_tokens').where({ token }).first(); + if (!row) throw new AppError('Token not found', 404); + if (row.used_at) { + const err = new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + err.usedAt = row.used_at; + err.usedAction = row.used_action; + throw err; + } + if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { + throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); + } + const invoice = await db('invoices').where({ id: row.invoice_id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) + - Number(invoice.paid_amount_minor || 0)); + + // Surface the Skonto state so the public page can decide whether to + // render the "Paid with Skonto" action card (migration 126). Only + // applies when the invoice's payment terms actually carry a Skonto + // percentage — admin shouldn't see the option on an invoice that + // never offered the discount. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + const hasSkonto = !!skontoPercent && skontoPercent > 0; + const skontoDiscountedTotalMinor = hasSkonto + ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) + : null; + + return { + invoiceNumber: invoice.invoice_number, + customer: { + label: customer?.company_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.display_name || customer?.email || '', + email: customer?.email, + }, + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + totalMinor: invoice.total_amount_minor, + paidMinor: invoice.paid_amount_minor, + lateFeeMinor: invoice.late_fee_amount_minor, + outstandingMinor, + currency: invoice.currency, + status: invoice.status, + reminderLevel: invoice.reminder_level, + expiresAt: row.expires_at, + hasSkonto, + skontoPercent: hasSkonto ? skontoPercent : null, + skontoDiscountedTotalMinor, + }; +} + +/** + * Record the admin's payment-check action and fire the downstream + * consequences: + * - 'paid_full' → markPaid for the outstanding amount, no reminder. + * - 'partial' → markPaid for the amount supplied, then fire the + * next reminder for the remainder. + * - 'unpaid' → fire the next reminder (level 1 or 2) with the + * existing Mahngebühr logic in applyReminder. + * + * Atomic: token consumption + invoice status update happen in one + * transaction. The reminder email is queued AFTER the txn commits + * to avoid emailing a customer about a payment that never + * actually committed. + */ +async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminId }) { + // 'paid_with_skonto' (migration 126) is a fourth admin action — the + // customer settled the bill within the early-payment-discount window, + // so the recorded payment equals total minus the configured Skonto %. + // Same token-consumption semantics as 'paid_full'. + if (!['paid_full', 'paid_with_skonto', 'partial', 'unpaid'].includes(action)) { + throw new AppError('Invalid action', 400); + } + + const row = await db('invoice_payment_check_tokens').where({ token }).first(); + if (!row) throw new AppError('Token not found', 404); + if (row.used_at) { + throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + } + if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { + throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); + } + const invoice = await db('invoices').where({ id: row.invoice_id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) + - Number(invoice.paid_amount_minor || 0)); + + if (action === 'partial') { + const amt = ensureInt(amountMinor); + if (amt <= 0) throw new AppError('partial amount must be > 0', 400); + if (amt > outstandingMinor) throw new AppError('partial amount exceeds outstanding', 400); + } + + // Consume the token first — atomic with status update so a + // double-click can't fire the action twice. + const now = new Date(); + const updated = await db('invoice_payment_check_tokens') + .where({ id: row.id }) + .whereNull('used_at') + .update({ + used_at: now, + used_action: action, + used_amount_minor: action === 'partial' ? ensureInt(amountMinor) : null, + used_ip: ip || null, + }); + if (updated === 0) { + // Lost a race with another consumer. + throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + } + + try { + await logActivity('invoice_payment_check_recorded', + { invoiceId: invoice.id, action, amountMinor: amountMinor || null }, + invoice.event_id || null, + adminId ? `admin:${adminId}` : 'public:payment-check'); + } catch (_) {} + + // --- Apply the action ----------------------------------------- + if (action === 'paid_full') { + await markPaid(invoice.id, { + amountMinor: outstandingMinor, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: 'Confirmed via admin payment-check link', + }, adminId || invoice.created_by_admin_id); + return { applied: 'paid_full' }; + } + + if (action === 'paid_with_skonto') { + // Resolve the Skonto percentage at click time so admins can't + // accidentally double-discount after the template changed. Same + // resolution chain pdfService uses: invoice snapshot → source + // quote snapshot → global crm_invoices_skonto_percent_default. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + if (!skontoPercent || skontoPercent <= 0) { + throw new AppError('No Skonto configured on this invoice', 409, 'SKONTO_NOT_CONFIGURED'); + } + const discountedTotalMinor = Math.round( + Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100), + ); + // Outstanding-aware: if the customer already paid part of the + // bill (rare on the Skonto path, but possible after a partial), + // record only the remaining slice up to the discounted total. + const paidMinor = Number(invoice.paid_amount_minor || 0); + const remainingMinor = Math.max(0, discountedTotalMinor - paidMinor); + if (remainingMinor <= 0) { + throw new AppError('Invoice already paid past the Skonto threshold', 409); + } + await markPaid(invoice.id, { + amountMinor: remainingMinor, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: `Confirmed via admin payment-check link (Skonto ${skontoPercent}% applied)`, + skontoApplied: true, + }, adminId || invoice.created_by_admin_id); + return { applied: 'paid_with_skonto', skontoPercent }; + } + + if (action === 'partial') { + const amt = ensureInt(amountMinor); + await markPaid(invoice.id, { + amountMinor: amt, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: 'Partial payment confirmed via admin payment-check link', + }, adminId || invoice.created_by_admin_id); + // Then fire the customer reminder for the remainder, unless + // markPaid flipped the invoice to paid (i.e. the partial + // amount equalled the outstanding). + const refreshed = await db('invoices').where({ id: invoice.id }).first(); + if (refreshed.status !== 'paid') { + const nextLevel = (refreshed.reminder_level || 0) + 1; + if (nextLevel <= 2) { + const lineItems = await db('invoice_line_items') + .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); + await applyReminder(refreshed, lineItems, nextLevel, adminId); + } + } + return { applied: 'partial' }; + } + + // 'unpaid' + const nextLevel = (invoice.reminder_level || 0) + 1; + if (nextLevel > 2) { + // Already at max reminder — admin has to take this offline. + return { applied: 'unpaid', reminderSkipped: 'max_level_reached' }; + } + const lineItems = await db('invoice_line_items') + .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); + await applyReminder(invoice, lineItems, nextLevel, adminId); + return { applied: 'unpaid', reminderLevel: nextLevel }; +} + +/** + * Admin override — issue the customer's running monthly draft NOW, + * bypassing the cadence-day wait. Mirrors the scheduler's monthly + * pass (migration 128): clears is_monthly_draft, sets the issue date + * + scheduled_send_at to now, and fires sendInvoice inline so the + * email goes out on the next email-queue tick (~60s) instead of + * waiting for the next scheduler iteration. + * + * Refuses when: + * - no draft exists (admin hasn't queued anything yet) + * - the draft has zero line items (nothing to send — same as the + * scheduler's empty-month skip path) + * + * Returns { invoiceId, invoiceNumber } so the route can surface the + * resulting invoice on the response toast. + */ +/** + * Read the customer's running monthly draft + its line items so the + * customer-detail page can preview what will ship on the next cycle + * day. Returns null when no open draft exists (admin hasn't queued + * anything yet for the current period). Used by GET + * /admin/customers/:id/monthly-draft. + */ +async function getMonthlyDraft(customerId) { + const draft = await db('invoices') + .where({ customer_account_id: customerId, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) return null; + const lineItems = await db('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', draft.id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + return { + id: draft.id, + invoiceNumber: draft.invoice_number, + currency: draft.currency, + periodStart: draft.monthly_period_start, + periodEnd: draft.monthly_period_end, + netAmountMinor: draft.net_amount_minor, + vatRate: draft.vat_rate == null ? null : Number(draft.vat_rate), + vatAmountMinor: draft.vat_amount_minor, + totalAmountMinor: draft.total_amount_minor, + lineItems: lineItems.map((li) => ({ + id: li.id, + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unitPriceMinor: ensureInt(li.unit_price_minor), + discountPercent: Number(li.discount_percent || 0), + lineTotalMinor: ensureInt(li.line_total_minor), + parentPosition: li.parent_position == null ? null : ensureInt(li.parent_position), + detailsText: li.details_text || '', + })), + }; +} + +async function triggerMonthlyBillNow(customerId, adminId) { + const draft = await db('invoices') + .where({ customer_account_id: customerId, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) { + throw new AppError('No pending monthly bill for this customer', 409, 'NO_MONTHLY_DRAFT'); + } + const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); + if (items.length === 0) { + throw new AppError('Monthly draft is empty — nothing to bill', 409, 'EMPTY_DRAFT'); + } + + // Arm the draft: clear the discriminator, pin issue_date to today, + // and set scheduled_send_at to now so the flush pass + sendInvoice + // path treats it like any other ready-to-send invoice. Logged as a + // distinct activity so the audit trail shows admin override vs the + // scheduler's automatic fire. + const issueDate = new Date().toISOString().slice(0, 10); + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + issue_date: issueDate, + scheduled_send_at: new Date(), + updated_at: new Date(), + }); + try { + await logActivity('monthly_bill_triggered_manually', + { invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end }, + null, `admin:${adminId}`); + } catch (_) {} + + // Inline send so admin gets immediate feedback (PDF stored, status + // flipped to 'sent', email queued). A failure here doesn't roll + // back the arming — the scheduler will pick it up on the next tick. + try { + await sendInvoice(draft.id, adminId); + } catch (err) { + logger.warn('triggerMonthlyBillNow: inline send failed — scheduler will retry', + { invoiceId: draft.id, err: err.message }); + } + return { invoiceId: draft.id, invoiceNumber: draft.invoice_number }; +} + +/** + * Cron tick — find scheduled invoices ready to send + invoices past + * due date that need a reminder. Called by invoiceSchedulerService. + */ +async function runScheduledTasks() { + const now = new Date(); + + // 1. Flush scheduled invoices. + const ready = await db('invoices') + .where({ status: 'scheduled' }) + .andWhere(function() { + this.whereNotNull('scheduled_send_at').andWhere('scheduled_send_at', '<=', now); + }) + .limit(20); + for (const inv of ready) { + try { + await sendInvoice(inv.id, null); + } catch (err) { + logger.error('Scheduled invoice send failed', { invoiceId: inv.id, err: err.message }); + } + } + + // 2. Monthly-bill issuance (migration 128). + // + // Walk every monthly draft whose period_end is today-or-earlier. + // - If the draft has zero line items, skip silently (empty month + // per user spec — no invoice issued, no email, just a log). + // - Otherwise flip is_monthly_draft=false and arm scheduled_send_at + // to `now` so the next flush-pass picks it up and runs the + // standard sendInvoice path. Keeping the issuance one tick away + // from this pass means email queueing + activity log + dunning + // schedule all stay on the existing well-trodden code paths + // instead of duplicating logic here. + const monthlyToday = new Date(now); + monthlyToday.setHours(0, 0, 0, 0); + const dueDrafts = await db('invoices') + .where({ is_monthly_draft: true }) + .andWhere('monthly_period_end', '<=', monthlyToday.toISOString().slice(0, 10)) + .limit(50); + for (const draft of dueDrafts) { + try { + const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); + if (items.length === 0) { + // Empty month — leave the draft alone (admin may still add + // items between now and end-of-day) OR mark it consumed so + // the next save creates a fresh period draft. We pick the + // latter: clear is_monthly_draft so the next createInvoice + // for this customer mints a new period. + // + // Status is 'skipped', not 'cancelled': the latter implies + // an admin (or Storno) deliberately voided a real invoice; + // an empty monthly period is a "nothing happened" non-event + // that we still record for audit-trail continuity. Listing + // queries that aggregate cancelled rows (e.g. the Bills list + // cancellation footnote) should not pull skipped rows in. + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + status: 'skipped', + updated_at: new Date(), + }); + logger.info('Monthly bill skipped — no items queued', { + invoiceId: draft.id, customerId: draft.customer_account_id, + }); + try { + await logActivity('monthly_bill_skipped_empty', + { invoiceId: draft.id, customerId: draft.customer_account_id }, + null, 'scheduler'); + } catch (_) {} + continue; + } + // Arm for the flush pass: clear the draft flag, set the send + // time to now, recompute due_date from issue_date + the global + // crm_invoices_net_days_default (best-effort; admin can override + // by editing the draft before the cadence day). + const issueDate = monthlyToday.toISOString().slice(0, 10); + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + issue_date: issueDate, + scheduled_send_at: new Date(), + updated_at: new Date(), + }); + try { + await logActivity('monthly_bill_issued', + { invoiceId: draft.id, customerId: draft.customer_account_id, + periodEnd: draft.monthly_period_end }, + null, 'scheduler'); + } catch (_) {} + } catch (err) { + logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message }); + } + } + + // 3. Overdue payment-check prompts (if reminders enabled). + // + // NEW behavior (migration 115/116): instead of auto-firing the + // customer reminder when an invoice goes overdue, we email the + // ADMIN with three signed-token action buttons: + // - Paid in full → markPaid for the outstanding amount + // - Partial → admin enters amount; partial + reminder + // - Not paid yet → reminder fires (with Mahngebühr at level 2) + // + // The reminder thresholds still gate when the prompt fires: + // - level 0 invoice past firstCutoff → prompt for level-1 path + // - level 1 invoice past secondCutoff → prompt for level-2 path + // Throttled to one email per 24h per invoice via + // invoices.last_payment_check_at. + const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); + if (remindersEnabled !== false) { + const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; + const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30; + + const firstCutoff = new Date(now.getTime() - firstDays * 86400000); + const secondCutoff = new Date(now.getTime() - secondDays * 86400000); + + // Pre-reminder check (would-be-level-1). + // `kind='invoice'` filter keeps Stornorechnungen out of the + // dunning ladder — they have no due_date and no payment + // expectation; reminding on them would be a customer-facing + // bug. + const firstBatch = await db('invoices') + .where('kind', 'invoice') + .whereIn('status', ['sent', 'overdue']) + .where('reminder_level', 0) + .where('due_date', '<=', firstCutoff) + .limit(20); + for (const inv of firstBatch) { + try { + await queuePaymentCheckEmail(inv.id); + } catch (err) { + logger.error('Payment-check email failed', { invoiceId: inv.id, err: err.message }); + } + } + + // Pre-reminder check (would-be-level-2, including Mahngebühr). + const secondBatch = await db('invoices') + .where('kind', 'invoice') + .whereIn('status', ['sent', 'overdue']) + .where('reminder_level', 1) + .where('due_date', '<=', secondCutoff) + .limit(20); + for (const inv of secondBatch) { + try { + await queuePaymentCheckEmail(inv.id); + } catch (err) { + logger.error('Payment-check email (level 2) failed', { invoiceId: inv.id, err: err.message }); + } + } + } +} + +// Module-cached issuer country code — refreshed on every business +// profile save by listening to the same query React-Query revalidates. +// For backend purposes we read it lazily once per process and cache +// the resolved Intl locale; admins changing the country in Settings +// take effect after the next backend restart, which is acceptable +// (this isn't on a hot path). +let _cachedIntlLocale = null; +async function resolveIntlLocale(docLocale) { + if (_cachedIntlLocale) return _cachedIntlLocale; + try { + const businessProfileService = require('./businessProfileService'); + const profile = (await businessProfileService.getProfile()).profile || {}; + const cc = (profile.country_code || '').toUpperCase(); + if (['CH', 'LI', 'DE', 'AT'].includes(cc)) { + _cachedIntlLocale = 'de-CH'; + return _cachedIntlLocale; + } + } catch (_) { /* fall through to per-locale default */ } + return docLocale === 'de' ? 'de-CH' : 'en-GB'; +} + +function formatMajor(minor, currency, locale) { + // Sync version — keeps the existing call-sites working. Reads the + // module cache populated by the async warm-up on first send. When + // the cache hasn't filled yet (first invocation in a process) + // fall through to the legacy de-vs-en split; the cache fills after + // the first send and every subsequent send uses the correct locale. + const cached = _cachedIntlLocale; + const intlLocale = cached || (locale === 'de' ? 'de-CH' : 'en-GB'); + // Best-effort warm-up — fire and forget; the next call hits cache. + if (!cached) { + resolveIntlLocale(locale).catch(() => { /* tolerate */ }); + } + return new Intl.NumberFormat(intlLocale, { + style: 'currency', currency: (currency || 'CHF').toUpperCase(), + }).format(Number(minor || 0) / 100); +} + +module.exports = { + listInvoices, + getInvoiceById, + createInvoice, + spawnInstallmentInvoices, + scheduleInvoicesForEvent, + updateInstallmentPlan, + validateInstallmentPlanInput, + sendInvoice, + sendReminder, + markPaid, + cancelInvoice, + releaseForDelivery, + reissueInvoice, + createStorno, + sendStorno, + queuePaymentCheckEmail, + getPaymentCheckByToken, + recordPaymentCheckAction, + renderInvoicePdfBuffer, + renderInvoicePdfFromPayload, + runScheduledTasks, + resolveSkontoPercentForInvoice, + // Monthly billing accumulator (migration 128) — exposed so + // customerHoursService can append hour-logged line items onto the + // running draft without duplicating the period/totals logic. + getOrCreateMonthlyDraft, + getMonthlyDraft, + appendToMonthlyDraft, + appendOneLineItemToMonthlyDraft, + triggerMonthlyBillNow, + // Exposed so contractService can mint an invoice number for the + // empty-draft path (convert-to-invoice on a contract with no + // source quote). Stays gap-free per crm_invoices_number_format. + nextInvoiceNumber, +}; diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js new file mode 100644 index 00000000..3f241ab2 --- /dev/null +++ b/backend/src/services/pdf-i18n.js @@ -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 }; diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js new file mode 100644 index 00000000..01c58100 --- /dev/null +++ b/backend/src/services/pdfService.js @@ -0,0 +1,2189 @@ +/** + * pdfService — render quote / invoice PDFs. + * + * Built on PDFKit + swissqrbill (the latter ships the SwissQRBill class + * for the QR-bill payment slip + a `Table` helper for the line items). + * Same engine renders both quotes and invoices — they differ only in + * title, lead-in text, optional Rabatt column (quotes only) and the + * QR-bill section (invoices only, when qr_format = 'swiss'). + * + * Public API: + * renderQuoteToBuffer(context) → Promise + * renderInvoiceToBuffer(context) → Promise + * + * The caller (quoteService / invoiceService) hydrates the `context` from + * the DB and passes everything in — keeping pdfService a pure renderer + * makes both unit-tests and preview-from-form (no DB write) trivial. + * + * Money: every "*_minor" field is treated as INTEGER minor units + * (cents/Rappen) and rendered via Intl.NumberFormat using the supplied + * locale + currency. + * + * Layout reference: the user's existing Angebot / Rechnung templates + * (issuer block top-right, customer block left, "Datum" line, title, + * salutation + lead-in, line-item table, totals box right-aligned, + * payment conditions block, IBAN block, footer). + */ + +const PDFDocument = require('pdfkit'); +const { SwissQRBill, Table } = require('swissqrbill/pdf'); +const { t } = require('./pdf-i18n'); + +// Page metrics in PDF points (1pt = 1/72in). A4 = 595.28 × 841.89. +// 1mm = 2.834645669pt. +const MM = 2.834645669; +const PAGE = { + // A4 ISO 216 — portrait. Quote/invoice rendering is hard-wired to this + // orientation (DIN 5008 address window only makes sense in portrait). + // Landscape callers (tax report, future wide-table exports) read their + // metrics from getPageMetrics('landscape') instead. + width: 595.28, + height: 841.89, + marginTop: 40, + marginBottom: 40, + marginLeft: 40, + marginRight: 40, + contentWidth: 595.28 - 80, // 515.28 +}; + +// A4 landscape — width and height swapped. Same 40pt margins on all +// sides, so contentWidth grows from 515pt to 762pt — enough horizontal +// room for the tax-report table's 9 columns without column squashing. +const PAGE_LANDSCAPE = { + width: 841.89, + height: 595.28, + marginTop: 40, + marginBottom: 40, + marginLeft: 40, + marginRight: 40, + contentWidth: 841.89 - 80, // 761.89 +}; + +/** + * Page metrics for the requested orientation. Default 'portrait' keeps + * every existing caller behaving identically. Used by createBaseDocument + * and by any renderer that needs to size its content against the page. + */ +function getPageMetrics(orientation) { + return orientation === 'landscape' ? PAGE_LANDSCAPE : PAGE; +} + +// DIN 5008 Form B address window — the standard window position for +// envelopes commonly used in DACH (B5 / C5-6 / DL with window). The +// window's top-left corner sits 45mm from the top and 20mm from the +// left of the A4 sheet, 85mm × 45mm in size. Picking Form B (the +// "newer" form) over Form A means the document still fits envelopes +// printed by every German/Swiss/Austrian/Liechtenstein vendor. +// +// We render INSIDE the window: +// - Return address line (small grey "Absender" reference) +// positioned in the upper ~5mm of the window +// - The actual recipient address starts ~17.7mm below the top of +// the window (DIN 5008 says address-line 1 starts on row 4 of +// the window, which is 5mm down + 12.7mm of line-rows) +const ADDR_WINDOW = { + left: 20 * MM, // 56.69pt + top: 45 * MM, // 127.56pt + width: 85 * MM, // 240.94pt + height: 45 * MM, // 127.56pt + // Vertical offsets inside the window. + returnLineY: 47 * MM, // 133.23pt — tiny "Absender" reference line + addressY: 52 * MM, // 147.40pt — first line of recipient address +}; + +// Default to PDFKit's built-in Helvetica. These constants are STILL +// used by the rest of the renderer as logical font names; when the +// admin has uploaded a custom TTF (business_profile.pdf_font_ttf_path), +// renderDocument registers it under these same names so every existing +// `doc.font(doc._fonts ? doc._fonts.body : FONT_BODY)` / `doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD)` call automatically +// picks it up. If only one weight is available we register it for both +// — bold falls back gracefully to regular. +const FONT_BODY = 'Helvetica'; +const FONT_BOLD = 'Helvetica-Bold'; +const CUSTOM_BODY = 'crm-body'; +const CUSTOM_BOLD = 'crm-bold'; + +/** + * Layout constants for the contract signature page (the dedicated + * final page of every contract PDF). Both renderContractToBuffer + * AND pdfStampService read these — the unsigned render draws empty + * boxes at these coordinates; the stamp service later overlays the + * signature PNGs at the same coordinates with pdf-lib. + * + * Coordinates are PDFKit-style (top-left origin, y increases down). + * pdfStampService converts to pdf-lib's bottom-left origin internally. + * + * Changing any value here means re-rendering all unsigned PDFs that + * are still pending signature — or the stamps will land in the wrong + * place. Leave alone unless redesigning the signature page entirely. + */ +const CONTRACT_SIGNATURE_LAYOUT = { + // Title row at top of page. + titleY: PAGE.marginTop, + // Prompt text below title (small instruction line). + promptY: PAGE.marginTop + 50, + // Y of the "Customer" / "Contractor" labels above each box. + paneLabelY: PAGE.marginTop + 100, + // Y of the empty signature box itself. + boxY: PAGE.marginTop + 114, + // Each box is half the content width minus a 20pt gutter. + boxWidth: (PAGE.contentWidth - 20) / 2, + // Tall enough that a typical canvas signature reads cleanly. + boxHeight: 80, + // Two side-by-side panes — customer on the left, admin on the right. + customerX: PAGE.marginLeft, + adminX: PAGE.marginLeft + ((PAGE.contentWidth - 20) / 2) + 20, +}; + +/** + * ISO 3166-1 alpha-2 → full country name, locale-aware. Falls back to + * the bare code when not in the map (no need to maintain every nation + * on earth — the user said de + en, with the issuer in LI/CH). + * + * Using `Intl.DisplayNames` would be neat but Node's built-in support + * for German names is patchy across versions, so a small explicit + * table is more reliable for the formats actually used. + */ +const COUNTRY_NAMES = { + de: { + LI: 'Liechtenstein', CH: 'Schweiz', AT: 'Österreich', DE: 'Deutschland', + FR: 'Frankreich', IT: 'Italien', ES: 'Spanien', PT: 'Portugal', + NL: 'Niederlande', BE: 'Belgien', LU: 'Luxemburg', GB: 'Vereinigtes Königreich', + US: 'USA', DK: 'Dänemark', SE: 'Schweden', NO: 'Norwegen', + FI: 'Finnland', PL: 'Polen', CZ: 'Tschechien', SK: 'Slowakei', + HU: 'Ungarn', IE: 'Irland', + }, + en: { + LI: 'Liechtenstein', CH: 'Switzerland', AT: 'Austria', DE: 'Germany', + FR: 'France', IT: 'Italy', ES: 'Spain', PT: 'Portugal', + NL: 'Netherlands', BE: 'Belgium', LU: 'Luxembourg', + GB: 'United Kingdom',US: 'United States', + DK: 'Denmark', SE: 'Sweden', NO: 'Norway', + FI: 'Finland', PL: 'Poland', CZ: 'Czechia', SK: 'Slovakia', + HU: 'Hungary', IE: 'Ireland', + }, +}; + +/** + * Build the salutation line. When the customer record carries an + * honorific (Herr / Frau / Mr. / Ms. / Dr.) AND a last name, we use + * a personalised greeting; otherwise we fall back to the generic + * locale-specific opening from the i18n dictionary. + * + * Recognised honorifics are matched loosely (lowercased + trimmed, + * dot suffix stripped) so "Herr", "herr", "Mr.", "Mr" all hit. The + * gendered forms only fire when we can pick a gender from the + * honorific; ambiguous titles like "Dr." use the inclusive + * "Sehr geehrte/r Dr. ," (German) or "Dear Dr. ," + * (English) variant. + */ +function personalSalutation(locale, salutation, lastName) { + const honorific = (salutation || '').trim(); + const last = (lastName || '').trim(); + if (!honorific || !last) return null; + const key = honorific.toLowerCase().replace(/\.+$/, '').trim(); + + // gender from the honorific: 'm' / 'f' / null (ambiguous) + let gender = null; + if (['herr', 'mr', 'mister', 'monsieur', 'señor', 'senhor', 'meneer', 'sig', 'г-н', 'господин'].includes(key)) gender = 'm'; + if (['frau', 'mrs', 'ms', 'miss', 'madame', 'mademoiselle', 'señora', 'senhora', 'mevrouw', 'sig.ra', 'г-жа', 'госпожа'].includes(key)) gender = 'f'; + + switch ((locale || 'de').toLowerCase()) { + case 'de': + if (gender === 'm') return `Sehr geehrter ${honorific} ${last},`; + if (gender === 'f') return `Sehr geehrte ${honorific} ${last},`; + return `Sehr geehrte/r ${honorific} ${last},`; + case 'en': + return `Dear ${honorific} ${last},`; + case 'fr': + if (gender === 'm') return `Cher ${honorific} ${last},`; + if (gender === 'f') return `Chère ${honorific} ${last},`; + return `Cher/Chère ${honorific} ${last},`; + case 'nl': + return `Geachte ${honorific} ${last},`; + case 'pt': + if (gender === 'm') return `Prezado ${honorific} ${last},`; + if (gender === 'f') return `Prezada ${honorific} ${last},`; + return `Prezado(a) ${honorific} ${last},`; + case 'ru': + return `Уважаемый(ая) ${honorific} ${last}!`; + default: + return `Dear ${honorific} ${last},`; + } +} + +function countryName(code, locale) { + if (!code) return ''; + const upper = String(code).trim().toUpperCase().slice(0, 2); + const dict = COUNTRY_NAMES[locale] || COUNTRY_NAMES.en; + return dict[upper] || COUNTRY_NAMES.en[upper] || upper; +} + +/** + * Format a minor-unit BigInt-ish integer as a localised currency string. + * Returns just the number portion ("750.00") not "CHF 750.00" — the + * currency label is rendered separately in the totals box for layout + * reasons (matches the reference PDFs). + */ +function formatMinor(minor, currency, locale = 'de-CH') { + const value = Number(minor || 0) / 100; + // We render only the number — currency renders as a separate column + // to keep totals right-aligned cleanly. + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} + +function formatCurrencyLabel(currency) { + // Render the ISO code; matches the user's reference PDFs which show + // "Gesamtbetrag CHF 750.00". + return (currency || '').toUpperCase(); +} + +function formatDate(value, dateFormat) { + if (!value) return ''; + const d = (value instanceof Date) ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ''; + // Respect the `general_date_format` app setting (read once in the + // service layer and passed through ctx.dateFormat). We build the + // string by hand instead of going through Intl.DateTimeFormat so + // a chosen "DD.MM.YYYY" actually renders with dots even when the + // customer's preferred_language maps to a locale that prints + // slashes (en-GB → 02/12/2025). + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const yyyy = String(d.getFullYear()); + const format = (dateFormat && dateFormat.format) || 'DD.MM.YYYY'; + switch (format) { + case 'MM/DD/YYYY': return `${mm}/${dd}/${yyyy}`; + case 'DD/MM/YYYY': return `${dd}/${mm}/${yyyy}`; + case 'YYYY-MM-DD': return `${yyyy}-${mm}-${dd}`; + case 'DD.MM.YYYY': + default: + return `${dd}.${mm}.${yyyy}`; + } +} + +/** + * Resolve the BCP-47 locale used for number / currency formatting. + * + * Strategy: the issuer's country code wins. CH/LI/DE/AT issuers all + * get the Swiss-style apostrophe thousands separator (e.g. 1'000.00 — + * what the local accountant + the bank expects on Stelleabrechnung / + * Rechnung), regardless of the document language. Outside the DACH + * region we fall through to the bare ISO 639 locale → BCP-47 mapping + * so en-GB, pt-PT, etc. keep their conventional formatting. + * + * Per maintainer: "in FL, CH, DE we write 1'000.00 not 1,000.00". + */ +function localeForIntl(locale, issuerCountryCode) { + const cc = (issuerCountryCode || '').toUpperCase(); + if (['CH', 'LI', 'DE', 'AT'].includes(cc)) { + // de-CH is the only one of these that uses the apostrophe + // separator in Intl.NumberFormat. fr-CH would render 1 000.00 + // (NBSP) which Swiss accountants don't want either. + return 'de-CH'; + } + const map = { de: 'de-CH', en: 'en-GB', fr: 'fr-CH', nl: 'nl-NL', pt: 'pt-PT', ru: 'ru-RU' }; + return map[locale] || locale || 'en-GB'; +} + +/** + * Render the issuer block (top-right): logo + company name as + * a side-by-side banner, then the address block, then a tidy + * label/value contact column. Matches the reference letterhead. + * + * Layout decisions: + * - Top banner: logo on the LEFT of the column with the company + * name vertically centred to the RIGHT of it (mirrors the + * "LUCA BRESCH MEDIA" branding screenshot). Either piece can be + * suppressed via issuer.showLogo / issuer.showCompanyName. + * - Address: line1 → "postal city" → CountryName, left-aligned + * within the right-side column. + * - Contact rows use two columns: "Phone:" labels at left, + * values aligned underneath each other. Looks like a small + * invisible table. + */ +function drawIssuerBlock(doc, issuer, x, y, width, locale) { + const startY = y; + const showLogo = issuer.showLogo !== false; // default true + const showName = issuer.showCompanyName !== false; // default true + + // ---- top banner: logo (left) + company name (right of it) ----- + // Path resolution happens upstream in resolveLogoFile() — by the + // time we get here, `issuer.logoPath` is either: + // - an absolute file path that has already been confirmed to + // exist on disk + filtered for PNG/JPEG, or + // - null when nothing resolved (logged upstream). + // We still wrap doc.image() in try/catch because PDFKit can reject + // valid-looking PNG/JPEG bytes (mislabelled extension, truncated + // download, etc.) — we'd rather render the rest of the PDF than + // crash on a broken logo. + const logoFound = showLogo && issuer.logoPath ? issuer.logoPath : null; + const drawLogoSafely = (file, opts) => { + try { + doc.image(file, opts.x, opts.y, { fit: [opts.w, opts.h] }); + return true; + } catch (err) { + const logger = require('../utils/logger'); + logger.warn('PDFKit failed to embed logo image', { + path: file, err: err.message, + }); + return false; + } + }; + + // Logo height is admin-configurable (migration 108). Falls back to + // 56pt — the prior hard-coded value — when unset. + const bannerH = Math.max(24, Math.min(200, Number(issuer.logoHeight) || 56)); + const inlineName = issuer.companyNameInline === true; + // Logo and company name stack VERTICALLY (logo on top, name + // underneath). When `companyNameInline` is set, the bold-title + // name branch is skipped and the name is rendered as a regular + // address line right before the street address (handled below). + let logoDrawn = false; + if (logoFound) { + logoDrawn = drawLogoSafely(logoFound, { x, y, w: width, h: bannerH }); + if (logoDrawn) y += bannerH + 4; + } + if (showName && issuer.companyName && !inlineName) { + // Bold-title branch — the standard letterhead look. Skipped when + // the admin opted into the inline-name variant. + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(12).fillColor('#000') + .text(issuer.companyName, x, y, { width, align: 'left' }); + y = doc.y + 6; + } + + // ---- address block (left-aligned within the column) ----------- + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8.5).fillColor('#000'); + const cityCountry = (() => { + // Match the screenshot: "FL-9494 Schaan / Liechtenstein" on one + // line. Fall back gracefully when fields are missing. The + // country name comes from the explicit `countryName` override + // when set (migration 107); otherwise we resolve it from the + // ISO country code via the locale-aware COUNTRY_NAMES map. + const cc = issuer.countryCode ? String(issuer.countryCode).toUpperCase() : ''; + const pc = issuer.postalCode || ''; + const city = issuer.city || ''; + const left = [cc && pc ? `${cc}-${pc}` : (pc || cc), city].filter(Boolean).join(' '); + const country = issuer.countryName || countryName(issuer.countryCode, locale); + return [left, country].filter(Boolean).join(' / '); + })(); + // When the admin opted into the inline-name variant (migration 108) + // the company name renders as the first address line, in the same + // plain weight + size as the rest of the address. The bold-title + // branch above is skipped in that case. + const inlineCompanyLine = (showName && issuer.companyName && inlineName) + ? issuer.companyName : null; + const addressLines = [ + inlineCompanyLine, + issuer.addressLine1, + issuer.addressLine2, + cityCountry, + ].filter(Boolean); + for (const line of addressLines) { + doc.text(line, x, y, { width, align: 'left' }); + y = doc.y; + } + y += 6; + + // ---- contact rows (label / value, two columns) ---------------- + const labelCol = 38; + const gap = 4; + const valueCol = width - labelCol - gap; + const labelX = x; + const valueX = x + labelCol + gap; + + const contactRows = [ + issuer.phone ? ['Phone:', issuer.phone] : null, + issuer.mobile ? ['Mobile:', issuer.mobile] : null, + issuer.email ? ['Email:', issuer.email] : null, + issuer.website ? ['Web:', issuer.website] : null, + issuer.vatId ? ['VAT:', issuer.vatId] : null, + // Migration 139 — Steuernummer (DE/AT local tax number). Distinct + // from VAT-ID; both can appear simultaneously. + issuer.taxId ? ['Tax:', issuer.taxId] : null, + ].filter(Boolean); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8.5); + for (const [label, value] of contactRows) { + const rowY = y; + doc.text(label, labelX, rowY, { width: labelCol, align: 'left', lineBreak: false }); + doc.text(value, valueX, rowY, { width: valueCol, align: 'left', lineBreak: false }); + y = rowY + 11; + } + return Math.max(y, startY + 60); +} + +/** + * Render the recipient block INSIDE the DIN 5008 Form B address + * window. Two parts: + * + * 1. Return address line (small grey "Absender" reference) at the + * top of the window — this is what's visible through window + * envelopes above the actual address, by convention separated + * with "*" or "·". Optional; suppressed when issuerLine is + * blank. + * 2. Actual recipient block starting at ADDR_WINDOW.addressY: + * - With company → bold company name, then "z. Hd. " + * - Without company → bold person name, NO attention line + * (avoids the "Noam Mayer / z. Hd. Noam Mayer" duplicate) + * - Address: Street → "POSTAL CITY" (no country prefix on + * postal — the country line below carries that already) + * - Country line in caps for window-envelope readability + * + * The block is positioned absolutely; the caller does not need to + * thread a `y` cursor through. Returns the y of the next free row + * AFTER the address window (useful when drawing the horizontal + * divider below). + */ +function drawRecipientBlock(doc, recipient, locale) { + const x = ADDR_WINDOW.left; + const w = ADDR_WINDOW.width; + + // ---- tiny return address line at top of window ---------------- + if (recipient.issuerLine) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(7.5).fillColor('#555'); + doc.text(recipient.issuerLine, x, ADDR_WINDOW.returnLineY, { + width: w, align: 'left', lineBreak: false, + }); + } + + // ---- recipient address ---------------------------------------- + let y = ADDR_WINDOW.addressY; + + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(11).fillColor('#000'); + if (recipient.companyName) { + doc.text(recipient.companyName, x, y, { width: w }); + y = doc.y; + } + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10); + // Postal line mirrors the issuer block: "- " + // (e.g. "FL-9494 Schaan"). The country code prefix is dropped + // when the customer has no countryCodeIso so the line still + // reads cleanly. The country name on the line below comes from + // the explicit `country` override (customer_accounts.country_name, + // migration 107) or falls back to the locale-aware lookup. + const cc = recipient.countryCodeIso ? String(recipient.countryCodeIso).toUpperCase() : ''; + const pc = recipient.postalCode || ''; + const postalLeft = cc && pc ? `${cc}-${pc}` : (pc || cc); + const postalSegment = [postalLeft, recipient.city].filter(Boolean).join(' '); + const lines = [ + recipient.hasCompany ? recipient.attentionLine : null, + recipient.addressLine1, + recipient.addressLine2, + postalSegment, + recipient.country || countryName(recipient.countryCodeIso, locale), + ].filter(Boolean); + for (const line of lines) { + doc.text(line, x, y, { width: w }); + y = doc.y; + } + // Return position just below the address window so the caller + // can position the date row / title underneath. + return Math.max(y, ADDR_WINDOW.top + ADDR_WINDOW.height); +} + +/** + * Draw DIN 5008 folding marks on the LEFT page edge so the printed + * letter can be folded cleanly to fit a window envelope. + * + * 'half' → single mark at 148.5mm from top (C5 / half-fold) + * 'third' → DIN 5008 thirds-fold: marks at 105mm AND 210mm so the + * paper folds neatly into thirds for DL / C5-6 envelopes + * 'both' → 1/2 mark + both thirds marks (three total) + * 'none' (or anything else) → no marks + * + * Marks are drawn 7.5mm long, anchored against the left edge of the + * paper, 0.4pt hairline, mid-grey so they're visible to the person + * folding but unobtrusive when the page is scanned or photocopied. + */ +function drawFoldingMarks(doc, mode) { + if (!mode || mode === 'none') return; + const MARK_LEN_PT = 7.5 * MM; // 7.5mm = ~21.26pt + const ys = []; + if (mode === 'half' || mode === 'both') { + ys.push(148.5 * MM); // 1/2 fold (C5 envelope) + } + if (mode === 'third' || mode === 'both') { + // DIN 5008 thirds fold uses TWO marks at 105mm and 210mm. The + // 105mm line aligns with the top edge of the address window + // after the first fold; the 210mm line aligns with the next + // fold for the bottom third. + ys.push(105 * MM); + ys.push(210 * MM); + } + doc.save(); + doc.strokeColor('#888').lineWidth(0.4); + for (const y of ys) { + doc.moveTo(0, y).lineTo(MARK_LEN_PT, y).stroke(); + } + doc.restore(); +} + +function drawTitle(doc, title, x, y) { + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(20).fillColor('#000').text(title, x, y); + return doc.y + 8; +} + +function drawDate(doc, label, value, x, y, width) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + const right = x + width; + const labelWidth = 80; + doc.text(`${label}:`, right - labelWidth - 80, y, { width: 80, align: 'right' }); + doc.text(value, right - 80, y, { width: 80, align: 'right' }); + return doc.y + 10; +} + +/** + * Render the line-items table via swissqrbill's Table helper. We supply + * widths in points; the helper draws the borderless layout the + * reference PDF uses. + * + * Columns (quotes): Pos / Anzahl / Beschreibung / Rabatt / Einzelpreis / Summe + * Columns (invoices): Pos / Anzahl / Beschreibung / Einzelpreis / Summe + */ +function drawLineItems(doc, ctx) { + const { type, locale, lineItems, currency, intlLocale } = ctx; + // On a Stornorechnung the line items were snapshotted from the + // original at FULL positive amounts (so the DB-level invariant + // qty × unit = line_total still holds for both rows of the pair). + // The cancellation semantics live on the row-level totals, which + // are already negative in the DB. For the customer-facing PDF + // we flip the per-line total display sign so each row visually + // reads as a credit ("-CHF 300.00") — matches what bookkeepers + // expect on a Storno. + const isStorno = type === 'invoice' && ctx.doc?.kind === 'storno'; + const lineTotalSign = isStorno ? -1 : 1; + const labels = { + pos: t(locale, 'table_pos'), + qty: t(locale, 'table_qty'), + desc: t(locale, 'table_description'), + disc: t(locale, 'table_discount'), + unit: t(locale, 'table_unit_price'), + total: t(locale, 'table_line_total'), + }; + + const showDiscount = type === 'quote' && lineItems.some((li) => Number(li.discountPercent) > 0); + + // Column widths sum to PAGE.contentWidth = 515.28. swissqrbill's + // PDFColumn carries `width` + `align` directly on each cell; there + // is NO top-level `columns: [...]` on the Table constructor. The + // previous attempt to pass column widths separately was a no-op, + // which is why numeric cells were left-aligned even though their + // headers were right-aligned (header `textOptions.align` happened + // to work on PDFKit's underlying text() call, but cell-level + // alignment needs the API-supported `align` property). + // Column widths sum to PAGE.contentWidth = 515.28. The qty column + // gets a bit more room than the original 40pt so the German + // header "Anzahl" (6 chars at 10pt + padding ≈ 50pt) doesn't wrap + // across two lines. Width borrowed from the description column, + // which has plenty of slack. + // Column order matches the public quote response webpage: + // # / Description / Qty / [Discount] / Unit / Total + // The maintainer's call — description first reads more like a + // line-by-line list, which is how the web view presents it. + // Widths sum to PAGE.contentWidth (515.28); description takes the + // widest column, qty + numeric columns stay narrow but right- + // aligned. + const widths = showDiscount + ? [30, 225, 55, 50, 75, 80] + : [30, 275, 55, 70, 85]; + + // Per-row padding — tight rows. 3pt top + 3pt bottom keeps each + // line item compact, with just enough vertical breathing room + // for the divider lines to read clearly. swissqrbill's PDFPadding + // type requires array form (number | [top, right, bottom, left]); + // the earlier object form was silently dropped. + const ROW_PADDING = [3, 4, 3, 4]; + // Match the totals box font size; the maintainer wants the line + // items and the billing totals to read at the same weight so the + // eye doesn't bounce between two scales. + const ROW_FONT_SIZE = 10; + // Visual divider between items — thin grey rule under every data + // row. swissqrbill PDFRow supports `borderWidth` as a 4-tuple + // [top, right, bottom, left] and matching `borderColor`. We only + // want the bottom line on each data row, and a slightly darker + // bottom on the header row to anchor the column titles. The + // grand-total divider above the sum row is drawn separately in + // drawTotals; here we just delimit items from one another. + const ROW_BORDER_BOTTOM_WIDTH = [0, 0, 0.5, 0]; + const ROW_BORDER_BOTTOM_COLOR = ['#000', '#000', '#cccccc', '#000']; + const HEADER_BORDER_BOTTOM_WIDTH = [0, 0, 1, 0]; + const HEADER_BORDER_BOTTOM_COLOR = ['#000', '#000', '#000', '#000']; + + // Migration 119 — sub-items + details_text. + // + // Hierarchy rendering: + // - Top-level items get a numeric position (1, 2, 3...) and their + // line_total renders in full weight. + // - Sub-items render with an empty position column, the + // description indented with a bullet prefix ("• "), and + // their line_total wrapped in parentheses to mark it as + // display-only (doesn't roll into net). Sub-items with + // unit_price = 0 render the price columns empty. + // + // Details rendering: + // - Each item that has a non-empty details_text gets an extra + // row right below it: empty position cell + the details text + // spanning the description column (smaller font, italic, grey). + // swissqrbill's Table can't actually span columns, so the + // details row fills the description cell width and leaves the + // remaining columns empty — visually equivalent. + // + // We compute a displayIndex for top-level items so the position + // column stays 1..N regardless of how many sub-items sit between + // parents in the array. + let topLevelCount = 0; + const buildItemRow = (li) => { + const isSubItem = li.parentLineItemId != null || li.parentPosition != null; + const posLabel = isSubItem ? '' : String(++topLevelCount); + // Bullet (U+2022) is part of the WinAnsi character set that + // PDFKit's built-in Helvetica supports, unlike the earlier "↳" + // (U+21B3) which rendered as the font's .notdef glyph ("!3"). + // Custom TTFs registered via business_profile.pdf_font_ttf_path + // typically include the arrow too, but the bullet is the safe + // common-denominator that always renders. + const descText = isSubItem ? `\u2022 ${li.description || ''}` : (li.description || ''); + const subItemPriceless = isSubItem && (!li.unitPriceMinor || Number(li.unitPriceMinor) === 0); + const unitText = subItemPriceless ? '' : formatMinor(li.unitPriceMinor, currency, intlLocale); + const displayLineTotal = lineTotalSign * Number(li.lineTotalMinor || 0); + const lineTotalText = subItemPriceless + ? '' + : isSubItem + ? `(${formatMinor(displayLineTotal, currency, intlLocale)})` + : formatMinor(displayLineTotal, currency, intlLocale); + const numericColor = isSubItem ? '#666' : '#000'; + + return { + padding: ROW_PADDING, + fontSize: ROW_FONT_SIZE, + // Border is set by the caller (buildGroupRows) so the LAST row + // of each "group" (parent + sub-items + their details_text + // rows) carries the divider, and the rows above it leave the + // bottom edge empty. Without this, every row gets its own line + // and parent + sub-items look like separate items. + borderWidth: [0, 0, 0, 0], + columns: showDiscount + ? [ + { text: posLabel, width: widths[0], align: 'left' }, + { text: descText, width: widths[1], align: 'left', color: numericColor }, + { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, + { text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor }, + { text: unitText, width: widths[4], align: 'right', color: numericColor }, + { text: lineTotalText, width: widths[5], align: 'right', color: numericColor }, + ] + : [ + { text: posLabel, width: widths[0], align: 'left' }, + { text: descText, width: widths[1], align: 'left', color: numericColor }, + { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, + { text: unitText, width: widths[3], align: 'right', color: numericColor }, + { text: lineTotalText, width: widths[4], align: 'right', color: numericColor }, + ], + }; + }; + + /** + * Build a "details" row that follows an item with non-empty + * details_text. The details text fills the description cell at a + * smaller font + italic-ish (Helvetica-Oblique) + grey colour; + * other cells stay empty. No bottom border so the row visually + * belongs to the item above it. + */ + const buildDetailsRow = (text) => ({ + padding: [0, 4, 3, 4], + fontSize: 9, + borderWidth: [0, 0, 0, 0], + columns: showDiscount + ? [ + { text: '', width: widths[0], align: 'left' }, + { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, + { text: '', width: widths[2], align: 'right' }, + { text: '', width: widths[3], align: 'right' }, + { text: '', width: widths[4], align: 'right' }, + { text: '', width: widths[5], align: 'right' }, + ] + : [ + { text: '', width: widths[0], align: 'left' }, + { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, + { text: '', width: widths[2], align: 'right' }, + { text: '', width: widths[3], align: 'right' }, + { text: '', width: widths[4], align: 'right' }, + ], + }); + + const headerRow = { + // Table accepts any registered font name; if a custom font is in + // use we route the bold row through it too. + fontName: ctx.fonts?.bold || FONT_BOLD, + fontSize: ROW_FONT_SIZE, + padding: ROW_PADDING, + borderWidth: HEADER_BORDER_BOTTOM_WIDTH, + borderColor: HEADER_BORDER_BOTTOM_COLOR, + header: true, + columns: showDiscount + ? [ + { text: labels.pos, width: widths[0], align: 'left' }, + { text: labels.desc, width: widths[1], align: 'left' }, + { text: labels.qty, width: widths[2], align: 'right' }, + { text: labels.disc, width: widths[3], align: 'right' }, + { text: labels.unit, width: widths[4], align: 'right' }, + { text: labels.total, width: widths[5], align: 'right' }, + ] + : [ + { text: labels.pos, width: widths[0], align: 'left' }, + { text: labels.desc, width: widths[1], align: 'left' }, + { text: labels.qty, width: widths[2], align: 'right' }, + { text: labels.unit, width: widths[3], align: 'right' }, + { text: labels.total, width: widths[4], align: 'right' }, + ], + }; + + // Group rows so a parent + its sub-items + every involved details_text + // share ONE bottom divider drawn after the entire group. Without + // this grouping, each row (parent, sub-item, details) gets its own + // divider and the visual cohesion is lost — sub-items look like + // independent line items, and a details block looks orphaned below + // its parent's divider. + // + // Algorithm: + // - Iterate items in their array order (already grouped by the + // editor: parent → its sub-items → next parent). + // - Collect each parent's row + its details row + every sub-item's + // row + sub-items' details rows into a single "group" array. + // - Apply the bottom border ONLY to the last row of each group. + const dataRows = []; + const groups = []; + let currentGroup = null; + for (const li of lineItems) { + const isSubItem = li.parentLineItemId != null || li.parentPosition != null; + if (!isSubItem) { + // Start a new group at every top-level item. + currentGroup = []; + groups.push(currentGroup); + } else if (!currentGroup) { + // Defensive: if the array starts with an orphaned sub-item + // (shouldn't happen — validateLineItemHierarchy rejects this) + // give it its own group rather than crashing. + currentGroup = []; + groups.push(currentGroup); + } + currentGroup.push(buildItemRow(li)); + if (li.detailsText && String(li.detailsText).trim().length > 0) { + currentGroup.push(buildDetailsRow(String(li.detailsText).trim())); + } + } + // Apply the bottom border to the last row of each group. + for (const group of groups) { + if (group.length === 0) continue; + const last = group[group.length - 1]; + last.borderWidth = ROW_BORDER_BOTTOM_WIDTH; + last.borderColor = ROW_BORDER_BOTTOM_COLOR; + for (const row of group) dataRows.push(row); + } + + const table = new Table({ + width: PAGE.contentWidth, + rows: [headerRow, ...dataRows], + }); + table.attachTo(doc); + return doc.y; +} + +function stripTrailingZeros(value) { + if (value == null) return ''; + const num = Number(value); + if (Number.isNaN(num)) return String(value); + const s = num.toString(); + // Only strip zeros AFTER the decimal point. Naively replacing + // `/\.?0+$/` also ate the trailing zero in whole numbers like + // "10" → "1", which made a quantity of 10 render as 1 on the + // PDF while the total (qty * unit) stayed correct: Anzahl=10, + // Einzelpreis 123, Summe 1230, but the column read "1". + if (!s.includes('.')) return s; + return s.replace(/0+$/, '').replace(/\.$/, '') || '0'; +} + +/** + * Totals box, right-aligned. Two columns: label (left), value (right). + * VAT row drops when rate is 0 + amount is 0? No — reference shows + * "ges. MwSt. 0.0% 0.00" so we keep it visible. + */ +function drawTotals(doc, ctx, x, y, width) { + const { locale, currency, intlLocale, totals } = ctx; + // Layout: align the totals labels with the RIGHT column of the + // payment block beneath (where "Please transfer the amount …", + // "", and "" appear). Both columns of the + // payment block split the page in half, so the right-column + // anchor sits at `x + width/2 + 10` (mirrors drawPaymentBlock's + // `rightX = x + colWidth + 20` with colWidth = (width-20)/2). + // Values + VAT-rate column stay right-aligned to the page edge + // so amounts still stack tabularly. + const right = x + width; + const valueCol = 80; + const rateCol = 40; + const valueX = right - valueCol; + const rateX = right - valueCol - rateCol; + const labelX = x + (width - 20) / 2 + 20; // matches drawPaymentBlock.rightX + const labelCol = rateX - labelX - 6; // small gap before rate column + + // Divider line ABOVE the totals block — spans the FULL page + // content width (from the left margin to the right edge) so it + // visually closes off the line-items table above and the totals + // stack below as one continuous letterhead section. + doc.moveTo(x, y).lineTo(right, y).strokeColor('#000').lineWidth(0.8).stroke(); + y += 6; + + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10); + doc.text(t(locale, 'totals_net'), labelX, y, { width: labelCol }); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY); + doc.text(formatMinor(totals.netAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); + y = doc.y + 4; + + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_shipping'), labelX, y, { width: labelCol }); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); + y = doc.y + 4; + + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_vat'), labelX, y, { width: labelCol }); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' }); + doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); + y = doc.y + 4; + + // Mahngebühr row — only rendered when a late fee has been added + // (second reminder onwards). Sits between VAT and the grand-total + // divider so the customer sees a clear "VAT + late fee → Total" + // arithmetic chain. The grand-total figure below folds it in. + const lateFeeMinor = Number(totals.lateFeeAmountMinor || 0); + if (lateFeeMinor > 0) { + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_late_fee'), labelX, y, { width: labelCol }); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(lateFeeMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); + y = doc.y + 4; + } + y += 6; + + // Divider line above grand total — spans the right half of the + // page only, from the label anchor to the right edge, so it sits + // visually over the same column as "Please transfer …" below. + doc.moveTo(labelX, y).lineTo(right, y).strokeColor('#000').lineWidth(0.8).stroke(); + y += 6; + + // Grand-total row uses the SAME font size as the rows above (and + // as the line-item table) — the maintainer wants the billing + // titles to read at one consistent scale instead of stair- + // stepping up to a bigger headline. The row stays bold for + // visual emphasis. Includes the Mahngebühr when present so the + // customer's "owed" figure is the single bottom-line number. + const grandTotalMinor = Number(totals.totalAmountMinor || 0) + lateFeeMinor; + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10); + doc.text(t(locale, 'totals_grand'), labelX, y, { width: labelCol }); + doc.text(formatCurrencyLabel(currency), rateX, y, { width: rateCol, align: 'right' }); + doc.text(formatMinor(grandTotalMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); + return doc.y + 10; +} + +/** + * Render the payment conditions + IBAN block. Two columns side by side + * matching the reference layout: + * left: "Payment conditions: . The amount must be paid within + * 30 days from invoice date." + * right: "Please transfer the amount to the following bank account: + * " + */ +function drawPaymentBlock(doc, ctx, x, y, width) { + const { type, locale, paymentTerm, bank, intlLocale, totals, currency, issuer, doc: docMeta } = ctx; + const colWidth = (width - 20) / 2; + const leftX = x; + const rightX = x + colWidth + 20; + const startY = y; + + // Quote vs invoice differs in two ways: + // - Quotes never render the IBAN block (right column). A quote + // is an offer, not a demand for payment, so wiring money + // against an unsigned quote should not be encouraged. + // - Quotes honor the per-issuer toggles for the net-days line + // and the Skonto line. Both default true; setting either to + // false suppresses that specific row. + // Invoices always show every available row + the IBAN. + const isQuote = type === 'quote'; + const showNetDaysHere = isQuote ? (issuer?.quoteShowNetDays !== false) : true; + // Skonto is suppressed once the invoice is in dunning. A + // "Mahnrechnung" rewarding the customer with an early-payment + // discount makes no business sense — they're already late. + // Quotes still respect the per-issuer toggle. + const reminderLevel = Number(docMeta?.reminderLevel || 0); + const showSkontoHere = isQuote + ? (issuer?.quoteShowSkonto !== false) + : reminderLevel === 0; + const showIbanHere = !isQuote; + + // If the quote has nothing to print in either column, bail out + // early — don't render a bare "Payment conditions:" header with + // no rows under it. + const hasNetDaysRow = showNetDaysHere && paymentTerm?.netDays; + const hasSkontoRow = showSkontoHere && paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays; + // Late-fee note in the payment block is redundant now that the + // Mahngebühr appears as its own row in the totals stack. Keep it + // suppressed to avoid duplicate "+CHF 25.00 late fee" text. + const hasLateFeeRow = false; + const hasLeftContent = paymentTerm?.description || hasNetDaysRow || hasSkontoRow || hasLateFeeRow; + if (!hasLeftContent && !showIbanHere) return y; + + if (hasLeftContent) { + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000'); + doc.text(t(locale, 'payment_conditions') + ':', leftX, y, { width: colWidth }); + y = doc.y + 2; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10); + if (paymentTerm?.description) { + doc.text(paymentTerm.description, leftX, y, { width: colWidth }); + y = doc.y + 4; + } + if (hasNetDaysRow) { + doc.text( + `${paymentTerm.netDays} ${t(locale, 'net_days_suffix')}`, + leftX, y, { width: colWidth } + ); + y = doc.y + 4; + } + if (hasSkontoRow) { + doc.text( + t(locale, 'skonto_phrase', { + percent: stripTrailingZeros(paymentTerm.skontoPercent), + days: paymentTerm.skontoWithinDays, + }), + leftX, y, { width: colWidth } + ); + y = doc.y + 2; + // Show the post-discount amount so the customer doesn't have + // to do the math. Computed off the grand total (incl. VAT + + // shipping) per CH/DE convention. + const skontoTotalMinor = totals?.totalAmountMinor + ? Math.round(Number(totals.totalAmountMinor) * (1 - Number(paymentTerm.skontoPercent) / 100)) + : null; + if (skontoTotalMinor != null) { + doc.fillColor('#444').text( + `${t(locale, 'skonto_amount_label')}: ${formatCurrencyLabel(currency)} ${formatMinor(skontoTotalMinor, currency, intlLocale)}`, + leftX, y, { width: colWidth } + ); + doc.fillColor('#000'); + y = doc.y + 4; + } else { + y += 2; + } + } + // Late fee note for second-reminder invoices (never on quotes). + if (hasLateFeeRow) { + doc.fillColor('#a00').text( + t(locale, 'late_fee_note', { + amount: `${formatCurrencyLabel(ctx.currency)} ${formatMinor(docMeta.lateFeeMinor, ctx.currency, intlLocale)}`, + }), + leftX, y, { width: colWidth } + ); + doc.fillColor('#000'); + y = doc.y + 4; + } + } + + // Right column: IBAN (invoices only). + let ry = startY; + if (showIbanHere && bank) { + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10); + doc.text(t(locale, 'iban_intro'), rightX, ry, { width: colWidth }); + ry = doc.y + 4; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10); + if (bank.accountHolder) { + doc.text(bank.accountHolder, rightX, ry, { width: colWidth }); + ry = doc.y; + } + if (bank.iban) { + const formatted = bank.iban.replace(/(.{4})/g, '$1 ').trim(); + doc.text(formatted, rightX, ry, { width: colWidth }); + ry = doc.y; + } + if (bank.bic) { + doc.text(`BIC: ${bank.bic}`, rightX, ry, { width: colWidth }); + ry = doc.y; + } + } + return Math.max(y, ry) + 8; +} + +function drawFooter(doc, issuer, locale) { + // Footer format (per design review): + // ", , - , " + // e.g. + // "Luca Bresch Media, Im Fetzer 45a, FL-9494 Schaan, Liechtenstein" + // + // The previous version printed ` , ` which + // dropped the country prefix from the postal block AND used the + // bare ISO code instead of the full country name. + // + // Footer sits within the content area (above the bottom margin) — + // writing past doc.page.height - marginBottom triggers PDFKit's + // auto-page-break (the original bug behind the mysterious empty + // trailing pages). + const lineH = 12; + const hasFooterLine = !!issuer.footerLine; + const reserved = hasFooterLine ? lineH * 2 + 4 : lineH; + const footerY = doc.page.height - PAGE.marginBottom - reserved; + + const cc = issuer.countryCode ? String(issuer.countryCode).toUpperCase() : ''; + const pc = issuer.postalCode || ''; + const postalLeft = cc && pc ? `${cc}-${pc}` : (pc || cc); + const postalSegment = [postalLeft, issuer.city].filter(Boolean).join(' '); + + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888'); + const parts = [ + issuer.companyName, + issuer.addressLine1, + postalSegment, + // Prefer the explicit country_name override (migration 107) + // before falling back to the COUNTRY_NAMES lookup. + issuer.countryName || countryName(issuer.countryCode, locale), + ].filter(Boolean); + doc.text(parts.join(', '), PAGE.marginLeft, footerY, { + width: PAGE.contentWidth, align: 'center', lineBreak: false, + }); + if (hasFooterLine) { + doc.text(issuer.footerLine, PAGE.marginLeft, footerY + lineH, { + width: PAGE.contentWidth, align: 'center', lineBreak: false, + }); + } + // Reset fill colour so any code that runs after the footer (e.g. + // the appendSwissQrBill page) doesn't inherit the grey. + doc.fillColor('#000'); +} + +/** + * Add the Swiss QR-bill payment slip on a fresh page. This is rendered + * by the swissqrbill library — we just feed it the issuer/recipient/ + * amount. For non-swiss QR formats this returns without adding a page. + * + * The QR-bill spec REQUIRES the slip on a separate physical page, full + * width at the bottom — swissqrbill handles all of that. + */ +function appendSwissQrBill(doc, ctx) { + if (ctx.qrFormat !== 'swiss') return; + const { issuer, bank, doc: docMeta, recipient } = ctx; + if (!bank?.iban) return; + + doc.addPage(); + + // swissqrbill expects amounts in major units (CHF, not Rappen). + const totalMajor = Number(docMeta.totalAmountMinor || 0) / 100; + + try { + const qr = new SwissQRBill({ + currency: (ctx.currency || 'CHF').toUpperCase() === 'EUR' ? 'EUR' : 'CHF', + amount: totalMajor > 0 ? totalMajor : undefined, + creditor: { + name: bank.accountHolder || issuer.companyName || '', + address: issuer.addressLine1 || '', + zip: issuer.postalCode || '', + city: issuer.city || '', + country: (issuer.countryCode || 'CH').toUpperCase(), + account: bank.iban.replace(/\s+/g, ''), + }, + debtor: recipient?.companyName ? { + name: recipient.companyName.slice(0, 70), + address: recipient.addressLine1 || '', + zip: recipient.postalCode || '', + city: recipient.city || '', + country: (recipient.countryCodeIso || 'CH').toUpperCase(), + } : undefined, + message: docMeta.invoiceNumber ? `${docMeta.invoiceNumber}` : undefined, + }); + qr.attachTo(doc); + } catch (err) { + // Don't kill PDF rendering if QR generation fails — log + carry on. + // The invoice without QR is still legally valid; admin gets a flag + // via the calling service. + const logger = require('../utils/logger'); + logger.warn('SwissQRBill render failed; emitting invoice without QR section', { err: err.message }); + } +} + +/** + * Build an EPC069-12 (SEPA Credit Transfer) QR payload. + * + * Format (each field on its own line, '\n' separator): + * 1. "BCD" service tag + * 2. "002" version + * 3. "1" character set (UTF-8) + * 4. "SCT" identification (SEPA Credit Transfer) + * 5. BIC optional in v002 + * 6. Beneficiary name max 70 chars, required + * 7. IBAN no spaces, required + * 8. Amount "EUR123.45", optional (customer + * enters amount manually if absent) + * 9. Purpose ISO 11649 4-letter, optional + * 10. Structured reference max 35 chars, optional + * 11. Unstructured reference max 140 chars, optional + * 12. Beneficiary-to-originator info max 70 chars, optional + * + * Total payload <= 331 bytes. EPC QR is EUR-only; banking apps + * silently reject non-EUR payloads. + */ +function buildEpcPayload({ name, iban, amount, currency, reference }) { + // Amount field: "". + // Spec says EUR-only, but many wallets accept other 3-letter + // codes and either honour or ignore them. Emit whatever the + // invoice carries so the QR isn't a no-op for CHF/USD/etc. + const cur = String(currency || 'EUR').toUpperCase().slice(0, 3); + const lines = [ + 'BCD', + '002', + '1', + 'SCT', + '', // BIC (optional in v002) + String(name || '').slice(0, 70), + String(iban || '').replace(/\s+/g, '').toUpperCase(), + amount > 0 ? `${cur}${amount.toFixed(2)}` : '', + '', // purpose + '', // structured reference + String(reference || '').slice(0, 140), // unstructured reference + '', // info + ]; + return lines.join('\n'); +} + +/** + * Append an EPC (SEPA) QR code to the document. Unlike Swiss QR-bill + * which is a full-page payment slip, EPC is just a QR code with a + * short caption — banking apps scan it to prefill a SEPA Credit + * Transfer. We add it on a fresh page so it never collides with the + * line items / totals above. + * + * Requires EUR currency. Non-EUR docs log a warning and skip — EPC + * QR codes in CHF/USD/etc. are silently rejected by every major + * banking app, so emitting one would be worse than emitting nothing. + */ +async function appendEpcQr(doc, ctx) { + if (ctx.qrFormat !== 'epc') return; + const logger = require('../utils/logger'); + const { issuer, bank, doc: docMeta } = ctx; + + if (!bank?.iban) { + logger.warn('EPC QR skipped — no IBAN on the resolved bank account'); + return; + } + + // EPC069-12 spec is technically EUR-only, but most banking apps + // still parse the payload for non-EUR currencies and either honor + // it (when the bank supports the destination currency) or fall + // back to manual entry. Render the QR regardless and log a note + // when the currency isn't EUR so the admin sees it in the logs — + // emitting something is always more useful than emitting nothing. + const currencyUpper = (ctx.currency || 'EUR').toUpperCase(); + if (currencyUpper !== 'EUR') { + logger.info('EPC QR rendered with non-EUR currency; banking apps may fall back to manual entry', { + currency: currencyUpper, + }); + } + + const totalMajor = Number(docMeta.totalAmountMinor || 0) / 100; + const payload = buildEpcPayload({ + name: bank.accountHolder || issuer.companyName || '', + iban: bank.iban, + amount: totalMajor, + currency: currencyUpper, + reference: docMeta.invoiceNumber || '', + }); + + let pngBuffer; + try { + const QRCode = require('qrcode'); + pngBuffer = await QRCode.toBuffer(payload, { + errorCorrectionLevel: 'M', + type: 'png', + margin: 2, + width: 320, + }); + } catch (err) { + logger.warn('EPC QR generation failed', { err: err.message }); + return; + } + + // Fresh page so the QR doesn't fight the totals/payment layout on + // page 1. Centered, with a caption explaining what it is. + doc.addPage(); + const captionTop = PAGE.marginTop + 20; + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(14).fillColor('#000'); + doc.text(t(ctx.locale, 'epc_qr_title'), PAGE.marginLeft, captionTop, { + width: PAGE.contentWidth, align: 'center', lineBreak: false, + }); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#444'); + doc.text(t(ctx.locale, 'epc_qr_subtitle'), PAGE.marginLeft, captionTop + 22, { + width: PAGE.contentWidth, align: 'center', + }); + + // QR centred on the page, sized at ~180pt (≈63mm) — comfortably + // scannable on every phone camera + small enough to leave room + // for the printed IBAN beneath. + const qrSize = 180; + const qrX = (PAGE.width - qrSize) / 2; + const qrY = captionTop + 60; + try { + doc.image(pngBuffer, qrX, qrY, { fit: [qrSize, qrSize] }); + } catch (err) { + logger.warn('EPC QR embed failed', { err: err.message }); + return; + } + + // Human-readable summary under the QR so the customer can still + // initiate the transfer manually if their banking app can't scan. + const summaryY = qrY + qrSize + 24; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + const summaryLines = [ + bank.accountHolder || issuer.companyName || '', + bank.iban.replace(/(.{4})/g, '$1 ').trim(), + bank.bic ? `BIC: ${bank.bic}` : '', + totalMajor > 0 + ? `${t(ctx.locale, 'totals_grand')}: ${currencyUpper} ${formatMinor(docMeta.totalAmountMinor, currencyUpper, ctx.intlLocale)}` + : '', + docMeta.invoiceNumber + ? `${t(ctx.locale, 'reference_label')}: ${docMeta.invoiceNumber}` + : '', + ].filter(Boolean); + let lineY = summaryY; + for (const line of summaryLines) { + doc.text(line, PAGE.marginLeft, lineY, { width: PAGE.contentWidth, align: 'center' }); + lineY = doc.y + 2; + } +} + +/** + * Build a configured PDFDocument with our font conventions and return + * it alongside its page metrics. Used by both the quote/invoice + * renderer below (portrait, DIN 5008) and the tax-report renderer + * (landscape, wide table). Keeps font registration + page sizing in + * one place so future PDF features stay consistent. + * + * options = { + * orientation: 'portrait' | 'landscape' (default 'portrait'), + * issuer: { pdfFontTtfPath? } — used for optional custom-font registration, + * info: { Title?, Author? } — PDF metadata (filename in Chrome viewer), + * } + * + * Returns: { doc, page, fonts } where + * doc — pdfkit PDFDocument instance, ready to write to + * page — page metrics for the chosen orientation (see getPageMetrics) + * fonts — { body, bold } logical font names; the caller passes these + * to doc.font(...) calls and they resolve to either the + * built-in Helvetica family or the admin's custom TTF. + * + * The function does NOT pipe the document to a stream — the caller + * decides whether to buffer (`doc.on('data', ...)`) or stream straight + * to an HTTP response. Mirrors the pattern the existing renderDocument + * already uses internally. + */ +function createBaseDocument(options = {}) { + const orientation = options.orientation === 'landscape' ? 'landscape' : 'portrait'; + const page = getPageMetrics(orientation); + const doc = new PDFDocument({ + size: 'A4', + layout: orientation, + bufferPages: true, + margins: { + top: page.marginTop, bottom: page.marginBottom, + left: page.marginLeft, right: page.marginRight, + }, + info: options.info || {}, + }); + + // Font registration. Resolution priority: + // 1. issuer.pdfFontTtfPath → legacy free-text upload (migration 103). + // The UI for setting it was retired in favour of the dropdown, + // but any existing value still wins so deployments that already + // pointed at a custom brand font keep rendering with it. + // 2. issuer.pdfFontFamily → bundled-fonts dropdown (migration 121). + // Maps to backend/assets/fonts//400.ttf for body and + // /700.ttf for bold. Falls back to 600/400 if 700 is + // missing (some families don't ship every weight). + // 3. Helvetica → PDFKit's built-in default. + // + // Same block is mirrored below in renderDocument so quote / invoice + // / tax-report PDFs all resolve fonts identically. + doc._fonts = { body: FONT_BODY, bold: FONT_BOLD }; + const issuer = options.issuer || {}; + const fontRegistered = registerCustomFonts(doc, issuer); + if (fontRegistered) doc._fonts = fontRegistered; + + return { doc, page, fonts: doc._fonts }; +} + +/** + * Try to register a custom font pair on the given doc per the + * priority order documented on createBaseDocument. Returns the new + * `{ body, bold }` logical-font-names object when a custom font is + * applied, or `null` when we fell through to Helvetica. + * + * Exported via _internal for unit tests. + */ +function registerCustomFonts(doc, issuer) { + if (!issuer || typeof issuer !== 'object') return null; + const path = require('path'); + const fs = require('fs'); + + // Priority 1: legacy free-text path. + if (issuer.pdfFontTtfPath) { + try { + const raw = issuer.pdfFontTtfPath; + const candidates = [ + path.isAbsolute(raw) ? raw : null, + path.join(process.cwd(), 'storage', raw.replace(/^\/+/, '')), + path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)), + ].filter(Boolean); + const found = candidates.find((p) => { try { return fs.existsSync(p); } catch { return false; } }); + if (found && /\.(ttf|otf)$/i.test(found)) { + doc.registerFont(CUSTOM_BODY, found); + doc.registerFont(CUSTOM_BOLD, found); + return { body: CUSTOM_BODY, bold: CUSTOM_BOLD }; + } + } catch { /* fall through to family / Helvetica */ } + } + + // Priority 2: bundled-fonts dropdown. + if (issuer.pdfFontFamily) { + try { + // Sanitise the family name aggressively — comes from user input + // (a saved dropdown value), so prevent path traversal even + // though directory names should always be plain ASCII like + // "Inter" or "Playfair-Display". + const family = String(issuer.pdfFontFamily).replace(/[^A-Za-z0-9_-]/g, ''); + if (family) { + const fontsRoot = path.resolve(__dirname, '../../assets/fonts', family); + const bodyCandidates = ['400.ttf', '500.ttf', '600.ttf', '700.ttf']; + const boldCandidates = ['700.ttf', '600.ttf', '500.ttf', '400.ttf']; + const findFirst = (names) => { + for (const n of names) { + const full = path.join(fontsRoot, n); + try { if (fs.existsSync(full)) return full; } catch { /* ignore */ } + } + return null; + }; + const bodyFile = findFirst(bodyCandidates); + const boldFile = findFirst(boldCandidates); + if (bodyFile && boldFile) { + doc.registerFont(CUSTOM_BODY, bodyFile); + doc.registerFont(CUSTOM_BOLD, boldFile); + return { body: CUSTOM_BODY, bold: CUSTOM_BOLD }; + } + } + } catch { /* fall through to Helvetica */ } + } + + return null; +} + +/** + * The main renderer. `type` is 'quote' | 'invoice'. Returns Buffer. + */ +function renderDocument(type, context) { + return new Promise((resolve, reject) => { + // Wrap the body in an async IIFE so we can `await` the EPC QR + // PNG generation (which uses the qrcode library asynchronously). + // Errors from the IIFE bubble up via reject(); the doc 'end' + // event still resolves the outer Promise once writes flush. + (async () => { + try { + const ctx = normaliseContext(type, context); + const doc = new PDFDocument({ + size: 'A4', + // bufferPages: true keeps every page open in memory after + // they're emitted so we can switch back and stamp the page + // numbers ("Page 1 of N" / "Seite 1 von N") once we know how + // many pages the document ended up with. Without buffering, + // PDFKit flushes each page as soon as the next one starts, + // so we couldn't know N until it was too late. + bufferPages: true, + margins: { + top: PAGE.marginTop, bottom: PAGE.marginBottom, + left: PAGE.marginLeft, right: PAGE.marginRight, + }, + info: { + // Chrome's built-in PDF viewer uses this Title metadata + // as the default save name when the PDF is served from a + // blob URL (where the original HTTP Content-Disposition + // header can't propagate). Format mirrors the filename + // we set on the HTTP response: "_" + // so saved files have a meaningful name in either path. + Title: (() => { + const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber + || (type === 'quote' ? 'Quote' : 'Invoice'); + // Prefer the recipient (customer) for the label — + // matches how admins typically file invoices. + const recipient = ctx.recipient?.companyName || ''; + return recipient ? `${docNumber}_${recipient}` : String(docNumber); + })(), + Author: ctx.issuer.companyName || 'picpeak', + }, + }); + + const chunks = []; + doc.on('data', (c) => chunks.push(c)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + // Font registration. Same resolution priority as + // createBaseDocument: pdfFontTtfPath (legacy override) → + // pdfFontFamily (bundled dropdown) → Helvetica. Helpers below + // read `doc._fonts` (one extra word per doc) so we don't have + // to thread the font names through every drawing function or + // fork the helpers per branding. + doc._fonts = { body: FONT_BODY, bold: FONT_BOLD }; + ctx.fonts = doc._fonts; + const registered = registerCustomFonts(doc, ctx.issuer); + if (registered) { + doc._fonts = registered; + ctx.fonts = registered; + } + + // ---- header layout (DIN 5008 Form B) ------------------------- + // - recipient block in the address window (top-left, + // 45mm from top, 20mm from left, 85×45mm) + // - issuer block top-right (logo + company + address + + // contact) sized to NOT overlap the address window + // + // The two blocks are positioned absolutely; we keep a `y` + // cursor for the body content that starts BELOW both blocks. + const leftX = PAGE.marginLeft; + // Sender block: narrower (180pt vs 220pt), further right, and + // nudged down by 16pt so it doesn't crowd the very top of the + // page. Leaves more breathing room for the logo + name banner. + const issuerWidth = 180; + const issuerX = PAGE.width - PAGE.marginRight - issuerWidth; + const issuerY = PAGE.marginTop + 16; + + const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale); + const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale); + // Start the body content below the header blocks AND the + // address-window bottom edge — never let the date/title row + // cut through the window region. The title position isn't + // dictated by DIN 5008 (the spec only fixes the address window + // position), so we pull it tight against the window's bottom + // edge to give the body more vertical room. + let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6; + + // Storno discriminator. Drives: + // - page title swap ("Stornorechnung" instead of "Rechnung") + // - mandatory reference line under the title + // - sign flip on line totals (row-level totals are already + // stored negative in the DB, so drawTotals renders them + // naturally — see drawLineItems for the per-item flip) + // - suppression of payment terms / IBAN / QR-bill blocks + // `type === 'invoice'` is preserved as the outer document + // family — Storni share the invoice renderer surface, only + // the cosmetic + accounting-sign branches differ. + const isStorno = type === 'invoice' && ctx.doc.kind === 'storno'; + + // ---- document number (above) + date (below), both right-aligned + // The number sits directly under the sender address block so the + // customer + accountant find the invoice/quote/Storno reference + // exactly where DACH letter convention puts it. The date follows + // on its own row with the same right-anchored column structure so + // both label-and-value pairs align to the same right edge. + const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || ''; + const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label'; + const metaRight = leftX + PAGE.contentWidth; + const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap + const metaValueW = 110; + if (docNumberForDisplay) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + doc.text(`${t(ctx.locale, numberLabelKey)}:`, + metaRight - metaValueW - metaLabelW, y, + { width: metaLabelW, align: 'right', lineBreak: false }); + doc.text(docNumberForDisplay, metaRight - metaValueW, y, + { width: metaValueW, align: 'right', lineBreak: false }); + y += 14; + } + // Date row — same right-anchored layout so the two values stack + // visually as a single meta block. Replaces the previous + // drawDate() call, which lived below the title and used a + // tighter column spec. + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + doc.text(`${t(ctx.locale, 'date')}:`, + metaRight - metaValueW - metaLabelW, y, + { width: metaLabelW, align: 'right', lineBreak: false }); + doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat), + metaRight - metaValueW, y, + { width: metaValueW, align: 'right', lineBreak: false }); + y += 18; // line height + cushion before the title + + // ---- title ---------------------------------------------------- + const title = type === 'quote' + ? t(ctx.locale, 'quote_title') + : isStorno + ? t(ctx.locale, 'storno_title') + : t(ctx.locale, 'invoice_title'); + y = drawTitle(doc, title, leftX, y + 2); + + // Mandatory Storno reference line — "Bezug: Storno zu Rechnung + // R-XXXX vom DATE". This is the §14c-defensible link from the + // cancellation document to the invoice it reverses; readers + // and Finanzamt auditors need both numbers + the original + // issue date to reconstruct the chain from the documents + // alone. Stamped FIRST (before sourceQuote / replaces) so + // it's the prominent reference on a Storno. + if (isStorno && ctx.doc.cancelsInvoice) { + const { number, issueDate } = ctx.doc.cancelsInvoice; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; + doc.fillColor('#000'); + } + + // Invoice → source quote cross-reference. We deliberately keep + // invoice numbers on a strict monotonic sequence (R-YYYY-NNNN) + // for tax-compliance reasons (CH/LI/DE/AT require + // "lückenlose Rechnungsnummern") — instead of mirroring the + // quote number on the invoice, we surface the link as a small + // "Bezug: Angebot Q-…" line under the title. Readers see the + // provenance without breaking the numbering scheme. Only + // rendered for invoices that came from a quote; no-op for + // standalone invoices and Storni (which don't reference quotes). + if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; + doc.fillColor('#000'); + } + // Cancel + reissue trail (migration 114) — when this invoice + // replaces an earlier (cancelled) one, surface "Bezug: Ersetzt + // Rechnung R-XXXX vom DATE" so the customer (and auditors) can + // trace the chain. Rendered in the same grey-666 small-print + // style as the quote-source reference above. Suppressed on + // Storni (which carry their own cancelsInvoice reference). + if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) { + const { number, issueDate } = ctx.doc.replacesInvoice; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; + doc.fillColor('#000'); + } + + // ---- salutation + lead-in ------------------------------------ + // Personalised greeting when the customer record has an + // honorific + last name on file ("Sehr geehrter Herr Bresch,"), + // otherwise the generic locale-specific opening from the i18n + // dictionary ("Sehr geehrte Damen und Herren,"). + const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName) + || t(ctx.locale, 'salutation'); + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000'); + doc.text(greeting, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 4; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY); + const leadIn = type === 'quote' + ? t(ctx.locale, 'lead_in_quote') + : t(ctx.locale, 'lead_in_invoice'); + doc.text(leadIn, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 16; + + // ---- intro text override (admin-customisable) ----------------- + if (ctx.doc.introText) { + doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 12; + } + + // ---- line items table ---------------------------------------- + // Small top padding — tight against the lead-in text since the + // maintainer wants the items right under the greeting/intro. + y += 8; + doc.y = y; + doc.x = leftX; + + // Force the items table to auto-paginate BEFORE it can collide + // with the totals + payment block at the page bottom. We + // compute the same anchor as below, then temporarily inflate + // the page's bottom margin so swissqrbill's Table sees a + // shorter usable area and breaks to a new page when items + // would otherwise spill into the totals zone. The header row + // is already marked `header: true` so it auto-repeats on the + // continuation page. + const _origBottomMargin = doc.page.margins.bottom; + const _itemsBottomReserve = PAGE.marginBottom + + 30 // FOOTER_RESERVE + + (ctx.paymentTerm ? 80 : 50) // PAYMENT_BLOCK_HEIGHT + + 12 // gap between totals + payment + + 90 // TOTALS_BLOCK_HEIGHT + + 20; // small breathing room + doc.page.margins.bottom = _itemsBottomReserve; + try { + drawLineItems(doc, ctx); + } finally { + // Restore even if drawLineItems threw — keeps subsequent + // pages on the document's normal margin geometry. + doc.page.margins.bottom = _origBottomMargin; + } + // y after the table — used only to detect whether the items + // overflowed past the totals anchor below. We don't use it as + // the totals position directly because the totals block is + // pinned to a fixed offset from the page bottom regardless of + // how many items rendered. + y = doc.y; + + // ---- pin totals + payment block to footer --------------------- + // The totals box + payment block ALWAYS render at the same + // distance from the page bottom regardless of how many line + // items rendered. Reserves below are conservative-but-tight: + // they reflect the actual measured block heights, with just + // enough breathing room that a wrapped line or extra Skonto + // row doesn't crash into the footer. + // FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap) + // PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without + // (header + 3-4 rows including the + // skonto + skonto_amount lines) + // TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping + + // VAT + middle divider + Total) + const FOOTER_RESERVE = 30; + const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50; + const TOTALS_BLOCK_HEIGHT = 90; + const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT; + const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT; + + // If line items used more space than the totals anchor allows, + // advance to a new page before drawing totals — keeps the + // bottom block at a CONSTANT position from the footer on + // whatever page it lands on. + if (y > desiredTotalsY) { + doc.addPage(); + } + // Always reset to the fixed anchor — independent of where the + // table ended on the page. + y = desiredTotalsY; + + // ---- totals box (right-aligned) ------------------------------- + y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth); + + // ---- outro text ----------------------------------------------- + if (ctx.doc.outroText) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 12; + } + + // ---- payment conditions + IBAN block -------------------------- + // Pin the payment block to the fixed anchor too — the totals + // box can end short of it (e.g. when only Net + Total render + // with no shipping/VAT), so we snap back unconditionally. + // Suppressed on Stornorechnungen: a cancellation document is + // not a payment instrument — no Zahlungsbedingungen, no IBAN, + // no Skonto. Customers reading a Storno expect total clarity + // that this is the REVERSAL of an obligation, not a new one. + if (!isStorno) { + y = desiredPaymentY; + y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth); + } + + // ---- folding marks (left edge) -------------------------------- + drawFoldingMarks(doc, ctx.issuer?.foldingMarks); + + // ---- footer --------------------------------------------------- + drawFooter(doc, ctx.issuer, ctx.locale); + + // ---- payment QR on fresh page (invoices only) ----------------- + // Two paths, mutually exclusive: + // - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI) + // - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank) + // Both append a fresh page; 'none' is a no-op. + // Suppressed on Stornorechnungen — negative-amount QR codes + // aren't a defined construct in either spec. + if (type === 'invoice' && !isStorno) { + if (ctx.qrFormat === 'swiss') { + appendSwissQrBill(doc, ctx); + } else if (ctx.qrFormat === 'epc') { + await appendEpcQr(doc, ctx); + } + } + + // ---- page numbers ("Page 1 of N" / "Seite 1 von N") ----------- + // Stamped after everything else so we know the final page + // count. bufferPages: true (on the PDFDocument options above) + // keeps every page open for back-editing — bufferedPageRange() + // returns {start, count}. We switchToPage() each one, draw the + // pagination label in the bottom-right corner, then end. + try { + const range = doc.bufferedPageRange(); + const total = range.count; + // Stamp on EVERY page including single-page documents. The + // "Page 1 of 1" label is a tamper-evidence cue for the + // recipient — if they receive page 1 of 3 in isolation, + // they know pages are missing; conversely "1 of 1" lets a + // single-page invoice confirm it's complete. The cost (one + // grey line in the bottom corner) is negligible. + for (let i = 0; i < total; i++) { + doc.switchToPage(range.start + i); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888'); + const label = t(ctx.locale, 'page_of', { + current: i + 1, + total, + }); + // Bottom-right corner, just above the bottom margin so + // it doesn't trigger PDFKit's auto-paging. + const labelY = doc.page.height - PAGE.marginBottom - 12; + const labelW = 120; + const labelX = doc.page.width - PAGE.marginRight - labelW; + doc.text(label, labelX, labelY, { + width: labelW, align: 'right', lineBreak: false, + }); + doc.fillColor('#000'); + } + } catch (err) { + const logger = require('../utils/logger'); + logger.warn('Failed to stamp page numbers on PDF', { err: err.message }); + } + + doc.end(); + } catch (err) { + reject(err); + } + })(); + }); +} + +/** + * Normalise + default the context shape so the rest of the renderer + * can rely on it without optional-chaining everywhere. + */ +function normaliseContext(type, ctx) { + const locale = ctx.locale || 'de'; + return { + type, + locale, + intlLocale: localeForIntl(locale, ctx.issuer?.countryCode), + currency: (ctx.currency || ctx.doc?.currency || ctx.issuer?.defaultCurrency || 'CHF').toUpperCase(), + issuer: ctx.issuer || {}, + recipient: ctx.recipient || {}, + bank: ctx.bank || null, + paymentTerm: ctx.paymentTerm || null, + lineItems: Array.isArray(ctx.lineItems) ? ctx.lineItems : [], + totals: ctx.totals || {}, + doc: ctx.doc || {}, + qrFormat: ctx.qrFormat || 'none', + // Date-format config from the `general_date_format` app setting. + // Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | + // 'YYYY-MM-DD', locale?: string }`. The service layer hydrates + // this; defaults to DD.MM.YYYY when unset. + dateFormat: ctx.dateFormat || { format: 'DD.MM.YYYY' }, + }; +} + +async function renderQuoteToBuffer(context) { + return renderDocument('quote', context); +} + +async function renderInvoiceToBuffer(context) { + return renderDocument('invoice', context); +} + +/** + * Render a contract PDF. `context` is the shape produced by + * contractService.buildRenderContext: { locale, issuer, recipient, doc, + * sections, signatures }. Returns Promise. + * + * Layout: + * - DIN 5008 envelope window (same as quotes/invoices) so the + * recipient address lines up with envelope windows. + * - Title from doc.title (admin-typed) or t('contract_title'). + * - Contract number + issue date right-aligned under the issuer block. + * - intro_text paragraph. + * - For each section: bold heading from t('section_'), then each + * block rendered as a paragraph (block.name bold, then block.body). + * - outro_text paragraph. + * - Two-column signature block at the bottom of the closing page. + * If signature PNGs exist in context.signatures.{customer,admin}.signaturePath + * they're stamped into the box; otherwise blank lines for handwritten + * wet-signing. + */ +function renderContractToBuffer(context) { + return new Promise((resolve, reject) => { + (async () => { + try { + const ctx = context || {}; + const locale = ctx.locale || 'de'; + const doc = new PDFDocument({ + size: 'A4', + bufferPages: true, + margins: { + top: PAGE.marginTop, bottom: PAGE.marginBottom, + left: PAGE.marginLeft, right: PAGE.marginRight, + }, + info: { + Title: `${ctx.doc?.contractNumber || 'Contract'}${ctx.recipient?.companyName ? '_' + ctx.recipient.companyName : ''}`, + Author: ctx.issuer?.companyName || 'picpeak', + }, + }); + + const chunks = []; + doc.on('data', (c) => chunks.push(c)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + doc._fonts = { body: FONT_BODY, bold: FONT_BOLD }; + const registered = registerCustomFonts(doc, ctx.issuer || {}); + if (registered) doc._fonts = registered; + + // ---- header: issuer + recipient blocks (DIN 5008) ------------ + const issuerWidth = 180; + const issuerX = PAGE.width - PAGE.marginRight - issuerWidth; + const issuerY = PAGE.marginTop + 16; + + const issuerEndY = drawIssuerBlock(doc, ctx.issuer || {}, issuerX, issuerY, issuerWidth, locale); + const recipientEndY = drawRecipientBlock(doc, ctx.recipient || {}, locale); + let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6; + + // ---- contract number + date (right-aligned) ------------------ + const docNumberForDisplay = ctx.doc?.contractNumber || ''; + const numberLabel = t(locale, 'contract_number_label'); + const dateLabel = t(locale, 'date'); + const issueDateDisplay = formatDate(ctx.doc?.issueDate, locale); + const labelColumnWidth = 110; + const valueColumnWidth = 120; + const blockWidth = labelColumnWidth + valueColumnWidth; + const blockRightX = PAGE.width - PAGE.marginRight; + const blockLeftX = blockRightX - blockWidth; + + doc.font(doc._fonts.body).fontSize(9).fillColor('#000'); + // Number row + doc.text(numberLabel, blockLeftX, y, { width: labelColumnWidth, align: 'right' }); + doc.font(doc._fonts.bold).text( + docNumberForDisplay, + blockLeftX + labelColumnWidth, + y, + { width: valueColumnWidth, align: 'right' }, + ); + y += 14; + // Date row + doc.font(doc._fonts.body); + doc.text(dateLabel, blockLeftX, y, { width: labelColumnWidth, align: 'right' }); + doc.text( + issueDateDisplay, + blockLeftX + labelColumnWidth, + y, + { width: valueColumnWidth, align: 'right' }, + ); + y += 22; + + // ---- title -------------------------------------------------- + const title = ctx.doc?.title || t(locale, 'contract_title'); + doc.font(doc._fonts.bold).fontSize(18).fillColor('#000'); + doc.text(title, PAGE.marginLeft, y, { width: PAGE.contentWidth }); + y = doc.y + 10; + + // ---- helper: ensure space before drawing, paginate if needed. + const bottomLimit = PAGE.height - PAGE.marginBottom - 20; + function ensureSpace(needed) { + if (y + needed > bottomLimit) { + doc.addPage(); + y = PAGE.marginTop; + } + } + + // ---- helper: render body text with inline **bold** support. + // Splits on `**text**` markers, switches the font weight per + // chunk via PDFKit's continued: true text continuation. The + // first chunk anchors at (PAGE.marginLeft, y); subsequent + // chunks continue from PDFKit's cursor so wrapping works + // across font switches. After rendering, we read doc.y as + // the new cursor. + function renderBodyMarkdown(text, opts) { + const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0); + if (parts.length === 0) return; + const last = parts.length - 1; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isBold = part.length > 4 && part.startsWith('**') && part.endsWith('**'); + const chunk = isBold ? part.slice(2, -2) : part; + if (!chunk) continue; + doc.font(isBold ? doc._fonts.bold : doc._fonts.body); + if (i === 0) { + doc.text(chunk, PAGE.marginLeft, y, { ...opts, continued: i < last }); + } else { + doc.text(chunk, { ...opts, continued: i < last }); + } + } + } + + // ---- intro text --------------------------------------------- + if (ctx.doc?.introText) { + doc.font(doc._fonts.body).fontSize(10).fillColor('#000'); + ensureSpace(40); + renderBodyMarkdown(ctx.doc.introText, { width: PAGE.contentWidth, align: 'left' }); + y = doc.y + 12; + } + + // ---- sections + blocks -------------------------------------- + for (const sec of ctx.sections || []) { + if (!sec.blocks || sec.blocks.length === 0) continue; + ensureSpace(32); + doc.font(doc._fonts.bold).fontSize(13).fillColor('#000'); + doc.text(t(locale, `section_${sec.section}`), PAGE.marginLeft, y, { + width: PAGE.contentWidth, align: 'left', + }); + y = doc.y + 6; + // Thin separator under the section heading. + doc + .strokeColor('#888') + .lineWidth(0.5) + .moveTo(PAGE.marginLeft, y) + .lineTo(PAGE.marginLeft + PAGE.contentWidth, y) + .stroke(); + y += 8; + + for (const block of sec.blocks) { + ensureSpace(48); + if (block.name) { + doc.font(doc._fonts.bold).fontSize(10).fillColor('#000'); + doc.text(String(block.name), PAGE.marginLeft, y, { + width: PAGE.contentWidth, align: 'left', + }); + y = doc.y + 4; + } + doc.font(doc._fonts.body).fontSize(10).fillColor('#000'); + renderBodyMarkdown(block.body, { width: PAGE.contentWidth, align: 'left' }); + y = doc.y + 10; + // If text rendering pushed past page bottom, PDFKit + // auto-paginated — sync y to the new doc.y for the next + // block. + if (doc.y < y) y = doc.y; + + // Special-case: when the block is the + // `quote_line_items_table` system block AND the contract + // was generated from a quote, draw a real formatted line- + // items table immediately after the body text. Columns + // mirror drawLineItems (#, Qty, Description, Unit, Total) + // but inlined here because the contract document has no + // `lineItems` ctx the standalone helper expects. + if ( + block.slug === 'quote_line_items_table' + && ctx.quoteLineItems + && ctx.quoteLineItems.length > 0 + ) { + const currency = (ctx.quoteCurrency || 'CHF').toUpperCase(); + // Column widths sum to PAGE.contentWidth (515.28). Same + // shape as drawLineItems' no-discount variant. The desc + // column is widest; numeric columns stay narrow + right- + // aligned. + const widths = [30, 275, 55, 70, 85]; + const colX = [PAGE.marginLeft]; + for (let i = 1; i < widths.length; i++) colX[i] = colX[i - 1] + widths[i - 1]; + const headers = [ + t(locale, 'table_pos'), + t(locale, 'table_description'), + t(locale, 'table_qty'), + t(locale, 'table_unit_price'), + t(locale, 'table_line_total'), + ]; + const headerAligns = ['left', 'left', 'right', 'right', 'right']; + + const ROW_MIN_HEIGHT = 18; + const PAD_X = 4; + + ensureSpace(ROW_MIN_HEIGHT + 4); + + // Header row — bold + bottom border. + doc.font(doc._fonts.bold).fontSize(10).fillColor('#000'); + const headerStartY = y; + let headerMaxBottom = y; + for (let i = 0; i < headers.length; i++) { + doc.text(headers[i], colX[i] + PAD_X, y + 3, { + width: widths[i] - PAD_X * 2, + align: headerAligns[i], + }); + if (doc.y > headerMaxBottom) headerMaxBottom = doc.y; + } + const headerBottom = Math.max(headerMaxBottom, headerStartY + ROW_MIN_HEIGHT); + doc.strokeColor('#000').lineWidth(1) + .moveTo(PAGE.marginLeft, headerBottom) + .lineTo(PAGE.marginLeft + PAGE.contentWidth, headerBottom) + .stroke(); + y = headerBottom + 1; + + // Data rows. Sub-items (parent_position != null) render + // with a "↳ " prefix + 8pt indent in the description + // column and an empty position column. Numeric values + // come from minor-unit BigInts via formatMinor. + doc.font(doc._fonts.body).fontSize(10).fillColor('#000'); + let topLevelCount = 0; + for (const li of ctx.quoteLineItems) { + const isSub = li.parent_position != null; + const posLabel = isSub ? '' : String(++topLevelCount); + const descPrefix = isSub ? '\u21B3 ' : ''; + const descIndent = isSub ? 8 : 0; + const qtyText = (() => { + const q = Number(li.quantity || 0); + return Number.isInteger(q) ? String(q) : String(q); + })(); + const unitText = formatMinor(li.unit_price_minor, currency, 'de-CH'); + const lineTotalText = formatMinor(li.line_total_minor, currency, 'de-CH'); + + const cells = [ + { text: posLabel, width: widths[0], align: 'left', x: colX[0] }, + { text: `${descPrefix}${li.description || ''}`, width: widths[1] - descIndent, align: 'left', x: colX[1] + descIndent }, + { text: qtyText, width: widths[2], align: 'right', x: colX[2] }, + { text: unitText, width: widths[3], align: 'right', x: colX[3] }, + { text: lineTotalText, width: widths[4], align: 'right', x: colX[4] }, + ]; + + // Measure tallest cell so the row's bottom is the max + // of all column heights + a minimum row height. + ensureSpace(ROW_MIN_HEIGHT + 2); + const rowStartY = y; + let rowMaxBottom = y; + for (const c of cells) { + doc.text(c.text, c.x + PAD_X, y + 3, { + width: c.width - PAD_X * 2, + align: c.align, + }); + if (doc.y > rowMaxBottom) rowMaxBottom = doc.y; + } + const rowBottom = Math.max(rowMaxBottom, rowStartY + ROW_MIN_HEIGHT); + // Thin grey divider under each row. + doc.strokeColor('#cccccc').lineWidth(0.5) + .moveTo(PAGE.marginLeft, rowBottom) + .lineTo(PAGE.marginLeft + PAGE.contentWidth, rowBottom) + .stroke(); + y = rowBottom + 1; + } + + y += 10; + doc.y = y; + doc.fillColor('#000'); + } + } + + y += 6; + } + + // ---- outro text --------------------------------------------- + if (ctx.doc?.outroText) { + ensureSpace(40); + doc.font(doc._fonts.body).fontSize(10).fillColor('#000'); + renderBodyMarkdown(ctx.doc.outroText, { width: PAGE.contentWidth, align: 'left' }); + y = doc.y + 16; + } + + // ---- signature page (dedicated final page, fixed layout) ---- + // The unsigned PDF ALWAYS contains an empty signature page at + // the end, with both signature boxes at FIXED coordinates + // (see CONTRACT_SIGNATURE_LAYOUT below). pdfStampService.js + // uses those same coordinates to overlay signature PNGs with + // pdf-lib AFTER the unsigned PDF is rendered — no re-render + // needed at signing time. This is the same model DocuSign / + // Adobe Sign use: the original is byte-immutable; signatures + // are appended as overlays. + // + // Audit data (timestamps, IPs, hashes) is rendered as a + // SEPARATE "audit certificate" PDF by pdfStampService — not + // embedded here — so the contract PDF stays purely + // representational and the audit trail is a sibling document + // that can be verified independently. + doc.addPage(); + const L = CONTRACT_SIGNATURE_LAYOUT; + + // Title row + doc.font(doc._fonts.bold).fontSize(16).fillColor('#000'); + doc.text(t(locale, 'signature_page_title'), PAGE.marginLeft, L.titleY, { + width: PAGE.contentWidth, align: 'left', + }); + doc.strokeColor('#888').lineWidth(0.5) + .moveTo(PAGE.marginLeft, L.titleY + 22) + .lineTo(PAGE.marginLeft + PAGE.contentWidth, L.titleY + 22) + .stroke(); + + // Closing prompt — generic line so unsigned doc reads coherently + doc.font(doc._fonts.body).fontSize(10).fillColor('#000'); + doc.text(t(locale, 'signature_page_prompt'), PAGE.marginLeft, L.promptY, { + width: PAGE.contentWidth, align: 'left', + }); + + // Two empty signature boxes — customer on the left, admin on + // the right. drawn at fixed coordinates so the stamp service + // can find them later by constant rather than runtime layout. + function drawEmptySignaturePane(x, label, info) { + doc.font(doc._fonts.bold).fontSize(10).fillColor('#000'); + doc.text(label, x, L.paneLabelY, { width: L.boxWidth }); + doc.strokeColor('#cccccc').lineWidth(0.5) + .rect(x, L.boxY, L.boxWidth, L.boxHeight) + .stroke(); + // Caption labels — name + date placeholders that the + // stamp service overwrites with the actual values when + // the signature is applied. The unsigned PDF shows these + // as empty labels. + const captionY = L.boxY + L.boxHeight + 6; + doc.font(doc._fonts.body).fontSize(9).fillColor('#000'); + doc.text( + `${t(locale, 'signed_label_name')}: ${info?.name || ''}`, + x, captionY, { width: L.boxWidth }, + ); + doc.text( + `${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`, + x, captionY + 12, { width: L.boxWidth }, + ); + } + + drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer); + drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin); + + // ---- page numbers ("Page 1 of N" / "Seite 1 von N") ---------- + // Same stamp the quote/invoice renderer uses (line 1680 above). + // bufferPages:true keeps every page open for switchToPage; we + // walk the range after all content is drawn so we know N. + try { + const range = doc.bufferedPageRange(); + const total = range.count; + for (let i = 0; i < total; i++) { + doc.switchToPage(range.start + i); + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888'); + const label = t(locale, 'page_of', { current: i + 1, total }); + const labelY = doc.page.height - PAGE.marginBottom - 12; + const labelW = 120; + const labelX = doc.page.width - PAGE.marginRight - labelW; + doc.text(label, labelX, labelY, { + width: labelW, align: 'right', lineBreak: false, + }); + doc.fillColor('#000'); + } + } catch (err) { + const logger = require('../utils/logger'); + logger.warn('Failed to stamp page numbers on contract PDF', { err: err.message }); + } + + doc.end(); + } catch (err) { + reject(err); + } + })(); + }); +} + +module.exports = { + renderQuoteToBuffer, + renderInvoiceToBuffer, + renderContractToBuffer, + // Building blocks shared with other PDF features (tax report etc.) — + // they all run through createBaseDocument so the font + orientation + // story stays consistent. + createBaseDocument, + getPageMetrics, + drawIssuerBlock, + // Shared with pdfStampService — the same coordinates the unsigned + // render uses to draw empty signature boxes are used to overlay + // signature PNGs at stamping time. Single source of truth. + CONTRACT_SIGNATURE_LAYOUT, + PAGE, + FONT_BODY, + FONT_BOLD, + // Exposed for unit tests + advanced callers. + _internal: { formatMinor, formatDate, t, registerCustomFonts }, +}; diff --git a/backend/src/services/pdfStampService.js b/backend/src/services/pdfStampService.js new file mode 100644 index 00000000..45c6702a --- /dev/null +++ b/backend/src/services/pdfStampService.js @@ -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 }, +}; diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js new file mode 100644 index 00000000..9ab6c64e --- /dev/null +++ b/backend/src/services/quoteService.js @@ -0,0 +1,1792 @@ +/** + * quoteService — orchestrates the lifecycle of `quotes`, their + * `quote_line_items`, and the public `quote_action_tokens` used by the + * accept/decline link in the customer email. + * + * Mirrors the layered shape of customerAccountsService: pure functions + * doing one thing each, with a small set of transformation helpers at + * the top. Routes (adminQuotes.js / publicQuotes.js) stay thin. + * + * Money is stored as INTEGER minor units (cents/Rappen). The service + * re-computes line totals + net/vat/total on save, never trusting the + * payload — the editor sends a hint for live UX, the server is the + * source of truth. + * + * Statuses (`quotes.status`): + * draft freshly created or edited after send; not visible publicly + * sent emailed to customer; public token live + * accepted customer accepted; ready to convert to event + * declined customer declined; admin can resend after edits + * expired valid_until passed without a response (set by the scheduler) + * converted accepted + event created from it + * + * Per-customer feature override: when `customer_accounts.feature_quotes` + * is false (toggled by admin on the customer detail page) the service + * refuses to create / send / convert quotes for that customer. Admins + * can still view existing rows for audit. + */ + +const crypto = require('crypto'); +const { db, withRetry, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { getAppSetting } = require('../utils/appSettings'); +const { AppError } = require('../utils/errors'); +const { formatBoolean } = require('../utils/dbCompat'); +const { claimNextSequence } = require('../utils/documentSequences'); +const { formatShortDate } = require('../utils/dateFormatter'); +const businessProfileService = require('./businessProfileService'); +const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); +const pdfService = require('./pdfService'); +const emailProcessor = require('./emailProcessor'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); +const fs = require('fs'); +const path = require('path'); + +const VALID_QUOTE_TRANSITIONS = { + draft: new Set(['sent', 'declined']), + sent: new Set(['draft', 'accepted', 'declined', 'expired']), + accepted: new Set(['converted', 'declined']), + declined: new Set(['draft', 'accepted']), + expired: new Set(['draft']), + converted: new Set([]), +}; + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +// `ensureInt` + `ensureNumber` moved to utils/numericHelpers (D.2 cleanup). +const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); + +/** + * Compute line totals + document totals authoritatively from the + * supplied line items + VAT rate. Returns BigInt-safe integers (minor + * units). Discount is applied before VAT. + * + * Hierarchy rules (migration 119): + * - Items with `parent_position` are SUB-ITEMS of the referenced + * top-level item. + * - Each sub-item's `line_total_minor` is computed (qty × unit × + * (1 − discount)) so the renderer can show its individual price + * in parentheses for transparency. + * - **Parent total auto-resolves from sub-items when any are + * priced.** If at least one sub-item under a given parent has + * `unit_price_minor > 0`, the parent's effective line_total is + * the SUM of those sub-items' line_totals — the parent's own + * stored unit_price is ignored. Mental model: when you list + * itemised equipment with individual prices, the parent line + * becomes a header that auto-totals what's under it. + * - If all sub-items are priceless (transparency-only bullets), the + * parent's own qty × unit × discount math stands as today. + * - Sub-items NEVER contribute to the document net directly — + * only the parent's effective line_total does. So sub-items + * don't double-count, and the parent's "sum-of-sub-items" total + * is what lands in net + VAT. + * + * The empty-payload check upstream ensures `lineItems` is always an + * array; we treat anything truthy on `parent_position` (number or + * string that parses to int) as "I'm a sub-item". + */ +function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) { + // Phase 1: compute raw line_total_minor for every row from its own + // qty × unit × discount. Sub-item lines are computed here too so + // the renderer can display their individual amounts. + const computed = lineItems.map((li) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = Math.max(0, Math.min(100, ensureNumber(li.discount_percent, 0))); + const rawLineMinor = Math.round(qty * unit); + const discountedMinor = Math.round(rawLineMinor * (1 - discount / 100)); + const parentPosition = li.parent_position == null || li.parent_position === '' + ? null : ensureInt(li.parent_position); + return { ...li, line_total_minor: discountedMinor, parent_position: parentPosition }; + }); + + // Phase 2: resolve parents. For each top-level item, sum its priced + // sub-items; if the sum > 0, override the parent's line_total_minor. + // Index by position for O(n) lookup. + const childrenByParent = new Map(); + for (const li of computed) { + if (li.parent_position == null) continue; + if (!childrenByParent.has(li.parent_position)) childrenByParent.set(li.parent_position, []); + childrenByParent.get(li.parent_position).push(li); + } + for (const li of computed) { + if (li.parent_position != null) continue; // skip sub-items + const children = childrenByParent.get(ensureInt(li.position)) || []; + const pricedChildrenSum = children.reduce( + (s, c) => s + (ensureInt(c.unit_price_minor) > 0 ? ensureInt(c.line_total_minor) : 0), + 0, + ); + if (pricedChildrenSum > 0) { + // Override the parent's effective line total with the sum of + // its priced sub-items. The parent's own stored unit_price is + // intentionally ignored here (the editor disables the parent + // input when sub-items become priced — but the backend is the + // source of truth either way). + li.line_total_minor = pricedChildrenSum; + } + } + + // Phase 3: net = sum of top-level line totals (resolved). + let netMinor = 0; + for (const li of computed) { + if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); + } + + const vatPercent = ensureNumber(vatRate, 0); + const vatMinor = Math.round(netMinor * vatPercent / 100); + const shipping = ensureInt(shippingAmountMinor); + const totalMinor = netMinor + vatMinor + shipping; + return { + netAmountMinor: netMinor, + vatAmountMinor: vatMinor, + shippingAmountMinor: shipping, + totalAmountMinor: totalMinor, + lineItems: computed, + }; +} + +/** + * Resolve parent line_total_minor from priced sub-items, in place. + * Mirrors the phase-2 step of computeTotals so non-quote callers + * (invoiceService.createInvoice, the PUT-invoice route) can apply + * the same hierarchy math without going through full totals. + * + * Each item must already have line_total_minor pre-computed (the + * raw qty × unit × discount product). After this call, top-level + * items whose sub-items include at least one priced row will have + * their line_total_minor overwritten with the sum of priced + * sub-items' line_totals. + */ +function resolveParentTotalsFromSubItems(items) { + if (!Array.isArray(items) || items.length === 0) return; + const childrenByParent = new Map(); + for (const li of items) { + const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position); + if (pp == null) continue; + if (!childrenByParent.has(pp)) childrenByParent.set(pp, []); + childrenByParent.get(pp).push(li); + } + for (const li of items) { + const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position); + if (pp != null) continue; + const children = childrenByParent.get(ensureInt(li.position)) || []; + const pricedSum = children.reduce( + (s, c) => s + (ensureInt(c.unit_price_minor) > 0 ? ensureInt(c.line_total_minor) : 0), + 0, + ); + if (pricedSum > 0) li.line_total_minor = pricedSum; + } +} + +/** + * Validate the hierarchy of a line-item payload BEFORE insert. Throws + * AppError on: + * - duplicate positions + * - sub-item's parent_position not found in the payload + * - sub-item's parent is itself a sub-item (max 1 level deep) + * - circular reference (item references itself) + * + * Used by both quote + invoice services so the rules stay identical + * across both flows (and so the quote→invoice cloner doesn't have to + * re-validate). + */ +function validateLineItemHierarchy(lineItems) { + if (!Array.isArray(lineItems) || lineItems.length === 0) return; + const positions = new Set(); + const parentPositions = new Map(); // position → parent_position (or null) + for (const li of lineItems) { + const pos = ensureInt(li.position); + if (!pos) { + throw new AppError('Every line item must have a positive position', 400, 'LINE_ITEM_POSITION_REQUIRED'); + } + if (positions.has(pos)) { + throw new AppError(`Duplicate line item position: ${pos}`, 400, 'LINE_ITEM_POSITION_DUPLICATE'); + } + positions.add(pos); + const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position); + parentPositions.set(pos, pp); + } + for (const [pos, pp] of parentPositions) { + if (pp == null) continue; + if (pp === pos) { + throw new AppError(`Line item ${pos} cannot be its own parent`, 400, 'LINE_ITEM_SELF_PARENT'); + } + if (!parentPositions.has(pp)) { + throw new AppError(`Sub-item ${pos} references missing parent position ${pp}`, 400, 'LINE_ITEM_PARENT_NOT_FOUND'); + } + if (parentPositions.get(pp) != null) { + throw new AppError(`Sub-item ${pos} cannot nest under another sub-item (max one level deep)`, 400, 'LINE_ITEM_NESTING_TOO_DEEP'); + } + } +} + +/** + * Two-phase insert into a *_line_items table to resolve the + * parent_position → parent_line_item_id remap. The payload uses + * position numbers to express parent/child relationships because the + * DB ids don't exist until rows are inserted; this helper handles + * the round-trip. + * + * trx — db or transaction handle + * tableName — 'quote_line_items' | 'invoice_line_items' + * ownerColumn — 'quote_id' | 'invoice_id' + * ownerId — the parent quote/invoice id + * items — array of line-item rows with `position` + + * optional `parent_position`. All other columns + * passed through verbatim (except parent_position + * which is stripped — it's a wire-only field, not a + * DB column). + * + * Caller must have already run `validateLineItemHierarchy` on the + * items, so this function trusts the hierarchy is sound. + */ +async function insertLineItemsHierarchical(trx, tableName, ownerColumn, ownerId, items) { + if (!Array.isArray(items) || items.length === 0) return; + // Phase 1: top-level items, captured into a position→id map for + // phase 2. + const topLevel = items.filter((li) => li.parent_position == null || li.parent_position === ''); + const subItems = items.filter((li) => li.parent_position != null && li.parent_position !== ''); + const stripWireOnly = ({ parent_position: _pp, parent_line_item_id: _pid, ...rest }) => rest; + + const positionToId = new Map(); + for (const li of topLevel) { + const row = { + ...stripWireOnly(li), + [ownerColumn]: ownerId, + parent_line_item_id: null, + details_text: li.details_text == null ? null : String(li.details_text), + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await trx(tableName).insert(row).returning('id'); + const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + positionToId.set(ensureInt(li.position), newId); + } + for (const li of subItems) { + const parentId = positionToId.get(ensureInt(li.parent_position)); + if (!parentId) { + // Defensive — validateLineItemHierarchy should have caught + // this. Rethrow as a 500 so we don't silently swallow. + throw new AppError(`Sub-item position ${li.position} references unknown parent ${li.parent_position}`, 500); + } + const row = { + ...stripWireOnly(li), + [ownerColumn]: ownerId, + parent_line_item_id: parentId, + details_text: li.details_text == null ? null : String(li.details_text), + created_at: new Date(), + updated_at: new Date(), + }; + await trx(tableName).insert(row); + } +} + +function formatNumberInTemplate(format, year, seq) { + // Tokens: {YEAR}, {MONTH}, {SEQ:04d}. Defaults handle padding via + // a tiny formatter, kept inline to avoid a new dependency. + return format + .replace(/\{YEAR\}/g, String(year)) + .replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0')) + .replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0')) + .replace(/\{SEQ\}/g, String(seq)); +} + +// Atomic gap-free quote number generator. See utils/documentSequences.js +// for the locking story; migration 132 created the underlying table. +// The previous SELECT-MAX-then-INSERT path raced under concurrent +// admin creates and could emit `Q-2026-AB12C3` after 5 retries. +async function nextQuoteNumber(trx) { + const format = (await getAppSetting('crm_quotes_number_format')) || 'Q-{YEAR}-{SEQ:04d}'; + const year = new Date().getFullYear(); + const seq = await claimNextSequence('quote', year, trx); + return formatNumberInTemplate(format, year, seq); +} + +function ensureCustomerFeatureEnabled(customer, feature) { + // Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`) + // is checked at the route layer (feature flag); here we only enforce + // the per-customer override. + if (!customer) { + throw new AppError('Customer not found', 404); + } + if (customer.is_active === false || customer.is_active === 0) { + throw new AppError('Customer is deactivated', 409); + } + const flagField = feature === 'quotes' ? 'feature_quotes' : 'feature_bills'; + const flagValue = customer[flagField]; + if (flagValue === false || flagValue === 0 || flagValue === '0') { + throw new AppError(`This customer has ${feature} disabled`, 409, 'CUSTOMER_FEATURE_DISABLED'); + } +} + +// --------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------- + +/** + * List quotes with filter + sort + pagination support. Returns a flat + * list (transformed by the route layer); pagination metadata is in the + * wrapper. + * + * Filters: { status[], customerAccountId, from, to, q } + * Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc' + */ +async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) { + return await withRetry(async () => { + let query = db('quotes') + .leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id') + .select( + 'quotes.*', + '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', + // Surfaced so the route's transformQuote can compute the + // customer.isPassive flag. Hash itself never leaves the API. + 'customer_accounts.password_hash as customer_password_hash', + ); + + if (Array.isArray(filters.status) && filters.status.length > 0) { + query = query.whereIn('quotes.status', filters.status); + } + if (filters.customerAccountId) { + query = query.where('quotes.customer_account_id', filters.customerAccountId); + } + if (filters.from) { + query = query.where('quotes.issue_date', '>=', filters.from); + } + if (filters.to) { + query = query.where('quotes.issue_date', '<=', filters.to); + } + if (filters.q && String(filters.q).trim()) { + const term = `%${String(filters.q).trim()}%`; + query = query.andWhere(function() { + this.where('quotes.quote_number', 'like', term) + .orWhere('quotes.event_name', 'like', term) + .orWhere('customer_accounts.email', 'like', term) + .orWhere('customer_accounts.company_name', 'like', term); + }); + } + + // Total before pagination. + const countQuery = query.clone().clearSelect().clearOrder().count('quotes.id as total').first(); + const totalRow = await countQuery; + const total = ensureInt(totalRow?.total || 0); + + switch (sort) { + // "Newest" / "Oldest" sort by CREATION time, not issue_date — + // the latter is admin-controlled (retro-dated quotes, future- + // dated quotes for accruals) and drifts from actual chronology. + // Sorting by created_at always puts a just-saved quote at the + // top of the "Newest first" list. + case 'oldest': + query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc'); + break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('quotes.id', 'desc'); + break; + case 'value_asc': + query = query.orderBy('quotes.total_amount_minor', 'asc'); + break; + case 'value_desc': + query = query.orderBy('quotes.total_amount_minor', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc'); + break; + } + + const offset = Math.max(0, (page - 1) * pageSize); + query = query.offset(offset).limit(pageSize); + const rows = await query; + return { rows, total, page, pageSize }; + }); +} + +async function getQuoteById(id) { + return await withRetry(async () => { + // LEFT JOIN customer_accounts so transformQuote (which reads + // q.customer_email / q.customer_display_name etc.) has populated + // fields. Without this the API returns nulls for the recipient + // block and the editor shows "undefined undefined" in its summary. + const quote = await db('quotes') + .leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id') + // Migration 130 lineage: the human contract_number of the + // contract this quote was converted into, so the detail view + // shows "Linked contract LBM-C-2026-0010" instead of just "#10". + // LEFT join — most quotes never get converted to a contract. + .leftJoin('contracts as conv_contract', 'quotes.converted_contract_id', 'conv_contract.id') + .where('quotes.id', id) + .select( + 'quotes.*', + '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', + // For transformQuote.customer.isPassive — never leaves the API. + 'customer_accounts.password_hash as customer_password_hash', + 'conv_contract.contract_number as converted_contract_number', + ) + .first(); + if (!quote) return null; + // Self-join so the response carries parent_position alongside + // parent_line_item_id. The editor uses position (1-based, stable + // within the payload) to thread sub-items; the DB id is just for + // unrelated callers. + const lineItems = await db('quote_line_items as li') + .leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.quote_id', id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + return { quote, lineItems }; + }); +} + +/** + * Create a quote. Validates the customer + recomputes totals. + * Returns the new quote id. + */ +async function createQuote(payload, adminId) { + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + ensureCustomerFeatureEnabled(customer, 'quotes'); + + const profile = (await businessProfileService.getProfile()).profile; + const currency = (payload.currency || profile?.default_currency || 'CHF').toUpperCase(); + const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; + + // Default validity = 7 days. Admin can override via Settings → + // CRM → "Quote default validity (days)" (key + // `crm_quotes_default_valid_days`). + const validDays = ensureInt(await getAppSetting('crm_quotes_default_valid_days')) || 7; + const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); + const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) + .toISOString().slice(0, 10); + + // Authoritative totals. + const totals = computeTotals( + Array.isArray(payload.lineItems) ? payload.lineItems : [], + payload.vatRate, + payload.shippingAmountMinor + ); + + // Resolve bank account for the chosen currency. + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); + + return await db.transaction(async (trx) => { + const quoteNumber = await nextQuoteNumber(); + const row = { + quote_number: quoteNumber, + customer_account_id: payload.customerAccountId, + status: 'draft', + language, + currency, + issue_date: issueDate, + valid_until: validUntil, + event_name: payload.eventName || null, + event_date: payload.eventDate || null, + event_time_start: payload.eventTimeStart || null, + event_time_end: payload.eventTimeEnd || null, + expected_duration_hours: payload.expectedDurationHours == null ? null : ensureNumber(payload.expectedDurationHours), + payment_term_template_id: payload.paymentTermTemplateId || null, + // Migration 124 — split payment-term picker. Editor stops writing + // to the legacy single FK once both new ones are present; the + // legacy column stays nullable for backward compatibility. + payment_net_days_template_id: payload.paymentNetDaysTemplateId || null, + payment_timing_template_id: payload.paymentTimingTemplateId || null, + // Migration 142 — ad-hoc installments override (commit #6). When + // the editor's InstallmentsPanel is set the array lands here; + // composeSnapshotFromSplitFks then substitutes it for the + // template's installments field at every snapshot-read site + // (send, convertToEvent, convertToInvoiceOnly). + payment_term_installments_override: Array.isArray(payload.installments) && payload.installments.length > 0 + ? JSON.stringify(payload.installments) + : null, + net_amount_minor: totals.netAmountMinor, + vat_rate: ensureNumber(payload.vatRate, 0), + vat_amount_minor: totals.vatAmountMinor, + shipping_amount_minor: totals.shippingAmountMinor, + total_amount_minor: totals.totalAmountMinor, + intro_text: payload.introText || null, + outro_text: payload.outroText || null, + internal_notes: payload.internalNotes || null, + cc_pdf_email: payload.ccPdfEmail || null, + business_bank_account_id: bank?.id || null, + // Migration 140 — cross-document lineage UUID. A freshly-created + // quote is always the root of its deal chain; mint a new one + // here and let convertQuoteToContract / convertQuoteToInvoices + // propagate it down. + deal_uuid: crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await trx('quotes').insert(row).returning('id'); + const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (totals.lineItems.length > 0) { + // Normalise rows for the hierarchical-insert helper. We preserve + // the wire-only `parent_position` field here so the helper can + // resolve it; the helper strips it before the actual DB insert. + const rows = totals.lineItems.map((li, idx) => ({ + position: ensureInt(li.position) || (idx + 1), + quantity: ensureNumber(li.quantity, 1), + description: String(li.description || ''), + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: li.line_total_minor, + details_text: li.details_text || null, + parent_position: li.parent_position || null, + })); + validateLineItemHierarchy(rows); + await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', quoteId, rows); + } + + try { + await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote created', { adminId, quoteId, quoteNumber }); + return quoteId; + }); +} + +/** + * Update a quote (line items + scalar fields). Editing a `sent` quote + * reverts it to draft so a fresh send is required to push the change. + */ +async function updateQuote(id, payload, adminId) { + const existing = await db('quotes').where({ id }).first(); + if (!existing) { + throw new AppError('Quote not found', 404); + } + // Once a customer has responded (accept / decline) or the quote has + // been converted to an event/invoice, edits would invalidate the + // record the customer agreed to. Lock these states the same way + // sent invoices are locked. `draft` and `sent` remain editable; + // `sent` reverts to `draft` further down so the admin must resend. + // `expired` is left editable — quote can be revised and re-sent. + if (['accepted', 'declined', 'converted'].includes(existing.status)) { + throw new AppError( + `Cannot edit quote with status '${existing.status}'. Duplicate the quote and start fresh if changes are needed.`, + 409, + 'QUOTE_LOCKED', + ); + } + + const totals = computeTotals( + Array.isArray(payload.lineItems) ? payload.lineItems : [], + payload.vatRate ?? existing.vat_rate, + payload.shippingAmountMinor ?? existing.shipping_amount_minor + ); + + return await db.transaction(async (trx) => { + const updates = { + updated_at: new Date(), + net_amount_minor: totals.netAmountMinor, + vat_amount_minor: totals.vatAmountMinor, + shipping_amount_minor: totals.shippingAmountMinor, + total_amount_minor: totals.totalAmountMinor, + vat_rate: ensureNumber(payload.vatRate ?? existing.vat_rate, 0), + }; + // Revert sent → draft on edit so the admin must explicitly resend. + if (existing.status === 'sent') updates.status = 'draft'; + const map = { + eventName: 'event_name', + eventDate: 'event_date', + eventTimeStart: 'event_time_start', + eventTimeEnd: 'event_time_end', + expectedDurationHours: 'expected_duration_hours', + paymentTermTemplateId: 'payment_term_template_id', + // Migration 124 — split picker. Both legacy + new FKs accepted + // on the update path so the editor can transition without breaking. + paymentNetDaysTemplateId: 'payment_net_days_template_id', + paymentTimingTemplateId: 'payment_timing_template_id', + introText: 'intro_text', + outroText: 'outro_text', + internalNotes: 'internal_notes', + ccPdfEmail: 'cc_pdf_email', + businessBankAccountId: 'business_bank_account_id', + validUntil: 'valid_until', + language: 'language', + }; + for (const [api, col] of Object.entries(map)) { + if (Object.prototype.hasOwnProperty.call(payload, api)) { + updates[col] = payload[api]; + } + } + // Migration 142 — ad-hoc installments override (commit #6). Treated + // separately because it needs JSON encoding + "empty array means + // clear the override" semantics. + if (Object.prototype.hasOwnProperty.call(payload, 'installments')) { + updates.payment_term_installments_override = + Array.isArray(payload.installments) && payload.installments.length > 0 + ? JSON.stringify(payload.installments) + : null; + } + await trx('quotes').where({ id }).update(updates); + + // Delete + reinsert keeps the editor flow simple: the frontend + // sends the canonical line-item set on every save, we drop the + // old rows and rebuild from scratch. CASCADE on parent_line_item_id + // means deleting parents sweeps their sub-items too, so there's + // no orphan risk here. + await trx('quote_line_items').where({ quote_id: id }).del(); + if (totals.lineItems.length > 0) { + const rows = totals.lineItems.map((li, idx) => ({ + position: ensureInt(li.position) || (idx + 1), + quantity: ensureNumber(li.quantity, 1), + description: String(li.description || ''), + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: li.line_total_minor, + details_text: li.details_text || null, + parent_position: li.parent_position || null, + })); + validateLineItemHierarchy(rows); + await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', id, rows); + } + + try { + await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`); + } catch (_) {} + }); +} + +/** + * Build the renderer context object from the quote + DB lookups. Shared + * by sendQuote (where we persist the PDF) and previewQuote* (where we + * just return the buffer to the admin). + */ +async function buildRenderContext(quote, lineItems) { + const { profile } = await businessProfileService.getProfile(); + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + const bank = quote.business_bank_account_id + ? await db('business_bank_accounts').where({ id: quote.business_bank_account_id }).first() + : await businessProfileService.resolveBankAccountForCurrency(quote.currency); + const paymentTerm = quote.payment_term_template_id + ? await db('payment_term_templates').where({ id: quote.payment_term_template_id }).first() + : null; + + // Resolve the PDF logo to a verified absolute disk path. The + // helper exhaustively tries: + // 1. business_profile.logo_path + // 2. app_settings.branding_logo_path (absolute multer path) + // 3. app_settings.branding_logo_url (URL path) + // …and for each, generates ~7 candidate disk locations before + // giving up. Returns null + logs a detailed warning when nothing + // resolves. Already-verified path means the renderer never has + // to second-guess. + const { resolveLogoFile } = require('../utils/resolveLogoFile'); + const resolvedLogoPath = await resolveLogoFile(profile); + + // Resolve Skonto values for the PDF payment block: + // - if the chosen template defines its own skonto_percent + + // skonto_within_days, use those (per-template wins); + // - otherwise fall back to the global CRM defaults + // (crm_invoices_skonto_percent_default + _business_days); + // - the whole row is suppressed when the global + // `crm_quotes_skonto_enabled` toggle is off. + const skontoEnabled = (await getAppSetting('crm_quotes_skonto_enabled')) !== false; + let skontoPercent = paymentTerm?.skonto_percent; + let skontoWithinDays = paymentTerm?.skonto_within_days; + if (skontoEnabled && (skontoPercent == null || skontoWithinDays == null)) { + const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default')); + const defaultDays = parseInt(await getAppSetting('crm_invoices_skonto_business_days'), 10); + if (skontoPercent == null && Number.isFinite(defaultPct) && defaultPct > 0) skontoPercent = defaultPct; + if (skontoWithinDays == null && Number.isFinite(defaultDays) && defaultDays > 0) skontoWithinDays = defaultDays; + } + if (!skontoEnabled) { + skontoPercent = null; + skontoWithinDays = null; + } + + // Global date format from Settings → General (general_date_format). + // Stored as JSON `{ format, locale }`; missing or malformed entries + // fall back to DD.MM.YYYY in the renderer. + 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 default */ } + + return { + locale: quote.language || profile?.default_locale || 'de', + currency: quote.currency, + qrFormat: 'none', // quotes never carry a Swiss QR-bill + dateFormat, + // Issuer + recipient blocks are shared across all three doc services. + // The quote variant opts into the two extra payment-block toggles. + // See backend/src/services/_renderContext.js for the spec + drift + // history. + issuer: buildIssuerBlock(profile, resolvedLogoPath, { quoteToggles: true }), + recipient: buildRecipientBlock(profile, customer), + bank: bank ? { + accountHolder: bank.account_holder || profile?.company_name, + iban: bank.iban, + bic: bank.bic, + currency: bank.currency, + } : null, + // Resolved above so Skonto honours the global enable toggle + the + // default-rate fallback. If no template is selected at all we + // still pass the Skonto defaults through so the PDF can show a + // sensible "X% discount if paid within Y days" line. + paymentTerm: paymentTerm || skontoPercent || skontoWithinDays ? { + description: paymentTerm?.description, + netDays: paymentTerm?.net_days, + skontoPercent, + skontoWithinDays, + } : null, + lineItems: lineItems.map((li) => ({ + quantity: li.quantity, + description: li.description, + unitPriceMinor: li.unit_price_minor, + discountPercent: li.discount_percent, + lineTotalMinor: li.line_total_minor, + // Migration 119 hierarchy + details — surfaced to the PDF + // renderer so drawLineItems can indent sub-items + render + // details_text below. + parentLineItemId: li.parent_line_item_id || null, + parentPosition: li.parent_position == null ? null : Number(li.parent_position), + detailsText: li.details_text || null, + })), + totals: { + netAmountMinor: quote.net_amount_minor, + vatRate: quote.vat_rate, + vatAmountMinor: quote.vat_amount_minor, + shippingAmountMinor: quote.shipping_amount_minor, + totalAmountMinor: quote.total_amount_minor, + }, + doc: { + quoteNumber: quote.quote_number, + issueDate: quote.issue_date, + validUntil: quote.valid_until, + introText: quote.intro_text, + outroText: quote.outro_text, + totalAmountMinor: quote.total_amount_minor, + }, + }; +} + +async function renderQuotePdfBuffer(quoteId) { + const data = await getQuoteById(quoteId); + if (!data) throw new AppError('Quote not found', 404); + const ctx = await buildRenderContext(data.quote, data.lineItems); + return await pdfService.renderQuoteToBuffer(ctx); +} + +/** + * Preview a quote PDF from an unsaved payload — never touches the DB. + * The frontend "Preview" button on the editor calls this with the + * current form state so the admin can validate before saving. + */ +async function renderQuotePdfFromPayload(payload) { + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + const totals = computeTotals( + Array.isArray(payload.lineItems) ? payload.lineItems : [], + payload.vatRate, + payload.shippingAmountMinor + ); + const fakeQuote = { + quote_number: 'PREVIEW', + customer_account_id: payload.customerAccountId, + language: payload.language || customer?.preferred_language || 'de', + currency: (payload.currency || 'CHF').toUpperCase(), + issue_date: payload.issueDate || new Date().toISOString().slice(0, 10), + valid_until: payload.validUntil, + intro_text: payload.introText, + outro_text: payload.outroText, + payment_term_template_id: payload.paymentTermTemplateId, + business_bank_account_id: payload.businessBankAccountId, + net_amount_minor: totals.netAmountMinor, + vat_rate: ensureNumber(payload.vatRate, 0), + vat_amount_minor: totals.vatAmountMinor, + shipping_amount_minor: totals.shippingAmountMinor, + total_amount_minor: totals.totalAmountMinor, + }; + // Carry position + parent_position + details_text through to the + // renderer so the preview matches the saved-quote PDF: sub-items + // render indented with parenthesised totals, parent shows its + // resolved total (sum of priced sub-items), and details_text rows + // appear under their parent. Without these fields the renderer + // treats every row as a top-level item and shows the parent at 0. + const ctx = await buildRenderContext(fakeQuote, totals.lineItems.map((li, idx) => ({ + position: li.position == null ? idx + 1 : Number(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: li.unit_price_minor, + discount_percent: li.discount_percent, + line_total_minor: li.line_total_minor, + parent_position: li.parent_position == null || li.parent_position === '' ? null : Number(li.parent_position), + details_text: li.details_text || null, + }))); + return await pdfService.renderQuoteToBuffer(ctx); +} + +/** + * Send a quote: render PDF, persist snapshot, generate accept/decline + * tokens, queue email. Transitions status draft|declined → sent. + */ +async function sendQuote(id, adminId) { + const data = await getQuoteById(id); + if (!data) throw new AppError('Quote not found', 404); + const { quote, lineItems } = data; + + if (!['draft', 'declined', 'expired'].includes(quote.status)) { + throw new AppError(`Cannot send a quote with status '${quote.status}'`, 409); + } + + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + ensureCustomerFeatureEnabled(customer, 'quotes'); + + // Render PDF + persist snapshot. + const ctx = await buildRenderContext(quote, lineItems); + const buffer = await pdfService.renderQuoteToBuffer(ctx); + const pdfPath = await persistDocPdf('quote', quote, buffer); + + // Snapshot payment term so future template edits don't mutate the doc. + // Migration 124 — prefer the two new split FKs; fall back to the legacy + // single FK when the quote was authored before the split was deployed. + // Output shape is unchanged: { description, net_days, skonto_percent, + // skonto_within_days, installments } — that's what pdfService and the + // scheduler already read. + const paymentTermSnapshot = await composeSnapshotFromSplitFks(quote) + || (quote.payment_term_template_id + ? await db('payment_term_templates').where({ id: quote.payment_term_template_id }).first() + : null); + + // Mint a single shared token; accept and decline are differentiated + // by the request body. This makes the email link survive a customer + // changing their mind inside the 15-min window without sending two + // links. + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = quote.valid_until + ? new Date(new Date(quote.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000) + : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + + await db.transaction(async (trx) => { + await trx('quote_action_tokens').insert({ + quote_id: id, + token, + expires_at: expiresAt, + created_at: new Date(), + }); + await trx('quotes').where({ id }).update({ + status: 'sent', + sent_at: new Date(), + pdf_path: pdfPath, + payment_term_snapshot: paymentTermSnapshot ? JSON.stringify(paymentTermSnapshot) : null, + updated_at: new Date(), + }); + }); + + // Queue customer email (with PDF + cc) — honour the global + // crm_quotes_pdf_attachment_enabled toggle. + const attachPdf = await getAppSetting('crm_quotes_pdf_attachment_enabled'); + const frontendUrl = await getFrontendBaseUrl() || 'http://localhost:3000'; + const responseUrl = `${frontendUrl}/quote/${token}`; + await emailProcessor.queueEmail(null, customer.email, 'quote_sent', { + quote_number: quote.quote_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + response_url: responseUrl, + accept_url: `${responseUrl}?action=accept`, + decline_url: `${responseUrl}?action=decline`, + valid_until: formatShortDate(quote.valid_until), + event_name: quote.event_name || '', + total_amount: formatMajor(quote.total_amount_minor, quote.currency, ctx.locale, ctx.issuer?.countryCode), + cc: quote.cc_pdf_email || undefined, + attachments: (attachPdf !== false && pdfPath) ? [{ + filename: `${quote.quote_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }] : undefined, + }); + + try { + await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote sent', { adminId, quoteId: id }); + return { token, pdfPath }; +} + +function formatMajor(minor, currency, locale, issuerCountryCode) { + // Per maintainer: every DACH-region issuer (FL/CH/DE/AT) writes + // 1'000.00 with an apostrophe separator regardless of document + // language. de-CH is the only Intl locale that produces that + // format, so we force it whenever the issuer sits in that region. + // Outside DACH we still honour the document locale. + const cc = (issuerCountryCode || '').toUpperCase(); + const intlLocale = ['CH', 'LI', 'DE', 'AT'].includes(cc) + ? 'de-CH' + : (locale === 'de' ? 'de-CH' : 'en-GB'); + return new Intl.NumberFormat(intlLocale, { + style: 'currency', currency: (currency || 'CHF').toUpperCase(), + }).format(Number(minor || 0) / 100); +} + +/** + * Persist a rendered PDF under storage/business-docs/quote//.pdf + */ +async function persistDocPdf(type, doc, buffer) { + const number = doc.quote_number || doc.invoice_number; + if (!number) return null; + const year = (doc.issue_date ? new Date(doc.issue_date) : new Date()).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', type, String(year)); + fs.mkdirSync(root, { recursive: true }); + const filePath = path.join(root, `${number}.pdf`); + fs.writeFileSync(filePath, buffer); + return filePath; +} + +/** + * Record a customer response from the public accept/decline link. + * + * 15-min toggle rule: the first response opens a window equal to + * crm_quotes_accept_window_minutes (default 15). Within that window + * the same token may flip accept↔decline. After the window expires the + * response is locked. + */ +async function recordResponse({ token, action, ip, tosAccepted }) { + if (!['accept', 'decline'].includes(action)) { + throw new AppError('Invalid action', 400); + } + const tokenRow = await db('quote_action_tokens').where({ token }).first(); + if (!tokenRow) { + throw new AppError('Token not found', 404); + } + if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) { + throw new AppError('Token expired', 410); + } + + const quote = await db('quotes').where({ id: tokenRow.quote_id }).first(); + if (!quote) { + throw new AppError('Quote not found', 404); + } + if (!['sent', 'accepted', 'declined'].includes(quote.status)) { + throw new AppError(`Quote cannot be responded to in status '${quote.status}'`, 409); + } + + // Terms of Service handling on accept: + // - Setting OFF: ignored. + // - Setting ON + box ticked: normal acceptance; ToS snapshot + // stored on the quote for audit. + // - Setting ON + box NOT ticked: server returns TOS_REQUIRED; + // the frontend keeps Accept disabled until ticked. To refuse + // the engagement the customer clicks Decline explicitly, which + // records `declined` like any other decline (no ToS needed for + // decline since the customer is rejecting the terms anyway). + const tosRequired = await getAppSetting('crm_quotes_tos_required', false) === true; + const tosText = await getAppSetting('crm_quotes_tos_text', ''); + if (action === 'accept' && tosRequired && !tosAccepted) { + throw new AppError('Terms of Service must be accepted before the quote can be accepted.', + 400, 'TOS_REQUIRED'); + } + const effectiveAction = action; + + const now = new Date(); + const windowMinutes = ensureInt(await getAppSetting('crm_quotes_accept_window_minutes')) || 15; + // If there's already a response, check if we're inside the toggle window. + if (quote.responded_at && quote.response_locked_at) { + if (now.getTime() > new Date(quote.response_locked_at).getTime()) { + const err = new AppError('Response window has closed', 423, 'RESPONSE_LOCKED'); + err.lockedAt = quote.response_locked_at; + err.currentStatus = quote.status; + throw err; + } + } + + const isAccept = effectiveAction === 'accept'; + const newStatus = isAccept ? 'accepted' : 'declined'; + const respondedAt = quote.responded_at || now; + const responseLockedAt = new Date(new Date(respondedAt).getTime() + windowMinutes * 60 * 1000); + + await db.transaction(async (trx) => { + const updates = { + status: newStatus, + responded_at: respondedAt, + response_locked_at: responseLockedAt, + accepted_at: isAccept ? now : null, + declined_at: !isAccept ? now : null, + updated_at: now, + }; + // Snapshot the ToS text the customer agreed to. Only set on the + // FIRST acceptance — subsequent toggles inside the 15-min window + // don't overwrite, so the audit trail captures the original + // agreement moment. + if (isAccept && tosAccepted && !quote.tos_accepted_at) { + updates.tos_accepted_at = now; + updates.tos_text_snapshot = tosText || null; + } + await trx('quotes').where({ id: quote.id }).update(updates); + await trx('quote_action_tokens').where({ id: tokenRow.id }).update({ + used_at: now, + used_action: newStatus, + used_ip: ip || null, + }); + }); + + try { + await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public'); + } catch (_) {} + + return { status: newStatus, lockedAt: responseLockedAt }; +} + +/** + * Admin "accept on behalf of customer" — records the quote as + * accepted directly, bypassing the public token + response window. + * Used when the admin is on the phone with the customer and they + * verbally accept; the admin wants the quote flipped to `accepted` + * immediately so they can convert it to an event/invoice. + * + * Unlike recordResponse: + * - No token required + * - No response-window lockout (admin can accept stale / expired + * quotes too — useful for retroactive bookkeeping) + * - Skips the ToS-required guard (admin is responsible for + * confirming verbally; ToS_snapshot stays null) + * + * Refuses to act on quotes that are already terminal: `accepted`, + * `declined`, or `converted` rows would silently overwrite history. + * Admins use the cancel/duplicate flow for those cases. + */ +async function adminAcceptQuote(id, adminId) { + const quote = await db('quotes').where({ id }).first(); + if (!quote) throw new AppError('Quote not found', 404); + if (quote.status === 'accepted') { + throw new AppError('Quote already accepted', 409, 'QUOTE_ALREADY_ACCEPTED'); + } + if (quote.status === 'declined') { + throw new AppError('Quote was declined; duplicate it to start a fresh round.', 409, 'QUOTE_DECLINED'); + } + if (quote.status === 'converted') { + throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED'); + } + + const now = new Date(); + const windowMinutes = ensureInt(await getAppSetting('crm_quotes_accept_window_minutes')) || 15; + const responseLockedAt = new Date(now.getTime() + windowMinutes * 60 * 1000); + + await db('quotes').where({ id }).update({ + status: 'accepted', + responded_at: now, + response_locked_at: responseLockedAt, + accepted_at: now, + // accept_on_behalf flag intentionally NOT stored as a separate + // column — the audit log entry below captures who accepted and + // when, which is the legally relevant breadcrumb. + updated_at: now, + }); + + try { + await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`); + } catch (_) {} + + // ---- customer confirmation email ------------------------------- + // Renders the quote PDF + queues a "quote accepted — on your + // behalf" email so the customer has a paper trail of what they + // just verbally agreed to on the phone. Failures here don't roll + // back the acceptance — the DB row is already updated and the + // admin can re-send via the resend flow if SMTP is down. + try { + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + if (customer?.email) { + const fresh = await db('quotes').where({ id }).first(); + const lineItems = await db('quote_line_items').where({ quote_id: id }).orderBy('position', 'asc'); + const ctx = await buildRenderContext(fresh, lineItems); + const buffer = await pdfService.renderQuoteToBuffer(ctx); + // Persist PDF snapshot under the same convention sendQuote uses + // — keeps every issued PDF on disk for the audit trail. + const pdfPath = await persistDocPdf('quote', fresh, buffer); + + const formatMoney = (minor, currency, locale) => + new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', { + style: 'currency', currency: (currency || 'CHF').toUpperCase(), + }).format(Number(minor || 0) / 100); + + const lang = customer.preferred_language || ctx.locale || 'de'; + await emailProcessor.queueEmail(null, customer.email, 'quote_accepted_customer', { + quote_number: fresh.quote_number, + customer_name: customer.display_name + || [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.email.split('@')[0], + event_name: fresh.event_name || '', + total_amount: formatMoney(fresh.total_amount_minor, fresh.currency, lang), + accepted_on_behalf: true, + attachments: [{ + filename: `${fresh.quote_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + } + } catch (err) { + // Email failure is not fatal — log + move on. The acceptance + // itself is recorded; the admin can use Resend later. + logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message }); + } + + return { status: 'accepted', lockedAt: responseLockedAt }; +} + +/** + * Convert an accepted quote to an event + scheduled invoices. + * Wraps everything in a transaction so a half-finished conversion + * doesn't litter the DB. + * + * Implementation note: invoice creation delegates to invoiceService — + * required by Commit 7. We `require` lazily to dodge the circular + * dependency between quoteService and invoiceService. + */ +/** + * Convert an accepted quote directly into an invoice — no event, no + * gallery, just the financial document. Used for engagements that + * don't produce a photo deliverable (consulting, equipment hire, etc). + * + * Creates ONE invoice per installment in the payment-term snapshot — + * same fan-out as convertToEvent, but without the events / event_ + * payment_plans rows. The first installment is scheduled to send + * immediately; later ones use the same trigger-relative-to-event + * date logic the schedule pass uses, anchored on the quote's event_ + * date if any, else the issue date. + * + * Leaves the quote `accepted` → `converted` state machine intact so + * the same status badge logic works for both paths. + */ +async function convertToInvoiceOnly(quoteId, adminId, options = {}) { + const { quote, lineItems } = (await getQuoteById(quoteId)) || {}; + if (!quote) throw new AppError('Quote not found', 404); + if (quote.status !== 'accepted') { + throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409); + } + if (quote.converted_event_id) { + // Already has a linked event — nothing to do here; tell the + // caller to use the event-detail page for new invoices. + throw new AppError('This quote was already converted to an event; create the invoice from the event instead.', 409, 'ALREADY_CONVERTED_TO_EVENT'); + } + // Guard against double-spending a quote that already has a contract + // in flight. contractService.convertToInvoiceOnly re-enters this + // path on the contract→invoice button — it passes + // { fromContract: true } so the guard yields. + if (quote.converted_contract_id && !options.fromContract) { + throw new AppError( + 'This quote already has a pending contract. Convert the contract to invoices instead, or cancel the contract first.', + 409, 'CONTRACT_IN_FLIGHT', + ); + } + + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + ensureCustomerFeatureEnabled(customer, 'quotes'); + // The customer must also have the bills feature enabled or the + // generated invoice can't be sent. + if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') { + throw new AppError('This customer has Bills disabled — enable it on the customer detail page first.', + 409, 'CUSTOMER_FEATURE_DISABLED'); + } + + const paymentTermSnapshot = quote.payment_term_snapshot + ? (typeof quote.payment_term_snapshot === 'string' + ? JSON.parse(quote.payment_term_snapshot) + : quote.payment_term_snapshot) + : null; + + const invoiceService = require('./invoiceService'); + + return await db.transaction(async (trx) => { + const installments = Array.isArray(paymentTermSnapshot?.installments) + ? paymentTermSnapshot.installments + : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; + + await invoiceService.scheduleInvoicesForEvent({ + trx, + // eventId omitted → invoices have source_quote_id but no event_id. + eventId: null, + quoteId: quote.id, + customer, + currency: quote.currency, + language: quote.language, + lineItems, + totals: { + net: quote.net_amount_minor, + vatRate: quote.vat_rate, + vat: quote.vat_amount_minor, + shipping: quote.shipping_amount_minor, + total: quote.total_amount_minor, + }, + installments, + eventDate: quote.event_date, + // Inline event snapshot — copied so the converted invoice + // keeps the quote's event label / times for accounting + UI + // even when there's no `events` row to fall back to (migration 123). + eventName: quote.event_name, + eventTimeStart: quote.event_time_start, + eventTimeEnd: quote.event_time_end, + // Migration 124 — pass the split payment-term FKs + the + // composed snapshot through so the converted invoice carries + // them on both the FK and snapshot paths. + paymentNetDaysTemplateId: quote.payment_net_days_template_id, + paymentTimingTemplateId: quote.payment_timing_template_id, + paymentTermSnapshot, + adminId, + ccPdfEmail: quote.cc_pdf_email, + // Net 14 / 30 / 60 / 90 carry through from the quote's + // selected payment-term template so each scheduled invoice's + // due_date reflects what the customer agreed to on the quote. + netDays: paymentTermSnapshot?.net_days, + // Migration 140 — every spawned invoice inherits the source + // quote's deal_uuid so quote + N invoices group under one deal. + dealUuid: quote.deal_uuid, + }); + + // Mark quote `converted` without a converted_event_id so the + // existing transition rules still apply (can't be edited / sent + // again). The list view's status badge says "converted"; admin + // sees the linked invoices in the customer detail panel. + await trx('quotes').where({ id: quote.id }).update({ + status: 'converted', + updated_at: new Date(), + }); + + try { + await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: installments.length }, + null, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: installments.length }); + return { installmentsCreated: installments.length }; + }); +} + +async function convertToEvent(quoteId, adminId, options = {}) { + const { quote, lineItems } = (await getQuoteById(quoteId)) || {}; + if (!quote) throw new AppError('Quote not found', 404); + if (quote.status !== 'accepted') { + throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409); + } + if (quote.converted_event_id) { + return { eventId: quote.converted_event_id, alreadyConverted: true }; + } + // Same guard as convertToInvoiceOnly — refuse if a contract is in + // flight unless the contract→event button re-entered this path. + if (quote.converted_contract_id && !options.fromContract) { + throw new AppError( + 'This quote already has a pending contract. Convert the contract to an event instead, or cancel the contract first.', + 409, 'CONTRACT_IN_FLIGHT', + ); + } + + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + ensureCustomerFeatureEnabled(customer, 'quotes'); + + const paymentTermSnapshot = quote.payment_term_snapshot + ? (typeof quote.payment_term_snapshot === 'string' + ? JSON.parse(quote.payment_term_snapshot) + : quote.payment_term_snapshot) + : null; + + // Lazy import to avoid the circular dep. + const invoiceService = require('./invoiceService'); + + return await db.transaction(async (trx) => { + // The events table schema has drifted across migrations: + // installs that ran the original 060 series have + // host_name/host_email; later ones renamed to customer_*; some + // have both. Rather than hard-code one set and fail on the + // other, introspect the columns at runtime and only insert + // fields the table actually has. + const adminRow = await trx('admin_users').where({ id: adminId }).first(); + const oneYearAfterEvent = new Date(quote.event_date || quote.issue_date); + oneYearAfterEvent.setFullYear(oneYearAfterEvent.getFullYear() + 1); + const placeholder = crypto.randomBytes(32).toString('hex'); + const shareLink = crypto.randomBytes(32).toString('hex'); + const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.display_name || customer.company_name || quote.quote_number; + const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`; + const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; + + // Each candidate column is paired with the value we'd write. We + // ask the DB which columns exist and only keep the matching pairs + // — bullet-proof against schema drift in either direction. + const eventCols = await trx('events').columnInfo(); + const candidate = { + slug: `quote-${quote.quote_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, + event_name: quote.event_name || `Event ${quote.quote_number}`, + event_date: quote.event_date || quote.issue_date, + host_name: fullName, + host_email: customerEmail, + customer_name: fullName, + customer_email: customerEmail, + customer_phone: customer.phone, + admin_email: adminEmail, + event_type: 'wedding', + password_hash: placeholder, + share_link: shareLink, + share_token: shareLink, + expires_at: oneYearAfterEvent, + is_active: true, + is_archived: false, + is_draft: true, + created_by: adminId, + quote_id: quote.id, + created_at: new Date(), + updated_at: new Date(), + }; + const eventRow = {}; + for (const [k, v] of Object.entries(candidate)) { + if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v; + } + const inserted = await trx('events').insert(eventRow).returning('id'); + const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Junction row so the customer can already see the event in their + // dashboard once the admin activates it. + await trx('event_customer_assignments').insert({ + event_id: eventId, + customer_account_id: customer.id, + assigned_by_admin_id: adminId, + assigned_at: new Date(), + }); + + // Payment-plan glue. + await trx('event_payment_plans').insert({ + event_id: eventId, + quote_id: quote.id, + payment_term_snapshot: JSON.stringify(paymentTermSnapshot || {}), + created_at: new Date(), + updated_at: new Date(), + }); + + // Build the invoice schedule from installments. + const installments = Array.isArray(paymentTermSnapshot?.installments) + ? paymentTermSnapshot.installments + : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; + + await invoiceService.scheduleInvoicesForEvent({ + trx, + eventId, + quoteId: quote.id, + customer, + currency: quote.currency, + language: quote.language, + lineItems, + totals: { + net: quote.net_amount_minor, + vatRate: quote.vat_rate, + vat: quote.vat_amount_minor, + shipping: quote.shipping_amount_minor, + total: quote.total_amount_minor, + }, + installments, + eventDate: quote.event_date, + // Inline event snapshot — same rationale as convertToInvoiceOnly + // above (migration 123). + eventName: quote.event_name, + eventTimeStart: quote.event_time_start, + eventTimeEnd: quote.event_time_end, + adminId, + ccPdfEmail: quote.cc_pdf_email, + // Net 14 / 30 / 60 / 90 carry through from the quote's + // payment-term template (same as convertToInvoiceOnly). + netDays: paymentTermSnapshot?.net_days, + // Migration 140 — propagate the quote's deal_uuid down through + // every spawned invoice (same as convertToInvoiceOnly above). + dealUuid: quote.deal_uuid, + }); + + await trx('quotes').where({ id: quote.id }).update({ + status: 'converted', + converted_event_id: eventId, + updated_at: new Date(), + }); + + try { + await logActivity('quote_converted', { quoteId: quote.id, eventId }, eventId, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId }); + return { eventId, alreadyConverted: false }; + }); +} + +async function duplicateQuote(id, adminId) { + const { quote, lineItems } = (await getQuoteById(id)) || {}; + if (!quote) throw new AppError('Quote not found', 404); + + return await createQuote({ + customerAccountId: quote.customer_account_id, + language: quote.language, + currency: quote.currency, + eventName: quote.event_name, + eventDate: quote.event_date, + eventTimeStart: quote.event_time_start, + eventTimeEnd: quote.event_time_end, + expectedDurationHours: quote.expected_duration_hours, + paymentTermTemplateId: quote.payment_term_template_id, + vatRate: quote.vat_rate, + shippingAmountMinor: quote.shipping_amount_minor, + introText: quote.intro_text, + outroText: quote.outro_text, + internalNotes: quote.internal_notes, + ccPdfEmail: quote.cc_pdf_email, + businessBankAccountId: quote.business_bank_account_id, + lineItems: lineItems.map((li) => ({ + position: li.position, + quantity: li.quantity, + description: li.description, + unit_price_minor: li.unit_price_minor, + discount_percent: li.discount_percent, + })), + }, adminId); +} + +// --------------------------------------------------------------------- +// Presets (line items + payment terms) +// --------------------------------------------------------------------- + +async function listLineItemPresets() { + return await db('quote_line_item_presets') + .where({ is_active: formatBoolean(true) }) + .orderBy('display_order', 'asc').orderBy('id', 'asc'); +} + +async function createLineItemPreset(payload) { + const row = { + name: payload.name, + description: payload.description || '', + unit_price_minor: ensureInt(payload.unit_price_minor), + currency: (payload.currency || 'CHF').toUpperCase(), + quantity_default: ensureNumber(payload.quantity_default, 1), + display_order: ensureInt(payload.display_order), + is_active: formatBoolean(true), + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await db('quote_line_item_presets').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return await db('quote_line_item_presets').where({ id }).first(); +} + +async function updateLineItemPreset(id, payload) { + const map = { + name: 'name', description: 'description', currency: 'currency', + unit_price_minor: 'unit_price_minor', quantity_default: 'quantity_default', + display_order: 'display_order', is_active: 'is_active', + }; + const updates = { updated_at: new Date() }; + for (const [api, col] of Object.entries(map)) { + if (Object.prototype.hasOwnProperty.call(payload, api)) { + updates[col] = col === 'is_active' ? formatBoolean(Boolean(payload[api])) : payload[api]; + } + } + await db('quote_line_item_presets').where({ id }).update(updates); + return await db('quote_line_item_presets').where({ id }).first(); +} + +async function deleteLineItemPreset(id) { + // Soft delete via is_active = false to preserve historical references. + await db('quote_line_item_presets').where({ id }) + .update({ is_active: formatBoolean(false), updated_at: new Date() }); + return { deleted: true }; +} + +async function listPaymentTermTemplates() { + return await db('payment_term_templates') + .where({ is_active: formatBoolean(true) }) + .orderBy('display_order', 'asc').orderBy('id', 'asc'); +} + +async function createPaymentTermTemplate(payload) { + if (!Array.isArray(payload.installments) || payload.installments.length === 0) { + throw new AppError('At least one installment is required', 400); + } + const sum = payload.installments.reduce((s, x) => s + ensureNumber(x.percent, 0), 0); + if (Math.abs(sum - 100) > 0.01) { + throw new AppError('Installment percentages must sum to 100', 400); + } + const row = { + name: payload.name, + description: payload.description || '', + net_days: ensureInt(payload.net_days) || 30, + skonto_percent: payload.skonto_percent == null ? null : ensureNumber(payload.skonto_percent), + skonto_within_days: payload.skonto_within_days == null ? null : ensureInt(payload.skonto_within_days), + installments: JSON.stringify(payload.installments), + is_system: formatBoolean(false), + is_active: formatBoolean(true), + display_order: ensureInt(payload.display_order), + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await db('payment_term_templates').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return await db('payment_term_templates').where({ id }).first(); +} + +async function updatePaymentTermTemplate(id, payload) { + const existing = await db('payment_term_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + if (existing.is_system && Object.prototype.hasOwnProperty.call(payload, 'installments')) { + // Allow renaming + description tweaks on system rows but never let + // an admin reshape the installment array — keeps the "factory + // presets" semantically stable for migrations & docs. + delete payload.installments; + } + const updates = { updated_at: new Date() }; + for (const k of ['name', 'description', 'net_days', 'skonto_percent', 'skonto_within_days', 'display_order', 'is_active']) { + if (Object.prototype.hasOwnProperty.call(payload, k)) { + updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k]; + } + } + if (Object.prototype.hasOwnProperty.call(payload, 'installments')) { + updates.installments = JSON.stringify(payload.installments); + } + await db('payment_term_templates').where({ id }).update(updates); + return await db('payment_term_templates').where({ id }).first(); +} + +async function deletePaymentTermTemplate(id) { + const existing = await db('payment_term_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + if (existing.is_system) { + throw new AppError('Cannot delete a system payment-term template', 409); + } + // Soft-delete to keep snapshots referenced by sent quotes coherent. + await db('payment_term_templates').where({ id }) + .update({ is_active: formatBoolean(false), updated_at: new Date() }); + return { deleted: true }; +} + +// --------------------------------------------------------------------- +// Split payment-term templates — net-days + timing (migration 124). +// +// The two new tables decouple the "Net X days" choice from the +// "payment timing / split" choice. CRUD shape mirrors the legacy +// payment_term_templates helpers above so adminQuotes routes can drop +// in matching endpoints without re-deriving validation rules. +// --------------------------------------------------------------------- + +async function listPaymentNetDaysTemplates() { + return await db('payment_net_days_templates') + .where({ is_active: formatBoolean(true) }) + .orderBy('display_order', 'asc').orderBy('id', 'asc'); +} + +async function createPaymentNetDaysTemplate(payload) { + if (payload.net_days == null) { + throw new AppError('net_days is required', 400); + } + const row = { + name: payload.name, + description: payload.description || null, + // Allow 0 ("Sofort fällig"). ensureInt would coerce non-numbers + // to 0 which is fine for missing values but we already null-check + // above to catch the genuinely-missing case. + net_days: ensureInt(payload.net_days), + skonto_percent: payload.skonto_percent == null ? null : ensureNumber(payload.skonto_percent), + skonto_within_days: payload.skonto_within_days == null ? null : ensureInt(payload.skonto_within_days), + is_system: formatBoolean(false), + is_active: formatBoolean(true), + display_order: ensureInt(payload.display_order), + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await db('payment_net_days_templates').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return await db('payment_net_days_templates').where({ id }).first(); +} + +async function updatePaymentNetDaysTemplate(id, payload) { + const existing = await db('payment_net_days_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + const updates = { updated_at: new Date() }; + for (const k of ['name', 'description', 'net_days', 'skonto_percent', 'skonto_within_days', 'display_order', 'is_active']) { + if (Object.prototype.hasOwnProperty.call(payload, k)) { + updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k]; + } + } + await db('payment_net_days_templates').where({ id }).update(updates); + return await db('payment_net_days_templates').where({ id }).first(); +} + +async function deletePaymentNetDaysTemplate(id) { + const existing = await db('payment_net_days_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + if (existing.is_system) { + throw new AppError('Cannot delete a system net-days template', 409); + } + // Soft-delete — sent quote/invoice snapshots survive independently. + await db('payment_net_days_templates').where({ id }) + .update({ is_active: formatBoolean(false), updated_at: new Date() }); + return { deleted: true }; +} + +async function listPaymentTimingTemplates() { + return await db('payment_timing_templates') + .where({ is_active: formatBoolean(true) }) + .orderBy('display_order', 'asc').orderBy('id', 'asc'); +} + +async function createPaymentTimingTemplate(payload) { + if (!Array.isArray(payload.installments) || payload.installments.length === 0) { + throw new AppError('At least one installment is required', 400); + } + const sum = payload.installments.reduce((s, x) => s + ensureNumber(x.percent, 0), 0); + if (Math.abs(sum - 100) > 0.01) { + throw new AppError('Installment percentages must sum to 100', 400); + } + const row = { + name: payload.name, + description: payload.description || null, + installments: JSON.stringify(payload.installments), + is_system: formatBoolean(false), + is_active: formatBoolean(true), + display_order: ensureInt(payload.display_order), + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await db('payment_timing_templates').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return await db('payment_timing_templates').where({ id }).first(); +} + +async function updatePaymentTimingTemplate(id, payload) { + const existing = await db('payment_timing_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + // Same rule as the legacy helper — system rows can be renamed but + // their installments array is locked so migrations + docs stay + // semantically stable. + if (existing.is_system && Object.prototype.hasOwnProperty.call(payload, 'installments')) { + delete payload.installments; + } + const updates = { updated_at: new Date() }; + for (const k of ['name', 'description', 'display_order', 'is_active']) { + if (Object.prototype.hasOwnProperty.call(payload, k)) { + updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k]; + } + } + if (Object.prototype.hasOwnProperty.call(payload, 'installments')) { + updates.installments = JSON.stringify(payload.installments); + } + await db('payment_timing_templates').where({ id }).update(updates); + return await db('payment_timing_templates').where({ id }).first(); +} + +/** + * Compose a legacy-shape `payment_term_snapshot` JSON object from the + * two new split FKs on a quote or invoice row (migration 124). + * + * Returns null when at least one of the two FKs is unset — the caller + * then falls back to reading the legacy `payment_term_template_id` + * column for backward compat. We deliberately don't blend partial + * data with legacy data; either the split path applies cleanly or it + * doesn't. + * + * Output shape is identical to the legacy template row so downstream + * consumers (pdfService, scheduleInvoicesForEvent, dunning) work + * without changes: + * + * { description, net_days, skonto_percent, skonto_within_days, + * installments } + */ +async function composeSnapshotFromSplitFks(row) { + if (!row.payment_net_days_template_id || !row.payment_timing_template_id) return null; + const netDays = await db('payment_net_days_templates') + .where({ id: row.payment_net_days_template_id }).first(); + const timing = await db('payment_timing_templates') + .where({ id: row.payment_timing_template_id }).first(); + if (!netDays || !timing) return null; + // Migration 142 — ad-hoc installments override. When the quote + // carries a populated `payment_term_installments_override`, those + // rows replace the template's installments in the snapshot. Keeps + // every other snapshot field (net_days / skonto) coming from the + // chosen templates so the override only touches what the admin + // explicitly customised. + let override = null; + if (row.payment_term_installments_override) { + try { + override = typeof row.payment_term_installments_override === 'string' + ? JSON.parse(row.payment_term_installments_override) + : row.payment_term_installments_override; + if (!Array.isArray(override) || override.length === 0) override = null; + } catch (_) { override = null; } + } + const templateInstallments = typeof timing.installments === 'string' + ? JSON.parse(timing.installments) + : timing.installments; + return { + description: timing.description || netDays.description || null, + net_days: netDays.net_days, + skonto_percent: netDays.skonto_percent, + skonto_within_days: netDays.skonto_within_days, + installments: override || templateInstallments, + }; +} + +async function deletePaymentTimingTemplate(id) { + const existing = await db('payment_timing_templates').where({ id }).first(); + if (!existing) throw new AppError('Not found', 404); + if (existing.is_system) { + throw new AppError('Cannot delete a system timing template', 409); + } + await db('payment_timing_templates').where({ id }) + .update({ is_active: formatBoolean(false), updated_at: new Date() }); + return { deleted: true }; +} + +module.exports = { + // Lifecycle + listQuotes, + getQuoteById, + createQuote, + updateQuote, + sendQuote, + duplicateQuote, + recordResponse, + adminAcceptQuote, + convertToEvent, + convertToInvoiceOnly, + + // Preview / PDF + renderQuotePdfBuffer, + renderQuotePdfFromPayload, + + // Presets + listLineItemPresets, + createLineItemPreset, + updateLineItemPreset, + deleteLineItemPreset, + listPaymentTermTemplates, + createPaymentTermTemplate, + updatePaymentTermTemplate, + deletePaymentTermTemplate, + // Split payment-term templates (migration 124). + listPaymentNetDaysTemplates, + createPaymentNetDaysTemplate, + updatePaymentNetDaysTemplate, + deletePaymentNetDaysTemplate, + listPaymentTimingTemplates, + createPaymentTimingTemplate, + updatePaymentTimingTemplate, + deletePaymentTimingTemplate, + + // Internals exposed for tests + invoiceService re-use. + _internal: { + computeTotals, + ensureCustomerFeatureEnabled, + nextQuoteNumber, + persistDocPdf, + buildRenderContext, + // Migration 119: hierarchy helpers — shared with invoiceService + // (commit 3) so the quote → invoice cloner stays consistent. + validateLineItemHierarchy, + insertLineItemsHierarchical, + resolveParentTotalsFromSubItems, + }, +}; diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js new file mode 100644 index 00000000..7fd33efe --- /dev/null +++ b/backend/src/services/taxReportService.js @@ -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. + * 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 + * + * 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 }, +}; diff --git a/backend/src/utils/appSettings.js b/backend/src/utils/appSettings.js new file mode 100644 index 00000000..354eaa05 --- /dev/null +++ b/backend/src/utils/appSettings.js @@ -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 }; diff --git a/backend/src/utils/clientIp.js b/backend/src/utils/clientIp.js new file mode 100644 index 00000000..d6321d52 --- /dev/null +++ b/backend/src/utils/clientIp.js @@ -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 }; diff --git a/backend/src/utils/dateFormatter.js b/backend/src/utils/dateFormatter.js index 5bbd0518..55cc8eaa 100644 --- a/backend/src/utils/dateFormatter.js +++ b/backend/src/utils/dateFormatter.js @@ -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, }; \ No newline at end of file diff --git a/backend/src/utils/documentSequences.js b/backend/src/utils/documentSequences.js new file mode 100644 index 00000000..81b9ba50 --- /dev/null +++ b/backend/src/utils/documentSequences.js @@ -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 }; diff --git a/backend/src/utils/iban.js b/backend/src/utils/iban.js new file mode 100644 index 00000000..d5bc5425 --- /dev/null +++ b/backend/src/utils/iban.js @@ -0,0 +1,134 @@ +/** + * IBAN validation per ISO 13616. + * + * Three checks: + * 1. Format — 2 uppercase letters (country) + 2 digits (check) + + * alphanumeric BBAN. + * 2. Length — each country fixes a total IBAN length. We accept any + * country whose ISO code we recognise; unknown country codes + * fall back to a generic 15–34 char range (ISO 13616 caps every + * IBAN at 34 chars). + * 3. Mod-97 checksum — rearrange the IBAN so the first four chars + * land at the end, expand letters to digits (A=10..Z=35), the + * result modulo 97 MUST equal 1. Catches single-digit typos and + * digit transpositions with high probability. + * + * The validator is pure (no IO, no DB, no network) and returns a + * structured result so callers can surface a precise reason to the + * user. + * + * What this does NOT do: + * - Confirm the bank itself exists (would require an external + * directory or a bank-routing API — out of scope here). + * - Validate the BBAN's internal structure beyond length + charset + * (country-specific BBAN rules are not enforced). + * + * Usage: + * const { valid, normalized, reason } = validateIban(' ch 93 0076 2011 6238 5295 7 '); + * if (!valid) throw new Error(reason); + * // normalized === 'CH9300762011623852957' + */ + +// IBAN length per ISO country code (ISO 13616, public registry). +// Anything not in this table falls through to the 15–34 range check. +// Source: SWIFT IBAN Registry. Update when new countries are added. +const IBAN_LENGTHS = { + AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, + BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28, + CH: 21, CR: 22, CY: 28, CZ: 24, + DE: 22, DK: 18, DO: 28, + EE: 20, EG: 29, ES: 24, + FI: 18, FO: 18, FR: 27, + GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28, + HR: 21, HU: 28, + IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, + JO: 30, + KW: 30, KZ: 20, + LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, + MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, + NL: 18, NO: 15, + PK: 24, PL: 28, PS: 29, PT: 25, + QA: 29, + RO: 24, RS: 22, + SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28, + TL: 23, TN: 24, TR: 26, + UA: 29, + VA: 22, VG: 24, + XK: 20, +}; + +/** + * Rearrange + numerify the IBAN per ISO 13616 then take mod 97. + * The whole-string-as-BigInt approach is acceptable here: max IBAN + * length is 34 chars → numerified length is at most ~68 digits. + * Native BigInt is plenty fast for one-off validation. + */ +function mod97(iban) { + const rearranged = iban.slice(4) + iban.slice(0, 4); + let expanded = ''; + for (const ch of rearranged) { + if (ch >= '0' && ch <= '9') { + expanded += ch; + } else if (ch >= 'A' && ch <= 'Z') { + // A=10, B=11, ..., Z=35 + expanded += String(ch.charCodeAt(0) - 55); + } else { + return -1; // invalid char — caller treats as failed checksum + } + } + // Standard chunked mod-97 to avoid BigInt allocation cost. + let remainder = 0; + for (const digit of expanded) { + remainder = (remainder * 10 + Number(digit)) % 97; + } + return remainder; +} + +/** + * Normalise + validate an IBAN string. + * + * @param {unknown} input Raw user-typed value. Spaces and lowercase + * letters are tolerated and stripped/uppercased + * before checking. + * @returns {{ + * valid: boolean, + * normalized: string, // empty when input wasn't a string + * reason?: 'EMPTY' // nothing useful supplied + * | 'FORMAT' // failed the structural regex + * | 'LENGTH' // wrong length for the country + * | 'CHECKSUM' // mod-97 didn't equal 1 + * }} + */ +function validateIban(input) { + if (input == null) return { valid: false, normalized: '', reason: 'EMPTY' }; + const raw = String(input).replace(/\s+/g, '').toUpperCase(); + if (!raw) return { valid: false, normalized: '', reason: 'EMPTY' }; + + // ISO 13616: starts with 2 letters (country) + 2 digits (check) + + // 11..30 chars of alphanumeric BBAN. Total length 15..34. + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(raw)) { + return { valid: false, normalized: raw, reason: 'FORMAT' }; + } + + const country = raw.slice(0, 2); + const expectedLen = IBAN_LENGTHS[country]; + if (expectedLen != null) { + if (raw.length !== expectedLen) { + return { valid: false, normalized: raw, reason: 'LENGTH' }; + } + } else if (raw.length < 15 || raw.length > 34) { + return { valid: false, normalized: raw, reason: 'LENGTH' }; + } + + if (mod97(raw) !== 1) { + return { valid: false, normalized: raw, reason: 'CHECKSUM' }; + } + + return { valid: true, normalized: raw }; +} + +module.exports = { + validateIban, + // Exposed for unit tests. + _internal: { mod97, IBAN_LENGTHS }, +}; diff --git a/backend/src/utils/numericHelpers.js b/backend/src/utils/numericHelpers.js new file mode 100644 index 00000000..b0ce39ff --- /dev/null +++ b/backend/src/utils/numericHelpers.js @@ -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 }; diff --git a/backend/src/utils/pdfFilename.js b/backend/src/utils/pdfFilename.js new file mode 100644 index 00000000..ea4741b3 --- /dev/null +++ b/backend/src/utils/pdfFilename.js @@ -0,0 +1,79 @@ +/** + * Build a consistent filesystem-safe filename for quote / invoice + * PDFs. Format: + * + * _.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 }; diff --git a/backend/src/utils/publicTokenGuards.js b/backend/src/utils/publicTokenGuards.js new file mode 100644 index 00000000..e0bdde08 --- /dev/null +++ b/backend/src/utils/publicTokenGuards.js @@ -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 }, +}; diff --git a/backend/src/utils/resolveLogoFile.js b/backend/src/utils/resolveLogoFile.js new file mode 100644 index 00000000..92b48acb --- /dev/null +++ b/backend/src/utils/resolveLogoFile.js @@ -0,0 +1,193 @@ +/** + * resolveLogoFile — resolve a logo to an absolute existing file path. + * + * The CRM PDFs accept a logo from three different sources and each + * source may store the path in a different shape (absolute multer + * path, relative URL, bare filename, etc.). Rather than have the PDF + * renderer guess, we build an exhaustive candidate list HERE, return + * the first existing PNG/JPEG file, and log everything we tried when + * we come up empty. + * + * Why this lives in utils: + * - both invoiceService and quoteService need the same resolution + * - the renderer (pdfService) should NOT touch the DB or the + * filesystem-discovery rules; it just calls doc.image(path) + * + * Sources we accept (in priority order): + * 1. business_profile.logo_path — explicit per-CRM logo + * 2. app_settings.branding_logo_path — absolute multer path from + * Settings → Branding (preferred — already absolute) + * 3. app_settings.branding_logo_url — URL path from the same + * branding upload (fallback for older installs) + * + * For each source we try multiple candidate disk paths: + * - The raw value as an absolute path (if absolute) + * - STORAGE_PATH joined with the value (stripped of leading "/") + * - STORAGE_PATH/uploads/logos/ + * - STORAGE_PATH/branding/ + * - CWD/storage joined with the value (last-ditch for older + * docker-compose configs that didn't set STORAGE_PATH) + * + * Format filter: + * PDFKit only natively decodes PNG + JPEG. SVG/WebP/GIF/TIFF files + * are silently skipped here (with a warn log) so the rest of the + * PDF still renders rather than crashing on an unsupported format. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getStoragePath } = require('../config/storage'); +const { getAppSetting } = require('./appSettings'); +const logger = require('./logger'); + +const SUPPORTED_EXT = /\.(png|jpe?g)$/i; +// Formats PDFKit can't embed directly but `sharp` can rasterise into +// PNG for us. We transparently convert + cache. +const CONVERTIBLE_EXT = /\.(svg|webp|gif|tif|tiff|avif|heif|heic)$/i; + +function generateCandidates(raw, storageRoot) { + const value = String(raw || '').trim(); + if (!value) return []; + const stripped = value.replace(/^\/+/, ''); + const baseName = path.basename(value); + // Build candidate set; dedup at the end so we don't stat the same + // file twice when the inputs overlap. + const candidates = [ + path.isAbsolute(value) ? value : null, + path.join(storageRoot, stripped), + path.join(storageRoot, 'uploads', 'logos', baseName), + path.join(storageRoot, 'branding', baseName), + path.join(process.cwd(), 'storage', stripped), + path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName), + path.join(process.cwd(), 'storage', 'branding', baseName), + ].filter(Boolean); + return [...new Set(candidates)]; +} + +function pickExisting(candidates) { + for (const c of candidates) { + try { + if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; + } catch (_) { /* ignore */ } + } + return null; +} + +/** + * Rasterise a non-PNG/JPEG source into PNG so PDFKit can embed it. + * The output is cached under STORAGE_PATH/cache/logo-png/, keyed by + * the source path + mtime + size — re-uploading the SVG invalidates + * the cache automatically without us having to clean up old entries. + * + * Returns the absolute cached PNG path on success, or null when + * `sharp` fails (corrupt SVG, unsupported feature inside the SVG, + * etc.). The caller logs + falls back to the name-only branch. + */ +async function rasteriseToPng(sourcePath, storageRoot) { + let sharp; + try { + sharp = require('sharp'); + } catch (err) { + logger.warn('PDF logo rasterisation skipped — sharp not installed', { err: err.message }); + return null; + } + try { + const stat = fs.statSync(sourcePath); + const cacheDir = path.join(storageRoot, 'cache', 'logo-png'); + fs.mkdirSync(cacheDir, { recursive: true }); + // Content-addressed cache: sha1(src path + mtime ns + size). + // Including mtime means re-uploading the source invalidates the + // cache entry naturally. + const key = crypto.createHash('sha1') + .update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`) + .digest('hex'); + const cachedPath = path.join(cacheDir, `${key}.png`); + if (fs.existsSync(cachedPath)) { + return cachedPath; + } + // density: 384 gives a crisp render even when the SVG embeds at + // a small intrinsic size (PDFKit's `fit` will downscale, never + // upscale). 512px wide is more than enough for a letterhead + // logo. + await sharp(sourcePath, { density: 384 }) + .resize({ width: 512, withoutEnlargement: false }) + .png() + .toFile(cachedPath); + logger.info('PDF logo rasterised to PNG', { source: sourcePath, cached: cachedPath }); + return cachedPath; + } catch (err) { + logger.warn('PDF logo rasterisation failed', { + source: sourcePath, err: err.message, + }); + return null; + } +} + +/** + * @param {object} profile the business_profile row (or null) + * @returns {Promise} absolute path to a usable PNG/JPEG, or null + */ +async function resolveLogoFile(profile) { + const storageRoot = getStoragePath(); + const raws = []; + const profileLogoPath = (profile?.logo_path || '').toString().trim(); + if (profileLogoPath) raws.push({ source: 'business_profile.logo_path', value: profileLogoPath }); + + try { + const brandingDisk = await getAppSetting('branding_logo_path'); + if (brandingDisk && typeof brandingDisk === 'string' && brandingDisk.trim()) { + raws.push({ source: 'branding_logo_path', value: brandingDisk.trim() }); + } + } catch (_) { /* ignore */ } + + try { + const brandingUrl = await getAppSetting('branding_logo_url'); + if (brandingUrl && typeof brandingUrl === 'string' && brandingUrl.trim()) { + raws.push({ source: 'branding_logo_url', value: brandingUrl.trim() }); + } + } catch (_) { /* ignore */ } + + if (raws.length === 0) return null; + + for (const { source, value } of raws) { + const candidates = generateCandidates(value, storageRoot); + const found = pickExisting(candidates); + if (!found) continue; + if (SUPPORTED_EXT.test(found)) { + logger.info('Resolved PDF logo', { source, configured: value, resolved: found }); + return found; + } + if (CONVERTIBLE_EXT.test(found)) { + // SVG / WebP / GIF / TIFF / AVIF — rasterise to PNG so PDFKit + // can embed it. Cached under STORAGE_PATH/cache/logo-png/ so + // repeated PDF renders don't re-encode the same source. + const rasterised = await rasteriseToPng(found, storageRoot); + if (rasterised) { + logger.info('Resolved PDF logo via rasterisation', { + source, configured: value, original: found, resolved: rasterised, + }); + return rasterised; + } + logger.warn('PDF logo rasterisation produced no file; trying next source', { + source, configured: value, found, + }); + continue; + } + // Unknown extension — try anyway, PDFKit may still accept it. + logger.warn('PDF logo has unusual extension; attempting to render as-is', { + source, configured: value, found, + }); + return found; + } + + logger.warn('PDF logo not found on disk after trying all sources', { + storageRoot, + sources: raws.map(({ source, value }) => ({ + source, value, candidates: generateCandidates(value, storageRoot), + })), + }); + return null; +} + +module.exports = { resolveLogoFile }; diff --git a/backend/src/utils/safePath.js b/backend/src/utils/safePath.js new file mode 100644 index 00000000..a1dca605 --- /dev/null +++ b/backend/src/utils/safePath.js @@ -0,0 +1,124 @@ +/** + * safePath — path-containment helpers for the contract / quote / invoice + * PDF surfaces. + * + * **Why this exists** + * + * The audit (#25, #31) flagged that several routes pipe `fs.createReadStream` + * on a path read directly from the DB (`contracts.pdf_path`, + * `contracts.signed_pdf_path`) and that `attachSignedPdfUpload` accepts + * a route-supplied filePath with no containment assertion. The + * defence-in-depth concern: if a path ever got into the DB pointing + * outside the legitimate storage roots (via a future migration bug, + * a hand-edited row, or a SQL-injection regression elsewhere), the + * stream would happily read /etc/passwd or any other readable file + * for the requesting admin. + * + * Today the DB paths are written by the service layer and never + * accept caller input directly, so the practical exposure is low — + * but a 4-line containment check at the read boundary makes the + * invariant explicit and protects against future drift. + * + * **Approach** + * + * `assertPathInside(absoluteFilePath, allowedRoots)` resolves both + * sides to canonical absolute paths via `fs.realpathSync` and + * verifies the file path starts with one of the allowed root strings + * followed by a path separator (so /storage-evil/ doesn't pass when + * /storage/ is allowed). Throws `AppError 403` on violation. + * + * `realpathSync` resolves symlinks, defeating the obvious attack + * (symlink in storage root → /etc/passwd). It throws on missing + * files, which is fine — callers already exists-check before stream + * via `fs.existsSync`. We re-throw missing-file errors as + * AppError 404 to keep the response shape consistent. + * + * **What the contract surface uses** + * + * Two roots: + * 1. `/storage/business-docs/contract//` — system-stamped + * PDFs (immutable as-sent + signed copies). + * 2. `/uploads/contracts/signed/` — + * wet-upload PDFs (admin or customer-supplied). + * + * Both roots are constants from the operator's perspective; legitimate + * paths always live under one of them. + */ + +const fs = require('fs'); +const path = require('path'); +const { AppError } = require('./errors'); + +/** + * Resolve the canonical (symlink-followed) absolute path. Throws + * AppError 404 when the file is missing on disk; caller handles + * the 404 response. + */ +function realpathOr404(absPath) { + try { + return fs.realpathSync(absPath); + } catch (err) { + if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) { + throw new AppError('File missing on disk', 404, 'FILE_MISSING'); + } + throw err; + } +} + +/** + * Assert that `filePath` resolves to a location inside one of + * `allowedRoots`. Throws AppError 403 on violation. + * + * Both inputs are resolved through realpath so symlinks in either + * direction are followed before comparison. `allowedRoots` that + * don't themselves exist are silently dropped from the check (a + * deployment with both quote and contract roots may have the + * contract root missing on first boot, for example) — at least one + * root MUST exist for the check to allow the path. + */ +function assertPathInside(filePath, allowedRoots) { + if (!filePath) throw new AppError('No path provided', 400); + const resolvedFile = realpathOr404(filePath); + const resolvedRoots = []; + for (const root of allowedRoots) { + if (!root) continue; + try { + const r = fs.realpathSync(root); + // Append a separator so /storage/foo doesn't match /storage/foo-evil. + resolvedRoots.push(r.endsWith(path.sep) ? r : r + path.sep); + } catch (_) { + // Root doesn't exist yet — fall through. Next iteration may resolve. + } + } + if (resolvedRoots.length === 0) { + // Defensive: refuse rather than allowing free access when no root + // exists. Should only happen on a half-provisioned install. + throw new AppError('No allowed storage roots configured', 500, 'NO_STORAGE_ROOTS'); + } + const ok = resolvedRoots.some((root) => + resolvedFile === root.slice(0, -1) || resolvedFile.startsWith(root) + ); + if (!ok) { + throw new AppError('Refusing to serve a file outside the storage roots', 403, 'PATH_OUTSIDE_STORAGE'); + } + return resolvedFile; +} + +/** + * Convenience helper that builds the standard contract PDF roots + * (system-stamped + wet-upload) and delegates to assertPathInside. + * Use from contract PDF stream / read sites. + */ +function assertContractPdfPath(filePath) { + const cwd = process.cwd(); + const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage'); + return assertPathInside(filePath, [ + path.join(cwd, 'storage', 'business-docs', 'contract'), + path.join(storageRoot, 'uploads', 'contracts', 'signed'), + ]); +} + +module.exports = { + assertPathInside, + assertContractPdfPath, +}; diff --git a/backend/src/utils/schemaCache.js b/backend/src/utils/schemaCache.js new file mode 100644 index 00000000..4e12897c --- /dev/null +++ b/backend/src/utils/schemaCache.js @@ -0,0 +1,96 @@ +/** + * schemaCache — process-local memoisation for `db.schema.hasColumn`. + * + * **Why this exists** + * + * The CRM services on `feat/crm` are riddled with `hasColumn` guards + * because the schema has been drifting fast (every doc-feature + * migration adds a column that older installs may not yet have). + * Each call hits information_schema (Postgres) or sqlite_master + * (SQLite). On hot paths — `recordCustomerSignature`, + * `recordAdminCountersignature`, `getContractById`, the monthly + * billing pass — we issue 4–8 hasColumn checks per request, all + * for columns whose presence cannot change at runtime. + * + * The audit flagged this as a perf medium. Caching is safe because: + * + * 1. Schema-changing operations (migrations, `ALTER TABLE`) only + * run at boot via `run-migrations-safe.js`, BEFORE any service + * module accepts traffic. The cache is populated lazily after + * boot finishes, so the entries reflect post-migration state. + * + * 2. The Node process is the only schema authority. There's no + * sibling process sneaking in `ALTER TABLE` while we serve + * requests. + * + * 3. If a future migration path needs to run mid-flight, it can + * call `invalidateSchemaCache()` after the schema change. + * + * **What we cache** + * + * Just the boolean answer to `(table, column)`. A miss means the + * column doesn't exist on this install; a hit means it does. The + * cache key is `${table}.${column}`. There's no TTL — the entry is + * valid for the lifetime of the Node process. + * + * **What we DON'T cache** + * + * Negative results from `hasTable` failures (the table simply isn't + * there) — those go through the underlying call each time. That + * scenario is exceptional (table truly missing during a half-applied + * migration window) and we want it to surface, not get masked by a + * stale cache. + * + * **API** + * + * const { hasColumnCached, invalidateSchemaCache } = require('../utils/schemaCache'); + * if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + * ... + * } + * + * Drop-in replacement for `db.schema.hasColumn(...)` calls. The + * existing helper signature returns a Promise so async + * call-sites need no shape change. + */ + +const { db } = require('../database/db'); + +const cache = new Map(); + +async function hasColumnCached(table, column) { + const key = `${table}.${column}`; + if (cache.has(key)) return cache.get(key); + // Resolve via the underlying schema API. We deliberately don't + // catch errors here — if the call throws (e.g. DB connection lost + // mid-boot), the error surfaces to the caller exactly as it would + // have without the cache. + const present = await db.schema.hasColumn(table, column); + cache.set(key, present); + return present; +} + +/** + * Drop every cached entry. Call this after a runtime schema change + * (rare — only the dev tooling does this today). Safe to call any + * time; the next hasColumnCached lookup will re-resolve. + */ +function invalidateSchemaCache() { + cache.clear(); +} + +/** + * Drop entries for a single table. Useful when only one table was + * altered and other tables' caches are still valid. + */ +function invalidateSchemaCacheForTable(table) { + const prefix = `${table}.`; + for (const k of cache.keys()) { + if (k.startsWith(prefix)) cache.delete(k); + } +} + +module.exports = { + hasColumnCached, + invalidateSchemaCache, + invalidateSchemaCacheForTable, +}; diff --git a/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf b/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf new file mode 100644 index 00000000..6089659d --- /dev/null +++ b/backend/storage/business-docs/quote/2026/Q-2026-0001.pdf @@ -0,0 +1 @@ +pdf \ No newline at end of file