From d543949188e34625f7d0fa93606ad6cb22553b0b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 18:18:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(crm):=20backend=20code=20=E2=80=94=20servi?= =?UTF-8?q?ces=20+=20routes=20+=20utilities=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in the full backend CRM stack on top of the consolidated migration (60abe8c). Services (CRM) - quoteService — full lifecycle (draft → sent → accepted → converted to event/invoice), Skonto + Storno + reissue paths - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan, monthly-billing accumulator, payment-check tokens, dunning ladder - contractService — block-composable contract editor, in-browser signature flow, wet-PDF upload path, integrity check, audit trail - customerHoursService — per-entry locking, billing integration - dealsService — cross-document lineage (deal_uuid) - taxReportService — quarterly aggregates + CSV/PDF export - eventReminderService — pre-event customer reminder cron pass - _renderContext — shared issuer/recipient blocks across PDF types - pdfService extensions — custom-font registration, font picker Routes (admin + public) - adminQuotes, adminInvoices, adminContracts, adminCalendar, adminDeals, adminTaxReport, adminDev, adminBusinessProfile - publicQuotes (accept/decline), publicContracts (sign), publicPaymentCheck - Extensions on adminEvents, adminCustomers, adminSettings, adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos, adminCategories, adminUsers, adminArchives, adminDashboard - server.js wires the new mounts (kept upstream's noStoreCache on customer routes per 3-way merge) Utilities - schemaCache (cached hasColumn lookups across services) - documentSequences (atomic gap-free numbering — §14 UStG) - safePath (path-containment guards at fs stream boundaries) - clientIp (sanctioned XFF reader for audit logs) - publicTokenGuards (pre-multer token validation + attempt counters) - numericHelpers (ensureInt / ensureNumber consolidation) - dateFormatter (formatShortDate + dateInputLang) - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile Infrastructure - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost / Montserrat / Noto-Sans / Playfair-Display / Poppins) - Backend package.json + lock updates (pdfkit, signature_pad, qrcode, et al.) - Sample storage layout under storage/business-docs/quote/ Tests - 14 new test files covering quote/invoice/contract lifecycle, installment plan reshape, line-item hierarchy, customer hours, payment check, tax report PDF, IBAN parsing, filename sanitiser --- .../services/contractService.test.js | 109 + .../customerAccountsService.passive.test.js | 213 ++ .../services/customerHoursService.test.js | 165 + .../services/eventService.calendar.test.js | 119 + .../services/invoiceService.hierarchy.test.js | 122 + .../invoiceService.installmentPlan.test.js | 322 ++ .../services/invoiceService.locks.test.js | 374 ++ .../services/pdfService.baseDocument.test.js | 236 ++ .../services/pdfService.helpers.test.js | 95 + .../services/quoteService.hierarchy.test.js | 217 ++ .../services/quoteService.locks.test.js | 179 + .../__tests__/services/taxReportPdf.test.js | 251 ++ .../services/taxReportService.test.js | 338 ++ backend/__tests__/utils/iban.test.js | 124 + backend/__tests__/utils/pdfFilename.test.js | 121 + .../__tests__/utils/resolveLogoFile.test.js | 108 + backend/assets/fonts/Comic-Neue/400.ttf | Bin 0 -> 43500 bytes backend/assets/fonts/Comic-Neue/700.ttf | Bin 0 -> 42536 bytes backend/assets/fonts/IBM-Plex-Sans/400.ttf | Bin 0 -> 57008 bytes backend/assets/fonts/IBM-Plex-Sans/600.ttf | Bin 0 -> 57064 bytes backend/assets/fonts/IBM-Plex-Sans/700.ttf | Bin 0 -> 56964 bytes backend/assets/fonts/Inter/400.ttf | Bin 0 -> 66912 bytes backend/assets/fonts/Inter/600.ttf | Bin 0 -> 67144 bytes backend/assets/fonts/Inter/700.ttf | Bin 0 -> 67128 bytes backend/assets/fonts/Jost/400.ttf | Bin 0 -> 25680 bytes backend/assets/fonts/Jost/600.ttf | Bin 0 -> 25788 bytes backend/assets/fonts/Jost/700.ttf | Bin 0 -> 25724 bytes backend/assets/fonts/Montserrat/400.ttf | Bin 0 -> 48832 bytes backend/assets/fonts/Montserrat/600.ttf | Bin 0 -> 48948 bytes backend/assets/fonts/Montserrat/700.ttf | Bin 0 -> 48824 bytes backend/assets/fonts/Noto-Sans/400.ttf | Bin 0 -> 28132 bytes backend/assets/fonts/Noto-Sans/600.ttf | Bin 0 -> 28284 bytes backend/assets/fonts/Noto-Sans/700.ttf | Bin 0 -> 28208 bytes backend/assets/fonts/Playfair-Display/400.ttf | Bin 0 -> 53956 bytes backend/assets/fonts/Playfair-Display/600.ttf | Bin 0 -> 54068 bytes backend/assets/fonts/Playfair-Display/700.ttf | Bin 0 -> 53996 bytes backend/assets/fonts/Poppins/400.ttf | Bin 0 -> 16288 bytes backend/assets/fonts/Poppins/600.ttf | Bin 0 -> 16192 bytes backend/assets/fonts/Poppins/700.ttf | Bin 0 -> 15952 bytes backend/package-lock.json | 450 ++- backend/package.json | 4 + backend/server.js | 53 +- backend/src/routes/adminBusinessProfile.js | 516 +++ backend/src/routes/adminCalendar.js | 312 ++ backend/src/routes/adminContracts.js | 610 +++ backend/src/routes/adminCustomers.js | 345 +- backend/src/routes/adminDashboard.js | 172 + backend/src/routes/adminDeals.js | 83 + backend/src/routes/adminDev.js | 436 +++ backend/src/routes/adminEmail.js | 98 + backend/src/routes/adminEvents.js | 69 + backend/src/routes/adminFeatureFlags.js | 46 +- backend/src/routes/adminInvoices.js | 882 +++++ backend/src/routes/adminQuotes.js | 820 ++++ backend/src/routes/adminSettings.js | 19 +- backend/src/routes/adminTaxReport.js | 123 + backend/src/routes/customer.js | 342 ++ backend/src/routes/publicContracts.js | 342 ++ backend/src/routes/publicPaymentCheck.js | 101 + backend/src/routes/publicQuotes.js | 184 + backend/src/routes/publicSettings.js | 13 + backend/src/services/_renderContext.js | 172 + .../src/services/businessProfileService.js | 340 ++ backend/src/services/contractBlocksService.js | 288 ++ backend/src/services/contractService.js | 2297 ++++++++++++ .../src/services/customerAccountsService.js | 384 +- backend/src/services/customerHoursService.js | 471 +++ backend/src/services/dealsService.js | 178 + backend/src/services/emailProcessor.js | 89 +- backend/src/services/eventReminderService.js | 264 ++ backend/src/services/eventService.js | 93 +- backend/src/services/galleryOgService.js | 11 +- .../src/services/invoiceSchedulerService.js | 74 + backend/src/services/invoiceService.js | 3295 +++++++++++++++++ backend/src/services/pdf-i18n.js | 523 +++ backend/src/services/pdfService.js | 2189 +++++++++++ backend/src/services/pdfStampService.js | 361 ++ backend/src/services/quoteService.js | 1792 +++++++++ backend/src/services/taxReportService.js | 760 ++++ backend/src/utils/appSettings.js | 38 + backend/src/utils/clientIp.js | 35 + backend/src/utils/dateFormatter.js | 23 +- backend/src/utils/documentSequences.js | 93 + backend/src/utils/iban.js | 134 + backend/src/utils/numericHelpers.js | 34 + backend/src/utils/pdfFilename.js | 79 + backend/src/utils/publicTokenGuards.js | 157 + backend/src/utils/resolveLogoFile.js | 193 + backend/src/utils/safePath.js | 124 + backend/src/utils/schemaCache.js | 96 + .../business-docs/quote/2026/Q-2026-0001.pdf | 1 + 91 files changed, 23578 insertions(+), 123 deletions(-) create mode 100644 backend/__tests__/services/contractService.test.js create mode 100644 backend/__tests__/services/customerAccountsService.passive.test.js create mode 100644 backend/__tests__/services/customerHoursService.test.js create mode 100644 backend/__tests__/services/eventService.calendar.test.js create mode 100644 backend/__tests__/services/invoiceService.hierarchy.test.js create mode 100644 backend/__tests__/services/invoiceService.installmentPlan.test.js create mode 100644 backend/__tests__/services/invoiceService.locks.test.js create mode 100644 backend/__tests__/services/pdfService.baseDocument.test.js create mode 100644 backend/__tests__/services/pdfService.helpers.test.js create mode 100644 backend/__tests__/services/quoteService.hierarchy.test.js create mode 100644 backend/__tests__/services/quoteService.locks.test.js create mode 100644 backend/__tests__/services/taxReportPdf.test.js create mode 100644 backend/__tests__/services/taxReportService.test.js create mode 100644 backend/__tests__/utils/iban.test.js create mode 100644 backend/__tests__/utils/pdfFilename.test.js create mode 100644 backend/__tests__/utils/resolveLogoFile.test.js create mode 100644 backend/assets/fonts/Comic-Neue/400.ttf create mode 100644 backend/assets/fonts/Comic-Neue/700.ttf create mode 100644 backend/assets/fonts/IBM-Plex-Sans/400.ttf create mode 100644 backend/assets/fonts/IBM-Plex-Sans/600.ttf create mode 100644 backend/assets/fonts/IBM-Plex-Sans/700.ttf create mode 100644 backend/assets/fonts/Inter/400.ttf create mode 100644 backend/assets/fonts/Inter/600.ttf create mode 100644 backend/assets/fonts/Inter/700.ttf create mode 100644 backend/assets/fonts/Jost/400.ttf create mode 100644 backend/assets/fonts/Jost/600.ttf create mode 100644 backend/assets/fonts/Jost/700.ttf create mode 100644 backend/assets/fonts/Montserrat/400.ttf create mode 100644 backend/assets/fonts/Montserrat/600.ttf create mode 100644 backend/assets/fonts/Montserrat/700.ttf create mode 100644 backend/assets/fonts/Noto-Sans/400.ttf create mode 100644 backend/assets/fonts/Noto-Sans/600.ttf create mode 100644 backend/assets/fonts/Noto-Sans/700.ttf create mode 100644 backend/assets/fonts/Playfair-Display/400.ttf create mode 100644 backend/assets/fonts/Playfair-Display/600.ttf create mode 100644 backend/assets/fonts/Playfair-Display/700.ttf create mode 100644 backend/assets/fonts/Poppins/400.ttf create mode 100644 backend/assets/fonts/Poppins/600.ttf create mode 100644 backend/assets/fonts/Poppins/700.ttf create mode 100644 backend/src/routes/adminBusinessProfile.js create mode 100644 backend/src/routes/adminCalendar.js create mode 100644 backend/src/routes/adminContracts.js create mode 100644 backend/src/routes/adminDeals.js create mode 100644 backend/src/routes/adminDev.js create mode 100644 backend/src/routes/adminInvoices.js create mode 100644 backend/src/routes/adminQuotes.js create mode 100644 backend/src/routes/adminTaxReport.js create mode 100644 backend/src/routes/publicContracts.js create mode 100644 backend/src/routes/publicPaymentCheck.js create mode 100644 backend/src/routes/publicQuotes.js create mode 100644 backend/src/services/_renderContext.js create mode 100644 backend/src/services/businessProfileService.js create mode 100644 backend/src/services/contractBlocksService.js create mode 100644 backend/src/services/contractService.js create mode 100644 backend/src/services/customerHoursService.js create mode 100644 backend/src/services/dealsService.js create mode 100644 backend/src/services/eventReminderService.js create mode 100644 backend/src/services/invoiceSchedulerService.js create mode 100644 backend/src/services/invoiceService.js create mode 100644 backend/src/services/pdf-i18n.js create mode 100644 backend/src/services/pdfService.js create mode 100644 backend/src/services/pdfStampService.js create mode 100644 backend/src/services/quoteService.js create mode 100644 backend/src/services/taxReportService.js create mode 100644 backend/src/utils/appSettings.js create mode 100644 backend/src/utils/clientIp.js create mode 100644 backend/src/utils/documentSequences.js create mode 100644 backend/src/utils/iban.js create mode 100644 backend/src/utils/numericHelpers.js create mode 100644 backend/src/utils/pdfFilename.js create mode 100644 backend/src/utils/publicTokenGuards.js create mode 100644 backend/src/utils/resolveLogoFile.js create mode 100644 backend/src/utils/safePath.js create mode 100644 backend/src/utils/schemaCache.js create mode 100644 backend/storage/business-docs/quote/2026/Q-2026-0001.pdf 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 0000000000000000000000000000000000000000..5106d718fe2a7f04377ed6cdb133785d0942bf50 GIT binary patch literal 43500 zcmd4437A|(xi?;QPIsT~K7D#Qz0UM3J-yCM_w?-h)|oYv%w%6?NJ3_^5CQ~3*byPJ z2~ou5!WBhOK?L!`4iH69u2*rpD%bsT6$PV!*AKm3xh|m7{r%oL-IECG3LR^#N3l(;}eeeJMLhNS0VM`$#rWt9(m8YGx7Xw#@xS~+_-rp z)faO!ray(W`L!FH+s+AI-@=$Lj{Nhx4=fz~m(a8L)^|PL2Xj`HYpX5Dw*6=ya4e)UK2Y-Q}qi_hM>u%~AK*$Kuz zybkZX&PIZzM!pfxH{m&c_JNBpzoWH!3eTTpOg?hn1-lmt@tR$X-E|e}|Neo6%MVH^ zSCX-NdyqeJ{=$L1srx6)c%H@kKOMZ_qKj`0??ip~p?v9ugNODWJm>R2yq~cLDF4HZ z`o@8S{z)v%6#O`VjpLkT^*A@OHk>gLbhVt+GsE2-E!|!wGHO?;Mzrp!JaekJ+g!|{|%+un`M9R0t z`RgJ@6=$zFe?z37FWrT&4$zrfMY>I#o#Gr7=ahK+usClMXR|oR#JNG7N5y$cobRDC zyFuKC#JO6ed|BL|BhLH9c|x4~#CfYYH{mR?3W1Ak87`UxE@}c7JpvbXfr~!i;so;p zA6K$~z{sG$$O>TO7FG?coMp8FFCzjk>jYlL1YR}>ylfMA*(30BP~hdLz{_cYmm9=L zY!!GpdjfZW+X1w(3b@0O%6W%$8}2yfksbIokK+s+n{cebu@c7!jsYB9I9hNtGG2Tu zwYfMg&Nqtl=f(LdaegcHesNlyZxZJ(iSt!B6K>NiVasNT6{%KcR(>lF0P1ru>J4ZFz(AS@TiG1& zahsR{53x_NJUa?5@CZ8w%KHRxg=U-wKif(4eF~*Wm{Y4TrZf0iQ6ka$P55z?x&=72 z6+c`DAM z`v!hC@PY55MhQ@j<0qj#Yw(WRBnb#?XxV1eeF6^aUx9$?mQeFCoQ+zaKD2edW5pj7zl-bBryK$#7GE;n5Jm#)eqa1z@vEoHyF9)4-0~-U`#0c5 z@tIOx|8erS%b$urEB;UMvEoxF(g^R7`oGIk7MH&-rvuYY`d9q-;>*Qf;oIknMa-!m zm0x03{b%{!xE7Do6?wj1{Naf=#ebm^i(gxo@)`8bvEtDaDaKv7Y=Vm5EZ%OwSA6kA z&T^f_Kj1y&tm3DOpF8okRL=KG*Am?*9(;Wcd?#RiWBRF5X#Snu&zdK$Vzbf2U*;+O_C@5Jou2DNnyUg-n1eIGd1ZJ@SI?DP1w1MeG|3qKq3 zxRIOYzZ)F08rK^9Y@$R3B@%^iB!0y*_&LG7T2Nn`kPtgq2hzLolOQYhU_KM2mj$Ia z2}*B)+&GAONOH6b$yO_&%K|wTjF@CDx3<@~(^(gsf$d68TtB@aW1Njfl26SqeV|e6Su+ zHY{;NNwii*e>I_BviO<69h>nbwcjGRq$GMl5xmlhp6Eb1#4$ahM{J@;bkQSr(Icwp z5r^mzja`SIOSID|IIK&wRtBHF1?9X8KS{J)LA!553bkApEq90(y3iZMNg1m!Pn*7I zderog>3-9DaX#Dhko-ltUi!3D1%7%jPjWX>m;Klwd|QEb=@{h{+T4tmv;+QbK--5> zM?m{GVx%cW58=DJaNLdO4}%YX1lNz^`5t_CFOHAl{yrQZ$GiLS z?o%i`kLyu9KaArMygP<`PvZCn;4uTTXIM9$dXZ}Y#|LoK0_Ho#8xO8J-i{T24Oou@ z*5iQn*MRjnU^xz0j+bCLUV`N~U^xz0jsuqCfaN$~IbMS0cnOx{faN$~ISyEk1D50L zE2!t|IKF|S3bp4^`{U@bJZgU$H9sS2K2hV#X#F2h7ZalSYJP0qSXJ{-=)iZ<_%X^0Kg2uw=8R`-03+jJk^y9@ZqA#8X7JPMS zEU4zUqL!<1T!Z5hwXqa59po&bdLjqX8^%7 zfZ!QG@C+b$1`yDwy;sb@JaQe!EW8ghFpn8X80jf~Pn>@Q-rz1ii#Jb+xfw;Nk0XB` z`Cmqx??jvLM4RtJo9{%M??i2nqqfIU+vBM1an$xWYI_{D5&g)cz4xKLdCZ`vFe;B@ zRPw-+JJH@d(cV1Tdmq}H#|*j;?aiaTkbaTxVdQ@V*JH@{Bo33H8-0L$08+~b&@;qU zg{7dRMVUc0H$e&=V^O4=!G(0Z@k0}`qD^@g#8FXvm4%CcW>t`WzKG{9;rKod32*<* zDvJet8N^uuRe*+udOf)6pj}_V_3JpkfrGRE5c*2l<}q+Oo5$|m2M#jdS%(&O3!8_~ z@lY38I82c8F9rv){)1&&$FKieqyvbgOUQ` zdlP#L#!Y8Iq&UI*t|&cSDXxB$LS+Z=hU_g>_(nz=S(Dx^>V{a2A3Ov zguaylOCS1`-cha%OJ7TXD-OQNgd|f5Ui3k5V3K5dL~g2!nWP(Ap^ zUxN0|SVTNg5tQl^P}0@JDXjzVO?cAKBOhj;fF4rht$d^O73oLPvoe=WvNht1R7Pqd z(MU4V7a5CuvMw6cqkh=C7`}?&tBk%1>NN5!0>;C z%=-D_hl}SfzPkA0;tPwpFa81r$k?( zGW^RJm%r)!19l$ds{`zOb^$xcF2tO<2o|!7*(K~!b{V^zEz=QBQC^PaNM4feD*jn| zfd3jA{Froa@ilS(T5$}zt@z39{8Km{WI*yvB+oX_j?YCRs~=;|^{exijc3f}JHq+; zx%smqM>fyqrNqK#t(c^{cgJ>x>+14sF3(0|V-KT+qw^z8d7jBf=Fe)%OPN?*tgb09 zXCiwZk$rwPGMe{}M&{>7j!M4Kk)sKDG%t;Ax;&D1#BejZuqSU?clpDTB%zpm-QKD? zN`2Vr=Oa}S+{Q*8_Hr-Y#qw<3?B2PD1DtX;|zL^v02eNZ!6~b{;7adS|D*F1qWQ56{od&4tlhdB^B(Fq_#tTTPkk z@El&9uc5n|)eDcg*>1}5n3?UGo7=N6m*?rZxl#+}B74xD*vMQ{-kgbyNAjk`0@|dE zuA9v(v5~wrHi8krC-Y5ti|B6jL}brVW!FfAUQ%*Y?IPZ_0Z!tJlx2-e@F->yYjh z>AuWSrjKr(J*w-YdA=}`_oQjc0kuYsy6EJ_DbE8K3pufF_9)Fmbl1ocj4-NoH`c}Q zS@|CLK=0`j%w&2yhn7qN{z;^sJXoh0LKq#qF?8~1o((#>o@ zc-{&0(=f5;fr>t)%maqcghCKrU=SJ^8KIu=0T8@9>a(Wv??{KE!1Dka>rXf3gPEh8 zt|4?hT`Mw2Wx7^oj+*Ei&Kxz}7K4$;_R%$)i42Gt)to^Whvp-oete#g zV*%HEmXNn4lW$Jvn=vt4F()R0x~H6(v4x%(QTR8_0laL=x0T2GsM9h|sF!bTJZk3t z@mWwu8pZZy-E+D;9hpd{fV>l>8Rd+>kzRldof29~XMslr(;FL#^&IWserjtM`U4F( z=rd5ZR78*hpR-P47LDjYT2@M^I+}$=M=VqrAKYv!RqblAkBy zl3PFfh$&)@gdZ^_&6RT_L~`w*-l!iC#3ttR7L4}E^A=N!rw#UG8lB%0%bUSnLG?_d z3t`;P&w)_9{+k7W21XE@Sm+7IP{Rb;iHoQO<(;Au07A6cf=P_QFazPhN?r#EN?ahe zML@_nmq_Y~x_~SL<-Ujj0&}v|7qJ0!)8Nvpyd5+o5}AliQl&JCL*;j(F-A}4*{0cS zWB{CV4eTP%(N_Kp3$I*%uzd- z4q?B*{J#&SCr^c7{|FXSe-j-Wi1mc)mf=g?TnWxe5a7OaxmQ-=sXtvuV@CZ_YRys? zOrZ<>#`A?Lg#1D z&1*9cGd6*nb-3Ylvp(}Mq5^QU0XHILBjuSy$|lM~H=8LB-E5&ebh9<{2&myG?q+cZ zCXc(h%p=@LI|Fw{+BV9^>FsvPC+>DoK5=&@4 zH*X0)Y?9=0Xiwl}b0g`zb#Gpdue-d=(3^G0q5{_Sa7EbT#^+k6#S%=nB|AMGUF~&k z0l&v*iPiDCppx=%#jB*0bVY^Euhc6lJ%tNFjNR(~0n+ z3TMctBcmf>QG)3%1DX;rmSiVD!vg?21f%^pA#~iRbFfFg{T266T?o8+C@Am45G1oqBSRi)yMl>S;UaY1r%4 z9A2;Ed|gNFsPaXxcD;s&>*=X{M?KE^q1WFp-H#}uwd|IhT3;>6CW)&|nqEyBPp-k^ zvdP?Gmzc`gJ_|RQyfaQKv~78(VwPmtH{;eMRSl_Y!nqWs&`T~$XP@G`a?ZJ2bQ@!-nioRQ((dr$^jW(@ z*X*_reb%ON=N)&z=G0cqON#U&qD}^3w_KN-so@q=A2(Y@d)q5al6fp7aY@EpF;6p$ z`wh%&kt}BEFv2k`62F?J(T&VvF)c8Y$uk4mtMfI!xVI_kO)5%tI_~5O;aU_pkmV@@ z`+_w*=%w3k0~xD%4fqgcN+FMe2NnpXoZPQ>b#^3Eeo5)v*5z?H+%xBe%wA{E?%dGH zAKAIl?QrPZ&+=%hhnuJR_?}&bTYCn0_rOc5ho;`KzTMJF7}a8)>Ty{#Z$(3|g=;;R zzl%kk)`cHNiGK!6tKKLw!EQ#7%>Z5x>dIrD?`Z zW)n9b7ULv{rh);I7A!J|MMzrH-_Kb8ME`haJIcsZrZZNhA{{Uq+l~GxcMs@kS9xON zUQp3!vYDgX^ksb!)VsPwRPO?RS#<4{O`9szh3idPJ8si_iwrQJ8SHkgV-=jqn?`6 z)sq$V5Q2){K0!`EJrql9+H244*fr_!YBu+lJv*mu`a#{{w2l4fl1u(=#Nj}7>z{wi zwa?FKsArfT>c&h9*Y@H1x)NlOl&_`8<;zj51S!J$k&6cfzgF?9(>5r zaNyhx;?1RVIRv7iknW~bea50xqCWv$S9h`uTOdeiRARYGi79~WE2Yi>%}b^T_zU!a zzq+2Z34O3Y&OI$gTe6v#R?J>iMly}__s)4;Hus=La|kGUP_UXRJ9R)UC`D%7OU z>-X#4deu|mafDTiNm2u$fTp{3U$izD75LD}*6_FU_kx;1f&c+GOLJi4Cb(I^J|RY! z7c59KuSe!uRuhlK;xR=DLxLDwVs&MLUFKy(w!fpFOE_~bP{(t?q3ezV9&L?BJJ*Bf z111<#6))#Sovuw!)kN$BKb-iGop4|DWpsqb7g%bML%P_6X1 z{5H*|Ivl%Ps>w3wa(Sx*npPPJR0D-jM=SdwU*IGHp$;T)#Gv7_6Bx_*THI<(?Vvg! z8xj95G3YUHg;xmPSAM92vv>qXQh8CxUKb*+Ol6oyR1!oV&O>4zsmvZPgIj_$*5xA* zD?OHw$6GCAC~EhO{5I(!mH0nd#n&$@I=)_l+aS@U-OT==CiwsGf9a&%vm^e|v7tZbgZ_nKATX%kJ_t3hV)=pfxYTeCi$FE#P{XUG-R_S9v#+F=@!Z}Ie zaE<|IAbv=}8O%^-+G&Q|r<&3((A#Io&Gfyt#?Is}XChJNY zW|!@1lV*16))ANPb^HgA*vo%EV~3DxyV+#%YKxDP;6$U2m8E-IS?hHxY)D^@e%VJ*_kw}BtDo#luH35^Tu5(4}n*Pqt z#8hYdv~E}JPTk>IzpHCYYg4YbHJ6pSkY) zX-`DD*LMSMy#}zwi?8t~AUh8cKkOzEZkm}*vdK&kL!@K;FyNP%EbRx#mIcUgP$f85 z7OaLCfp!hC!DK8MPsUOGEu{bwh;xU5jJLM`H3hQB?&Gmq&gDkNUk`#Iir|8 zn$PMU?~ae9`=)C&kFG_>GqwHonGU~ZbGf6H6ZM^Q?H%dP-jVqxOH-T6>d@Sts`ki= zZ1+f|t~ot*UA2M6%7EMM1^sge8YUW>W-3DS0~0&a02uATT;+M`Uig@6SvHrh2Ga0p z$N<0nRqbCqcm=v+zwZ-_shpfNNl8XuJh`K0$1O*eCo zU3YqFiO|)09g!!Vuy`Fen7j^~m4DIgaTd06fkW3jyi&OE4f0gC1E%Mt55lMWwquQ4 zvJjg|V)iwo_ertDh|qvs&CF(jK#-#)Neqeaq#+6CnqL2f1>ajRgeSgSHiUCIOjOj` z9*uX#67koKVgO>eF^mE`Y23QKm?mX}RF=>*_O(qNHj`JgtCpIPYZ#@C|(&Q8J>oW@jcb)67YHpi#<@uE(tGc$dcc0mt9!;+5@7owMZ(3<-J2-pq zcRI~(&F+*V7fvLbx2>4EU~nM2se5u|CRE+9Kr2_0fcdM4=MS)kT+$7mLh}hyCWdh* z5X=L@B!ERifcZNTCIXTs1+O!vi|ArO+0W5UyrS@JA#?54bW`Emvd0RlV^O3Q>MY%9 zu`k|dwV^0k;L*21-@}Nq>tt8tY}u60Vj=^@|A)lBzMirA&iamMgnAq@jzR2)c=vK; zz*3&(%hZLSArlKpIMDu;ogI9tFWc3&xvgV;wzs)&xa-XR>5kF1j+NcHn$h%{zRupJ zO@o$}jUD4#Y67F}UF*^fQ_Y=|6*W71yXM+>ejwS{*4L40(+dw&^)>da_J-;PvTZBS zHJJA;7?bYoI^LNu?P0K45OcqTN0pU~Rn}Lg&??BU$$EiD2Hon$ zOei-^AQb69-K2>c>@3j!pvSZF0z#jyK%c((OYS&mw;kFTGR=)PjwV+1TRaY*HFm*R zvUz)M>fpfDSKam9J~K~bLba*=n}JE=s|-5DZsCtYrUk+{Aj5D87KEOKn{Lgawh(L`5&rOvknb-gCw@mCk_4(cT-g!+l(ue(LP z5!RRM_BbVB;DT8MH1aSB0oMbKIU?n8A!&i}^#YEFqc-S2EgWx<)b^AB8b4WLTL#zY zDTFuT`4ayI^p*-%nG5R9l6=SnA%tn6s!_sZil-7ns_ssd+OKFO69Rvaz3>%9u_}DV zYIAt4O5s_qD~hg3=5L!^R>fudf!V9!^29MbI=ljR`q_>AeS8UNT%kw3pdC_ zqA}D4_|&VV7l_%lT43?NnQ;wJD8i63LiQ ztx4{Nl5~PNpDZ&txl6LdmYXW?@Tg-Q%8JC=jp^ZP3py`QwW(udZ+AmBy{^uwwUz|# z`ye7%`&uo7qp^W4^>tQVbA)O$9cK=^@faK`zZ=gwPNbp9S-{( zv)diJG*~4a8}Pvvwt3C;s+A+TR4frqBw~rA6XruxnZ5`K35`z?_ciPcC96VeiQ2R+ zu{JWnw1E&U6Rg`x`nHfR`Q%6E&%AHn_B&>0S0&eaI)}P)O>3K*vq4?6X|Zr^dv#4^ zEa!yKs9QVYneO4MHm^CdW$xyQZ5y06&0|xe=Z@~#a@W3nAK$t4{oBvjSKlx;InXxS zRpoNytuHYcjV3lHd>&1yaz`6RN5`+4nY?*^$8{?@?YhJ2v{uB?V&GM{_^MPdJ;*3> z9HMI~=>#{IV51eLm?Z(;I|JUuU~ebPjq1++kBy`{D<5i^I!vuNIy~?K14^f0Blz55ogc1z6sHOye+em-uQ#;WBVSHj$Fd zb|SxL#)^r+p)W7jrrtasW^g$7=2@VaLBFc3uJ(d3RmZF2u{v*+H(VJ4Se_0Wv0id5 zKw~vTYta=3mrZkjyF^SX%{?2v+M_FXw z1e>r4s)H=F${vVdkN(LIkhz|15m+e0|IdoS4MJp{T zsGgiZ=c=u$a#L<0=kfc<3qFhtIxP?|uakeAyb^`W z`K%M#TH$_vqj=ut)1)2bJJP&A^EjQ}MG#)%rGN*0NAQH%j3Ja(phI5=R7qnG=T7qu zx|v~DgTP79l0pypx$IOs30m^7Ed%rl-Yod-cfbGv{w8kWOLnz9Ac-eMx5tc<&|Y-q^30wn)?J|N4y(8qdNPbwNs#uG`> zs^7>8mQ(jGxRy#9wPAP`+(C(^2<;v3SikN)JGNXl;BneLCjah@TMl)uI5527GJj=z zhfmgYRSm33j%0iuhhh!geu1*_pLcD(=j_StVa=u5duH~$y>r7AD|W2YOga@{wZmYg zK>^!+jTpz*q{EP>YgsZE3sgjPogBF(%68A zd(5Ojp9O4jdif^_XK0ERHu*FtI?_2L02RJJ@LRer-cB!hZ+}4}bOIa+oOS`9YDw3E zZpfvb7!A)1td?v!X2bSV7w@QxmoS@%q(|`76ZSn%+4$4r^*VR%bmF{7m7aMf;WkiM z^GIbuP@p#LB@q|1zW*kH?|u zh5m+B+45Yg0m#6)9AO5WMBpU1`pE8)Mh z7Val=7ka_VN9}Mk*b5KxvsGPF6t2BOgJTTXBKKQ;u&o!KE&S33*u_|3?(<&)XLQ!T zI-glL8-7&xNPKEeJP6zezbK8ME@A$t7+_8Y&>&kgcP!wAN8)5e>Jkc=`7D7kyweGr zOW}US?~u+R7vAD${9JcBbRIIssiF9qT!s1@SSlA!R68JDlu;G}8;};R9^`Cb^@h=1 zP?Zj0^nhRyHf)3?0Ru9bN~YKHwbyOBUh+6pxBT%hm^`Z8E!}9Q0g(O zptxd!SuIjv@h@V`gx)M|1PtWk6kdTcr8~(U{P$aYe|}L8=G;|>=$zX()V8&&|Lno` zEuEJ2x6bYP#P0R)p4;=l?x79uT)+C_-1>KJn!ad=#!$pLNR@zxIYqNK-@pac=A$`FzK@Hq=pi$}QZfIKDlbJ;tmc+T?LqNyhyKm6!M-MRhtOW%J> zn9MEGnO=|eoIP94&GK&o^D{MTN5@z3!oM0YwG@9Y-3ED}l67#Fb0n&KvKcY~Bpfgf z8|0!mI2@@2; zO_9!1E=bNct$W+5ZSUW@?rl@O?emCC_;tq=tlSP|Te`jPtf7%hCcAeJR-0CZYqq?5 z)2>hK+5DbOGwHDA~W_UTdI`L?=#T|02cS?_;Gm6I@PhgY|pxBL2B%_W=62*c1{ zvV9wv)smR*nFMBindU#~->(7J+X&NvLd;~?f`RN;17e|f3yQg%yR@;^csv~grh~gI zxfz#CxikVoY&Ix8Z~IR7{)ye^6D8y3^()qF zUc2GEb93hnT4sjqcBi+pxj)($&yH2}zO8jSvwB$by47S$Uwe8WShFwzs~q~2GaKj_ z2agW0x?BzBMcE7r6E4L)AP>x-V2Nch!Qjg!Lo@Uhkgq@KpBJ3}v&4HoZ_urA;zAG? z^u;HLSaCLrdJclV*O%)Nog>3aRj#L=rBV%Wybu>bPmtBlV7@Ny6XtF*!BUzIBeYQa_S~W?0+mzJlZxg7w&+uUuz9bb&tK{@r%yga>>Uv zAL42*>^-=n@H@bz0WLRiFvz00THUdXj-N0D!*Y{I8HV7Lj4>jxz;X=e6xkAc#UuZ_ zjKCz1>Dc#dey2xz#Tu|UY|j7)f?vdX8 zqzO82i!&j6sf!1f2lwR9r5`qK)y!5`uJedrMuktcMJ}-vDt;SN`;Kt50*;|MMlQ8yxL7 zzp{Y)S%3Z;n+tPKwP8Vf73}=FTKEmuRj)&}7k*Lrk%ABal~)yhp#mi_ZVte<3b2J~ z++1)*FF|%<+@OLaE6WD_VgAOUBO&1--LmU{pl`sgc>G11?(o?Px52z<@j2|akMX-; z5%SuU!dv-uaDdpHiyuJZD1IV{Bq6=kN7jq}fDiR%K5&I23 zgtFGw7_jkUg+pQzeaRKD6;|{2IuYPk=;dCQQ>gp{XW`(7;pfHbA~ zYBUJVg7kD5DGF~#+cd9SgR|~&{^cH{ZM*q3{5{gGpmeFDmOW!R#Aq1yTSUr0@-#wa zKn4RzLZ+h@Hb$oWVIlyvMw2Lz2)f?I9@CXBUqIUn@zUy&H^VE$uhAVg<(q-f*I?z+ zZOSJ;=BLprzEJ#<1REr5(MeI~o5ng&l_#60?Pv`DISpE&deCF2Wn!F|)WD8O|CeM! zQSQm{KII`1ZD=kHOQ{V<>=!#iPPcoX)$5QN?3ckN7jz$10X4~X+2Zf`r}?iDV;f^< zaCI02C(> z3q-5KsKJ+n{sn0TK3$@0WrHhXX25NdUEN+{j1-*^Xy-S%!_!H4+ItVI%1%H7ykU*s zt2*^JYcs+dDk91rVTJYrx0<3vpJJuy-EF zsc}0@WLU{5#KS1sRE=E1eqL(WvfwkJ2GyPNJ;(sbc!H3_kS?ULR5%%_Az3V*NGu5~ z#6zk}0S;&!Gy>xN@sh)ef-Bn>fV>Vpvb}BX{DA|}m6f`K=Y~65)@HrqF}SOwOC4^v z6YfH#FiG# zrRny{UHz8cJp*IcZH&gPn0;zxIJd96Z7Mkqz0j^Dy9AEC#-En3&K_ein#)w++$bG~M4^K_b<1C#NYHG>_Nu6^T&-yQp^2AaHhv*z(T8>ah{oxYc} z(D}R9(Oz&gKj6beKi3#NUGJ2j^Gw5m$}-zZ^i&_4FX^4lNJ(t$~{fu zo&vuR90p4vd5BU>64EQaVB)8ThI-E$8Q7c-75=SaOJ8KtufXzQ4ew~09cXU>;1Z8n zR-C_L?6Q^FT!rpD+R_>c*gcxvUfaNLk9MBX<#T)lH0Tw;yh{26iwpZBlx&KH@nTjI zJELGDA56Gju`u#Chgtc>iuWH2%$!G118Rw1zhIwS1DkPG*FCsf^IpK9E?veE4v z8VvL6?Ou=LdM{LMm48TgsQSc)ST#6JXAk$lUQyVsmT1iw|1y6S_M;Bom{Y5`MQ(6O zilwZwHKN0b-yKHaiy=Y!W^9Oj5%B9|rZ6KC#%vD2*<~;*m|RL^)B%$1)i{jB_A{MZdMnQ&Y!t`JOm@^F8 zsVvdXUeorUhR&U$6ndADeu&h0(*8@fndSOa2S8Y+9 zK`m`}E?T=A9g+i_AW}2f^!5QG6JV9xdKv}9wQFt$rwq0IHm{pr@ z@36TM996Rr3+-5K^ElnMRhKqqNauA(hcpim?y}lo^>t1G#r@DRAVIZ0wRl0=mc<~*o5p#gDq(6e^ z^pS1bZN~~55jG>%GeGe2%~%z)v3voA_p#mt`RNh~5fEs&WR`>v!(AuVJdxGkm}n*M zo$*Ls>ZN(7>2>?t)8ktFjLhH^sR;F{nyC)zqIg4eWlb1Tw*_*_rwQ(hR;+Iy>gzsZ zXW1{}k^xp;{Q}hjg0sGyi$zNcH5eOuMjE0y2Xv{Klk!Fqc z^{*orh7=`Or(r%BB=J@-c|Z;uf+a>2L({AGt?15G!J$l(G!>7IRMb@?@CyK|{Ildh zxa*9Co-5AUQ`-vy;^?W}wPQZkmK`{!XU_0J8NB{D`u%lwSA$}Rz#Fns{rz^=FYM?X zJa0wMj?UbZa64!ZNr&H+I#`;Qry2SFP&po9}8~-`c;>)v-2%aTPLb zpXj#=SQO65IpQ(9Y&Q8zLTno-M75AVjBYgKCNO$KKn-ARORgHCsw9&^vE11wa!GJ~ z`Gk0ttW4C^LO2VR#j6uI1g0a5i7-P@=Tjh=(ev<%8ofSvMcb<6p38ej5vRR6&|W_> z>znNV3TDkEa0Z;55V@Nxgue zg^)wYU;HX8{d06T56yRuUAaDzD{=;rUBbqp8RJ+S9>A@9x1(SC3h& zDy#&H*SOuk9_YdS2oZX8Mu8D|1Gqq&BqMXUY&PgXy8W0(k$uFazr;mT*J9a z>c{b+V2W45}x&>kL~VAz)b^GV~U*K^W28w7R7RYg{ng zw{1*&w1CAq($X^IZEaq^a&F`5^=>FEa!YGhfBW#jit6;5c5Cx+0AfF4o#X!MN@vAL zSJ&uZXQ;xXnjPkFZ7fn7_xs|l$;22UnR)R)A^UVm{|w`F$vGXubTizZd?)!qNk0~D z{V;UqQ@w&S(r6&;+e>RXmJ8j65we=1YB1ppdHYK51)A>D4v<)MkY?3EuR{UBeLp4K zOw!QJM4=03lSWVDI$ZoCe-iW-`(oyjN#GY5A&Jz2n?gxBVTc6FOr+`!LnKX_r3elY znM+!V;oT~`wS?Y&*NPg4>~dlunXR$4X&bDZv@*k|xxImH?Y6ed&%J1s&*OHwLMspK z-`*T>*78=;WD8gWPOtra|73F6HLode^CsTJl&i?uKf zBMS8w?6%T0V8O21xn#W3t0JyQ^qF9`%lb=*vq>m*hT+yQT9>OXUomce*p8v{3uonF z9X4SwC66@m76gr`UTMp@@J_;7b;JhcO`bb!a@#B}2Y=A*&|Qwghxs0x)2$Xh$oFBr zfhJpcVNFPLU}5szc5+dc^t&GeUSYqj_Nj=bEjgKmtc1UaAi}qZiZlu<8vH}0h=t+u zdV$)qVB{)!RQLz1AODgKo?F{j?t&4|X}#++c=Fnwco1e>i|y%e*&z7YzVUfgMdH8y z3!XIFli##KaS$*q7JK-=@c)J#WZ*L}+X^?Yv2bhI00zk0rD15{VrMkym+KEwrcUu z{A1{=lkB;e3IB*ax5lgRw!;6A36-zbyh`C|UP}*!kD0x2nel54fzF{6t|rT_z^wt; zbI0XsmSI@;R4e8pp&S;h8Ro|fOZ&aD5}=?HEYLQjZ_Gp7pHP!P^x^r!nS^p;#N0K} z8?W-&?1`$r$$&$zj`j9b)b_?O37vcIbbZ$m5+ORl1|nWNE1X>`c}- zocbEPF|ylXto<4(Zenkf2O*YQ0@Qv`L83osH-gwjPn5LYQp1TJ8lo!5`-o_9 zWlc6qE%r|Iqcf~(qN;zT@38Kuu$UVfK0BTD4b^`=(qxyYtyLqP{}ydUr#S3pZ>IL~ zt`S<0$+5=zT~aIG4l~HKu&b`9h2c|!5JDlRz*7@Dj1@&@nVSzoYN4ebaCmdGJXbcY z$zp9DtSE>siHT4bGQ!4b*)+tB(zFvwNZHvXXgit`dRX~$ZVkzi%Uw|E|6;WLyx#8X#I~=_|wOEpc=tYQlAOtdG zAh zFZPQ$bre2WKE09KIEIG8xVy1TU&e~o!%UTIl4^tTkXg(o%YH<%S`msXOZ%{YH?!M- zVTkfvKzK?ykd*LD#py%#)%Tt0IUWf+>Yb^Y4u zRTE?JIM$xU8)I=1GyOU&rwD^)pgoCX{iLP=)l2^RQck}wSQc#zT1M-FN~VSrntF_X zawJ|+?TK_og5+t9H@KAr2cknaTNh_{dL%cb56Lmzz2^+u3#2*!2<*+KSNp0O5m22- zwArjdr<=RCwYV!oZuknIp1N(-TN|po!d@_}ruP3mXQf@%Af*hBZ=G*%f~#GZ!``&s z6}NeHf1shE8a&TDfyIS{#~GI3PxG%~ukB&BKDQ>IW4TzY)`oC&OG`#FDKd#yAQ^MO z0W1!+?1#}3_CLk6pTsVC!3uuJMtVuNsJJzGfGx!?kE7N(oMIzscdA!yLUGggF_dkR0qjm--RTKEAaZ=;dlD_Awgd0p`i zcB}M9u(*@foFLQ`HuO0X8Hk)p^7_)6lases5wWmj-UOI19WILp+g)Og#21C|a197& z4?PvW2Du%JC^^|Aw8B?On8L2S=%x!@W%dBlSLidTslmn#Ru7ptCMfHxjk9 z41=NhlbPxUUvyJe>1nQu)MjEMjoj_4i0Hb< z;qq71xm3iIf%{4e+$Mbid@9J=1h-Dn#KXcUnrD1LY=To>8+Gy;t+I2s++wj7xzzgV z>bj7xwk~v2bu>U%X`#aBtEu$*tIJocMaZyc`5&dD!ZtL<9;Mok4RggxY72#%^?|@z zCG#O>RV-FTEYZQA$U%^kLF1X?;5J#Y?Z@UIP{QVDj}X29c}V8#!-BS3EoSTCf8c9u zj$`a95*dq(4q~#kwPZ7?#Im*E8jQTu{;slJgk(sJEiLsSA*JN=%i@s;4+&bJGRYEo zImuV`z5PQ&=L`;=gY!`9hF1J<+1S#$p(WW9ne1zfA%@vz_f=#f4V8g}&E`_wio2)& z+2QkshtJF9=ses8p97W~(*I3uqmiyi|3uiXsV=L#rK&3BcDY@uRSne$y6fbtxkGv% zmg3j4@!SYjtAZH9CXUgDZyHW!s1YDdBtYyJ%L+|%w2YW6MJBf12O-2)i-8Q$pN2`q z5pNA!cM1(B{v^mukc`?e8c{C@mG?!R>(R>gvG{1Vc0l89bvPnhs@3M!Xe~kx+?sn$ zf76z3uUhz>>KUwf7_wh;T^nG<`crlZ{$|4jRIdpGA%djlFzG_P!-{#rQdqt5m)F9% zfS~}r{)M3l(V~(VX+m*?N-bNON_HOp7M&OVlLC(Un5x6Ic8SMvC2hFl(QbE;=@`v! z0iOQ3gs1&%oSiG?$$WQ{WCpG}I84Wi)qGgBNM;#Uy!Tgf^j$+BxBMkB}FC*&9sDyKsXnFMxfRZ&}{!uO9{IO z&CYb=uVUD0lRbl7_1$I#cxKySm0b3bmfD6e7B!z9$2RENLk^oc6|Iih5;8Xaaal%M z?Aqn34mQcb;W0ka7OqI|2HpP~V>ZN!Pa`6cY&0H^`ZSHo)6y0w`ml8fLyS}`@-MMJ zNKc^L3f3dW0g>upCZG%8Bh<~ffDAw)2N^hdpD-91j9I(17m%2j%NhR}z0qiIUHi(F z?KKSzHPvY>tp5@c>UyJ7?d?;Yjn&nSbP!%hh`o80ALfr^CN;64TtC+0!ejs#mUbF} zV-+}o1#}i=CplfQm?9EkEYcK7r{WZkDeP5-z#7B)`6Y;fh08d;K!jD_8n*qR=<30u$DLYYt|*`PWr!kJ_+!c6U{CWz%@b zUs<)XjX#*Iu)>1vN=BpA2@`j!6_QjH^7-sGi__t#aJMyvGMckJ(m8Il>F_I5ezcwVK+o^$7Qe_NNlM1ApeDIh25%`b>}+Tu&zh;01+jz z83YXe*hBvY^eMjqTjelzX*`bbgx+{hOBSC*8=(Y-(u}5o5Q?Q`Ba4pejtP4K4Kt)K zEM?V8L8V}5Zt?)|>s*@6!xOb+56~6KBHJru@2-QhLyhOSWse@JfU^jaA|FQv*$Sg3 zR&UfyK3MqJ9V(h&iKtbczDII==Z49%BmT%=?sYrd`)}i>e_Bff*w~*8l3j@_N({nA6FxA* zx$?K=Hx>iJ@L3P>J&tJeDj`dhyg~;2Wy?H?42CRec$8g;q`R`MtG>DwI|Hd!m(v@r z@;WOmit3cCCKx@r$)?q`M^l|O0muM$waV|U0{>UimXK=mwqc$FB|gO-lG_kYbE^Gj z>Xz*{gXxd1#qJ^4IwWqf8rwdBM0GEjK1EkA=@kf0wZ}~rR=drujM?m}$6Tel-1bZO zr`)Q;`I}0MOLZu#Y%s-s3wJL3t;8Z<$3uuGbg^oJ2DP1zmJ1(YwYv(d^C>jXVtwPD zZl#6R!bH)&b0D4=&%_4ev9ac0W4H+$s#G-A@^#74`o{I`$+1j)ZC6KaXI*ljt}9aC zgFQ0)i;qbcNsodiUe=ZQ_$!ej*o8=ALHgV+@PuWz7KWYD}5H<9qPGJ)Jbj; zs`G1jGE7b{vTTvY0--2COWo@K6f?i^gmI<~G8LLRHUW$T%wF*HEyGlU~!Y0+` zXiYaH>fZ&nn&&E z^|Z9Cp2_6GHW=aqRhv8b`vY!&73^45i=SxQHke)AUe|&Z)S6~(>Tso3H1%yt)!M+^ zLp8)t(HCMpwR{bWVs1jmXFul$PnrG;{_#_$zreq;Ed9Kta$e#;Tb4evlzyD&m!*Gr zDg8zMG1M<%ACf=H-$mSX8+>Qb89ShtcecX}pT+86S%$R@{3 znA^Jea48N9HVH*=a;)+;Hco{Ey0juSv9ux;ywyXbpLZ(by7?U*)#X&j7O|J*gJN~+ zWqg*%BUY*2ES_gI&kr85x%r(Rws}>%^(h&SO4SWZ-Chj>g8mn{6?FawTIUC?Sj+rD z;|IR*Goq=*2CVXUYNy{1*RwnrLozp3(RW8T0vDM>PT)AI5lZe^~6bq!EQ%p%737=+}LRg zvND^)ij50S3*HH!N;bHSY{S}_$?@D!JR@jvG(a}EI2a;0A<JVWmh=OtG%sXrwpP%Pq>+Ps$fi^gP|R$HRp4!hz$LA`Zp)mtXtbanSVv~;^` zP>#v%v815x7#mRcu+wg}b(zi6*KIoevTuRs8-djQVt!$-8!^9bK4tnZ_`jYq{RRH~ zvUHk9Mmfj%zlrn&DAz7gJ_TFQ{I#Ak{TDoU%JdhoFo5WQQ4W=FlyjUt^l;XesN=B?iC}Hbg$w(#U zYEIOOx=@iK?_H`MYr6@@L^MdcvaufFw%CRX5h1a7(rzr=A)kzaMu^5Ip~a8@413bj zk^_IB{Xg1cs@6{dLjAh)tZ%&cGk08n%TxCtVqVvFw)?ms+6I;dDyu=qpU(-Ga8^xHB&P`)><0;dB!Jj;3`V0Ig z%hIWQqnwxcpO&T5SQ_ca`Bza7XE}C|_wcj9@z&>TOY3D;=P8cd2bm7S)f_I+M_`+P z><=L;gwG@nv5(#nLX=+_v1%mF8cS;o>+jMUy(Oz}$qXYLx9dH&RU@$PI&33TR>&yU zsg*V?dsSDivN;ZVRflcW3Ka`2Y%8X$9;a%ZnnKvHZd*BJLmxF4e*`>y9$cykf1&P^ zxhg+oP$z7MogGaXGqxzJL$IdVG#vt8hNFV^C#(bP5W!D`4gr2+@uWlwm$AH2An2QC zEXC3PwZedTzHFAUYTOrcw$P&&B(iQYd1}gGE z`*x41A(mWs<4i|$3j6D*UOC;;g~!#sQAOu!WBl1q`CKN1PQmm_+ZdilI?4V1!b>pp@7AE=E7oXd7%lh8)5`}b1;EGuHCUCK*go}5b zGW{3)2d7Mbf&cHabiz5Koa6jCkuKIjiSo$_A+ulbdkBqay&d*u`zTgZ)Y4v2HDap- zjDv8VL41NpN^2{{vPy%Rh$R@cfm&k;hTmIWq#)KULv<&vQCgB)l1UYLqZ{rphkn~P zF8Ig>kGB1$T}Q60uyO2;D;>b*f7;BtOE;X|Bwgt6e5-DHaK#iae3RzMRPks01?e)( zmjlOgaGI7W9yTz7)tn=0(+b{fwP0&JnC#$0g=L-Ev={tX?A`FUWE6WgIFgAt0;GL$ z?AK76W+j6rsrjHx;Cx~mBVubM*}tp3tVy2;g(My*h?v5!8cB8S=xpeQ!m3Nv!DwGJ zI#jRG4mG;lmF*tLq=zX&EHmAdS)GYi^0|&R+0O0VVFbNcV3Fp@p?Ex(Lcos$Zl~dF zx_2mD7*%28;GfS-XBsEdsc|RhnpkUtH9S%5>P=(%Y>9`wKK&>B0|Eyq{W0merTjnT zk1eHtPx|^&`t$sUOX;7+e)Q$?f6VV&mi~vO^yjF2)DL}NVK(MN;Vr9H@W6SWq{`7WtWVWeiU1O;5z2+-xhke+hz^zoB(bT=8 z#;e{q9Wr}b{e3fu72W(h15MfKCW!o%h|UfBtJ=;SUVrV%Do1y!Av06~1L$mR5Za4s zt8GXnn^o^v#K+T$V{~svZVMl+Yu(!AvwsNf#vINTTgAGfRb0v08{pF;8JlbkLdM3{ z>Z;AE9(G{wEt_h;`ZS`p%_d}QAc5U3Bx|^%OQH^i0F`9yQx$e9QTwlymK*(B3o2;< zZ!C{i*Ndp_RcvZizldpxX_6;kl_-Ax`#m-*ogj{{HRYdRD+;+O9 zR_~zIREaJgmfvVEv9~D$JGxJ`1Pend=nn0}NOWf<5{T}MpECU?{B6t9iS8Krf6DJ( zmQM7=NdGavh0=?!!g}zK^gE<~2PmZAuaZ85zffw086mF-;{jmkM4n$Q%kvZVtJmi# z{s_FyD!sT=&QBqezpfnoef!tg@1;kU^8A?n-pC`B>H!D0oQz3Q=#+G381q_lTyOcAYlm%Nc5J6}k zY9H(s-(*9XOWh-ybNnAH&_@6#Joq5j{b)6Ms z9t(m1T`6;oAFHXLeYv8MX}e8RLu01VEuDK-w609m?0frQv$<(~$Dx}oUH#59_Azm2 zYS5&6?M|!1W3fl|IB>?I#-)|_ZCY{3RA0~b|JT~JKu1-i>A$Kw-M62&(|Nx-Nq1h| zNjjbGJUb0^LU@D#qJo6Lsv$%ki@*p$R%VbvRMu5s-5C!vI&Q}Cursc@LBYk5fte#Z z%Ao5}R#8Wm!=mdb!|3>gk@S95w{OzKxMyb0rn%I;b?erx``7EQzyAOG8=_N|u4rmU zeZa;5bG4X5gh0|jrvXyvSdRKh2J$ z#EUkh#lOvV$@jF;GByb8dZEiyPC$(yf^Z1(bMOdIkdd+rBiPkN7)YcQm@)0j{CpuO z$}i6^EAiO#>^T`4Ca1t;lRGrY1@Yzk5kk)lH6eTGf23!tc6XM#3#*k<%vE4elJ( zh8PyeR9$%)4tIX9nE8eN=4ve_BTtq$n0N#JY4WiqsDTdI74g}bXe&8R=!-Q(YN`ce zT>?dYqkzpF&r1vIJK^a-OrBpq!)>J`hj(UWW&;7uope}`>Ki5G1{OMi;p zml7}9fENF@)|L`dhg9ajMCSjFSU{ma&rQG%0=U_tWvq+aecthQ5T!^ajh>Rc$0&VM zY$1x13z}3tv$1qz))2BaiM8)U))2bBnV7?|f)X4i42g7Gi0Nw@XO%D8)HFTA&L(F? z{JJfKp$;HP&Uf4SrJ#L-Rc0T<3N&Yq%>s#@sn3f24GH?!Jsd>RT0JS` zU3yA1KmBz4L*UJHaK#DJ)7UGZ>SM8ty-$b{LLs0BP6gbpe#< zgE~yYs3n{K(G$fd}h`k;RV8sx337 zt!jz%wqTV9zQ%OT?gd!MO>UVlVM{VT5TJMo68LzTj;*s6wq3WYvt!dNEb^ugVp7z2 z;%0UVy7R3xm7eXEpy1Z29Tl0@H0WZ35!NrToeu6dSrKr+W8hoG6rs^Ha}H`iZ2Vau ze-*&yV0r|+cqw7$MkGaTAvNTbE5|2{*ONiv3eu1X>m?~!PzYT# zc$T-cTSH&;P3Zdg<3o*D{jS)^u!=8@K|~puHX2H?{b$q`b;o;Kjj9qk)o!BKco4l#lYo zfltS`8+aQV;IBbyxn1o0NtXG5R|u767l;t*cgeMvSJzLLPRb`#P%eo3|7>Mu%{~Pi zer08qWBSb4pVkJuhkSqjuo1aw`F9Vmf?*$!uZNv6lGTGP@dLrJ1Yl(`h| z*rLDV|MJTs-tjfG?|Ln7HA`^{zbqU(t8mOB@)nL+O0x?kU3wDt(|(%uSNeWgkwg;5 z+SF#X+XhnM!;6aY^V8*gAK=+61K&|WNB?jUJl)aFKy8*glw*5~rAT9wX`Z8t%s|f( z))tqg;xr?1KdYBz^;31cDnAEftDw6eKHV<0|JcSX;IL%*v#Lrw_@)fMM0TBsJmHBV z7wRhtJT?JnDFWCRTxv8nVqOq~!0WBzp zwY`{+fS<_M0Z~@yJo>0EUIXKLv<>JK1E2~reW+;X<_b1A*O%+{6sADaOK9R0W>N*58G1 z*1Fn6?+WNZ>?w9m@_i=X)fv`1@3X=D!-^LUSsc><9AMeB4exL(d&FCJDWGd&J8U8g z;1kG2bDpvRBM9%(q~aj)m{B)ob0Gj9i<5Z6W0#uZJd|D}y09peScEj|fH2U`@ZG4U zVFL=wGu1pYWL6awJ7fGiW<|9q&P%sD#C&99$Jno6KamYB>hAbVCkoS2U+vAy0N=o8 zTcK%cfk@48hp?JO*%yhBQJ?G%SLWn^9gO9)HAL!aE6dRv0CZGdo?$cPVMrR=%aIEq z09Avb)p~@OyI>q9r?6s2HU}e>q@=t zOo~4psS}g*r=xYn%N6&~P7k?uI#lys1**FayNW#km$hJEfIXIOo5G#gu>RPg+E1xg zt2zb7FP>c(ia_80SKnS(Pey49dfua$FOJK(;5faB_oU1iNE3p$xKCx8mt>lk2>UFl zX;7|77&{lD|8&#B_GIC3~C) zny|^;J|QY%OK!cwP$wF1Ei*TYaE7DnTb;qVJ}|8|M_pH7-emQlz(;<`WPC$}C(K>f zRZR&wVaH-s+Gm)$HpYb+usbM>3~Il%pr8l}*lt^S>w+o~Usxu6AjNuWbB*b6#%Cy& zbr(*wvmX8d8ODs6p||q=xai5l7!{OU?H&y+g0Ny%n5C-H1$)s^Ma_8Z!M!ki+x9`shGS`$@*);jA61qf-_KWehf9*0G;nwixbi4+^5z3GIP z0az?!3&IKN7?_gj_XQRKFcP}d=SPi`J;8$7ne}Enc2VcNv&a)!Uz#Pfd2`Lz&$yJhR%?g6elT%P*Of|ib3g8Xh#7opq8nc&*7;*` z>-D&iS2-qntZ@u!aBr2zf7oF+{pdBK5>b^i1+QG!+ zwmV%4KMA*x<^i*ko$gkaVCDpkU*)f({I{a~f;K~<{A_p#Nz?(Z9 zisM?xUdNZtyPPk>reTTtuzJ=Nb8T@wpYBRO<&L?B+$S{h1$R zd9#*e?a11nU6Q>!`@NjboCkA0$UUCt$y=KD$NZlBE&1;iL<_bRd{j8U@QI>XMGqEz zJ|G*!;8 zJY4xf)wNaI{YL*R|8D;y{$u{Lfq8)?ffa!bfvthN1AD5K8mh5FqqwXlR1>S2Su?-p z`kIkiYi)L|w>DTCt({)`aByC5NpMAQLvU;G?%-Q>tLiq^ZL8Z^_rtoqbx(v2)$gr; zqW*CGvHI8R-><(Iwue38;qamG3*i&t)8TXBFC+6J$0Dys&P2{fE;g7NHa2W)j5YD5 zXw%kaYOZRY*SxKHfAf3IU$$6Vu5H=g@=B|zHP*VVbtGC5ogLj7eLXtT=4$iex4-Rd zY+mg8*s9ou*p}Fi*q+!!vEkUsNqmxPl4nxQq$tK8bP77k_kMrjb!zN0U^l)m`o>!& z=_h2kGyU|)4-n^R}gN;Ty~T?Sue$KRI|GRD&~FE!S2Sa zXG6M+RL0JdmwPCJqm8{u6;Lyc@C?j(2OuN-HJ*hmmc5Mpi>QlzMumnrP2$JE)w!h%$$8{wZB;m`;9vFYe!l`-tO55za?gMTOi-7VNQ1 z<+TbLefFYiOgn7$fKMbliutO|BsQJ$QbIa+oLaru@c-5`V(Drm{B^ zpRgl|OY8~o#Dn05p98%HQQqa?6$RZ-Qzd-Asq9bii+-L;>Az?m^dd8v1?7T-o(0JX znO+62rVx5Q(HGF={RJd^7oZjR8ERdWA9fKm3>rU*`@_&X&I1zcW1#W7I8G5H(9|wz zEa*J$FlM5D1lU{UaA} z)spDauTU1z&wmF+t2DYn=y^hkXs(nWY;|T~MijkT!5!$68_^zg(98@;6VETEwlRzms34JtImK!f4ijvdu>> z0|+O2vV`X0skO8UDf&T^5^BdcZom_QBI|Ko^)(tUN6AErL0XEh?w7QxlXP86x1bbj z@Z8@bjfm6fxCZ$PI{NkTJaGUeoapa&XgXL=p%#s3J0`J{3>$o_SRr6#=mdABxo*CKp>n`BZ?lR}p!z{wl#}yJ;Ey1{8aNj=^^2IK4zKqd(n1uhA>m)BiWL zjgROb>2*3m|B1TzkbX~pk6K&_%HN1Kx(2-2T6kk#Cu>pE=tjC3`}><@4Q;0Hz`OK8 z`Z3)~Td?o`T{=hS>E{?@_t8mu2Wz`0=t+8t{vEw@DW)X%%%?(<7duK8X8K({2v{v2>*EedB9aXqTh;$jfBXaOy?7mT)xN)O!u`LbU7d zgY?2xJy5vVxoDU@t)Lb6uiHoFjwdOfQ$U;@D29G=1~{8}8UT zoMod^D2^?i3&RiD+}(|6BI_uThnj-Dekxh8kWOgJ)=JK??paz+G{Ag;H7 z-~u9xirRriF(Mv|=PK%|EGhy<6!pjA>h8MAn(5!?`>MM$3CXJa`{$QVS5?1y_1^b= z-}im*SHn1C%!h-Kd8a3rPdV>&-o+S?qV&<}_3JiW@eczJ;`etMb2mo&D@o~0+IWe?!^AbSMIPqN2w{4_g?}4EZVZXu=!@}3j5^RL=$*DD)nH4jOf0YVGKzF6{cVBi93tYHx+4;+H*@dE#>&TGKzf&?0@Pwc4i@ zEz&227EgSrZSfJ=(y1;+ViuVqrmrYzY+_1r@pk75W4f%7O}2P~jBwgA!M; zfS|>opv5p~@hlbtJ!4 zoIOD`py~id7X{UDlycrBy#rSqE65JYn#Xq=zMJt~i|-12$MMCwXTA8gO zW(36QPD zZ-Pz&d|S{iwQfCQ>(s7TPA{*#bcXvst~^vJS3X_4Tls$NNFDxxN zm3}aSW2gfvsQeK$a}4KKD$g!`qS_AK`6b<_f9SJHHfiO3dz-z{?*{Ae6|7^ zUU~4u&C1i2U!vrBy#&9$j9(8dx&NIL*SPa;`c*l1X_+Xg;^)-z>Tk}HSZjIK#EPty zwSf=cd)->?_}>{?}q$*AJ(FT7CiV^ zG2>oPqYob^cwG!M5XZ+V*0UL_p4NRcaT@ej1ZQf;$AUgds(0hV(Q_Z#>&M3i7zRL( zwEm5d^25v_R)<^2dAC>>Zm}-hSQVQ=(ZpLlf=8IaPj5zRw*gu^pd~r~K72^p--VWG zjkv`caSK{@gSUPb&wd^sF4l-!a3d4@XMCLCO5eq}NXnT2or3Y^v6h^X4H@ZCDJli{gFMH*C|&Ylm*Lqk=0&c~blWL&JPt0i z1z0ybwP2lV(VdVCH&K8GI5 z=&_6*%b3|2&_x#gomZWq`~QD4Ls4Mj89?@sfQ;xy$HsG*gXcgm&w(zUUNRR%6ND9- zhtpw&XksZ=h$d(rbgU3IvQ^xFP|SP_dY{DI+8j`?*Q19U@Vyb=L-@WK-<$A#3!c6g z=Mz*xbM%K(=IGTbRs3yp)KrCv=nQ4MEhP*NF`^bDYUx=Oo5Nwkih#yTR}C7cl@ zl|f16lW5lt=*ocZIY4(55IhG6o&yBW0fOfM!E=CsX6)VILc|kV(B2TvcjL=3%VaD0 zP$r@DX|WblXzeKKm9a`+#aQpbSnt7DAHrDg!C3D>FGtbKQS@>Yy&Od^N72hs^g{fi zjB!4Mah9=Sp2ln(#cY&88~0$G_h6i5jPoIkvy2t<5XM=?IH6IY-lM4h7|xHQ-c$G* z1fLiLbaw+Aq>~Cu3mlO+s*$aSEGa=wk>)I8Ui{EgAs*fMx6arYF7D+8IwAamXWOuvtPkaY;j+b?QUUzvJsy{|;Im0DjX z#z^qesEzzJ=}ADrq{GrIo?fk=`FX69qcuWg{hy_Ng^Mk#x2}a1;Y#@pH&+0;;a7Nu z`oTZ`3hW*R>z;m56LeMulyo+7D(l95BYwGnk^9)kpbb}e2j3)pRr;~?yn!2x25Ul1 zL=y2tDv?VJCMFY)BvUCl<%f@e;i&|kx}SZVJ&C6_R-a-91D;Y6p+r|wNL3S=- zQ2HSMHK5%nJy>}i*Z)>|y|R&o^p7FT@4)vk1CbXKWwvE@`CKBg`U&RVu)1vCv~9NB zt(6<+=JzBHZ<#Gi*@e$qu>f}NOz+T=$ugTOvx)TNqZr}D{CG>57t4wHJuPLam`SyBO{)t)YuN;_|s8-MtyAtIu zt}h#N+a7J?u8HM4mzT}UXOm?^c5dU@v#6{co=ue3uSZd7PD_;g>8gKjE^$P!4Tu_1 zRQ;7Gx6}Q0s`ue>T;Z};;R>)@Uop$;|UYnF*mnsVXn*zb92=Z%q4bVJn8Ydma?gsSe_^wvkMrL zWn%qo*^(YFThrs10X#C_QZ@^80~3i|M=U$W6Lgb?tLfm=u`JCm-&r;_CvkHkaX4`p zeI02xWr3j$v-9hAB=wqFmZEi+dX3r3y+**-|XqCJK*Y5elqZ@iRT12Dzli z7s}F(J!QTVfRruGEoECVLD0G}EF;^2DuA~%KSx#OCk3?j;-fYHoU9X$qj)kQ zIrUa}0U%6Z=<|u?htmr*sRG$dqX{V|GyqwH5>uOAnAE#)o#F9v22bJP`p}j>l;z^;&9g_0yC&x{W!LWX)h%VExO&6v>P>o~mPENC z%GKf#CQodcJtE5!Wxg<8_7!N!fwabtc=9arK-c(T%rLt3HYd|~ ztac50p!@U)Rx;h4!$_t9|1?TZo~$!W;SspA(?Ie>nTj z&xM6O4^mW7G7lI&6AD2^f$=szK2DfW0SN9LQLTmY?FB6bIuBs5{z6MRSUke% z90KC$94;O)&^b~(Vx+THJYu4Aw0Oi!=UDNGh0gKf5i6Y=ibrg8E);>NhXdZ?-JCnXBKtOuv~Zva+XerUBDfAJ@l916?}4B*vEjTq%r? z&S{K~&KZo4&RL9)&N+;a&UuWF&W#u!otrQ|IyYl{bZ#vshQx|$D+0x#`2@HhpC{s2 zz`5K?#M@phw-w55SePAH6Vo8w)2__)LVub#{Qp`5blFnwtj+Ziw|O~{Ub&81` z&w@MBEOsqH&zb6U7ZW`K@*cFNx3m0>`~qy~w9ryH3;d%Hc9SFN{v+MoPh;%`J}`jO zJ~NM1v(VpC?klzihg!=0f2k@cd?%_8V2W5En`li;6K?=&R~|k*ot_3SnuP=e`-N!g z=iILX!-HUAfpQSljNq$TQSFG6jh7u0h24i+(}~2;VYIpI8bg$8MA%zy2aAs1zkKDC+2sh%O*%ya6RM1 zf`;q)IWUS-pIHEC5CrL|g?=rK9;Pr(oJ22Z?=+nN5aP{dEMiQC2?PgGatb79ae>4Z z0b#(g%2H4C1!5Vh;UWPDOt~s9(nCPgaQ#-<4jz(7Or@u(Q<}w*+C4EC9n)pDdA2n% z1j$a&OL`$1txrYSoW-w|xK&qT^htap<;|z7vjq{*QXZ}KCa_ic8=h zt%Sv?a&TgHy$0c$7@BK6(#}<^+p&}GZq(MFba(uuyR~PRws-0i6UFjiVQGchL$wYk zi{)j7!yrGRvBOyDXMznA+FEW0h|9&0iHvhP<`ygM}JaR^ZpNLXzf;@KPO39R(|a z0>8fOp{8JkE3M^TtnyW7C|`}{xT=)-VR1RootT0&u3>9!kqEjx12nHI zKFZh>F4p6M)5V73qll#9Vk0g@$tJ2Zjgrk&hc32I9lAJ+>d?j3;$z^36S$hi6@)yl z=8BKO?x3q}xYEncrh1&p&Y^nZ>RhTPuFj)+;%Ynfxg6EzsZY9Cpg!qh2lYu8JE_hJ zT@vdaM9I80xr6` z7&soPPv9luSGj~6m+F^exV(&zPSwie_;opCEiS3f75XLBxl+`j8&u~Y9v-f@dX@Mk zp14}Sq$l2_UsCmJ(9W>l;@Ql?EcA zV8E}+7K6msptfXS(q0ocaBkR&lYwtH;*zfmhv7Zf!kW*E$6Y;UbFk2v>+yB>b|pIl zexGViCwV$($@{nkM@v^G6XrW3>1=56UYp>Sg| zsO8XiBmW|gNq3`3jb%zn6Pyp!yM%S9aJ^5>9q}vmKIPmn7v01-`sqCB*JL!Rs`wtF zGa%q!jH=gALG2Jza3#jv3A_G%(p|{BSjT2dTVn8fNZi4sH9n8cz@2u9IXK&E=0>A3 z-eHf*<;B!fYn@wz03Bji}S22;0Y-I{f4XVA#1>8Y{Np~2qnOg8OLXOfw$Y-uP2 zfRRz2P7z|3@xJ=S2p4Gb@UFfSmjZDGZqYG_3%C9x8lX$1FKxMBGo@D3c=pE6q0^fR zM$PNjjH$DWM$O~b4CxUi7E`vx)+({6dZ5)5L7_2yc8f{#d_R6p%p8@izN@3E65C6M z4|tTQVs&4Eb4;;0u8b*abT1w5_4#6o!*$iYUYR%S-Ai7imdaz2TY46;_hH1eN~Ph! zu6%>hAWaN(hmG7Y8L~@8#tmy2Xj(GxLs$Zs!y5!`&w-4Hpd_}Tv8_34v_uLSvxUec zh3RYM`6}TA<2=~c%7Lstoswca4k1F-@~CAA_*o#>=jML7x2HRow@B`N>!OMhncf?) z$NavC*)fzeZrVP-S(BCMhO?up9F`1)wDIx9`x=Y9x$wP?#&&+?0Z*8)6R=IS2R&*` z^(G6}XSL1Cr?xx%ggw=|W-t&7%KoI~sbx2DIWOODX^Jmi+ntECCt{K%8>b<)r8XG6_jdW~`M=ZQ#4mcIfwD=UxiFiEVkH`HGo^(4una6)B2;c^P6BX5- zUVO7AyIj(^!x@!bw&PDao%{v;;Xr&*)2r#n^HAK-fF_8q8CZMe57HQTO98Qqaki>7 z-KUrh+_-Eot{P3qLy}COS8g;Jx#^IYF+8QrSTI%6g4uvIAClJg_cPW%-appaj%Jz* z%>}C^TnOky3dGd#1a4Sc%DAjjZQCix3d+5Gd62If=Uw00KD=V2uehSIaYeDaZ`Nv) z1FGhC&zQ`b8gLJ$&*?2``B0!Sssx6EH+1)Ay2JMNt^L#6GSlPvspjV8xxSg?`C(1b zRNqkaJd@9&M151u9sJu(!9Y3{R67ETpU5RX5bJ9KA0m7p9?QST?h(8;Tnb1vE)O<~ zyTsBBH2Wtp)LAeYS`$@9T4SHoR^?7UF1-$k7B6XNoFo4NCopcvz74nxv0$DW42ad; zrwax!G7t?3KXdxG^8NE06`vB5OPe?Kd*bpw_34)m{%}T)sX=$wQ#<#6dC=oW9w29F z^wsni`f3$@5vd9atg;j_6BcuNChqCqw7De5(BH=S`{m%isN(kwetG}Sr@GuhH73ve z@Zd{NtLUqRznia<9!AW&yVMa=B!-+dM9|k55ir0QA)G8`$;3^&!P9~gVQzr+=X$gG zT+nKX0)522dizpfSb~QI13@YhOx;qIjsVDyBDHIae^?YmnV;bI=xX^LGb4PQNo~x0?bUU&!m0(}|E44TTDA0iQPt zCLFQLK~FdtNqP!i8DP@b*fhVL--Vbnq&(QONuu%SBioDdm=??^Gtulduy`h&&ZI3C z4N`tsmqd_ZHNIREQZ$j@h^{ALv_7h++6GnA)D4d}QWps!i!AKY>DEsyl3ut(jmTp0cX)wy@7?ZFBp=xwt>N zK~eQ_euZDa)1c`ZZEmIUZx^(=4wo+G=%&V@zmQ9LC=PG~t{{CeX zmrsuE!*QZ%x{w~pHmzt*jpWQ*-@WKCx`?$mA6hgL_AGT!CEz zJ?{lH?WGnA=cM5x=!AJb1PW7T3{dQh^Csw3vXNF#1B<2eX{ZAc(rsxr6f6(Ym`1BI z$55lSvoy^=Xi2*LYSeY5S+xcX+IsRuVqM1UD3*+Ae zJT0uTl*MmE_Y8+{M_LfzAZ%sa4&D!7GR^r+lL_=bBJ~k6NQ%+I6xy3$?z%uknT7Ok zP=2zfvvXB%ds{<)bJM8T?t`}FQj**H`e!>^$GV25Y}`cLbMekY7cWmGGA*%mOS9r| z#1&^iPPDddXzyOvm&mUg1e1-n0Vu#1uDs5l0K7wNx-{8Cs?-{0G8&8~f*Pt7gK7nH z0czHMfNfr2X0tMb^e3#q46$XoOct7G&Sr^02|d9KyE>sc(Jbo=swS*!!>o_e zY^e(W2QLg*qpId{_7_{GTZcnf=zgs?-Pf8d_^obRFx(WL&i8HW&Lo=(rG-xOaJvi< z9|<%K_BMB_Gz;OTU^p+k%$ArE4|@a6t6CZ-BiiQfsq+xnlUPILPyBJ|eTdsOu+~x` z27=*hOf+t0l-PcZOqn4qJVeye5Jx>tP3ORuA_XJ(Ql3WAr-(Jz4pQi#W7ZX=rfslCuSG~ZV`f0?-)XbOgTIr*F~uJ}PafPq zx%}XAK!v%2&BUL_Tt$#e)?4cGlEsSE%?vOA4xolUQxXhDp*m^M3$(nJOE%z%a8nM- zsIZ72AN84{m2?8=+Q@`j`S8Kp4tgDG#N+JRs5Q6s&2RJsGO- zl{~0OtZ8qxWUk~utpM|P!${GA?Nh3YMW9)zzh_6ye|ViF9-mgD&c)X(At#V+bMk)^ z%982`9e>`9fEX(PU(aAIhmq&g$$Cnig{+LVOe|41h)!kGxf~LTFsV zMKxYsTbfI>a442!5V1uUWUV!s>!7)(b@3xH z_l-37RxD4x1`8ASj98{{S}X|ME}i>EEuT)(+~=B3%>4-O1Anhs4a7#l@1Ype2& zBj7i+Y44jK-gVg>mwD~;%T+^ZxP79(%`E0Uw6chKA76Fx(9H+m^_@Wr?@6jkW@g>` z>Ape0OEUW(_!E%XLDpQ#L+C&jF<4kjJQ_|X^0|ZH#a>jiLk5xhTT|A|!tkk5YI=(h z>FH2s6rw$r4)IZv?TMh4U;JEM$`hVQzxj;lH%@r+xg{~*C%QKnB(jHvTSYQ#5HQ4@ zv7rBqFr1?P?5={aVyPOm1fIQb(1=K`tjFy9oc|KoXedR!q@+XbV(JWOGP2NQf-KNT zT1Y0JC7znC4&B46<_v$YYw-ntfOolEAbb0Ny=-^FFtC2ntwr2VTcdtQ(0tFG7Qa)$ zb{h#6*8zSvzaFZV#@b3?^*a8+_<(?|gn;ePP$a`T;a1YzVL!Q*9Ni}-p(bN>w^Hrb zM(C5=D5UD2Nw=(B^EmW1@HHj+kF^V0@Q{U^e+T0Z(6~*f8#k7FfyPbB;3%)_?Iw_> z#biz&hy`B$FS|3Ux?H>~;9q(lOt=KdlQ`H_W$HS(zR3FHE` zv*FSpdKcP;8NMa($QuBU&^m(1{V|#r$j%Iz)@HMeW!tlDg(f@)$tDa;9hUl{sA@!Y z@$BXv4jW<#t-dGfiwvf=j!vYz!cHI}9GUGN-`$@{=gw~P_g0DbE*O*UKx=ACcl%^2 z>eBp*7HjREAL(3^NpEWY`;+K+b>)N7qO=pfjaj}$WMY(@tKk5%+m>-N>>>Cr9N?;= zjGZV`Yi0F%CmyrlF(i5DjrB2uS%Q&$2>ujqCD(_Q+3Z%^e&lbN?XV>++-zWGtXHE^ zEEwYg6awRhYQLSQdmO%({dOdko$*P6?*H6z3CUh(1leM?TlQmuPYW{!UGko_dBZwr zWVu`_mn}Nr9AvtZ1@=c=yQY{`)$h)_j9kJjtI{3bFcf0)Ng~kVH20BzOfXNh(*eidM64J+58rk%)`f^zp`3gyO z;K3RAS;$#Hwm_PtBB7iSYth&S3a{$kV1Gh^9pwrQI1p3_576-P7XD4gif@gXgFZEG zX*5Hr^JU*_j(EHw^VhgJ8o0A}wI%i4mIvK(RB`!sNs_BUju@@{YLD0JJN^Ypu|w@! zcmL)m^5Gj)A{FH*Pi{ZHHp!% zQ&uA}zp7=Icg`>C-rBP;YLlJ*n62shRpu49owIP{eAVOBl#&_`c`f-3H&2hA+p)=E z(_jD(QXVG7EdEoP#(eppU%;CY3SxeQNb}7g$x-4vpqrBAE5r98N8~UndK*E{WDY_3 zpdd<3Th<*$zChPkSGc@^n1A;(A2{bjyRN+DqqnC*PM6UX^-6vI=+J}j+JDEyt_$|x zbc16tXD0lw1|Y+LAt59jY%&Cf_JXLZghE_?J3J~T*+kTxVDTgZ13lzYSrWz}Gtys^ zZuN_Ov6!F#9Ys(UAK>lc{9S&IIA0#~OM`yg_s1TI;_x-PLi>OR>x|&hSQF;-4e*fB zEzBrXh!7-Xbn8?q0!k-Cf?rF3_%OGN&i@eeThRX)^{>VsiTl-91-%n3{z-a3`UvFy z<*Umc&|e-Vl3C*BLkx~>qrq$>)vHET21DIm)k>}Pnl(e$j5T-02XrH;kM-tKsa!g% z+qgA-QfCe1eLF#cy?trYF@>clSY}n}1VJv|zIy$;&YOMHP$ZCX%A2QG?QCs5cdRrY z^rV`6b~Tpq%`^`5CRC5r7=7OX%XxR5wdKQmSIWlk6rfnDSWc zK6};|Hc97RDdysJ=|<_puxfLqv=(vLVa;MAEbNWo3`_uFAYszrXUpcZCh{qth^1w# zIw(c>0JYCV>~1e;!5`-r5Z>deKeqVZxZfO8rD-uQfA0DoosWN3i6iW#cKu8xk^meD z94-S*G!RYDnvk|*9%zvulCgw3;fG0Px|5kIbrR$9>6TC3KUTM|hcWq6Q$zy4rB0VG z4`@;UZ;1@~vvEHa`QwYtRk8#Oq!)b_c2J6ZLRd4Th##U=YS<1=Bit9l^+@UvfKIWb z+MP~k2^8JaF3d#|k#7JAh8M5@s|qsI zIN=N8FS-_gLVeJe``oe;^DX`hA5~(i#r})W)4b?&_4IF?4NAbXc>GVEkYD&j5r5^s z1+B=eXEh~h3R@NI8k03omZ>a(Njm^^&1b0FsWS7XYj&y*6y#s8Aa2usj2vKi{ZUO` zJPOB$G)<5le@*6EKyKiE!a=n1y1|Zqb1YxVq%;Sd8#NNeN(7N2{sCk`Xy@`7c&P=q zfVV{~B273dgNhhDR1$}f*ZQd^Y`o4GQPPe(?sMjRLF0|4s9y~k&$-(a@unR&&ElRp z0vRmz$^O*ha{l>*Uq1fLpheOAKK{VstjCw)A6UFd29)|-y)KPmu3`)k;;>zC`Zuhk z7>j0ewUwlZISTR`TExudxdG?>ajyBD0Sg}>43XJlS^PSW`Kvf`*cX@akJ1u5{#ifF z9R5#>vouR2b4cEv1vpOewFdvc`dWW>aAMsDQb+ZI1e-Wzn$hqJ5h$ z=rnxNALY{%tNMl(U$5cim(sfcM;JQ`K2maKvHnc3dZAT77qNk!(-4g&li@N*M4^BL zMUai{z~do!_jGU3>T(2CGgQ@RFi}A6%ymyx!_!Q<^>mdP1wQ?e2xF1%NLNcCog}A9 zE^UQp2l^$}@<~F&qDQ52eKiZP0siNcT;kGt$GJ0GHoRrk*>`Tmv8`o=Kd$^S)#!r9 zS$0n*8#}l54D1<(ZzWAKA8(g5%m$5KOfhzo(q2-dy??GvFVgk;FoTNRx!nS@@`&;a3)Ysh8Ocr2D` z?iF!K;!Y!6=h&7L5vFwNr1{+&btE%Q4}bc-2Zl^qAnLt&UqdFQ-uuukZ&GQdhqaLH zlHG^KH(zCUsjJq zb$B&1nL-A%2d-XoNe5uQ2-8O>Wi@gnHd`}jsD=nbKXGHk5%M6@mwtNK!blZgI({9ci4 z@*$FZABw3Yl|2yABnM;1e@BuSdxg=@e(>7nT0a6o1_Wqo{WP zWFYEzLQ!5XBVr|u`uA6jA#a6;;c^GX7}sr^+TpPA0J`P|r|)eyn$LOY!p-rSaF@5C z!MDO+NNm3Gfw_gV&c9DiH29)>vU|_H@$Up4tboriJpfA#cyN-0t|}{4*C;9>qMOU> ze$l+4>U#)k)hLZ>F__b@MNI!>j0RzU-Sv+a%tF-o3unsg^Gah*hd<);fwg|!! zoLq|Kj}RGJPUSP1oDg3%xB)g$s~-uI2B0#f%dW3FK52@oS;t%6?#O2BSKa4It5MVU zO(AdIcI9p6pkhu*%Vf6_PA_iYPV4DVP zah5J6yk^*pr!q+>UG1caD9RHHj1vs35qYE?_B^`_)Blof%ny^P#s-#zjq7Uh1qcaT zN2x_?L>AUXS4I7Xu+Rsi$EPWpLj8>)-gQj62RcN1)q{q%eZmb3YgH@3C5@;6DWdo} zSE5&;P+|@_D2@4Q96ZQD7Und6zkBhY>`~yBkAZJn!qTAX_QgWSpR~K+gr&TR+gPPO zHVh2H1WqcfF^Y#9Xl$%DHmLq}B0O332a7B4(urYF^SE>Q z0>4|j38=~+G4GlzAwr`2nK`=!+9c-((JkD}dBH`FnAs$`+pBTj27OQ|LFHF#mP@SU z$g!PkiK-3ukr`*4FKDXM^`y(|k)LooR4sh(y~vLQPAk8vd`&X3Jp9Kw(XXD5a)y{c zbqiaoPC@CUkwQ72r(rEl3{W&s4<17nNM81B={KbBiFQSD!7Vg5G?3b~h;g)4r=~iJ zx4F*uCjAk4J6z{Nys{{^EVuR6B?*pO`6j5E5B3HN2i%w~TDha(v@k6)vC;bU6 z3^0@&>^^8k@Z2m>ivsYgnbSG&q?~R{2fFxW;ii>+vz4*Nb!q71=IT zCqXVxtEd`wgj1gfy5=JkijPe-cW&%BxFAF!(E3K(-x++w2r`3X|B>bP=HCWm=*fZAPL3F}n_a(K4(R{kUp|MLYm z6RIZ25lgP>pOK}L0_#vB{`Jz*z~1rUtwpbA@ju1Ffq zr8A?KPjyUcu8{5ShE%~DQM~Sk0>9b6cEIC*v!D~a(dl(0Bz=%&gzpf>ww{lI95cp6 z)()Xs_-9c+!;p%VNo8QWz~>4jzvh`CEDKoxcdI2>&10ffdu6Oujz)d$#_ju~@o=D} zEo9i^jcGE{dyxdp4@8k6yM0EDsG1{{;!#9eCarUlUXG;O&%%pM%liA5EshNI4 z+a47TjoNAy{%t|Yaxdj9*Er&5hP?im;#7^z(Y;p5*PrM)w}14)@ocL#=hwJhy6oKk z5qDm0THZ8V{G{6$@j;v9mc5~1uxIz=^mVJ`XfU$4ZSOwrS-0s;R(oMh+wdylBU8{5 zegJ;i$T~}Hvvp{|H8)Ko%ja`h~@1N7nqC*%rc z3!Dt$DT!8fE7!047OvF5kz#UyP7S`RS169CKjF3n-Lfii1H!Q}$uPA{YG_ajav*_& z|9D?_3Q?+PN>;L&_8Ujvy3QYsDc`z%?(Fjfjl9l(DrjV7$(Bh4;bVpqQ%^-FO<#Cc zBf7g;NYHwrv>;3n6wV2a6SPyTyP*xbi3vB~2?kj8jv!kVo)h6r*b|q4bB#A&lJ^Ex z%^q2o{0KtIA@|xTuRj}wZ(5PYAWPN7Co`9|fz$Y$>CFw_@nE_5_HXrjL+=tg>J;)c z9;)Re3z$j2g6@m!T2As3*Em?)&KO}TE}J!3nxu5i<4?*&i?n85mtKM#=qGQO$1Wi+ ziBeQVDn68_$c)uu(lhT-y`S~LUzkm2vU-wS-LFe}f>`ZhhmCOfiX~GGZlRu#1rW$V z0S#@K7}?u2>?)lzvVwj@qOrj&r2+*biIJe}gIF_175-jIPnbH`-afy5`{L_L6h+R~ z*4FjyM`;k;*&Ggeo99D+7_R#*RcoJRntI~s})MS^lTK*HhuqyeuP8tuMn|EB=6{LtFX z4P9+bYx_0_$PudR3oioCr}#ku7Su3ZdSL?p_MFqtZSO5zFfy>cuXN!^x|EA|H>8I% z4INSQ$oZqw*RCub7+!Jh%5ClITXGXk9UEF(gwZH?&`N=;2>fRgr4qeXG?PZ?6X8(G z@FxP$(MGFTB0;Nbi~;Z@P>z<@yZ5dbyLyB6VX!K7>(QK=p=nuFevhw8Vm$|DS6u4c+h|dwWtG~_~b!m zav2(=5xfh;QI~H^bTtX)KG8VkCGcGlZc25IF7!@ZvmSf&jS;sJ<$f^TZN1zY@UP!7 z(e{Z3|80SRT_c;WpEMYp?r`vU88vxp^NWU^Tl-el)+T=k|1G>RTONlv6J8z)6NZrx z1YUF*L|Sc@5H6%RrB;gQ_l$Z7yGr@`&AMxc@?1|5dAg!sqkNhytW{bGyAXpxOnpmN zi>6`+p4{f~yEqsLX#Raf^YQil`8XD%>hfmPNO$HbXhg(UB3O&JgWkKzJDWr{4}`|) zy`DnFT&mf3v#xxmIdgI>>tv@Vfm;nNb)QIrtGPYz+0qhKd_j|=Gn+4F`!}vQZ}Zx% zk&r)PN@rRM#eq#@EgO2wT@whV%btczE)>*!;pP3yR!PdD308##2okB;6cXgy4Pd`22uvg=X{FH8QdFxCZmtB0P4Ub6K7pl* zsqVUrRn=47PkK1EPL0OZnJC1@7Q*)yC>h{~miu`$rFt%$(x2)Z>GB2AIm@1Kox_AI7 zR*FL-Cnkq3|0n(+;0&=;sX=zs<5Ll2yVuw>oTu4SoTp2Dr-0=Ps^uewn3`}Myvh_t zCb{VWvtPD&ef-@vhpZ(QpXDuXFZS!X7BBQrA_iwJ$h%4OtHBT>BqbCr7Z+G$1OqQD$|LBtiZeHiV5HRLS=|sSZbyE$r3dKbD zBQO}im?`LiR%jU2X#I(7Ja|K^%LM6&y7TKU#r%#3T>&NP{@AT%kWs*W^W7fEGOPK% zcf%=Wwpu>`@sDkWa-N02S4S45>EJ&D$}sle+v)evQK-+ze36r z86hs6*U;<*DE8{Y6{1X{6z&tmV#zJ3<(g~$(bKs{g?IE#5!2Sk%rU4t<~wcx+K_*= z_+RFb=d4aa_x%g`ybjeY_(|Y_#w@6gRo6#8m2;M=ZE%l8eyPSgN@Y_{= zqZ$imb>Ap$o;W2}xw>&8olJ!7i{GOK$J+xD`{Ii#bjZaoW4DBpFN3cO3bNO=_!ZJv zFfQO_2tLuX3}#lOz7rTVCD!0{$_}q0D+E-7B~UQ7a;O-32#1y(ew;8nAoeVj7^DYs z;I(wF8h|qBcKV{~;F^dUZjKb1n~Py3s0FS{O!XcOr>$ncN0zPbMDUT`sYFc4A(>tK z_+(4h@MpES(HI6t6L9ud7T8^e6zTK&PD(gW;c6rWGC;mmI8UoT59*%O(<_u)fB?eI z-@{)!PyX9K->3U(8-a)M8XmAi^UQb%`Uwx@R@8^zM>!jHQA#CA;#94ocuCypYnJ}{R$*t8fPm{Foyny|UI}?hGu6o6(u5p63RvaW zD(>)e`WobrE10%8Q;KE`tnP0ah$%r=uE7%asG4zL&rQWbI9QCNG!==aD_fc(fpxM9 z|FG?y80PBAG&{6)^{iHAA2HCVW3_*iAiH>g$cl zc1YQtLX0#anE2pKh(ln)u+axzLo(!O$-{)kbsKAKYHZPc_2f&VG|;*gTJ>z^b9n<6 zq9im6P@HZ+lRg(zeir4@*6HS%Zi#C?sB%(ai>5}LYS>mtbBE7Ce#3R`*gq(*OlRU& zC`Uco)gwI@ZVhzET2yXyjD?X#3O4c`TY@c<}5&qPUTxwM2`r7nWekWZb zeGYGsKlbSjW0Kug3+oMF1B}5;+nF4aO>)={A;r?lB_yBh#cPfkw}C>HMn$j6JQhAMUAnKm(yzsa5E~CHzS(H^BuNK!1>RMkx-{CHHhSp-dbjRYB z()>e1DK+K`G;5H(aKB_dYluzt8IPYeub9FCC2X{fb*!E?JxvB=*=)0!U4BnIlI%@H zvKB`s5a!0t>5L~7aJzzVk0?Qpe^sivDJDYzH2&wFaUV9=1l9Kb>9vspHp|D|4HL^IvlhIG1>TyDL>Cq;a-9`}Tx4_2kU zhhjqY?bPIYfQAD8Q1#gh17$Mm>Q)22E=^HgJ(*%lrpEdb$m|7NWTmuPPIk$0!By_yb!)Wx)IJn&&vXh~9raDTk z4h%K)GR%es`tbUQToxIcX{UIJ#FFi|HKM9w3tC)_ZQ^ATRsXNQegQ@Ir?E}sYS<)> zo!21|%N4M^>1Im+aa!vwZA4I;Hi;v2rkJvdHu1L7;ZJt#33IuA|DP^bRCRm)1I|CK zl@6<*CP;{z{sTKAJrCJ?(*6!4dBT^ybc4%j9eY)0i}0|(vA2YiURV{z#4!mhehpF? zI7#Kjia)y8vh)|xW&phOX6X{_^O#{{rI8G#WoEKBhz%EDq^B5Zc#kUr1GJ~GZp8rO z*e#h)xBjmtS|12h*FSaq*S9JK7j{%v5;(Wn!IvQ35Fd?!?Lc1SUjJdDsl~J%Mg7|yGz4sIJj&FUYN9+4@=*G z>1&qUaQ$;J;baQ=;mTG4E`bpGMjYf%`8rCGgxf< z@d67(Y3ArX7JLCpmw|6cB*>SPm`W@k9UkoO?rd$zXP4||cEJ-%F!$Dc8C8$XDLa~5 zv8R`YQRl=p7e{Ap2|-e(e{;=>71vDT|MWF0dbV|UZ|muqqyL$q^qSFd(d!H0jVTtd zJ08-4LDlMU$YHD8U-sGG3Ui`mq2TC3f#vAY>Scq8?v0%IcwUnp^bM>8;`^Bz1 zYYY^cs(ZU!<=q3sN6T#X@B5E~h=(bgJ*n>!E2Tf;7N z2=Ennk81e)UDyyRB>Ixs>Gs5g4~oOMEZN+tbkxT?yuKKmb#1f#A=lz@SvyaDt6?M) ztJ)cHb^&ahjmSxy(skA#*;!1w6ysogD^crq;=Aj#Qb4!BPQ6h}g!Of&$rQ`vda5tk z5eZu4^WnRq{J5fN3U8KU2ytAX$>&8BIT}%KSLkoMETz=}HpX@dYMtvTN+v6rJ)}Yl zDz#XRmiU9eBOUiGBBfEv1q| zS5@As)C!vz#HqW7Jp4>Vx+7-P4E9W6cw)q-hGm~?z-S1{*Z4giv-Qjrx+(6zPjOpX z8`~pJH=MwZuAr=5m$AF~AjVH9mj-wUdq4l`{0d6zfaX(`N0CTGc}k&!G!Vv~GY#^Y z5L@{(zmJ>oP6Q8RNWZ{4$yw5R_d_KoE>L$Ug9`|MvId8PDpD_KqoCkaHM*>=dT7Ko z$~<>Cld1hDDJY!o_fgS^_&=Q4O2FsyvDtciIi z&D64;VxF>PZ6d)~q9swtXX;)sU87kdt_8z6;RPc%pV+lm$IZ8*Gw{UAGkC+5hBu$! zMI=%A5DI=oQ2pp&R9>w?F&jrLS00Zla%{C6y&e%L=zD%$&kpgobIlNd1LZXDH%L;X zQ0!1o;r#~rTsm*EY6Y1BI|4~WQwVU7(W>hV7IXdeQ$3N$vfha54Tqc_ry8`YBe|}X zZN&lGL2oqb{UQI=SWm(ea(F^6TTqo9!Q60nYfm_x468n^h-n1fAR&u;4Piu*;WsUU zH<8~IMycV%26{;nnmz!n3FVB~1AuJNYziBVECg>Yj&{Ots>9sVjl@=PCj6=oph^YX zhkWLc9QIhkZm$-OH{m4&S-d3$p45i!c;~8s*B{9arTO6|4Y^X0u)`($Wz}HF=M9qV zjyN4b)!X17Xw!-gdoh|DN{aFP4f#L-X|(z#58A33i#fN;5r5KqzRNFpHP!9CDvT?;!!Yi0+EmHe*P-$p#c}LXd6&<_xz0q5KvzjCyuZB>#3bbT&6q z218>rCGRjYFg)DvMII($glxiUE;CIpEy+IFr>BpCsdy>nk)Idx*&UWj$4i1raJNXm48??3oZmFFFRsM?{FLgm zE^rq&PpNUZb_NzdvG^k&y(k4BH?l9V_w#<}A+>k0oZ>~Qy+DO5ftr@FoJ?u#vi`!7 zEvaaWRXW%c@F+C07l>PYIv14;X0j_O#~l0J%%)plfEXn6B@lujw@ZLQev43sw!v}6 zb`Y{R!i=PytNByw6NXP;oiZ7)N3Pnu`4(#ZOtq2naH1llG3p@;<@8NcE&_RSDRK{G zKZCeTRPeN_8DGUnFrcSMBa$Izw(i;P?YgyduqDyqbJ<;1hu4Ky8aXrzuFNL8%?8uM z9B)q+yW?S(f>OW7?YBE@mTuW?cRGupBbpx{zk~e{Ycx(V(>J`YC8@u!1zWX^Mz9b( zfj4kqlXAvvjp%#j@mjm;64dG4Qh!}bb1)*i3`V=j6Lpka*zkvJFmE8@JD=YXR+RAF zTnfvIW4R|7So~8&R>``COgPSacn&#)BooALp}$%lSl{^SvZZ$7uCZKxx-DDEXQn$t zO>uuX2UDV_X}YwB+$DWm`UuOjOZlUVkd}D%ZFuGx-i0W)M$as| z=oyZO-zFZ$#p}4h(5roZUi5ic?pD#~=4zjJ*899wJXY-!9(vswY!{0s-#(BCwI`|@)8nKH04HMau;Esg!n%h$f&H8xIBogM{2!N;zqMZf z7yOG$%CD-IzrugHqLehrY|XmegF^51OrAAo+P% zZ2spxmQ69+MHZ_qAP@Y;;&tJTVAzGXct7tTvw875e0xCC0*jZbv5(&84)aUS!Y&oB z^B$8~b^0PkCtqoEY5^to)i_dk`4_bSJ@rcFn98r+e+}{NN{856jP&-B(#`GG61Ul# zICJE!@Ru6Fbcu;h!aHVjAfx*b9>II`E;GZo%uJV%W<&KQgve;rD37J66`HDV z+(FF6CN>qr-+aOz5q`F%c2fR?uQ1ScjJCh0X#+)i=_Yt6b#Zkn6$*>3@c#;a5#!j} zGyL%TE_m1SU6)>X>suT@rmd8pG83+7PvV$?_$*!<1RTC1IR#F{-bsPm&MI!-d)o4s z`3phu;6PmrwJ{AWb)vY>A*7nNuRn zORw{y?Fmo|=Wt?rMjVsiCkf#(i9=1Ldo*z(Sq``KT)8w$HD|;=E!}xtcU;$<)+cOs zb9$@h?rV3!)KMU-yCqg7VetuvAcd4Ij6tv#Y8@%`R&f3=8H$L4N~q5NBkb!V9Sjw@hhEo zxTO6nuV0nz)%f*`+Nx`(&mLR6C_wWl_Q?r4>95ksgJOP9Eq@s~1t;ngo#^#{!Czle zPV}IcA45(v)fao8MEgUc{g?S?Xo`t$`F~?Ck{`Q_@#asEfDvL2F(vH4G|&!A+KL7| z(^?F`wh6=nv1kOls-|F=01iva5mtrlRf{IALm3-6?w3 z3|#djC4_-u;rEaCo!d)$tnv-Azo+Q%x-D`vtYnj|iA1XgFI(%{R{Rh7`OMapz328t z{QhvnTMQM)BndBkLLO`mZ~AXdEbbu(2fw>*ZEMS#LUI{x+rwOmeKOd8lVX!%uAb-f zr!D^}|46<3sPtmJ{tNtQz5IvLx9a6D@niM!qtfH`@}DfJ|7qzz>g6x;4~hP5(6I;_B+D zQVLG@Uw$5QBGbbr3{zLHwNhvOF^SoX5|pPp$PUrz8y_MSYOWOS!lwH1QrBqL$Uq-v zxFeTpL=esii=4Ja(F&uTY*j&y9WO2^D0#TE#5 zZ7*#(G#Pirix=TelAVd>c)%uycPk#woo$Lm$FkG4U}MHZe@zpm(ixwIur`_i2Z3<|vIC5!(?WsiQ!%eIY|YB) zOs**O-zEy3{?+Pl?-Ko&{Agf@B2d3{*A@SG)DfA3DRc(i*J1A6+HvOnSP#*Lt8|Y^ zJB{vZ&dTQD?R}@;sD+UUUidm<&%_H?puoVM=g@{vQ~p!_&L!o<3-$Uh)OjSeLp)C} z|A}5+`7JyYPfEW-`S(CF7W_rdCy|3@g{z`$5e^FU1sM#L^+cVYLg<`Qr}87z2}r-J zxAOuIoT1K>^*TSP)sd=wK^`|^zhGIq4R6vL!hS(lQ^IdW<{*W>h-`@~r2r@V^G5hO zK%b@?i1F!fZ#V3y3TmwsLn#y_iju(*XEMS;h!;`R?t^YRpr&>Vbu8=aMOSV4XtBP7 ztru@qtG@ska#8=w1j2R-3QLK$X4jNySWkH(mO$5o>soHu9|?7OoU&C8_ziK*?T8|9 zYqrK4M%>IuV1s|texy*eZtC5Ct0y0d+pTuETs=|{Etr*%!C&?#-$+}we=5c>m!Oxqyn;U)fTecoRbbh5EZC!0=N{!@PEl5(OG zz5WaQ6HCfTX6ofH@gFWJC;HLLe?s*^kA19z-^f1(KNP*~6n`HA@4^GIh&OJ+R>1<^ z3;dgmbHX2jaEj5iArc`+R3sb8B;wAn6W$O!ZqLxm(?kSGk4c;oF(K8pX3c(6O9T`x z9(X%qJHI9e#;>))&fj}B>Zb}PybCnHvHPJRCA-6N%8O^1+?-`9oVn!N`jv0++09}I_2_Y_-!^q|~ zhXhzy2y6~xOjwo>hxj1G2`N{~p$x=cLx>}=!NevGF^5?eJ2+t-2V=X|TT3uz@m4UD z%PVX4>wYt0#F6aQ2I{Hb^y}`|{df24zyJUH|Mo-1YSsmY%&3NCgvsqAZ zi+0NG;q8>$^CA1TXb<+7iVM<0A>@don^Xl1+-`HJcK{EPH!%CNNl8&xE&44^}_>3IL1% z4I6kL7Z}B2M6r>vyXuB>JRxlV;o2ai=gz`!Fgbjh-7JT^;i)bIpuY)Y@yV*@DVrx& zO$*%Fd!3^&yriLVW(_PI<1>bpkB_MCdxm?;2?gNPjQb2bz}=>t;&+9Px_I5(_HQ-T zccrjfOfKSEHZ&W2xWdIgp>pi)n&^#`p(zE&A*zsFSV+pQxS8Qx4o>g1Y7%Z&u2O5IO2s=>es~wx4_o-^cqGamPyJO{5Rp2B&jvwRj z2rF1gvoR3vjK`l*t zz-3Mdemvc>f?miP@X5~c@PGzk!?(<4wOSS#IdX5pG_^r1;miexX`b1=jE%*g!XmHTg-g<Bl~ zjtr>!rW7(z8seFflCofs`$rP>6$~gg4!JtN3`8{34m^7=Xtw3sym=az9?nqg4Mhb( ziyJ42ICKh|-e5OIo29ad!xFN&EM~pa6b=FKXbqI+8*HWm?{u`Mm5xfF2WXqs&)&gr zhOD2>eh?OLXx;-t)9~!kT%Q?nl=tQTy*zjluuBWTL+fkk{!SQi=*RpH$3eHKl zQujdvz->Z!$MwA&6fDa;Tml+wK%50R2Ae5h7tVbN4NiQUbbJd4a{;`@B7Cg~b$N!& z0)?sy-Yd=-U3e2{DKYaWYMJ?MxO)OBKd7oCEj`Tuba%i`R(%o(6W<{TFAsI}}1HWTy+(J}g( zDe(87Kw_=XoeHfd(s+=jL8Lh@(i|tqa&psP-k`3Kpo_XvA{BEx@crfkfsck;0FA=e zU8$|a@8g|WE_~?UhC;VS4QkFaf30mdH@$9c2N>iy znwe#mbMMD0X>F86|VejgB}rKx}g#^&;efAEY$O1StD zBXhXy6(Nm&t4^RO!}s!y(v6{U(!B<5WEjzXN)UrfZ%FT)cQ^ z81JmaJKa>08X`|BROOae!o2P_PTT>H=4>}d;7lzvcdyj};S8w6E{)dwBZGY;jK)m! zx-=F&d@`t?R-po>O?ud@txI2JL!gJZkSE1qEUv6SCfvh~a2a>T#9>^6Peto?=dr0O zyUya)^!MiB_&kppOxt4MzfA`7H#>CPXiOp#+b#uQ@9v}&jZQTuhXy>5wXik_U{qQN z>lBZJm*mpg89c-Xj#HVYxLl}4$~CgSwx+tQw5TxSl`dc9!B**pf+vdrhXjLnw@mpg zMqDNaUr%H-VK!Xus4&{?#_cAX%~WkSuC?b?s(txZWjLa==IgDB2u`sv8LUcgNMX%K ze6dXe-g&#ju+3rQ8I6u@28Yd{vcKpv=zAX0=}q~&lxDTbVbafEB^Q!d3?Gk}rfYeSmv;nJ{C9^YYr5+HcM#tX_Z7^N^6W$4)r<01h;Fw8eH zgTZa!9XpQ3jsk3#1I$YTK{$qhza0}d?g6S_=D`{nTuBQ>Nu$8$X7qf3C-QBlEUl=7 zYBuYL#}5qe0DdOO?t5gCrhv0dP6iKy2x~2)mo&{DWrqEiNf)lGp6Y&ryHnevlJ@iP z=Z3~HMMG@Lqh|C{d2dpmaREk<^xDBV-4>uF`!isvIOIctq z*84%5aJqqYNSCn=VolP+0h|22mMtV#qsdqx!wSqdAyXL!haaL1!pqG_m-33jo^Nrg zKbIW36E1U}9yW41tqYdFeY-SRd>D;P-^Wfu50~|~0i;_SxL)qAN9ZE)`GQX}Od=T3 z;%)G_QBhG=Ds16_R?IQO4wT(M<#QW~EW(Xr7qW;WaO`{{v%NmQ&E<2o1dII4pyF0g zihPTL!Lx_Q;R1kMWAYaa8E=A^518*OvZ=P$3R7*Pw!KheEpS@yIzPAMv5*^{T(mCr zth(_NeIcA$wz4_uKvz>Fb#hem(oX z{);CETmzlxw=B+^jD=X<*TjSKVV? zYmeLFEV4c7G)l({0LYzTD|UH}Q%!Okj-j64MH@JMUgB+Fy)U5+=z~7+grs-@&p4g- z%kA4QKiepsOkY(zBGZd@$oY22&n`+IB3~_@aa!$^pY7yyAxJFc|6A!a>tvUtHx;dl zZpBW;2g+9EX65V3Pa%|y<*ms(f@2Z3sya<-Eta-=%j=cl+GS-6uRz&sNVtug|;0yT3qRu&Llv-+144-(L!& zg&PXr_51uY{V)4JE{YeuUaTu#H)O$(3xV!HZ!j5LTVgKRQgWtrLFty#_d~JJ`p|_k zYuSdf7sHdo&xX&3ua`T^W9846?=3%6eysdV`T6pT6|*Z}tlVGuX632M_bWfGyi)mf zm8xn@)uyVgRnJwuRP}1rk%&L?Y~*K=-pJv|$;jELFS|QS)59Dqa_FiBFEtjz17z89$w9PE1V9Oe{(~oLHYYm$)!=&#(uE z?HqP-_~hYhhCe_2dTm?n`r56vJ8QqJ^VTh@d%o^my{7*0`j_j^Hh3F48=h*|-q737 z+jv)FPvg19OO0PP_9rz-Yci0mN;cp#xFz{qa!>M5@&tx9CI%hb)3$ruTvPIz#-tGQ z<6N=jES}K0EtV7g?P-VNg5m|F#Q`)t7XOe>u_>*BTA%{Y*C{TDlH_Od-}90Ydws|` z`NcYM<>#^-VQm}FPUu9!j- zq%i5E4umsMhh+$P+-O{H;rA)jZ5tJ{KayQaAP#3iC_?0up2GclJTFK18p1IM14yGM z?(=x4=0IU2J&I`W`n!;}@dzm2RNB=B9 z+&Gl^d-98W$Z(kf<^6$%u`+VAV$$>aquo9_0mRoKbnw5Stm^bP_)f+p@FLO|c+XX8 z5;W#?zUflJo1>sRr+LmLXwK=Lxuhq_IzV$?|C|dl7NP}WZ{?q`99b3osprq@a1 zvCnx6d9G1PjEM@E#9}^3Eui%%^cUunw2wxxCsAJ`r0IlpHjz&BAMNk!-`IZ#^&4m5y3GEKyZtVB;59at6ltk%#X+%Y9g$Z0Q~&D_IRIUUK|OGtAl zbFToR>WR#~5_7IQbDsxI!#$aM6~@c(%)JqJL=VtP{9lfpc?0HZHNp;P$-5A<68BMD zT_`C^%MiO5&nHnUwb3a2UWz9jsPjTvg)2hMi0h_Kn3FF=TApti^$@syv;ZybK+2_{6Q|-bT8morAm4*X!{f44 z>p}UPYUSvG%=bHe0Cl|Gzt1pKU|@t^!#SHcOdZ;3zy@@DH-KFtzUVlA*8;ZJhH-(z zY-mVF$J$4NnLfG14Eh?q=MFv6ySviG9*r$Li8 zgJ#k!Xqx8G{WO>60mik9HbGncYx*%=q{nFsJxM>MpJBY7qi@p>AyZaj>~E%{^fA7T z+cC$!r0d{xenJ0CZ_~@5YzJ+{*!maH>?CA-zr(D32dmU)m?3|lGtlq<664~(z%#y& zdGfF5jnC;n>3itMF3^4{#$^vZ59&XJal!kM_vvbSgf7t<(MONbw`d(^(hm9uT2CA3 zAMv%nLa$RV?V}IqBl;7#wO8mMy#{&Tt5^Y#(|76T^d{EC!}Jt=L4RZ_re+%2O?!ZH z(y@G|hwTstDVv#vS(%O5;a|+jT#$2lm{&RR-nKUNs-CV`tT~ljvOD2pdN3VK#nRpnl8746(?z5rd2OrlH4$;YbrUQJj zWkxUil?J1@WBEQ(H@`w&xDVlX2a1#?jii(BgOW=n?zPQtXfX6K1M=}CzxfS}$CUW> zE6fRM*;m3gPnq7E+B}{A?rhl?=D!bVNRFVE83p^wc+~5f4fvR*Hg~j(lz71Z9T*~6 bRFbjc5KDijSNZfliCSLC>xAib3(@}o?SClo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..27bd7ff9ebcd1e15761c870be657569283299fa7 GIT binary patch literal 57008 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..111aa7505585215254631f85059d6cf83eb21764 GIT binary patch literal 57064 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

Cm9D983rcFQqx;Y5j$(IH*zVCiKNV_O*d`Y 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3236059581f945bfffc66ff9853b5893a73ff919 GIT binary patch literal 56964 zcmdSCd093If5Y4%BN0F%CFjoltS8 zZ5;!)x0Gsez}Df4v*LgvZ(D53b*NrytK|GX&)VmlB!IMUfA^pF?0omQ_FB(+ruD3~ z_TEuiDV4&?sr-{BOl)<0?5tEumvOdk(yWe#wEuK7S?)>q)zkYOvQm6e? zDQoG>xs^3%AMi|7sxyxCOXkiSH}CYP$NiY&)f_Kewx)C4hMiYm%kguhzhv3@=lWxZ zD$*{W!1ZA(*PXHEl&cbxI9{hz;`h$z+^|l?s2rs>e985=Ggg0R<=V2g50pBW!$s>> zt>|1Hc=p;MO1*X+CmL39!u{Xww>Tz$RkUi&xfcf3mQ3RKFcoTAy>?mW8G*KklzN5y zt^75e7p`-7k~)-nyNLAub2`_osNA{mW{#o9DqXjB!?~$1roX4uJJ9c3v#x8!x(#EB zK2&P|`=tM=5?(>Q`B}=%aRb=IVyP?DW9TBiDmUbxzM@sqBBJOrhXq`S! zO<4AwuGOmHjII@DtJ2k-=bodo;XS`lh+H)LeMgP~M(VuyU1uvlnVM8)Yg6KcBE-pgx#oy&m2xjU*l}O%y6+--KAISm3oO@sORZ6Jz0;}qjZC=(!+F-&exebMaOA3O!$ZTQhlO6 zRtMR>uijFxs29{8gWsr~YKMAA-LJN>KB#Wf-&b2$u7$6c>o|1@sm`bD8f%x*da|so zADI2URxW4qWUV%v{T{QPZLS_N>xpK4s#zDAb%R+?FzXd&eWqF8Dr;rdy3*|LGw07T z>y>6*Z`LEty2Y%QnRTaG&oyg@p&`+%GtK(PW*u+VLu9S~Vb%xCnSfc3HtQ_24x07T zvZh|YH*2Z;Aaj1OS*MuwNVAS(ZK*`kiCjfXL_6Kc*Ng5sk!QN{s0_3-D zmTGl7OO3jlrB>a`QimKKQ}t-^FI1y?hNVe8%Q9TWlkcd10S%<-17a7Buon6p*u~$t z{&%dyZLA~a1bWtPVOQ34^lY{MQh%a9)(2U?uiw(I=oj=J{TscLy&dd7r0>_;NOPOs zqOaAP^yT^zeZF3&&(h2FX}Vp{(bILSZq_4oovz?qsV>yHdXP@iKJ8S!>MQQ;Tdq}q zr4)O4f!ysyN-~Q&taqqCQl7nBONp2FE$V>krCfWNL&>s8snJEfOUd@KPIMVQXe&k5 zdaqgMnRU5YNi~V za?c5Vq5J!vw2wkPA>qis^^4pYqMed`@?wweKIrkN7B!KBNoq1SGnK`qrmIt^%V{hr zY6(lKTCX<15ewc(Y8_5J1s*ufJq|UE#mSvBNY%#TQZrfHY8H!2&1P|{IchFtNM4ru zFBT8mM3a|gaJ6vRVG zyE2_rN)U6a9QQbGah%Vx+|lN!auix$TCZ6>){m^|R)(pit;V{l zbc%XI?NXPKqt{TZ!W2s_9nd$I5*Dgcsnb%H7`0rjM4w*5R^!p5NueWZGMEbHfVp5F zcn7>2`bxbQ`n!5R^bfT^^o=3{Q6F-AfUTQUU#fT6lbB#X_<&Rg0DKD_B+rB7d5}C0 zlIKD7w#oNv^8LHH>pdiG=lf|m-yCX&N4PnZjJS^@UL`&UTyI(UP--vTMzv6pi9Qtn>L-AlQ9DfbBF z9x>&<#C9*`zf2jg5NEv#r|q&orEC{vA2DUSNp;xd|C-7FGxa9hw+#RH!v9yO?W>%5 zjjc_=k#LG5NN9PK#?MV2uX6kvrM`h2gwEI#)=YRJHF^!Y>^$KEbUOOwcLW`_>+p%m z@pY5y=i#zN9wryccooWC3v<+|+Csb3OfU<~251pl`Yv?6heY1zzWt&7XknTyS&l8W zG&CNb9l>5Eg*L&}O>lJ+_V2^0eAvDZ%kshHO>lV=IkrH%_;~SH(*eB${E1RsU<1%< zVdyOE?Nt2SOUUIFbmCREuYuRW8~D68*}esg{c+EGl<)zza{xN}JjMv)F(jgtB`&*S`Cv}Vd41W?y`Hg)lbSU&ED3U0OU+9zYR_>9y z9ts_@&-AVB(nEW&XsJb*yYS~Qi{V)4YgwXqNNMleHH)5OCz{U@o0s-A@;M~=2zQ|C zYy4{DC;7bIm(TH(MG{76BE8{fU^7z;G`e-;=`hT9P zj}|+hka(1D{n?szqRXM@`*D&493P5s13f(EM=59$GTsY}Zi|N4wiMbgIuv>X={$q| zI=IzE8Z^bE!Gc2v@yv>C*cPSa&{xRRSW8&$NG8Jj&|k0-qj90v@lEDu^F3PoEG2Qc z5C7teQf;I}DMr)6n(yjw@1bv?#Ma1Tv^eyIP-K@BF7Kb%T>rEu+c%aZni}y8e~Q{v zpGDXihzE!~GGh zrUp{G=#OJ_4{1MUFTcMGqKWLC%>Kv56C>xyIk6mm!Uge-iD69&OB7xTPtZLO(NS}Q zEl0C!ANRGi$Jm9Z__xi4r$7>w* zan_b;UppA~7et7;##>)ALtwW_rn~(O#5Iebu_{}dG=Ol_> zHM&2b73Q4C5!;LW;Iq)v&vGMI!rMMC6Fy>5rAcS6#0G(VAmN(%2AsYnE3B6R!uU+ilqTOS*f2xt3#_>6rt(an|@go8u-m$Ki8*S^nSceY^Cc`SjcCIuV339L|TdH&r*Zvm(b(DleyV?8E{X2rY+Ag^jqx9 zPqtq1DdC?S3lbT96K^f;ODwZD-}Z}VoJmGA2eITDq{E9s>MxM9erhim_#4cj;AB6^ zDg9!+K^j~bg2&XhWkXBljJi{0Jti8@47f*PJgFiB}(6>bRWEUl4r}p@%1(*63 zTOULxWK36NBli^|5xaFxK?bpgiXwO!%i`v$55ATjy=VOd^1qS{~eLe+BimRDJIDV3j9{;;yC;;p)K%P%Z1 zEGr7W5L`6ulVSd$9}L|xbm5Sn57{`RX^5}nzLKfM-xQq#G6RnUngbOD)BSq~PtN}! ze_h^=yymSq?{egV3p%g}{(4cM|!2eTA^ z)3*#=nAR4ku$TM~Qdg(!Nd7YE-K5gQ{P@q}ei*yYcWq3M_g!z5XR7;ZSC@02gE4IV z2$B{#**-GBR=i*#`qqFRk5lc;#Vu5e7>||l>KSUaTEn=TjLpx&=RL-lm3X`F;_Y7K zSjMq$QqANiqjoNGv$aOF$3u)I{Ue#hP3AZih_@I-3y_6p&mmsPtXN+coayRvK{OIjQ*%!gU7)W;CGyR4$!|~PFSfIp>c9Q4v0sw zdutw2_i;cO4rEeuseLxra%daHujG+_FkA_M5{`px%fUd7><@EfSC}J5{s~7^m?H{~ zC^(|vh=L;ujwm>y;D~}F3XUi^qTt98IC2<{9EKwJfkdQk;Gx7m?)c!255K95*UEyDY~&-pS9C;Lq5vo% z_aNJHP)V99^rTN`4yiiM)uTrZT;Bw)BhTx>4d6zw1>6Mg0C$4Bz&3CcPIKRYbJ+G1AdLR=)<1gD1dl&OHg90#Acyz#i}{IG(a!s8%#sB8!1G+$SH~elyXG zEHF@eeNp+?)cg9$NT}b_XVcWiwrLmItFUK4Vb2PCR%Vv=-?8UD&Gn(VJ~Y>d z=K9cFADZh!bA9+P>=_K;6vt136Uk6)yAsYM8qa3D8Y#VSDF%pFO9w`4kK>FFS-Oy= zFU*1G@oERr%tWJ^SF-H}SAna+HQ-vX8A$E_uRIwvMQXo`dw&fc2Ty?E{XA2jZ~j)f zl2b$@#Vgr0aKQsbEuIg{aeqG<{_pzah(@2x`y5+CkOK!GI0Qv#sg5Iw2iiZOM}Xjw*UjZc^Wp6hE1el z6KU8)8a9!JO{9fukw`5PsYN2SNIYpReg_@Tf#<;s;6?mxW0e2>BLJ}vk%R3mY(JL= zk0N$a&E0)95|8lhJpcDRLZo&7c8_qZHl3(;bCgC-7H|R=a03r~^0JKqvG6gG+9ZKw zj#JpCvQ1-~&UO&jGeH(Ll)hRH`=;ebcLyWK05HB2`3Bi92UX->3-_c=v)i*obtQRq zgR8*R;2Ll(*bJ@%*Ml3tjbID73H%5eZl=6jz^&jmu$6OnaP3ZT7uW{w2KRt_!F`nd z0O=kC4?*L@;1TdB=mFco4)7SX9p_Iu_at}2*OF3%S?;5gR%6Nj^~U^ z<>7529I1k9138ffCyv00BXHsfoHzm}j=+f{aN-EH&8N2c)Ha{m=2M%rezkd?+B{Ef zo~Jg?Q=8|h&GXde`QvJ`5NdfA2Cwa8>tgF>YvL9nxELVR_sM-9azBFH5B8Hgy+C-K zNe%3n275>Tx$rm-yXkB7@k&t8*XoP>^Qm5HSq&m4i9JWmYg>p=rJnGRh?`s=|B;6|_o+ysubq&qlwC%6l219yXa zz`dZ)f_B5L-EeC++}aJdcEhdRaBH`TrDf*g*WhvR1UOl2{UUUaM< zZ1*DDy~uVivfX=}KY5Wdh_hKAE5CsrG0n{A_1Ac5H*)aFBEP8Ypx$UX+h~6X4%Cp= z*7VQu8`2LtxwrgR<$F7MolM(pFPn&*6aVkyZ1N_~hBG!N?D$%|YdW=&)-#J-?Uqxt zSnPeD9Y^3bO;0ddbD!M{gj4^l<|l3^BAU7nx$Z-*`;hBCJ?nAEokn6x0TSj^- z!jvbXiHT^UFG{(1E0J9u8WEKpR$ju~XT$somKLI}lKLZa?-v_}%#5NCuN5G??2W$u8)n25&FOhaHcp1C`UZw7@v3(u9LAp2L z-CNN8HpeoPW1eT_oO$k)vO?8 z?p**Da^I=YvYNDOz&WH@%XU5J0vo_4()@4tY0T&pb$$>$1ReidBOSkk*5^RqsODkw zgz6jc>}|AjAGYxhco*yk_Vb!)NH2{!j4E0kS?a-y;3dw=lXowJSHP>_HSjukgYo7! z$?+}r6S=dL^rf^|rQDs*-TD9W-9&;s<&e$OBKFt>BcyO$Y_SzDuo@1nK^N_3R*z7d zuX*0{2+w;SK}QdwpNFX#PZ5*;9q=A_AM6JoK-q_E4*;iWckFmUqH&qik=VVKG-iyN zGY@h6ZSkPA&ns_OhGn97GZWCW82L3a>$v-oCAmhk?rYHGl+Q-BFd~~EYVULhr30>dbo7hhb zyq_3&KQZurV&MJ6!29VVNnuoz7;8D;6wu2o&TG!p+NpQl}l=CBy@*#RLhmi0g zBzy>I=BorGkj#E67{q=C$b|D*a0npoDWlw&kEk^bAhahwdg0KZG<6A&o;w z;}Fs~gftFO$9%Yc2(BN3>xba_A-H}Bt{;Nyht&O)^%L+@@G}tY!%^4_||ZrU@u(X3fH${FT^yA z)m3v(4cl7otE0qvBDMzd-$YqgQg%1E3S14Y0oQ`f;Cj;E4h{E_?g7es5IjVA4}(X* zqo4R#}$rvcw2&V zCgLHDiFi1+TN9uCegpTv&pFegz$eq9P|Iv=V=waBkM_d{N*af@j%U9WNuJ8_QnnHU z+BQ=I*B|T`Z~4p<`zGEZw>K!|Ez-V?Ch!amu~rXp)h^y!*rhtLk*~3lfg_hPLMfw@ z>D=AtjXuXdnAstxh@=LiBLPst`5@bJFb)kH&$b0j024tg^`8V4lfe{tIF;=*wr$)u zlkF_Fv)Rt!{9G`P;{`zCCy98arfWEVCA4;ftH9OZ8gMPx48Co2^JJr#$9X==GrEmT zzD*k*qdvRAli(@vGmYjgNJ>2x8(!25qS?bJ;SA6QH)c}9 zS!`#sox^o`dutx|&WD}_Y)@f(Dx6x1PhUgKxSspEzy|Q|#WIKONCA4clK08tm-{LI zC*Y^xXW#+uF>yG2c^Etb9tAyMJFrK{KSG~ALZ3cDpFTpLK0=>9LZ3cDpFSdnQDJHO z!o4Y^&AHd8kNB3issB6RUHJVTn(;pS`@siD`$M(|fD=wLvV^@aMWQ=Gf5M;1a|U+n zD;jsu*sSp1_AXmZoo!8vMCzhx5~)ks5~fXxJ6?GTC^~ zPYey?4IO)+W50>NZEHCke&6nM)1&B%!Fg|u^macn;yD%8b8IZGqOo{7B}IG?7DURW z$V~cQrbo)w^hn9o(9iLkqyxLS{ivy!H&RxiDm3EEo%*>)RKE0yr@SS4ZxO!$m%RCVJqVVTd{(zSbz%) zaA5)Reymu4yc;VPuoVl~YQ_j;e4u~ZARco)X}Z7$V0xR-*bS}%SA%Q7wO}*&cRZ-+ zZBp)o;Gxhj(ehuS<-bJBe~Fg=5-tBF`gR);0R1eqM10n(Y;EoJ*r%1DsDq#vhp|W*i)BrS^8MUpE>h<8P5shc~!OwCA0$d`<6- z;}4)BGO{oTOV~@R<|Egg@c(NpK}KQIXnpqB<1JiU9l8M>?cu$^U3l{z`iDK_+(T>b zqi-m0E2hzsC)zo)JwOT4`;;dk?5N9@>^?hgpcc21YA>4eGI#~pZyO$@_qmVUMf0Tx zdMG?XBBj`UYx}K3i4|N%H)G+zgT|hAg#KvysYa8@LEf(2AE_<$9N;(^n((_EnHGkv z#4WFYS4nB!*n}=)6P%N`_@u3Rk8PyqEA|j?tW4$|O%#OgyhKg+f|tRo9KQx$XWxwV zurD<^Gu+O6fE67eCjWrm&jI4{zhgyjU`21x&K#tj5o`!yML>$4`i9qB*XW?PZ1M-Q4F&GY*? z?B`K-31wGdH8r8%M6>-j`Sv%p_-fRQ7Yc?dMQ)2#)}VkjEy< zxeq+R{SShNIR7ws1Uw3Qz;>_$yhz@LqvRLyQFeP89vS8Qo7CTK@w|8wm#LAo)(7oA zFHwWc_X=NZ3r@sO?G4-EE+`^4#Bc4zH@(dE6|TK%eAX`PaTmEiPwsp0D!#DqvU?)N zBa-g{D4;afHIyJdX}cuxh^}y-Co8NO5nYQ&Lq>SR5h(ZCBfMXS{g;fOi67}Vf<_vN ze+Ttz^LpUy*Jw>XC8WVAi9B3kPRaP7|A^C*PP6l@er9MeA!?VQ7*5t?j1|N8b`}BS-U2IosJ# zFb}#H0K0YVdxQKs^1UA10B!_Zz)j!|a3{D6Yy)?Ld%(TmA06DOCrb!Kcx+poF$4wC%E$+wEJE7 z_Z}3y&(`KE(&GFn`0&5M*RS9!Zy^FV@GxHEWws**By*e!(y@s_Ub6sEq z*aR(CLSr|$3S14Y0oQ`f;5ul#9^3$K1Y5vO;0|ynxC?9pcY}Mtz2H9bJlVM4v9r=I zQn$Tu^JT;7EHpPVvh53NZd#brqP6tK;FECIej;DEW2}y?i9^`hqYcvkb72=XQJZ)b zTQU87wjxK7p{*HdSeq|ARvqz+JRyN6`4S)aDfay#{_aoV(Qw;?tRaV&ki#ozPG1yG zjLf-66#gz0NY1q2cqDl*McRuxBq`FA9>h{cURN{rEPYjbjK36}t3l^Vp|unnE`?U% zL_RU!;V38GrBu6*8s-YeAEG&8%TDM$M2_~XlGp&eLNYS8FXKd}e9r!Fj_XVO_78IZ z8huSf5?MwHHjgCQk=TvzaDZ3AYd|8NH_=IZ<^UNZ_jjn}yWl0hl!=-;}x>tW+~hZ7@zY7TYDx=EKT}Be7lFO zcy_ygYqyl5>GC{%pDl?U*-}4djtuVcwk6V*FUA}Udxpq7ne-Tr?VI<_lVzY0e+c^< z`a$F%@uAUhwst&3KLn}w#Svdaf4I%#yhNOK3H`=RIVW0iyg!h(?}T+Ab)R>O)PZjx zVL$REh5=Cu=gcS_r7(Vl50Y=#h^NK(3D4LwGZ1W-q6KE8lJjZ$HodF9K9PJ& zM!dwnaE(OIZGRox!7ZA$%z_creq@3r1cujREo-+12g?D71@doA0a zd+zsa@tosn_l)oqd9poD_g~!~yWe%+=f2Io$$h!|Tz8wh*bR`w*k3}o;u&=?`K{~TIrN}GDlQ{W! zfGXZ;s=?|5Xk;r_>r{_r-^|?2ZOq_I z;@OG&_?FoN%>2zZ?>(K${NHc+s?LintI_QP+#xeBG7i9;3KHcFRP;mUICy%VbH~n= z#M5(;xxa&E?oZ}JHgS9(IN3Ar@?NBm_;uGw=ltbKY$y54sQwZ2e7HOVKA3azK9c>u zf?Tot*!Gj>d1$8e0~*-MGuAhN8^OQw236#pgNL~HVQ`|S%bjM1LFPH_ce-T0HbP;9 z4tYaFuF4bbLb2OCGg|{y$3A~5@AukIxG~Gfo`bf5C*grSAw}I;%Y6gSG>~WHObj}c zMr*Mbulw!KQOVbu(rCxNZ9Yq$$EzaOS~w;1ZP#&pJ-7kf2)2Nmz{%#Oyy$P_sc?Bt z(RbXlrB2$9e4bLt=P4EXOq3Ugj>%I-dk2ijNQ}}KpU9Zl;b=VKrLMoAuHXI)H*>7y z*?`6hMbdf-51H)*_AI}`8YIe)=QT7`6Mg0C$4Bz&3C@AlHVT6mod@3&?v3(TR>Va-Z)?_PlL!4AU^`~#D(-iq|fI-L!|xsn!Dx8 zX={+sOXRZ`$QXe=KCp}0N>5!PRk&% z)H9B~$cXh~bfW(#v|ZQ8v-e8fPFjcQt4glY(~#Vx2l_Q7NgsAK+?CjtXJS|+)f-SN zeUSHooV84xC?k_Dw1+2okkZ>wWXC)5tbv?uLCzDvL?H3C^ow622m2oBy+~v%J(2^I zA7k#3NKVem%!gcg7ufw`NyU5sv7h}^p2XJ@xjLfpx=qfGX=)=evP8B* ze~hV-L_sGU5hA5TK;q5hz0pXoQpQ0fSBc{CjPB)-*I;-Z06|a=rbGP)z@87w zr>~q(Upe3C>I2*-PhLO9_1)k}@Dz9&JOlQCXTj^Fm(hXufs6=fY9QZulX^Jdr_a*D})75|(sR zcljnq3$d2{bpVO9<(uLHi^Xer5@XJ~;izEX&&8qOWIxHtqJ&9UhV03=oaDP3@&#Wa z4j^akILJL9BZ;{MC;!>BOGN6UpLNC&iR3+z}x28+^O-hGZ%UxnuQj-0azI;X7qhj>W`Qq$pd`r4X zXYvK}@y6#!8mU>Dq~L4XxAR47hjwbQT&Z&`zT-rz^wQF*8s*QYavW@mic#{uO z!exn8I-_ZYQ+dRM@&^24w8io*?p*xEGn|!gap!Ztl$`>`1MvgGp}2nM<(>u)T|<y&pC11N1o49U& z=flIfNPcN*TfZYW+!B3hRLv$uB-I$?E1a@Sq$OOEd@I6fY#n=>xL>qbn_fzUgZ8zT z%qWc1WjU~OmEKG&?M(mgd%l6+e~UD7mra9wZ$Z4L(;8+yF8XdgZgq2fwY}B*)pA|q z)mE&_;j&O~$7PD|OY%X$y(BLWm}bq&~$)WvEW zxy`YD9nMWT91d?Zw^V;xHE0pT3>n-U`>qRO@KLu}o zI!H)6JK&RUoTpq{zEelt`4bn z>N~_Luc$xsCGNBM(#PlM?OOc$L8|_Lklds6K_BMX#YcFCQM|*i)PJg9^1a(9c!u%U zyg~d6zG40}vBs0MY)>KOXZT9S?|8QSd3q4PrJr^+Px*DjldE{ATK*5fP4t>>q{nm{ zaq=yUnO)CY3)|>9-N_dj?n0_}^Jc>Ryru9{`r$t&mU))1G#tVHzrc`|id| zFS+=l?_7An`RARxp= z=Cn_okW*05krOEBY3^|rPZV@6?_L(U&_Nd5#l2)PZEj%N?1kal>B)k5OIq0D!%X&`_D`Y&|IQ?}6f8R>uv3Tk7Pj~Jm#plV zOloBn_n5!AJ5_z)LVJHnkAIop-{US0ES=TfUC^VK1aiX1bK2pU?#$^f2o(4`I(CMh z%9YfC0`jud`0W9`Y4-MJy=m^k_FpC86*kRl-(hL1Wy$!C?L}N`|CL{k(vvN|B z_~k%N(@16qgKd&N=U2^2U1qL2%?We7j9Qp;b|TJcwQQ$lpGz|7wwIWKn(=_kb~^1V z&5<0Oob%e}F0)gYhEsdFk|bAl;Zkv*LWBK-O9-;r=W6yg$27;`!I6cW+QFG!q>a() zu{a&CbGDNwHQ@A4ecASy=A2)doa|G(^kt-!bC;1}IGLp+S-*@Z(ysV?HsKn~U)cUw zoFad-CdGL9m6uK2ZnXsieQG_s9RW_`M=FVkHNHMX)BrY$5gzkxfPXBV82i1jha|7%yTqvc?TiD(mw>+>s0Ee5KyF00N zj(=H4PIt#L!*R%gG*w>a>hn%v=VXZwC|Q1E$gHn_#||X6H__iZ(oWolVR(U?uf>5o3kWZQqMWazF(HOR6UQP ztzzLq&tt^EV6M&!)3`F83-ipqw5LEx^jo z6@dbrJLftKUyb-EeJUE#-lKB5y93=l8WM|JNlf-7J?@ezvf*!CFwnUIAxe4v&K2g~ zR>(En5&Ci_1`0YzVigPDg>m?~rLtPqjW+fy#@bxP$=xa4{^8wtt;IN8XUVb!OK2AS zN&Z&95p^e;EKHapM;&BjCyc>j&HZE{)T{|^U+gLFJHyH1p0zFGS8NhuZF=oO-I9M#ocW6>?(r5Q=pH9D*q1$0ok*T$AENDhAPI8q3b0R6($2}B zsmPM>fwLI4lb>lCr2-=P=1#1>Gmx`0^yDmjYj3xx-}LZD%a(B4_Uj zSrIPDEM#rJOMLeieFBaRlcK-wf;I21oG~ zA_$B$YbTFn5%o z;^G}dD|Kr?wW34e9XcvKDll2gN?}@o$vqZ_(Y?S9dVAhLlWbxFP1`N)!Q11514;35 zIQs5oOP160(NM!>)s!+^$N@;m;nZtuTV7^+~T*%M<35pPuD6~ex~D2FYesZs#W z)v~KGRjT5G(b#l%_p(kRti=gpp>ZY2oKK-n!>QWvaNXd|CDePClnxD4*c{Glm+Hr& z1V(vcxsU`OpR!dumWxSL{3#q-nC@TEor8hl)HdtjFZmfg4_`KhA(_d27X&-lYZWXZ zRjVLuNwHxojXTEchqJe38Z+=>Aexyp^nqxOaRSRDpaieOA;BtuWz4zvj@K5>xEoNM;J}VKleE@wLX8 zNx}%1UzSU(lq0T#P4)u5=+gI=$IZrAjz*ssZn;fC}3;c zT-pFy8;T`lVc830xEB*@BSTyorUbo+$l+2}We+nIqo*#%QI>Xlj<{YiKyCj@S zYVd<#cekBRV)8hvd)i!>AU!r;Q;rXsqW!O1f+xF7DZE8V4N zX%|OI>!LI%wmVW>&$1JiCR(OXm9_aZwcF18x?R4rBwT3sqV9$C4-0zoq~I_e946$7 z*EW>gB9th4P?n5%WB7^e`8l!EA>)T0mGqw;#xRwyO9g9cl54A~Ym*C-i))huetk>t zf4%aGBkr3oms>K1!U$F9ODo5c@8?#j(alB0DTO*aF=cRyRhcq2#rl+g%#)Il;mt2A zi`Bla*x0Uo2e}4=$+gKT!)sE8SFT^YemDmdQV=Mt(4%!mAVCKTOX};!=#jdDnP2<>|CRwKZdOy_GX3r!v=H6gOwo%z2F~CeAIbEUQay_b;8Zc764; zlY?bN1zDw8IrYWyCAIbQCp1kiDkv#0$!x2vU$*#)LelUWLlI0&t`%DHb$XiN;gCRj zV@(4D!Ndd&-}3duEkaRCM@2m=*6({?Eo&NkQAfwcV}}RZSC6VbwPs|P zEOe!aQJhbZbGAxtj!BE>L(W}p!-_J2_xxFB zXI$th8&Y0TQC=2|-H?4w+)W!dZkar;E`Qj_nx)mFss`6gYZZO6Wf84+R2HV*$RQh6g@8cx_BmO-}=*pwH$BF`)sJoRw-80d0WRahKAmOtFD7ucQ8xA9g+Ina=#s2OrImVTPE~<7#$T0#erRTPlwO2>B&mYtl*G7LFGJG4 zF0Z>Q&gyblQQa_>h2_>$WWz|E9!RfE4;8dbBoEEp^cmX(z>whhf| zEGesT$9ifDhn9PsgJ#s$&nk1d(uS6t;~vyjQ#Ui{c6rhhQqyNG&dBs-#pUMa>h&ds zae?CEKwM!-lrQou<9Nn88=G@yIQ@C4_4TEtaHgiFwRO~}Z1|FAg;`^aasX%kId2e4 z`U&__Jb)9a1Gsb|zSIqRq}dZQfGfR!4D(`FAA7!)EuLkTwKIQPv?79 zs5Q%af2l{);1p)}K4g60aMpjY{#%|nRq4(A56}b(aCarz>McA?EiA#kiQ~b$xgC_X zxqRr*a&W;GYvQoMgZx8>`UedjcJ$X){LwEUng5#9Wb*XVVaaHYi%If&;<}RDUE0pn zGRL}~V2CFjmW(^|zDJ+wIe$F-`1QKZOP}w3{DY}m>^yZ2d3sd> zEi;Xh@qxHqn#_`VVX)3=nLcUD_^GYdyrY|qe}E3hSbSEAs&3AWOUukmbmbQodeeMe z`MxglNOXwXvce>V8)UMQwyidhT$^s1l0g~vroZf>Qha9%Qk*!VVS2J7VZo^Bv$q6_ z2bE@RDJn`Y&9>q@hK+2fD;r}=wYJ(wRgX415k93upV1#zab2(&yq3P@y=ZqYA&J259R#jCNtgaTP zJpZDWmW$@kyQJm3#_F?c8X9WOu5L8CIx*Bld!1$4Yhs8LPkOqO0K=o=x@fb}F4}Af zeEP=_=$XytKEn?rr$z|*r!Dq#)|>5pEq3n#T{oc3mbSFX#Ae|b!nCF7@#(}gP$g|? zSo+eA+LCuApCGDn{OeK8bK2iOt_kY8fq{*&5519jq9m2o9Pf&Yi$ND-tcW5Gkhvx8 z742m@(ssH1jYG4GgTr$z9Bo}ecVgjSqvyPDruV`3Vfdkh0De3#FWrNv(+#^3b+WWi zRn-}VB|{qXiH;hGDJyk_W#W~59S&G%h6eMW%0(0YHhtC5Q*tK+%L=n{hmV{&Jrm%IT-47H6N5m6w^8p5gMw&YVy) zs~o${K+zXlxAUxQUUOnfa)MioHvv_!x|oQybwQ#h+l* zKUlSDN!v87H{|6kxOPMDJLEf-cq9=X8r!jA5)wqBzOGbDIBAq@KxAZGL^x)KANpcf z{KRqi-au(aFlS49>8RDq^wi#Gs_K#pv-Es%lavvIA4{f;7_7(Vf^KhANQPoZ?O-j= zORwm8=!S9C-s-VeTk&uF<~MI}Zz{g0!O$0IPD}OplKAX=SCX$Q-c&v8X@fx#4nDj# zl9ipIBT%UWiTeHobkJE;d*$`FHy1mKnki!2wl@SvzxZI&CV8=k@^}Kk zB4VeVpe>4XI(cfe%jfHIM=1`5J#MO_Hlwz&)`7zfcv2pI@TW_ir*D1a(VwnxcIsGd z{rJbdfA9UrgAYocZfqxsJd;FriLtS9F)`jav18&ZVX<&nsMlndV9{38Hkx|q;WN5l zh#xwpe9^L$oj1-XaqG$Lo6{WcaXMX=*9(VTCTGJH z$$6-**Ma(ibX}l7?47J#-{_zA{zJ!~r;9h7+smtI^3;j4!-*4^eU32|~7hX@iGlP6L zkZ-CQ%(HHl%{egz1qagfwzQr!Yj>JXOH22s`=x)VO&Rn6q?E;7>xlxT#V@ECB!=Wk zugSMeSHzJn9X7jX)PH}5p1WXaU}RBNLsfp|?7DX!P0LG4%1e9n=Cr)T#Jn_X*V5Vb zqzb!F0tuQS&QD@)D^?Z_vtZJ}_bFAO;ln0)EP@)ORT~hmS-y{-? zpQv|o#%WhgpMKS8OTJt3z4*~hEwg90G_@pMGUT$jS$Cd){=M^O&6`kGf6Cl-bJ|A- zC(mpbeX|UIu7E%CWlSki^SQ||FHGAa90El^$hJ<+&z7I_u{o{%f|--wZ_G4mvE}zRnz*c|GuV24@ z@nWMf$pz`*#VYMB*6;N`q0@W+qFbz8S8VGVSQgaS4nQi_0dc>G2Fu0 zEJD?jEw9V7c;>9jr_bDc+TtsRT#__lbY*KvNo!@vnBvQI>e{+VbM89t{CnojnKfzX z@ZxDB){U4}oLNyA_-^mTk-CXoVpV-}q33|5H(S`5taqC~%W^qQFc#zNa@ppAXOJNX z{)9?~IdHwK@sgkR9=YWf?Yl+i_a4MH{;UTbmH*LDLqQu&8Q8ARf_ZL{hiz^BZC5&7 z1;&$a?EU$|g?jqJh2PQ7edjy9O~wagh5kqXk$h4_gHlwy8y9X!G8&XxkckV6&sd*$t+y;tQ27QlB{eUpU)`QtRynL(%NHC)cOD|o1o4C3Xl-Nr*H%1B zfx{z&F`j}0cWC|hU!8i(E$(G^JoFcyEUG%^X=~TBS6uOI??=5WMBh!m`cv|C5q~

d|)X)gno*&-VB%$hUfgEQSai>3NA0E`)H|$QJ8rsZ$I(`O+Uk{mJ95s3-h2AXn;5S-iN|C;ZX?}z zXBXu}f-O^DMnXigsf^NyY(LuF;@Et@tn}2YZ|Hp-UjFCRH|PmSSnON;$t~!2BL8m+ z>15flqfu)ko#SK2h;xe!v*;Cz#H}o{?Y&}LATX}N*nHfayU)L1+nhPuE;#@0IadsA zYh2YdW9ZNsO{?gqnzBWPXT!TVMjL9I3lfszbX6QrA6yr=EzUX+rIAXV9=M&WQFQR@IGoCu`^t!s!$L-jb;ZI5NXIQ(MR*WCNe7GL*pGkuUm1X27 znYvFlbtp-B$i-v!_ly zXQa)Irj?W0`mtlwCDSM8C3M`h%I3+68y6-v{qh*D*#7oBqu1GLRC94`V&Y7t2g{Rn zk1FeBULdo+i~$+cmrCbUFGvXV^1s+-Y@V zo3H%YMQg$g+NM=V!KLLbt!HQUexlW75k6_&b^OvAi;k3|dkMv!jKuu>42e54B5D$j zI~f^^*f3*0cHn7`hS(!z=?uydd75@oSDyMG<;w#dwM`B2Jcuym^op_P&6#;&a7ET6 zy12>3c@rm}Rx$RXxaR5=rKJ@mse{vGe5}{8(TMTRLjZ{ zwk%bHg`uApFCwSK5~ z`5E6IJNEl$kh#W3DeDsQ?u##-;YLmJE(kcpSQ}Go^{o$lbla*%NByds$ZPw1@9CN5 z{ybvL9zz>3W|}uPSz^>=!fR)jH=0v4#_Y?_&eehN10H?mBX|C2e6gc!#{F$UM{&z_ z_dMKQ=m>9cy1^c#+eb5M#Mq4(fsn3#+kYDPAm) z3{zMf`ubZxec(sdbwB=8C+nX{)6`33q7`wa)fCPr-Yc?>iNP9P^c3r}5i!H5wTEGn%?ORGR* zS;sB7yQ}M-`Sb7T>biTucT1<$f4_cOX?SCJ9H0&ps6#9~PV~7w9@ya(Ux!q2>G&Rr zHKo@?d`uhb8PbRzTN8S-ACI|q$xr^c{oAUO^iV=m?b+O+|8n-FVvQQHy7muA~~2odgeaD7#8cY)&rqXK4LorCw6GuF^VCsVjMy zpgFUqX|k$Js5wpaZclnR!e+cV z>}F)RKd7G_*Hl|mHf(D4X*uKDM@?HhdS=70aV0Gm%$&4ze8$?02~D-b#|aj?AB!i>ZQYXpAsT2-_BlMxxHyL+ zHdgG;4yFvz2*dc50(?rnW;Z<$%SIpA-20MF+R@&A>-Jy&@G|}L-sW@lt-bH*Vt7>n zuWFHb5xtlsuQO@z;7lviDoTp&Dl$S3cQ6>Fz+a^ocxuN)#Upb@{VHxIi&8CL?=y+s zgrcUhQJo{kT)3cR$++}28O;q1jZM|14G9-#Tf4ruzn~_!ep&0JbW~uo`O6Yl);yWeFxr}?{oznuPFDRr$vS956d8`0OKH1DusRs54i>p(#B7l^8BASqxa zaUzggomUHOotA?0ReRFpy7Xt&!&g~RgH zX~;1!#%dgrV2PSV0v%U0q>4m)Rz-hdjw~O(xMAG+Gm&xcKl({jZ=do#BhRk!j_m6E zhR*Sm&lx-6(%X)esJsXNrL`AYOLZ|4 z>@ol-T#%VpE2q{opTE%h1a`6mZ19^909vo$|I z#~GjP&q&Dir#Vxy5_+>eaWQwr=jNyQO6Yc3YINudN1OGm8p~7Xjm_C*sY42jv!_hS zXf7-q>l@pgnw@Rc)%ol|)eK405;dWh_UIqZ34ERJ-pqgWj|(X3EYU zvM4lF&$nv(nX)}Iwe7WyKR>5=!Y-nxeq6rfgRhAb_ z?ENAuYwGme1s9F4nAbI-WWu`HWv`^eQX-5^yoL1^UDjPX+Azdnvai;vi5}2C82Lr)9;C;bcF6o^;8$0QEs(E&5)3I`+8Gq;xqgXJK$qA)E` zULHs*q$Y~z54hJchI>nRpF;k(?j%|T`Jcm+tZC-@4Dvo|Wy*D41>wB%nf`83XpJc| zStb0!%ct8-sUqY?dp-#3)u_3UK-nX!mIrF$3k!ljqv+A+7Z;2xbqDjH#FSA%UUnH% z2b3{E->IHs?!`eFT01S7dZBG5{G~tn-1on)@6@eQ2h!=Ek*>tNd*WgJk>Sf2y;FNR zFEsKEH>R&5_uErfv=-VUlf-H6E#*VURxB>4jPvIY)jNj{P9M2GkUyrxJv2LOFr7o@ z;Pun$6YCR`m)D!3Jkh+4t1r`3(Y&lr$ZJGbAb)IeI4|fGy1p>39o0$dCD#cU8O$bJ(Fbg4PIBmffI1KV#w(7C52US1$je$Im|z3#5qM$=&!+M?_c#?f7kh@(H)eR!b!>3guHXgNEZVkl+oH=Y zp`(Vh8U53a)%8OfGtxaZ7hFK%Yc97gm(->%%zf!^NL#{ewv;!IcWI=K#cv^8S*%WekOHL^a21?TD8#A3MYsFLjA7(Bqn|DrV5@Sj7|Eh8n#KMxzXtv!RP0s8ywBI#+!Obh+vyOC6#3FAXC5gzP$S8$ufEy1%GzPl zr;>g==?61j5tpx1lVUQxSy`F9Emz5%B7|`J7%;kB5~w%xkoGepW-vb=2G#2EYf6VV z4ja}uymZaPyvp3%%Djo)b=5DHm9MV|zEEAa>TQ2Rk=yAiYAAR+N@u4jH>=q(anyFNmE1`+pqUa<06fI z?xNneOd`oE6w7K8cI!}azaXZz`}tMXL9J`ATx}(<=92i7XgarfD^tFC@TFrb`tQKr zi)pPVF`Ab%wxXe=qQWZ4i%WAlbzBJ(!@jOK0v6MmFrx=X7Q`&JGzx^EzIY`ZJs&sP z_|H=`eqaCo`$u0KF>BbcStIOqazCSMO!z?c>I^m)q+FDJA)J`6Vgg zP4DuPT!b(1xf{LnB7ccfj9%k|&}r@6xDjoZG*-VfyfeMsCE9NWrqE+M<;IQrdVR(> z8?}rb$@uK)mIvOBdo++08?S)}#!{pV2fv zx2`cTtf_Ns{h7fj*`v!!YEw({XAI|bQ()Ml_<(=9H_4lv>aUvaP4*_I#N#FWrR~{y z{=unPsowOY%BH;F}h#0i5#XItw~%w(Q$@g=A@os=ZqRnrvMz4MIZh|0(mg)Dy^xumIS$w<9% z{F03uGA2n+E$g_^-SbsRm5Tb)G$9&0`qUY_Y$&WOElu(k7G@nY zMoxqzfwvfVzusWqKykl7Iy_9=c#IyN5`EjR^HLkamUJ87f+gW@vNDbOqz7b zg3h&TJEyd#EsrZIC@3i@C>R#MBE3DX{hqb!?rv|td)?Z5+M8Q1m^xwV1+C%Dobx-E zo;S9pD%(G#U{=A9f~?94^D3}YxPaH2mWD}i5@9@emUJNnhq{Hy}+ zl+l&3ah^Dwm)J|DzSXKhA3V${3~$cM@TJG4B_zki_+qrj;c)VZrjub)pR>zp`ymmX z_#Y!Z8F6EgfVUpf(*qe8rOcw!&@?jq7RcDXpyj>sKb^d=(cLg_lGiz-%^9O_{r%|C zzt;t4pWXZ6>8D+P{b`1d3OzyJWIf5ON;z~eo>^dJ`ID=Xo0A<+Cclz=AlZ?eJZKPO z4-C~+TVq^sdY=1TZd1{vMgD~l5FGK*cfBCPZGkB;WQ3-6L3r> zzrkS$3t0{mGl`R7oD9Qcnb{-;h5?e90fw-S#|Gmh%p_#7wEwF2T51WKY?xW8tLwe5 z>(#5{tFONLj;eu{KrEaxwb+()EF8Y3u`C?)_{!-6eUTb-S+u;hp{3a#340y&;fi^^ z4-dPXCI{A(avLU4_CqMU9vqD~XE208+lbF@SMy?uRpu_Z91Kq7y> zVR>EM@`lFYy1L;;U)<@8`@9LKGeHOHMw*&N>g4aMJ(WIx+T%(4{FNR)AJQ>$R?Ktq zI)`I|vd*z*zja6F9dwMY73+gB`gcf|z|JC^r{1NkbHZ4ew6gP#nTKxIs>(zA2Z@w2 zm-68W-H(jR$~OdIz}r|ekXeMR2FR=dVv8UFNC(S~m8~h;v^ThEZ`tai>z9s|9eeW0 zC&!)`!?%d2M&aSo)X6epjOk8DLh=U22Ez-A278ue_8(Fo+WF!EI?zg06Cbx?!HqU= zB(JgSOVih`%A^in{7-xjOmA z9pN{xIxHr;(X`xGZnqj8xbR{z&G&zq3wzBt+SK%{9{VzY^_8araNcMM3~aOj-G0mC zkK;4<ToqK3ZmTb_m?4YvjTJn}XZf6jpHRn%mX zR8Yo|N=_(6%L^ud>Q#0wtWaL}IM3_kf8YPjZ|eTr#6EiP!UaZE zS5fJKBbpY0$<1+5(NHyEWz68tqI?|r=mqj9##N%|@3}$n=t(?H1sghLMunVY-j|@IA z_*7en_@%xM;75BZ2!z=)Ks6DzD(3Bi#R?Eu35EX&VXy1 zF68#b3|n2x=YBI14n_0V+74cK-96T8x9PevZLV_d#80#dS9_*g`*yO`NNbI)N%Reu zFR#EHli++@G!zJtfY<9X+AVgM%TjDT#W^^^*~kq(Ek5AdF~xF{Q_TS7)T2P17~MF2 zaLJN`<2_fkIsU)ix^gho z#`Qs5R^Nj1rMccN71f3?fVDa-m285gFAQN2Sm*$uer|vOw=r9g>K3fLVAAOpr4pKl z2un|R64i`d->uPi?(AQ-JGmp!R$kXos(#@J7IQv2aF?~S63qAkXU5mLDm$lRNqH!n zu}7^3G{(|~4ihXQ(YN8rr&tDlQhMw!3~g0|GZCz=PB>xxo`7u&2D@w{QPBQ_N3vF+ zD>YWP2D~9uRZv~=BL6{KfbDssF>f#|?%c4@JLYKUO;=}~hG?XFcztCyzh=dr=(v4J zdoE*%1(q&YQ`5X}=uQVqI_|vFap?_5by;0t$WiX{CmrUHS7Wj@_t!P9Y-~ynIjieJ z_JCbuv(ztcXjnT(&<5CRvi+F5+`tEqOKsDcolY&c6;SK6Fc+|>@&;agg2p=TGf;?6KQ#zkRHCjLw;OpV}w>W(-_6 zgIxh(1F-M6kua&w`RqQc@MAFxrMj=+$^xGjF;KWUpZw2lhWJYrj}~sU6|>SA)J*Yk zp-b=oOIo6s>=&F}F8zjnZDMn=djR+v;m0P`VoZRq!;U@Y-bOS9_fNs!D~2;JTa?qE zEru86bAATAe=7ZjVz`jE%K4n(aLgVkkJJH~o&mkGgpB7J<|dmn4dv~ne$V2?)DyDz z8tDhnBxmMkm^l*?3gm{;WJ7yMpHJ%Zy;8m%(m%ln$%B_$2u(3U7}Vm!?KR`YK6TB) zHA%3NZkg2(8djk-oaonf)@R=h%Z^R-zq_W&)4*gI*StWeb8o6C!BzZbXlQlIypVOovwa^WS_5h*f zK8@DwYL36~Jo==^8aP%ldkE9jBJ&-Xjmz^3_0Do<^KMuh z-ay7_8ee}i^UiQ{TQr|lsw7o79!*+<<`#q1VAOlD)s6MO9XH+2>%r%b58;RPE6jMpYGSC-=R$@)dLJ<(jgf7#y7y0P|_evii3mKx0t#s|~y7(IsCFjqi_ z#W(v^M^*ja+ZJxRwIjEG2!;lY)lz0O+1QaOYjl;`wU-|DHzt}AjeaHKV@mkbi7}!^ zeKFR!$@vtVk2M+oUNKyZH5q=k7+#dmc@762!1smxj2SVCWSJdjAHf}Qw-&e(;0L@5 zX=q$L%lRn{IXAgM$nkyx_%ex?L59Dtz+VP@i70~%|2GBx5r0=vKIb_cazpNC#LK{a zcCas>;**CdO9SyNcQ1-*2jEZi;%?cGNYo!?0w2wFFIe`W4bJc_)|7U!h<8A6%G>Mx zVmL3W41cc}F3Kvy&lbar@;T4pXeHC+PoW#1D`fSJq$?NBMh$UqtvPClx{?N;mU(Nv zu4J;-r}epD`5?v-M>z$}WKuHq4K`Hf!)CBxiZv@2Dt9n$<_Q@zm-Yr2_kpURYX*B9 zPI%lTx&s}9y-so2-A*VS0B zfeTuGiQYh$bT!tAn|{5a&~~Ct<+eDZ)EPJ^tpj~ELMD%R7|% z6jvh}R>)@e_q(+bg?c-9_q#VY1~ zVfn}nT`>Ev=sqwsIv+fNf(7XO|vY9;g+^yb}m#<7EB2L8uq8#SIk`#T<)$5_#>fMD4O*z z?rFx~{fifO<@@?}b+&}!)=((a)cNGd#`204eorvq^Xql`cx$p_(9GHz8{00ukC|LG ze`90AhWYEX4x7iR*IDO*ZpuDbQ@+E(2TSppHeB9kntS?X<#HCN{U7ClB|TB>H2nz| zEa0E;)e&RPjdsV_{XdAN;C$@L@b`-0V(iNBv&HbDe9kLy;in?temVbN$ob3uE1W-( z;b#@N12pDA>1DuSL5cEAz-W+{M}|LFfD2>7Y53s+oLribembAY8SwWD=_#E7zpnr% zA9FbTF+t)aZc}N7$n`GJAr16$`(kWs6(gfSI&F@TsyG2REs5t0&g zq7V>r4u{X`@HWuI;B{U2r?4ia-+6i9C>>#QCZ2=2`;O^Y_Z>FU^4Sddcuf4#)gm zKlvhT_J6I+-{2UtQZ#O_wbT-yC23Sk(P+|`lzb2_>hrR94)0q&gBO4J4PW6;$x#Fz zb7`AwwI|8uZdg?F`C4HcvGV3qIo*`*4>zuw6HCC%f9mq!>e_kb>JoNn$ddLHF0Cnp z%`(tFR(MndW zq^BV(;MPH1xr(sCtFqtcqCXIyw_Ft0E|=TpwwWP6Q0en#EM~ge%T9aTc^iiWg3kGy#>EI19!P(f`=EJhz;souFglTr=40Mg^)SSm>Vp9H0gn7 z>-{Chw(3B6X-OG8r6>LxO1h|r`?td5)nns(RC{R;9aVHX@bj^&4aA%m3LVbye3~}r zd&`zCUB*MuZVv_=4%)MEd~D;!vGI+|qp|*2B!VwUu~4s4x`@j5?CzYtq!b3z#8hO< zj<-NC4Ec#{$Bt>~{nhF@JtIe!(M3yN8Lr(uq4>DEkWV+t>V^G=$-`~gvH1uyDI&1~ zH;&w9Te8cOk=jgY_rzP*C4xbd*IP1bS~9nH@%*ZG`t{+J!@;l%wkv*HB;As$&(8Bk zP;M34rUUP*B-vcZN$KG%Z@gRUQ^g( zEAydZ{#(R4Li!9-^}SKQ$L(@fS~`6_4GWf5hZ0p+&Ce}x^y?F4ZhzDtVl@`M%H(o6 z+e6`&>S(1(rLKx)+p9ut?vTsItY$u*tZ1JF7*E_j9a2Lh4Ah~5kV>dY;TAB`#USTu z87{Meu`6b*3bZ`+u7C7_-8D7qHG_1~{E^q*>hIsRi}#%eZ7_g5!#qco-w%{@u&~jb z4=WQS*Gr1}LMI8Ubfu}Mr+r>Q(FD!3@w=K5eJq(>G&C@0fo-v&JmB;BT+UF*u$g`T zs?DLq+?KWhRLK<%xKwtxRjq#&_``@>DT4!2(Tbc)PXd9%=40mLX31=ZdH@J3`??8I zCRQT~-U^#081oiNHB%jVqHkHcD|TQ1DxJDCUQye2^DHF0Lo!?aXz_rNr`GSecuqtjzJ+f_?d4ZNG8z3b-vVqLH4jtl;BY z5@T(Vz~2f;HQ+%IG1@g5*t=tDLM7yBNmPQ{k57+2WgD4d1M*p#RmiE7cIKq{nFY(z zu|Rxb@4SWaKrFRvfg67=kH;lqH=1)!CQ_|c>5|x~ptUlb)Ty-9k<6TT95BalIIN`> z$5DqxXK{!xUUtYSQImd@UFid@&FOr|@U-Cv2FZZkVr(UaQOIZ2jM4{+F&MX-#*Cuu zj-;7URNBwGV9B^ntu5zWu%K_**UsR!+8-{C`Bav%p?;Bbz+F$g9Tqf`2 z@98Mr4>`1#D6|WP;FPb|1nt6p2V#cu1w0_2o$yr(GBXz1Y`V4?7ZtcEJw^ zo2pOwRdFAj%h0YV%0&fl)s1q_q+1Z>D@B#12v}48add0=vSf?XEwoDq-ZxFRj-`*M zrHg4ggKl916j6Wz51`Pk=0ApRCH#&n)2)y{`oA3@Tei!REsITMwF}&`qKy}zjXy!R zu*wlFT|~FwQ*#BnHBGey-9j4-AWuQJz|*VMb~_D0MG+D?qRvxv>kp8v$fwaQI1Br( zK)67+lrmh2Zo$D#K_rw#88ZTBKO@~ze;V21bPEkrk2X>07RHLw7MyWX{9+1pOQu?& zS*XJzfx8OL%7A7?BFUuB=)*$PIEBJ|5)d>?9(8}1W=(C&CL~3dr&%Cd3hh!~o@6ba zrc)qU1?t7=73$iLa$kvF6-Hk%y^71mtDlixy>n%9g>??-h}4ZaBY~3)xPAXdVaKO| zd=$6J?|e>~{rf-B^Wt|Eo=<`PCU7c({5-cO(vW|m?;xf)JBhGoum*ss+h3GIc_cUz zA^gr@0)#lKxx!2<=<^>8b|h4)?2jzRV#+*Q%RiS_i)25D8tAkPOfv1d>Yo)4Bv z^2CZIQAKkI?Sj_QxaY$7d`3P7IUD+C6YJ${kk1Y_O3y*&=!F$o(B-ll?MAg$JE&f( zhN%i(w3E;wE^znP@G+2EFu$L>!Vqu=K&pF>%8u>?@encW&8& z_GBpkN|gTt$VUCJ7IA7I{2&>5C&-yQVr6IO+)h=;xLZf;X(eVWVR__>&-(iRov@qCh`urU$H?ZFvuY8{4&=EYdXX+WzOc_FP z-X8BG`59Oh(7+v^-+(Pb=ZI&vv2J<+x&k#c!8VmtLdf9~dc1_1OX%qm3X=@}pwR83 zjk7R8&{SNw@wP=b+^|UUKh(B+cN=p5&*y9R;79?Dtc3CZt#%y`h4I=e5@wkl}hL0@mwn174P$V zq8`8a;%)a!mZLXIDbQHs2@MuFIbhBi>+X&>3gBaG9xQh1ZoS zV{wVCn1$On#w5ofZn!Wq_>%dV;PC?gq3UVz!iRR!5 z^8?Lk4FkvTbmkm^g-$d1LMAKp#X`+j?1ay?crZ*;*kbHY*;cHubM@E?io92gv~$;t z6jXMgSMY`!C8sdHHRQ|$=h8WB3)@K^#5mKCUrxcg*-E72el2IGU?*7*IRZR`#%T(+ z4X}j*c3y@-e{>gXB{xV>@LtKBjgptN^rDvDqNN!vHDl8jcx0aNg%)aI_OqIk5N<{< zDTbw>b?w=JR`GV5e0s8hESvmsu|1D-#wf0e$Ey&29LM?u zV;5g|PC}jg$SH~2vvZp&c*J`3HWikkJ5}_sikemQxQfE2Okg}HrI1M2G0LPFna?B` zhwoUr;m|CiA${q1#q$%l&mtP)_VVSx!1H=Py&MuouI5w9p}Ql$;l!+Ruzxw;`uz8< zP>iYe;Qn{KJyJOrZx4*KE0u(g2)?S3Kx=`HMTS>1+vGu7Kk?D5vPiZqTmI)|i^Auc z-_vuDIeMWfjup){a9zq_m=Jm&p{M!xLxf$;0bF8~7m}m@f;s2+Q&Y{rp-hLx z{DEEG_yi)}f4%bl&oGsIblK`7zjxWYp!F}KA9N=`i`(X!>)Y>6JO(po*RbT64<&QpwvTY;6h6l+d|?wnHi|)ArT7K0)1| zlc_tOOOLYCpv~_K+MLRHIc-L#pli?>5Aj`0G6a-E=@*FfK0AdzU7*nE6ex7S9fh?l z|H7~l^yKHDCx64&i{JxDNSo2JtF`nY&=N6fuSh_o(INuE>A-vBlxmBD_mD^^bYQKD z9^-OK`jYBxh${aJD1oZx%9P+T#pk+#wa#r!xc=(wh^m2^T$=Y+AZzpkYC!uD`UdB2nGmRT=X8Lm|Ay!+)mdEd^`Lt z+JtsYdx!P~ok};LJF0sR8sirIO8uAhr}Xa_)P`QeHp62^i?P#qm+?*GglVVgN9G~( z8|HK7k1Zxk*wSeku#8!5u^h4-vpivW#qv9=%4)IJTRW}Gt=p{otY5Sqx4vmh+V{^`R$1CqplUUJCtl z=xpf2FbNyNo^W}%Hrx@uEqpH07U_-*M8+bUBl*Zpk%N&Vk%uEsMZO>TQRJ1#o6*7O zs_4m>3fnpaV#!zowtVabNp>a&l53J*Og@@?FQra3r8cK-NqsH#TI;?J`h`plO>6Z)gvq?^Jp1qrDc=9WnH+=ka^NgNDnw`5YNox&(Ztw z+wX{v-b~#1Z7%Z>t+a^PSuL?KoZiUPq=MmecI*SXgxy2x+4m4%jj#yeH11y|ORy-b zXBpyUCjqmOB~lhK;Oy@KKdi(LBi>08Y$5Tp?~p|}o6;oxgap|)h=tuvK+W-27HNrWhSnbb>nk~#D%q>=TLa@ez$vnu#NHIW|aR?>sh5|`k*9B~ab zz_Au-V*5~o9xzo7%j za{#~ZA$Ej%`ePy?l;d=9JrB}P0sAsJ$OHPPo_&*qBoC>@I;Mj#CIO1fRgK0zPq^niZro3Z8MiDh?d4#!#m` zw%@!$CxjOR5wzoSZ8kw&C{ z5A*>6I7`SrPAjJWKt*(%wont!mM(#B3by`&ja~+4W)j+M{u9i&_!V-ar}&kMTZ)g- zuyRe&{%Yl#pSC=qTubng_mqxa$N$A@FC?|O~Qol-@)@VA$^N{O)Fp*T%}xR zrt`#@)Im=q($jjRaVghXT=R3pkq>Ra6(K`7Q}jBrj_knsrF#%>$4R6caGvM&Bro9G z$yI=C#BbN&Y7?%v;ELbv!9UJYM(r~=2XzC|Z^iu{T-P9MMNTy$mo4~xDfX}F#~Zq# z?;gZ2o5%q&EZ(}CR0DcFU>kAv=_cgDpS>RC36m^xs3*119LrCJr=AT@JsqBUdOCHx zlD-gM`Hh7)UpfCCl!c$f%4;XrJgk(d6(uY@$5Z8zt_H9!)MY#3=_huJyG^JWuirj= zZ$wIEu9`fByUYAh6M(%G)@*3I1!N)KwU|mcW40e{$j@8lr>zd59hL(ZSD|AQ33VX}D*}9XN&e9-KORNSrJCC$t13!iagh3%Fn+AK?7i zi{vC)D?}nVul7#z2u`!TjcUm4kdquCKSUo~i?-bc?6{5`Lz|yNn{$lWMP3Ky?Zauj zdub_f>jv_9oW%P%oLTz?@(j+oy#Oxr7IG_2(R~l6RlCq-X_v!c(FuTmX$3i&np4Lqs%!Jj6` z6fM|-&qnRk0lyp>QHyFG@hIyJlU@y6t;lMZ87C z^_smqw$|6zHHx@4E8o{XdusT`gJVUdgXT$*)DpuSLnPMai#4&abXj&Zn+5YudPVQ-0I#t-Ix_ zSvG9VZ`ga?=IxsfD0LOp=J{mNJ&~XBBj{~1w|-Lp`p(>o^%18(;YhrT32B q{-rOGV0Yi~!^11TEfHMegiO}6Y~?d5>E6+9P$cDFSQQuFc>XV1a=1qT literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Inter/400.ttf b/backend/assets/fonts/Inter/400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6d53192d9070dffd0f7ee8a97f666c356e6b0c67 GIT binary patch literal 66912 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

bXJoWy;BQ8g<5ByX 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 PQ`w7TVC_-yLGJ$ml7{B( literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Inter/600.ttf b/backend/assets/fonts/Inter/600.ttf new file mode 100644 index 0000000000000000000000000000000000000000..663ee5432cbb25ab3f31a40584b56e765827349a GIT binary patch literal 67144 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 z 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 HaSiw1raq6~ literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Jost/400.ttf b/backend/assets/fonts/Jost/400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d222f4cf1890845694e9968673534ea33ecf18fb GIT binary patch literal 25680 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

wW6VKYfo{$(erA3DEaDYm zZQ_sJP5mo?md8%SSqQSjp0RIbuGwzKEY_So7vS$=v5xFogiU%lbDf2i(obftv#}n` z{KhO#4xSHWu8Z*Pp;f*H@&bI<$n#LuI80iJ=lPlERct>X>~*^X`|=I};#}-;JA{#F zGxqFViamQb6Sm*&9-j7K=iUa)OhVWfcOCZ2-HiJ^xDFz1MwuX>>_Yw!_BifFBxWD( zcOypt@HgQ3Rmd}h90R!9#mnpfMl*?`WCB)Gc&vnZN{R6tjli@A5x9+Gd8b03j7y=R z1GMbK-ocD&xkhZ?j4)4T>{z@BG?~X>nxPGMp~MzET?c$-SVC9fyouLvH}2hI9NC@W z$wp4uCgfX-`m96FZQumkDVXhj%=oYi?-;br$T5s(0bE}Tsx}~>LMK)lmGJ;O-E%k5 zQ~`o~VSJ$iW>DY6p2RPbM*(jg=8#Tca)Gj+0^%|9(%8PuuRxk!#-7T`-p4iA#W;dp zmtVyW(cc0$*q+XDtX3zmU-IjO?Zvzak=+g8agzKAdnE5Ce&cDe2JEanzDM*; z*m-*&xfx!=LB7}WpRup=O7ba4{eNPg=y$O$^*GzXnu;`wX44!Y*5(bZyK0YowP*A2dVa-oMZ>YoxsIK?yKBOkb4}(P z$(*B^GtZBF@yvZ~=A6i!6*%?mJ{)dW&KwTzIh;tOCZ5%G)vg_Krf{cxZp)m*VTk0D Mbo92zvOYup59~pR?*IS* literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Jost/600.ttf b/backend/assets/fonts/Jost/600.ttf new file mode 100644 index 0000000000000000000000000000000000000000..af13d369257c340066f078dd6ad2b6417e1624de GIT binary patch literal 25788 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 IY2PCM7lZTMtpET3 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Jost/700.ttf b/backend/assets/fonts/Jost/700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9f4dc7985b72658378e636fbafa85cf1823175ad GIT binary patch literal 25724 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ð5r%3VCPp66CMxbYiJ4&~F<|kJmh5lJhSN zD_WB!<9#1S=B-(ix9DTFu9=`~C(^-|O`*$!bvh4GrCPK{%a<6FiLC0nxtmg?eBYF0 z8g8AJH9?lm`Eq{h?J4GRo2>#=DIynnUjRO4(6<$l`=k{|t-B74;zH-I&GMba%RI}P z(yEdY6Z2C#7l(z%M$W4&Y{|}PD=w+3Dlhe>l!vWb+A>@g9g`b1ZGP8udwfE6aYlvR zS#8NJbU2EN97#@0{z>+hzmNKn&cZ=b`)J+yPsaP6O(~Kq9+qP%GnenM`SzfHrZ&15 z^pdaZB-=B!-wl;L&!qmDyl+)LpRJsi=bBel-RnZHTHn%AU)S7RS6Nf%@zm6K<}O$? zr)%**nAvT!m!_qa+HG!gt}DxymuJgz{UJNU;&5bSQ zlA8eaM6VO*kJ;s8`nWHGFZ91@m4?Kmyp-9Ch<$Ub3R<&sW)%`+E#RL!x1_o{jM>*e zhuG&V0sFG6ENhoFkCZ)CYzO}eoc3ZGTUzkO??uYJiXQKV3%q+uus$qFZNeTWz4$*t zm<6yMjCVy4mVvaenZUa;2r~ii5{CUS2+IWCPKNy`2+IZR0?zNMAk2xh-)FoZ2Vqpk z#f*1#5M~E#55xXjg<+0%7Vbu1zjgR`$#87<$ycg<@ZXL!Q++U=#a=G7yILQoxKUz6 z27S6hSwO0y;k0a5${x3hHL`w z2#@jt4D#&8TaVY`Jy47}S)(+mTa%oTk&Ius#h79?rx-1STkhY$xU^5m6M`L!x63Q? zB8^C5Ojjbpb1(ai%bb#8wj@W!CB=?0JzNe(GEThXD&$QV@8Aj^xGB#`@H+zd$~@5J zAUtsK*Z4XIaqrjiHe3}k2Xx=TyvK|IhUYqU#H#AdirBsxCaQrkJ^d;9t}pqy3`25y zda?oBv*)QrX7g&%*fT!UL~LLxs(>OA=zwBHo(*>Vmjt;4x)cc zqmhlLtS;=R0a=(Kl$7^OIfc11`{sCutnTbHds|mlGQe)g$S@eq=BILN3bJ#Gt>w)* z+1VxKxn1S1+QOXNvaAV;718M*R&qfvm#NLjhPg|VV#A=8+R|ib6=X3Llbi3F*@41x zOj_w@3{f@kyDoQjNlta1GrJ_yS()$5F3KouMDfKQrE{XHXzzA>_2xF&O0wYz;n11P z9kcm)$VNtnCL8Vef4Q&9CB#ur(1@s({_-SS68&Ww44DrmWf~2cbZ^W|BAH5-r^|Ji zM>S$Lz+==qJq9OTu)=_gn*le;sPj_(>%UUYi;@M==NreyjORzo(|;7+o!b@HmD?Tu zqmWy)Zy5DG%2g~Iw8xKTl&Jf{SPFX|bQ0Ig`#lSOsVsTDzr5eKQCUJNdymI+4_)O$ z9uMfk+|AW;v#x`5TMyk%l^qSfJ4SW49HwO^#J`OARcd@V;-%AfM}zDLAV{E;i6MCz z@h&lhcZEqNQxbl%EG;oHjeg*P0N<9%`=;U+C#9z+8Pd|^l5|yi=`H;l@sVtl1w@wz zHK~yiAJx@S9^#PvRMNM5L@l=G%1;lKqQhah?(GScNG zjq2%@Qp-o^vun^xVQg4ad7p|s`0?1-hl?mp4a%uR8Yg+=4%)Mz?)Sx=HtmEh3b9h} zHY>PE#!QqEz3wdzfeY<^cV`1V6n+s85nipF2ZIoIqk8eE3tN`$==+OV#v%i zq_i~Or{&3A6vbf50V>cfwN8jT+D zsD^`i5SsKcY}$0^ebu^>>cayn&c9=8kyf^|?R*lM>G6%Dn^c=B-e4gofUfjy`I{?3# z;c8#-SAwG~!Vd>L96Et%U%m1EOD}D_=%S9haZo(ci7(XndWrpSddEcmAu*#U4 zk}?=WniS+tkposjX#gZS9)c@&(1k3(6~K?f#EVJO6jt z=udYxdbeJ`6q7u472V#^D}CqWW|o}PG;ql(Pu1EV3=UqtwmLbv*l4`JX=`Wamd3hM zdV03hSCuTPsU0jXURYDJ5T*otEcSn90fMt_dqIW zHTbVqA|Sie!^dm1TCs9$i+{alNtt`3LC&oC#i;yHy1L5B=j0x~Lzi&)1N84{{ww7p zsG}X}JY0hY_O-jk^&6_nltE#mXn5*N>iJqDE;zEnv7@ugZ z$SZH3Vb_li@b1pe-6y%rM|RC_-n^iw zaQ-Px&0FRd70o|ocFjq~sljUm4&TevL)filr2Y;~rkXV`O~)f06vVhynzUj#X- z9v)KbDO+u^S&B`SOByihR428MszLek^5y%c)S~t3&iV5@SIHIb>T36jqbR@f>Z>t>)cz5@?3@FCx*#%-G9M$%plJOuK}n#H(^Dv^Ac|ysJm9>t@)1{|CA@o zWb_@Wcg^tq=4!d&Y79uO_1!1yd?RwM?{#VR{Ysu(P~f|m+Ec;zfcD6fA)mY^arF8L zOC+q1x)uA+dF{Em?RoheTHI!{+hV0q?s2u{=grE^ot2k2%av;>voaVV9AruM@Hlo) z3EE0}e}b(d?IisSmhnK)I|h+_gIL<5vbnLb zQJyb{eAgejgF@7|z<)mW&Yc6F&dRN>O&1?aI`zmw9)E}4(&FRn(K|&sas(tsV7C?d zji*6Zj2N+J;JZ4od-lneT?vO{s>4s>ht1t&0fPJ{m>jAY?R2?1M=L5vXS-apM}4>F zY|YKxnzK#*YHRLR-vc#EtE-pRR4=WnS}K*?t+{2n?ZY0=NJnmN$B4%>+@4#OzcpWu zZq46%wa;VhPwE=^q0y%(H~l2IkC-e!p;O*YSQ~w@Hp+{b$-^Rz8lfibxvVX?&@mq zx}Lneo^{^p)wxB5=Nx%aNjdVik`jEa=jivZp_G1K92CHi~1S? z*rcGRBJsPp{)~mS%d$;bi^@iq-K8`hxko-RUEb5Vu{JfWc4qR>0$<~FuUEE|l&JZ_ zi&ki?Nn?pbdS_^24>Z++YvPIb{iaLWT+%k%_nUaE)W{=#MarULdADzy9P>35`<^^I zKr62S^Y}uEz!#s8J-%-=CcjWhPz&e-Z7QSjiWK!<$lJ!`jlO|9cypi>t_C1!JElY8^1wOXR_Xr2TY84w$W*6?% z)mY>gI&=*kQlgv}XuOh?mX@Slsoba132 zsc#Oby}i2ntw3GG#V9|YYV2{_!N+ja=SRMk^0tfXeq#Aa-7Q!cAv@AKftN3V814?2 zUr=U1{n5p@G~PnkTpxk_y1878yNHW{CRaI0X?`iM_YM8Bx>^p`)FAIV8HZ;2C0l7| zIxV}{cq<4N2zcxx&&hJ<<<7L_C`C$(JIn2I&9vppBBfczWn`O6^Ezi|WM{ZtowEb9 zejjx|oqSQq1?nyD@?9uz`~L0KA97uKQJ43%I@4Z$SkK;H)!tJ56Z20lme=@}%5!|D zkq@k`XG1>DLb`CweSU>^kA9E3C9-9z#;6wKPTiETcIb?vJVD%LJXaNtX>;eS!os%f z?6$(<>S~${m9G}e%FdoufMpA3TM+^A3e7jtqiH zrdMJ6;Yac

)saM9fPYoMh!!-T&Z&>DAKWJ?}j4Zr?R>lkXDZoq{irRmsB=|3!fC z?M}DRgV#)9(<>i*@c!4TtIlgU`|O5pMpwj*$~HN!JPMtp{xxKT4bM9`cPh_`pE)x= zAvN^@{1R}lY)eT@ObPuFudh;0mbd7zhfb1h)njxm#2&zJ0{os3I68KQU#6^=f6$dM z{#U?<1;*Emr8o?l7Aw!vI6jmjnMYcKpRz42iPQy3f@9^Z)38K3uRrMm z+8RnU;YQxgWfWj*0Ls8OzNw7&0DoFdlZc_tdl#H^Jtv_wS0nC8PE+_Lr@<^Wr8y7q zKO+tGw4PEU-apl!w|my^#9w8C_AVOmS2OP;0UK1lM| z1Dg@lRV1Enk%g(|baQG(dVGAoAUp;ExqL>q=>+n+eWo+Rk_dS%kR^FDotBJ5$ZNhVRz6QQB|FlKn{aW?EN(z{sOP21 z5yZ!&Ne*$q6JW%6^2Z6W=9$cm~fj=GK!Hi&?F4H9fM&*!fPA*4{ z`+G>-L^~WEH$lUnlvm{Gy6Zr5BEFw^3D@VazavEMX#PoxHL^{9AkPly%o+@B+Eiag zitqdHQm(_x6`I_9Uq02~D`?ftI=l}_k@jA;!{)O@%w$;%$acsDJjaysqwfZ5LmrZ*;m1yu2vo)+SqQpE?uxgIceQG ziu(|8_o#7XpFiB$e~B7}IJOVL8;i(Mrp;WiXw#Q?!I&GO)zPw=IHbP-}CtYbN_qp$+_nq|3A&*$4jwZ zc@XQBI`TIy%b#5P)bX`aN*2p=Nw0i~_s5b@*5dF#-+r4}%7MLO6<7moB(3nHx&vO{ zN6Ek8(|9(mrHwRBchbk{bMOv(gkFY+M~6@=GzzUk5B84l!*4)O3ga50F==Wv4VulG zt(rm2F3pf;Ofw-`#a3}h{7}54C0dgY#Fm$$*9O^%oxtNZ1q{ot=-latVgWlnc>Ve znfo&*Y*t%=t--d-w%_)?-D00@kK0G>$LuHUr|loxFWJAeUxnM$GzVS`Il_(_N0X!7 z(c>7rY8=Zo#(D||HwV$KIlH| z{-gUwPD9R%Ip;kFPqAl-r_Qs<)9*RpIpR6#Iq$ie>&*@1R^_hBJ(&AW?wQD}iY^GAgx~7-`WN^c{H^|7{zLv_{xSb4{~7-| z|0Vx~|7-vCJRvVF46VO&Zw%?0an>=)x%S&uwrz)1@>5H3a=ymAnBlHvJPN$B{jp-hMD95_kq7CNO*H$lZP3E^}I}nm=7JO$I%BF zw}H3-d8|DO0`ytbA17|%CD47)e}HWM4!m%Ko*)LnN$i4G{(KTtCi8_h)Bzx89^Fs! zxZHgtOk>0-loOi}2K_nuLn(2hTo~5}+Zag@yo9xpYT#!;6~KIXQ~+!bB=ZHc7JzpL zayNEB_<%;0N}xGF9xxx63$V0&;QuN|UX^P_EooH2>fV%%$0iDpN7e4mc=kC9KhtNi zimcEQ;R%fOB3a8Il%e02Vdeh@+Bz55g>!w2>rSt;UaW9ZLv=FoqbO66^JVjRGUJB!$DH3)- z#su+b=Aq9se2ewuSMZ(-Wms7{2l4^l58uJHLSIK8_X-aIzax~jS6EJJR3uaQ$uvfn z@$^cvj$Lcb9q0p1q#Njml@0;eCBxqYJ_mXOcno+7_yWMr7(81Nwu6WK0T=?Fod#Af z6(8W3$r%GoKmwSqXS$AED3dBTRUYF{0Zisr;2Gc=z}{Z6w*LoUby+#q=HtKtoZlhf zF#z)Bc~N>!jLRzG9JZD zfQ`H+%$dRA$`LY3%T=BdFXrk>xP%$qXt{9(d#zLM@``_f1QEZa*Ep9DKN`ayf(54{6^)1#nUuqxBf z(GPFwePjvFXc5Y9LvEaOfr}$wslihEkRCv45;fPVxhyS-zc!R?gsd3KErix6$_$`x z8}iokGJVKn5`C~`05c`_Ho$0f zPfpp%hkK6s)-S>tFbz@AnPt?YRsy{+3QPB+)CyRF*_m0c561=HvH2EgVI>AgJ8aW| zlD)VRtGESYki)KAgj;jbj5_DxN(%r!@=Ivv3?KAe7VF{9c-H?Dw7d>2Z{T?>A8)!l z$z1sLFMyttxQG6NHT(H^x3&PY?f2pP|1{zU+>9*H1?eA>zvAkCg!}0?nBj&H5g?3v zM2R_Y7V)yW5f^~PG+K(;@E7C`L>-9n$O2n&^?S(yjBhnK!@CgmpcXb}Hu)iXr{dB7 zA2?S;9>54ZfZ6?8jM|g23U;73JjmmV?Igb<50N+VyYb7o5)t&Tr_iID5OLr~Ay$Rnf+9c)zZnh3E67g0S`4$BZ~t0j7dC3A4w^-s zcuoC15lIk};R^W%du@1R5~88=@O)NC;q01ZVhxPNX81KmidS&gzKn7431Y_BVZ%CT zfG)!5ahUuTyZu>|E*5=Z8AgK>*f(_qyW5T;?!&7Xx!xu3Vf`}=d)1AIjrKTs8ZmeF zkVk0{&*v?O#;_Za74)MisyKURgGM~^v3R$%9m0WMxj;ee~0E~ZQ1 zk*NZ45ZLaGO1c8OhpOqFbS169(|0Yso35g(=^DD0*3o-tJ-wGU&~?CzdUspLK!3DlRe%LZLSQFjjB^O);_ zrh}JLbZ}PD!3%O7jN|qRRxMU(u}VV=I}-6uapgcV!KD`$m8e=v^w=n$*gmk$$Z2O{ zdz|Nlg5~M#dV&JM6uu~h536O%j9ZdzgHY1juI2_&D{SOFBqgf664O z`P^DOA$uynWkyNdM!ckYCQ7r^GvU@s70eQX(-V|av)y7mUM4fuf+>D5R4L)fWcF&uO1c$ltX^e zi!pk!QpHtO3azfj;&?@PX=L%-PN{sZES1fXrP6#^D#^bth4UMw&}>;M{(&qN<;hae zFH4L3BT~R8OA94gTHuwXLT{%uKUbFKLPT6L@dog%U+9+)~svx>FB3AM8e9zKlGnsqHvLDFA{p3PzgJ8TJXR4 zmqcq^XozSQ3K2uI(3~Mi!g8SnZphvfE(-diR-&n_)MAI<-qFUw)uUw%t4EFX8%ODr zqxtLDH)2eT>Yf}WkqsN0-lTM>`LSo7A-PqnNB6F4I;FvSS(aoSJ|#<*@7wskxomNV1d==g zgalqfcs!W|a0m&BIUXd$lt4tV$bSIz8dB_Hqz-}`;vjJ9W{y1Kf$ zy1Kghn30e|h?xldgrTXvp)sO&ra=gq4d_EnowK{I&z^G>&nJawA8hKLTf1`Sp{+u6 zGz;OMHoH3~f9^?hsu10)fj`*YQ`7Utx1Y|(^V4{)A6_%Ge(&=AdLeqkgixLy-m=*c zmhQhoh~7UTe%I*wWourGyLFxref>g&etp@{ruBdWck~plfy-8JA6@>PQ`g}6V~{Ie zzI15B`u6c4A^u|=@kPr4&@b`-C!WEdNL#*U^R{kBTb2;_27^Dbdfo6)Q@7)KA%2vN z{12=d+O}TzX;_aCKSsPTtQ}gjv@CGtr+B9Ld)BYpv^jF7;~gRHKQ4s+$LlvPU2p%z z9qmH={A$SH2SN%(q$6bretSe9p2Bbvbf?&btDlgv4NoDskHIfaWFfSPCWI}b58;4V zjc~2lfsoQD!X>59Nu3Np7$hSRM$2e~u`&~(P39wX$THvq5BbL92MOUy=^}xh2`c_f zM=%{f(1{R%ppb!@B?>0sQ#}WXYOzoF)i<{Hi1gv@8&`|;WgC~S67j2tHm^m={D33e zZqCbdPkAcH*>Ba-jcY{_hxRo?8&`?cHLKPjEywF24bmatftLV=DF_81bd_RS+!Jvl zfz|%MlbtBvU&KQq5_lxNAYe%U>&0dv{az8lVy4oiwBsyQ3dIt!K=g`E(JJair6?Bp zA{$}4(hf;yEB}G8NckbcG!CC8xi({OC4%CkZZccZ6J#75tN)t8i;Zn1>e#y z40sB12@s011ff(EqO~Le4ZKL{8^Xk=Y zLB)R`DR=2cbc=NTIJ@v`*EQ;@b)~vOoOWG?E>)MPi`IqV7o_u3T*@cPN6IPXP5e$M zFXHK0z>X#Lf3k=G@77IXN#U#mamRi#goI;U77)G7nZ=FmSA# z*2-zEoYu-XJuKTc9krr1x>qU~vYNxIb=0D+)=}%ansKgXxQ)Yl4%cyb7l*4TR5+A( z>ZpC*$rSEn3U@MvI~nsE9Q!igZ>LcFlf&BxU))Cc2*H)OO~vQ%Ru1QLxQHn)V#mm>i#U-Yvf`Z3l#gmrHOikiFisQ?|-_Svlz}##26^^)h?XnGW0kri@3Q8X^Ow;yJ}<;G@edT8uwd(QIC#8gCLIX;jv6Q)K~_2 zW3(KC+^-Sb{ju-|Wxk)3RC{1P;p1NXaKlsOgJd=7aGwDWrzuZ2bP*gbc7N<9-3h`; zo+A$A5ybpM2Dk@|=(#_1Z{t`>_aQJK1B4gklyLur!y`|wjSJDy%BkU=i>1}&Cn}!X z?S2KkpAs_=o`LsDwMCL}yZ;86;F%6Uf`LPlLmhD~pT?bsA5F5LsMR3k0xf_vZH)gI zFv~r0Oc0je{SxKHG)ZgyA-fFVXK*}O2Ac?l?;Q!M$d|eHcYox5nNuXYw{z~~|I!i4 zVX}K%Bm{b|1qP zbfFcWvL+E18PE{olkTaN3Y01tT#`i+2!p_5C{B+wA0le*N1&7C&@!8wo+Clwl>03} z|A-&e*!?0Obm$Z0hIGeByWJ0I($65-0TUb|CKR$3 zzyhbFGr%MLP6mWxgK!NJS2I4wlG>qiPIal4kxS``wt*-qLl~Q&r&W5I90^PZmt2dPVh4}{>2RF4Z6Y_Vz6pi$_8uNt zL7r$+>XSDTH|Ib+Jb6mP*HD6b>3Y&oD}nWza;O}7!}Zyg?ch5m%HV0oxD;*+Ch zy)|4l!YfCT;HM-3*vC`om(-q@{McmDBdw%rD9ZFW3Uswn$<0;-2C@2Ym-n zed=FJ`juVW$K6js<}aWQj)V8NV1tgi@1h*tf5eY88!Z`SBD;=40#PP~))kNJHf(xd*{RA$v*MdLEf*`Jp`m z<9*;yJ*dw(&w+Boa-e?69*$FL&VeymDyLAlXtThgnnekqqg+l{Mz%!mmyyb2(Ky{# z98i6{56jKE!|js1SFa}FK8`K%gk7b6)1+E_4Kssn;8ZSEKgLrlp6dkr1YxW11^G%| ztrd#8KVYCi@2QF~O1^jNVVA01LtO~iPJ&~<9on2k)_+e;fUPgnm*TqW;i|1!5k-T@XKmKI_9L{GLC9Q#qr|5hFXB7Mh+jt z|3oRZmH~9!(>TR*pqxxy?)2XTfV&qE>!xjq0reo!2NeflX*f6RVAgqic7;S3cI<4hGl#~CSpA!uawOPn#_ z@~D_59>qCbJOPd+IDQIx_iLO6@f(~*@mrk1D8n%kE1tm_EPf|`5Bh(=87TgUGfF&* zGf+H-GfMn7PAQ(p877Y7l;Q=PVd6#c5@wkHgi{eO;B9;hZMkhkPZ=S3xfI~$%Sjtf>Wg|;DgQc9#QZ}%ZQ&`IJEM+rGIgF(o#ZuO>l&7(jbu8s%mU1{t z*~C(gW%&lOe5bQ~gT-UwaY&rxo5}J`W%))zL!QRClH@ys<(tUz)vfM68|y5gVrSng+WozqvNj#YX*9}_fAc?SI{`!AqZj~KR|e5C;P zm&W4=;eBE>^(}Z#_`=>)&59@BE~x| zCWv}??3DLe^~N-BTTxpi&LzRZw`vXEy9OTK-m5<35-2>(8dFf8V zxY&dH$&1CRaqf4&0M2AO5ucyp!WiVWo~sK!GkOy3yQGnWBF|&DQatik+6Xl+`n~XzG7mGU=+Xvzu<1;J~t@>%A zrp-FQA0sKkuKmzQcfDSk4;}6HB62<#`Tq-uSF(fuo9vxSHkgys?QYCq2j9dUr|V-8|dNN+`Aoh)B3P__`BcH z`aK$J!X8XWWNI%qi52q9zk&lZ7HF~5dfgNAtDuYLFVydUX%>9=N1M||1R6EZoca{v z7j2uq_HIhLi#>W<2O)8tXjb>*iBxPs-dEuaoiuiv!21{OFu$jHC2xTvr&szmJgtRJ zkoBiNj^>`!*(}Pe^TIh!D%XY4{@;wDd5dh(jIqW6j4>|5*y0L|DIUT&;VFy&o`J{y zD!lX8#k;ZszH$U`$tP~c8NlO{AReED^Y|o`$0yTxd=khblMo(z=y}u;%;SogJg%6| zqX<794=6kmn89O!FdhT=vwt5UE0CuSxsg8)Z$9{UTD}P`I#t&!r%TII42$mPNNiV z>F879*gn!zGW+X)#HfkA0rd7Rv=izvk}-?n@%m5ES|rnPgBE!OAMg|1d2p~ER?V>SUZdgw6K`Y|0 z=o1GZlb$DHrrdqWer^&KwiZ+(NaZ#3LO%0clje!MR%KG^ukJzRgZem+AMMq;N%Fp! z&VEV#KpzWTz&M!Z(f-J*C@2@#&0F55<9Mc?TRSH4{?b=%|Hzk4|F0(` zDWHEL{dgH3H|Zs6O7lBt^;J72B;$ejT)(8Ay3ff zoL7E=c;zRES9|ol+7rX8J$hd4iQ(0rP+si`W&Zql4abkyaHjL>O|Z|3SQxL{1oDba zB(K=e9&&Ar7m-V2yl6wjU0AcJpIna~{cxW|TlDo%fri;on)y2K!QS1+=Z%^IRDBKr zzpx#K?f-BxHrt*lbidL^k9KNAJyJPe9_*?3;H$X2j_*Z>ZT<^AX#N)UWbdnyu$b6I z#Bt96N|u~v&CYXZbZO_;Tkf|R0hU|!MbJlTvs)}H9)A*Lrg)A;RB^N{ys=stKX(@~ zEXo=D?&mS;@rhYB;>7(TV$`wkX=>qge_nZf#JxG=00TXVk38Pe_9DIjDDG)C(btwt zhz5C`#z-#+dV0!-4;ud-^T!vt&(A0#i29FeD1FRuXl7vw;EoSDd- z@3b-kgM1X^FuCrKLaR$(pHY+a6jD>uo)hf@@KEql=Uiaj!5=uq4o;$`VlhlxwE(_W z-)c(F_!qbupFWv*ZxIV?;5n#FDs5kCT3+NGVx`vo1}y$5{9a-{m=m(??xz8JlVBK) z!XLzZK@fT=>|<2-X)#4`$m4fd2-U-)Cl!Jk|C{^2wQ&mVGNT=}^mYWTIRD=`CflgV zPZ!A#e0t>xS^O2{dmS*W2Vmyvj8G_+

>f{~h-qx?k1E`3ocNRiExt@65f0Vj&H6 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 RDa*wFO%N@lgi9;&{|EHMG&}$R literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Montserrat/700.ttf b/backend/assets/fonts/Montserrat/700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..016d0cd33d890178a6bed765321be8ec57663862 GIT binary patch literal 48824 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 KWt>}C!T$hXA2vY% literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Noto-Sans/400.ttf b/backend/assets/fonts/Noto-Sans/400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5ad62d14be478afa904c33829a417d31eb88e0d4 GIT binary patch literal 28132 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 qrmeH{)b={XT!++_|H3 z=gyotbLPyMb7m%l5<;{H6omA2c6FDG7~Up?76STK&(PqKHScdf55GSo#CUbjl4b2T z{Qi=0Ld;(!gq<8*QWe_~`|V4Flnf#Lx+P27mae_Ftqs5L!S7WYcdp;fR+!plkk0$Ij<4Soy7xEN6S5(Q_sOjQDBo6<;rBZH z_HNxdbuf0e>nZ$3`HD?Dc5PgL(}ExTfRMEx1D~3m>ksaxesUclo8Lux*W~)0<4s@P z6(wX#F5cg}d)MBn+a9`R2O--Y!uR*@-ZQ>?YtH2t5;8f3^eRFLBdZY8;JzE7l5~0-KDmgjA!dA`Mf!ZCpo%{${*3T$ zQV3`m^>+~~YOmq>G0-l3ANv!+Ke1_qm+>%@VQ(S+g48gZ586_-Nwo>#0Py#EDkvjqC}c=2k0=zgG#aj*tDYlz-G4O{Ty$m6WHH^ zaPG}M@uo<;xfvk?E%d14w?(`dzbKPifzy>T-a3@rfsnVGpoM66Ucar%HxZVj zNiP*jIcU1i*~jby_AYyiz0O`{&#{y23HC7V2iU#rZgwZTjorYmWyjei?0j|(+s}5h z3ATx?VJp}$u71|dT3J1-VG$N&Ww`A4WoBBI&lEIGKcgSfzliH?`X+siz9<#aXT|jk z`nb3rqW9B#=w0Hvhu%SNpCBVUkDa8YucInk1T5aAo_B;tDZF2XwYnTV$mwg^ZIHQ+5U&MbQzV%7Xfcpg8C%*EDx5I*8k^+Z_bQjX> zldcgVk6$j}$E0f!KPKKDlfHrYB!`m+c}j#lWtI4}v==7LYm-E)-!xge@ZcD-Tg?(2ZOx!sF~i#7_uH zR*5t!QAfXsO9Eah;sYY?6LFtR0TDKe@V6pt65&1(Iz(s}VY>)hMR-hvSBUU=9+K-t ze3gjbD8dH?HIbI^16I2Xem#?5853%3;|5RNru6%YR; z!pnFFS;ZmG@etq7m)!r*583T(1$ye`ydR#{d+O19PVYO}euF-P9)n(%PiK0J+*jm& za*yaKkJD^FK_3}Ge2C8U5A+R`HGA=Xg1qF*j#6<^D|+kv>qROq7C~Qn8>P-&=vnBg z@^v}-DZh;9%~NN%njw^$f%Q?|3wfUgcm4s?r1T9D)@3k8P`?Nt6Og0`Cq;Nbz<DQ^{_EJNQBZ=qWt@i;glZ8+M7Uan2SnHMT2e6-3Xih}yQ{q#|x6L1(xrb~Ir zba&3(D9x5F`TzNmdG^##Gl%8hnNMcmnYReZ{AK3XnV+Y;Z$uid^M2`H<}IE(&-ef4 zFLRH;p3fl92H2ZhzuURl6GvCVCpxy8-|2tDT@|*wv!kMx}j$g`8r(-gM@tN_+ zJe&CoYVmC5G%f;Fo}DXiCJ&k8&YaU3p?q|l_cxQzJkXa$=hDu^BEuTS|ohs}&slOe!?*Yp&gKjp*!=EqDuzAQ<$ zgny8anRW9pKLhv9;reAc&%v{48F7fwgwJ{T=v0JpgkgO10_umv%^?v|1!)z-Re+g( z4WwEMqn{e1Ul&@o7nhkV#AP8vWC`p5%W&Dra$F9w64nAIS&hquvGEGb@2|#HNv_3Q zK7w)bR>;8H$#+Q|xr_V@X(jiNdy(&bT(iJCA`6NZ|k@O3S&9NEsOKfP%;ZvBOi+!1YBwN$Beze0 zezyYK+i_9yEznUxz6Ux&cLN2vUh^QrpOA-9)+4x7;quS1JToN`k(UpszHKzA8}nJD}q|LYL=pFTIJpORpr)BJJFD3}4CDmH2M%vLYY;%`tpINfZ<=0JQ_4 zZwM5PgQ5x0lk<5aw32pkGw+|Pp_^<3&z%SATtL1`E`&|#V$kRqxr|&6>Rb)Fd<{0k z>(OUiL@`^ei1h>wGkoDjmKliy%N+|1ZpXP`4hnWFTngSvzX7d zVWDV4D(pj)_>jH`clW*9c@cp_=a01`P@jh zOv<)J%m4u4Z)A%uw^4GN<9ztc7cx9=Oh!}aPu@;<^rP>Nyn_y8sJ;rUnb9K;t~ zddu|Y`Q&8WNJml1e`QOT^S+$?ZeFTpv?j-n%PnDJ6)i93h=B4r7$sj&UN_#ZcHFSo zi|@rdnnPu~A|<~TP`Qj8^6&U1a}iI?@i7wEX2rJxJMuS?w?SC2d7H_pxLuOJS3v$S zatJXBxp}7dKbw6&C~6>o$FcH@Ixu|4slvaNbK<|`dd$J91PpfjEdO#mVz7PASe)MCf*BxIrdq-#iwb^}_VWD2E%F~w21CGZ|R zjJ|@up25K$j9(-(t*A`@hWj@t=IoT)BVa9pJ&6z(O66te3XI?j04z+{fh}97I9dlb zO>L!kzin#$4%z~Y$}qO(AQi(5xD^RF<}&uMr9igHzX^>CO!<4xyF6E(H&1IM3R;Y3 zD=y9f1bls%WN0;A!X9Ezve($_5|tEEp5&22F6PQ}6}XHptIO#sbNO9ySG}v#b+@~$ zOrOqVrqLti99-l!(W|6rvEnm!JBWq`Rbdmy!pVJKYH`NH-Gr%58nLNo436AwKpTb`|PO#QN1K_h;LO((K@ZL`1I=9Z}*11RNjp?t`MEWUq zayl7jn~<(4^EdK}NQeHC@JO~xEgng1v2ORl-T(p4IXl)5Tkryor3ukd&( zLS8RNhmjUo?Jk@t!vIpbaX(~SW~AK+*vWL7GJf;476g;*KQL=91!k(a$)ob5#GQzz zJh1^`I| z0A5ZvZSnhe*YvFn#aB1Br`A*ie1pFFwW*2C?d=sIozK~B_2`Q{jtXYf?bbWlUDiZb zG}c?87#d(^{l+4Lbf+QGmFiuSzxCOiGMnCN%BK!VYt`v(X5OEa#FzP0>SixOE?Yo{ z0JH;Qm?3qdCTS_lF&WMASTa%L_fZf1vUfip7#SX3xNLCPWw*Or4hQ{3dTI;3e00(B z#Y+}09bD|P*<3EClfAfaV&cGoiHUtndpkROd%8M%7fiz_pMHM^zO1LSv#+){fQEU>A|M$cGEohja2(&1Brr!>U`}glmsJlp z-D)l^IA5DAzxvirN7rCg|E>miOSq&=A6r#m^%^@9m0i}amPQ@6>Z%I%qPMHMW3}f7 z_o7h4q1cIbOPQg1Ra1OHZLvL(a>e@m>Frui$W~QrHPS!0y^b1(t)|RX2AWdNgXAH> zgM@P+=RTS7@Gh9y0I$VKNKj(nXrm&cr9E)xl(xsVclNlNDkoy?rBpwB)A7bNZyi|l zjVqcPF1rEWI`J*H%ERVLjUa%h8H@yOi8~p!m$t+UzQ&mO4R zt-db#&2J{J)9k8wfcGs5&3qSAv!#3tr|2vuPXe7J5vTo+J+|i2N7v93V?X=Z*w5v( zp3D)<4yL%&PH7lNH5Gp`&+gKV=Igb}L)$!y7yAOsmwI@}iS%{mcsLxlSa@iro0ng* zHn!s1d-vY4V)?hHmhWq*-@kmtzJ`V=jx$CsdNJAvT8-PI<1MVAMKq9#(~A#7na_7> zhgx3UMt^FKR2lPan%05u%k-)Ml1A`4tk@x%Nb#2Sd&+WDfo8@rG@-qVsL9QYY0Le| zE_ZFDa-h7+**Q|zwJ{l4-gp*WF!n%durpX)?JHYit*};))YWfTTep*sgS_q<A1g_(X9=&T%B(Rl8L`c2`=&6Uf0y^Gr#n_Bux zYiqsrQBz~^to~hxSRmS4nJ?v!^fYg%H5hkUid$;E5zDwe=B|ipOEk-!Kotq#v1xky5=@h zW!Rf<2XoI@Y@I~E7X&o8O>R{)rJl~M4Xx*_s@>rAZH+`bz5PS> zHdE3@52imY(pAyNVr#qlCmW20-A1deF5K9huXNH4li!vwRsr`4lx1eWA|7Zwyyr@w zj>QxK5y9Q@xkiFKViqATIT_KlS%oe4^bI@F8_JZ))^3_(I(kO_P0tll6gCzdKplG~U+S(%f2>bZfjd_H)B^_A0x* z(pJZQBmG|frERY?XM8{=JuMZC#8ZoA_gf3<|MWLoh(`c-I zdv9`eSKy0yYpM=})|OV$Z!{f}39ZcWb&S?n{N|dCJ&PtA0*m&a-`U&HQ5Lh?QV}cX zvl)G)T#S*YNFv>tq|)iqiT+%ODW6Z|RSLQI3Y*DGNJc4jih}cep53iotAd|OqKB%M#pGi%bI_;*vOW#h{YEC>QT+3((xjn+ZQNGM8iYDNMuLb z_%2(G-&LmGWT|wQR#uj}E2lk)Dyu!xRl(llBs?r`UMYth{F=p1E5Z zWY55QF(jlK7{ecc48en|1H5;tjI!=BE7yfi#dQPIEcbKa%9MijTd3&GR2no_wudX~ z3UUmc(Iq3v@t(*^E7T?f*AdQHI&|*RaQk^{8V*%Q_Oze4-E4D3b-_oGXe_Cx+h3r*>RU?Pm;$uhpN6+iB>6aObSKc|b z`sS&&>eXL8(NNRhJs~N|J2`zhk#9x)RD2HU4!Bhw8qcE<29<}4NpnZ~v-V4A&NVbA z{fa_cq)@8W%H}5@dWbzU9jC7sChD}NM3wGdWW()?gsp?D&_lxm7t(Cw2(1&8aO(_0 zU{z2D$N^0cbfBp#E^*iS0v(~|sloK;f9kL6S}?YEpvl$>ZEfjTxGj{cRC&AV+Ez!Q z+We-bsw({Ore8P5oKfL|qTJrY)(ZY06e5xflYgK*nkYi)mC=EuqiStW)w1&P(fVE2 z^j5Aox+J+BqJD^N@pP_h9GYq}Y9@8s(Qofse9^{)#k@_&XIf0u>vq&jcoz7`RzY=q zuvDQ2BAR5kulml^i|<2Ie8b>CqBzBU#7v;u#|S7HEv=p1gHN5>~uad!RD^pi)A()y#c zDg84v)0@;K@aIYwUw0wun@H&b5Ctlog$|})92ueEk&!+Z{nXvp=T7J01Dd%nlO(@E zdgz^&8Jz*03Z;PNx%Qw|>ot~ytw$4UPpTB#6*}A{y)I;o{}2tpVCi?Z#nNO@Ga)l&=f%ImFLqC zri$O%Ih(iCh4+}tVWx+XjpuH_g(sIv9*JK0gn}xhz4tz(c;eo(C5bAYW_`xKWxn)r z6=2?Vy~4iIlOH7;U%HI>-O&c z+JfnK%2#!DuLzB8>t0n(H!rz(Lrr4+B}*p1J&GROmdMhMK>BmA>ifm1aBY1S=MjOk zSx5;kzcIxs!=ziDTb&v6;nusqe}N)j@ulbwU$UX(C*P!JtbyEz4+H-qQo`|;RzDQ}S=(dDx&T_9&zd-O5sK71dNzmG@(jiiz@k}S5aO|>WIFiwcSdu=k>rGj`0;_LY@^+k3ovtXk21v zUwha0kH7!MiCb=PyZh|?**89S0g>z zPzixx;zFVNop%l%yhYkNIz50y6gtuD9Mpgz^junSApObA6cq1EkOx+S^E@n&PYK*5 z2M@mUP6xYp^oy^{XxK8HFRJO$M8QuALo-+2|5z&@2u(1mHsy=p%z2izN4`>- zvLt)z>g!mitIk+fb^O@a;g+xi`KC|$7StDxtX#c(1x6CIK@4?$Rn(OzvlAepE6xdl zteQgO{Znn-2m33B9e!K0Gu-4XZ78qkb24CA z!K;k=cu^njaYhMHMw47_TH+Dfgt@LDY=F~G&=ka^$~F7;uM0MWi?mu>5p7x5w-=Zy8*yT|TyUWMpNwtP<44BJ7ta#X;Stt63Bl zJXIVD5v_zak-m`)^u~uPf+J0XU+HQ&Yf(*$@}1&p7ObBfXzLp?8a5g9P3Np0JJPnM zz1d#}#f8%h8YsOLYfGVZQ7)D}iPR~=Gsl6UeBy$`%a@0j)&*NlE?ubDTXq3`B;7_I zY4rNKhdk=sHOjn52%3k$TMHTmVReVK5&UL>m6fX!vr|1zo+P&j2yGrl$Sm76GrT}m zRH;FmQaw?(cV*MY@{09MZQU5vO9pB;?Ad#vKVfTXD2tXBVImp~`MaiHU$ix$)$KBx z+Z(j%kj7te^+gwcZFBkJ`jsmUfyR)zX^^k7Ll0(gv>T>;G8f8b7u1$F6v{^|{)~dD zbg?FP(~7Di(Ri%B_{b5~<*C!#J^J$H-RWCtPjgdvxH0`1GD1CjQQMc;hv=iQV;hh` zYMy2FnImoX@^ZT~6k^Wlx0v$h(D}%~|FttcT>^YPrv6R_jV;m9F^)ad&Y< zTjbQ+gGW3IEg^@!qQdS7eW+57D{{(WY{~Skv4KkIW}j2!kGv^XClJ;P6rtl}>k3^A zV{QNzXkGkSC^)%hOTld?e*Mlh-)%YfTzbM+|{gwsnl2)7Ey*Xzh}4cu8&jP{mr_zL0xb$h{!? zR95EBC@CJ^jk6TujIWZX2wZLP(;e&gB-gbD8_P90OmSq39;T#5ML1DR&8ezDO-Vfq zUUAv#!>uN_L0@foNz!nUvDw>`)YcD$!b7#o;DMRf|Afp{&g*INyqWh#j|9B~y_^ev zE~LKAq#Vh&WdtFmo_3})JfPx{cKWKEs(0o_{UDNq-d>aDTzV%|L3XcG%c*fOHf`|E<~cbIdH zV`g5-^}5HA7n(h*C(W~A2DqXMc`GKC0&_@S=yZ?AHuTkv_=4+0$*3t6i*r2KO5`y zAq#!;ED4Z_=tCy17l2xx;;_Nk>WM6@>>COW-f%;3G%sgOp1QQZlUh^Zy$9B&-|=&E z5cqZh#)~Lcu|sQx);>Gz^0`%3_hZqG`W#)d#nZSV0;vz~=?5B{ z%9k`!C_Ows{6VjAa)}d{H9$}pnw#|OvlmU|6c$hgE6^x*T=YxkOh2j#1&WFS!6Mo^ z{T9B3tzL?=QbAj&3?7YCM6+sSJgfdO-e5dC4}D&#DN-|qRH!LZwjBHVkq(s_TToa| zUY;~?Y@xC+A4TPBay!qx;qpHj>Y8gbnwsW%!@I0BJ#B4oGN>!6f(2ZQ)7RG!2_|>w#q9cycu+Yv$%t&Wm@b3iBj|!jQl9!ne$I!;K|AbwR1Fw8EK>-5Kep z3<1B+(&V6t>F?Anu24#wn4+ZI22F+27Q-QU&pD$raz&oH>Er3OH2-sYX?mI#ETX&D zFH2+R2r)v}<2DyGGuJc3xaCQpQz3?KW|CDJ`22Wx=ayEwu77pVkqm{Bj?n6bjtWa% zskqmVP7FQo63l?Yt^lmzR@U7rol))`%vj5(ZtLC_Pn&(ig1?Ia0x3$`KmvZ>E|u z$JNo=yqvsRUUbpA(w6x3t{_kgxW5k6cv;xHz|{0##B5FoNmW9YD)ST*KUMo;W_xpS zakI}+U*(ON9ZfuLZ=f~Wg0hlLR$HNwM>bmRYRIdW%tG=iE6a|fP>L~X$(jMW`l@v$ zS95mjFy_rUWa*ZQZkw;P z#Zg)BaMqVc+P#h@-(d5q>ey)G5W9#O3OA{B!J)>+MSg>JyGF~_Y{+Y#T0S<_oHv}M zHF&oyTi&c3f_?;d86><4rlkaL0`52iAJ8SqHNK#$+Y_pJuojW)|O^XQBhG#Q=2Ivk~KG}i;6T&%`N7*9f@W>;Cz5N4)m#$!YYAo z$m`E<*=2~@V`k>K_WVaDzWUX#9>l%%;S1;q+K@gm&^ItOg@B&L>B;8tHFD&fZIZd0 z^LP5tbLm4!lgfNXe+>OJiM5%67TmG%kHY|`iJ*)u5oTVR$kp52w`gJ;Mh5XrW2%v+ArigQ>hRY4W;t%0i#RjHe0; z6R+<@>@4~WcCT@NJg*Bl9C!oc`5w@VJS8PQUrCAQ8IQHt=PR~)*xlUm?sPbu*(bOA zR%bquiolCjSoJY8^vyGki>{gVl$*epvWZ7n!@;L45EHIt$WCFpW%OUiH$_L=OIz)Z zk}6X}TV1=QuB12?8@gy?GPUud!Tt-!>yjHU8fw|Oq^fdwvaNk`I8wQI*ZJpeZr|Jh zMpLVoF6dvP&hNn&9cvG{+!e@Hz$3ht*1e2N(D=+ZQkBw+-tK{TkWMC)lqkex!liQXl(sW zwbfy%^7tbb#)|A_O<_}!{-?}tF_|jMpbqo(P%&0a-^2VH9$bU$ByWM-Kxgv*aP-nk zM}PSC4@Zw38~q_Qz7qTXU9p$c@tQ32v*zSPthVQ;($&(-Z0U?;qo+0tzLsL@Nxu@aN`V7}(f&FyX;C@Or_MFlk({#ol7FBtBRI;U2yZ;k~*zHoKZ zKWUd^^ns4Hs#M|Qcir_~u+-x^mOk&Fw+hWnv(M~vRn^u@&{bF0nWQeT=#dAWU* z5)1apE|HdT`xrQqw-ZKhxp&Sgp=dAo(X-X%Ui5o=7Y04$RV!Bnx}9a+?ut;jIu)x5 zxNVMVYuLF^TGkj1BnqXxczJzyQPC2E9v#@;?Jg;?8ug79s54kIGH1`Bu*i8Zf?z#? zuduwj%CJ-$45+VSA8UdY0gc)aPr(e#zv1%>zMB@a$637+4zldzVotfNfpS?Cs&SP? z^b(Vf9ErtawL-z{v-u1bqoLV*lcYIK$4q3-gU`a8F#a2&+6VHw*emR z)TTCCdPFM!;$?cJLK)dHx^8C#{h)~&u;SwfOeI#bK&t}2I?Q+9MGWX$guI)hC>Bq^ zW&Q4gF0;R|u(ZhGiunD#26M=IVXQ7k(IqMLMvFOTFswAf1<{-3ordkI+p1|dgJEw_z1&BU50r z6|3g3IaBXj%G*`J^=~PRwEEcsMyt+>7h?x?kardVkHhAT`|o|rZ9*JjTWrR)x&sdj{D5CxyV=M}%TDt!b(SToO6);Tu|H%rt|@V?8>(nYI!ZiV3%urKW!d$S^14!X zt8^FhbNeK;v<|GPzWWUD{p9y&gg=R$3v=neKAX=;vUMK(+FAGnM%20VyJzA5fn5}Q ztv&uE_y{m=VEBb49tK}J>w)!w%cTwJiI10E0A;`i>%D|omU;TlCmMhHQ`sT=2!)do z_+wWy_ctSdzo^&Q1crOS;m@Og6ZM6iMbvkrz~^-Mesa$l;ZI@*631Uo&+8@U!}o7+ zIQP0?zdzu7T`cIC`9Q$=d}IAQ_>J|s5q?X7Uo1=E!lM*vez}fysQ#h zM+BbN*odd1uPRz~sVDuPrlboF*z|6lo@$NB(pf*OLEj$jsy@C7zF4EarK%%(e8rZg z^psi+pKN@oiyl{N;Ftv+g+EWwu{KM`y98ZMhwsPEdQPv?;ZI`S;~bpROU`Ek`FwH zoT`34xfFI^S^r~=wou5X4SCC|OQNfqQVSirs+!`;uqNayuQKv?Ej6B6*O9h8J$;i+ zY_u#MD79apG(haE9WyYE#Mjc z_*~@A`4RcQB&SD{)2?~&{jfeEpUm@sFPsN|@_RD;Jx+g|y5kr7s8nPkGmaNC_Wm>^ z2vjx$n41fXEPu~UVk~^^zK|;c?u^Wuk6vFFD>z2tMA;k>yFVKp3b#R zi(!k+qrcCC6M^$9{6)}mg6Q9I>=!K6Z65cgriFVunx=;8H@YmfUFjcmG+1jZD^pf$EnO6ERlZje<05yUwSQ2E z+;C;Mvi zWwn09T4S}vtji2lVGUo+k*|b~?GBUKVP#{D zi9|y~O|r4sZn4-fl-uQU$LPP}Os*10TE2i@C=YxG3}0b}9<2&WCU<#>Dd_WJgHldg zPFJ-FHVZxCY|NYR>a}LA-PYZQhmG&9^poGvXR$vIbxc7H`RR9mvvH#gmmZr3mu{5d zh4dwYX#@9j8Mp~W!&II^zS?>;&wxEC^iA*3uvctK!EV)TTJD45>EQp0|5WT>Q56+p zWj1?+?_d!tvPD0(XO3al_Yw4nXNh*2uhGHTb@U~Al_-yE_dFNu=HQFiAdQe2!$-s&wo&tf(Ooa2r{QP(m3#?EJEV7o-LwR{BTc|zt`dDsTw@cujy@pEu^_y-G7KZZ!)hh zG8zI6{sC1{Ual^0u(HLiRc}}88g>s1s`PnzI_1F7z82ozIy%8PzZzvL9$y5<%~@C( zgGG75l;`GVX6W~{Yt5SVqpjF5GoDzdzz)a1kJJ3kbb_u+|0gX<-}ZEFRUwaQgE`OB z@?gE9U21lauYUS@W!{$U%qWvqH;^(FJprKte2Ukar3GxN%v? z;j3Q0Ak_<6n4IoVhuvUPmwCJ%pWEhi*@{c;(z0aA7u6Oe%2PeATCX$U$TwE`ywxS2 z=bB2iC8f^dlG4HlicKa*VZOs+fCU=3g)*C@hcPxvuwX&A$&>h+i|lp@$aUFo$A@&j z%q@P3t?_Ss(O)`A|7>(S{D##-_WERTu&l{e>a@Cj?k!V62cJPXf>`O6M6#2cN}MH@ zP}JSAKyA`2*PHAXyT|HG1soT7N=rRh1jKcYtZaiH;#txE=6*lJ>7AZri!;qq17Axc z8FR1(0A_62exG&bJ|6Zfd-FXWr$uX1tApmYPWGxll2^V+2^~e+)LZH>F_V``CWdUqsZ(p=4}U^K+<78?#KBBT)y_vJ)#W(XFc=a{qx|h z^WcMY?@W4$`%LA00&+gcub=&K_Wix5yvKKVKgizZJi_NCSpVhZ@_c6f7w`UJ-5Q5$ z&#-E3{z|pe7N{XNMiP+2rDDxICUe|&!Sw>!ki*x^b3>`voPrq`E5#1Y&gQX3M|8)E zr8}`jGv3ygzy{5V8WSBK!3NFP+Dk^J#1731E;#?IE2gshGch`0KRsVBFFeKB&6zsS zuOyo1)D2>9<`Mc+cdt|4n>o#g1n7?32PO7q#!s<&bAKr&gYwo)lb3JIOjXTo%uH2{ zH17G*jhTF+JhwMf%z3g7CBa3q=#=?q{@zT!B@{9hTQiq+%-fk+-(5PlH8VL-Iv}KU9Ohu+?*W$DE-EIL}Ivz|5XZf7jOf)~~Fr-{|pg4Oeyg z`iHEo7O^MuV|8&geJruAvwvqD_GFq%?DgS>*8H4Ox^CAUN~PczzW>ubvsdu{YENeJ zv^|*&t8VA^WQwhVVoxUDD)>L&lL@_&PNVJ^r?Pu8*JFXxwb)1W-t3;tIB>=`c*u|{ zScSyeAkN|0?dxLT#E>9cAmD|E+?6+Ea+3p;R#^=5AGCaUGUac>Mod@zc-w|^J&oRA zcSYUKq4Jd7m2ww(<92&(g$?^L!+d9EesrWU)*Z(#%=U`fg~8>^#0JfpZfwwut+PfP z&ML23rS(Tk9hEk^qL6RLtf&Y&LZ#@JRlwOgLuYOUoE_O^b(ZTy*=~CJo=mI0VsLIx zW{rjAXv+WJ_GDu7>+GIP$U$S~Q=GZ_Blr;C#pxF7gv`QnC#s@%1NN0!80H$Z4?7iA z&~s%!_ox>}kMIk1WhQTQ2I6g*ou;3Q;cVk%d{1Zj~ql^~k~u z0>*JcJ!DvBH9L!Z3+q;3S>wtj;F;;q*_!mHn8(6uGW~)KgXMBf<~9M_pGGT*bbn`K znXh18s3Iq)xdwu7GwazJavSEaD)R4{Iu66w@&e8th~Qg3hv2KWRh(X7+#*Ze^9kmjQzJFfboJwtQQw~Zee)24h=#8hwUHVvEBm?lj7P3N1An{F^YX?nr*hUw3yPt44$F$c~2 z&F7nsn{P1RX};I|nk8y!wDek*TGm;1SPoj=u>9HbNii$d6kCh^#j)b%;swPw6yI5V zZ}G#$CyQS$eyc=NvZ`cr$)1wKCC5sxD|xx(t&$H)KDX9eueIK9eZu;hjo5;=8e6Mv z#5QR=XuHk!3)>5}4{T|Bp}p8%Zf~^r+rMuAh5auMyCdQ_XZCu+@wDSb$7fEbbDi^c z=MS6@I-hjD?0nn#QK`1Hy>xZyvC^kYKXL_KyItqIUdQYs>aKTpx(D1V+#B3G-22@} z-N)S5x^HoR*L|=1A@`H+=iINk|LFd}{TYs6FDx_T=#Ow&ds%9s#v zTh#dXTuDODCgitAfAtQ3{$b;zjnjY6Xr;$+N;seE3Lhw9_%3^%krMCQ@Lqh!zmflF z30ut1&_Pl~_BLTH#S^53T|_Lbk;J6!#K+#j9OHAGLDETrxSd!xS%!BJY9SulO5B*2 zH-Xo40Sn>o#$Az(8}LkKf4lJv;tq)P*kOWr1b37_DfT5x=aV?Q4OP8{tYn8t9eW+m z2pMBm+%J;`_AwcQ4vKXGnfutqfR&Mz(l^PNRD#=p^jq+oKQF*{e&kz6wCp{yoE?CM z`(vU+o)SZCSu5s$IAakwtMDvA`40M>Oq$|^Hr7YV*&Z@LKf$W4J-GW|G1yD=B7PWf z8|kIC%u}qNEJl1ldXx+x-vPEwq#xkv=)FkyK4idk#E5qbmH-+=Z4_HkyK-X&-m zp}VnG=`74$d0oy&UdKp+(;L2P9pGj3u>8H`BsQson56`8d4`w;-8t<~yQP}}<88s) zVcv~)FpIX>lldG^6WXd+GRp11+hG0;Tw6rD@V1zL^S0QIeD46BobG41rSnKwv<2FM zx5dnjHkbzed+>Z7_j|}EHro!oE#}{x&o+~4(H6WtW^S&9qpf+Ha9*Md(Vh>H2I)z( z-AVBJ3C#R|izoP&zJmK>;ztki!y8w~Z=6MCr#Iut@7GBwJ&12x&=-ubO}&YG1@ZG6 zrv|oy1`bIn+Mp2a;Kl6_?esAr}g_7tw4+?%Y)*3x;sw@*la?TdXbD-2Rg4{2eJ!4;ZaS&b;oY)`ZvT)Y^6+&2DlCsb}cH z@rdG%fdWfW_i;SuavDO)U05+R4%$xPO&dz%6y$0nao1f#3OmWdr&!bVF8q|oadPX;I0^U; zswBr~4o-Ew2J@p_as|yJm*CXjH)uXi559~1oqUY{t>t!{82lgb7XJX0--zD02~usG z{DPdsIkr!W6K($l`u|FtXZx?9_=n^w@-oi&dI_!d9!}f56z#kXEwB@GpCq@Twax;s z@OI{HzMmYxY~di*4IIMk>|AmV)&qPK{*uGwC{CRH3r^^KkQUHFtit*sd6lX$d-*L+ z$-I~RFRa!0H=HtiANdi^Ccc<_AB!T2$irCe@(|UM%V++-Cd^3izdb=aFTuIFHfo0< zl`rXckw?j6)J@B<-qb_A)QA6vBS3>Rgfn(4XqZ-FEf8k-v>JYfIIY1(@Fazin%2>J z+CUp=6E+33&{o<;+i3^>=bJ9tO?z-sZy))qVqjT+KkFD)?VH>djkZOz&(6GE`^Wd} z-MDMdxD08JWuNivGm(APDf&0RU4`2O)p9vR<;vp9Kz$!#KoJdqziQZ|?8C?Y&h z9?6$;<n!i*!71kxtHCMCJTNLQ#T(tbqxerK+{B!u_=-}isIzH{f! zo#o7#GiT16Gc#tKF_sHQ!`R%;uI~JKCOu<33!%TBJ2o6j+3TZZ`S7B6XAvi6p?Z{qz4ysy|ewthma60b65HzALT zE$eqrP-%=gTJfB-WyiUjTR!N^!+Q^7o%e4YS-+|D=x=UhY(ojY*K9?A_LOc3-dEwh zWb4@E{^s_g5Z*5Wz?*jL+_?U>yl)}TwSNUX)nn`TPjDZ*g|W?lKz!%;`mvGNcQ@5A zwgt5lJ0^DSp1kL&8+S0a4QVuwPwX0**qV0DWsHpn5MReQ6KoNDzyP}!u9h|8s%Acx zk1w2k!2W_Sf@QEY#>LMW=Ub=Wj^b?uaWr_YNIi$}HJcSP7v4))6WmU=8tx8UoE^Z$ z*(K~&xQE#jaGzv9hWj756j_#Fe`WuP{BpU8Dcr)X2&WPlAY!ZlH}DES;Fe!GV&!rp zVsk+FAMhJ^o_q)T5iFE}5DQ{rZc+B|JoJG2KwRV0h&tR9cHOzt(xrm>E7+mwd=6$$FSQbJnP-iEz zqxO2rPyCSa#C_r;xPKI<;9f>m2_g#ID83X7!RyisCZ61EuO&rh&Uqd6ZeS2;wEvOxKdmq4v4*CLX6_MNvsje#3Iozx<#v~ z7u6yt0>TY^6$lI7az%#F@PsJfpYo6R33ICxPbuzq5 z`s+nf=7}7TGsS;J#;;Ji;jfUXDrC$G`E{M7mwH)lopK-IoTq?~z$O26GW;4Da*c8) zLaveFH%k9FePutQuW%`TrHolA{r{9{mn-KWe7Ou?E>kV1uk1PTE4WXPi~146S^iQo3W(-7VdI=`M$xhCWk^JA%6cH}PsGZaYd~1T}aSxqp=IW#ocaQOM88Mf%yC z`*S@|JSs-f+wY2O!l8t?@{}X=;!Zp*>liqsLwowUZwV!soo-c3i=!Ml~g}L z-xx%%Q~O3O%9_5YkD$-0R{*^h7kZ2nJ$B~mN6*H^U*&eujZ$YW^eL3CUUSg1=(6*R zgl6hO-(8iu`nceIfc6^deTYX?{wH9Q^Z$@;Q!k}>0wLql-7mlX zmi+8#J^}X$4k~B=lp*KJuf#n$FOqJ94F6B*{$09|Eb#UBvcy|t{4N>tf&6;2^h4S} zo&g!>Rv7}>2C4iqXxbiz4N4ETa{2c-LsbUUQ$l5UoCN!~)QP7cA1 ztA6RKc=(0%tK&gVvK{dJNQRs*-OHtGlrCf`hgVx{9f{CLd-CK|c=8>_k|(C#&i*{@dn4kIH2Q_V zCNvAi=!CJv0De@0ON&t`fYBhxLf}>r zT$z~jSA(m?G5Td=^y@;)_TaLxdAO`>kS&HrU@5KwHjJx~EoUoO5nF}JiLvoo%=mA> zRmpC`>^_Kb@^0|Jue1AE9eWUSuU7Ujdj$DD%AR0zF@8RY8Rj$WhipE34jO=k>;;Tv ziveAygxDw{HUge3W|pI1Hs-~-EKiPw*(kjTFiq9OhjeAQgsh2>H4&&u9I0z@r2&R| z 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 a4I?{t?we|gs%WH+;a{aBFMC*XE&D$zm8Go! literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Playfair-Display/400.ttf b/backend/assets/fonts/Playfair-Display/400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..eaf6b45000c97b2f1026eafd56f55c16846f1e7f GIT binary patch literal 53956 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 F{{w#!=mh`( literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Playfair-Display/600.ttf b/backend/assets/fonts/Playfair-Display/600.ttf new file mode 100644 index 0000000000000000000000000000000000000000..91db371005fe74dee7697ab3fddf8c7f4c2bf6ca GIT binary patch literal 54068 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 EKf3Z`bpQYW literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Playfair-Display/700.ttf b/backend/assets/fonts/Playfair-Display/700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..373ea2e8dc945d9a3276ba1bd19ab79634de1ca5 GIT binary patch literal 53996 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 MWbW1nMIU1R5BXnhwg3PC literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Poppins/400.ttf b/backend/assets/fonts/Poppins/400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1b2780848a9bb5b6c080d33e3b58040d46846d47 GIT binary patch literal 16288 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 M&TsPaat-8v04$1pMgRZ+ literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Poppins/600.ttf b/backend/assets/fonts/Poppins/600.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d61ff4b6e2524c8d71cb5fcb637eb33f6cc5a5e1 GIT binary patch literal 16192 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 literal 0 HcmV?d00001 diff --git a/backend/assets/fonts/Poppins/700.ttf b/backend/assets/fonts/Poppins/700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7a4bbd2a6bcc995a71d60899745abfb7316acb23 GIT binary patch literal 15952 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" } }, + "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