From 60abe8c76d6a6f7de6d9036fd0d0273805bcafb4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 18:18:15 +0200 Subject: [PATCH 01/17] chore(migrations): consolidate CRM migrations 102-143 + extract email-template seeds to self-heal services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces what would have been 42 individual in-flight migrations (102→143 on feat/crm) with one consolidated migration that creates every CRM table in its final shape — no ALTER chains. Coexists with upstream's pre-existing 102-106 by filename suffix; the runner sorts within same-number groups. Tables consolidated: - business_profile + business_bank_accounts (issuer block, fonts, PDF layout knobs, tax_id, timezone) - payment_term_templates (legacy) + payment_net_days_templates + payment_timing_templates (124's split) - quotes / quote_line_items / quote_line_item_presets / quote_action_tokens - invoices / invoice_line_items / invoice_payment_log / invoice_payment_check_tokens - contracts / contract_blocks (13 system blocks seeded) / contract_block_inclusions / contract_action_tokens - event_payment_plans, customer_hour_entries, document_sequences ALTER on upstream tables (hasColumn-guarded): - events: quote_id, calendar columns (event_time_*, is_full_day), event_reminder_* - customer_accounts: billing_cadence/cycle_day, country_name, feature_hours_logging, hourly_rate_minor Seeds: - RBAC perms (quotes/bills/contracts .view/.manage) + customers.create split into edit + events (mig 134) - Feature flags (quotes, bills, contracts, hoursLogging, taxReport, calendar, calendarBooking, reminderEmails, crmDevelopment, messaging — all default OFF) - 30+ CRM app_settings rows (skonto/QR/reminder windows, payment defaults, installment defaults, ToS, event reminder defaults) - 4 + 5 + 4 payment-term system rows across the legacy + split tables Email-template content moves out of the schema diff into three runtime self-heal service files that idempotently create missing rows + backfill empty translations on first access (per the maintainer's "never ship compensation migrations" rule): - backend/src/services/crmEmailTemplates.js (NEW) — quote_sent, quote_accepted_*, quote_declined_admin, invoice_sent, invoice_reminder_first/second, invoice_paid_receipt, invoice_cancelled, invoice_payment_check, invoice_paid_admin_notification, storno_issued - backend/src/services/contractEmailTemplates.js — contract_sent, contract_fully_signed, contract_signed_admin_notification - backend/src/services/eventReminderTemplates.js — event_reminder_default + per-event-type variants Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly, all CRM tables present, seeds populated. --- .../migrations/core/107_crm_consolidated.js | 1845 +++++++++++++++++ .../src/services/contractEmailTemplates.js | 181 ++ backend/src/services/crmEmailTemplates.js | 452 ++++ .../src/services/eventReminderTemplates.js | 333 +++ 4 files changed, 2811 insertions(+) create mode 100644 backend/migrations/core/107_crm_consolidated.js create mode 100644 backend/src/services/contractEmailTemplates.js create mode 100644 backend/src/services/crmEmailTemplates.js create mode 100644 backend/src/services/eventReminderTemplates.js diff --git a/backend/migrations/core/107_crm_consolidated.js b/backend/migrations/core/107_crm_consolidated.js new file mode 100644 index 00000000..b3608c89 --- /dev/null +++ b/backend/migrations/core/107_crm_consolidated.js @@ -0,0 +1,1845 @@ +/** + * Migration 107 — CRM consolidated schema + seeds. + * + * --------------------------------------------------------------------------- + * + * Single migration that brings up the entire CRM feature area: + * + * - Quotes / Invoices / Contracts data layer (tables, FKs, indexes) + * - Payment-term templates (legacy + split: net-days + timing) + * - Installment plans, deal_uuid lineage across quotes / contracts / + * invoices, monthly billing accumulator + * - Customer hour-logging tables + customer-side feature flags + * - Admin calendar columns on events, pre-event reminder columns + * - Document sequence helper (gap-free atomic numbering) + * - Email templates seeded for all CRM lifecycle events (en + de) + * - RBAC permissions for the new resources (split customers.* too) + * - Feature flags (quotes / bills / contracts / hoursLogging / taxReport / + * calendar / calendarBooking / reminderEmails / crmDevelopment / messaging) + * + * Replaces the in-flight chain of 42 separate migrations (102..143 on + * the `feat/crm` branch). Each table is created in its FINAL form via + * a single createTable() — no ALTER chains. Seeds are grouped by target + * table at the bottom. + * + * --------------------------------------------------------------------------- + * + * Sections (in order): + * + * 1. Issuer block — business_profile (singleton) + business_bank_accounts + * 2. Payment-term templates — legacy + split (net-days + timing) + * 3. Quotes — table, line items, line-item presets, action tokens + * 4. Invoices — table, line items, payment log + * 5. Contracts — table, blocks, block inclusions, action tokens, + * signatures, payments-tokens + * 6. Event ↔ document glue — event_payment_plans + * 7. Customer hour logging — customer_hour_entries + * 8. Document sequences — atomic gap-free numbering helper + * 9. ALTER existing upstream tables — events / customer_accounts / + * monthly_billings / app_settings / admin_users + * 10. Seeds — payment-term templates (system rows) + * 11. Seeds — split payment templates (net-days + timing) + * 12. Seeds — email templates (8 from 102 + per-event-type reminders) + * 13. Seeds — RBAC permissions (4 new + split customers.*) + * 14. Seeds — feature flags + app_settings (CRM defaults, ToS, + * payment defaults, reminder defaults, locked roadmap force-off) + * + * --------------------------------------------------------------------------- + * + * Idempotency: every step is guarded by hasTable / hasColumn / row- + * existence check. Safe to re-run; safe on a partial-state DB. + * + * Money is INTEGER minor units (cents/Rappen) + ISO 4217 currency code. + * Never DECIMAL/FLOAT — avoids floating-point drift on totals. + * + * Cross-dialect: tested on SQLite + PostgreSQL. JSON columns use + * `t.jsonb` where available (`t.json` falls back). + * + * Translations: en + de hand-translated. fr/nl/pt/ru are intentionally + * absent on email templates; the renderer falls through to en until + * the admin overrides them via Templates UI. Flag for native review + * in the PR description. + */ + +const crypto = require('crypto'); + +// =========================================================================== +// Inline seed payloads — kept at the top of the file so reviewers can scan +// the seeded data without scrolling past 1000 lines of schema. Schema first, +// then SEEDS section at the bottom of up() references these. +// =========================================================================== + +// Seeded inside Section 2 createTable blocks (only fire on fresh table +// creation — no idempotency check needed). 102 + 124 source migrations. +const SYSTEM_PAYMENT_TERM_TEMPLATES = [ + { + name: 'Komplettzahlung nach Auslieferung', + description: 'Zahlbar nach Erhalt der Bilder, ohne Abzüge.', + net_days: 30, + skonto_percent: null, + skonto_within_days: null, + installments: [ + { label: 'Gesamtbetrag nach Auslieferung', percent: 100, trigger: 'after_delivery', offset_days: 0 }, + ], + display_order: 10, + }, + { + name: 'Komplettzahlung vor Event', + description: 'Zahlbar 7 Tage vor dem Event.', + net_days: 30, + skonto_percent: null, + skonto_within_days: null, + installments: [ + { label: 'Gesamtbetrag vor Event', percent: 100, trigger: 'before_event', offset_days: -7 }, + ], + display_order: 20, + }, + { + name: 'Komplettzahlung nach Event', + description: 'Zahlbar nach dem Event, vor Auslieferung.', + net_days: 30, + skonto_percent: null, + skonto_within_days: null, + installments: [ + { label: 'Gesamtbetrag nach Event', percent: 100, trigger: 'after_event', offset_days: 0 }, + ], + display_order: 30, + }, + { + name: '3 Raten 30/30/40', + description: '30% bei Auftragsbestätigung, 30% vor Event, 40% nach Auslieferung.', + net_days: 30, + skonto_percent: null, + skonto_within_days: null, + installments: [ + { label: 'Anzahlung bei Auftragsbestätigung', percent: 30, trigger: 'quote_accepted', offset_days: 0 }, + { label: 'Teilzahlung vor Event', percent: 30, trigger: 'before_event', offset_days: -7 }, + { label: 'Schlusszahlung nach Auslieferung', percent: 40, trigger: 'after_delivery', offset_days: 0 }, + ], + display_order: 40, + }, +]; + +// Net-days side of the split (mig 124). "Sofort fällig" = net_days=0 — +// invoiced amount due on issue date. +const SYSTEM_NET_DAYS = [ + { name: 'Sofort fällig', net_days: 0, display_order: 5 }, + { name: 'Net 14', net_days: 14, display_order: 10 }, + { name: 'Net 30', net_days: 30, display_order: 20 }, + { name: 'Net 60', net_days: 60, display_order: 30 }, + { name: 'Net 90', net_days: 90, display_order: 40 }, +]; + +// Timing side of the split (mig 124). Names match SYSTEM_PAYMENT_TERM_TEMPLATES +// so the old + new system rows are equivalent — the backfill on existing +// installs name-matches against this list. +const SYSTEM_TIMING = SYSTEM_PAYMENT_TERM_TEMPLATES.map((t) => ({ + name: t.name, + description: t.description, + installments: t.installments, + display_order: t.display_order, +})); + +// Contract block library system seeds (mig 130 + 131). Twelve +// EXAMPLES-ONLY blocks across six sections — every body carries the +// "have your lawyer review before sending" disclaimer in its +// description. The quote_line_items_table block (mig 131) is a +// special render-time inclusion: contractService injects a real PDF +// table of the source quote's line items below the body when this +// block is included on a contract that has a source quote. +const SYSTEM_BLOCKS = [ + // ---- BASICS ---- + { + slug: 'basics_service', + section: 'basics', + name: 'Vertragsgegenstand (Foto-/Videoauftrag)', + description: 'EXAMPLE — have your lawyer review before sending. Beschreibt Art und Umfang der zu erbringenden fotografischen / filmischen Leistungen.', + display_order: 10, + body_text_de: `**Vertragsgegenstand** + +Der Auftraggeber ({{customer_name}}) beauftragt den Auftragnehmer ({{issuer_company_name}}) mit der Erbringung der nachfolgend beschriebenen fotografischen und/oder filmischen Leistungen im Rahmen der Veranstaltung „{{event_name}}" am {{event_date}}. + +Die konkrete Leistungsbeschreibung (Anzahl Stunden, Locations, Lieferumfang) ergibt sich aus dem zugrundeliegenden Angebot bzw. aus der Auftragsbestätigung.`, + body_text: `**Subject of contract** + +The client ({{customer_name}}) commissions the contractor ({{issuer_company_name}}) to provide the photographic and/or filming services described below in connection with the event "{{event_name}}" on {{event_date}}. + +The specific scope of work (hours, locations, deliverables) is set out in the underlying quote or in the order confirmation.`, + }, + + // ---- SCOPE ---- + { + slug: 'image_rights_private', + section: 'scope', + name: 'Bildrechte – privater Gebrauch', + description: 'EXAMPLE — have your lawyer review before sending. Standardklausel für private Endkunden (Hochzeit, Familie, Portrait). Kein kommerzieller Gebrauch.', + display_order: 10, + body_text_de: `**Bildrechte – privater Gebrauch** + +Der Auftraggeber erhält ein einfaches, zeitlich und örtlich unbeschränktes Nutzungsrecht an den gelieferten Bildern für ausschliesslich private Zwecke (Familienalbum, Druck, Versand an Freunde und Verwandte, Social-Media-Beiträge im persönlichen Kontext). + +Eine kommerzielle Nutzung – insbesondere Werbung, Verkauf, Lizenzierung an Dritte sowie redaktionelle Veröffentlichung – ist ausdrücklich nicht eingeschlossen und bedarf einer separaten schriftlichen Vereinbarung. + +Das Urheberrecht verbleibt vollständig beim Auftragnehmer.`, + body_text: `**Image rights — private use** + +The client receives a simple, time- and location-unlimited usage right to the delivered images for strictly private purposes (family album, prints, sharing with friends and relatives, social-media posts in a personal context). + +Commercial use — in particular advertising, resale, licensing to third parties, and editorial publication — is expressly NOT included and requires a separate written agreement. + +Copyright remains entirely with the contractor.`, + }, + { + slug: 'image_rights_commercial', + section: 'scope', + name: 'Bildrechte – kommerzielle Nutzung', + description: 'EXAMPLE — have your lawyer review before sending. Erweiterte Klausel für Geschäftskunden, Agenturen, Marken. Lizenzumfang muss im Auftrag konkretisiert werden.', + display_order: 20, + body_text_de: `**Bildrechte – kommerzielle Nutzung** + +Der Auftraggeber erhält ein einfaches, nicht-ausschliessliches Nutzungsrecht an den gelieferten Bildern für die im Auftrag bezeichneten kommerziellen Zwecke (Werbung, Online-Marketing, Printmedien, Social-Media-Kommunikation des Auftraggebers). + +Der räumliche und zeitliche Lizenzumfang sowie zulässige Bearbeitungen sind im Hauptauftrag bzw. im Angebot zu konkretisieren. Eine Übertragung der Nutzungsrechte an Dritte (z. B. Agenturen, Konzerngesellschaften, Vertragspartner) sowie eine Sublizenzierung sind nur nach vorheriger schriftlicher Zustimmung des Auftragnehmers zulässig. + +Der Auftragnehmer hat das Recht, ihn als Urheber gemäss § 13 UrhG bzw. den jeweils anwendbaren urheberrechtlichen Vorschriften zu nennen. Verstösse gegen die Nennungspflicht berechtigen den Auftragnehmer zu einem Aufschlag von 100 % auf die ursprüngliche Lizenzgebühr. + +Das Urheberrecht verbleibt beim Auftragnehmer.`, + body_text: `**Image rights — commercial use** + +The client receives a simple, non-exclusive usage right to the delivered images for the commercial purposes specified in the order (advertising, online marketing, print media, the client's social-media communication). + +The territorial and temporal scope of the licence and permitted edits are to be set out in the main order or in the quote. A transfer of usage rights to third parties (e.g. agencies, group companies, contractual partners) or sublicensing is only permitted with the contractor's prior written consent. + +The contractor is entitled to be named as author in accordance with the applicable copyright law (§ 13 German Copyright Act / equivalent). Breaches of the attribution obligation entitle the contractor to a surcharge of 100 % on the original licence fee. + +Copyright remains with the contractor.`, + }, + + // ---- PRIVACY ---- + { + slug: 'model_release_private', + section: 'privacy', + name: 'Modelvertrag / Persönlichkeitsrecht – privat', + description: 'EXAMPLE — have your lawyer review before sending. Einwilligung zur Anfertigung der Bilder für private Aufträge.', + display_order: 10, + body_text_de: `**Einwilligung zur Aufnahme** + +Der Auftraggeber bestätigt, alle von ihm benannten und auf den Aufnahmen erkennbaren Personen vorab darüber informiert zu haben, dass im Rahmen der Veranstaltung Foto- und/oder Filmaufnahmen entstehen. + +Die Aufnahmen werden ausschliesslich zu den im Vertrag vereinbarten privaten Zwecken erstellt. Eine Veröffentlichung durch den Auftragnehmer – etwa im Portfolio oder auf Social Media – findet nur statt, wenn der Auftraggeber dem ausdrücklich (z. B. über die separat angebotene Portfolio-Freigabe) zustimmt. + +Bestehende Persönlichkeitsrechte abgebildeter Personen sind vom Auftraggeber zu wahren.`, + body_text: `**Consent to photography** + +The client confirms having informed in advance all persons identified by them and recognisable in the recordings that photographs and/or video will be taken during the event. + +The recordings are produced exclusively for the private purposes agreed in the contract. Publication by the contractor — for example in a portfolio or on social media — only takes place if the client expressly consents (e.g. via the separately offered portfolio release). + +The client is responsible for safeguarding the personality rights of the persons depicted.`, + }, + { + slug: 'model_release_commercial', + section: 'privacy', + name: 'Modelvertrag – kommerziell', + description: 'EXAMPLE — have your lawyer review before sending. Modelvertragsklausel für kommerzielle Shootings; setzt unterschriebene Model-Releases der abgebildeten Personen voraus.', + display_order: 20, + body_text_de: `**Persönlichkeitsrechte / Model-Release** + +Der Auftraggeber sichert zu, von allen auf den Aufnahmen erkennbaren Personen vor Beginn der Aufnahme eine schriftliche Einwilligungserklärung (Model-Release) einzuholen, die mindestens den im Auftrag definierten Nutzungsumfang abdeckt. + +Auf Anforderung des Auftragnehmers übergibt der Auftraggeber Kopien der Releases. Bei minderjährigen Personen ist zusätzlich die Einwilligung beider Erziehungsberechtigter erforderlich. + +Der Auftraggeber stellt den Auftragnehmer von allen Ansprüchen Dritter wegen Verletzung von Persönlichkeitsrechten frei, sofern diese aus einer unvollständigen oder fehlerhaften Einwilligung resultieren, die nicht vom Auftragnehmer eingeholt wurde.`, + body_text: `**Personality rights / model release** + +The client warrants that, prior to the start of recording, a written consent (model release) covering at least the scope of use defined in the order has been obtained from every person recognisable in the recordings. + +Upon the contractor's request, the client provides copies of the releases. For minors, the consent of both legal guardians is additionally required. + +The client indemnifies the contractor against all third-party claims for infringement of personality rights insofar as such claims arise from an incomplete or defective consent that was not obtained by the contractor.`, + }, + { + slug: 'model_release_minors', + section: 'privacy', + name: 'Aufnahmen von Minderjährigen', + description: 'EXAMPLE — have your lawyer review before sending. Zusätzliche Klausel, wenn Kinder fotografiert werden. Sollte zusammen mit einem der Model-Release-Blöcke aktiviert werden.', + display_order: 30, + body_text_de: `**Aufnahmen von Minderjährigen** + +Werden Personen unter 18 Jahren abgebildet, sichert der Auftraggeber zu, vorab die schriftliche Einwilligung sämtlicher Erziehungsberechtigter eingeholt zu haben. Bei getrennt lebenden Erziehungsberechtigten ist die Zustimmung beider Berechtigten erforderlich. + +Auf Verlangen der oder des Erziehungsberechtigten sind Aufnahmen einzelner Kinder unverzüglich und ohne Erstattung von der Auslieferung auszunehmen sowie auf Wunsch dauerhaft zu löschen. Bereits ausgelieferte Bilder bleiben hiervon unberührt.`, + body_text: `**Recordings of minors** + +If persons under the age of 18 are depicted, the client warrants having obtained, in advance, the written consent of all legal guardians. Where guardians live separately, the consent of both is required. + +At the request of a guardian, recordings of an individual child are to be excluded from delivery without refund and, on request, permanently deleted. Images already delivered are not affected.`, + }, + { + slug: 'dsgvo_data_protection', + section: 'privacy', + name: 'Datenschutz (DSGVO)', + description: 'EXAMPLE — have your lawyer review before sending. Hinweis auf Verarbeitung personenbezogener Daten gemäss DSGVO / nDSG.', + display_order: 40, + body_text_de: `**Datenschutz** + +Der Auftragnehmer verarbeitet personenbezogene Daten des Auftraggebers (Kontaktdaten, Auftragsdaten, gegebenenfalls Bildmaterial) ausschliesslich zur Vertragserfüllung und im Rahmen der gesetzlichen Aufbewahrungspflichten. + +Die Verarbeitung erfolgt nach Massgabe der jeweils anwendbaren Datenschutzgesetze (DSGVO, nDSG/CH-DSG). Detaillierte Informationen zu Art, Umfang, Zweck und Speicherdauer sowie zu den Rechten der betroffenen Personen sind der Datenschutzerklärung des Auftragnehmers zu entnehmen. + +Bildmaterial, das identifizierbare Personen zeigt, wird ausschliesslich auf gesicherten Systemen verarbeitet und an Dritte nur im Rahmen des im Vertrag definierten Nutzungsumfangs weitergegeben.`, + body_text: `**Data protection** + +The contractor processes the client's personal data (contact details, order data, image material where applicable) exclusively for the purpose of fulfilling the contract and within the scope of statutory retention obligations. + +Processing is carried out in accordance with the applicable data-protection laws (GDPR, Swiss FADP). Detailed information on the type, scope, purpose and storage duration as well as on the rights of data subjects can be found in the contractor's privacy notice. + +Image material identifying individual persons is processed exclusively on secured systems and shared with third parties only within the scope of use defined in the contract.`, + }, + + // ---- COMMERCIAL ---- + { + slug: 'payment_terms_reference', + section: 'commercial', + name: 'Zahlungsbedingungen (Verweis)', + description: 'EXAMPLE — have your lawyer review before sending. Verweist auf die im Auftrag / Angebot definierten Zahlungsbedingungen. Die konkreten Zahlen werden über Platzhalter eingefügt.', + display_order: 10, + body_text_de: `**Zahlungsbedingungen** + +Die Vergütung ergibt sich aus dem zugrundeliegenden Angebot. Sofern dort nicht anders geregelt, ist die Rechnung innerhalb von {{net_days}} Tagen nach Rechnungsdatum ohne Abzug zur Zahlung fällig. + +Bei Zahlung innerhalb von {{skonto_within_days}} Tagen nach Rechnungsdatum wird ein Skonto von {{skonto_percent}} % gewährt. + +Der Auftraggeber gerät ohne weitere Mahnung in Verzug, wenn er die Rechnung nicht innerhalb der genannten Frist begleicht. Es gelten die gesetzlichen Verzugszinsen.`, + body_text: `**Payment terms** + +The remuneration is set out in the underlying quote. Unless otherwise specified there, the invoice is payable within {{net_days}} days of the invoice date, with no deductions. + +For payment within {{skonto_within_days}} days of the invoice date a discount of {{skonto_percent}} % is granted. + +The client is in default without further reminder if the invoice is not settled within the stated period. Statutory interest on overdue payments applies.`, + }, + { + slug: 'cancellation_tiered', + section: 'commercial', + name: 'Stornierungsregelung (gestaffelt)', + description: 'EXAMPLE — have your lawyer review before sending. Gestaffelte Stornogebühren je nach Vorlaufzeit zum Event. Beträge im Auftrag konkretisierbar.', + display_order: 20, + body_text_de: `**Stornierung und Rücktritt** + +Bei einer Stornierung durch den Auftraggeber gelten folgende Pauschalen, sofern keine günstigere Vereinbarung erzielt wird: + +- bei Stornierung mehr als 60 Tage vor dem Veranstaltungstermin: {{cancellation_30d_percent}} % der vereinbarten Gesamtvergütung +- bei Stornierung 30 bis 60 Tage vor dem Veranstaltungstermin: 50 % der vereinbarten Gesamtvergütung +- bei Stornierung weniger als 30 Tage vor dem Veranstaltungstermin: 75 % der vereinbarten Gesamtvergütung +- bei Stornierung weniger als 7 Tage vor dem Veranstaltungstermin: 100 % der vereinbarten Gesamtvergütung + +Bereits geleistete Anzahlungen werden auf die Stornogebühr angerechnet. Dem Auftraggeber bleibt der Nachweis eines geringeren Schadens vorbehalten. + +Höhere Gewalt (Krankheit mit ärztlichem Attest, behördliche Anordnungen, Naturkatastrophen) berechtigt beide Parteien zur kostenfreien Verschiebung des Termins.`, + body_text: `**Cancellation and withdrawal** + +If the client cancels, the following flat-rate fees apply unless a more favourable arrangement is reached: + +- cancellation more than 60 days before the event: {{cancellation_30d_percent}} % of the agreed total remuneration +- cancellation 30 to 60 days before the event: 50 % of the agreed total remuneration +- cancellation less than 30 days before the event: 75 % of the agreed total remuneration +- cancellation less than 7 days before the event: 100 % of the agreed total remuneration + +Down-payments already made are credited against the cancellation fee. The client reserves the right to prove that a lower loss occurred. + +Force majeure (illness with medical certificate, government orders, natural disasters) entitles both parties to reschedule the appointment free of charge.`, + }, + + // ---- NDA ---- + { + slug: 'nda_mutual', + section: 'nda', + name: 'Vertraulichkeit (beidseitig)', + description: 'EXAMPLE — have your lawyer review before sending. Gegenseitige Geheimhaltungsverpflichtung. Geeignet, wenn der Auftrag vertrauliche Inhalte umfasst (Hochzeit prominenter Personen, Corporate-Event, NDA-pflichtige Inhalte).', + display_order: 10, + body_text_de: `**Vertraulichkeit** + +Beide Parteien verpflichten sich, sämtliche im Zusammenhang mit diesem Vertrag erlangten vertraulichen Informationen der jeweils anderen Partei – darunter Geschäftsgeheimnisse, persönliche Daten, Inhalte des Auftrags sowie die Identität der abgebildeten Personen – streng vertraulich zu behandeln und nicht an Dritte weiterzugeben. + +Die Vertraulichkeitsverpflichtung gilt während der Vertragslaufzeit und für die Dauer von drei (3) Jahren nach deren Beendigung. Sie gilt nicht für Informationen, die nachweislich öffentlich bekannt sind, ohne Vertraulichkeitspflicht von Dritten erlangt wurden oder aufgrund gesetzlicher Verpflichtung offengelegt werden müssen. + +Verstösse gegen die Vertraulichkeitspflicht können Schadensersatzansprüche der jeweils anderen Partei nach sich ziehen.`, + body_text: `**Confidentiality** + +Both parties undertake to treat all confidential information of the other party obtained in connection with this contract — including trade secrets, personal data, the subject matter of the order, and the identity of the persons depicted — as strictly confidential and not to disclose it to third parties. + +The confidentiality obligation applies during the term of the contract and for three (3) years after its termination. It does not apply to information that is demonstrably publicly known, obtained from third parties without a confidentiality obligation, or required to be disclosed by law. + +Breaches of the confidentiality obligation may give rise to claims for damages by the respective other party.`, + }, + + // ---- CLOSING ---- + { + slug: 'closing_jurisdiction_ch', + section: 'closing', + name: 'Schlussbestimmungen (Schweizer Recht)', + description: 'EXAMPLE — have your lawyer review before sending. Gerichtsstand und anwendbares Recht für Schweizer Auftragnehmer.', + display_order: 10, + body_text_de: `**Schlussbestimmungen** + +Auf diesen Vertrag findet ausschliesslich schweizerisches Recht unter Ausschluss des UN-Kaufrechts Anwendung. + +Ausschliesslicher Gerichtsstand für sämtliche Streitigkeiten aus oder im Zusammenhang mit diesem Vertrag ist – soweit gesetzlich zulässig – der Sitz des Auftragnehmers. + +Sollten einzelne Bestimmungen dieses Vertrags ganz oder teilweise unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen davon unberührt. An die Stelle der unwirksamen Bestimmung tritt eine wirksame Regelung, die dem wirtschaftlichen Zweck der unwirksamen am nächsten kommt. + +Änderungen und Ergänzungen dieses Vertrags bedürfen der Schriftform. Dies gilt auch für die Aufhebung dieses Schriftformerfordernisses.`, + body_text: `**Closing provisions** + +This contract is governed exclusively by Swiss law, to the exclusion of the UN Convention on Contracts for the International Sale of Goods. + +The exclusive place of jurisdiction for all disputes arising out of or in connection with this contract is — to the extent permitted by law — the registered office of the contractor. + +Should individual provisions of this contract be or become wholly or partly invalid, the validity of the remaining provisions shall not be affected. The invalid provision shall be replaced by a valid one which comes closest to its economic purpose. + +Amendments and supplements to this contract must be made in writing. This also applies to any waiver of this written-form requirement.`, + }, + { + slug: 'closing_jurisdiction_de', + section: 'closing', + name: 'Schlussbestimmungen (Deutsches Recht)', + description: 'EXAMPLE — have your lawyer review before sending. Gerichtsstand und anwendbares Recht für deutsche Auftragnehmer.', + display_order: 20, + body_text_de: `**Schlussbestimmungen** + +Auf diesen Vertrag findet ausschliesslich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts Anwendung. + +Ausschliesslicher Gerichtsstand für sämtliche Streitigkeiten aus oder im Zusammenhang mit diesem Vertrag ist – soweit gesetzlich zulässig – der Geschäftssitz des Auftragnehmers. + +Sollten einzelne Bestimmungen dieses Vertrags ganz oder teilweise unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen davon unberührt. An die Stelle der unwirksamen Bestimmung tritt eine wirksame Regelung, die dem wirtschaftlichen Zweck der unwirksamen am nächsten kommt. + +Änderungen und Ergänzungen dieses Vertrags bedürfen der Schriftform. Dies gilt auch für die Aufhebung dieses Schriftformerfordernisses.`, + body_text: `**Closing provisions** + +This contract is governed exclusively by the law of the Federal Republic of Germany, to the exclusion of the UN Convention on Contracts for the International Sale of Goods. + +The exclusive place of jurisdiction for all disputes arising out of or in connection with this contract is — to the extent permitted by law — the place of business of the contractor. + +Should individual provisions of this contract be or become wholly or partly invalid, the validity of the remaining provisions shall not be affected. The invalid provision shall be replaced by a valid one which comes closest to its economic purpose. + +Amendments and supplements to this contract must be made in writing. This also applies to any waiver of this written-form requirement.`, + }, + // ---- Added by migration 131 — scope section, after image-rights blocks. + // Auto-inserts a table of the source quote's line items below the body. + { + 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.', + display_order: 99, + body_text_de: 'Leistungspositionen gemäß Angebot {{source_quote_number}}:', + body_text: 'Service items per quote {{source_quote_number}}:', + }, +]; + +// ---- Permissions seeded in Section 13 ------------------------------------- +// Mig 102 (4 quotes + bills perms) + mig 130 (2 contracts perms). Granted +// to super_admin + admin on insert. Editor/viewer roles locked out by +// default (matches customers.* pattern from mig 090). +const NEW_PERMISSIONS = [ + { name: 'quotes.view', display_name: 'View Quotes', category: 'quotes', description: 'View quotes and their line items' }, + { name: 'quotes.manage', display_name: 'Manage Quotes', category: 'quotes', description: 'Create, edit, send, duplicate, and convert quotes' }, + { name: 'bills.view', display_name: 'View Invoices', category: 'billing', description: 'View invoices and their payment status' }, + { name: 'bills.manage', display_name: 'Manage Invoices', category: 'billing', description: 'Create, edit, send invoices, mark them paid, and send reminders' }, + { name: 'contracts.view', display_name: 'View Contracts', category: 'contracts', description: 'View contracts and their signing status' }, + { name: 'contracts.manage', display_name: 'Manage Contracts', category: 'contracts', description: 'Create, edit, send and counter-sign contracts and manage the block library' }, +]; + +// Split-permission projection from mig 134 — every role that holds +// `customers.create` gains the two new narrower perms so no role loses +// capability on upgrade. The split itself lets admin opt into a finer +// grant later via the Role editor. +const CUSTOMERS_SPLIT_PERMISSIONS = [ + { name: 'customers.edit', display_name: 'Edit Customers', category: 'customers', description: 'Edit customer records, manage hour-logging entries, trigger password resets, and fire admin-override monthly bills.' }, + { name: 'customers.events', display_name: 'Assign Customers to Events', category: 'customers', description: 'Add or remove the events a customer is linked to. Does not grant the ability to edit the customer record.' }, +]; + +// ---- Feature flags seeded in Section 14 ----------------------------------- +// All default OFF on a fresh install — admin opts in via Settings → +// Features. The clients-section parent is DERIVED from these in the +// frontend FeatureFlagsContext (any one of them being on flips the +// parent on); the backend treats them as independent. +const NEW_FEATURE_FLAGS = [ + 'quotes', 'bills', 'contracts', 'taxReport', 'hoursLogging', + 'calendar', 'calendarBooking', 'reminderEmails', 'crmDevelopment', + 'messaging', +]; + +// ---- app_settings seeded in Section 14 ------------------------------------ +// CRM behaviour toggles — mig 102. Defaults ON so the feature works +// out of the box once an admin enables the master flag; admin can +// then opt out of Skonto / reminders / late fees / QR-bill in +// Settings → CRM-Settings → CRM behaviour. +const CRM_SUB_SETTINGS = [ + // Quote behaviour + { setting_key: 'crm_quotes_pdf_attachment_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_quotes_skonto_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_quotes_accept_window_minutes', setting_value: 15, setting_type: 'crm' }, + { setting_key: 'crm_quotes_default_valid_days', setting_value: 30, setting_type: 'crm' }, + // Invoice behaviour + { setting_key: 'crm_invoices_qr_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_invoices_reminders_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_invoices_reminder_first_days', setting_value: 14, setting_type: 'crm' }, + { setting_key: 'crm_invoices_reminder_second_days', setting_value: 30, setting_type: 'crm' }, + { setting_key: 'crm_invoices_late_fee_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_invoices_late_fee_minor', setting_value: 2500, setting_type: 'crm' }, + { setting_key: 'crm_invoices_late_fee_label', setting_value: 'Mahngebühr', setting_type: 'crm' }, + { setting_key: 'crm_invoices_skonto_business_days', setting_value: 5, setting_type: 'crm' }, + { setting_key: 'crm_invoices_skonto_percent_default', setting_value: 2, setting_type: 'crm' }, + // Numbering + { setting_key: 'crm_quotes_number_format', setting_value: 'Q-{YEAR}-{SEQ:04d}', setting_type: 'crm' }, + { setting_key: 'crm_invoices_number_format', setting_value: 'R-{YEAR}-{SEQ:04d}', setting_type: 'crm' }, + // Mig 104 — Terms-of-Service step on quote acceptance. + { setting_key: 'crm_quotes_tos_required', setting_value: false, setting_type: 'crm' }, + { setting_key: 'crm_quotes_tos_text', setting_value: '', setting_type: 'crm' }, + { setting_key: 'crm_quotes_tos_url', setting_value: '', setting_type: 'crm' }, + // Mig 130 — contracts behaviour. store_ip default ON (GDPR opt-out + // for operators with strict data-minimisation requirements). + { setting_key: 'crm_contracts_number_format', setting_value: 'C-{YEAR}-{SEQ:04d}', setting_type: 'crm' }, + { setting_key: 'crm_contracts_default_valid_days', setting_value: 30, setting_type: 'crm' }, + { setting_key: 'crm_contracts_pdf_attachment_enabled', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_contracts_require_drawn_signature', setting_value: false, setting_type: 'crm' }, + { setting_key: 'crm_contracts_allow_pdf_upload', setting_value: true, setting_type: 'crm' }, + { setting_key: 'crm_contracts_store_ip', setting_value: true, setting_type: 'crm' }, + // Mig 141 — installment-row defaults (pre-fill for new ad-hoc rows + // in the Quote/Invoice editors). + { setting_key: 'crm_invoices_installment_trigger_first', setting_value: 'quote_accepted', setting_type: 'crm' }, + { setting_key: 'crm_invoices_installment_days_before_event', setting_value: 14, setting_type: 'crm' }, + { setting_key: 'crm_invoices_installment_days_after_event', setting_value: 14, setting_type: 'crm' }, + // Mig 143 — pre-event customer reminder global toggles. + { setting_key: 'crm_event_reminders_enabled', setting_value: false, setting_type: 'crm' }, + { setting_key: 'crm_event_reminders_days_before', setting_value: 2, setting_type: 'crm' }, +]; + +// Customer-surface feature defaults — mig 109. These globally enable +// the Quotes / Bills tabs on the customer portal so admin doesn't have +// to flip the toggle per customer. Stored as `customer_surface` type. +const CUSTOMER_FEATURE_SETTINGS = [ + { setting_key: 'customer_feature_quotes_enabled', setting_value: true, setting_type: 'customer_surface' }, + { setting_key: 'customer_feature_bills_enabled', setting_value: true, setting_type: 'customer_surface' }, +]; + +// =========================================================================== +// up() +// =========================================================================== + +exports.up = async function (knex) { + // ------------------------------------------------------------------------- + // SECTION 1 — Issuer block (business_profile + business_bank_accounts) + // ------------------------------------------------------------------------- + // Source migrations: 102 (base + bank accounts), 103 (pdf_font_ttf_path), + // 106 (pdf_show_logo / _company_name), 107 (country_name), 108 (folding + // marks / logo height / company-name inline), 121 (pdf_font_family), + // 139 (tax_id for Steuernummer / §14 UStG). + if (!(await knex.schema.hasTable('business_profile'))) { + await knex.schema.createTable('business_profile', (table) => { + table.increments('id').primary(); + // Issuer block printed top-right on every PDF. All nullable so the + // singleton row can be seeded empty and filled in via Settings. + table.string('company_name', 255); + table.string('address_line1', 255); + table.string('address_line2', 255); + table.string('postal_code', 20); + table.string('city', 120); + table.string('state', 120); + table.string('country_code', 2); // ISO 3166-1 alpha-2 + table.string('country_name', 120); // free-text override (mig 107) + table.string('phone', 64); + table.string('mobile', 64); + table.string('email', 255); + table.string('website', 255); + // §14 UStG requires EITHER a VAT-ID (USt-IdNr.) OR a Steuernummer + // on every invoice; Kleinunternehmer (§19) typically only have the + // latter. Both columns nullable; admin populates whichever applies. + table.string('vat_id', 64); + table.string('tax_id', 64); // Steuernummer — mig 139 + table.string('vat_label', 64).defaultTo('MwSt.'); + // Stored as DECIMAL string to match app_settings percent values + // elsewhere; rendered as-is on PDFs. + table.decimal('vat_rate_default', 5, 2).defaultTo(0); + table.string('default_currency', 3).defaultTo('CHF'); + table.string('default_locale', 8).defaultTo('de'); + // 'swiss' | 'epc' | 'none' — drives QR rendering on invoice PDFs. + table.string('default_qr_format', 16).defaultTo('none'); + table.string('footer_line', 255); + // Relative path under storage/ — uploads via adminBranding.js. + table.string('logo_path', 512); + // PDF-font choice (priority order): + // 1. pdf_font_ttf_path — absolute / storage-relative TTF/OTF path + // (mig 103). Used when admin uploads a custom font. + // 2. pdf_font_family — bundled-fonts directory name like + // "Inter" / "Playfair-Display" (mig 121). Resolves to + // backend/assets/fonts//400.ttf + 700.ttf. + // 3. Fallback: PDFKit's built-in Helvetica. + table.string('pdf_font_ttf_path', 512); + table.string('pdf_font_family', 128).nullable(); + // PDF layout knobs (mig 106 + 108 + 110). Defaults preserve + // historical visual state per the "preserve existing visuals" + // rule — except pdf_quote_show_net_days / pdf_quote_show_skonto + // (mig 110) which default false: the historical default was to + // NOT show those blocks until the admin explicitly opts in. + table.boolean('pdf_show_logo').notNullable().defaultTo(true); + table.boolean('pdf_show_company_name').notNullable().defaultTo(true); + table.string('pdf_folding_marks', 16).notNullable().defaultTo('none'); + table.integer('pdf_logo_height').notNullable().defaultTo(56); + table.boolean('pdf_company_name_inline').notNullable().defaultTo(false); + table.boolean('pdf_quote_show_net_days').notNullable().defaultTo(false); + table.boolean('pdf_quote_show_skonto').notNullable().defaultTo(false); + // IANA timezone for date/time rendering on PDFs + admin calendar + // (mig 137). Nullable; frontend falls back to browser Intl TZ + // when blank. Admin-only setting. + table.string('timezone', 64); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + + // Seed the singleton row so getProfile() always returns something. + await knex('business_profile').insert({ id: 1 }); + } + + if (!(await knex.schema.hasTable('business_bank_accounts'))) { + await knex.schema.createTable('business_bank_accounts', (table) => { + table.increments('id').primary(); + table.integer('business_profile_id').unsigned().notNullable().defaultTo(1) + .references('id').inTable('business_profile').onDelete('CASCADE'); + table.string('label', 128); // e.g. "Hauptkonto" / "EUR-Konto" + table.string('account_holder', 255); + table.string('iban', 64).notNullable(); + table.string('bic', 16); + table.string('currency', 3); + table.boolean('is_default').notNullable().defaultTo(false); + table.integer('display_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['business_profile_id']); + table.index(['currency']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 2 — Payment-term templates (legacy + split) + // ------------------------------------------------------------------------- + // The legacy `payment_term_templates` (mig 102) mashed "Net days" and + // "installment plan" together. Migration 124 split them into two + // orthogonal pickers — `payment_net_days_templates` (Net 14/30/60/90) + // and `payment_timing_templates` (Komplettzahlung / 3 Raten). Both + // tables coexist: the legacy table stays referenced by historical + // quote/invoice rows; the editor reads/writes the new split FKs. + // + // Seeds embedded in the createTable blocks (fresh creation only ⇒ + // no idempotency check on the inserts needed). + if (!(await knex.schema.hasTable('payment_term_templates'))) { + await knex.schema.createTable('payment_term_templates', (table) => { + table.increments('id').primary(); + table.string('name', 128).notNullable(); + table.string('description', 255); + // Net payment window in days (30/60/90 etc.) used when no + // explicit due_date is set. + table.integer('net_days').notNullable().defaultTo(30); + // Skonto (early-payment discount) — both nullable when not offered. + table.decimal('skonto_percent', 5, 2); + table.integer('skonto_within_days'); + // JSON installments array. SQLite stores TEXT; Postgres JSONB. + // knex .json() picks the right native type per dialect. + table.json('installments').notNullable(); + table.boolean('is_system').notNullable().defaultTo(false); + table.boolean('is_active').notNullable().defaultTo(true); + table.integer('display_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['is_active']); + table.index(['is_system']); + }); + + for (const tpl of SYSTEM_PAYMENT_TERM_TEMPLATES) { + await knex('payment_term_templates').insert({ + name: tpl.name, + description: tpl.description, + net_days: tpl.net_days, + skonto_percent: tpl.skonto_percent, + skonto_within_days: tpl.skonto_within_days, + installments: JSON.stringify(tpl.installments), + is_system: true, + is_active: true, + display_order: tpl.display_order, + }); + } + } + + if (!(await knex.schema.hasTable('payment_net_days_templates'))) { + await knex.schema.createTable('payment_net_days_templates', (table) => { + table.increments('id').primary(); + table.string('name', 128).notNullable(); + table.string('description', 255); + table.integer('net_days').notNullable().defaultTo(30); + // Skonto lives on the net-days side because it modifies the + // payment window, not the installment timing. + table.decimal('skonto_percent', 5, 2); + table.integer('skonto_within_days'); + table.boolean('is_system').notNullable().defaultTo(false); + table.boolean('is_active').notNullable().defaultTo(true); + table.integer('display_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['is_active']); + table.index(['is_system']); + }); + + for (const tpl of SYSTEM_NET_DAYS) { + await knex('payment_net_days_templates').insert({ + name: tpl.name, + net_days: tpl.net_days, + is_system: true, + is_active: true, + display_order: tpl.display_order, + }); + } + } + + if (!(await knex.schema.hasTable('payment_timing_templates'))) { + await knex.schema.createTable('payment_timing_templates', (table) => { + table.increments('id').primary(); + table.string('name', 128).notNullable(); + table.string('description', 255); + // Installments JSON — same shape the renderer + scheduler consume + // from payment_term_snapshot: [{label, percent, trigger, offset_days}, ...] + table.json('installments').notNullable(); + table.boolean('is_system').notNullable().defaultTo(false); + table.boolean('is_active').notNullable().defaultTo(true); + table.integer('display_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['is_active']); + table.index(['is_system']); + }); + + for (const tpl of SYSTEM_TIMING) { + await knex('payment_timing_templates').insert({ + name: tpl.name, + description: tpl.description, + installments: JSON.stringify(tpl.installments), + is_system: true, + is_active: true, + display_order: tpl.display_order, + }); + } + } + + // ------------------------------------------------------------------------- + // SECTION 3 — Quotes (table + line items + presets + action tokens) + // ------------------------------------------------------------------------- + // Source migrations: 102 (base), 104 (ToS), 119 (line-item hierarchy + + // details_text), 124 (split payment-term FKs), 140 (deal_uuid), + // 142 (per-quote installments_override). Note mig 110 adds + // pdf_quote_show_* to business_profile, NOT quotes — handled in §1. + + if (!(await knex.schema.hasTable('quote_line_item_presets'))) { + await knex.schema.createTable('quote_line_item_presets', (table) => { + table.increments('id').primary(); + table.string('name', 128).notNullable(); + table.text('description'); + // Minor units (cents/Rappen) to avoid float drift. bigInteger + // keeps room for big-ticket items; SQLite silently promotes to + // INTEGER which fits 2^53-1. + table.bigInteger('unit_price_minor').notNullable().defaultTo(0); + table.string('currency', 3).notNullable().defaultTo('CHF'); + table.decimal('quantity_default', 10, 2).notNullable().defaultTo(1); + table.integer('display_order').notNullable().defaultTo(0); + table.boolean('is_active').notNullable().defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['is_active']); + }); + } + + if (!(await knex.schema.hasTable('quotes'))) { + await knex.schema.createTable('quotes', (table) => { + table.increments('id').primary(); + table.string('quote_number', 32).unique().notNullable(); + table.integer('customer_account_id').unsigned().notNullable() + .references('id').inTable('customer_accounts').onDelete('RESTRICT'); + + // draft | sent | accepted | declined | expired | converted + table.string('status', 16).notNullable().defaultTo('draft'); + table.string('language', 8).defaultTo('de'); + table.string('currency', 3).notNullable().defaultTo('CHF'); + + table.date('issue_date').notNullable(); + table.date('valid_until'); + + // Event-data snapshot — not a FK because the event might not + // exist yet (it's created on quote acceptance). + table.string('event_name', 255); + table.date('event_date'); + table.string('event_time_start', 8); // "HH:MM" + table.string('event_time_end', 8); + table.decimal('expected_duration_hours', 4, 2); + + // Payment terms — legacy FK + snapshot from mig 102, plus the + // split FKs from mig 124 (the forward path). New quote/invoice + // writes populate the split FKs; historical rows referenced via + // legacy stay editable through the snapshot. + table.integer('payment_term_template_id').unsigned() + .references('id').inTable('payment_term_templates').onDelete('RESTRICT'); + table.integer('payment_net_days_template_id').unsigned() + .references('id').inTable('payment_net_days_templates').onDelete('SET NULL'); + table.integer('payment_timing_template_id').unsigned() + .references('id').inTable('payment_timing_templates').onDelete('SET NULL'); + table.json('payment_term_snapshot'); // copied at send time + // Per-quote ad-hoc installments override (mig 142). When NULL, + // the chosen timing template's installments are used as-is. + table.json('payment_term_installments_override'); + + // Totals (server-computed, never trust client). + table.bigInteger('net_amount_minor').notNullable().defaultTo(0); + table.decimal('vat_rate', 5, 2).defaultTo(0); + table.bigInteger('vat_amount_minor').notNullable().defaultTo(0); + table.bigInteger('shipping_amount_minor').notNullable().defaultTo(0); + table.bigInteger('total_amount_minor').notNullable().defaultTo(0); + + table.text('intro_text'); + table.text('outro_text'); + table.text('internal_notes'); + table.string('cc_pdf_email', 255); + + // Lifecycle timestamps. + table.timestamp('sent_at'); + table.timestamp('responded_at'); // first accept/decline action + table.timestamp('response_locked_at'); // responded_at + 15min + table.timestamp('accepted_at'); + table.timestamp('declined_at'); + + // Terms-of-Service step on quote acceptance (mig 104). + // tos_accepted_at = when the customer ticked the box; snapshot + // of the text they accepted lives in tos_text_snapshot for audit. + table.timestamp('tos_accepted_at'); + table.text('tos_text_snapshot'); + + // Cross-document lineage (mig 140). Every quote/contract/invoice + // tied to one customer engagement shares this uuid — the lineage + // card reads everything via WHERE deal_uuid=? in one query. + table.uuid('deal_uuid').nullable(); + + // Set when the quote converts to an event. Nullable, ON DELETE + // SET NULL so deleting the event preserves the audit trail. + table.integer('converted_event_id').unsigned() + .references('id').inTable('events').onDelete('SET NULL'); + // Back-pointer to the contract this quote spawned (mig 131). + // Quote detail page deep-links to the contract; service refuses + // double conversion to event/invoice when this is set. + table.integer('converted_contract_id').unsigned() + .references('id').inTable('contracts').onDelete('SET NULL'); + + table.string('pdf_path', 512); + table.integer('business_bank_account_id').unsigned() + .references('id').inTable('business_bank_accounts').onDelete('SET NULL'); + table.integer('created_by_admin_id').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['customer_account_id']); + table.index(['status']); + table.index(['issue_date']); + table.index(['converted_contract_id']); + table.index('deal_uuid', 'quotes_deal_uuid_idx'); + }); + } + + if (!(await knex.schema.hasTable('quote_line_items'))) { + await knex.schema.createTable('quote_line_items', (table) => { + table.increments('id').primary(); + table.integer('quote_id').unsigned().notNullable() + .references('id').inTable('quotes').onDelete('CASCADE'); + table.integer('position').notNullable().defaultTo(0); // 1-based display + table.decimal('quantity', 10, 2).notNullable().defaultTo(1); + table.text('description').notNullable(); + table.bigInteger('unit_price_minor').notNullable().defaultTo(0); + table.decimal('discount_percent', 5, 2).notNullable().defaultTo(0); + // Server-computed: round((qty * unit) * (1 - discount/100)) + table.bigInteger('line_total_minor').notNullable().defaultTo(0); + // Hierarchy (mig 119). parent_line_item_id NULL = top-level + // (rolls into net); SET = sub-item (display-only itemisation). + // ON DELETE CASCADE matches "remove the line, remove its + // sub-items" mental model. One level deep only — service + // validators reject grandchildren. + table.integer('parent_line_item_id').unsigned() + .references('id').inTable('quote_line_items').onDelete('CASCADE'); + // Free-form notes under the description on PDFs + customer view + // (smaller, italic, indented). Good for fine print. + table.text('details_text'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['quote_id']); + table.index('parent_line_item_id', 'quote_line_items_parent_idx'); + }); + } + + if (!(await knex.schema.hasTable('quote_action_tokens'))) { + await knex.schema.createTable('quote_action_tokens', (table) => { + table.increments('id').primary(); + table.integer('quote_id').unsigned().notNullable() + .references('id').inTable('quotes').onDelete('CASCADE'); + // 64 hex chars = 32 bytes = 256 bits — same entropy as share tokens. + table.string('token', 64).unique().notNullable(); + table.timestamp('expires_at').notNullable(); + table.timestamp('used_at'); + table.string('used_action', 16); // 'accepted' | 'declined' | null + table.string('used_ip', 45); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['quote_id']); + table.index(['expires_at']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 4 — Invoices (table + line items + payment log + payment-check tokens) + // ------------------------------------------------------------------------- + // Source migrations on `invoices`: 102 (base), 111 (imported_pdf), + // 113 (payment-term FK + snapshot), 114 (kind/Storno discriminator + + // cancels/replaces/cancellation_storno + invoices_kind_status_idx), + // 115 (last_payment_check_at + token table), 123 (event snapshot), + // 124 (split payment-term FKs), 126 (skonto_disabled), 128 (monthly + // draft cols + invoices_monthly_draft_idx), 133 (partial-unique + // single-draft-per-customer index), 140 (deal_uuid). + // `invoice_line_items` gets parent_line_item_id + details_text (119). + // `invoice_payment_log` gets skonto_applied + skonto_amount_minor (126). + + if (!(await knex.schema.hasTable('invoices'))) { + await knex.schema.createTable('invoices', (table) => { + table.increments('id').primary(); + table.string('invoice_number', 32).unique().notNullable(); + table.integer('customer_account_id').unsigned().notNullable() + .references('id').inTable('customer_accounts').onDelete('RESTRICT'); + + // 'invoice' | 'storno' discriminator (mig 114). The renderer + // branches on this; list-view filters always pair it with status. + table.string('kind', 16).notNullable().defaultTo('invoice'); + + // Lineage FKs. + table.integer('source_quote_id').unsigned() + .references('id').inTable('quotes').onDelete('SET NULL'); + // Back-pointer to the source contract (mig 131) when the invoice + // was created from a signed contract rather than a quote. + table.integer('source_contract_id').unsigned() + .references('id').inTable('contracts').onDelete('SET NULL'); + table.integer('event_id').unsigned() + .references('id').inTable('events').onDelete('SET NULL'); + // Storno + reissue self-refs (mig 114). ON DELETE SET NULL so + // purging an original leaves the Storno's link dangling NULL + // instead of cascading. + table.integer('cancels_invoice_id').unsigned() + .references('id').inTable('invoices').onDelete('SET NULL'); + table.integer('replaces_invoice_id').unsigned() + .references('id').inTable('invoices').onDelete('SET NULL'); + table.integer('cancellation_storno_id').unsigned() + .references('id').inTable('invoices').onDelete('SET NULL'); + + table.string('language', 8).defaultTo('de'); + table.string('currency', 3).notNullable().defaultTo('CHF'); + + table.date('issue_date').notNullable(); + table.date('due_date').notNullable(); + + // Event snapshot (mig 123). Inline so the renderer doesn't have + // to JOIN events on every fetch; the event reference itself can + // be deleted without orphaning the invoice's audit display. + table.string('event_name', 255); + table.date('event_date'); + table.string('event_time_start', 8); // "HH:MM" + table.string('event_time_end', 8); + + // Split-payment series metadata. Solo invoices = index 0, total 1. + table.integer('installment_index').notNullable().defaultTo(0); + table.integer('installment_total').notNullable().defaultTo(1); + table.string('installment_label', 128); + // quote_accepted | before_event | after_event | after_delivery | fixed_date + table.string('installment_trigger', 32); + + // scheduled | sent | paid | overdue | cancelled | pending_delivery + table.string('status', 16).notNullable().defaultTo('scheduled'); + table.timestamp('scheduled_send_at'); // NULL = send now on create + table.timestamp('sent_at'); + + // Totals (server-computed, never trust client). + table.bigInteger('net_amount_minor').notNullable().defaultTo(0); + table.decimal('vat_rate', 5, 2).defaultTo(0); + table.bigInteger('vat_amount_minor').notNullable().defaultTo(0); + table.bigInteger('shipping_amount_minor').notNullable().defaultTo(0); + table.bigInteger('total_amount_minor').notNullable().defaultTo(0); + table.bigInteger('paid_amount_minor').notNullable().defaultTo(0); + table.timestamp('paid_at'); + table.string('payment_method', 64); + table.string('payment_reference', 128); + + // Payment terms — legacy FK (mig 113) + split FKs (mig 124). + // Snapshot is the legal record; the FKs are convenience. + table.integer('payment_term_template_id').unsigned() + .references('id').inTable('payment_term_templates').onDelete('SET NULL'); + table.integer('payment_net_days_template_id').unsigned() + .references('id').inTable('payment_net_days_templates').onDelete('SET NULL'); + table.integer('payment_timing_template_id').unsigned() + .references('id').inTable('payment_timing_templates').onDelete('SET NULL'); + table.json('payment_term_snapshot'); + // Skonto opt-out (mig 126). When true, admin actively suppressed + // the Skonto offer for this invoice — overrides the template. + table.boolean('skonto_disabled').notNullable().defaultTo(false); + + // 0 = none, 1 = first reminder, 2 = second (with fee). + table.integer('reminder_level').notNullable().defaultTo(0); + table.timestamp('last_reminder_sent_at'); + table.bigInteger('late_fee_amount_minor').notNullable().defaultTo(0); + + // Throttle for the payment-check email (mig 115) — don't queue + // another within 24h of the previous one for the same invoice. + table.timestamp('last_payment_check_at'); + + // Monthly billing accumulator (mig 128). is_monthly_draft=true + // rows are the running per-customer draft that accumulates + // line items across the period; the scheduler arms it on the + // cycle day and flips it to status='scheduled' for normal send. + table.boolean('is_monthly_draft').notNullable().defaultTo(false); + table.date('monthly_period_start'); + table.date('monthly_period_end'); + + // Imported PDF path (mig 111) — admin uploaded a finished invoice + // PDF (rare; usually we render); store the source for re-download. + table.string('imported_pdf_path', 512); + + // Cross-document lineage (mig 140). + table.uuid('deal_uuid').nullable(); + + table.string('cc_pdf_email', 255); + table.string('pdf_path', 512); + table.integer('business_bank_account_id').unsigned() + .references('id').inTable('business_bank_accounts').onDelete('SET NULL'); + // 'swiss' | 'epc' | 'none' — overrides business_profile.default_qr_format + // when set per-invoice. + table.string('qr_format', 16); + + table.integer('created_by_admin_id').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['customer_account_id']); + table.index(['status']); + table.index(['due_date']); + table.index(['scheduled_send_at']); + table.index(['source_contract_id']); + table.index('deal_uuid', 'invoices_deal_uuid_idx'); + }); + + // Composite index for list-view "kind=invoice AND status IN (…)" + // — used in nearly every query. Raw CREATE INDEX since knex + // doesn't expose IF-NOT-EXISTS for indexes portably. + await knex.raw('CREATE INDEX IF NOT EXISTS invoices_kind_status_idx ON invoices (kind, status)'); + + // Partial index for the scheduler's monthly-pass lookup (mig 128). + // Postgres + SQLite 3.8+ support `CREATE INDEX … WHERE`. Falls + // back to a regular index on ancient SQLite. + try { + await knex.raw( + 'CREATE INDEX IF NOT EXISTS invoices_monthly_draft_idx ' + + 'ON invoices (customer_account_id, monthly_period_end) ' + + 'WHERE is_monthly_draft = true', + ); + } catch (_) { + await knex.raw( + 'CREATE INDEX IF NOT EXISTS invoices_monthly_draft_idx ' + + 'ON invoices (customer_account_id, monthly_period_end)', + ); + } + + // Partial UNIQUE index — one open monthly draft per customer (mig 133). + // Cross-dialect: Postgres uses TRUE, SQLite uses 1. + { + const client = knex.client.config.client; + const name = 'invoices_one_monthly_draft_per_customer'; + if (client === 'pg' || client === 'postgresql') { + await knex.raw( + `CREATE UNIQUE INDEX IF NOT EXISTS ${name} ` + + `ON invoices (customer_account_id) WHERE is_monthly_draft = TRUE`, + ); + } else if (client === 'sqlite3' || client === 'sqlite') { + await knex.raw( + `CREATE UNIQUE INDEX IF NOT EXISTS ${name} ` + + `ON invoices (customer_account_id) WHERE is_monthly_draft = 1`, + ); + } + } + } + + if (!(await knex.schema.hasTable('invoice_line_items'))) { + await knex.schema.createTable('invoice_line_items', (table) => { + table.increments('id').primary(); + table.integer('invoice_id').unsigned().notNullable() + .references('id').inTable('invoices').onDelete('CASCADE'); + table.integer('position').notNullable().defaultTo(0); + table.decimal('quantity', 10, 2).notNullable().defaultTo(1); + table.text('description').notNullable(); + table.bigInteger('unit_price_minor').notNullable().defaultTo(0); + table.decimal('discount_percent', 5, 2).notNullable().defaultTo(0); + table.bigInteger('line_total_minor').notNullable().defaultTo(0); + // Hierarchy (mig 119). NULL = top-level; SET = sub-item + // (display-only itemisation). One level deep — service + // validators reject grandchildren. + table.integer('parent_line_item_id').unsigned() + .references('id').inTable('invoice_line_items').onDelete('CASCADE'); + table.text('details_text'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['invoice_id']); + table.index('parent_line_item_id', 'invoice_line_items_parent_idx'); + }); + } + + if (!(await knex.schema.hasTable('invoice_payment_log'))) { + await knex.schema.createTable('invoice_payment_log', (table) => { + table.increments('id').primary(); + table.integer('invoice_id').unsigned().notNullable() + .references('id').inTable('invoices').onDelete('CASCADE'); + table.bigInteger('amount_minor').notNullable(); + table.timestamp('paid_at').notNullable(); + table.string('payment_method', 64); + table.string('reference', 128); + table.text('notes'); + // Skonto bookkeeping (mig 126). skonto_applied flags the row as + // "this is a discounted payment"; skonto_amount_minor tracks the + // discount that was given so the tax report can reverse it out. + table.boolean('skonto_applied').notNullable().defaultTo(false); + table.bigInteger('skonto_amount_minor'); + table.integer('recorded_by_admin_id').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['invoice_id']); + }); + } + + // Payment-check signed tokens (mig 115). One-shot tokens embedded + // in reminder emails — admin clicks "Paid in full" / "Partial" / + // "Not paid" → the page exchanges the token for invoice context + // without requiring a login. Token consumed on use. + if (!(await knex.schema.hasTable('invoice_payment_check_tokens'))) { + await knex.schema.createTable('invoice_payment_check_tokens', (table) => { + table.increments('id').primary(); + table.integer('invoice_id').unsigned().notNullable() + .references('id').inTable('invoices').onDelete('CASCADE'); + // 64-char hex — same shape as quote_action_tokens so rate-limit + // / format checks can be re-used. + table.string('token', 64).notNullable().unique(); + table.timestamp('expires_at').notNullable(); + table.timestamp('used_at'); + table.string('used_action', 16); // 'paid_full' | 'partial' | 'unpaid' + table.bigInteger('used_amount_minor'); // for partial payments + table.string('used_ip', 64); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['invoice_id']); + table.index(['used_at']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 5 — Contracts (block library + contracts + inclusions + tokens) + // ------------------------------------------------------------------------- + // Source migrations: 130 (4 tables + 12 system blocks + perms + + // settings + feature flag + emails), 131 (event snapshot fields, + // SHA-256 hashes, lineage FKs in/out, 4 extra locales on blocks, + // 1 extra system block "quote_line_items_table"), 135 (wet-upload + // flag), 136 (render-failure markers), 140 (deal_uuid). + + if (!(await knex.schema.hasTable('contract_blocks'))) { + await knex.schema.createTable('contract_blocks', (table) => { + table.increments('id').primary(); + // Stable slug — system blocks have hand-written slugs; admin + // blocks generate from name. Unique across both. + table.string('slug', 64).unique().notNullable(); + // 'basics' | 'scope' | 'privacy' | 'commercial' | 'nda' | 'closing' + table.string('section', 32).notNullable(); + table.string('name', 128).notNullable(); + table.string('description', 255); + // EN body (canonical). Other locales nullable — admin blocks + // may be EN-only; renderer falls back to EN. + table.text('body_text').notNullable(); + table.text('body_text_de'); + // Four additional user-fluent locales (mig 131). + table.text('body_text_ru'); + table.text('body_text_pt'); + table.text('body_text_nl'); + table.text('body_text_fr'); + // is_system blocks: admin can edit bodies (lawyer review pass) + // but can't delete — only deactivate. + table.boolean('is_system').notNullable().defaultTo(false); + table.boolean('is_active').notNullable().defaultTo(true); + table.integer('display_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['section', 'display_order']); + table.index(['is_active']); + table.index(['is_system']); + }); + + for (const block of SYSTEM_BLOCKS) { + await knex('contract_blocks').insert({ + slug: block.slug, + section: block.section, + name: block.name, + description: block.description, + body_text: block.body_text, + body_text_de: block.body_text_de, + is_system: true, + is_active: true, + display_order: block.display_order, + }); + } + } + + if (!(await knex.schema.hasTable('contracts'))) { + await knex.schema.createTable('contracts', (table) => { + table.increments('id').primary(); + table.string('contract_number', 32).unique().notNullable(); + table.integer('customer_account_id').unsigned().notNullable() + .references('id').inTable('customer_accounts').onDelete('RESTRICT'); + + table.string('language', 8).defaultTo('de'); + // draft | sent | signed_by_customer | signed_by_admin | + // fully_signed | cancelled + table.string('status', 24).notNullable().defaultTo('draft'); + + table.date('issue_date').notNullable(); + // Soft deadline (surfaced in email + on public page). + table.date('valid_until'); + + table.string('title', 255); + table.text('intro_text'); + table.text('outro_text'); + + // Event snapshot (mig 131). Inline so the renderer doesn't have + // to JOIN events on every fetch; reference can disappear without + // orphaning the contract's audit display. + table.string('event_name', 255); + table.date('event_date'); + table.string('event_time_start', 8); + table.string('event_time_end', 8); + + // System-generated PDF (no signature, or stamped with the + // customer's drawn signature). Wet-signed PDF takes precedence + // when set — see signed_pdf_is_wet_upload below (mig 135). + table.string('pdf_path', 512); + table.string('signed_pdf_path', 512); + // SHA-256 of each PDF on disk (mig 131) — used by the + // integrity-check service to detect tampering. + table.string('pdf_sha256', 64); + table.string('signed_pdf_sha256', 64); + // Wet-upload discriminator (mig 135). TRUE = signed_pdf_path + // points at customer-uploaded wet-signed PDF that must NEVER + // be overwritten by automatic re-stamps. + table.boolean('signed_pdf_is_wet_upload').notNullable().defaultTo(false); + // Render-failure markers (mig 136). When the post-sign PDF + // stamp throws, the catch block writes these so the admin + // detail page can show a "PDF stamp failed — click to retry" + // banner. Both cleared on a successful re-stamp. + table.timestamp('signed_pdf_render_failed_at').nullable().defaultTo(null); + table.text('signed_pdf_render_error'); + + table.timestamp('sent_at'); + table.timestamp('signed_by_customer_at'); + table.timestamp('signed_by_admin_at'); + + // Customer in-browser signature evidence. + table.string('signed_customer_name', 255); + table.string('signed_customer_ip', 45); + table.string('signed_customer_signature_path', 512); + // Admin counter-signature evidence. + table.string('signed_admin_name', 255); + table.string('signed_admin_ip', 45); + table.string('signed_admin_signature_path', 512); + + // Lineage (mig 131 + 140). + table.integer('source_quote_id').unsigned() + .references('id').inTable('quotes').onDelete('SET NULL'); + table.integer('converted_event_id').unsigned() + .references('id').inTable('events').onDelete('SET NULL'); + table.uuid('deal_uuid').nullable(); + + table.integer('created_by_admin_id').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['customer_account_id']); + table.index(['status']); + table.index(['issue_date']); + table.index(['source_quote_id']); + table.index('deal_uuid', 'contracts_deal_uuid_idx'); + }); + } + + if (!(await knex.schema.hasTable('contract_block_inclusions'))) { + await knex.schema.createTable('contract_block_inclusions', (table) => { + table.increments('id').primary(); + table.integer('contract_id').unsigned().notNullable() + .references('id').inTable('contracts').onDelete('CASCADE'); + table.integer('block_id').unsigned().notNullable() + .references('id').inTable('contract_blocks').onDelete('RESTRICT'); + + // Denormalised from contract_blocks.section so renaming a + // block's section later doesn't reshuffle already-issued + // contracts. + table.string('section', 32).notNullable(); + // 1-based order within this section on THIS contract. + table.integer('position').notNullable().defaultTo(0); + + // Frozen body snapshots captured at sendContract() time. Future + // edits to the source block don't mutate already-sent contracts. + table.text('body_text_snapshot'); + table.text('body_text_de_snapshot'); + + // Soft toggle. included=false leaves the row for history but + // omits it from the PDF + public view. + table.boolean('included').notNullable().defaultTo(true); + + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['contract_id', 'section', 'position']); + table.index(['block_id']); + }); + } + + if (!(await knex.schema.hasTable('contract_action_tokens'))) { + await knex.schema.createTable('contract_action_tokens', (table) => { + table.increments('id').primary(); + table.integer('contract_id').unsigned().notNullable() + .references('id').inTable('contracts').onDelete('CASCADE'); + table.string('token', 64).unique().notNullable(); + table.timestamp('expires_at').notNullable(); + table.timestamp('used_at'); + // 'signed_by_customer' | 'uploaded_signed_pdf' + table.string('used_action', 32); + table.string('used_ip', 45); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['contract_id']); + table.index(['expires_at']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 6 — Event ↔ quote glue (event_payment_plans) + // ------------------------------------------------------------------------- + // Created on quote acceptance: snapshots the payment plan so future + // edits to the quote's template don't retroactively mutate what was + // committed for the event. Source: mig 102. + if (!(await knex.schema.hasTable('event_payment_plans'))) { + await knex.schema.createTable('event_payment_plans', (table) => { + table.increments('id').primary(); + table.integer('event_id').unsigned().notNullable() + .references('id').inTable('events').onDelete('CASCADE'); + table.integer('quote_id').unsigned() + .references('id').inTable('quotes').onDelete('SET NULL'); + table.json('payment_term_snapshot').notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['event_id']); + table.index(['quote_id']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 7 — Customer hour logging + // ------------------------------------------------------------------------- + // Source: mig 129. customer_accounts.feature_hours_logging + + // hourly_rate_minor land in Section 9; the per-entry table is here. + if (!(await knex.schema.hasTable('customer_hour_entries'))) { + await knex.schema.createTable('customer_hour_entries', (table) => { + table.increments('id').primary(); + table.integer('customer_account_id').unsigned().notNullable() + .references('id').inTable('customer_accounts').onDelete('CASCADE'); + + table.date('entry_date').notNullable(); + // "HH:MM" — varchar so the editor's + // round-trips without timezone interpretation. duration_minutes + // is computed server-side on save so aggregate queries don't + // re-parse every row. + table.string('start_time', 5).notNullable(); + table.string('end_time', 5).notNullable(); + table.integer('duration_minutes').notNullable(); + + // null = inherit customer.hourly_rate_minor. + table.bigInteger('hourly_rate_minor_override'); + + table.text('description'); + + // 'unbilled' → 'billed' (folded into an invoice line). + // 'cancelled' reserved for a future soft-delete-after-billing + // workflow; deletes are hard for now. + table.string('status', 16).notNullable().defaultTo('unbilled'); + + // Backlink to the resulting invoice + specific line item. + // ON DELETE SET NULL so purging an invoice keeps the audit + // trail (admin can still see entry duration + description). + table.integer('invoice_id').unsigned() + .references('id').inTable('invoices').onDelete('SET NULL'); + table.integer('invoice_line_item_id').unsigned() + .references('id').inTable('invoice_line_items').onDelete('SET NULL'); + table.timestamp('billed_at'); + + table.integer('recorded_by_admin_id').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + table.index(['customer_account_id', 'status']); + table.index(['invoice_id']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 8 — Document sequences (atomic gap-free numbering) + // ------------------------------------------------------------------------- + // Source: mig 132. One row per (kind, year). Atomic INCREMENT on + // claim — serialises concurrent admin creates on the row lock + // (Postgres) or transaction lock (SQLite). Replaces the racy + // SELECT MAX + INSERT pattern; §14 UStG requires gap-free + // single-sequence-per-year for invoice numbering. + // + // NO backfill loop here — on a fresh install there are no + // pre-existing invoice/quote/contract rows to read MAX() from. + // Year rows are created on demand via UPSERT in + // backend/src/utils/documentSequences.js. + if (!(await knex.schema.hasTable('document_sequences'))) { + await knex.schema.createTable('document_sequences', (table) => { + table.increments('id').primary(); + // Discriminator: 'invoice' | 'quote' | 'contract'. Open + // string so future doc types (storno, proforma…) reuse the + // table without a schema change. + table.string('kind', 32).notNullable(); + table.integer('year').notNullable(); + table.integer('current_value').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // One sequence row per (kind, year). Lookups + atomic + // increments both go through this index. + table.unique(['kind', 'year']); + }); + } + + // ------------------------------------------------------------------------- + // SECTION 9 — ALTER upstream tables (events, customer_accounts) + // ------------------------------------------------------------------------- + // The tables here pre-date the CRM feature work and live on + // upstream/beta. Every ALTER is hasColumn-guarded so a re-run is a + // no-op. Each column references the source migration that added it. + + // ---- events -------------------------------------------------------- + if (await knex.schema.hasTable('events')) { + // 102 — events.quote_id FK to quotes. Nullable, ON DELETE SET + // NULL so deleting a converted quote keeps the event's audit + // trail intact. + if (!(await knex.schema.hasColumn('events', 'quote_id'))) { + await knex.schema.alterTable('events', (t) => { + t.integer('quote_id').unsigned() + .references('id').inTable('quotes').onDelete('SET NULL'); + t.index(['quote_id']); + }); + } + + // 137 — admin calendar columns. is_full_day NOT NULL DEFAULT true + // so existing rows backfill cleanly to the historical default + // (everything was a full-day event before). + if (!(await knex.schema.hasColumn('events', 'event_time_start'))) { + await knex.schema.alterTable('events', (t) => { + t.string('event_time_start', 5); // "HH:MM" + }); + } + if (!(await knex.schema.hasColumn('events', 'event_time_end'))) { + await knex.schema.alterTable('events', (t) => { + t.string('event_time_end', 5); + }); + } + if (!(await knex.schema.hasColumn('events', 'is_full_day'))) { + await knex.schema.alterTable('events', (t) => { + t.boolean('is_full_day').notNullable().defaultTo(true); + }); + } + // Defensive backfill — handles any rows that landed via an older + // partial state without the default. + await knex('events').whereNull('is_full_day').update({ is_full_day: true }); + + // 143 — pre-event customer reminder columns. + if (!(await knex.schema.hasColumn('events', 'event_reminder_disabled'))) { + await knex.schema.alterTable('events', (t) => { + t.boolean('event_reminder_disabled').notNullable().defaultTo(false); + }); + } + if (!(await knex.schema.hasColumn('events', 'event_reminder_offset_days'))) { + await knex.schema.alterTable('events', (t) => { + t.integer('event_reminder_offset_days'); // null = inherit global + }); + } + if (!(await knex.schema.hasColumn('events', 'event_reminder_body_override'))) { + await knex.schema.alterTable('events', (t) => { + t.text('event_reminder_body_override'); + }); + } + if (!(await knex.schema.hasColumn('events', 'event_reminder_sent_at'))) { + await knex.schema.alterTable('events', (t) => { + t.timestamp('event_reminder_sent_at'); + t.index('event_reminder_sent_at', 'events_event_reminder_sent_at_idx'); + }); + } + } + + // ---- customer_accounts --------------------------------------------- + if (await knex.schema.hasTable('customer_accounts')) { + // 102 — billing_cadence + cycle_day. per_event default = "every + // invoice is sent on its own schedule" (the historical behaviour); + // monthly / quarterly snap to the cycle day instead. + if (!(await knex.schema.hasColumn('customer_accounts', 'billing_cadence'))) { + await knex.schema.alterTable('customer_accounts', (t) => { + t.string('billing_cadence', 16).notNullable().defaultTo('per_event'); + t.integer('billing_cycle_day').notNullable().defaultTo(1); + }); + } + + // 107 — free-text country override (mirrors business_profile column). + if (!(await knex.schema.hasColumn('customer_accounts', 'country_name'))) { + await knex.schema.alterTable('customer_accounts', (t) => { + t.string('country_name', 120); + }); + } + + // 129 — per-customer hour-logging toggle + default hourly rate. + // hourly_rate_minor nullable = "admin must enter a per-entry + // override or the entry save fails." + if (!(await knex.schema.hasColumn('customer_accounts', 'feature_hours_logging'))) { + await knex.schema.alterTable('customer_accounts', (t) => { + t.boolean('feature_hours_logging').notNullable().defaultTo(false); + }); + } + if (!(await knex.schema.hasColumn('customer_accounts', 'hourly_rate_minor'))) { + await knex.schema.alterTable('customer_accounts', (t) => { + t.bigInteger('hourly_rate_minor'); + }); + } + } + + // (Sections 10 + 11 from the scaffold — system payment-term seeds — + // landed inline inside Section 2's createTable blocks. The "Net 14/ + // 30/60/90 / Sofort fällig" rows ARE the seed; no separate seed pass + // needed. The legacy `payment_term_templates` seed also embedded + // there. Note: source migration 120 ALSO inserted Net 30/60/90 rows + // into the LEGACY `payment_term_templates` table — those rows are + // intentionally NOT duplicated here. The new split table is the + // intended forward path; the legacy "Net 30/60/90 on legacy table" + // rows from 120 were a stopgap before 124 split the concepts. Fresh + // installs jump straight to the split-table world.) + + // ------------------------------------------------------------------------- + // SECTION 12 — Email templates (content owned by self-heal services) + // ------------------------------------------------------------------------- + // This migration owns SCHEMA only — no template_key rows are + // inserted here. CRM email-template content + skeleton rows are + // managed by three runtime self-heal services that idempotently + // create missing rows + backfill empty translations on first + // access (boot / first GET /admin/email/templates / first cron): + // + // - backend/src/services/contractEmailTemplates.js (mig 130/131 + // equivalents: contract_sent, contract_fully_signed, + // contract_signed_admin_notification) + // - backend/src/services/eventReminderTemplates.js (mig 143 + // equivalent: event_reminder_default + per-event-type variants) + // - backend/src/services/crmEmailTemplates.js (NEW — covers + // quote_sent, quote_accepted_*, quote_declined_admin, + // invoice_sent, invoice_reminder_first, invoice_reminder_second, + // invoice_paid_receipt, invoice_cancelled, invoice_payment_check, + // invoice_paid_admin_notification, storno_issued — equivalents + // of source mig 102 + 112 + 116 + 122 + 127.) + // + // The self-heal pattern (vs in-migration inserts) keeps legal/ + // marketing prose out of the schema diff, lets admin edits land + // immediately without a migration round-trip, and matches the + // feedback_migration_no_compensation rule when copy changes after + // beta deploy. Pattern lifted from contractEmailTemplates.js. + + // ------------------------------------------------------------------------- + // SECTION 13 — Seed RBAC permissions + role grants + // ------------------------------------------------------------------------- + // Source migrations: 102 (quotes/bills perms), 130 (contracts perms), + // 134 (split customers.create into customers.edit + customers.events). + // All idempotent via existing-name checks; grants project forward + // from existing roles so nobody loses capability on upgrade. + + if (await knex.schema.hasTable('permissions') && await knex.schema.hasTable('role_permissions')) { + // 1. Insert the 6 net-new CRM permissions (skip those already present). + { + const existing = await knex('permissions') + .whereIn('name', NEW_PERMISSIONS.map((p) => p.name)) + .select('name'); + const existingSet = new Set(existing.map((r) => r.name)); + const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name)); + if (toInsert.length > 0) { + await knex('permissions').insert(toInsert); + } + + // Grant to super_admin + admin (the CRM resource-managing roles). + const roles = await knex('roles') + .whereIn('name', ['super_admin', 'admin']).select('id'); + const perms = await knex('permissions') + .whereIn('name', NEW_PERMISSIONS.map((p) => p.name)).select('id'); + if (roles.length > 0 && perms.length > 0) { + const grants = await knex('role_permissions').select('role_id', 'permission_id'); + const grantSet = new Set(grants.map((g) => `${g.role_id}-${g.permission_id}`)); + const inserts = []; + for (const role of roles) { + for (const perm of perms) { + const key = `${role.id}-${perm.id}`; + if (!grantSet.has(key)) inserts.push({ role_id: role.id, permission_id: perm.id }); + } + } + if (inserts.length > 0) await knex('role_permissions').insert(inserts); + } + } + + // 2. Split customers.create → customers.edit + customers.events + // (mig 134). Insert the two new permission rows + project the + // grants forward from every role that currently holds + // customers.create. See feedback_permission_split_compat memory. + { + const existing = await knex('permissions') + .whereIn('name', CUSTOMERS_SPLIT_PERMISSIONS.map((p) => p.name)) + .select('name'); + const existingSet = new Set(existing.map((r) => r.name)); + const toInsert = CUSTOMERS_SPLIT_PERMISSIONS.filter((p) => !existingSet.has(p.name)); + if (toInsert.length > 0) { + await knex('permissions').insert(toInsert); + } + + const createPerm = await knex('permissions').where({ name: 'customers.create' }).first(); + if (createPerm) { + const newPerms = await knex('permissions') + .whereIn('name', CUSTOMERS_SPLIT_PERMISSIONS.map((p) => p.name)).select('id', 'name'); + const rolesWithCreate = await knex('role_permissions') + .where({ permission_id: createPerm.id }).select('role_id'); + if (rolesWithCreate.length > 0 && newPerms.length > 0) { + const existingGrants = await knex('role_permissions') + .whereIn('permission_id', newPerms.map((p) => p.id)) + .select('role_id', 'permission_id'); + const grantSet = new Set(existingGrants.map((g) => `${g.role_id}-${g.permission_id}`)); + const inserts = []; + for (const { role_id } of rolesWithCreate) { + for (const perm of newPerms) { + const key = `${role_id}-${perm.id}`; + if (!grantSet.has(key)) inserts.push({ role_id, permission_id: perm.id }); + } + } + if (inserts.length > 0) await knex('role_permissions').insert(inserts); + } + } + } + } + + // ------------------------------------------------------------------------- + // SECTION 14 — Feature flags + app_settings + // ------------------------------------------------------------------------- + // Source migrations: 102 (quotes/bills flags + 16 CRM_SUB_SETTINGS + + // 4 ToS settings via 104), 109 (customer_feature_* toggles enabled), + // 125 (default payment-term-template IDs — derived from the seeded + // template rows), 130 (contracts flag + 6 settings), 138 (force-off + // locked roadmap flags — pre-existing reminderEmails/calendarBooking/ + // messaging rows get clamped to false), 141 (3 installment defaults), + // 143 (2 event reminder settings). + + // Feature flags — insert any missing CRM flags as OFF. + if (await knex.schema.hasTable('feature_flags')) { + for (const key of NEW_FEATURE_FLAGS) { + const existing = await knex('feature_flags').where({ key }).first(); + if (!existing) { + await knex('feature_flags').insert({ key, value: false }); + } + } + // Mig 138 — force locked roadmap flags OFF on installs that might + // have them pre-seeded ON from an older codepath. No-op on a + // fresh install where the rows don't pre-exist. + const lockedRoadmapFlags = ['reminderEmails', 'calendarBooking', 'messaging']; + await knex('feature_flags') + .whereIn('key', lockedRoadmapFlags) + .andWhere(function () { + this.where('value', 1).orWhere('value', true).orWhere('value', '1'); + }) + .update({ value: 0 }); + } + + // App settings — CRM behaviour + ToS + customer surface + installment + // defaults + event reminder defaults. Idempotent skip-on-exists so + // re-runs never clobber admin customisation. + if (await knex.schema.hasTable('app_settings')) { + const allSettings = [...CRM_SUB_SETTINGS, ...CUSTOMER_FEATURE_SETTINGS]; + for (const row of allSettings) { + const existing = await knex('app_settings').where('setting_key', row.setting_key).first(); + if (!existing) { + await knex('app_settings').insert({ + setting_key: row.setting_key, + setting_value: JSON.stringify(row.setting_value), + setting_type: row.setting_type, + }); + } + } + + // Mig 125 — default payment-term template IDs. Looked up by name + // / net_days against the system rows seeded in Section 2 so the + // setting points at the right row even if admin later renames it. + if (await knex.schema.hasTable('payment_net_days_templates') + && await knex.schema.hasTable('payment_timing_templates')) { + const netDays30 = await knex('payment_net_days_templates') + .where({ is_system: true, net_days: 30 }).first(); + const timingDelivery = await knex('payment_timing_templates') + .where({ is_system: true, name: 'Komplettzahlung nach Auslieferung' }).first(); + + const paymentDefaults = []; + if (netDays30) paymentDefaults.push({ + setting_key: 'crm_invoices_default_payment_net_days_template_id', + setting_value: netDays30.id, + setting_type: 'crm', + }); + if (timingDelivery) paymentDefaults.push({ + setting_key: 'crm_invoices_default_payment_timing_template_id', + setting_value: timingDelivery.id, + setting_type: 'crm', + }); + for (const row of paymentDefaults) { + const existing = await knex('app_settings').where('setting_key', row.setting_key).first(); + if (!existing) { + await knex('app_settings').insert({ + setting_key: row.setting_key, + setting_value: JSON.stringify(row.setting_value), + setting_type: row.setting_type, + }); + } + } + } + } +}; + +// =========================================================================== +// down() +// =========================================================================== + +exports.down = async function (knex) { + // Down policy: + // - DROP all CRM tables in reverse FK dependency order. + // - DROP ALTER columns on upstream tables (events, customer_accounts). + // - LEAVE seeded permissions, feature_flags, and app_settings in place. + // Admins may have customised values; rolling back a seed shouldn't + // clobber them. Matches the policy in source migrations 105/125/141. + // - Reverse drop_index calls aren't strictly needed — dropTable + // reaps indexes — but we drop named raw indexes explicitly for + // consistency with the up() raw CREATE INDEX calls. + + // Some FKs point INTO CRM tables from quotes/invoices (e.g. + // quotes.converted_contract_id → contracts, invoices.source_contract_id + // → contracts). dropTable on the referenced table handles these on + // Postgres + SQLite (knex's dropTable cascades the FK definitions), + // but we drop the referencing tables first regardless so the order is + // explicit and dialect-tolerant. + + // ---- raw-CREATE indexes on invoices (must drop before dropping table) ---- + if (await knex.schema.hasTable('invoices')) { + await knex.raw('DROP INDEX IF EXISTS invoices_one_monthly_draft_per_customer').catch(() => {}); + await knex.raw('DROP INDEX IF EXISTS invoices_monthly_draft_idx').catch(() => {}); + await knex.raw('DROP INDEX IF EXISTS invoices_kind_status_idx').catch(() => {}); + } + + // ---- CRM tables in reverse FK dependency order --------------------------- + // Drop child tables before parents. customer_hour_entries depends on + // invoices + invoice_line_items + customer_accounts. event_payment_plans + // depends on events + quotes. Contract tables depend on contracts + + // contract_blocks + customer_accounts. Invoice tables depend on invoices. + // Quote tables depend on quotes. + const tablesInDropOrder = [ + 'invoice_payment_check_tokens', + 'customer_hour_entries', + 'event_payment_plans', + 'contract_action_tokens', + 'contract_block_inclusions', + 'contracts', + 'contract_blocks', + 'document_sequences', + 'invoice_payment_log', + 'invoice_line_items', + 'invoices', + 'quote_action_tokens', + 'quote_line_items', + 'quote_line_item_presets', + 'quotes', + 'payment_timing_templates', + 'payment_net_days_templates', + 'payment_term_templates', + 'business_bank_accounts', + 'business_profile', + ]; + for (const t of tablesInDropOrder) { + await knex.schema.dropTableIfExists(t); + } + + // ---- Reverse ALTER columns on upstream tables --------------------------- + if (await knex.schema.hasTable('events')) { + // Drop the reminder index explicitly first — Postgres tolerates + // dropping a column with attached index, but SQLite older versions + // emit a noisy warning. The `.catch` keeps the down tolerant. + await knex.raw('DROP INDEX IF EXISTS events_event_reminder_sent_at_idx').catch(() => {}); + for (const col of [ + 'event_reminder_sent_at', + 'event_reminder_body_override', + 'event_reminder_offset_days', + 'event_reminder_disabled', + 'is_full_day', + 'event_time_end', + 'event_time_start', + 'quote_id', + ]) { + if (await knex.schema.hasColumn('events', col)) { + await knex.schema.alterTable('events', (t) => t.dropColumn(col)); + } + } + } + + if (await knex.schema.hasTable('customer_accounts')) { + for (const col of [ + 'hourly_rate_minor', + 'feature_hours_logging', + 'country_name', + 'billing_cycle_day', + 'billing_cadence', + ]) { + if (await knex.schema.hasColumn('customer_accounts', col)) { + await knex.schema.alterTable('customer_accounts', (t) => t.dropColumn(col)); + } + } + } + + // Seeded permissions / feature_flags / app_settings: intentionally + // left in place — admin may have customised values, and seed-on- + // existing-key is a no-op for forward re-runs. Operators wanting a + // truly clean revert delete those rows manually. +}; diff --git a/backend/src/services/contractEmailTemplates.js b/backend/src/services/contractEmailTemplates.js new file mode 100644 index 00000000..cc118584 --- /dev/null +++ b/backend/src/services/contractEmailTemplates.js @@ -0,0 +1,181 @@ +/** + * Contract email template definitions, extracted from migration 130 + * so both the migration AND a runtime seeder can read from the same + * source. The runtime seeder is needed because an admin who ran + * migration 130 BEFORE we added contract_fully_signed to it won't + * have that template in their email_templates table — yet the + * dual-party send in contractService.recordAdminCountersignature + * depends on it. Per the maintainer's "never ship compensation + * migrations" rule, we self-heal at runtime instead. + * + * `ensureContractEmailTemplatesSeeded()` is idempotent — call it as + * often as you like, only missing rows get inserted. Module-level + * boolean caches the "all templates verified" state so the check + * is free after the first call in a process. + */ + +const CONTRACT_EMAIL_TEMPLATES = { + contract_sent: { + category: 'contracts', feature_flag: 'contracts', + variables: ['contract_number', 'customer_name', 'response_url', 'title', 'event_name', 'valid_until'], + en: { + subject: 'Contract {{contract_number}} ready for your signature', + body_html: `

Contract {{contract_number}}

+

Dear {{customer_name}},

+

Please find the contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}{{#if event_name}} for "{{event_name}}"{{/if}} attached.

+

You can review and sign the contract directly in your browser via the link below:

+

+ Review & sign contract +

+

Or open the full contract:
+{{response_url}}

+{{#if valid_until}}

Please sign by {{valid_until}}.

{{/if}}`, + body_text: `Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}`, + }, + de: { + subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit', + body_html: `

Vertrag {{contract_number}}

+

Sehr geehrte/r {{customer_name}},

+

im Anhang finden Sie den Vertrag {{contract_number}}{{#if title}} – „{{title}}"{{/if}}{{#if event_name}} für „{{event_name}}"{{/if}}.

+

Sie können den Vertrag direkt online prüfen und unterzeichnen:

+

+ Vertrag prüfen & unterzeichnen +

+

Oder öffnen Sie den vollständigen Vertrag im Browser:
+{{response_url}}

+{{#if valid_until}}

Bitte unterzeichnen Sie bis {{valid_until}}.

{{/if}}`, + body_text: `Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}`, + }, + }, + contract_fully_signed: { + category: 'contracts', feature_flag: 'contracts', + variables: ['contract_number', 'customer_name', 'title'], + en: { + subject: 'Contract {{contract_number}} fully signed', + body_html: `

Contract {{contract_number}} — fully signed

+

Dear {{customer_name}},

+

Both parties have now signed contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.

+

This is the authoritative signed copy. Keep it alongside the related quote and invoices.

`, + body_text: `Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.`, + }, + de: { + subject: 'Vertrag {{contract_number}} vollständig unterzeichnet', + body_html: `

Vertrag {{contract_number}} – vollständig unterzeichnet

+

Sehr geehrte/r {{customer_name}},

+

der Vertrag {{contract_number}}{{#if title}} – „{{title}}"{{/if}} wurde nun von beiden Parteien unterzeichnet. Im Anhang finden Sie das beidseitig unterzeichnete PDF für Ihre Unterlagen.

+

Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.

`, + body_text: `Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.`, + }, + }, + contract_signed_admin_notification: { + category: 'contracts', feature_flag: 'contracts', + variables: ['contract_number', 'customer_email', 'signed_customer_name', 'admin_dashboard_url'], + en: { + subject: 'Contract {{contract_number}} signed by {{customer_email}}', + body_html: `

Contract signed

{{signed_customer_name}} ({{customer_email}}) has just signed contract {{contract_number}}.

+

Open in admin

+

The signed PDF and signature evidence (typed name, IP, timestamp, signature image if drawn) are available on the contract detail page. To make this fully binding, counter-sign the contract or upload a wet-signed copy.

`, + body_text: `Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}`, + }, + de: { + subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet', + body_html: `

Vertrag unterzeichnet

{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag {{contract_number}} unterzeichnet.

+

Im Admin-Bereich öffnen

+

Das unterzeichnete PDF und die Signatur-Belege (Name, IP, Zeitstempel, Signaturbild falls gezeichnet) sind auf der Vertragsdetailseite einsehbar. Für vollständige Verbindlichkeit unterzeichnen Sie den Vertrag gegen oder laden Sie eine handunterschriebene Kopie hoch.

`, + body_text: `Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}`, + }, + }, +}; + +// Cache the "all-seeded" state so the check is free after the first +// successful run. Reset to false on insertion failure so subsequent +// calls retry. +let _seeded = false; + +/** + * Insert any missing contract email templates into email_templates + + * email_template_translations. Safe to call concurrently — each + * row's existence check happens inline before insert. + * + * Returns the list of templateKeys that were newly inserted (for + * logging / diagnostics). Empty array = all templates already exist. + */ +async function ensureContractEmailTemplatesSeeded(db, logger) { + if (_seeded) return []; + if (!(await db.schema.hasTable('email_templates'))) return []; + + const cols = await db('email_templates').columnInfo(); + const hasTranslationsTable = await db.schema.hasTable('email_template_translations'); + const newlyInserted = []; + + for (const [templateKey, def] of Object.entries(CONTRACT_EMAIL_TEMPLATES)) { + const existing = await db('email_templates').where({ template_key: templateKey }).first(); + if (existing) continue; + + const enContent = def.en; + const masterRow = { + template_key: templateKey, + variables: JSON.stringify(def.variables), + }; + if ('category' in cols) masterRow.category = def.category; + if ('subcategory' in cols) masterRow.subcategory = null; + if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag; + if ('created_at' in cols) masterRow.created_at = new Date(); + if ('updated_at' in cols) masterRow.updated_at = new Date(); + + // Fill any subject_ / body_html_ / body_text_ + // shaped columns the install happens to have (legacy variants vs + // the modern email_template_translations table). + for (const colName of Object.keys(cols)) { + if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.subject; + } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.body_html; + } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.body_text; + } + } + + try { + const inserted = await db('email_templates').insert(masterRow).returning('id'); + const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + if (hasTranslationsTable && templateId) { + for (const lang of ['en', 'de']) { + const content = def[lang]; + if (!content) continue; + await db('email_template_translations').insert({ + template_id: templateId, + language: lang, + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + newlyInserted.push(templateKey); + if (logger) { + logger.info(`Self-healed missing contract email template at runtime: ${templateKey}`); + } + } catch (err) { + // Keep _seeded=false so the next call retries. Don't throw — + // the caller (queueEmail upstream) will surface its own error + // if the template still can't be looked up. + if (logger) { + logger.error(`Failed to seed contract email template ${templateKey}`, { + message: err.message, + }); + } + return newlyInserted; + } + } + + _seeded = true; + return newlyInserted; +} + +module.exports = { + CONTRACT_EMAIL_TEMPLATES, + ensureContractEmailTemplatesSeeded, +}; diff --git a/backend/src/services/crmEmailTemplates.js b/backend/src/services/crmEmailTemplates.js new file mode 100644 index 00000000..2114fdb2 --- /dev/null +++ b/backend/src/services/crmEmailTemplates.js @@ -0,0 +1,452 @@ +/** + * CRM email template definitions (quotes / invoices / Storno / payment-check + * / paid-admin-notification) — runtime self-heal seeder. + * + * Original sources: migrations 102 (8 templates), 112 (quote_accepted_customer), + * 116 (invoice_payment_check), 122 (storno_issued), 127 (invoice_paid_admin_notification). + * + * The consolidated migration (107_crm_consolidated.js) owns SCHEMA only; + * this service file owns CONTENT. `ensureCrmEmailTemplatesSeeded()` is + * idempotent — call it from server boot, GET /admin/email/templates, + * and any code path about to send one of these templates. Missing + * rows get inserted; existing rows are LEFT ALONE so admin edits are + * never overwritten. + * + * Same pattern as contractEmailTemplates.js + eventReminderTemplates.js + * — per the maintainer's "never ship compensation migrations" rule, + * we self-heal at runtime instead of bolting content into the schema diff. + * + * Translations: en + de hand-translated; fr/nl/pt/ru intentionally + * absent. Renderer falls through to en until admin overrides via the + * Templates UI. Flag for native review in the PR description. + */ + +const CRM_EMAIL_TEMPLATES = { +quote_sent: { + category: 'quotes', feature_flag: 'quotes', + variables: ['quote_number', 'customer_name', 'response_url', 'accept_url', 'decline_url', + 'valid_until', 'event_name', 'total_amount'], + en: { + subject: 'Your quote {{quote_number}} is ready', + body_html: `

Quote {{quote_number}}

+

Dear {{customer_name}},

+

Please find the attached quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}. Total amount: {{total_amount}}.

+

You can accept or decline this quote directly via the buttons below:

+

+ Accept quote +   + Decline +

+

Or open the full quote in your browser:
+{{response_url}}

+{{#if valid_until}}

This quote is valid until {{valid_until}}.

{{/if}}`, + body_text: `Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}`, + }, + de: { + subject: 'Ihr Angebot {{quote_number}} ist bereit', + body_html: `

Angebot {{quote_number}}

+

Sehr geehrte/r {{customer_name}},

+

im Anhang finden Sie das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}}. Gesamtbetrag: {{total_amount}}.

+

Sie können das Angebot direkt über die Schaltflächen unten annehmen oder ablehnen:

+

+ Angebot annehmen +   + Ablehnen +

+

Oder öffnen Sie das vollständige Angebot im Browser:
+{{response_url}}

+{{#if valid_until}}

Dieses Angebot ist gültig bis {{valid_until}}.

{{/if}}`, + body_text: `Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}`, + }, + }, + quote_accepted_admin: { + category: 'quotes', feature_flag: 'quotes', + variables: ['quote_number', 'customer_email', 'event_name', 'total_amount', 'admin_dashboard_url'], + en: { + subject: 'Quote {{quote_number}} accepted by {{customer_email}}', + body_html: `

Quote accepted

{{customer_email}} just accepted quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.

+

Open in admin

`, + body_text: `Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}`, + }, + de: { + subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen', + body_html: `

Angebot angenommen

{{customer_email}} hat soeben das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.

+

Im Admin-Bereich öffnen

`, + body_text: `Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}`, + }, + }, + quote_declined_admin: { + category: 'quotes', feature_flag: 'quotes', + variables: ['quote_number', 'customer_email', 'event_name', 'admin_dashboard_url'], + en: { + subject: 'Quote {{quote_number}} declined by {{customer_email}}', + body_html: `

{{customer_email}} declined quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.

+

Open quote in admin

`, + body_text: `Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}`, + }, + de: { + subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt', + body_html: `

{{customer_email}} hat das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.

+

Angebot im Admin-Bereich öffnen

`, + body_text: `Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}`, + }, + }, + invoice_sent: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'due_date', + 'installment_label', 'installment_index', 'installment_total'], + en: { + subject: 'Invoice {{invoice_number}} — {{total_amount}}', + body_html: `

Invoice {{invoice_number}}

Dear {{customer_name}},

+

Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.

+

Amount: {{total_amount}}
Due: {{due_date}}{{#if installment_label}}
Installment: {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}

+

The payment details and IBAN are on the attached PDF.

`, + body_text: `Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.`, + }, + de: { + subject: 'Rechnung {{invoice_number}} — {{total_amount}}', + body_html: `

Rechnung {{invoice_number}}

Sehr geehrte/r {{customer_name}},

+

im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.

+

Betrag: {{total_amount}}
Fällig: {{due_date}}{{#if installment_label}}
Teilzahlung: {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}

+

Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.

`, + body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`, + }, + }, + invoice_reminder_first: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue'], + en: { + subject: 'Reminder: invoice {{invoice_number}} is overdue', + body_html: `

Payment reminder

Dear {{customer_name}},

+

Our records show that invoice {{invoice_number}} (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is {{total_amount}}.

+

If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.

`, + body_text: `Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.`, + }, + de: { + subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}', + body_html: `

Zahlungserinnerung

Sehr geehrte/r {{customer_name}},

+

laut unseren Unterlagen ist die Rechnung {{invoice_number}} (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt {{total_amount}}.

+

Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.

`, + body_text: `Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.`, + }, + }, + invoice_reminder_second: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue', + 'late_fee_amount', 'new_total_amount'], + en: { + subject: 'Second reminder: invoice {{invoice_number}}', + body_html: `

Second payment reminder

Dear {{customer_name}},

+

Invoice {{invoice_number}} is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of {{late_fee_amount}} has been added. The new total is {{new_total_amount}}.

+

Please settle the outstanding amount as soon as possible. A revised invoice is attached.

`, + body_text: `Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.`, + }, + de: { + subject: 'Zweite Mahnung: Rechnung {{invoice_number}}', + body_html: `

Zweite Zahlungserinnerung

Sehr geehrte/r {{customer_name}},

+

die Rechnung {{invoice_number}} ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von {{late_fee_amount}} hinzugefügt. Der neue Gesamtbetrag beträgt {{new_total_amount}}.

+

Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.

`, + body_text: `Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.`, + }, + }, + invoice_paid_receipt: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'paid_amount', 'paid_at'], + en: { + subject: 'Receipt for invoice {{invoice_number}}', + body_html: `

Payment received

Dear {{customer_name}},

+

We received your payment of {{paid_amount}} for invoice {{invoice_number}} on {{paid_at}}. Thank you!

`, + body_text: `Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.`, + }, + de: { + subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}', + body_html: `

Zahlung erhalten

Sehr geehrte/r {{customer_name}},

+

vielen Dank für Ihre Zahlung in Höhe von {{paid_amount}} für die Rechnung {{invoice_number}} am {{paid_at}}.

`, + body_text: `Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.`, + }, + }, + invoice_cancelled: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name'], + en: { + subject: 'Invoice {{invoice_number}} cancelled', + body_html: `

Dear {{customer_name}},

Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.

`, + body_text: `Invoice {{invoice_number}} has been cancelled.`, + }, + de: { + subject: 'Rechnung {{invoice_number}} storniert', + body_html: `

Sehr geehrte/r {{customer_name}},

die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.

`, + body_text: `Rechnung {{invoice_number}} wurde storniert.`, + }, + }, + quote_accepted_customer: { + category: 'quotes', + feature_flag: 'quotes', + variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'], + en: { + subject: 'Quote {{quote_number}} accepted — thank you', + body_html: `

Thank you

+

Dear {{customer_name}},

+

This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}.

+{{#if accepted_on_behalf}}

This acceptance was recorded on your behalf by your photographer.

{{/if}} +

We'll be in touch with next steps shortly.

`, + body_text: `Dear {{customer_name}}, + +This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}. +{{#if accepted_on_behalf}} +This acceptance was recorded on your behalf by your photographer. +{{/if}} +We'll be in touch with next steps shortly.`, + }, + de: { + subject: 'Angebot {{quote_number}} angenommen — vielen Dank', + body_html: `

Vielen Dank

+

Sehr geehrte/r {{customer_name}},

+

hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für „{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}.

+{{#if accepted_on_behalf}}

Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.

{{/if}} +

Wir melden uns in Kürze mit den nächsten Schritten.

`, + body_text: `Sehr geehrte/r {{customer_name}}, + +hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}. +{{#if accepted_on_behalf}} +Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst. +{{/if}} +Wir melden uns in Kürze mit den nächsten Schritten.`, + }, +}, + invoice_payment_check: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'event_name', 'due_date', 'total_amount', 'paid_url', 'partial_url', 'unpaid_url', 'skonto_url', 'has_skonto', 'skonto_amount', 'late_fee_due', 'late_fee_amount'], + en: { + subject: 'Check payment for invoice {{invoice_number}}', + body_html: `

Time to check on a payment

+

Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}.

+

Please check your bank to confirm what (if anything) has been received, then click the matching button below — no login required.

+ + + + {{#if has_skonto}}{{/if}} + + + +
+ Paid in full + + Paid with Skonto ({{skonto_amount}}) + + Partially paid + + Not paid yet +
+

If you select "Not paid yet" or "Partially paid", the system will queue the next reminder to the customer{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.

`, + body_text: `Time to check on a payment + +Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}. + +Confirm what was received: + Paid in full: {{paid_url}}{{#if has_skonto}} + Paid with Skonto ({{skonto_amount}}): {{skonto_url}}{{/if}} + Partial: {{partial_url}} + Not paid yet: {{unpaid_url}} + +Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.`, +}, + de: { + subject: 'Zahlung prüfen für Rechnung {{invoice_number}}', + body_html: `

Zahlung prüfen

+

Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}.

+

Bitte prüfen Sie auf Ihrem Konto, was eingegangen ist, und klicken Sie unten den passenden Button — kein Login nötig.

+ + + + {{#if has_skonto}}{{/if}} + + + +
+ Vollständig bezahlt + + Mit Skonto bezahlt ({{skonto_amount}}) + + Teilweise bezahlt + + Nicht bezahlt +
+

Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung an den Kunden gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.

`, + body_text: `Zahlung prüfen + +Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}. + +Bitte bestätigen: + Vollständig bezahlt: {{paid_url}}{{#if has_skonto}} + Mit Skonto bezahlt ({{skonto_amount}}): {{skonto_url}}{{/if}} + Teilweise: {{partial_url}} + Nicht bezahlt: {{unpaid_url}} + +Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.`, +}, + }, + storno_issued: { + category: 'billing', feature_flag: 'bills', + variables: ['storno_number', 'original_invoice_number', 'original_issue_date', 'customer_name', 'total_amount'], + en: { + subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}', + body_html: `

Dear {{customer_name}},

+

Please find attached cancellation invoice {{storno_number}}, which formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}.

+

The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.

`, + body_text: `Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.`, +}, + de: { + subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}', + body_html: `

Sehr geehrte/r {{customer_name}},

+

anbei erhalten Sie die Stornorechnung {{storno_number}}, mit der die Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.

+

Die ursprüngliche Rechnung ist damit nicht mehr zu begleichen. Bitte bewahren Sie die beigefügte PDF für Ihre Unterlagen auf — etwaige vorherige Mahnungen sind hinfällig.

`, + body_text: `Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.`, +}, + }, + invoice_paid_admin_notification: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'paid_amount', 'paid_at', 'payment_method', 'payment_reference', 'skonto_applied', 'skonto_percent', 'skonto_discount_amount'], + en: { + subject: 'Payment received: invoice {{invoice_number}}', + body_html: `

Payment recorded

+

Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.

+ + + + {{#if skonto_applied}}{{/if}} + {{#if payment_method}}{{/if}} + {{#if payment_reference}}{{/if}} + +
Total invoice amount{{total_amount}}
Paid total{{paid_amount}}
Paid with Skonto ({{skonto_percent}}%)−{{skonto_discount_amount}}
Payment method{{payment_method}}
Reference{{payment_reference}}
Recorded at{{paid_at}}
+

This is an automatic notification — no action required.

`, + body_text: `Payment recorded + +Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid. + + Total invoice amount: {{total_amount}} + Paid total: {{paid_amount}}{{#if skonto_applied}} + Paid with Skonto ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}} + Payment method: {{payment_method}}{{/if}}{{#if payment_reference}} + Reference: {{payment_reference}}{{/if}} + Recorded at: {{paid_at}} + +This is an automatic notification — no action required.`, +}, + de: { + subject: 'Zahlung erhalten: Rechnung {{invoice_number}}', + body_html: `

Zahlung erfasst

+

Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.

+ + + + {{#if skonto_applied}}{{/if}} + {{#if payment_method}}{{/if}} + {{#if payment_reference}}{{/if}} + +
Rechnungsbetrag{{total_amount}}
Eingezahlt{{paid_amount}}
Mit Skonto bezahlt ({{skonto_percent}}%)−{{skonto_discount_amount}}
Zahlungsart{{payment_method}}
Referenz{{payment_reference}}
Erfasst am{{paid_at}}
+

Automatische Benachrichtigung — keine Aktion erforderlich.

`, + body_text: `Zahlung erfasst + +Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert. + + Rechnungsbetrag: {{total_amount}} + Eingezahlt: {{paid_amount}}{{#if skonto_applied}} + Mit Skonto bezahlt ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}} + Zahlungsart: {{payment_method}}{{/if}}{{#if payment_reference}} + Referenz: {{payment_reference}}{{/if}} + Erfasst am: {{paid_at}} + +Automatische Benachrichtigung — keine Aktion erforderlich.`, +}, + }, +}; + +let _seeded = false; + +/** + * Insert any missing CRM email templates into email_templates + + * email_template_translations. Idempotent: existing template_keys are + * left alone so admin customisations are never clobbered. + * + * Returns the list of templateKeys newly inserted (for logging). + */ +async function ensureCrmEmailTemplatesSeeded(db, logger) { + if (_seeded) return []; + if (!(await db.schema.hasTable('email_templates'))) return []; + + const cols = await db('email_templates').columnInfo(); + const hasTranslationsTable = await db.schema.hasTable('email_template_translations'); + const newlyInserted = []; + + for (const [templateKey, def] of Object.entries(CRM_EMAIL_TEMPLATES)) { + const existing = await db('email_templates').where({ template_key: templateKey }).first(); + if (existing) continue; + + const enContent = def.en; + const masterRow = { + template_key: templateKey, + variables: JSON.stringify(def.variables), + }; + if ('category' in cols) masterRow.category = def.category; + if ('subcategory' in cols) masterRow.subcategory = null; + if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag; + if ('created_at' in cols) masterRow.created_at = new Date(); + if ('updated_at' in cols) masterRow.updated_at = new Date(); + + // Fill legacy subject_ / body_html_ / body_text_ + // columns when present (the modern translations table is populated + // below regardless). + for (const colName of Object.keys(cols)) { + if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.subject; + } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.body_html; + } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = enContent.body_text; + } + } + + try { + const inserted = await db('email_templates').insert(masterRow).returning('id'); + const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + if (hasTranslationsTable && templateId) { + for (const lang of ['en', 'de']) { + const content = def[lang]; + if (!content) continue; + await db('email_template_translations').insert({ + template_id: templateId, + language: lang, + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + newlyInserted.push(templateKey); + if (logger) { + logger.info(`Self-healed missing CRM email template at runtime: ${templateKey}`); + } + } catch (err) { + // Keep _seeded=false so the next call retries. Don't throw — + // caller surfaces its own error if the template still can't be + // looked up. + if (logger) { + logger.error(`Failed to seed CRM email template ${templateKey}`, { + message: err.message, + }); + } + return newlyInserted; + } + } + + _seeded = true; + return newlyInserted; +} + +module.exports = { + CRM_EMAIL_TEMPLATES, + ensureCrmEmailTemplatesSeeded, +}; diff --git a/backend/src/services/eventReminderTemplates.js b/backend/src/services/eventReminderTemplates.js new file mode 100644 index 00000000..f640d276 --- /dev/null +++ b/backend/src/services/eventReminderTemplates.js @@ -0,0 +1,333 @@ +/** + * Pre-event customer reminder templates — definitions + runtime self-heal. + * + * Migration 143 originally seeded an empty `event_reminder_default` row. + * That left admins staring at a blank editor and had no per-event-type + * variants. Per the maintainer's "never ship compensation migrations" rule + * (see contractEmailTemplates.js for the same pattern), we self-heal at + * runtime instead of bolting on a follow-up migration. + * + * `ensureEventReminderTemplatesSeeded(db, logger)` is idempotent — call + * it as often as you like: + * - Missing template_keys get inserted with EN+DE example content. + * - Existing template_keys whose EN translation is entirely empty + * (the legacy migration-143 case) are backfilled with the example + * content. Translations that already have any subject/body content + * are LEFT ALONE so an admin's customisations never get clobbered. + * + * Process-level boolean caches the "all templates verified" state once + * we've made one successful pass, so the cron's hourly retick is free. + * + * Variables expected on every template: customer_name, event_name, + * event_date, event_type, days_before, business_name. Keep this list in + * sync with eventReminderService.composePayload. + * + * Per-type template keys (`event_reminder_`) are seeded for + * the four SYSTEM event_types from migration 061: wedding, birthday, + * corporate, other. Admins who add custom event_types via the Event + * Types settings page get no seeded body — they author their own via + * the Reminder Emails tab (the "Default" pill on the sidebar makes + * obvious which types are still riding the catch-all). + */ + +const VARIABLES = [ + 'customer_name', 'event_name', 'event_date', + 'event_type', 'days_before', 'business_name', +]; + +// Tiny HTML signature line shared across templates so the maintainer +// only has to brand once. Variables substitute at render time. +const SIGNATURE_EN = `

See you soon,
{{business_name}}

`; +const SIGNATURE_DE = `

Bis bald,
{{business_name}}

`; + +const EVENT_REMINDER_TEMPLATES = { + event_reminder_default: { + en: { + subject: 'Reminder: {{event_name}} in {{days_before}} day(s)', + body_html: `

Hi {{customer_name}},

+

Just a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.

+

A few things that help us hit the ground running on the day:

+
    +
  • Confirm the exact start time and address (a what3words pin works great).
  • +
  • Let us know if there is anything we should keep an eye on — VIPs, surprise moments, restricted areas.
  • +
  • Indoor venues: a small corner for equipment setup is a huge help.
  • +
+

If anything has changed since we last spoke, just hit reply.

+${SIGNATURE_EN}`, + body_text: `Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, + }, + de: { + subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)', + body_html: `

Hallo {{customer_name}},

+

kurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).

+

Damit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:

+
    +
  • Genaue Startzeit und Adresse bestätigen (gerne auch ein what3words-Pin).
  • +
  • Kurz Bescheid geben, falls etwas besonders zu beachten ist — VIPs, Überraschungsmomente, abgesperrte Bereiche.
  • +
  • Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.
  • +
+

Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.

+${SIGNATURE_DE}`, + body_text: `Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}`, + }, + }, + + event_reminder_wedding: { + en: { + subject: 'Your wedding on {{event_date}} — last details', + body_html: `

Dear {{customer_name}},

+

Your wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.

+

A short pre-day checklist so the photo coverage flows smoothly:

+
    +
  • Timeline: a rough hour-by-hour run-of-day (getting ready → ceremony → portraits → reception → party) helps us anticipate every moment.
  • +
  • Family shots: a short list of must-have group photos (with names) keeps the formals quick and stress-free.
  • +
  • Getting-ready space: a room with natural light (window-side) makes a real difference.
  • +
  • Surprises: let us know about any surprises so we are in the right place at the right moment — and won't accidentally spoil them.
  • +
  • Logistics: ceremony start time, venue address, parking notes, and contact number for the day-of coordinator.
  • +
+

If anything has shifted since we last spoke — even small things — just hit reply.

+${SIGNATURE_EN}`, + body_text: `Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won't spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, + }, + de: { + subject: 'Eure Hochzeit am {{event_date}} — letzte Details', + body_html: `

Liebe/r {{customer_name}},

+

euer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.

+

Eine kurze Checkliste vor dem Tag, damit die fotografische Begleitung reibungslos läuft:

+
    +
  • Ablauf: ein grober Stunden-Ablauf (Getting-Ready → Trauung → Portraits → Empfang → Party) hilft uns enorm, jeden Moment einzuplanen.
  • +
  • Familienbilder: eine kurze Liste der Wunsch-Gruppenbilder (mit Namen) hält die Formalitäten knapp und entspannt.
  • +
  • Getting-Ready-Raum: ein Zimmer mit Tageslicht (Fensterseite) macht einen riesigen Unterschied.
  • +
  • Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind — und nichts versehentlich verraten.
  • +
  • Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.
  • +
+

Hat sich seit unserem letzten Gespräch etwas verschoben — auch Kleinigkeiten? Einfach kurz antworten.

+${SIGNATURE_DE}`, + body_text: `Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}`, + }, + }, + + event_reminder_birthday: { + en: { + subject: '{{event_name}} on {{event_date}} — quick check-in', + body_html: `

Hi {{customer_name}},

+

{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in before the day:

+
    +
  • Headcount: roughly how many guests should we expect? Helps us plan group shots and candid coverage.
  • +
  • Schedule: when is the cake/song moment? We always want to be ready for that one.
  • +
  • Theme or dress code: if there is one, let us know so we can match the vibe.
  • +
  • Surprises: any surprise guests or moments we should keep quiet about?
  • +
+

Looking forward to celebrating — let us know if anything has changed.

+${SIGNATURE_EN}`, + body_text: `Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}`, + }, + de: { + subject: '{{event_name}} am {{event_date}} — kurze Rückfrage', + body_html: `

Hallo {{customer_name}},

+

{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage vor dem Tag:

+
    +
  • Personenzahl: wie viele Gäste werden in etwa kommen? Hilft uns bei Gruppenbildern und der Candid-Strecke.
  • +
  • Ablauf: wann ist der Torten-/Ständchen-Moment? Den möchten wir auf keinen Fall verpassen.
  • +
  • Motto oder Dresscode: falls vorhanden, gerne kurz Bescheid geben, damit wir die Stimmung treffen.
  • +
  • Überraschungen: Gäste oder Momente, über die wir nicht reden sollten?
  • +
+

Wir freuen uns auf das Fest — kurz Bescheid geben, falls sich etwas geändert hat.

+${SIGNATURE_DE}`, + body_text: `Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, + }, + }, + + event_reminder_corporate: { + en: { + subject: 'Coverage prep: {{event_name}} on {{event_date}}', + body_html: `

Dear {{customer_name}},

+

{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:

+
    +
  • Shot brief: what is the photography for — internal comms, press kit, social, website? It affects framing and crops.
  • +
  • Agenda / run-of-show: who is speaking when, plus any moments worth flagging (awards, panels, Q&A).
  • +
  • VIPs & brand: a short list of names to prioritise, plus the logo/colour direction so we keep the deck consistent.
  • +
  • Access: entrance, loading dock if any, on-site contact for the morning. Photo IDs or accreditation needed?
  • +
  • Confidentiality: any sessions that are strictly internal / no-photo?
  • +
  • Delivery: rough turnaround you need (24h press selects, full gallery later)?
  • +
+

Happy to jump on a 10-min call beforehand if it is easier than email.

+${SIGNATURE_EN}`, + body_text: `Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}`, + }, + de: { + subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}', + body_html: `

Sehr geehrte/r {{customer_name}},

+

{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:

+
    +
  • Briefing: wofür sind die Bilder — interne Kommunikation, Pressekit, Social, Website? Hat Einfluss auf Bildausschnitt und Format.
  • +
  • Agenda / Ablauf: wer spricht wann, sowie besondere Momente (Awards, Panels, Q&A).
  • +
  • VIPs & Brand: kurze Liste der zu priorisierenden Personen, plus Logo-/Farbvorgaben für eine konsistente Bildsprache.
  • +
  • Zugang: Eingang, ggf. Anlieferung, Ansprechperson am Morgen. Lichtbildausweis oder Akkreditierung nötig?
  • +
  • Vertraulichkeit: Sessions, die ausschliesslich intern sind / kein Foto?
  • +
  • Lieferung: grobe Vorgabe zur Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?
  • +
+

Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.

+${SIGNATURE_DE}`, + body_text: `Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}`, + }, + }, + + event_reminder_other: { + en: { + subject: '{{event_name}} on {{event_date}} — prep notes', + body_html: `

Hi {{customer_name}},

+

{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:

+
    +
  • Start time & address: please confirm both — even small changes matter for arrival/setup.
  • +
  • Run-of-day: a rough timeline of the key moments (start, highlights, end) helps us be in the right place.
  • +
  • Setup space: if indoors, a small corner for gear makes a real difference.
  • +
  • Anything specific: people to prioritise, things to avoid, dress code, surprises — just let us know.
  • +
+

If anything has changed since we last spoke, hit reply.

+${SIGNATURE_EN}`, + body_text: `Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}`, + }, + de: { + subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise', + body_html: `

Hallo {{customer_name}},

+

{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:

+
    +
  • Startzeit & Adresse: bitte beides kurz bestätigen — auch kleine Änderungen sind für Anreise/Aufbau wichtig.
  • +
  • Ablauf: ein grober Zeitplan der Schlüsselmomente (Start, Highlights, Ende) hilft uns bei der Positionierung.
  • +
  • Aufbauplatz: bei Innen-Locations ist eine kleine Ecke fürs Equipment Gold wert.
  • +
  • Besonderheiten: Personen, die im Fokus stehen sollen, Dinge, die vermieden werden sollen, Dresscode, Überraschungen — gerne kurz Bescheid geben.
  • +
+

Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.

+${SIGNATURE_DE}`, + body_text: `Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, + }, + }, +}; + +let _seeded = false; + +/** + * Idempotent seed/backfill for every entry in EVENT_REMINDER_TEMPLATES. + * Safe to call repeatedly — both at boot and inside the cron tick. + * + * Rules: + * - Missing template_key → insert master row + translations. + * - Existing template_key whose EN translation is entirely empty + * (subject + body_html + body_text all blank) → backfill EN+DE. + * This matches the legacy migration-143 "empty seed" case without + * ever touching admin-customised content. + * - Existing template_key with non-empty EN translation → leave alone. + * + * Returns array of template_keys touched (inserted or backfilled) for + * diagnostic logging. + */ +async function ensureEventReminderTemplatesSeeded(db, logger) { + if (_seeded) return []; + if (!(await db.schema.hasTable('email_templates'))) return []; + + const cols = await db('email_templates').columnInfo(); + const hasTranslationsTable = await db.schema.hasTable('email_template_translations'); + const touched = []; + + const isEmpty = (tr) => { + if (!tr) return true; + const s = (tr.subject || '').trim(); + const h = (tr.body_html || '').trim(); + const t = (tr.body_text || '').trim(); + return !s && !h && !t; + }; + + const upsertTranslation = async (templateId, language, content) => { + if (!hasTranslationsTable) return; + const existing = await db('email_template_translations') + .where({ template_id: templateId, language }) + .first(); + if (existing && !isEmpty(existing)) return; // never overwrite admin edits + if (existing) { + await db('email_template_translations') + .where({ id: existing.id }) + .update({ + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + updated_at: new Date(), + }); + } else { + await db('email_template_translations').insert({ + template_id: templateId, + language, + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + created_at: new Date(), + updated_at: new Date(), + }); + } + }; + + for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) { + try { + let existing = await db('email_templates').where({ template_key: templateKey }).first(); + + if (!existing) { + const en = def.en; + const masterRow = { + template_key: templateKey, + variables: JSON.stringify(VARIABLES), + }; + if ('category' in cols) masterRow.category = 'crm'; + if ('subcategory' in cols) masterRow.subcategory = 'event_reminder'; + if ('feature_flag' in cols) masterRow.feature_flag = 'crm_event_reminders_enabled'; + if ('created_at' in cols) masterRow.created_at = new Date(); + if ('updated_at' in cols) masterRow.updated_at = new Date(); + // Fill legacy subject_/body_html_ columns if present. + for (const colName of Object.keys(cols)) { + if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = en.subject; + } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = en.body_html; + } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) { + masterRow[colName] = en.body_text; + } + } + const inserted = await db('email_templates').insert(masterRow).returning('id'); + const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + await upsertTranslation(templateId, 'en', def.en); + await upsertTranslation(templateId, 'de', def.de); + touched.push(templateKey); + if (logger) logger.info(`Self-healed event reminder template: ${templateKey}`); + continue; + } + + // Template exists — backfill empty translations only. + if (hasTranslationsTable) { + const en = await db('email_template_translations') + .where({ template_id: existing.id, language: 'en' }) + .first(); + if (isEmpty(en)) { + await upsertTranslation(existing.id, 'en', def.en); + await upsertTranslation(existing.id, 'de', def.de); + touched.push(templateKey); + if (logger) logger.info(`Self-healed empty event reminder translations: ${templateKey}`); + } + } + } catch (err) { + if (logger) { + logger.error(`Failed to seed event reminder template ${templateKey}`, { + message: err.message, + }); + } + // Keep _seeded=false so the next pass retries. + return touched; + } + } + + _seeded = true; + return touched; +} + +module.exports = { + EVENT_REMINDER_TEMPLATES, + ensureEventReminderTemplatesSeeded, +}; 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 02/17] =?UTF-8?q?feat(crm):=20backend=20code=20=E2=80=94?= =?UTF-8?q?=20services=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 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 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 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 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 zcmdqK2VfP&7B@b#yZ7D{NC+f!Lkff{B_W|hLJ7UM&;p^CB!mt@ARt|&i5QRv2#5_q zK@mX_k%u9m0xDt$^(l4|1QC+E-|w8gbMH+Ew&(koFPk$vduHa$nKS*&*}XyvA7EnU+Dh39-B-?mpvE z=Zt;v=mDf%-d>30mgC2yjBXP?WVjIDtwng;cpxf&TImHm6aDJr)27cpv+D}dfA<5D zS4^EeD&^qrt+#~uItv8|NK2WWp;ZzGg}8>&h_LjOv@y*^YDYX*NB$c#CQq9lm;6S$ z5Z7M;8;dihj>#BrU$sO?dpzPhgcO5$}}Dm8TKsI-Vs` zQQi89Uf|zopV+ORh#NI$YO1I+Zt9pxqE2ec^mLSvBqdA}^dKU*>Y36izm*rlCXJbz zE}F7iD=lT}BoUT2DQyy{pos{Z7X>?>i95>`Zf@=2b^NmYlI(ATs{mm~Tae%o+}d}G zI&jCMEH-_!s3^*cQhF^RHN%eFYxb*Xk?v#rk}GHm<39 zs=c2+Rv)1c=Bpo`fPvdX?*yQW?iWG#qTW_-smI}J3+fH^I-JL0X~t(F#{Rn;O4O)HOSL68FBa}*eklp$0?qYYctt#-X zsHJMHjAXdOs0C|7sRe6889&rsj{F>&$o@p~>q96ME<*Jo96FfO4r2c_rZ9~uOlSNw ziYa3q_mFEF51P$5v>C;huaRHA#%brVyN^Tnv41jCo6K}3GY?ZabQ)u(GKEr(hd`ke zl?yKQz*0=JG{u3RaxZ1C1lQNGj9ivLI#W((YUxbpDLeYPHi{{aA5k}ucsq{^ZZ_jkvF`afyp&h4c$Dvz^XKgEU^08wU{2x08unP|1 zr_hf%^b+x`T_V2`LVmaiH9|P_J5Kv8`}3K?HOAy~s(hxC&!LaAyO}YY+5ZsxA7cM% z_OB+t?R9eXV4?;WdBFvTaFM$n%%Oga_hY;tWA-v;FZpF<^2^HP*Uph&JI8TWaA;Mc zEbpPTvMR@{N|a?)j(?juzs>PWF%Mk7I@hl@o#V{sGS1`j&g1y=I2Z0I+DJ|tL>N7Y z<3B*ItqEb^B6nL8#(%>UzTvc2nC3T3?Hhz@A{an3Jk6_XFt5HPo)hn2cFpD4^KWUEGq3Zi%JL0pV9|2 zju1@&(SUk@C_osXGD(Ts&op**a^*YhzQJxOa*NHfG(Ym}qu-~Og-kLqd;Oqevb*!r zZsFn)Py4Bai^f2@ta0!P#>C>+1&yw=eC1+<%*Dv4uA>+$fB!m!@cn%4!Dve3DUGOi zzcyo}rSVo>t9i^_j&av>Ey5VQ2qW;F*GwLjGk9d4h|$^m!br_m5=LtpuS>dU45usJ zbv4I$-kjrGsfhBOS7XZV*D?E$5lq7%g=oonjxLVuA35>tGL9CGCb_BUrgt+Az^?;H($ zDRT8qjQ8g_1IQKUo!t;hYf`CAWp}!>7ce8)Z@H7%{|LLS*kw8z*6c`i$#l|~#{O^F z&1d&p$MyjkJ~iVj9}@{o$Xv^ADqVMKH6x9{ykOn!71eGs#Tw&fM%~zFFis+w7vA&9~h? zZXNVwzNc{TA2Bh8m`4CyCuRdP*?HZ(Y$C}^sJ<+sj5=etq!#VcBeTGRR-5r0b zjhp)@rSr2pA_%jG!kHf{pLO@nt7yodeKL2u>hs8-Nnu`h<-5OlY*h+xyxG`9x#`b* z&bljC@vZI(U-JD<$y@Qv^XA7E1rwt?wYuVe3a2D*t}Gto&SM&!Hv{k2h`2$4HR zC2M3Z66YU!P^JEd$o(#vJ6a_4TzCI7f4Y0vp9wL4m-_Eh_~)f!zDHxC^=JO5eCqzs z=cg$Dw|DbRi|@bIn#~K|`I;BKpYHB2-ahUg(>ug_1>?bcOVm8>!F&76r_9%^XEj!N zhyM5e{|(iDfG^ibTQVij=xo(FjhY^5x>|#~Eql|TSk@Tpjr~2pS@-us|FN*Re~i*! zAmm;9=E9*2$XGQS5X0HYTZS+>?wPS2-#2e7m;$HOcFKZ5II!T zl*8pXQ5zb7G!ZQ)%jqIc&XV&)OPM8?igt2^TqC;34baN;mK$*r*9SVpEn=YDDz}Ox z`Ivl643azLPBBhZAg|;F{wv+8dMcF}ifOI;_j&M86&TzZPF3?%_ zll??p*&ou;A)SGcR1#z)A)_InFcgxKkkoMaN5~P7c#2FBzH+1-CH&-QIU1NTatttI zAx$4hGgUZbnw*T2xD1(rv{U31QC?1!Q&At&sjD-ww!}FnKBdf=gPV8 z-zV<_ejaK0|1E1?!QihH0Heni|WvvI;2E;q?dXa{u8T24MH9~D)gCH|KP<2nwJ z+vRrPsiy159dZZS4b^lY*K}2`=_q+YUIY&xLIbGb>c(|ko$EN9>o|bxxB}O)&UIXi z>o|n#xDwZH4X)h~>5q0@K~lT+;dbrA?b?Uy+p1}e>)5JYjcd0&*X}*Cy=;%tQXL23 zJUJ15s_7uvNp^yt>N|+*yOivTp5eni!-so@54V7tUM;}Eb+2))Yh3H4xz_!-*8REG zOLMLJajpAut=m!SX{dwAsC$EJy$aWQRj&0aTH-8ja){t;qegHrHikuEi+suNAo#%W^G-axF%1 ze=R59M}L*_EA&?!7(nAky27&JQlr)4IP{Nuao+`zQ^Q1g%J|&M}Zjxw^ z4bid>Bi0eD*`?eDouowH?10z~^GE4#ekbG2pXFQF2WWDjxkb(}ah5M>wGC~ic}z|M zq?*Uj3vp@>%-g`c4b0neh54IY30PzPhP2R%VtgUZXj?96RY)pZu161>h(aDTc!7xZDa!4+Ca3N6lyVO zFJmwk%!D-1S5c!6q9)g%M)rbhj4Ec9OfhH3(YTL+)(0}cyspV@=5@IfD?LrNF^|aQ z2nmv!xl`(9q_p8~H&;O-JEaf658w~zV(x=Xl0kDH=bP-G=lv zCm_}Y#A<<9Es*0t%;k3RIViM2tUjiIR9nb-cwUYW32YW~`V=@_g)~or(^WKHg0m5T z(OAd$;3*4FiE<<`qZpegSDEMKYUF+ZIorYS58(3$(A$YzRw0)}x!idKl*R%xj`Q1z z{8pjd5_#{$C_2K-2K{Wr-6xlU>T=}eOPcEX1$B0?rrNTj@DW<%7OKgb$<^D6L{UWN zC_+)w^cQ5^{?J|UJ2CFip}699aNSGLv!6tnk&us;a)a3 zsc`GgI;JCPEogqj^t_@#+sWFZKc!j1`qAuSW_Ts{@~Sncx~m}b9IZC3eVf90-=R1B znVyTM^N!=~hpsaFjup0dgggDLr~T8bPS={-TT(^SntL%yxc#I!8&QgIO0k(mhOl%%R6uL z7t(i|6YkV_d$iq_p4-xLn@Yz`k~7R7T(t9HRYZUF%%9C4kdn^AtUvS~mp-tJd5uvu zTz6Ll^9`I`;SaU#qGknnb`7qGqG&pW{hN0>zxxOO{;bxhXqHqrW^z&7UwQg#=UI19 zp3j)Zci{gjBFuajHU`w9Yq)AHj7XqA9>6K2^%q7rZj066p(h$Q>j9SqNJdF&U}u6q zw1ao){)c~^tQfrKw9^=fv@=A6HD>S=&TFWrxO$r1ieJn{o+i#?!9kXLMCTFJJD!Vz zH_FRz)q)vkK8OB460|y4@%g-^vN_E>>(TRjQ=*d7A5yJ@8>xCK}FWN4=GWi2<$<%&7k@>X>H*dIoIm%c3ZjE1z zEwL6n)QYFHp1sn$BXG-8>kjJQXo>V^b{E0u(Q58Si06-`y|?IU!wOZYRa{RyHotJi zxPzkAyHpC?d6j@sYP}ckPkW0}Gk1D8xH}123tKBO@79Mo26Dj~$<-4)z0*Uf==#mV zdsfy+eGpV!62()#Wm8~Y@wP4SQ2QIc1Wq&PZ=@_SiTRcr?c?^bM@gY57EdMg#yjHt zX$05Ac`G{2j2M;Mnv=YftKMmjcg1=eu~2{N&m50gumzpGJNdL;&8pT2VGc%%7(#Ka z^iHpoZVE-)`c9Q2(i9o>J@u+m{;%$LX~U)dSNRo{D4udX=Z!_%fmd()TkXIreUZX= zWbQ%#;jV3ONvS;PE#IGt^A5$u=yyPk=~%gAjcT4{>p2%m*J|G3daQx_ImB57z&gy_ z<&e0oZWf;%o85}-F(EC^RprxFtxkI zFA+}~v3eZh8u4AR4&y%GvISGa~{`Ni18TXT#J(Fs-5~j*&fWJ<~ih<2>6hAxO)q@Gla$i=npWb zs6X?l^8L}>Lk(m9X>*nHiYSLY0x061_D89UhMPqn|MHV-j!yO@wDhl@=Rfi}iSPgV z&pqxlr9Z1z{;#vOazovtHvfyBuqzduM>sf6%K(A$@UFCi*Wv_}5F@Xx9}@Amvx3;Exq{WE#} zWBP_a$n2k-Y5pLqy9Y;jg1q}xQS0d~%fw0(po2|JBcWwCi*x>wfp+ThEynu=#&fGWFil zAI1Cq@H-)s&x$X~z@8N}V9zS+kUgucPxh>`G1;@iDs%;G(b|&5oa{kXZ=eDDRN0H{Q$gz| zTuIxI)D~@jU$d#Q}UX<^^a`q&NawK3%*a-bUJY#98>y;nKu;@gDr|<2wWX;vz2k zme0qq3HwBRhBt@F2D!DkB>n^c*WwCV$G74~SiSuue#V*gRq-n@H&MG4#BE#>bzA_N zWa-TsiE6kKK391BZM zvic2RtKSf|`VC-<&@ycG8^Bh-A#4{~pY1|p*eCq7iH(8pf8vRoF5(fNew@v5jan+lV$|8_{UC5iP@(!2xU;9Kx2t0cW40Y_#TKD0*{(N$?Rv{l-@tbvG_eeTFA$;K2BJk$4=Kw%q!M~ad9?2eunBRX zuSB3H&po6e_mC#sLmG1rX~aDwlI^YAa1UvV+2cJ>Ae-qjm_xpVpDd@X-eRED zz`mHjxv&FmT!?N)$ycILL;f3W)%#TkkCLe*J4*L|JPPw4xLT~AUPeKW4v?2g43 zr|j$lPSMi}`1(jYqdkr9n~c?l%24o0Uz{lg9%*j#=UFX~=d&R6(*#kD=c-WjyZIta ztiaVkJPga{hTM;$G3OjWuYL)$OKa{UZSi8eDLP=zc8El78y&eeJHY~ars&M$R~Ohq zFUNN-dSVoT#XcYeP##bLP!UiGP#I7KP!(_wAQVsy5C#YbL;#`yH378%wE^`2_W~LL zq5+Kov4A*06F^fyGeC1d3qVUiD?n>N8$diD0niUXR_6l&Nq|9sHGs8%b%0%f#{s(m zPaxMP0nY&T0G;MPA2jC0v1NZ|<0ZIb`0D%Ba48!>e zS%hJH#Ys~-U@{=Xd_YVAOa)8>Ob6%~@r3vY@G-y#W$dF5xX~H z_eRX#h}j!4dt+tO8?CQ5dKl3qIw~!3_xove=0n9NIZS{YCVcR8Q=oo1HeVp25jNcBP8UBcO6h?JhI@8C>mL^9C7Ze zmLtLuOqczZ{fa%;jrYVT=Ci+If5j1JpKlLF%DY~+eA@|Jhuzm6+a7#FP+j9~bS3lE z&(;NB3v6y{$X6X(7+>_AK)STzm+=co;_}cenH=^Pt=F<6{Y29M{Qy9B1T`n z9C7kF$fF9@A@q(Stvzg5X*9#il~xy}(T@jU+!%(HbU3dB5_#V5h?exM=*)9{7xc$N zqC0xtYodoZA^wd~=^|FWy)m2rh`vv&+p)Z^O~;!ZZNy}}-H{+Ncx0QxGuKoe-)8Xm zHk0S0S#lX_Y&OpwnfzUz`RFgR&@yHN<^VDQa{*p02KCzpyTWFoEZlN{U_d0GI-mxi zEubBsJ%FrM69F9oodBHyc>68u9RPd{|2KdufUAH!KtA9a;1|Gk zzzx8!0P1fR2NmG6MxZqGzI4E3Kn7q6U@Bl5U^-w1Qp^O*f-Ye;U=AP?Fc)wiU>;yT zU;%cjS%8Ir(%^I-sO|&ReW0)t6n26_HYj9+LN+L5gF-eaWP?IBC}g8wd)G^8aJUcB z+J_QmqlDQgVKz#bjS^;~gxM%zHcFU{5@w@>*(hN)avla8K zOu$^geSmp@`G6?5T++aOI$$y&19?vYOa)8>Ob5(@L}ml#05Sn{0rvss0pdB8P!^!v_}8c++PU z-USkP7l_{7YJhjQ8sOcn2HZF3J)u;*w*)I(yaj~OSIsZ{P7sZbCo$)Yz#B}XAxUas z*^qxW(*A^*fo7vV7+LX#6-NHv2;UFv0Se(6jAkzlb4YJcqxY{c+9O34b_BWL32#7w zpCjPs2zWSxP6=af{tfs9HTo&wGr;G7OMuIOF92Txz5@IQ z@Ezh(i?|8+4R8zm(7R{-z*{P0hhBu-lEKR=>;Q@L;yHZ=+~=q*7p1$V);YwIWedEi zmxLTLP|A78VVUzf-do#=_ttjeoi%!2jow4^#@O$OzY^&vu9EW%mNUIUhQ92ovp#q_ zg0{eKwDIbq7gkjofBUySfA2Q{S{2zYpwSR!;Ip<9e)UMxI-+w zt>0;)nQE3?>eRTTdG%+t}rjY^)s{pys20Uv!CQV@opV7RWbO+WNG@sq%0$YWLa5G2FnoW;HuKsoS}samEkf1 z`_d>G3H?n?>`lwd3bGI_vXP8|W-gZWIoPo_!%nq@jK&_d zm28c?{IDm+w;8ZQ?F@fcXk*Io{<1vpEGuBvorE{K=`HKZat7X3zX#ktAR5TG`4x>4&{Ey`7pS)3MiVkNs~a>~--CYUI-myW3va=k`EuLqT~acA9uY z8*}@DXZm-%u5c9wV}8+*#d*h?9kGZiQ6$Q}L3Nl+ z#?CmsL`s+C7xGK&BmaZ1Dt;rc$ZzF$@_T$^@kdZ|K$if^FL2xic_rcPbRXU^mja*j z@Q(LNxf1V|pO>Fu4@Ir1gX5@Uhhw=T-O~IWuI*yY42ihXfJDX z+OF8n+79B2&SmlS<{yktj9h$odAqUPm}!j0Hoju7}+epbjx--*5x@w`+>zFR3I^rTXxBX$?xKJM6>(HI(4 zi53f=Z%Z$HRlEx-v9FW9=-rSD(bjwed;{==uWIvMQKoMq1`FwP(-jkHX~-OJwxbPE zdn_$OFdyi`VLRrzpm~zUNmtDib-Qhcy6}FqG-%(Wa<nGeAwDXri}~2>6=p+y^Tm9S{hpf-<14zD zkI`Ou-8f^MVLrOKvBXDD;sf9Gc3;fLC3hU~f$w>{F6QI7SC|bgjW6b7w^xW^45f?t z@RpN~FMYRXKBl{46CYi{2Wjl3=HXhyJI7rmY!1=yhGU*dm+AOk!+eYwrCfT^%jjF> zky4kx$X_(HC#{ah5Bl5^vER6;#UTv)Y|G~k!S{2us>uBpgyQbPYjU?{0Q(7O?Y8{x zkW~`j=#@bsOCL#OCM=yQ8WUV;t@E6N8s{^M>D`e(4Z5M zEK!J&Vr_;&yz4`8Dwv?+_mc9}YR*bGgPjkU)r?PA04insJGJt%bK? z2IUL;T?uWLg*CYL!nj0HE}YI^h^~lOWf?<#rX!y4`wNsa&n`5>&_(hld2-IsIjO5i z&d^&~SCO1iCv=f4-8u6&BZ}tSnRD*MIZsD^>PiQGmpkXdD64f*{j7B1Z78dCwFH*N zO5zh=kKro{I9N$DPRH7*BFankPJdL|_NW&FEjbU5_^k+e6~nubd77^2G9Pp_%tZGg zCRvhM?TJ!#K#0}uf^ny|M`Htp4B-&!DHJl=D`YT-U_9rLE?yynI0W&v4Zt>ZhiJ4a zK^Wy64Nj_ZN-HM~E0u1*`=aFyl7soolhzV*=<6`x1K%FubUyGy+kmxHDd;|Wi1yHR z*1{M^I$pc{9wlGE?o1cvJI0VZ6LbuWS<3e@`*iey3;6nYS!m#Ph-{p%EEbv229L#j zOY{?=JB`CA9VIn!0~&ChH1DqaEWXcjJ>!cv@&^uI;JVM``z&xo_*qzQ{Rj>y{Q|d7 z;V70Bs^Sq{3r9XH9_68Mh==i~mI&iEP)c6ml72{~lHa=cpgX1b$jTr6pegF37S`2O zA+s_psjpckA7HfBured5T!7Y}(#?{eTIsOrGWeNf=!5ZK7G{|&j0NxFY~Tw|&2Y=1 zk%8*sGAIpYH_0mo)h6l?^sqa@nTj2?YT)Zh(I`g%w+#*byfN1`S@Kb9p|2}pOhPJw zJ&c8^h_Q@LrwI!>^c^E^8CGZ*_7rNgqhDzW;doeAa;zC1euAQePr%lIuz;}AIi)j7 zXZRn(DpUAx_uuHh%72mnZ2xrs(f)(|d-*5&xAc$pujL==AMEevFZ^!!{owb7-$lQ- z{Z9BD_Iu86x8D}O^?u9!=KD?eo9H*fZ-8GnzqWo&{p$Nw_p9tz#?S6s;G6Gz#rHGc z_k44GkNF<--Q&B%H`{kL)`ywC8NOqEhx+#M?d03qx3O;>-!R|uzNLIMpPN2E`F!Q` zk>Q(#KSU;b) z=h%DA; z*>>Bu*w*7$SLWNM<5yNj*aqOYRoY_pR^L_~tF$sUyHS8&PPt-yhF?p`HI88&zQ@>s z-$Ge!EXMDjWEf+y8tP+o!f%^2HtOK_Ov)Rj@M|VF^`Gz?CLiH^=%oG%&V-)Sx9S`4 zJ0)5AOg&W}sVCt#NZRYo^=SO+NQ7P)zcS*dYxvrJzV;oysQ;069^cA8t{ukL@1NFo z;H&oQwUzj8eWo@Q-=!a^4aVxei`Et^{AjHXz8POx3)TWOA5BA>)-d*vJB;1I><)r! ztyQc$h0*g+*PUX~vx=9*&oqh=ei+lDyA^*JMmY;_#UF-NOwU7I@o6t*JzMdIy5bLY z(I4uHKiCz2uq*yx7yZGm_=8>X2fN}Aa>XCyia*E|pGIBEZ;&hgAXoe(SNtSbeA+uw zyd+nA8n3PAB%Bi(h)tLB4Ria3x_yJ)zCmuEN=wqR(vrNCPo*W9DV$16ajm6m)~TJl+G$!Db{pOuz;DlPemGD%sbrIx91D(x_LS|w+SrP5Lx zwNfZKQ>aR-q)JOI)}o^1OreTrC1>J9_&9qS7ik z6HcX7aweQgtK>{_RB4r*38&I3In!OGRdObbN~`2dIF(k(nQ$ttk~7gzX_cG_r_w4p z6HcX7a;99ZwB)noOg<|u`7Ak;&q_-^OU}fnN~`2dIF(k(nQ$ttk~5V|rB!k!oJy{Ll~&1_a4M~m zGo`fBlFyPe`K+|$v*b)ZD=qmfIa6LLt&%h0R9Yox!l|@M&cvNctK>{Ll~&1_a4M~m zGnGfBRdObrN~`2dIF(k(nIxdnDmfEQrB!k!oJyQ#4uC$oEi^v$Wp(J~%Sb!)8u_s4{0zJNTyYFmDzwkrj6M5GXhAZu zzn%zv%V206ddPRN-W$yBAhPztj`$!Db{ zp9)o!DWwWkl!=B)t0)sLNz~Q*;CBOC>y5Eq57W!z_W(8RCVms^E3DPeYA3Zm!wj$N>9>yXzj5^Z>UA#7oEy#KKQk!U*vo8j64DT(hK;~XN0JWHUCxZ zYy5`Rd9tC1;7oevLM`Kz;ysCUT#zO~+1Ugf;-bZP-?8ir+P?j$KL_%`OY@>r_|dXOKg# zJSGo93OjH*u^Oi~nXuOw3;z4y>3q)pKb$*#dvqo^p}oukz7KQVhq~^AUH3t*dlK#+a8H@Z_d!Tc&q=s{gSJK^ z;R2VBXjwR-X8DMoIF*)0LWNUlX(Y68G!iPGN=qZ5!l|@0 z5?VMK36)Q!rIAqKR9fmC3a8T2NNC|`Bvd|?mimsuskGF0EF6u5%BRxONT_fsE%hvg zqqG+xE9$c|T|TM<3rDp;KFU}5R4CbWFoS%vHD(JCqNNcyX6 z_w@tE2`NUYF|>3_|C_@x$`y-I7W$|@eC_7&prYX#>20HNE#=U_VxjLrg27@uhm~e& zK7;R*lO!!^YT90WTlX1^5cJE4`!LQuk1^>0M$SX>WqAZ6-%IjEd9WB&s$r|l{_xS4 zixoYoku|E4zou2jcz0FaM9%b!Zz1xy+=cl#72{7+876%&`+kCRlNT`SuEj|~24>rS z&{wx)9XcqxE`gIr;uo!fDOq>Qlm75qZXA#DP(DwX!C_W9kKYq+bA?+myq`RA?Cv;L ztfJ4Jcn+_4R?OlUPh8S-dSAr-oj6Z?-y*RoC8a0{>q+BxM;a?t$#_pXuGKrzQhs*} zvC>M}k5g$lbt5YVoH*e009fEPwp@_V4$rYDb6i&|gw{C-U+#XCf2(t=+$Oi<8__%P z&F&{KKR%7GcJD^Jc^oA++}05tFanS*L@A~i0Gf(cJ$$)+y?p3Tv}&Aa!B+MOq@>-%31~W>!>@a$VzonSl2Y;()(98S)2%f@Kdg)!;!N`^ zaU8tQhs_IX>T&+3;dd12tKm5q^%_H~?mi(9*MQ9xzrsC@lp20toW9w8M*ITzl*ogd zhaOHTsZ1oXJly$o0g`}a2jbGnLZ0}6W6=rCDObL9VsS=%1NZbD`O-?{I-Rv5#+Pt& zv4W>~pTf-(m*M8%=-4Z1f6CC<8)*naM!VFBTk-%dm7fh#LphMIk57hJgF97GmKm;`nx#> z`js2A6aHL`#+0UbiShm&sBVROUHl8~8LkO@ z_;GyC!MP(n=Q5|sNTKR+DEo&nB}*cM7?Z>pDp!BEuMhi5#EIW_aPv?u;<_W;9GrX-zp#G+zd1MwrTZyZk`v!~kb%WN>{9TYi}N{)Z;xCP zkp7H_XDV%&Vr#gnC$!|y7L2#b(~L1qy)bc%iDirrRt#}CyR3r~z)(K%jONfraIXVT zWoQ8RG-6Om?uDC!`k^w^W!}^%P>aKB!d0@Z0e>#&QVL%_@%66TV60(@ZW!F_v|dHJ zs&I4ZonPcs32qKJqMSmJ6G`hHzFWPdJVLBq63j8Fu7Vh6m7A?=dCVkHa8+Nk>`XaTKYSwL_941~sG z7~aoK!+LB6>^>Gi7qSX(0IkQnPmkbj!d;j#pTwH&CA`6NRJ?)x;ybWzc^_}ce1f&} zx7bHs6_ynl&6OI~w+7-rFZOGmy?}-j^ug#SwZNaiX|r`v>1h_XF7j#MI|pTvX!m(= zVYR?mgXe9D(d;>_`3~SRFz2sFd|IUtohJ}-02sq4)RHI{Ta57*!ZurX?Je=FwoM?q z%{mn}vIP2>Kp%Ad_37KaFGgwMG#$sBup9CD6c!5tzsi9>BiVKyw=nd2;+6I`0;4dw zXjPLlre(^eLd+UBe!4{Xgy|`%5^WtP>*Y}^s3X)#0^$;<1aM_zaA`ejvKhv6>yJvK zXj9x^VQ4#WZ;!rXtqL08t%>;<SG}JDlwgn`k6*(b_t#UgjreAP(`w1hnEqqBX?3&{7yCZ#Ib@Uo+jz-dI z4%(I3SkEQF^0gnVOeV99!c44px}YXhPq3_sYomX7dIP;db4PE0-Pu4`%&x#b=5_Q8 z)i1WYdd2~-p7D-2cW2Ll=A}QZZs$R>gtuT(`F&A$1LVWl8$F1(W~Sl&nN{-Ncmwl3 z`W1V548O0k0a}|k@doBP=$~GN*61i~sgzZ=j@DNiZz@*ASW_9J4!xOQAMd38gx-~i z(Yh1bN;{c}o;?C{%}C5TvtgBW5%cH$ux5EcEP<|bm3Rm<_h#`Z=JIFImY&7hp#bj$ zy@_?qDXeHt;MbW>VnuZswrZbZMYj%XyT0OWY{OFIDe9jX*YeOeN8p+I5DJBM6^Ipj zC+7(LrXwD0xHQJK5R8^JVWk|4_YXTFT6j>n3<-~oY!VwD5~Bx&2RR~RV8OrR`bq+vDR$$gmOd zm*VjdKLYg&zDv zZu}`(y+nMD{G>$uH5ulGzwV|}C|7v#Gr*0{g%!IyU!Nsz{BL5mg?Dx!dARZU^4;G$ zyZQIjRtK(tztUkI9k3nE<-++v(7?4YeIoTJt>Nz*F89KF6qsymja^h4DS|E{9pTCUOr7 zj}Da~!2!|{5~|TXvPO7xTx{d|GBP|iJi4`vZ5$ODzENxb*oBFSGdlNNKP=<&GtT|8 z`iA@4>uXSC;OWk&rpt`J?Up*u9g7EbJ8&Xe4c)GX2oR&!-M*H>vgOjYqT{NIy3N zd2DocpuE8oSz0=vqn-O(XSBC*<`1xo{u=g7!WL0oi)~!CdQ5bvRw2B;9vKk;?m}fu zbZf0icz~`4op7Gne?Z0_&wT%h)UwWNrQ0OWY?t}!q|{eux0^XUzLX3I9JA}g;Dd6& z)jT=yV235gGTV*qSYz(@Pd)S9>>3@%wViZibuaKT&N+zk1TSTommBi9d7JRW3(L*I z=V&&rGsw+09C8c6C?jf1muOs4GRlSo!wMe#R$J{{CR4YZn>g{UP0l6qfzM>?XP$As z>TKw|{>`-i?0NdDso(5(?*Han=ko`sKY*Ym7orVb$P$a>lEHGJx*CZnus%mUxm(Fb zJ*TjpwxV#B_ByqR^v|3lE?e@Y_{@6+rgKWX%x!_`c;R#KZauZ<;`nRgpckI{qeUkV znj!X;gqOc6JlX-urQ)N!`Q#XAFjRRJ`r$r{} zge-rB(F^i{Zpb|2u6=##TV9r^?hTz3t51 zdq6gRWA6J;ObT-TT(-@iDeY&!nwola-qa_Cv~o_;hU|2H8GKj{_#O@IrA|u@rnO9N zADQ{x(@%asH)%(@-}wWtIltXV^-FDP9m_wG>m^5%TrLYw^0e@|IFWFb%a-D_C7p1@RdSO@F`NC{%{H^-b3P94;hL8rdqBxIy`L&X%ErrKc3!f*Jt2`veEQ|<( z+O2%ddEIJ|SP;F0L4F~pq4$$89y^~Re(@f(&=zw&TlkyKXBmG3G#=yfS@=`tAXRT# zf)_pqHkCwY7wBy8!e2A{GakPtqmAKuw&X&W$BLz0<j)JSU^Ug%)ruz%eYp| z?;#!NS@?X}OVP8R2EBWko($D1M7GvZI?!(1T8oVd&>b>T*4Jysgz6zd8?o1@5?G^V zWU07Pku_@s;6<;8oHsPXC>PwIe(gZ#k6${!4XoXuL51?t&~@AS0<-S2Ee|(q`q00Y z*V851?bU|Mw`}Xw^YKR))hR5aU9GcV^Ao+>{cGz3(SnW40N7zHmKKQaD0^A z&jAik$1L2I((n0gP4ocp_hrYGd*`OTvAIEERX0DjdE7QzF+ZpAMj6W!^)`?BwD37N ze=P}r3ae(9{E1KSZ^h4-*TKKWW5jD1p8~Om_8OtAnWZMr!n$1uW|pT z-3G0<9%J5UJbI1GXyH#w?yunOr0q4kKX_)mCb35L(4jSk75^08a-%X@bUgSREC@@& zU&Fg`9z2bM7M)z|SloDCZ&~=?#I6$YrVRGN(>!FwFDQaP?#4Td;N9^tgIjgP{PQ?x z(fL*3(HBuiZaVqWrG z=m`&r^qTke(080~?%OAupOx{>oTVpIQ{Px3n=kgP1`f#{UwkEdzVywE|2*}?e`b8+ zSshq&+{uXKFj(f z@xGotGuz(|aP>{&ESIYvx1Sum=}2qKapnS&5A>!LXr7fZ)nvKw2GXHQ7F}{{){b3$ zXvA-(c(TKzztgN`-fPpG$KD^AtCn^pH*ZW8JeHzMg9~dkD6zD(Yw?tLs+=??t99#z zV}H|HI5%QHF~xR%_6Mt1pP%|^v-BRlrnI~rpzSK8S4&)DwS&t)&{clSsf0)SK)z@n z7Cv8Q^Vl@p{ML9Ma-n@rM15H^JXFJa7AtFd9kxF1t+Bohm00h(dw4aBz_c3I9(&eY zi?>%D{Z{r%TGp-IoZe3zIk)=8mTgy;>CQsGN9OdKlhA!e%Ln>ynb&1$L50+}AL=na zCN3qudAHc$dJT_nU3@Yv{rG(&Cd4E)Yu&41VD;d(W7ekJ|0aEt(!}>4z!#Teu&W$v zxE#YVM}S|H!@}pw6%4B{UA}xf(7&yVe?`&F^eIxV_T|7sc}~FYP6J?S__r(0LUTS>H#-jeDf8^nb*eSANB{ZTedU zorkUO+jspi{iTAKZP!+itce!$KAiJ71yOPx6vOA>-PDrs*U;iUcv|aPbn?UkFFdU| zE&OTmvlpJmDhr>BS8Ib6p{7p*O+4br*~yF%&#<;zxxj4Mm47_CF$kFtjJsWBkBX>{M}&|p+A zyn%V~)^z8&*N)42({=kTdZ7emVj4V8q_ONz3roh);03h7i-pgZ?{U2&zT;QqiTxe& zEQg(4Bvwx4ynHfTcWDrGdBT}4s~r!H3=gau6cH7YE32F;Un8@QfslC+su#mcWNR!? z!)1_`RQM6B5B%!1jZBP4pr7Q9E4-v7=`v>~G~WOIYwjb4wR(0Z|7*#JM?r+Px;h6xcU z6Z2@V2HqFB%G8AWbaDJOoDz}@EPOdve7np$Yqg7RkO9i-rjsvgbG;R(bBbi-rsK)? zy2J`W;cZ{I>E}Y|ZoK^oH~n95+U>zR-0@GxBR*QW)>_yGVm=NkT1~Qo9vPs^w4l0SS`5r+#Msw9a`P{c8{*p;wSa#J)@0x{f*k05nMh#xZm^@RAnQU z^oeSgysmHWWqmVK*LDd{Xn4#nku3b@ga;&FTn`ccZ zoFCn%d6*?L`%RYFDwN#;-E*#zjr}Iqt%c9g9z?h%R$|{v>&)_`8{id4BrPn1Vn_(( zNq1#0zQfQi4$tZq9ORUSe{h!tqZXZ=JNw+}fiqjSn3*(SW~)}Sf)j?;dLCMccWVu8 zx7cYOKIC*RyDF+cv! zg5P7P@L9+7-{FYc$sqL6erPAGYm6dYV;t)m9mRBw6#{f^^3JRG-+y)IuSFv|pYpI^EE%+{@D^-r4FvgNGcWl}qISjxrihSt*Vy;cAD zT0;{q4B3^I_SoQIJI9ZIe7MDD2g(YdZQrxfc!(SP^No|& z^r8+iaXmUjgGPgwbye~iYp1d25%7bt=V#npavRHXBRuAD*kqK1&yfR4#9zZXk2^m1 zBa2QhPJtMYc8l`5@}>1_cOKhV4qo|Qmx&%ab{dl`K2Hw^Tj+PeP#PKbir5*!CvlQf z4!RU9^~+aq(70p|3DPbUwtj2aw&5KnR*DU36yCXwk$mRN?H$Icj167O`{vsGJGH&t zm1zxfCK|K2-D1Xq->iBz2~ei zU#`o%l&o`e?>o0}6#999x%`KRskO`;kTk1ht69#(_A|Qltk$;fl-cjr>fd}${=q}e z#chYxdinOYhu;{kW%u4ZY2vm)!?ukbx4A#JZ}7%)d5r5LM~-yK5qOrbh0nz|d1T5Xo0MJLyU-UwyDJSPvi^ zc|+x4f7-~PFL%WR#gxNFhWaz#aE|6~z*ynAnsP|c%3u7-RUEUd%bbU+RjV+0)4(y? zlbr=qd$k_g$T_iZv*AsRWaqWf^IK(a7`>!j(o^#a1GL=nBjSd)DQv4<=s&LU*sg`x zc)R3*x6f$}MHbAYM_O&+ogIiLH$GomqIfliV9Y3Q$%D9MMT3V;644DKg!r&7*GQl#7>SmU+1JOlYTKIhPCil@K zwD};EwXSPr-Xs(m;`}|Th-T2WUI;D^y&#{~YmFA3Tu|;>g&D&~OzG17wXIvGo?g1( zr77jFlp8&y@8mB1k8azX`C)}FtH;#u+q-+ShHZjsM{JopKD%G1g#+ui=-s(lqxiD5 zA~w#P_}CEe#yl|3@4M>ZUgkLo?GLyU^&Cb!gy%c~_NyM;;84KG zSC?vU7N%g8GQF3!=JpO5ynZdRqc%eHc>MVU2#h~)J$M>_Ec|IwJ5m1V50{WX_AKQn zZ)ze|=E3D#$Po06Rx&1-mw`B`3tB1l))Nxij%y+Hx{Ye3ta-3O+<=t2^+pb8QlI)| z;n)FF;+kX(&^8yGs$c8egGbn{LE{whLF3wUUi^9R?s1;WL z2wDD2^~O~as>Vc~mcbBIa^dz#7bj1?I7u7fTD#F%gt3a}Ev(9D-Wp()vnKceU6hmX zw9fCq?YkZ#J%Icot^8a%(sD87ln+`=w>D4CaUPIm(<`;C5?-Zs)hRdAtF)>bUZq); zba1oZ`P==$QPs;fEL)>S@co!nyQDguTQ^I6vOMVQlIASj@~G6{N8`xpu=Vk7-5%fG z&I!9o$cTC1Hu5d=mM5-w;Ymgo{*-*AM0}3c)52qIfU>yb=WFe`jL|fgql|Pyf}_mv z3YLPf4OVN=Ro2A*0?j%QpXEFtoug&w)aV|yQtNb$nJFt`;dj9K)yn9ewNh(!j$Wzl z%+;10?Z2a2*PZ>37S8&2;n++4w|DNmz5gZ7pY!2z{Dg9#t-p&K+H_6l0DRph85Vn@ zT$2jrTfsM`GC9e9rvYaz4V(f-7O@=wAoc0MU z-7dbPF|u%b>V=Gq52(`14qDK*4Cl>xaNbv~yuZT@vbFFeL&DPx+1rEX_G;nDI=`g& zr(i2+;X6iZ$l;rKjW-*oX0DH9&;8FWD3!spAHsB4Y1FUC$@7gyrd zeRw(BokwDk;cH}R=e7BA;hvx7&i!eRbAenqd~T~&bB8;z*1lEWcu~&EnZMxFjQ3lN zZP#v6D~!T}%qzw?drkO4>0492vZgQXOg%eMXuO-0H!5RRxKqQVA1$07sy*O5`slY= zGfv;%Wm^BrWomExa(#o1@}94jW_|Z)(wsKUW)B}cyG8Tawgt|^3uWSOS61Y0&Hib{ zy@^qyPClQovEhqOr}L!~FFJo)b9TVS5remm8nS7`u+2kh4Kdt|Hr}^y2XAp0wS!`U zYSNzhubjM%p55ZFoV~s6NWZ!MrN3|hhjk9>r61P|b`wy4fk*c$M}v+)SLgj0?lsJD zx>kQ?>>ui+sIs2;W4#nCM?#VRSkB*pw#U|pzS7~VVW);^#s-B2O|lpdx*v89z&qRB z(E0oMI&W&t3zq0}3zp#Y<@!8*dBJbGqhR*G3-a{9iaU2YFYUy)m|A?}9*B;3(xT44IZ!oZ*Hu>RCGl%b+6O}S4eror^U7eF-E71)4O&l zIN8itF*;&UT0+|HS)jg+#heq}eW%hN7jK zt@EgugvlMdPHt%~{Bpy?U(9d4eZ8|#TX9SlcAcIUpE$Nj%gNmzJU=btjd^{yzPZYI z)BkhkWfIE$9P+?UgEh}yJ8bRCX_vrgW%l4M&{?SX$?TV=@7M(50Kz295 z#XV-WZ8jmX!}Ly?)6^dLcJ0HT%-Jw!{MxRoe+^E3ZCW+kSkDx0{SiXoF*t z!SVB6os@B8%KWFRlnNNWVc5JA8Q@F9+epHI6&7p<12HDX%9x-?DyR%oMHs zt>R2v|DUA>ETR%K+M_s~7xZbrFe<-?o?N(~j!&IO29MdZAaVKC;Iw0NTMditys%&Y z#a&}+*VM%&=k!KxIxl%)(uV&mR&D7e$TR}B`kXVVv0YtD*%0s+&RUOq>Gt`YN!vE@ z^v)5zH(gx%z=uoAIB$i_PZ__itAF(=YkJ;)E^}$#j-K;7cf7Axi?QA7bebRB`3V{F z)-hT4Y+Cx!IRnOLez`SaXrp-_uU>y;>4)7H_wKc*+r7QUH}A0+%MEIKeIeJ*R@;kl zO=T}@nT28cjl!kI6~9Yg-wsARvK9HkIK*wQ0e*YOT34ghHHr1iAJ&$FCFfNHw2x3( zj@G}bwc=S@^VqEi`#hL5ZS}&-Yu8*}v}Ve{2m7wR^;p0AI&_-XtJnNa?dSLFFtK&Z zNr@dNwP=|doc8LRVOu8#`c{5<_OutqkAGp>?3XM122R{MY|g7`Z#SRNp;KCXoAgc{ z#<$R(XgVq(VMJWqh=hbuc>4-`e{Z~G`_?0S%qiA#+)EEz0n;lTtFRW<)2)cnDXTtQ z8YF#!=Z!*}^{YN*b?-&*W-iZvY~raMsFDxnv>ey1Zl`&@d(P|9X>LgRvCILJGQZju zKQwyYC#xR%ZmHH~xAXhAjyc~um!^GLzt{K{-52%jem_+LwTm>!^cZCN=kzC!y!h+< z1vg%N;n(@|etmJoqJ)G+BSzexkZ}K<159S#^9S)n_8s6U&#zUxM-CD6w zudzm%*Y)$xKXWRtd?9!-6Yn3VB^+kTR+3C3-!f~9BaQnCPybR(0 z;%7%FJx|8-QI7B9pl4|CjddLE+lijj5l*&8cIn6AlTlAcQBQX1&(B?)3*}zyXB;xc z+9iqX{Mjd=N2hWH($$UpEAiQ{59yDM(Oc?0iI7C0hG>GmQ zq^aqmOJV=f3OMY9^V|Q^+I7HJRb>4;bMI^EjgSP=Ng<^71W0+wOGpSIkU(ew5>g1H znNF9EH0g@c1VmsJ%L1zrT|iV81Q9HVio${-D|QhydHMb`_r8}WsJr`p-^=gh&N(x4 z=FFM7)9)PGp$-H8Ol!u!68Hp;8!!+Pg>c2+S?(j9(MMvd7_DHhKsEML>?lT4Z1XtK zKQ7t}+bx)MVo|nNc-zF}hTYY(i~ULtP@#HvL-NG7l?O`vif4BQNJsYAS}N;@w>)pr~JokopHRe;@8hl)--;TSdkyT zh*UNnow>ExUkt%yi|goHvyQR`m~<4$b(DRV;U~JYp~DqihZC6&fluJL!JXmZvOn&8 z@r1n(+5bEB3gBKG|JEEoYk{Heo**Xnknvqf7^A2Zx_a3ZkYwV{Tjpfkx!&W^ce6N z9N!`o;$HNjz-P+-LICdioB@0m$Db4iBfS>jvpK&7WRA2?3;{le`*+cmQYh-b1~>oL z$a2Fb*}!D`-NN5Lhw_Q6__9r%qjk*8m#)KkY>>*1hAnA-xp$Se{W1takSTRh@vtN zs5E8X~K%;K?i%qrUxev56TRWDXYD>=ZYjh9&fLlN!?=1s3Y;(3n{AC5s})r)7*!7@rg~ zaj?`a&O1MM#f8?Uia~k)t=T0dT-rCC7p^Z33aQOcE{X^pm%03F-;n=Wrp#var5pg#=+;ogY^Rs zg9parSii&3ij+2QqbN(Z;?r-Q!!`oHKeP{*r`lQfU0UJ`QH z?3v&cDI5F7<9jiQZ4=2=E6Cpt+v}!*q$}GmFfG`|vT2{B#-P&4Rbs2C>_pJLTji3^ zKaE>|nmhYDGV$trWY~=vfnzs4Fu1kQO6#$ydD+Wlwa4d_F7IpoYD{d#Q18OM#(mEj zzkg}cq*saS-$pz#D`4z^S^w}SbF!i)ca_Cv6dzi({MPej6-SLm!Gl z{86q9&*6Ag_dv}f>P`5ZGZ#GzTcefOI-Sazw`=lRf#B;LBa#GPeDWzj=}SMI`l<5~ zKWV04r^k<{el*_oliGdR7?8|AbxK}C5|^B2U*#`A8~>nm3-#Eak9;DOc?@gRv7O4l zL*$3!EQk0w@X%WXsFWw|n)C5fB_&UNJO|H7lkjXi-q?7&t?jj@rq^ib!qZEZoL+eL z*ToCpTC(J=g>Sn|{>!z+i?8jUG-*Gc*Z(r~uS?D@ zKD}(&>BaQ&*=29zo%HMWp`Y{f8(7=&C-ifp&o%Oy+U>4ZjRiI$+&21<8ydFCfO$6d z$ZmYEdEs`QZXN3f&Khr!mlS;o&Um4n5#sE>@FS0R4O8F5H)_u^9u{e+U>1o|p2lbD zJ9o9vI}ui4d@=yz6P5-RW5fYnW%MHda2DeyN^sGE*^gb4XX%Hl2h4t2ED4(ZP7o*D z3%-u^P?qa;{Jgm|X5Hw~>&A>-J9_k5;$(7-1r_3;?yaJ;#Q|a=>dF{co$H1Svn7G! zV@)_N17QB+IDUfc6Emg$s7vF)0BhCA({8biYPoI%^l;+t1Bb_eGQR7{d;NUH-D8N& z0mYNYJtMY}vvOQ#(O10DhmbyIf5-!Wk$p26$j+c(q)|pT$m1Z;k8t{APM=H;n&~*m zhVZ4FUP|_Hdb@GHxCC{djqBgVd;#+YGDn#=v>O-T?tghM!TeaB8pQ2nyDS&WQ$yS@ zv&8K*rt1u3f}gmbr!keMF_mJa7xbB!JAA6}`9R^r#t&a$PJ$3PYh@lekgSZ7l8nrf z62}Rd851UCWKKYQgvIq*%=!XWGuZ5epZCKIM1|>v>`%UsLa6KyI!cSpXZ8rE$rma$ zMGVAa`M^`WXqKr{na?npn4haMpV>qG$roZ7%93%4<@qiV)`%J@jeUm}pd-FI*xNJK z<2!wKV=Gy{h8fQ>1{;GB6gtWi*3jE>*aRLnk1R(pce2)4Zw?Ciz*Bky;R4A6h^N8V zszx4`8NHFy8yOvZ_hBr)LR!ea1I0>?BT49mPxAJOSGxx3bo2zdah9CHLh(5} zUt8lj)L0(pBI3Lx$I&4QrZxC4z0~PVPiVDWgTyQ3hA{?*;3I8)?a;4NBbHi_Y25Vj zv$O%@e72s*zk*Fu3h74y@)x&0n8D2S^+pdqY5IuC7aHtcZr3a z&+HP5b{2|-Pj>F#xl5Nzy;eI3?b@Xt$>(Wj{HsZT5&a!X%QqE9a= zNoP;}gbDP>rAuAa1hfegKoecbMA;h)ifsIG6mycDn3FJhtl63_X8sJ2yQAXOPA_PD z`<1~Ten^Yoeb@LSz18K+;t^ph<}R;e?gE*d`4X@zcAKzmi-8pDr~|Rvg!QuCPe{X8 zjm@4_5HTn)!9dQ7BUX;dojWutDKIXZk{yem9i5yV95kwU(X*pd^aJ`8V3{9vWgq4| zD^#ASlPyR~p7UG;?IliYHPhb4oM(fSj&NLJSLL(tkmThD1HFz4AWjt6aq zIoykw^Bj<_gNEe~CGH8#dA6%uK$~Tz9mSkyC*CW}c|eorJX9DOm;6Q_68i;X0RsNOeXlb3}Nx+BD6aG_pc6){f4H309hJ@6bcTwJUA z3+N~TteX4l&}(21S|k@@WAQRYeNLj@d*ALo)!^aAG&-kiA-Qby1HZL&rFax=kRxI_ zYl6Zs@Ij=6-nKZD?o7@({yY|6z5YD*XUFFp?u;?sA=)u_9Oz0CwCk}&i?-|{LBhzBDP|{v)tR# z-NJlE9w*@38yj){*%%=qD5kfs`9=eaa|n*~srC!o$B*BBp?$`M9plFBxG+LJ%86$~R*vQ!UQQ;k%JtL#~OeW*T@3_$3eqqOW({p3XiT3srE%=XEdw=#+4D{W8OOnF*DaE!&MZ-)(Dqm$+`< zPF$JGcyoK>$<3oiZ9dtkJn4<*S05O&X>w}8V8pLXDM*?0Xz>HsfV(FeXLZpcB4>bn zsf3YYhx82Uk9@9=TU;h$4D0IH#+3b+>*N)&gSr{l zkcO@s)J+DouWsb86}S&fXvYnG(W+16b<2B4B=;`;lJ_TXpEPOvQN9r-uUQ4bctTYK^G_2BfAA z2u(qm6QKxqJKt4n`RQehUfH=YG~2H1Y$I>6iTmqEbnZ<>jjLZLIjN~5rPq!AqT7Ph zq3aiQ{Y<{d&M#!|guKt0fVyMDX5#ETmuYV1D?%VC#5iNr&d-{fKHEt?zVMmU-PM2Q zdrOyGn8mKgz*Y;J^Iebvu$P#EJK1>I;p{W(KiKpjC_r8dN;UU%ro+{2sYoT?X<|?0 zr-*X~3HT-?f&}7Y95n*aPD34E>ZlHs6eWx8!_sLBXse5uncc1J8D)t>5X1q|ap)xFgY?eMOHOz6I z_oTKoeRT>I6PK23JN)#P(2+&CY2o^Cr@1~8A4tilPF1P=##O~vZ7!sTZC%o1{nHa& zX4>^iiwnq1?8S9IU7Sn()Cc7~Jby=jONe1w z&_O`<@@{(Kz_UAc9C+r5O#BR)nYxRo-@5eC>C+dtE_ra_vWFJTU(ED49RJRV36}aM z=U&xyJSir0{)29Q_*rb0vT&$@bT`t(d>Cgn*zy`vM326)_6U8K5YM=1Ls-PXLr>0L zw!^o)awth(NIYVPg+#_ihc_-*S`je%!SWGS7_We5A@Xq+JaOiS?<4+!t}VAm(L|`p z%8@I_h{vRyu-ME@{piur`na&rq2eL2=%EqCb2CO}l#Dy1i=L?O<87?0%krz2ay^bfp8kov^S#gn?1lF77m*279NP;WK{6K>yhtt>Z>kS8 zZ=~TL8(+14toexyT8Pgov2N9Uo1Q{kzk6k8)r0SVYUCYI-l&mvg^TmZi(-2Jgq*y= zy^>vb#gH$J`(bEnbnMUv(h62u4XRA**FP(HV4l4$f~gu(b(3?^a)!g7S11`d&Oo@aCV_p-$sL> zHudQ>$$2@+{RZ}vRCzk@(c9;*JzlD>&F~L=uqj!e(V92!$@RmN6Nmc`>=%y}MOId* znRJ<4PfYupEo7K+HqA9YM>kqMx~;1U;Y72n-jFpx)(w;P5BE2fHwqQj*|PGkoVdOy zG$%A^$go94g-eFzCWPdKk6J%*VCIm7gq+Ml5vhZtq6Q7trA*aFj!M>9*;Nfro02@R zJSC;l&PqG5C`vyyW%;0}n3R;5$ibvCDJ(1@AuJ4cNkYD#pi8~r!`|?oT89e#o5eGw z-DC@8@w}qrg_>J9zrgAhn}Pi!4FNjO<_tr9YW|9n=$2`j6E>Fw59~+9yg^BML#*`; zng0Eg>xLy8GTZWs77z0*d}8ss6Q$&-z*N83(FMs#`H-Vom_?tVhoCVHMv|y+0T@D% ztWCy8Y0@m3-1WwL4?oQGUQcrAQv{Z@fM7gKpXxeEu0HxGufMZ}O0;X({h{L^SrqG2eKG9#$mK>*+LOjd~R+$z&1CIx#DsWw#;~z%)=RZDlH|9Cnu+DZ(^%3R~Xfvohe;H06C%DrAG~f!YfId!l^Ke_tv_ix^%V}H9v=IpR zPab!hnN~~J(o>wa-ArTYp679Qm}x19yNri>!c6N4x6^{Q25~P#zFlTov*=2nQ5ArO zv!II1_2PB93bJaurs6I^ZSYeol{XPr5gL2jA1-+wRQ13<`6r|`lh-{`Mh@~N0M81}w zgkkq#-XCV0$YzIZo{FK6`mX9DgptdD@wvnKY?EAB7>uz`c3;<|scvG9=8Zc7Q-ur& zhrXBX3G-%-?Z9GO$To@DyT~?#|D8WyQxq##=H#v_uUe5kWJP6GT+9$dLcEk)xoQYL z_h!VE6`Yuu@areMhZXx`xi7zX?U*rZi}Q=u74MleFh9ApEGd8Bq)va}zwfm|?wFC+ zebOx&srJU6-btSKt-u2~{tt!|I~5f#s&UUhJNeA?C*2iOx&IFw?@-x@d1?*(Pjmcv zRSbnaMc}76ULftGb?T>p|H$!oaJFBMc1%t1s-iqi=_}>AZ$;-R=@yALo`s)D7WiTQ z;!LVeb*)f5d0bOk%%8P0Uv<5%y87(1+|MKzMjOyOHWDxZ;w8gA#KV-1(3PRNgJWF1 zyy&B1<(RM`$uS<@p3+9KT4m!I>L0Fiw*A1?MH}vsTQn;0ip*cvp&Ih<`ER&l=C6MB zuKphWN7nx#|I;qbsyy7u$>k>yoQNyBQt7b`##ATQTQ|wEIC_j81@?h44OzxpcdGj| zWXi)EpkrseS-y2N2+eo`W|MNL@3W6L-KD`--m4OarEOfT){6kJ&o(K$}s6H!wc>=g0P%DAf= z=_^t29aWU;5w3NC-kf#e{!^P{uWsndF!9%YtLW|z_-kS;`QO#6Up4c;vf+>TukXUv zF_-_e%YH?7LG0sN2<&(h_oCkV(y6yGWg|Trw~-wEfE?S zO)j9fDPCE{ZI;eH*P%*r=f^#yku6J%m?}U13sF_jWn$50uuk( zLF;(_&M{5u`QPQk>h8DT6U=vWEbA^tXo~)EMA%#|J@vV&YYN@E;h4F;Nyp7fJGOyt z?V7UTSWlV#n$lAhk2%mSbjyZgrmAi#F(|0Kt9-*zb6J{Sfur&}_N{IF|AU-b?Vrf` z^PT^roDzR8+?{@2_1YP8Niko%b@#k8 zJPqiAmHi*3Vb!vMRe%lj*zHl4X;|$&b35R?PWSK9K)pcUmw|N-v}w*bU>t;F;k_}i z#4-Xpwp3hzVNXt>&HDW|MU14cbY+-YBlgl7#Fx3=<<^e3Y1hG@-~Fb;IceN4gCycG zmgGl4Qp8lE&gjbM$^?@pw27v7yHC!jqQEP3Cu>a={{1U!!o`<64dFGF{gu`f?er-j z6m9Yz>}j1BN@N`E6~iwmcz?8c`+&b|!r{;G_ZUuaCpO+w7XEz`j`xJ&A1HW#y!R~p zMH7zj4FAxCBRs<|F&y#?1HKseM@!jpy_PbbsiT$f>Ry*km;I*LDEJbwyXQ=M+75|X-wkGW+Z&A3egf`dTHX>K_LHkW=DsFM33>Sl_r-p z%r;JJ^z`%Z`)OD)DJU4vrsKl%DtG#ord*!Wu#rdt_SxBJa9}lVFvReQ z^#gX&7ve>PW#xl+?2k%_%_;6LCByt{FH2vHNK6_&JSnNb^dxnhy@|KuzvIm$yq`De z=z8OR-ZW(6#{WicXN=SC=bhehZT|hdO>zr0Dt9qn1N#h^?J=D|z5t8bYFTrcR>`lt z_Kp~V+ACLMrsGzqgH_%UrYF-k6*{CJ#60L*(f03+1lg&GKO7Ajk1x1(MEU_^!JZ-E z0_;@F1&VsC*3vr7Pm1J!T95N*Wo6CE&!3r{Ju@GxK(Vm~gIcR^Fc|9f`g((*K|enu zCORV{Iwk{}*ao?$YVsksKdeEZp8THVmg~d6(b6A{ZAHBE*aqp*+6^;f`7WpKH>9I#YiLL4jjgL4t0kY+$z`g4mcfsGPrX7~Y4STVO*~f} zjt>_nz;7*$CZ(!k=Eu)|l%7)+cYY*d_wM)JUDRM!%PyeK{^s4qx&rnkMvB>t55GHI z^r0(`9AWj0Sz?%m8)LTNjO1bAxNsKsdp^fXf{|EZLwXQy(O-gFSl_}H`!(2o^rG=p zPxE2hs1o+u=fXndCc2XzpeIBs+QIgFte7U|iQ`}osSzv2%f(INPVs=)0n729OD?b| zA1V!k?W7`U3wA^gO4p=srB0Pr<)X@kt@<)mooXg-%U-A2rrNJMtU3<6_Lo$jtG-tm z)pqJk^)z*>xQ>O3h5o63x?^7d0m|=QJN{ZfJhg3R)Yjo3@`eQaeaH zL_1B}s$HOcLwjC(S$k7^TNkMtq?@d((Y5Oq>DK7B==SOk>MrYU>TX*Nu`05fY*k~` zZnem2jnx*by;cXUL#*Si_11H(ms_8-p*D6l9yX(G%51jT?6)~=bKK^v%_W=9ZN9fL z+V-<;w4H0a-1Y_AS8Y$(zHfWY_FLOdJFT6IosV6pU83C(yCS<9yLP*6cKhuP+numG zXZNw)4f{a*X!{iVO8Z9px%SKLH`$-Ef8YM9{nz%tIH(<*9K0Mt9O50a97Z~gU&0RUv)m^{J!&5=dYcAaZ$TCxrDgHyXal&T#odz>y_VYcCVwZl54VS zzUzaoZ@GTmJEV7e@0Go8xW&6ob6e!L(rts=7PsAQPrDs-JK}c2?X27TZdcvDbu+rF z-R<1--PgE3>b}E$ulsZEFS;LfKk0tX{gV4N_pjW4bno<#JZwB%JopRdkyl+^_uF{;MMN+px1J*N4@rXz36qq>zvoeUN^md z^rqf+-frIE-YMR>-ebHgy&Jrjct7U7$NL5Ex4dt7cltQ^c=^QpRQfFNdCsT9=Y-E$ zpNl?MeZKO!?JM|N`S$e<_RaLI@!jTo$FJOPo!<$6JO4EQiT(@xclPVsFS6f+e%tyT z==W~F8v#xMBLf-&_5|Dr=nTvYTobrE@P)uP0aLTRW~s8gs% zs9$JEXmn_Ds6I3|baZGL{+5OA2t6EnHMBFVZ&-BLCPEi9Kbt-s#GR;6EuKy@xOqB zh+_l~6Z{NZ0h%Ceq6yu919SjxEBJ1>F95oMmjf0{yM%nz4#ZPHT7vWy(3A?BOn~*L zP4_{dP^JDqg0?T@?~n8W=!2?MiEx`BR+ywR!4RPkYYBxo>%QrK1r}wc?T@m!2UN#} zI1YzUrhf*BLNny!AU!JN{U@LqCghnwG78Jhuum8(nZO7)gO~q3hgy zm!W$%brV_u%c)!UQ-Ie1pDXwVxW9qBND0#p{Cx(}YQ$H7g%!uK_#9d^vBGi_;QY$+ z``}Z^+XEvl+?I6a9;x|;=h2k^v0@m~lA%qizzHw~RDr0w7X%j>y3h6G59!}D<@I-- zF9<=B0_wkE7I_aytAuC{hY;@1V5u-uQb3gL*8()ne)Ln}HeD*5 zWp42$VU>jDmbJW+2Km1Sr{jd(93B_WND%-#VYejWoa_feI}Hce3k{OH(17!uGXZ-v zdmt~K9Cpey>|c}s@>I@32_R2Z2K|%?7XfbrUIv^1yr?{n;<**@pM<%@PNIpIgdAyv zuvS$7Uc-elO@XjiWsP$|`*D8fSz(~~xsXL(6!OF!LXmU^@5M^!>JeeO_yo%O5cXJh z85_g_*vASIhSS?Zf!IqZ6hnm(q9gR{4*DBH3VU+!6R=Mt)QkSYY;r?r21HXU?CV(J zT*ChY5A{#NaP=NxxJEA&YIVX%`nIqSuvNN_#i-R-b5IK%;#T2#`if9M_X_Wz*!BUi za|0`G0_sM%Fb`Hv1O}{J+5JoGr`ai-vg!~{^=g)%nw>Cs+ChX~%>;h3oB7)bvOjxj zY!&!l#8u;FL1`nqje`9M*zp@_v1i53DZt92z-514`V?yf$QyeiUp0`qgN4C!8*|ek z!b0i1;3Rz_j3l?%$wlFLaS={RVeCNFxCPt_pyDGCcqu<-X_(nGegk6-BI)2>L(&vC z5&DvTikk{{#8q)KOU>65H*6uA!>Dje!vTe^##xqiirWtK35weRQjApGj!0K8yRqzV zgHT)s5^F(Eghen|9AvVQB)ocx43N_?a4d%9}eUl4_F z)*FBi=l>an`S@TbuNpUSR0zd@YN1M)iZF$6*8qr1AbJ zQ3}A%cnFrfe&BoLH!0P}){6lzCm;&3!BCaGcKg@D=I%M+9pZ;iO#0yl_CTBi4krCc2pNETmtsgL z$}WzC;hcXwHUJ}q1QI1A!gBAsxRq-vF6fCNvACim9(zTJB#9&oNw`gMkdTaRW45@S zCJZF$unzh!;bo#H8QdCRmM|Fh`PbQEO z)UtdskxUYXlgapgqD&}&Wx{e{1nj0&kV@PVI1;x^&k>%$`!qwCMW&K!Zgu*3p^&hf zoJYY{VVy7<_6i$>hlFD6$u|mPg!!-#*o<~=uJ9o27`Bo&VJxg0&Jf1Ij`J+o44fm3 zCv(wibO>LOdE`MdpDZ8?g%Yv|=P+&wH*rQ}5zb*RCQERx*afyXT(JgavK`nLmV1_w zhhQ&p1#A$mBCE;6ut>O;ThrbE3x%7wMZ(R$YpEOdxnZT7+3Vg1i`~CxQ66^WVND)( z<+0Bx3A=D+YA4oJZE&V2O_(T57W`pt{$*GsG)T;zIOw+sJwBZ!8m* zkax*@*ts}DKEPVjhvX9Z2zK>8A(zRgy5`kz3?z z@(nECe}`R!AIOiejd+{PFqE2kk@qQcvMa ztcH10AL>i}s6XvT185)(qQSI34WR>QC=J6dXatRONl>@yU0849}?C3;M>I?qD77==rWlAG9Qb-vjfi7Q-U zu|(d(Ju*6)bH$Sj8D)qyJry=_N@{UjDN%+D#XrMfJ-n%=v}vk2jVME0ta_v+nyxOi zK;sn-@fqq-ayCa<2ojYUCu=D)L3!$B?IkFJCn(uBC|WZp`Atv+N-(I4s~SsNE7Zk2 zQC)F)bwy)EQ+1QfCsE;($iqiQLw#d;c-h-jI9UqYtVH!V3*nO#;nSn!;OQ}%QcLL# zz$+}mNAlc686q<^6+Pji4EiKFzFx_lK9k`n9wmIV!av%CC#b7sWmQ|U$xzs2=xun_ z&CrKWnNr)pc}2%?4MrJa6kahgQcZb%v%1DYK698XS)SM!b)DH8;wb!M6EyX|NtcU@ zT99R3e_!dxsT<8KP#N^lgqo!BAG-*@I+a!(J?XV8J4B2u+37~W+ka-TEtF@kqj-3^_rO#jrHNpmFoJs z3ifDjW0{N%Ke+F+nIk;MQcSJb5vBr3Ni~&SIrq{r_|ThmV%&b zsA#ONFK1z<)YsIrz_zkuN@){gWz$qqTW#{PX((;1sH>@{WV~!S(x`B@DTAsjOkNx@ zdD$^+dPTG1X|JFrZ^s^{N}5gCHvt>dG&E+?#DMa1W84XjpX6zJ~ z;-RUjXlg>N@VQu5GDlu5PHA0iv$Fy0yBT zD~ts*1+gt{no=#lE(l~@+611}P1E7F;VCJ}+MAM7a9cS|1+(D^o4jP!3g%!IK_S>F zaww={k1z_w$wDF%6SG(fLEF@TvV@RU{7p!YgwyV~rDT6k5jotedR|~Cbh(rnV_ps9 z8p2Rs4WJEHF&VBYm7mrYC1PDsSB?gq`&(Dk_PEPhYHBLv@T!K!>e>qH^7@vtnhNf= z>Z!mwvzBcvRczIxU!7UGRy~@v{f%0!Ep%%4TZP)+qfL~zQo<}t>gw>imfA*j9e-%q zyW7;#(4=K=FLPMQ?=N%e_zPZMS6|D6aR&>=97-_e)bSTxcClBThhr~04=2Cw+$q2C zJRpDNxr=9&d&^msL-Oj)oEAmQP|G4_xV6$$@K|#D!JM+>ET3F*?qHHLhax$1%96`2 zCOHqsB!rNa~FIf-GDw2>N`#&+qro=FHBXnK?6aX3mr|XZH#z zgeU>W5TbR9maVGARqG*yEC%eU)}1?btNH62!|?ot5WTLp?$)E(xBo6%Rfs+(g{XeD zQ@1*CSz~kO2{Hcw;t%ZBy=nJeAE%}Zv7kJj`wkwHK5oZ_xu@_vRS0d*@brn}C^sP% z*Tvm$c*eA0JH8+FD4vsrNXs5EG<`^u=m!S~ap@?+6Gs41_RIU86yirbib^BKWKF&B z%%@-Bxe}6>$rw90{q3_m$_Q~`FDei;CVlES?LM(bh--}yKQc3Y%+UIw0~X=AAM)=! zZtTRY#9?pG5#ss|6l4Cl2}8$?a4uUQq?5|y5>jX)h_b@pPWa0LRsg$7ybSkM@iE*_ z#aD2@7B}JM3sXp`Ne!+c9dL_*b5aJ$V7L`!B;3j}7H%uq8g2*K5$+1P67E{L4(@un z9_~}}X}H_vcDOIfop2Ax18@(@gK&?^lR}qQe z?L=RYC?QJg+#>429f7(y^erMnlo2JVUfL{YIk>tr5H)(s@dMnm&KRU{cuYw~XD!Hse{i z8~9oWU%_jGvC1*TSO)AuBO5T?$O2^I8et6LtDn)+=!_?y%Mot01+*|4<4VRo&Zud` z8j(g>BNT2dt{}tD&=---qi}eW$*auTAj3 zsz0SauCLIS==1g2xTfk8oE`NHeV9IwufBK!25xtqfG)bX(VOXQoQZlPJyB1@{fJ&y zug-ZKLOzw;`9u4osvZ?rG8vwWSx(?#vLb{KF_+pE2d>!7wn z+oEmM)@y6D<$#U27Hf00nc7@qx;9xGr;RceYC`~NT5qkZ)?RC+rD_ee`dV$Rsur!4 z*P;zSEgU`RCjg;i824P!1i= zX@{|YHdC0*6y`8~Hm99Up|XXmGP#Zs$fYUAY0CIx=g>%^ETbu{jO3V++!m1>KacB}$MJ)>3_(meh(jl^JBw>KlWRJYOJsSZjzlKutg_AQDg(9IM2AW)`~|x$-S`&$1g%ZlPJ0=0~1)^l>_7 zJbegetjj#2(WqANy27IzjdMj^-m&kLb_(O(?bl(9hleo^(zTZ_8W-(r2S&#~zP2Eo z#!MPP=~~0%=yHsuMPF2kQy5{@HJ8WQnHX=q*JO;lH1ghljnYPGL-FgiI?m}|qKiJ=# z{iVs(e=&yxUz%L3btq04$4Mhsd|~zi2CG`ooW<^BvpX`~*5q4Lx zdlRlEf&sq$#+B%Za@9tUtqb)EdyvtA6Ijxg?wFV5F*E%SZ4 zo8Ovrw-|pt+}iaQ(h~pw%wLUriEL5NyyrhF`<|ozPa;|*Yr<-5CHRuJ+FK-3ojxFb_pbW5wTn`k&>a;1v)U+(^|A8Vci+4UhTQ9uwZ+b#+U)Ls_Gp5->?|Bt)xsBV z#VVdzG@|?bsdB5kPf!tFMWt|%^`Z5#O@Vq;Q8iFF#d&L9VH&qTTVG;CzQf<28wCmw zxouQZyetswZ+TF){#%IMg~)9^^0hxoV14&Tp?43n{*d|~r~mu8VtqtoqWx#(DxbRl z{rM@%|Lxs6Y?u4b^=9AM0Mho!y_KSH|G39gmx_w#%Ta4@Q87@i=d73QkpGQ*{cori zU6((DwJ^in`OiB#+igyxrk9)U-r(uWzH}(oy^Yc;l@+3V|I>TY|CIV2i2Js_y^8im zw4U_(|1a{z*=4a}idtDv7_DKvec|sexrMWgBK}Ggfy-35+eY~cDB)k(2?){OLlgbj z_mBIvXSL-W$C1<5fQJFuzVXR#dDdpux1KPZHQmdf^_(5c!fw}kqo@?tPYUa^e+ySQ z-}_(WMNvHWC3_b?@#TN_M|}Ce=uZ<-;y<{A=LF4(J-fdQ6n-*FMu|WfBV%xeQyIJc z;?M1P%JMw@8-B!jKJQ z1Na-thQO!D6!;r~3kJB*OoYhhvbiWDTgsN;PHUVm2FVBH1902Qw$M{{m)%7T*#q3s z!JS^Q(FA1DWcKU(vFwoMYxnSMhR5=ZCrpxJ|KSR!df2N!X{4BJD zL`!7D|B##u|2#PlapueU@Gp=HfL|yV!T+#ag0^2Omm+kTTqYvqBk~c{Yq?yG^Xf;@ zip9AV??Wp-E-FA1yjIkb>u{sIF04U>WB*QF)(Yd`MSe%!D9xV`O`*0_!B*44Om!?|@U$QH5%YD;Y#f-~e+@KZ~N z$TqSK{M6nd+}_1xJ4l8fONJjyh9CEUDn32H#ci)~t83iq#kti3xYYx>)r)hh2XL$V zbE`Yi>SNFbW6|~ow|Y5l_43^6<+#<$bF16!t#M1+ZEUx$#_ejiXfU^^#w{AjEn19Q zG?ZI3m|Ilm7OluFTAf?e!7UoYEn0@XHe43T+$}iez_PSAyTt8OWlf* z4&0sAGH_(G^aBI{0s(EUUEoO?Xzl{NU8wOS)OeD71pY^HUxRhGA^ok9h&2+iQV@$q z%U+nvo#H>B&;+r%S^`{6k+bl;7$FkaY%b{wDCsh!c>yI|M&l()HV`lb>li;gW#g%p z90bf@#3&z-apdN(_ff< zyQ}+08k)*?NZrznFG7>cE89>Cw>}4yu?D*1y0zdO0aN1Acdd_*ZnJtWe23ofXTlXuS2&(}hPHCu zZ7XaQSo9t1Z_vNj6}WrN?aL`!XRL@@TfHk_YmLHk9&Wv&^&UTMDVVD-Mc-5$_quO- zJJc>QX||Dnghdux&|8om)*4{71vLja!8&Q+_q)@iajZ$!7*1s)g>}V(zS|mkyT;qA z?Y7(1)^b})$6W>l9M)ww?MtvEqQ6>J4zB&){e*BJH?DyQwtnR})!cV}Ko%&h7}N1K{GUZc zqE`n~3yP}Nc}^TjF68kwjH~uvB;B|#R)mM1NSlH?jX>yI%S2V|Oz?+(z%oMqKm5Dj z4#liZJ@IpT!i*NqWZ>lQ}9Lk`0ZA(BCXw! z{{f&?*N)HUEhVj4)>*Hf-7tbyS8 z8<77x)+lQvLbBnO#QibAezz2I8`xtm;@ac?KJp<>F?AwD)V=cwr=ElnqL3cVLvbXy zcgJ3Ajij;^^`}ODZ@rbSne+sObMvL(o)_*sbD%cfKeWJL^;GbD$9mQ}O(E7P6%PL$ zse0qw-RCVmwU3qJrRohKDri|Qt7_f5Q@+<2YW!F6tsIZL4=RH+qtZ$GxaamP@r0p#Vzuym;>+*wnHQEG_~ z?w-B2tg7hNW^V};?)E$Bvh9_aZ|}n#1HNF5Ea0sb*Dsgs=voM?6Rg?er#EzVM&Q!Nw!Lx2F~u^}X#{ zn8M%ve1|?<^6$>aOV2vw^%H-2{lut({lG`s{!%~iN$;DXXFuYLyz8!Qr?;hi(-rjJ zQyjJpqjjMgF|l&Q8r3?>>rppBdo>9=I{U90ZnW202EaPZ+U~{+tAM2$LOkPvis5^= zZ3H}XNd+s<8;TVeWaD0Hz-cO|lpbns#Jy0k*XS&Rw-g=ZU1)VzbZK~9Nvn0wpL@mc zefG$d2X&jrT5s*M-s7F9dnT~UO`HW6e<_pzvj&&07Vhtn)MKrj?Tu4Vvw^mycA{N@ z`YYi}v0(rAtog%Ks5^eF62+*r)c z%!4v&{G+(OS{mzQ5j^nhZ8-K*?EcJdPo##Gk@t_ZGI#rpB$iacuI9D(yyLGU0f+oxRXP>j)X|L&x_osbLJSo&# zEYpMepLpD3A;x2jb16kK8~ zywouEAA!Z`MNu4k1W?3%V!^m~d$R5p;V$9!eCjLzJ~j(?Noxn2RPO)!@2}~V|Lbg- zfZZPdjU@S|^|tQ4m(I4T_3iuCm-n8UVw&!q#-B@7I5w9(l(4iS{PSlloD!1sSO2Up zZ7cM@mfn|tccD@ucZu+SA~?pI ziSM{laQ|4)p4Xe={|`RjT4O}YzJ1sBzpkaX1mv??*}53YRqa*m7$_Ts^KR^aTPELC zhi^{GUe{{ii~WDy&w8}$bSCS0_u~iqJjAYd(6AEk!&ns*532X5V^lD|Jng)rJn^WUFXAE+cn-0$(U z)~)*o0X_q;3H?`diiS0ZhHt?Lkq5iL-(fAKi#%8YI)w>~u|OD{!R`+hWUyz2l_2a{ z5e9o!SP8{(?wvS*bQ$etBnk%2ucXuzITRwsK_S)1%x zWqq<|g;nUoutjS|7IU&AS-pV<>{DfDvQGuAE4X^2g!2Kx*vHbFxv*-(8~6rnhN9t* z!TQR;d+^oZuZ}ljL-AgGE%1$#Yl!Zm2mE9sXNX>+ z5Bz;aKhW+k((umj0FeQG<`^+n+$Y9~EKxyBf=yjXF$Gp-#l#%E0~{{qisks?&Z7d~ z!hz-8GbkS{&_pHi53w2kX9d3FBAyppQR)|9ofaXsix=VFi7P_v5--93vUmmWIKGO$ zB*lK%^6BDj@gCB?FHXV#0WM9P7H8o92;UY66rbRt@ArHTo3Jm$S9o)nY>*p^uf=!p zUl12zKk|e4307}c#Lsw-_^S97nBUO4rNu2=5^bCZnq=wQkgY(gu@z_?wgOFH`_Ck{ z|E$Y4zIE6Nv}wg;td9Wqah+5nmpPLa*oymn?%* z*)FscTLw2^%itik4h>_=;2^dU4PzV8s%#@#nQcU4*+#S~+lW?X8_{UC5shTa;Bssk z9K<%F_1H!I3t+PHuFtlk_p?Q4Bev@eV!PfF zBpdh^geDdN@YNyo+hFual8}-tA@@N-!qLA=!zRQ9S&4>RkhD}_IVr;uQif&253-Sn z*h#SREDH(gid5ZjxmZpr`^ZV?ZF1tra#D%qB%0-E& zS|m$LMV6NGEG-&Ki$6=UI z`bdb*64IO{L}v*JWC@9435jG031kT=#j-(P#LCAgL00hbEE^43HelH)9U_@!BZZ}+ z1xrOT+eSB}F%q`wWbEFr-xA&po;U#1eA9kAwuXgp^|m z3HFhYU>^wyW(g_B5)$l@kcB8M+0NHz2`R@ClFAZNmn9^LC8Rz}NIjO27`C@=!V=O9 zv&R`wAe-qDm_xpWpDd?sX)(}iU|-DNT-by@E<}5y=&O}c;BxKJFFOMY;*3`K8r3QN zxaT^oAJ$(kbZw;u98;egVBGJyL4WdY>?zPpg1515Dd^nf9OxiA`IiJ^_0j2j0KE?)yH_i1i(Z<7C^^{C&Xuf&jEg@ zcT3c}CFm|YOF3syE=(EGYT!iX->QE91kf1Fn5a;W&k zhf(0eD9~D9trZXBn-oi}DPo7UM@U%0?}s}Mt}d=v8^zDoMm(kA=}WBZZ(2L~DO}Jw z48@%dJN2s82AK#*0yF@m0D1%Z0mcEQ0;U0G03HI&1#QKIxjkpd+^>Eg?!Ef&I7JQ=WJ&vQr_`$85 zhaLMo*AB-Hd^1p8BOG+4@zveY)MOu3}u+nINl`E|-ibIZjV%+GDm2?!Z z1X}UD-x@vXMezX7^=%=KdqoFG-Wxc*JS^V9sPqX|y2h0HY^cb{n z6YL5bh>~zi0YU*WfJ%VMfM$T^fEIw3fL4IkfHr^!0C@XDv;(vUbO3Y&bOLk+bOCe) zbOZDR^vAh7Sq2P%e;^< zunrW~fxZGZI|LG;Vy7%7izc;HC%@pu0svip@!>F!*!_PI@E9-YPb$HT!$L2 zL(cu7>q-NRK|5yx#sa3`c`9HUU^-w1U?yM|U^XDu!x!u510U$2*?6V z0Y|0+rU9k{W&maaW&vgc=pB*o0T%&304@Q31Y8FE1h@)-^*_qLALZYV^6y9a_oMv# zQU3jM1>Uz=30MVKjr=NNj@gAdW*6p|U6^C=#t^^-@B{b*0sw)4Vu0d+AV4r69-vx# z3^yKJXzs9gdBR|4IfxU<4vDsco#_ET_AdQs}A1Xs)Kj8>ac9kdqNp_ZwXeo zcnb)lubN-@ogf+=k6_Ljh&PyqfRoh2)`9=)koF2@2AYk!Vr0b|Rv7uaApB)uU!f45 z!D#lXBG&EkOO>K^S51E=Zv~c2O*($>#d{-4+}DqCkwY9AhbHlD>() zukjAx3$*B$06HiB58!LSIlwo7Zvp24-vKTmF7=4t0KWrnqCE8OS$C8z1H6M2A-6P? zWf^vWRPw?(eFfZq&{}RvcTBBwh|S9sys6h4IgCRsXCa40=5@Tcwi)lOZN@um^u8Ls zhvtiM-WGoe(otN+=Sj?GdV>tI>~6EJc%oUH-)Q62MQ5z4H2&UiZT`k@5VR_?dDHHZ z^nMJ!^y`OMG~4Q!XC0VPT)5*8AudmdjrSXzo{=Rc4<0;boX8xKId+T~F>FHmV9^#| zUd|9LGAE6hAR3RuO(bWLOFB`~B9Tk>YWQ9vyE@$B$TtA{>>#X-N&gg%^>A6Nfg`Yz zq?nX9?JWol%y%&uGpdLh)pk^SJoti$ z6R-pTd_*Ha$%^$WTmZZkwXed)3ag5B4*V?eP~e6DLkq&2ip4PdNzN1R){!N5;8=Vl%-^-41*4?Jbl3#TDS-qC8Mz~jg>Lb-&DcgG+dUJWuUPuE2}{#S3}m6 zwV;)&BkRg~G9H?_1k&eV$Jzip)f5?rJ?j0kG4cw)o;VPdZGrc81F-w-jC~dDFT;6f zSsJtMXuQ!)Z&{a>lkm2B1(bHFs3YH%?_uR%u*}djLZb*xqx=2L0PLmd?bIm>*coRQN$H&YMt+NZQZ))i}=-T93?8%Uuftc4hw1J0y4nrxGwm&HtF}zb#1|?XYLS|O5+BD~zU#oJ zG_-64-mSfgy~0~qzpsD@H7AHkU{>V z{72z=p^$vHQ%LAZrN~6=w*Fl`u{EPUG^!Fk7CwJ`vltv8Dw!d%VTVef! z{DbgR9ay?6%JfadP$B((bK{_v2G8+kJNgjy$Kv?@q(7*-9EZFYG*8kv0vd{r)3Hfi zct2ViwC_=24#z_K5^WGdmYFKVaAeR0ZJ9JC_~3O%cSm=O0@CQ~!J=IFvICVP*?Vz0 z_Il#z!s#fhE-uG5}3*9D{W42F-VVtLn%Q3_UuNx44r7(NxEyc!ggMaD_~LSG@d+`Eespm;eEFo~Tiz|W99f>&RE~Bi2Wjl3<}I~` zcaGah*c?Lc24J4al$rQm!)%Ng#oT()bC9iYq}1gv@)r&LNvrPlgFg2}>^DBq5)p=d zw(awT;G4M`zGE$aK`8ESye7A32C!d%))w3E30WrbjZXO%D6!9j&yuHs-v+?ljm7uv z@w^69(s04=!9NQ;eJ7%ogwKOr4(u=h?rt2aF1rec?CQRz7YLzmXW$DOxCR!$M~eVJOYO!9?bv`ff}8NWQ%-^91mgt*j%h0+B&JS?T zZ8+yFGvVzRSfS!=4iU6%UsaWFcZy0OtK`i`xB*Vi4eQrh2lNA~42 zPg+Y%qp!oD9QgJKr}Kj!+6Jtpib40$QM7=rvns|oI=gYoA0fkY*q!XgTw)BllR?M8 zn5BH5vrmT%oW&Q%OF{#;Nvy;9%6u^$+TdZBZ;5^@=uQ(cO2h2xa?qVpd}iklIY>r(RK>cwJa|@uIdy?~@-fD34J$L^%2{arDcuzL zrJW9|E`y(mhkh6jreK!I##rzn&IZ2mwhZ?i8X2f9&Vf=tc6hif*y7!So1p3 zss_HE6o+~Qao^A&=k>X*1+;?HTj=Xb7?Y4nU=L$s%3v&`(`mwj4t>Xndxjkvi9LlH z?dX?T=#2^rrxibT(%$lf#0tNl4MCAXk;RV|A6I-_;32FsMc~H3#{-uI&JCOzm>D=E zuy0`Jz*d2c0^p#yU-_T$Kkk3Xe~4cfjvuzpZ{7{MPs_@yqs` z?3dv;$gj6wN52++4gKo+#rl=^E9vLwGF`v8F1fyTopqgbz2(~L+Ua`MwcfSDwa_)w zHNiE)m4@|lTURP{r!}DwE#nGu8PKL)bzZ>w`Ly$>^PuxptfDtMAID01F4oeS&LPge z&d$zO&PL8SXH{o}Gt?R26pkB?%Z_gxpE%xQ&FOB(7ROVLRrsBi*^VsyuF611PyBvL zGpycfJ1Sw7R>I*l@{C;LqVW}eBjvbp2Vj% zj$bedH;NgW{+oUUzgF@Y&WDcZ2XH2|Ro|em!Ecge>yz>Rhw=cVW;M!{y`%Yo>+|PZdSoEyo_2y?9#R%V@ zY0=$|-yfr#jkn|XM=z%5e(w0Rm$IMj`2F1R`?=}&bI0%Nj^EcEzptBqUw8bz?)ZJ( z@%y;r_i@MXwx4_BoX|jQx|FZK$Jfu} z>+A9L@%U6);+CD3_@#U*E%8j@R9fPjjU&z}pGu1_oOy66EpbrAQ)!8dHjenHd@3#R zQsGou;-`%xjw+u@OI%erm6kZGa4Icv*TxZll~1K59xI$m3rzvzh*Nf2^4V$0XQw5f zotAudTJov1P5$oXS`6nQEcZQct$2C_YoD%2)B3YHR09K07V> z?6l;w^Ch31mV9=;wmyXNAfHOB_)IvJR`Ho|DlO5m(-KzsRKAMO#Cesk;xmP*w2IG! zQ)v~S38&I3J`**SR`Ho|Dy`x(;Z$11XUbotReUC#N~`!xIF(lMnM!P@C7)ei^4V$0 zXY-1Dc3Se;yrMd)w2IG!Q)v~S38&I3J`-0|TE%C=skDmEgi~o1pNWqut>QD`R9eMn zx~sH`&xBEF6`u*G(kea^PNh|RCK@WO;xpk?TE%C=skDmEl&hVVd^VrSXQw5f&1dr2 zX~}2vnaZisDn1iVrB!?;oJy8{c$J`+x*ReUC#N~`!xDebi6v-wOuJ1zNaK9kQ*OFo;= zl$T1Y_)IvJR`Ho|Dy`x(l}@Eqd?uVqtN2Vfl~(bY>Z8&sJ`+x*ReUC#N~`!x98hT$ zp9!bZDn1iVrB!?;o~g8o&xBKH6`u*G(!!5j*%o{Uauw`tX2W)BlpH8~LLc1>8lT#- z67=#Vq!W6LT-bDeg3%FNzYV9``#N?R0gngDPJFtPlXaaJCrCYp9&?aHjd~jpB+jm z?NIXBq2yDciZanvp~Q2dLU)x`Q6`)nN?~>=`RughQ=y77rBtDcGSN_J6=lNp7B%#) z_=Uj6dVQ?dBlU3n`k$u#hF`=wkG1+K?TB^&EA_3~2K*}35-l6QDwTm%dT*_x)>v zx>_uL(W#{7hu>@ZMV^r-$%aCEl`X&?hX#MHHWga@ zAz15o#xG1Y!b-oY7J+qs0JI`EaQ5&Gw4d*R(muJH`_fGGqYT*a^@9yy8<7l)vRwQM z;aA!j>?96pd+_^No3wTKwXFHrMT{dGGwdPSXpQj;Rn>7$5Uv%&?%_9Ryw1zdn=S!i`T^Z=7Q^f>%0ex_e{21TB=>*|n{Ya@9H%zZVXrX^JEn)+-i}bbA*_``#ocmeI9VMZ?%pAV= zci;QD?|t3(KJI&O+&^YXnauY-NKenbasM8DjYh&bZXeOIaYW7b5k1>S6m1{TBp;20 z%BRxONT_fsEscZ;cCyv=dE#dod?U8E-$u$L?t_Z0MoB??`(uCiRAm|IZF z3%-|Z$Q17@6mK?kWe2^NBrU6OpFP?&r81KKYMtl0%yGgBQK}CuoznjvGvHvT7{Ou1nVZ}3eRATY%}q_)iEry}#|S~chPVsk z+)EgfUctz@SN>D($H@1(d`<2tM3q|Dt}}kulUm3_Pikb1s`#&IWij4emA@fpdY>jt zz9pZ~vngH{9V4w`2G|dE+=eaqL(HpS|&1KJo0Bg)!c^r04X#sPwnv zyz%`D#HN&#qA094O~7qw>{LbLz3I4BZ%a%0-6_OQD`j_1rQy_#tQc_OfYSqDf!Ek_ zK|(u>ufEg8aoxQTTIUdax%)}}ea;Q?8MzVPh~9*6c0Z5#aT~tcy#@W|In>zjSVy$P z2tc+F#h79cC@NYt@#Xeaa`ip*YMf}nR`z+MBxxpHa3IP_WiH0FgP6Ac%YpWIM!lX| z$&f!ICY`ED*j9T^A6P0loKE}Q;jeq-fqutY!`To~(U9(SqJ-S-nDj*YZeLsy;~d@` zoT(z-k>|MN_}X#SaS|HU3`bx5_Ot>0>DR_Vw~jR0C}EHV{9@QF^n>oR1b#F5Ff^UJ z@%zXbSnbf7q?o*kHNsg)y1gdoj+Jp;oN1mHZ=vk7Ve`V8dYu1h_#MUH;U2}PS07q+ z&k2FJ2JCV1E8G)Eso^)p>FeSr#V>G=i5$2&kZ?*#bs~=C;LfKD-~=o?5SLCCa>Qkh zMJF`J-1*Xp#Yyo!+!MFuODmDR(-@-kP6+FfJ5^jz-2lpsWF1@tq+#&~S8;Wn& ziq=pkakfJ-Z45?AU}%+Zm-dv$_b&Si*Bz8lh7)j4V(&m@I1D!jwxCoJPz9ZT!#(Cx zV>_XMncsXRO29)*=3mon_=W))8E1|AXgsDX84a|G^R9#YmE2rKy?G$>*8s+ zC&g26k7I_TeAdG~DxQFA=kqvxCppJ8971`oV4R)zBk&)`>awVu76W@7Ga6BNnBy&g zn?r9op#<~b9;G+3kdsa8A^1;X{wRvpbYPF8KU2zCaIfQplVZ<+drVA&n}gLYrKA#0 z=4Wpo&BF6ZF_EzoIM#T$D&MgjnhEy=T5t%?Uc2LWAX?y5Fc~wlYMl&(Ul*e}?ohbL z5tC93hMObO;T{zO;U2?oQ^$%uM~uY%1h6A;&jEfo-;d(lk)DrpNz;%*wPQc_Kgg79 zj`U$nZ^lr)dU$+Y*;gEA+}=~nRGgD~B^&mkC~s%DCt)W}M{yEL_hYamr*h|j2X^^kmxAZxIG?l2?d5Azq(3QAnMxC;*ch&oghm{i!g#wr z4H%Q`gGppe0%QEJVo1c?5Pf4rYN2J5j&u=|(;UC1)L0kjJ5 zKCQ*ugwJBe+=?~Z>v)6bpm-bm#rI*|@)6#U`2uU_AFz+SDr_q0<;5^izD867h&t|yY{X)s68VP-C>^!8`%PKCLn|EzpmXnbb~#F zFfG?16LuqhU&3NR;8!{DXV9;l3$%JMC8=^|IDQ>7Rv`x6Tfb7_-f;xC> zVm3y33wEX5pd&c~ZOIq-Ss_FB*JC2JNdL&d$k52Lkr9#6k+G5SkqskTMDB=+j#?SD zI=ZB3!J?P`A|WLWQQMuM(pB5-JX48_Y;YSDDjo3ze|UEYe`daU z8DGfaKl6oi@1Fbjxg+OV330B?xd!K2ol88Ie6GjWJ-*KPy5rX!{xe)d>mq71KpJ|H z307>78#yukYP-iz7`e{I7v;jPDHXpv(iK)mZ@}hg5Uu8*U73pYTyI#uc88V8Shi7^ zjP*`i@K;HKZB1Ma^5K;RdV}URX@K2XFIdbzjD5_TkPIak8{LxeijQQxFFv?kGN5_s z0jt|t&@ACCSTue&v|Ug681_bw;H{a7cz zS)XA3oCj-`rD6eeoy){(%-rk6lbFl5qc6RPwL>1>3HmqIF~_i?IgDRtI)W9|IoPUw zi51;StnIpq_pl91m&ZsxF|Or6HV5LFWC)c)zY4|*y^T3g|IL+(-dh~wS{O#lDzH*c z!25@-5iKetN`^%x#3Us|g~jV3Q6a9Fgm{U2OjvwEd{|6E6$%ZjOivUgECk_`i9ZS*qnD01{xuongU|KQ z$;Yp1dCSw^gFg=YbPpZB)gJusVvdbB8xk)(_*{AR_qLw$d-J`Hod(uYRA1Md;9W3! zEb%T3UBC@7vJ`Poo976~@1V`ee@NStSE*h8#USm5<8;3H*ze}Dd`F3!SLLA3KUZaS zz3HN|7Du0R$>aKTRaW3BZySHqh(+Eydb}|5kasD}z*UfYNK{;e3=0jCuCNG=?lG04 z;t~_;*OoC+3Gs1_WkUVf8gh}=>DkZ6w3^nT`(ypHzJ0+wApI83ynkE=^J-4h5h)W# z|LQnBc*nTj^SYG`*84n@IeFjcTO)cjNl*HqPOnB?h7nH}nGGpll!q)VUC_;r`JF7{ z(yYslHI56gV-k+&N?Jnwl9l4)BDB&`we^_jAe1aZq7+(ERFJNhJYxR)wLP-o;W?jg z%Lp+~1T^T8nVPjLebCN{sbhLIK*uXK_}OnlUzI&`ugPAowqE?s?8XC{$7FrE;U8a& zi)lXa{t*Ws?}@Ucnf)kFlm#;y%5p>g!@?wBmxXy|w7X=H<^K{Y-v; zEj|Ak;?2O{^ACd+?tZob{jAC#_A{AKItucK2Gz@bV7~LxUUatEpT01v zbVfdgEVq178qkX6JaXkHil>f=Jhs~S92&E1-pRelLkDv}U+y*Ex4R@3LoZ^0U&CqW z-6V|1=4L7{edkeooZH#P|7LQ3v3>-Ny<9&Vf6QvD+D+@>gFgyOX`=H8=)CHKzh*ta z_;-Mx>7sVF>71}47?0JN){MtE8-Lsy!1MKFaH0M8-e^%RgK5r(-+w$mG&h;VdP8Ze`Z*zTUd_cb7 zi;d5fuX@IZ6qLuo{U0L)^^kdv)3<7P$Ac86mt@leP2vxY#YhKwHa=H2QuLfxK(7|l zD;1%aj%lnXm7?BPA61PH(p`F#UR$pcAEAeZERtt(gR4}H2}}u$sahrYiacw+t2+!? zs#NVd)q{UEty{s>>(mV^Ee(f`{@T6ny4_puZ}i;m^>wu!=(z{$t$Ja5`))67Sz0^4 znRc-D!&_eJ(PrEB)$#fGfCP_C&MRE@hIZLC?t@fzDkI8n<8!q&s_d|?JdL#wS~g17#<~P^8w%WAeq5<-huDg)KtGvmZa2Hv>D;SP6=yLQ$`{md@s<(Y4(w>8 zoj~b!bXd7-dd87|#OHVOw#_Q;oA+~hPT)-l<|X>lDlVsuKPrDM5`PR<`flD+IZ=K) zKF%mne(VG>Mx4a>6pa0|&rE2~F1ng<+%uvc_Kx|^>#xg{caF%0ufJ{{Ig<6_z=1Dj zP24$f;7-r{_p15AwOn)Ct1@`}m(Tp;%kdMw-1y8F6Tn-}i{+bk8noV8$7QqeB!4#k z1m5tYGN)N@I!-!ED7+@|T^%*9o#eXP@sDAzVaG>ZeDFuHMlK3}4X3YOJdJ}ko#SXI z51!XsHvV_KJ5dzg!dr-5Jk3LP{JaAA4?TFZ0NxWHv$$`*9z0sqjfWhfjXZR6We$(A zHs5HiW#@Yx`vAK<;G2gJRcyYIToXU2&B6TF(PwE#AfnXj2VGVze6R=bnfLXmGv=GS zUy`Y3Whe8%L;Hsf{pW1idXs(us{vjq?vb6o{Z4k;WBxwkqb<*VG{XFy=0NxQz&&4{ zLw=Y=z3VTX<>u$>@-b0}EWtugg`>_kTV6K%>6=kWYI?|KzO9LuR+#Ucnf#4f;}zY<-D|dRuB$yFxL3IaYl~eAmbR+KlVzW@sf`*W{|%8b7pc`{9YVs%u;F=_NdSd~$?y{d>9VKbPB_#%kn?zF^~X z)whI!)&P2&|Y}O{B?~X zt2sN)?b>x(i~A>ZefD2xSLSbe?#2?${M7H^2^}&UG|OzfC~ed9=8H`;JoA$c?S{tH z>DeH@Wn8Ihwcpva==iwt$L94NRI_7T(gU@Eqr%#ZT#>fuSSISh<>Go!#c0lXnCsC4 za|FtZdf50}Ifv`f4&^TfzPf~#($c+R_d(ZoTFW=V&i|i1ODb6@Ok1VA3A3|bZ)79!E5l;gU^-6 z?6qGZ{#=Kig4TY#-$ol?X9x)>6<)G*OkzpQ^HNL6zahI_snNS$v%1}@U65T&4I@as z#pXZXIxJf**51EaoBF=JUnjndVZNUbH@STEP!4+^Y~y(iYUk%y9{FL9NBPm*jO9cr zAD>rypvyPSak9gUVKLDq5=&N!ExkwH|61wT$|aLSqAG{JC_9>C&1a89)+*n=VwD&y z^73Q)eehv=`iCF%)4ufeV-CMLM^=6M3G<6Z`A)k}qTGJvxxb9F`-|4uE_WgPQ9Z;5 zPyNDf^!Z>Kga$c*w?UsAoltub5(@O4~JUf>9;`HKcV&kml0kwc+{4 z zhk1g!Z{;<*jX#dJ{k?da8*ThCx6DyrK^Z;xTzP`~EcGqle6M3f^3tI`XVW<$E^%Ga zGRD`aYiaO20qcpR(okX09uD6ra8<#e8WlEC>zBN+Md=~OhM4ymWAb;UENxwSHD<49;YoZ0l_R2;E z<~w}5*b&X6xt7#eyI+zT1(Ug}a$}74v<8JHFGx$ma_)YK)pQUvE)i1h9rZ$b%PDKS zrB6w!+9|nV&&1;9PZbvadsg3Vi#nO-1IqQCqqQF0sCZ(t)ccaU*RI(KUu$1?GKX&rc4VcQ7l0BtCUi^MoI+FSzJzM z49Y*(T`%b8QQyM&Yd8V);>)??J7vx(B%}D?1M2IclPjaR?F!R5Mr~CD{p%9@GKF{i z;GuIII#>_h`JxB^3pBM}e2Aw!Comadi9zLe-U9!z5>ecbk8&m2yssc_6_QMH1Zgp7 zoGPX4#;73ebr!aO&~k+oY${n=j|sx5%YX6X0afl2r zJ^$3hnXk>Q98`1m=0TYUAMJdrTe}fS1KPG8)u@_pBTjr_Sa6A+VLh`Jhqrq?bHL&r zRTFy7d!WsXP74OlYZ{SKBW&op?v2NF&^EOhmelHzGb_4RZB?sf3$&jcS*N*b*_KO2 z=C7>ZCE3%C&Rfhk@C$SHaS26|%}?hoZeJUJRGW=(4d2-L$@nK^B%E{u#GxuNJaNYp zBf?34<*ZU@hoR*@IJ;9=kol>s=^xf%_JEo1&6xV$mmNnZC5`ITAtSj#MybXR)`Ai% z|5VMiR49($*k@V`a(b>CH0bf3-5wh@bZvK(Cma0)`J%lZ<#OiAES8sSGp%rWXrmT# z*YfzAXa1->`P*GtcPVo!K>TNGiDP?Ia z+r2{wD{0+Z1+7f>`%`DWH*>)3j->)*P4m;>&`z^u`;6qIj1HYfCnk+9wLr>!Z;)O; zt)^D@X6;vMJ=i#>``V$y9_!Zg@j-*ukzTY5+6(!joR4uib7cpf*QlIRczi3sGYC*Z zl*=tQ1;k97w*9vS^K&<69$e8SuZLb{S;EK;9mga$ozyX*=l!rkEVuAj_%f-z^rAFY zai2dB*XyCyBopcLn?WYx`z>wn=Fb!-jX!Hq9*jS9kmkYDI7WEVbE1D0fj^3GQxt{2 z28#<1p5@44@vqbQr6*q+m+W$$7>r_}9YdisD(5VN zT@ic|r#PjcN5L{bytIqPCTCcPb|pXdodM5|Xg#T1VyXCu77dKF@4maW$#`|jhEApZ zzHtOJO}T|%{GvW#w!)jB)Nd0&i)6Sm?I05GmzYp!So7>4a7YvaJ^pPwtntfE&Aktw zut*uyX5zfkq3vfpIQzXBv)-SXmfb0|xU91DmnS-mPEH<;?vj+0VYX^Lvs0UjDX}BR zzE`Vz!sOh4{>#j6GN9&xTie$j9ir`Q|LBmxYr6JWGjPzO9o;-b`ItBV;5Iob2YT_$ zV;g@QCxax1*_gLyavx{?BX+v>1_)DRNc~uvH02IRdu*ASuPldh!~AgcfF^^Jv_<*T zFz8HL_QcSC&C73~tsXHsX$0p(V-4t`4bX<@6S=ZCwHdyhh)WRTLmsOTU+zHOO0SJhr1aWEOJ9hvSXgZktKSR~rQvfMKe2K2(&l|%n4TY`{c}LSdc9L{s{3Zw^t%0< z=l65-!}%_^Iax51E@_3$C+I~{Ch)_?=W4VzwdHIE#tnQOlN$h~WkrLxa4cu#93@%M z2AF3&f`$_PP*Bj^?Jx~5qZfv#Z>CPF6i5ZP_)!E_%91JkRZ( ze7*6n`Nn4%x9J?m*9nn^WYtH&S!YeZU;aX-Ntu@tn9K8rD4HOGXMDzrw-mQ{j0Yr}_Vz36|*XN72{ImH2= z)gcy)`P;SZ+t!H4m&^M;^Y17uoT)stW?HJsBQZVDET?({Hnf=KWTEmHOU*L@SoZYu zE`@?+mHbOSs~|H?>w$%j&syj}UbgQl2zj&2=oO4Hd^*pZ;5j?Q-cN*KPKYj6ab;TR z#L&?8(JeADD84;nl*3{E;_^!w*(!fN^En%1O$DykX}eyq&LaM2nGK0H+SA78TDdHz z9nl}_qP{iUEA+|{$Q0-Bu?4h)4tw1Y8V=nc&c=hJ)@aeG`K4s(@XUTa2e*FU%}pC8 zpI$!yt*N2rjga)NZHF}NbZpax>~rBAR*$ILv18M?n#sXcqn??S@nrW07Cu;~!2^x! zS8rINYSd#>GoQmZb5K4m2bcRJyWA2bB0Tyb%7=2>_*@ymZ1uLOA1`}*&5+6S#!c#!*r{e_diqr3frtJ% z_`z4^Pkb(-M8JDbJ+wFd#;Bfs##~P5Rli&2*nYRZLH`J~u4=~}unnbF; zp7MF|N9Bkj@z>ONPkiI?&8Bl4Hr`B!)@{5NvGKWb5RY$m{k`1bZ_sEp=`x=&sF59@=Fc~`JzQ*OE%OsUe zmrcx9&G%=PtQ1``Ata_!NmSV!Jl4FmVZC&Xmrt96$D6mFdQv(j$fwacb~Sx^bL-Zd zH>8?fh)1Zib0_zaAGmBe;v)0T#uJZh{4r_og9_u1YMt%)SR;b{olvcP+(_9N3&5AngEQ-Osy4Ao zySg*AS1xIf?e6tL=T6&t?am+ZeSWvoJ-2o2xUDC~I@E*n;d)$Q-shabjd*PKG+sjf zWznaTN{2VbF{1R8s%G+ORRJltz0>J)P`}5MwnUO_?HN3 zoZQGr&p(j)#nh=^Qlm%oo!=(JU3cWcdDB84a`qE$#9PXn$2-E)Oo>&s!gGJM@yB5e z=)wEMKZai#vGEP54&bdFpUiBIrrrobLQN58D>p$`%X+sn%LKzO^a#c4LRlA zhvvRJ`Lb**X$=clDXhV^b2w_UxLOl$7b1X>MH#d!cXS zqMTN_oAzINm_^Ou$1cV3>>Yh~}Q%O8AjRd-rT^sthQi_Rxe zwnU8DA@LzqaPsu0PT(@8ru?}xxMQv%zdiNJJs-qT+hfgxwi(WCgPjG`VJN3(#iNxj zN<)<}CHWq7RMci$@201M6-fm0&*J=D=znaT=r3LV8g^}%UmQ?i&_oLp;^b2h{O@q6 z(a7vu-271MnKxR0EN?V+=Ea`WAI%TZe$E^JZr)YBM951onP0#35;%|br}2*CBE}ee zt5}Gngt){idaS({LDg7IUb<8`l;Sv!hE^jAi-;KX|FE=feT)kJGcT-}^X`IB^ZTG7 zJ=zYap_S6xPRbs6pMGD3vaz z{=QkRd~)*I4cg+j-U6R>(Ga#40a&xpR}ZT|=v+}rHq^!%(_?dJA0>Fst;3BDt@G-q z=p8%c9ZoSGn^2+qsHVf7=+WbeVNFMNDL1jxvGM;hzu2-xR{B@gQT_d=pEf_(v`IU7 zExcaL$j`QK|7=)v-O!w7&0mu}uja^JueYT3Cc6RGooqgC)5yMjz`U`1xePdPKn5;< z#Jv8mesh{PpVRNb>}JifLtm3UazS^`>*ltcTyy(t`BTqte)huDY2Q7&`8g_SvnL{vJC1 z$ef0-rk>iV(~K4gwd(3YdFGN5ZRsmp$QzHL^EW?5~M z(ppz(HYv3I3$o0+2V~V#857^0(doggk5)HLt2g7bWos`j$Zb8XeY>fxtG6AP^uV+S z?DlR4`R`}z_Bx)jd`|IAzVkOOHSH{`d!-Ujk6Wg|( z*sMub`_#b=k_R_!IygCbNNDE4sr{cG8SH=G-suxx8!_UwiPQJq=N~-s>Hbp>W`2`2 zIJMc(l!il_rw&Tio~qw3rBUCwxW0{2`k@yy|1W|6m%RMP9D`jP_69yW+R~VA>BxmO zv|iCd;kB8ozg-e6YnPndXV9XS;QPw1^FNrf?B;XhPd`7X$J1ZUP8!gvYV)ji4@_#& zd}4Ukkr|!SCVjj*H7$N7xc=iJt-}lE#drT@etc?-cD=>}1C!fKeW3NUc4!DHTR-si zeem_(^eT@ZFtZ=ZKXAaDGbjIFeJ7`+OzzuvN=nKUj59rR)Hsu4ZhI|%{I}a*JU@Q? z`4_i;%kuLQ+Ib_|S+`d(BsunI^OF9vdE7nV{0e(6Fed>3IUeck{C=l%5~N^9LN?kI!!(LHrFI?$5S_eGq<{!vk*F z@h>6%CD04x_zlcS@?Utb!zI(L)wdw`pgG+7Nxp@5pq+B`8=hyQ2EM0*XT%(By`f#8 zc;b2amhl~aLk}2y$(Td*jZA~+evC9#+;l1IjE4OM(mikfpuOR&1^O*adQQjlG=Bd7 zTDuOws*0?A=id8TD(MB%LwbEFFCmn)mjn_>fDl44{e=WV5>iO$y_cmZYeA_&K$lg1 zR?32l6k!3&suUGb5j7wpx~x)?m;al&_q{w(cl}G?ygPH|%*>fHy&gTfVS)3K-JPWV z(h2<~wv*8ky7()x*J8(G4}fkk2L&W(y?J0@N9_VNSn z-{)t?j;hstRA>0Yd-V3je{M89x_o`oln-^aqhmJ_L*u?B+X}sezJ&B4R3?|qGRpEZ z$!HjpQ8vrR7kjcH!&OX%B~*s6&tP`FH?@07?syBv3+)PXN19f_?#=9%%>J|dO#X*4 z|JjdV$K9xJg|GQfkf+7a--SPl;~Ryqg)$kph~?$eoRhdwd=h-LvN1WeZ(#Q6=PBPi zxUa@g`%KB5a~9W$8(^Qs z>`!yah;JV3vsrj;q*?qg0XmP!9OnLjUoFO<{Hvf5V4TD^?lkQYH!8w_YlY6?O)}qm z#0?BLz304)+d*dI_5ZP;YwP)x?q@N6JHhWaz_s^WlX08*$>KVp7PuK^+zRev@O>Y+ znP%K(?j>=ZA{4k;X57=wfEsLX#S0W8>0XGM98yW67?n|WmTcm5%4cf0_ zZB{W>af3^h`c>91;A90B$xOpGH*7TY&N|q&7KM30hxHIPZov{w#X8D&XU88D?fZe ztIYt*Jh!LkTb`bs6WG{7@UtdZr;5k^vn#r^w_Qnw>#7lzl_T;iE6Ek%VVBbAQj(ij zQj(X8!87x3T+iEx(OeVU`^l6w9#5(#SD`GbYB4ii;`I3C7R(MIS9(cXdwtq~zSdB_pb;M&wsi zkaOiMtbp{h*@ccQ3NIwenI>mbhN}Rr$!fDJ<51< z-Xgwq7jDfAYk$u8XXAlQi;O=R-#hv~ISIiME(76Aki$vzghZ`Ku%9cN>#8zF8=`cR za)@hQOZwuu#+$F>xTku-CfxKG`CPm4HRCfK*NubzLGy=juOXZl5Dv!ZbOk=ZA2&|4pf6CU#7wOUH~?GtIwPvH;p%MC}!Wdk3}_sTaC7x^7B9HJNz70 z&#A~>e6W6DaZv4PKC1rU;_Qk!+fLU86)(h9J*(f}c_1wU2R?=PULJl||DkYD2=QCr ztnFXCu>Q5h*_E@qwF`^;*S@)xGWHr@X8e(|@w6VgI)Pe)-F#j|SFkP{?5NBG{ji*yWQHjB(qpL`Va#&vGAKzLRgT698^KikO zdt_4Qhh*G8=SNjO-_kjjl`3)s4uTk`z~KES&oTDgFEk?vZjU7jzoW9GY`w`-FhpEkeFED!+vsc5O&+OUEzPcwH<%#y2IVN}HEwvA2 z_L81x)k)=cUWL2Fl5R+mt&e-MwoY10;QXB81XM8_@sCHd zR=&M@_1i1obuNAB%Oy*0>@6+bdt=FxFJCG>+In`@oHH#gXXea0+sfZw^Um5wOW$5~ ze(l=xtN3egucg~psKfnwd~v~_p83EU7!;awMqgtW`Aq5lK&_Z(-)SZru zaYgoqYX6V*8hUI0qOp_E^Z9gLg00O_ii^a?c#pvg-NTf(a4QrWNk5B6fp|!l(HB~d zKKo30|ACsiug2I#i?Iuh2a7g>wtGDP$z8hEhz$nvEgnZOyU~aBEZy*2f!R%sP!eCCcDxjGMGV8Olg85Q`X& z;~Pbe;piyEjKd>ol#Z_VOSlfQN9Zqx0yhCTj7gA&4xyS4WwPS>Vj-a8(1l`P>bjqN z+|5tuC?*kyWJl)roKQ`^k^DO0Leotj;^%F42d{7!aD5PC{R+^laDV;);}>xXJq%yQ z@MYwZ8UG!_mot1hInD6x##Z4C%q!^l|9#9WFux#A%kvAkkuKaM2M%uNfxvgGu#p^- z_(Jb4v?dUzS;9tsNcS6X248R&ZnJpkVli}u@X-w4hOi#V^OyH%_y~)RH3ZCGu#Sv# zZst)2DH>W;H5A}fIdo_x1=c=%tC|2>%x&NoGa3G67XB0k@=9{&1u>kL+%b#cI5zj$ z!=2*=g^Gs{(9>};zQRcpPrR!<_n|ydH%EEyvxj%%1)(1KNkhV}0QeoptrJ4URC<@K zKPrxfkXyzE-1Q$~oK9A*!vX#)#t>r&NI{zmT2F~q$7plON{|gE>x~U&Qt*v1#ct3A z5ll_N!8B-Xpy`2s@jHfZV)!PCN4UE%Zr_G+yN0d&I1rr+uJ-K`9(AW{H2mx2@=xSV z1!7x;M#e$GmMG*F7BN^rxkc(zNy|4pOmP7@xH%f^9NEAp}n>_Zi# z83#~*Jk^#d?!}xys_R(iq-&k0g)h7M3SaW4A3ZYqb#+m?gP1EE!d!vT^~Vg4((y^g z?gb(lCGwAqWCdz9;f`Sru^)2?0ZgI3i~GB*`S*}k8V2<9if>{*A%%e>IErB6Cfatr za6~BY+HypwIII`Sj&?nN_^?oU6jaAB-`J1&1|6qjPl$%cuBgZ(RYBQXo@hMeVUEF& zlVvbuWmi{cXJZI<;fEi(b4b-teN~lyXq6B|7&<$~=%?p1~aFqL57aabnBHuGpo*c5iT;EA704uuFxt zHMi%*;p@uuvkSrp1SO=Cn_~XDlA-f*qZ9haXY%B!6-P^xvik>*8Vkdqw9vp2Xip%c zU6>nfQ-q;Bwt_!tZuAy#XBlp~8Fw0UqrKu-(6s?4&5aInpCNy~01nHn@;Ss~m>caE zYJr4cFr#yaN-;1Xdgqgm=1Pzy2EKL619} zTNTjdNu^Kl#DoUQ_yPp<&sXB(jZ;b7zewEIZ`r=3h$}GW@(a4>k@t*Li_KPkwQw2j zj{|%;se&Uh7=kCAo61cU>_o0Q-6A2&a(w?5uJC6Kn(r?S|Idk@IA^nUu zw(OlVXZIE(8^<2R-0o$}?c7k`o!oGm(WO5Y;Sn5)rXoS1!gU3Hpj)AFNh{fEEQ40v zLbq>Ri^#0$#!|Aa)wsCG^-FRxz}S8MLg`!QjgJDzN#mdZqB#Fn>4oz|2rv%9>o?_O zym%Y)9ckP!2wY>$#5880gTKcI4Z7esH$U@>2o`4-9Qa-B?{6JHe(U?~GoH7@m+fKAdzB;fN;vPC8 zIeBF0gs||iK;v+lX9Bmo=caPE;wIMK<2WqbVaW%jobJa;i{a9H2#L6h%O*+kfYve5 zG@wOEL86dII&Njx3wYkK;b>FUo~bq;*i@$`mnGwWYN^fp){{G`nvOP*OjXxB|Qx=cVDH=+G<>$0V-my8D&k+j8KaZ7Lr{qMpN z-re{dF?4tG?h=qrqdVDKk9WtoX2Dq)pty?G3Og2$k4%Cs_Aju=O0HCHFDu(#QL(M8 zY+FURJ~T8dEG!GZf^*sSa^_Z9zP;=m3Dt*DS3Pw_97+5dVT9riMxff>IHWrsSbb39 zstFDO(iS5i^5YK+xGJmwMmH~emS)|KmC+p-RD|UhV2N^M-*%9tVKUhwqm>89(>^4y-?GWl`osmYU1EnRwY%9N8!)5b=G7pJ8aM?@6!Yo?x9 zu<&GE-N}UuPE0+NIxQ!sF)g)mXwI}$#F5Q+60K?gN+uc=CuEi7xvg7&?O12_7%3abhOQjt!lu6D%j22A_Tc7!nhVHAOqF zH#cA3LGIl6r`Xw@I`iVPrSH$84{TsZhRy-s7o)KEI2iA_vGNhDV|@n|PjG*Ey+}s~ zw6upa9nMB!aZ^ZdqS)g;IUVH+IGdouy^?4WgiAe25b%TN9+~FUZ9jhO@UcPZ;l>9! zlamM6=a7jLzBqpPJ>Q2;dHJ~`L$jlZGP_zgq&|ye<_FZDw0BRB7eu$D*gGC+T7}qm zP=xXQ(a+RIWsgi96p|cfU*|QUEj_DBC+Id*?fToRTO)_%W(*7;6y`9c@Azq{`sx%> z^ebyfnzFT+zhLdCi}D+k=umC#pojJc<2_X4_dVYV<-c>?dX(cLb2F-_?bATLYV@ob;3iByFpdKRP%8hY*YmX}XE z4V*D)B-tp;?jW3ZRH%D&LUiPuKdhb>Fmz;zE=he5;g$(aLJh*DD^19*;5b?0(m^nK zu#MK50P}{T`NSCc)Ua;~xkE+`PR$>gIb}*pu0Ca8KKZP2S9R@{arMRR&4yQnru6B< z{lj8{L*kZbgM-3D{f0NCr8Q)y7pv4ZrMbhVVdsb=#zr)#Lr`vL|A!vVh}p(FT^uVE zaHsdSe&YWn&@nQ4AATcRrOPEm>EEftKy(kQXyq{cskapdmLz~X;L!6${-YUu*6-2MR zrG3@0BrqW&Eiy9c#ET36u+wjH{Qxp}EpgW7gv7*0N3<+nF)3)=qDh0TcCs*Yk&a6U zGlHh#w`Fa)jf!RotE!{cPvGAXGb3V#qz@`Cj!ugUkJOVBq-gc%aSL7bCOMu|w=b}i^ zp?c<#jP+yxjK?89Q5|XA!bjdTUa;BGxHX#}({&2+sO|ZYzXHGh67LD-a>5!_x>Kr> zc1po9rkT%rm`zZ$(=f=;15kF&g`B+QQ&D zxdF-kafM(3=}iVBYou}_?Ss1DS+k+W`Fx@AFZ>40wimlGCqjv(W%E<;>H}VNvTS~9 zAC&eta*aZPb+Ry%^<{r7ip&j9%FSP1RJgKWcw%U7bnzccb*Y(g@tJA5h=D1QQ3Fym zLu%7v#-wPhY-%)o&$(J7=#7ao?F5FUmXD8b)7 zys$Tv^QASsx74w{PV-`#E>Uhb(HH3wSwTmIS}+D;Ja3&87@g{`_MEBLPe~cRsw8pN z)Xa&`m-g5B6Cpb}F+0;n-=g;qNSQip(9q1*+`?t~gm@S2T)ggd6?ru<#Xr6vPp8Xe z`P#&_L!SY(!_(SF=ScnOj3RxzaV?+T&S!KVyZG$0RQ7cwi{A$=e)1fN-`D**xwVtl zAs#mskK$$EX;K*UW;-2CN27td(#O2=n) zA^OMzL(<)oVh`lzlTBmK9Kmh;>#ga__e^X3iTaI%-&vAhH`>xl?~>rKY-F6yJw5e6 zRA$KV;Uil1EK6-A-pezNyjk4(Q#<@E>-iq9|B1ht%R8nN6r>C&D6r2TkdmLDG9Vwc zWyVuv6YfMHjdXBTmUVX{Fn5i8Fwe>4*YN*Re2jSn#KBARoC1Cmxr#duD)O8em(6eD z_c6N7W?U}(UShcC&A2?!-DQ4XFym^WQQ`{2Z876$yjPjut!7*@{H|ei+srsh_bH=; zsAc{#q37bN;#2Uq!;EVZ?DzwUk-#DEWV$-xD!)&h2V934*CveOU%~o>itIAu>iBGO zM41KLi)LITaI+Y0w}hiI=gY}vp@q$mEZ0v=GUv<9LPz<5+({MLB+z-fS?<6{a!22w zJb`{FSe9`xd4@N@mx^o_F*agzP)dWRdwzxH1%b}^q;(5U<*E1>G*TYWaJt~6A}?U2 zh5jkHBRyZKsHM=3$}I4wvt`{le+?aC+V5Iepuf6 z8vN$2ug!>!)~CnCiB+}hbMrP#nzGJ})M_7{V!fi`-WR@C zLU#dPuc5NZ5bp_7nEOAO{Y3>YlqjJm0Pm(!_pOR>{tL{jlz2&&+AGDq{Bq@=V86%g zw{bOK5_(W2`4Mu&`k5R*%TGUH&nn{jj(D5sjEJ7PoA`k{5&WD;!`sAlm6?E;4F zd-Gp(A5+|X{dMNo#2ZE(kUb`97znZ4p%?lvwIlr6=)9CTcOO4~r!Z||%&-A*p8h_< zcD_Zy`-J)=hR<7xk2ZSlDg=kLEW|yf^F8$_2S0EePAyaZ&h;ErZda7k^%8}KpoipD zn62P8ydO$KG+q|*A;OmKaQ;%qt%UWfB5gt%+hc|HuH~`qjUhJv74?()-yF(c^Z+U*2KN_|BM#B%|h?>p1|PDp3nG^n5r?w}$i& zMq94Kv{LfieV$J+7IvytKRxJF`A9)@U+6w>?Cd0qJ3FcDaQ5J!@`^0GU>%;og7nzK zG2U3ni{hiPf+h+bH~UYjukg1CZfw_fEf?Cg?Tx`Uk*n4v-s<2l4c*xpG^S{%M}pcF za&yrn_|>!|-rU(uYbgs~b61w#Z-lSvYZ?B7M&&wl_%}QFi@zTJj&6(qS@;K>_sX&h zrk8ZVu*3Vi$gw-F4!4XMyZFm_JIT@S$gv&Wp^zKWdjRR}l^c%kVqFz?djW1D-@(Us zUqGTBhKS$adh|e4-OAg+C-Cv`ZT!1gY*IK#*KdTQ^4P^Ebe~7$KlM?oI`4n3^6C85 zaA?`>L^v^Qm&dZ~VjV=5KaLlh^QE^uH*`CV{~JE<7ycjl6j{BH z@Q7>Td>}NHq8kKlY?=l98av!!|UklEr=ok<4JUx_GGXygeY6O!@47OBK#V^$HM0iqcufqm$-gqlx^^| zHm$I>t4Z57%@jV`>2sVj+T_1s-}F7UZwfowtJh(FU$#4-&3gm(4@`ErQ~O0~$AYdi z>M8Yq$z(@8q5dz+b_djZN`J*<2R*faXtINz+CQRp@S}%)4eTFtPe^v;A+=wX?G7^i zCo;VQ+^PR-%#N{(0#A_QoU02~FO|IFI+`DZgKkNr^KQH^DALi%59Dk&wh_&JHk)r- zo_+DN7-0z$1{W6(1{hV$Fm|kXzi8mVF=GY}8e{q*uNI1T$$8v2utr6+@J)g;%gQn{ z%ge>P<5N?|k4sA(51v488XurPL5qkHjf-C}9%g<-{Jz4b%)_ ztzNIyW`Tz)j1R72jDo(21YkbI7MQ(>68jGOhbvXqTelXL1v+?(x0hR6KNFEv7z{tA zafuUMpTY0|>jmMMb-1V!>7k*3+w)>VLSiQNZ&s8{YMNs#Y4h^)>-$MWu{g3|;z$l@ z*{X2hzfe_4^BOu5(P96cjz-ST{^5|~o!4JqLHCxaj0EJ*HQ{^E()>Xkvy`i44$*nV zFLP7E(p#<`l zrTuFoL3&!^H%EiU1GB$BDBhHYge!bPd}3qfuENXiQih@&D>eLb)ekb~|F#^DoSl_5 zd*sMjSy{72#;2#p1C*My2ED!^HI@FREl!P%O{D-y`~iHos!G9k05nRVoctQ+mg~en zlhSXEZPjvh@2&Tbr>wdlDLFqsIjO+(HOtWF-^tL4j=z5^))bVi$Kv4=S-B$IQsEnN zQJIWa3{dyzWFK!!d6A{@W3*2O#tPq@%HFszs$j^I)u&NU-Fw~;FDm;$I-DD}uwH&r z41IElL2;`ZZlikM!}-b8Cbz%V3Ln=-O%zKe!tG^VOO`3dQ8&6yOO}1C7}xco5d7

uKj&w4mvCBskx(W;7YXIn&qa&Z#ui<;K+74`<}evrXEx*J<^k?FcLA>{ z-N3E0E~3V+?f|^xzYtmx&q7b)XV9D24|*C?p-ZutuY&f*+5B>7n%v62%%2cA=%RGT z8IHliFld#p5*o2`yd1jbw+b%{hlEqY4bd4|=_AB}(7#kDJ}d6R9`beZj`&caR5&Vf z6ot@vU#DnSELN;jY{uJP2NcKgQsfoI4aHqWm(oU=shq5wt~{c=rTh_7_7SS_s%q6t z)l${Vsza(%s&`daRh_DPYEEsVc2@_gW7GrHIqDj9i+aBLl=@xuRduKOo+d(*q#3WN z)--A6YF27CX|`+jX|8B)Xzp5NS`}DLw5qXcv6^qS+UjYmomQ_{hgip3r&`apUT*!S z4QFFx<8D)EQ)aW-X1C1&o8vYYY_8bcu(@l~W$SO-WINY(rR{#(*KN<)Ub4Mmd)Kzh zPHpFG=W7>Xmt>b?S7=vf*KYTM-Cn!b>`vLeYj@SI(>};vYd_e&+P=wtuKh~;P4?&P zFWFzWzhnQfkFt+rAMZY4eG>a*^%>P?ZJ%fRT^R+Vq2pS|XB~GrzUla$<25H=rwFGcr%a~;r^Qa|oK85s?evk;S57}T z8J(@1U7e$x2RLUrH#r}7ads(mS?F@s)y6g5wb*r~>m}FwZt-pl-8Q-1b5D1l?!M0b zY4@$}yWL-LKjePg{jB@D?jN~dcfaG_$%)>gXdA&wz| zA&DW+hHMYn8?ry-NXUthb0P1Ad=zpk~?r+cvJY!@DC!y2%8A!h<*`)5fKsb5d$MKBl02&BPK>vMbt&KM=Zv_y%8rOu0)EF z-jRbN3nLdsz7lyY$|h=HR87>rsH;&wM03%0(fy*e(Wz(+FpR|Bc)B)ces-z-z<;R) z42ke}efqL>^7naTqtVxSulfpTsh$?;5B?+_FfJ{|#x8rf%MPXwuT-Rx9Pt&53LkK> zzZcX|T&xKcx!f`a-*6=g6Py7KAYT0+zNdWZ;KjZEr}=p8&4_zLD)~ zU_K4#fxQecOZ*d;sdxkaWDu7kei>9P+%gj=JrLL95Qez_kDv~O45AS~K&gW{4Q3Mz z;>IXUFqX?zQ1}?`{~NH#EA?>X#bbbd(>MknB2WJv6mX3S8N?1Q^Y?%XdRa`Mu;%8Q zK>Q0g(geVbq#&O9&mcr|&I&uw%OF1D=1b7?0w2yz1I*{cd$t3P06vrL3t_$o^B9?? z7529&h&$n521+YDP5m=yQVr+ko1ixyQ+)V#g5Tc>s;^lZP3a#gq$4f~TEto02oosM zQFiZe&Jy(e!!lll(jsnix@Fu2&PS9%`4;E%I3SH-4E_PSe+N%-?V=2d@tA|12j3`% zEU(sa-im%Gr!%<$3IkUnG;>cVthjpIFR4`2at84s>{_lwd=X_e0dz1cBDjfwR{;wE zrGPX*6JP>h4PYEK;}&}|V3GwS3%=ZofO3GB{JkW9e-*EYAn6itPa{kUI{;4sHUn13 zUy5%7JOKP2y63I+&@Y46B^>f?93WSb1KH=`ThHwRyahN7I0!ffcuD>q#`hV( z@1eO&PooK6;xyt6%u6c~RvuTbD&Tf2d~iPKPdLZ(8kZz|!=+=5K3mui8J+{I=1Taz zIM1*T@_ZBfEPpmO3dz{ZisT9)n{2^@%N1g|JfR;~i~P^zPjkt5(VNSQ(S>+BA2W7<&N;TAh&z z&ZULGO)^t=J5F+^U)5k4{uh3g&N%V8g|6GsKfH6p(RU)~-Vs*`r~**lw~DxcLDNRR zNLK-P#x5GxOK|d$n)z|uQk>~?7r*94@lnvB1wS}1uBBrRUdfHeOlmwwwF_JR3CwQC z?Za4uh#EMpBaX5eNhLTA&hR{^hDXUP;4P6$vRTCWq7$-NffpX=Jd1{-1ioE1+X0_0 znX&wC0~@0u3B6%LSDic6PDuTdWK=O%*_;Y`XbxofG&&cA9_j{eDpwBekwc-^uN+!p zYaypt++}ZsJ)iwj40Z8^j<8y8CTGBV@CKMBaW$Z!&;q(WV9|dd^d=6!92ACs}ng&o+K{p$*0VD3(DR&O>y^zl7ZM8p+~gIoI+pZI^`k}JQX6n27G(zeQ{o< z34SI6UkM(4amTk6-x}a(YJ8y+@o^bRsi^^71=3gv%BheXrJf3iM$A-lR2uZW9{V#M zW3SOk7K>k$MLG@+L`eI%KS8_O8LkrVHVL>vp+M%zWG3!r)NH7T@p?Gd59NHV>NCa{&fkcuh zE)g4dF`SOXLbK{6?gPBURYSB`%8bWL9EsTDNrIlT0bDXkAp^Mq*z%=|;;CE;NrPt4 zZ=icAon$agfLYuiythbo;?;6f&?*k)CX-<#7xJ6RZ6$eRIHxBgpusW=GB$GAB%jNH zgr^~gn@9ol*o-2hk(a}u6=MvSONz)?oB+Cv7Pgq>A=D_4@niy-NS;6$8bL}(DVNW* z;#F`3&`MavjpW*ifmD-8s7bS-k9HgC#|&;3slgkNRGamy+-O2ya~=adI`v!;RQp;{Hm62=;nroZmO$$543gvnl^dpkcTFD=#_8ygnKrn(7FlQ*%>=fJ(hd66?{$4;W;{|&v(UYrB)Lw&M?=IOi8X}uArqR*1Q z)^!bfm$g`l`VbnaKjE&C2hha*2>RB#u~uv(Jv_Dxc%Bz{kyr3g zD#)vO4R6I;^ESLKk3)%AsdC^Qc_-ePci~-epTwQ_;63@iSikb(Zt~u|5AVzS@&0@O zAIResH$IpT;Y0Z_KAex>BYAA%@-ciYujS+Tcvafe^2(-$dR1$EZA@&Yo_=HXX)*Fw zE8+E8RhquMvbDt^;bY?EZ-z?W>pxZ>E8}AmCHM3s`I{zxGw3%aHcO_{%JFDTcG;g+ zuN>M^TUTXJ4YdqDLk=!O4lYji9;a32S#TF8hY}~JCSI!?VRlC3awzeMs{G#2W3*Zp zD!z=#SbeYxuU_M!9J}p)vPm5EPTXL_5-C#iB*A+bUAhDnbeN#k?FN^_*#=)r>vEPRcjech8#>rx(zG38R=1#l~Ws8 zSlT!y!B~Bq92So4)KxXKDC;cvGt*>A{KUs8>&?z!M-D$;r)v0Fyo_Cxf-LKXU*vv* zvdJ6-3PZZG*}|F_Cq5oZAdMzW;%jwt4Y0=5vbXl4R&vW!T^IsYs+8L6q6(OAPIV<($r2UVS+xfyA* zn$%<{Z!u7rstwax%j;lJHP*FKH}%xodS}&oT^|F6A-}m=y!5gitFh29O3V znAEN+m%i2(Ibv<7uR?>)+^r2$d(9QCb#(@bUeVZ8JJn!a)zDf|XJBTl-U6&KOWDR! z#8$oX)tH5A)hk)spNZAlLZ)^<7pVPX(nNmCIZTtJtc|K~o!X?VXCF1KyUneQ&1zbE zsliIBztpH<6}+mxVJahI21-T^G8r{$SVfmiw5l^YTG<($RNa|Ts_=}ERe5G&NoCGb zQYA`Oys6P5i>cKzi>cjOZYr2Bsr{fviF2Ax#yK-k&Z$A>oEjz0B@^YG(NWGBoy0ja zN}Mx7#yK-F&Y82sxkSl0r$!6TsnwEmYBzDtd`X;BqlUG!uvchUgG^1Vzlw{A*RY;| zV$|rUDhzcEZOkb~8j@*aVp#W!FKgcMP1lq+H8r%+MvhWyby*2^)RNawKPip6NFH=q zNmh)Im3)~p(Vp3QNo^TPRYjd8Rk9RAMMF!C>`!N7qO(AzsTi`gQ6jHuXsfql7}oyM za!Gq=${(v&PCsXo;bej&X0d0Udn>B!n8+NRA7h*$9Zg*Q!c#JHm)M~!Gpih+j#(T` zejj&EW6ok8vV)~Q1_K$|JjKPZVHCcs2gR2O8s9YAnudlc{QFWCrCZyE$Q-8pkNzLVil1d~d4>GAOD>y7j(69y+oJ6%&Ix~mIs2v&dXK}Ga zY17kfO3}o&L`g$h*&>Z_n7m?iv21`vV~EYnNJL}~m_Rf)SCluEHrF)3Yis2p(zE0h 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 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

>f{~h-qx?k1E`3ocNRiExt@65f0Vj&H6 zhYLN?jDbIor9-vxG(oVBltQl-PZ_DOUQCRv2v;1unh{w?x0OKXK48Wl|v$s%2 z?B_x{!g^Vq(}73B`$+i-Gwz^yKcve$p*7yHytx4=^)K2u4KU^iSj+*m@dVe(+ak=v6XIwWsBpiIpJp9# zN9jo7f5bh%3Bim^%&bwK^u&B~JEppzgW5f5552Fe&3l0kYpMpvUTDgJ^8gykajqVF z+9J{P=iWn=kebJYw3tuIj1IPAz9|Vvve4u~kN?ACa!x)H`XAEqK}RjT#A3=3Y z(jr;#I}x~g-&&!3@uRlK956jDJ97C@)f$E|o_cp~=&8|jV=fLsnO`9c}_TCX+ zh~+qKb^^T4P${aqiS(pV6L0CLc1e9tNA*NW3gnB?x`H+$*0hnd)mzJx`Mhv>E)+Ax zo=zr5CDhtJ_p$0b8PuB9`lj09<9u^>-0G>-VP`Vi8nOVIUx@bO(faeh6@oSiZ1uem z>Oa2Fn<1=OZxg3T`yu+}=aBL>@J9BUj!@N4#kk=m!U2}IO~V$Lpu1Sj0PJJ74D`MX zw?pWdi8VSIF-9blpog8a>?fSTU66=odbFeSya(qK>XGFH3b^#hP}P77ywRey4vkw? z^O*`+JdFZFr@YkyS#bT~NFUs*`b?Ch?9GF*7cU#jFy%%gT! z;n$;Y#|jywfVn96evd#$P9vw+5c3ATm&N_c%cw=GCZWgN&odwJ62J>QNaUOh4&J7j zK5(wq+Bo>2Hx1~C){sxRzY9A?Z@--;X@dJmwBdZ@rS>z#jL$((#NnM34d|T#Q}b4# z-g}Pq;`-9@=n}!y7UZD_fVYOdv2-(WXyv4Oy2yAAu@}(s2}=L-J9wn{Dhg!q9)6f# zMf-dmb#$D16G;0lj-_{zj;Skp9v_Bsf+YO8UDKYzxADyLRXNdz;NPk(c)Jz4iqt;+w3e2v`={Jrs{k!j`9~Z^UZ@=|p9kS$^DrKqf9(oRS(9<4 zb;BUMnRbFGQmZ6>aZCD+kvBoL<050QbIa#R^kuHlh5ya@Ali{3@e|n7o`oxYV~4)K zFiSLu+4!zbEAIy1#{0l`@UHruyoY`_@890bJK*>8-uDB%L;F(Rn|+y}o$iW0Nhs%R z+;@sOfOp~a7u}*C_XA=cG+{ozK;tJCip78rieX zct1=L?}u^le*IM56;sJGvgte{>*N|r;Ca~;uBQavGgHWOv=-huQ_eHBHm<*5p0Ca0 z`C22-*P3{~Hk<2J-D6YBdu&R12DgmgjY#7e+Rho_qN@D%VIa5B#U$MZh^IGzbM z^Gt9Y@9?SPnP4O9SupEaFuxTM%zBo|GsIcE3n-W8i4CZ$YUolR&S^N+o%=Ib|01C+ zA<(K&Xo?;h6@~hXMvY0-S1f8Vj`#JGMg~y7jWY#joG@tGiLXpSL#;@gh0_2H_2_6K z>u4*!3@(FE4fFz9G4c&x+e@72^s@X>^G8n;V-Xlf^L=HQ$LP3=QC zA7={mbOG*3Qx$0HD70`HPJd`CX)5Vx4C`o=kB)};XlE#EXAo=WbhQ0lphj=pq_S3} zvi3x@Hh(o=OOIGs<1?@L_(IiEHi4)9VmH^n2Ywg5~~*q?vwFEZ*2_VU7&gV zMX$Liw}kG~uBS&ay7xmG3Cu)b8X>b9)K(8-=R$6MxccEK15a_H7yfu3a-?{IM6!ex zv=R|H zdV^@ir{i$n)76Hsw*~iw=+oSgHqobdSm+C5(~!etXux@){^y1IX;3ONQPF#8tCXh? z53gA-E*n|9ZjIP8x^ZY&Y+k)$*^pR^@5rqd%Q;-a;Q|hO*Iu$_qv%|Zi)h_UVLelf z;t;+CI1k}a^=_u|J0l?>Kc^~@GY(b%ES1I z&eQT2_(IkwzJQe|L!g@{VZTnZ_Kl)uXa&jhjg)xKLqTl@@K@#*z-m+5qnSinB~y8j z;`^)*o~Ho#GzwzJ#Ep#MfmZ?^z;^;jh%r_$xut`Mk zR`KurEX5BPs(!~M0JF}-&ru&3t>jH`M;43HtMBejz|$a`iKl4rM%<}XNdh7GNwj+f z5)Qy!3O`ebZIlQX@H2%167o>uFSSkj@|#3zpD)C5Yf*C{zGz%U-~8*x{Q%#^;w}ky z3fg10m#&mgqE@NRscEUj$Fc;~FS*gmFLeV=_@@l%MiShLqI0O+!3?@dwZT;EK2%!;kcVAgC?=av{OhZ~KupkKj9mu`&+d zR!V^8CgJOCDbgT~GF6)JEjP1Fmlk|;FH_4c!<(D0meF?vsaDH0PAJZ!WfbQJuE7J! z5r9$#K>~qt2HskuI`VKL#Wp-qnRNJ?U^=%>wG2e97C+*f&H=4#1h?YnfzQ$4g@92U z!9V|7S%fnOH2su1r5{of${4Ofw5eCQNaV|EJbRCQ0zIHWKcO9S+iMK)e?)4O?nyV& z`^}Y)8-%=>qLpZTwMz&CI3v#K?LQiUQ;4_!wk}(~nb=&hd1$pH-He2!2vdBNJG3K*^p{THxw934fTe5jH$*Oj5nsvOpT)1Fs5XH-6|Egi+h<;m+~`> zlF;dNfnG`xhFC))Q_44#oI~j+{6#cvr@C9ZjYQhD(Y4sMi0*_q`{CJlPyFD-zn}OC zJ)QW@i5pMccjC4aBPXg}{pG8xUS0XhE3e?Y5){Wad{CTJAMo|D+ZihGeM0X&LGV>F zY6s^({u7?;i{e-C`#y!o_!7L*-=Oy1hrjRyJd>mB^Zs4@O?-@={!hp^2%g*wcyN*M z-pF@LVlT~r9^V9?%mz!F11aUPCs7Q^m%*!j6h7@4@suor?^Ta+K@+^J4vYwBJTSoC z|3a}y48n_A2@h&D{Q7n5Gi`ylv;(^v-h_Yr4>1mJ`y{-PzrrtlOS~!_@Mm|!6aGZ} zS(d?{JuUtNHRFT_^osa}EJuy+f%kN`_@Vd_d~;Y8Xj34xLXW;V7}j3x%MxM#;;~~p z1U{GrT4=^wwdvwRcnJ>7L=~WKFU4;9HPELDw8L8L0k47Q*od)Bt7wD8>x1vQPH>_W`cFpQlD_1OEHoA0Vc*)S< z;zbJ=%%3;V-`6|0r+ZH4?2cLOZLKZMO^q2TVL{Wf<>Bc;b*8$dLAGphIA}UKUHuL92}a{Ug2^~uJ?>|2px@BQ*sv#J03?tKX+ff`+tfB^ zUcaGXY>-m{(lhBvjV~sO9H-rlD|J2nbBy}j#LqxfADgE{w|JsWhH=q3Ha2ot z=*%Efop4z4jep%$11QOWX?#hR$!O|d3Q~syMX<4Funu>@o;zu1LJo!_VPXk>!+oYB zQoEbiKW-Qt9cTu&P|V}}*L_4dOxx7^!EwW|!7v_RHZAGwA2W{2K~sYE+|>_>$)SWX zqseF(7&zkoRU%=VjG(26+QTM!aL(asd9Zt4KQbLR9PH`851$CE8>}5ToQAmmBL*>E z%}|A)1SAv#J%~1mnz;|(LnZu#Bh_MjAIG9Y0Dzx|Q3{5sNI$KHT^K&1sIV}`JDg4# zAx1%rpBhumF@X627O2AZsn{7Bb|7NHDCVay`U0Xumg)g1p(3k;{Hp`2r&R|lGZfSX zf$jt3A(+5v5}!&&H=1x5G*JS8kH~$8r&T9_TToI#56OMNB-lPL44f0141UReq7rfo2p$hI zEv+4&ZmRXhREa7zCV*n}rrL2CtxCPY)L@8RF*a;k0u8M0>|d5JIxqy<0bA-Qf7Zs*OTTzcm9j|GpIMof@!b@kW~ zik)B>9!MA)7-ors9k3>B+5QvONi$9g*#PtKC;~K#C4;6VDu7z&RM@hqfKg!i!c47{ z9hhw;u5le}H8qR?gMLFJ<2q=e(J(Tgs*LDlyKzAzkT5|(7|DP-W#VIDWu!Osgm`|! z1OCRwmrZ_L?tN@TG?c;5Y*%$<+%KIpuirSnGGTo60O^a0GBmz#iDAqTW-2vNzHEYr|ZR3hbP7+T6{8^JD1 zhxkiqFc}AcqL@kUBsf@$B@_&gK^w;xqP6*(!^dWh8A`@rwHCtQ`lS!|4x(Eygc%wQ zT-8I+WD-IPJq>`6iZ~4|meYd-akD1t@It+L0s^3Ud|j4`9LQ9`Sy%sfCuK^1dj7-B zhOBWVrWlb_L9z?>16BbQMZ)tpw?MqrP`w07Z5UV3m9f!K)3;F41P!7k1BC4^^_u8c zR2HVI{HmD-5dHCB{+nhV4>Y5q$Nj*A8m*_|cxduHm{!w(C9ta&0Qbm>`Z@aJf(&X8 zerAXr^tfxJ0#MCY524i$nG%k;f7uBeIf$o0)4%{_k3Xm#rC`!yDq#{ACUkQoF-aRulfx}pfuMJ8sxKH_$h05xn?bdWFTv7T$u+%Sk3rQ4b{fV)Qe4Fao?ep*YK zuC>zODOO)2uh!|b$&n^6G3w||UW)W|SO&ux_Dk?bj_HOlh^v&VE*&Mo^H}L&sZZAs zppjuHX6e}2bd%bpn8^J9RETQ9&ZQWDb)F0u-;Jst8w;Fq{+Ph2_%k>GHGb6c4DXew zXw4z0ZhU$j`7fxz@o8j-?5N+}kCVDVE7+az=?)o0P0_cLj3wDa&`1J_uVZ6Ih#t9( za+6lcO@;RK??xshsmCFVuyKj2f4Y%=2_!n!xpB0Nby)+d%e9+y;{Z3Q16c;c3iw%d z65bX%O57V@4S*e(&PHww4$q1qbj`3sWOoK);Y)RqcZ?B$Da?SeaJf1xGN~HZjXuE7 z++UthGJtW#5%*sc$zDM;3OafGdd3Wfuy90;8D?UjF@8BUML#Xh#4z;Y0qGhRmEiKM zu`v~oeDYvrtgRbDpb=Y8aY7Jy@{Yg0nRT&fbWU|WuvbUKa?>^=$>NAuWZDjQq0TgJ zFf4#kL|1=A%t{;>8$*{mW}jk?jI%6 zz-c1i{oZ`HB46TQ%#-mEvFyCrlFG^X6!ISz^@u=kY*HEVOV{!qTQD{c!$so}kxbcZ z{LphqBAIb!=tg1)BPxk`r%;TaGK5e}JpRY5KtZ@M+-wXtX2?%nbLD&&{`tV!`bH&J z-4l%el7Tq{%+LsvEWm_ifvl3o7=Kx|PnI8aeOT!FXu3)60I{?6%GR@o?id}#E`?7) z|0CTVC7oxL^q5y|6*qa|2fgq>z;E}$C-C)?@V9&L@kM!GIurQ1N$}rz@Z~hXzwV`f zxdsoAhE{>YkNDJmjPeCw4r(SkI4Wb5$w@_w(uW(PAeAWTG)DQ$n+|N4?Yh3yPxfR> zx9d`Qm20Jx3zUb~6rbIWGT(taSMnNZU$qKF$IKJ{-w)`9V+M=ln;#t&86r*2{A3vw z8DO#$Y1&2<#!EzQl%%LV^ZWV=r*N0cWpQzraV?TVwYQYpxfj?vqtDK$iiicH31kuxSl z$~!s_EiEn{JvgWL+Pt3hMNa4ZInFiU#~2?vc7A*v!L_+-rX z7ne8})tk-r3yKSevRj=uJh^kHx1vgmJ2Nd*QI2_aX-3OXwrwCBQ~#LJ^XML}8KSm~ z;ncnhm`A1cz}c@Im@fBnYekEd+3VJk9??AU36u%*fd#-_g!Q(wwBMr5eMaZ6+k<({ zND+&b7HC-r zl44l0Tpo1oS#ezp{+G>u*IxP1AsnnnujCGxsL87bgH;riZ?bjm;I<+`uAo@xI30XgdWxL0=E`E9w| z_3&)h^CUr8wb->pldsajG+4gS-ve?jFz$nvXz@B26VgD)pR8r>LYgV2*M}2d8a0eQc_Cd$~Rjw8X@_Iu_fQe2x`-avQ zp4O2)gylecGpry!SYLI?(ANmeTl-j)WSEi=#gbnM3u9M2rbXV>H3n@R9h<{iJ9?9Uy0oL?(xD>0UN%^?F|BT(sA!-rjjp7))Lzj>bje(2u$A0i zw{v@)3fE)Co7%Aor@Ci+v{zp^^$98+ofrNP=qnmLKxWalDK8z80pUXi+i^jCXQN|q zqAsGa4gu*HQ|+*1m9k7mq2MrY$$aZ$i{#y^fxF|DS(j8*T{7zyqjYf(u}UuN|IYH2 zZ0TB>SCym2y`6;GNBjW!%s|URtr@v*2!X|PhC93+gmaNJEWEtA`SOLwOH2RI+t=6o zhf?L?^7XT3tuL=|ojFt0Qd`$t4ZbJnMNpe?dV3b!Xhzu|TJh-pMC1OSsWe?D4s2UX z6xNon>j1@V-0VpP?q#|f)`L{6x}2j4G3aYx2GF@UV*=#osl|=vhPvjgvBZMh_=bY= zM%Ue5HBr@NLQMv#6B;bKF5FUOPcz#hi<`TpOskvcn4~jWoBRo67mOC2ph}N>SQ4Jm zQV?VI6=2HMx3Tkamnfq@o##qPnXUG~hJ5qnWximhedK#Q$nPf(6| zsWCe?Gu~i{iA>09E-qPIVn|+^m=JGGFj%6a;;qfj;>9JXOIlSI_}^mWcR@2?rlL&Y zr%ozeCzX!1YtP;X6yWrO&z}P)ocUyP9PP2o0rINrzjt8a+_?+(eJ|*aZ^2vHv3kjp z)$$70CEvOOy3c93&E^^t?zwx+`Mli<|m7$0r!{P;S8!`_nrM>Z4u8#S*{+FZx3 zk>PV>&@%xE7x?z zwuQ6}>ld_G8?0TKiD?-%glilF?pJ*p`veR#?!E!5+yp z9eHO#rb)i}dd3ReuX!}8sprzx)=PUjwx$fmHe}dat=3k1Mnmji%2s{TuGyVC8>=dc z)6+m3Ii#f*m)Eeaq+^T#`l!nWSv zY;4>)t8Qgw`N|?&Q&tv=OW`yALA&$~RM`5mZHpFeFJI+b)R8^cUMt(r#v@;&`=9ua zjHl2Wnlx`llXY~A+Ix}hw(vj@*y z%8J(3!6dmqDWGI|L)~yuR$9&W6=}tJH$S)poQyPvs_6AFKXQ8b*u`X?mCja_fJ%N$ay5sGQ85vEv znxSQzQQ7J zml%*FV|bh~JiNeI7MU877GcTodrpohg*{6{LKjV+o|RQz?5dUbj*Jj3(ha9F0vXN3 zPKkVO>)Pm|LOLxD3@`#Ln%VO-k*Vq;>szm}6`NK$`@4etwtoGV_MJ^lJKO75*VL@e z%W2KbY|Y7O%gk(h%3@xWWSTQ)@%HTpTxB;L`g6_Zj#-_0rPab>-RhW;DCovkRR0%jUR_ z$<(ICw5B50M<9jzsK({b_>ZIML=}BRwtFeAoQHS@|2t5p=FzoVi9Y)_@S#4kkLypx z_a}VxHSYlK^nn+uaITLSxB;c?WXE;cJCc{fKi z_OGbk&xA`s#)(FH1vJvDO09`bO|+UUyBE72-Zge;|GmFCxc}a#!on6rWmy%|*%y13 z;{S=BeOp=V8RNOXhquPwWI4=3ek6yLxJrgU2RVdjW3!1I6HHanX-V0s>AQ!X3JqJR zQa;hY49Orhs-xYgBN~TeTvjDzL8t;AjTWaoqLVQK8-u(%-+>B$?SUU%VO56LC)KsHUew*$QQ1k1Sh-0_`)>_PIgD7qw~TKqYPbM zcnoc!^U^0DLd6dve6}rT{QR^sH-3JpCrjnRHd+%-*N*!79bGIOJ9ozNul0y;XXjM$tx~(y3z>ED8!F*9Q2m|PZ@|bdN*@y* zF{LY&J8D){RjjPn{D!T5LEkyQ->|d(XUcK(oUWrX-M2SoIjVVI&}X4h<>%+UJLn>L z>we(6?|hk4|4<3vLDk!H`cdk=C;JW&;oNI@e20b=6;&(IbHYi2?*Ikge$wbvx5?(?;^k9BHLZO{Hn2E1HYersby3N-uE&(pZ*O zwpwqBTO5;YNQjOJ4hlCG+Y8z`euyMyfbwq zBAROEE3*&7MS}{S7ErWFX$)hpPp*y$kF+Ldlo&dyowe=NrPVFF8@9H#uP#k1!=8oe zW#v`t^wadq^Z{{srb6d3SyPl#+T7@xF8|uQxwL3Osl7Qbp}^i>UAs7++8}%d$l^xj zYVK2rBhM(62QD-yO6VWlZ82FBP0`ipI7jsJ2jr`+v~+9qz|5KQr*IRnUms=aL;nEV z$ZZt*MuQ&|2X`h1O>P&~FE8C)Q=VO8-VIB%oTcO17~`I>zDnmY^ckok4e@`pdTUZ`^&dsTm&_*C!? zsZ$|7dCkK+BU)6YL=<2?-wBrXSUTeKBXZ+Xtus@Ce!cCVaTRSfA0Bw@srZ-$;gP8s zjiK`R*+O|ie6n#8CxewJv6hhh-dhGN7lgsnVCt&k%ojhmRVgtud1$X8}2h3Q@=Zj(W5_p@W;r6x>33-n zNF94TThx`C+g(`Hm6Ov|l0u>K&Mg4NsK=SGn$~QL_H&x8t^X@=;es z1)awW*fVkf0!o!&Gn7?g!*fi z?=ACiR+?wbkC(gGRJvZTa1K>gj8-ylRJZdXS0nb(t5(!^DwA{`4lbs1)qK=$RUPPH z5=G8me6YFi-g<0&(3rvI)-W*MvF(2iqf3bDMcnjk%8(0^{bSl%t5GUOpiIEXpU2UR~l1Y zn<}>}2Gk#y6>~OM)ohv7v8A?ZOHkqB>gs;GeW0p(ap7pW zqp-ZZ&`~a{9JZXo!W^5UGpoC_ye~Jmue`JyJw56_7Cbh2WISP0p~5uvL{m`e{ISZg zZj$Q~{eff9l$%ta#=;G+?k%ieQ(b>a)8LiOecvv$R;Q&_=a8U-&Jk|o(qdKYa#xBo z)#xxJIn2&#;>Q7gu4I{g5ubli-xclcSM;f2Zd+DXYaWL|ecxTb;r711+c&KLZr=vy zn(pp3PBo-@979Gus7Flo6gUd>YLm7UM2)>wTJoYyD}8oNsnUnGy|r`=WF+aWMy^N9 zL22!qJt}pQQxyz1@E)0BN((lW+lvO0=UcCvH+yqcL}9_-!{oQM7H+V`dYgD{o7%)d zzX)Ysac)~8_h)JwZ<%{tPG!cjhSs@VU3LE*?i!Wu=V~pNj#cgp%Cd|aEaz?G7*0W^ z=WXLQS$K`>=_)1o?*UtU+W3{O)!?fbe62(}gQ~geB!}v|p4-Y>3r72m&dg0!`HkkZ z{#o~#vB-B>eL~H3Yp<-V+#3{;77r5?pKCPcB}{JV3jS}vY2|X%xe0HXc-pzojsVQ^ zou{Fz^QE+~CASS--IhOiWl!Of)ZE0z^u|@?{{E>msuvbFZ>(wD-j!KnPOHnd)@PU+ zgF3&xq5IHqQB3sG$nfIT9Z`jG`6g#d(NJ~$XbCmBQ4npvncU)i%|BX{FB@J z@36JP)r!|DKtf`*eUN?7p zP5q8JT{{|TcK*-XJN|=+vTH)t+PXM<+2?8S%H#$QS%(%ZG36#UnVVLUkb`SfA@7)_ zRzXZw9i*Jc5)Wb_m#pfDa>PyuS*rlKE4j#&Qj`jjn~LbY%vku`H_5x;E5+~#1fC7| zYXQDXRKt(eaIVrNrA)Z8F6^lFd_rEacBP+D$l+n zd;0CD@JELqx60Xf1a>YcmD61Rn9;OgLDQ@c)Uwhz?^2|LEt|p@HVm1ZY;`a zXGm~;P*6+7jHD1llEqM(j_#!{Ejih2P7Y6wpt>|5xDn-}HXGsTqji@&5$F25rBiPD zncS0}x%TQ}*SF9=dvs3ipJ98Z_Pf5i=bY3(ljp7K=YN;Z&9TodEAO@E^p@p09CSNN*@kvlJyJ7Fp7Y4Yj6(M3X5o!SrxSY;1B;Y^wIMB*|);|F~;$p{+L}p=1(2nM@E0oP#t54%$_8^S$QN_qZRTqX4vg| z7GG;Qi7zn&;>)Zsty)sQ)cHnw669w}NlK^jNUV&OHz|)QdhvBg6Pg0pEG$iJ!Coi5 z_=Xo23)nu!yU7cSM%v36?`AJ79C$Y~?3-R#6!0!(*tfi}6u@p|df)cK3`lz;GzIqN+fAnI?340YSN8jM zgTZDq80=cwQKU^oy&-K3R@QYq(pA&e#tz4|YH7bMpL7)~iMAA@&1Os?HwU{T&)|(c z8jEEh-@;12a|UC6QNFCa5}Tft9$R1HoRctQJ-D!CZDpJ#+Y(!4ve}qBb8$8n4dbkZ z8Rn{znyfjc8Ra%hdUo;z$BL+MFHow%FXySv$oe@7B18Np7QB6nAoE+-R>WDdEOC1| z_qtMNSNve+!R4uTdum#C_V=?oof)ObH!CU4nPpDPh&AVDrXhPJeWIwS?{d6Pd_t}y z-EjIy=vvZ^G`sy#d%D?fH>X3Wg!@AoArFv5on9lNTKa!a%uhL4&K7Ymn*KvdIfsr2xpAm;*7G;r2uy^dItew?v^w5Wn6h>#=aS{UC!8V zx#9}T{uxq^UZ-!XZ;ftkXb-&p`oQ)E+}5}0uY=tMEoGc_gZBD?qXO!_FqZPiRXU6@ zhx+FC{Z`rLa`n#dbNNTiq&!3M?nAkw};QaYe6SVKw+_%VEbgk6B^%%G&%hvU- z2S#-FeuhafgeR9&5zsL+X=tNT`3z-Co|AGs6Qxj7SI;2 z=$K<=RaIqMD@sZ#j?RC7Z0y5v!IP0fllo1>2Q%sv(uGa4Ivth0M0BkdO%*-`#se$m+(zevl#ESVJH$g5n< zD~5J~A&*MwjIt}g0to83ctAr>L8S;$Kd`C$#hgx{j~H*P$t`@zUr1I^i~{0O&(gSikB ze&HK7ymxd&IWh8Czl!s(*juEPx3m5H9f;}ijiYB(yDG=!t;jc>o&l%-15S3>Dj$@$ z^Bm!G41bQ}(LdnLre6aeHlKC461%|BIB7~KU`Lu=7c|Y$SsDg=DaY+;!BNRE*S^x0 zZ8pPJqQ8(2!nVK5X)oAc=%xh~$_Ld%_O$6SNio+lKcZEhk-r6>G&!qBh;o3>WWe8L z_zMhwf#8%E;F*B?!~R&X78`Tq+G{slbye%HjvoCL#Z$T@HNHw>FPuL4s;f3!d#xG| zy2bL}Lfxy%ZQs2eFR8p+M7wODGivS~Z})AhUleR%k; z9rZPr+&#dOBRK2fu&t#wm%+4`=o zjb%gT=Dhp{b9!TLUL#s5F#?moT zS>;-f>GS*r6(!v{)`3=Mb)u;{t9nIq*wUzFH!W=0T$?@ER#ul_wODgzq#ESL>2ja3 z*pZmmmT7LtwKi3o5^{5slCtAUHue~o-q4ZTI;%(G7A#6tMQDi+GeFBxddSOM>5bTveqjZ1R#OHzyLFuJNLY&F?Z zOX}-NQj12)+tci+C3UzjUelJ+To-LNcV$tQ)80CL!-=Pwt^0l1L^5m zN(ljvsYDDubFMj z?p)nkF<4YISW(g!(-NMWY$!-cDKI4GhPTA@>41$T^r0fh;9jI2DstNAtgWhA-D$IR zt;V&>wy1DHdHI6EoQ&M~SWMZO9Z9kA_RL(g>WMPMU=6VdUk5p-98PN1Q?}h=(-fPk zEE(n9{3@rGAZ1DKl3z_JMbGy;`ujUNWGfA`TF>G9mYZ*0I+gjQa;&wgrg_`X%tm7x z`-(D2_n)vGq2!U^l>l`Yr>;PcH$m|Bfx3fL{=D@u%P(7HpA7$w^^Pdlfv?GZUxTT- z&2?O6xQ3+RqmNuCWN%B0>u#cHk(bDwXn(vOaqij)Ya_kp!dusVpF7KDo0Xf>VY9dA zCfVZSZApptgao_1#MW-lX}76iZGtT+DJMSOo}6UI2A7N3JjH7#={*X3SE)dU-KjdM z1g@Iq`is{#vVG)!Q1JIwxE~B(v~ zq7ffMXjJVS=E9Uq!Gro9+s^N;kj7(#9PH+D^O3&GQd0}hTNC$b(C>!r*ei7}M#uAtWGKOO>G{5ub45w0XNivdL_l?O2 zY~;SwbzidUK_%?$2TGXC>%q$X7CGiJ_PCx|?%@^dzuMSB3BZ@1s6K1NZY4w^-yQA2 z2~8%dAFXozMgDfTJnE|aPu?7e?=j14B}85iJP`qm8u?q9t?nDdhZ4j$La8@${#eiep**2n;Kj{`$mg|9eI!gVAY6rPjr=`3_xuhm^?Yt)z^Mq~wf@ zWbI1%p2$owSS*H=O!l$4AN3{|)+KL*`tDj&q9zsjkr0Nma=GV+n9EaDR2HF1d@ zX2{VW<+;l>PyY6j<+qmJy8OqhN&fT=YA>Em0@2^$uY?dWpx$-Kk5~Vgu(><}^tN-p z7;}*nDQBv3Rg#qEarqNh`{N@ca?S8CXctLEiIoqtg+@z<^RGAYHV}SC5aS+sby|_l z5}ll?bSfR?Y0m77s1%c&Ejy%=n3ia>%$D{Wxxz%y&weoh?Al{R@l>A(6EgSl=7li45&79Gd z!6(r{1IaR#VN?q()J+*{`_3rJi^N?kUs>Wx&B$xFTHEsq+N{>Lf=rt&(~^@TKk@}) zQZEBQ9v9c;_Li3R=H|{VL)chVR#sJAT3YQ|uYozTkw6qhI^8SmiXuM)_t=>cfwiV6 z3t9Qz=bnE)cZm!bz5e>qD_nQT^{(qlcJ!`JrV=Foq2ik@ju>YY?W2;8=bwMhb!JKC z4Wn0FFYKBc^lc1iuK&j=fyw|D+jUK3|B#o_moc2!my6B1HW6Y%n|b|rZq zP)6nBy46a$#Lxx9eVQryCg5KO{COXEjt1YWES4|m1{nVg@L`4VtxJhLq^sm6~1IMRHiris8%=lGR3-pffS%^{?Dozvu;<}{e8rZj7i<{hM=-qwkoF_>XlbH&Ch zmK_{CNNG210?uBgU%mhwjQhd61p{~*rcfKmZW+>bDTQ#zWS4G8>F=mDWn`FY*}qi8 zgUTFvyUwXvk&9`a%rZKXQwoi#vvJY`QC z_1DN_FJ_ru;JgWYB5xH=d8=?cQWH;8;W=)X_$FlX3#Coopv$0sNb=YN8xkfKiUVXx zW>Q>2Omb2}bYZ-qATdj6vxlTaM8-xGg@wi0QY_`5{f0YFZgM}O=%pgWXHS41e9_CD zh%ZO#)%|4Nw^?aM1{%gQIWjcC9uX5}j7W-&OH4_q56RAI{mb;AvcSN|u+W*K#r`>l zZ1_u{AL`bN5&RE|09p0wTcADaKCDD3T_hLkIY;H9Ij%cqE8ji4Se4CnIw7CJd#if9 zK_zIu8Z-_lpUKDl6ve`|bQ2_oTA?4}zK*!(C&i&=w79*>-y!GswRG1}t3jN~*C^ul z;k_ii1SeWPTa+_sV}x%hx--Fwj}=A7%NAKtn8^2Wn7l7jK8Q|;PLHpc6O|Zcjw_!7awz9c<+Oa+ zPY>Qm&qxL|t)j|6jU)MZ;wEIkam=G9ZbAlob&-(IVw5S=Tc*9bVEF>)LpnG)A2p8h z@x(zxJ+kI>zH!h}E$#v3d3i|p6Yv}Xt93P(=M8s0^zN6M-u)Ny!dG`?q5MpK-P2|k z$lGP%;2@$9x6}QQ@*|z%(VLy}=@FNsQP=$05jFl#%38S#``*s6wLH0Q%U0HArx>y_ zF?&z=3fuC>l@~szzql$kH8nQIWO|maaYmyuYK)6D8tGQM5^o1!n|3JA5^rp`u zMrH9Omr&fRhO4iGgTw{(U zA}Shhh8HV?VbNjn(M4qm@$pfG6_gL#2yJdkvo~8*Us#GiE4}i7avFOXr`idcxs%m~ z$cV_WsF>)m%t(tZP3cVyikTh~9&DW+WHlyf^12Is*?r1upChmBnqUrVS7lWyy4%$F zc%$b^yj}sB{9$tcbH&>AYnQC+U8nXx(05ONj_)h8zIgh^0BWy@_w;+%Maj>eJ}p3^ zS3|s~9}1B0H}LG~Kd|GUpFQ?DfOH@69-D`^viLa%^Oe8Hd}V-mE`Z`abDsg)TxgEl zATD>m#`@?eh|1|bag>~Qd<7sE>mSYdI_V(3sk#mS=U`m?1>ZJKlg+Y6u9Z9F74mEN zQtVIUdssV)#Cu3RcpqW4vQ4=O-;a1wIjIx6|5Mqu2e(n2@!iwuX}v7Tl447`lgY!_ zi6hI7Z5#uR98Aa?V;MUSJATNC65C2;IWdm{cShsQ9s-=&gFce!4!QZ_xMXhxL2(WBRuYCWFTy8)h4-4GRq` z4X+x`8jFpU#`*KrYOXB=NRr#ow%z0Sv-2b?E!t8-W8 z?#eyo^0;QZnq7ChcDataUG5@x+P%;Hn)|T(9rtnfC+^ekvmVi7_xL?CJ=LDYo;98x z&t}h!-}~F{8TGv4IplfIb0*J~w={1!Z%5uMd4KVmynb(y_X=;5cb&J_JLJ97yTg0H zd&)Q6SLs{i>+z+1xA^Y$?e!h0{}% zbXGRWE?Jfr$<1=NoR)8fnVrYvr{ppD75T7yOg!8k|C6){N62gjT@7i| zEhGkE2g0V}hlmrArkJ$hom89f4h*4Q$)Jpb3ZU-`@Cb%+x=pwqX@3PEEyrTyy9mTh z(ET3r04$C0NAQE>5@8h)@v%(HR0!`Y--<(|K=>sw3qMu9=6DoOAnnL|5$HZb+$d=j zt|Buzo(F9sVWA&%3b4mIN7#?>=dd3;OzHqeFQ;MUGteC18t`ukSCfTwma>~cxQVA} z6ob%#d>nT{#zi2)f-)};E7K^MN%w&2NS^R4!xj-Hu0W!ei3pDya(_s{^5glZi1}5)Z9uImjX3A_w zCi}tiyZSqWxA$l8&Pb(2!rw5~!?O+p&Z`JqP6MQtt^jrtr|>2wt^>-)z%eom5f4Jv zk5HF0xPEY@F-ljHCc1`rX&T{a)Ts^qyN%>BJ+Sr9P{gRFvsmyEh7k5)6ayjp1l}VZ zC)J__?fM4#;m;uhdu4R_=<|SRg9ZEDSiwgcmX^+ie1P}ES8*QD*LBlLNO%xZz53mJbZ{_X*`T!R9 zGB5<(GzBa!8?WJ*$$1H2Gf*aPs-CGjHX%*6+-!M_$Bq#$bB&hnI4CPKEBgs8jHUYs zcnY{1^?Ly50geHEz{|i8fM;Ox5~WySVSQsCD8@)y2kYqu*diA(ayk)){X>GzaR+7s zh$VJ1$~X}tFd3uEHJ*|Ptm&)a0gN&V6%;W|Rf9Abbm%!a0Ng5`4d$0qup&^e%Ee ztf(i+df0`}z{+|8DUv8<3bEbT$&^Cs46LjtU}HUvJQ*TmuERX>^z$4U*k2!j^>*gH zDg{1)@E$FfDxZb0{G|v@L(hd|PDq?{UJ07H<^kwqC6V#&u@o~d_IHk(56kdNu~Iny z|CHSb+3U$5YTAL4U8r2=>MEaCvU*C(!<^ zMX|~Wx`DUQ0FH~!YuokE!cq*v_I)XGu`@ZBEas(8LJm8habA)^-=aVNF3Tf9 z52PO@Z=+p*kCF0u%!bR!=h)>w4?VdGqujgjt+fIE1epJ$`ItF>hOfI9qHfG1!A6|n zKC%<=291SJ9)4n#Y=sd>0i`~34A%6AzzZODEDT9C+ADyr>BI% zNH*>a4!qS0!DoUIkh22&^CN_*SE_}9Kim}~EUc@*V!}FIFPs8WJYvgsJ$CZ5v=6}N&Gk$`5-F@T^8o~2=JG?mD36Bg$8im)jpJA24&iz;& zK<^3@3I2RK;Ol1rt$C53TWBj?MOV``bS+HWwbOO9gLcv`+D#K!_E}Fi z&?MbRH_=|&N48=A`~A3&{0Tm{J|G|BIVnXq8y0k?6I&9_YdX3HGl}-arOc@koI#nB zoP#SUXHt#YI1yzW>`z9bAr?OXwf&4qB;g%}CFoCb0cI|bIe;=TiJW0F8DmkUGSEP*99h_Bl@Pu3k<9Pc-VwY(&s?l13+AE76}xU_U%>b~x-RxNvZ3SI0oY(3kAT=`#!@ zx>Eh!;#Gs`6qgp=HZSChV<&o+fc0NIYNETm~#aC!)tIX@u z9b2HJuTzVSAXkN%_mHg6G8U@kQR`hEsnm`am@~=V?ra(5OuKtBw0%-XR@hVxEYF07 z3au;^OimU*Q5zOsX4{ZTZR+Susin7er!t+1-qcoBXtiKbtwTrE6INbcZPglvH?cFT zm8J%iO%15v0rts?WXJOsQ{yKql^xI9PK}>zlI(ciT0Fo%8Q*?ENxY4CO06bpv9y}- z*2)&l0wPlb)T+6@*LXZlZgvZ1DJh?qeNfF0%xc?Jl_nh%P0EHDc$mvQ*F+N`=c+Oh zGFeT2DIk|7rDEbZ@B78m^4TYJ_46*T9Y#ylLw?eu7`<4kXe_4E`g(l4R9i7We%Y*U zsd|-X+q*7Ut=17V(JETY@p>C-- zs7RLvo1~(EB88_ZlANzd5@^V)NI{<>1wadGrzxxbwO(a)KI43h%bQl`RaUu2T=Coy zXWZ55Y_*M8M66jxBR>duznL-7Z*K z4Xv_)jvL@T%YhsE1DGhBAo>%89wb!4E}nMWi+@4%<~gRgZjKN)wF<2cK@zTnQHxuJ z*M$>;aX%)`b#;1rfNmdaDp@jC)wpELymIvz9Uhx;9s3X86k~?rF%n<3y5$8*x3%7N z|NSHwTQau&x|SDp1T5SY2=&*tyddhfwZ;Z8Zz(A$!B&$DDPh2W7Jwi%M*WYw^yD9+ 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 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 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 From a7e16e7bf668ff06158f95e13843413d43a55cd4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 18:19:32 +0200 Subject: [PATCH 03/17] =?UTF-8?q?feat(crm):=20frontend=20code=20=E2=80=94?= =?UTF-8?q?=20pages=20+=20services=20+=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in the full frontend CRM stack: admin authoring pages, customer-portal surfaces, public response flows, typed services, and the supporting component library. i18n locale JSON is the next commit (kept separate so reviewers can read it as data). Pages - Quotes: list / editor / detail / public accept-decline - Invoices: list / editor / detail / public payment-check - Contracts: list / editor / detail / block library / public sign - Calendar (FullCalendar — admin-only v1) - Tax report (period picker + CSV/PDF export) - Hours (logged time entries, per-customer) - Deals lineage (DocumentLineageCard surfaces) - CRM Development (admin dev tools, gated by crmDevelopment flag) - Customer-portal pages for quotes / invoices / contracts - Settings reorg: CRM-Settings group + dedicated tabs for Business Profile, CRM behaviour, Contracts block library, Reminder emails - BrandingPage typography (PDF font picker) - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions (event-time fields, hours toggle, per-event reminder override) Services (typed) - quotes.service, bills.service, contracts.service - customerAdmin.service, deals.service, calendar.service, taxReport.service, contracts-blocks.service - businessProfile.service (timezone, font picker, bank accounts) - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension Components (admin) - CustomerPicker (shared across quote/invoice/contract editors) - LineItemsTable (hierarchy + details_text, memoised pricing) - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger) - DocumentLineageCard (deal_uuid grouped view) - EditInstallmentPlanModal (atomic post-spawn plan reshape) - EventReminderOverrideCard, EmailTemplateEditor (tiptap), PdfFontPicker, IntegrityCheckCard - Feature-flag context + RequireFeature wrapper + AdminSidebar featureFlagsAny derivation + UI-hiding sweep Build infra - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded) - frontend/package.json: tiptap, fullcalendar, signature_pad, react-international-phone, et al. - tailwind + prose styles updated for editor surfaces 3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern over feat/crm's hardcoded option list; feat/crm's DecimalInput import preserved alongside. --- frontend/package-lock.json | 121 +- frontend/package.json | 6 + frontend/src/App.tsx | 105 +- .../src/components/admin/AdminGuestDetail.tsx | 4 +- .../src/components/admin/AdminGuestsList.tsx | 4 +- frontend/src/components/admin/AdminHeader.tsx | 109 +- frontend/src/components/admin/AdminLayout.tsx | 52 +- .../src/components/admin/AdminSidebar.tsx | 160 ++- .../src/components/admin/ClientsLayout.tsx | 75 +- .../components/admin/CrmOverviewSection.tsx | 272 +++++ .../components/admin/CssTemplateEditor.tsx | 4 +- .../components/admin/CustomerCrmPanels.tsx | 221 ++++ .../src/components/admin/CustomerPicker.tsx | 214 ++++ .../components/admin/DocumentLineageCard.tsx | 313 +++++ .../admin/EditInstallmentPlanModal.tsx | 209 ++++ .../admin/EventReminderOverrideCard.tsx | 177 +++ .../src/components/admin/HoursSection.tsx | 407 +++++++ .../components/admin/InlineCustomerCreate.tsx | 360 ++++++ .../components/admin/InstallmentsPanel.tsx | 320 +++++ .../src/components/admin/LineItemsTable.tsx | 481 ++++++++ .../components/admin/PdfTypographyCard.tsx | 102 ++ .../admin/ThemeCustomizerEnhanced.tsx | 14 +- .../src/components/common/DecimalInput.tsx | 108 ++ .../src/components/gallery/GalleryLayout.tsx | 10 +- frontend/src/contexts/CustomerAuthContext.tsx | 29 +- frontend/src/contexts/FeatureFlagsContext.tsx | 30 +- .../settings/hooks/useSettingsState.ts | 11 +- .../features/settings/tabs/ApiTokensTab.tsx | 6 +- .../features/settings/tabs/FeaturesTab.tsx | 126 +- .../src/features/settings/tabs/GeneralTab.tsx | 21 + .../src/features/settings/tabs/StatusTab.tsx | 4 +- .../features/settings/tabs/WebhooksTab.tsx | 6 +- frontend/src/hooks/useInstallmentDefaults.ts | 51 + frontend/src/hooks/useLocalizedDate.ts | 87 +- frontend/src/hooks/usePublicDarkMode.ts | 43 + frontend/src/index.css | 2 +- frontend/src/pages/admin/AdminDashboard.tsx | 22 +- frontend/src/pages/admin/BrandingPage.tsx | 87 +- frontend/src/pages/admin/CMSPage.tsx | 4 +- frontend/src/pages/admin/CreateEventPage.tsx | 52 + .../src/pages/admin/CustomerDetailPage.tsx | 528 +++++++- .../pages/admin/CustomerManagementPage.tsx | 383 ++---- frontend/src/pages/admin/EmailConfigPage.tsx | 7 + frontend/src/pages/admin/EventDetailsPage.tsx | 20 +- frontend/src/pages/admin/SettingsPage.tsx | 55 +- .../src/pages/admin/WebhookDeliveriesPage.tsx | 4 +- .../src/pages/admin/bills/BillDetailPage.tsx | 529 ++++++++ .../src/pages/admin/bills/BillEditorPage.tsx | 690 +++++++++++ .../src/pages/admin/bills/BillsListPage.tsx | 504 ++++++++ .../src/pages/admin/clients/CalendarPage.tsx | 737 +++++++++++ .../admin/clients/CrmDevelopmentPage.tsx | 319 +++++ .../clients/HourEntryDragCreateModal.tsx | 346 ++++++ .../admin/clients/HourEntryInlinePopover.tsx | 243 ++++ .../pages/admin/clients/HoursLoggingPage.tsx | 130 ++ .../src/pages/admin/clients/TaxReportPage.tsx | 449 +++++++ .../admin/contracts/BlockLibraryPage.tsx | 535 ++++++++ .../admin/contracts/ContractDetailPage.tsx | 1074 +++++++++++++++++ .../admin/contracts/ContractEditorPage.tsx | 535 ++++++++ .../admin/contracts/ContractsListPage.tsx | 202 ++++ frontend/src/pages/admin/index.ts | 12 +- .../pages/admin/quotes/QuoteDetailPage.tsx | 267 ++++ .../pages/admin/quotes/QuoteEditorPage.tsx | 650 ++++++++++ .../src/pages/admin/quotes/QuotesListPage.tsx | 171 +++ .../pages/admin/settings/CrmSettingsPage.tsx | 433 +++++++ .../admin/settings/ReminderTemplatesPage.tsx | 471 ++++++++ .../settings/SettingsBusinessProfilePage.tsx | 529 ++++++++ .../src/pages/customer/CustomerBillsPage.tsx | 342 +++++- .../pages/customer/CustomerContractsPage.tsx | 243 ++++ .../src/pages/customer/CustomerLayout.tsx | 47 +- .../src/pages/customer/CustomerQuotesPage.tsx | 288 ++++- frontend/src/pages/customer/index.ts | 1 + .../src/pages/public/AcceptInvitePage.tsx | 25 +- .../src/pages/public/ContractResponsePage.tsx | 496 ++++++++ .../src/pages/public/PaymentCheckPage.tsx | 405 +++++++ .../src/pages/public/QuoteResponsePage.tsx | 380 ++++++ frontend/src/services/bills.service.ts | 388 ++++++ .../src/services/businessProfile.service.ts | 157 +++ frontend/src/services/calendar.service.ts | 99 ++ frontend/src/services/contracts.service.ts | 455 +++++++ frontend/src/services/customer.service.ts | 127 ++ .../src/services/customerAdmin.service.ts | 220 ++++ frontend/src/services/deals.service.ts | 38 + frontend/src/services/devTools.service.ts | 46 + frontend/src/services/email.service.ts | 16 + frontend/src/services/featureFlags.service.ts | 24 +- frontend/src/services/paymentCheck.service.ts | 55 + .../src/services/publicSettings.service.ts | 16 +- frontend/src/services/quotes.service.ts | 396 ++++++ frontend/src/services/settings.service.ts | 24 +- frontend/src/services/taxReport.service.ts | 107 ++ frontend/src/types/index.ts | 5 + frontend/src/utils/calendarPrefs.ts | 52 + frontend/src/utils/dateShort.ts | 20 + frontend/src/utils/money.ts | 106 ++ frontend/src/utils/parsers.ts | 101 ++ frontend/vite.config.ts | 11 + 96 files changed, 18370 insertions(+), 572 deletions(-) create mode 100644 frontend/src/components/admin/CrmOverviewSection.tsx create mode 100644 frontend/src/components/admin/CustomerCrmPanels.tsx create mode 100644 frontend/src/components/admin/CustomerPicker.tsx create mode 100644 frontend/src/components/admin/DocumentLineageCard.tsx create mode 100644 frontend/src/components/admin/EditInstallmentPlanModal.tsx create mode 100644 frontend/src/components/admin/EventReminderOverrideCard.tsx create mode 100644 frontend/src/components/admin/HoursSection.tsx create mode 100644 frontend/src/components/admin/InlineCustomerCreate.tsx create mode 100644 frontend/src/components/admin/InstallmentsPanel.tsx create mode 100644 frontend/src/components/admin/LineItemsTable.tsx create mode 100644 frontend/src/components/admin/PdfTypographyCard.tsx create mode 100644 frontend/src/components/common/DecimalInput.tsx create mode 100644 frontend/src/hooks/useInstallmentDefaults.ts create mode 100644 frontend/src/hooks/usePublicDarkMode.ts create mode 100644 frontend/src/pages/admin/bills/BillDetailPage.tsx create mode 100644 frontend/src/pages/admin/bills/BillEditorPage.tsx create mode 100644 frontend/src/pages/admin/bills/BillsListPage.tsx create mode 100644 frontend/src/pages/admin/clients/CalendarPage.tsx create mode 100644 frontend/src/pages/admin/clients/CrmDevelopmentPage.tsx create mode 100644 frontend/src/pages/admin/clients/HourEntryDragCreateModal.tsx create mode 100644 frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx create mode 100644 frontend/src/pages/admin/clients/HoursLoggingPage.tsx create mode 100644 frontend/src/pages/admin/clients/TaxReportPage.tsx create mode 100644 frontend/src/pages/admin/contracts/BlockLibraryPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractDetailPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractEditorPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractsListPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuoteDetailPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuoteEditorPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuotesListPage.tsx create mode 100644 frontend/src/pages/admin/settings/CrmSettingsPage.tsx create mode 100644 frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx create mode 100644 frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx create mode 100644 frontend/src/pages/customer/CustomerContractsPage.tsx create mode 100644 frontend/src/pages/public/ContractResponsePage.tsx create mode 100644 frontend/src/pages/public/PaymentCheckPage.tsx create mode 100644 frontend/src/pages/public/QuoteResponsePage.tsx create mode 100644 frontend/src/services/bills.service.ts create mode 100644 frontend/src/services/businessProfile.service.ts create mode 100644 frontend/src/services/calendar.service.ts create mode 100644 frontend/src/services/contracts.service.ts create mode 100644 frontend/src/services/deals.service.ts create mode 100644 frontend/src/services/devTools.service.ts create mode 100644 frontend/src/services/paymentCheck.service.ts create mode 100644 frontend/src/services/quotes.service.ts create mode 100644 frontend/src/services/taxReport.service.ts create mode 100644 frontend/src/utils/calendarPrefs.ts create mode 100644 frontend/src/utils/dateShort.ts create mode 100644 frontend/src/utils/money.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce58fe8c..9aac8719 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,18 @@ { "name": "picpeak-frontend", - "version": "3.44.1-beta.0", + "version": "3.47.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.44.1-beta.0", + "version": "3.47.2-beta.0", "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^2.26.1", @@ -45,6 +50,7 @@ "react-photo-album": "^3.4.0", "react-router-dom": "^6.8.0", "react-toastify": "11.0.5", + "signature_pad": "^5.1.3", "swiper": "^12.1.0", "tailwind-merge": "^3.3.1", "yet-another-react-lightbox": "^3.28.0" @@ -150,7 +156,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -516,7 +521,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -540,7 +544,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1151,6 +1154,56 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", + "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2808,6 +2861,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2817,7 +2871,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2886,7 +2941,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2983,7 +3037,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz", "integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3262,7 +3315,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3349,7 +3401,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3480,7 +3533,6 @@ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -3496,7 +3548,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3508,7 +3559,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3586,7 +3636,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -3953,7 +4002,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4004,6 +4052,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4219,7 +4268,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4704,6 +4752,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4893,7 +4942,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5693,7 +5741,6 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -5796,7 +5843,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5960,7 +6006,6 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6299,7 +6344,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6590,7 +6634,6 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "license": "MIT", - "peer": true, "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", @@ -6626,6 +6669,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7241,7 +7285,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7289,7 +7332,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7433,6 +7475,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7449,6 +7501,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7464,6 +7517,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7476,7 +7530,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -7617,7 +7672,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -7647,7 +7701,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -7696,7 +7749,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -7757,7 +7809,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7796,7 +7847,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8243,6 +8293,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signature_pad": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.3.tgz", + "integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8711,8 +8767,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -8733,7 +8788,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8855,7 +8909,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index ce08812e..5f5a143c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,11 @@ "i18n:ci": "i18next-cli extract --ci --dry-run" }, "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^2.26.1", @@ -52,6 +57,7 @@ "react-photo-album": "^3.4.0", "react-router-dom": "^6.8.0", "react-toastify": "11.0.5", + "signature_pad": "^5.1.3", "swiper": "^12.1.0", "tailwind-merge": "^3.3.1", "yet-another-react-lightbox": "^3.28.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b96d4b1..d6bd48d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastContainer } from 'react-toastify'; @@ -24,8 +24,29 @@ import { UserManagementPage, CustomerManagementPage, CustomerDetailPage, - WebhookDeliveriesPage + WebhookDeliveriesPage, + // CRM routes (#TBD) — feature-flagged at the route layer via RequireFeature. + QuotesListPage, + QuoteEditorPage, + QuoteDetailPage, + BillsListPage, + BillEditorPage, + BillDetailPage, } from './pages/admin'; +import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage'; +import { TaxReportPage } from './pages/admin/clients/TaxReportPage'; +import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; +// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle +// (carved into its own chunk in vite.config.ts) doesn't ship with the +// main app. Only pages that visit /admin/clients/calendar fetch it. +const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage }))); +import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; +import { ContractResponsePage } from './pages/public/ContractResponsePage'; +import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; +import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; +import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; +import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage'; +import { PaymentCheckPage } from './pages/public/PaymentCheckPage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { CustomerLoginPage, @@ -36,13 +57,14 @@ import { CustomerCalendarPage, CustomerQuotesPage, CustomerBillsPage, + CustomerContractsPage, CustomerResetPasswordPage, } from './pages/customer'; import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; import { RequireFeature } from './components/admin/RequireFeature'; -import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { usePublicSettings } from './hooks/usePublicSettings'; @@ -172,12 +194,67 @@ function App() { } /> } /> + {/* Quotes (CRM) — gated by `quotes`. */} + }> + } /> + } /> + } /> + } /> + + {/* Bills / invoices (CRM) — gated by `bills`. */} + }> + } /> + } /> + } /> + } /> + + + {/* Contracts (CRM) — gated by `contracts`. Independent + of quotes/bills; a free-standing legal document + type composed from a library of reusable blocks. */} + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Hour logging (standalone surface) — gated by + `hoursLogging`. Independent of `bills` so admin + can log hours before the full billing surface + is enabled. */} + }> + } /> + + + {/* Admin calendar (migration 137) — gated by + `calendar`. Lazy-loaded so the FullCalendar + bundle stays out of the main chunk. */} + }> + }> + + + } + /> + + + {/* Tax / Steuer report — gated by `taxReport`. */} + }> + } /> + + {/* Developer tools — gated by `crmDevelopment`. */} + }> + } /> + {/* Default: send /admin/clients (no sub-path) to - the first available sub-feature. Today that's - always accounts; when calendar/quotes ship they - get their own routes here and the empty-state - in ClientsLayout handles the rare "parent on, - all children off" case. */} + the first enabled sub-feature. accounts comes + first because it predates the others. The empty + state inside ClientsLayout handles "parent on, + all children off". */} } /> @@ -209,6 +286,17 @@ function App() { {/* Public invitation acceptance page */} } /> + {/* Public quote accept/decline page (CRM). Token-only, + no auth required. */} + } /> + } /> + + {/* Admin payment-check page (CRM) — token only, + no auth. Reached from the "Paid in full / + Partial / Not paid" buttons in the payment- + check email. */} + } /> + {/* Customer surface (#354). Strictly separate provider / cookie / API surface from /admin/*. The customerPortal feature flag hides the *admin-side* surfaces (sidebar @@ -237,6 +325,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminGuestDetail.tsx b/frontend/src/components/admin/AdminGuestDetail.tsx index 0053e28e..b1a1c60f 100644 --- a/frontend/src/components/admin/AdminGuestDetail.tsx +++ b/frontend/src/components/admin/AdminGuestDetail.tsx @@ -6,6 +6,7 @@ import { Loading } from '../common'; import { guestsService, AdminGuest } from '../../services/guests.service'; import { AuthenticatedImage } from '../common/AuthenticatedImage'; import { buildResourceUrl } from '../../utils/url'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; interface AdminGuestDetailProps { eventId: number; @@ -17,6 +18,7 @@ type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; export const AdminGuestDetail: React.FC = ({ eventId, guest, onClose }) => { const { t } = useTranslation(); + const { formatDateTime: fmtDateTime } = useLocalizedDate(); const [tab, setTab] = useState('all'); const { data, isLoading } = useQuery({ @@ -159,7 +161,7 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue />

diff --git a/frontend/src/components/admin/AdminGuestsList.tsx b/frontend/src/components/admin/AdminGuestsList.tsx index 1f7201ca..53344802 100644 --- a/frontend/src/components/admin/AdminGuestsList.tsx +++ b/frontend/src/components/admin/AdminGuestsList.tsx @@ -8,6 +8,7 @@ import { AdminGuestDetail } from './AdminGuestDetail'; import { GuestSelectionsAggregate } from './GuestSelectionsAggregate'; import { GuestInviteDialog } from './GuestInviteDialog'; import { toast } from 'react-toastify'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; interface AdminGuestsListProps { eventId: number; @@ -18,6 +19,7 @@ type View = 'list' | 'aggregate'; export const AdminGuestsList: React.FC = ({ eventId, eventName }) => { const { t } = useTranslation(); + const { format: fmtDate } = useLocalizedDate(); const queryClient = useQueryClient(); const [view, setView] = useState('list'); const [selectedGuest, setSelectedGuest] = useState(null); @@ -271,7 +273,7 @@ export const AdminGuestsList: React.FC = ({ eventId, event {guest.stats.ratings} - {new Date(guest.last_seen_at).toLocaleDateString()} + {fmtDate(guest.last_seen_at)}
diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 5b7e3794..7217f825 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -35,10 +35,30 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak'; const logoUrl = brandingSettings?.branding_logo_url?.trim(); const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text'; + // Logo placement honours the same Branding > Logo Position setting + // the gallery does. 'sidepanel' moves the logo into the AdminSidebar + // brand row — suppress it here so it doesn't double up. left / + // center / right reposition the logo block within this header bar. + const logoPosition = brandingSettings?.branding_logo_position || 'left'; + const logoInSidebar = logoPosition === 'sidepanel'; const resolvedLogoUrl = logoUrl ? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl)) : '/picpeak-kamera-transparent.png'; + // Renders the logo + wordmark block per the current logo_display_mode. + // Re-used in left / center / right slots below so all three positions + // produce visually identical brand chrome. + const renderBrandBlock = () => ( +
+ {!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )} + {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )} +
+ ); + const userMenuRef = useRef(null); const notificationRef = useRef(null); @@ -79,10 +99,25 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const unreadCount = notificationsData?.unreadCount || 0; return ( -
-
-
- {/* Left side - Menu button, Logo, and Date */} + // border-b is on the OUTER
so it spans the full header + // width — putting it on the inner row instead left a 32px gap on + // the left (the px-4/sm:px-6/lg:px-8 padding) before the divider + // started, visible as a missing segment between the sidebar's + // brand-row bottom border and the header's bottom border. + // + // The outer header is explicitly h-16 with the default border-box, + // so the 1px border is painted INSIDE the 64px height (y=63..64) + // — same model as the sidebar's brand row (also h-16 border-b + // border-box). Both bottom borders meet at the exact same + // y-coordinate. +
+
+
+ {/* Left side - Menu button, optional left-positioned logo, Date. + Logo block appears here when logo_position = 'left' (the + default). For 'center' it's absolutely positioned across + the whole header; for 'right' it sits in the right-side + cluster just before the action widgets. */}
- {/* Logo - sticky to the left on all sizes */} -
- {(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} - )} - {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} - )} -
+ {!logoInSidebar && logoPosition === 'left' && renderBrandBlock()} + {/* Narrow-viewport fallback for center / right positions. + On screens where the centered (lg+) or right-anchored + (md+) brand block is hidden, render it on the left so + the admin chrome doesn't go logo-less on phones. */} + {!logoInSidebar && logoPosition === 'center' && ( +
{renderBrandBlock()}
+ )} + {!logoInSidebar && logoPosition === 'right' && ( +
{renderBrandBlock()}
+ )} - {/* Date display - hidden on smaller screens */} -
-

+ {/* Date display - hidden on smaller screens. + The vertical divider + left padding only render when + the logo sits on the left of the date (logo_position + = 'left'). For 'center' / 'right' / 'sidepanel' the + left cluster has only the mobile menu (which is + hidden on xl+), so a divider would be floating on + its own with nothing to separate. */} + {/* Explicit `flex items-center` (not just block) + the + self-stretch on the border-l variant so the divider + covers the full header row, AND the text baseline sits + exactly on the same y-axis as the brand-block / sidebar + logo to its left. The previous `hidden xl:block` left + the

inheriting its block-level vertical position, + which read as slightly off-centre next to the larger + logo image. */} +

+

{format(new Date(), 'PPPP')}

- {/* Right side actions */} + {/* Centered logo. Absolutely positioned so the existing + left/right clusters keep their natural sizing; hidden on + sub-lg widths to avoid colliding with the right-side + action cluster on narrow screens. pointer-events-none on + the wrapper passes hover/click through (the logo itself + has no interactive children today). */} + {!logoInSidebar && logoPosition === 'center' && ( +
+ {renderBrandBlock()} +
+ )} + + {/* Right side actions (preceded by the brand block when + logo_position = 'right'). The right-anchored logo sits + before the language / dark-mode / notifications / user + cluster so the widgets stay where admins expect them. */}
+ {!logoInSidebar && logoPosition === 'right' && ( +
+ {renderBrandBlock()} +
+ )} {/* Language Selector */} diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 25d6850b..7eef8a12 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -9,10 +9,23 @@ import { AdminHeader } from './AdminHeader'; import { MaintenanceBanner } from './MaintenanceBanner'; import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; +const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; + export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); - + const [sidebarCollapsed, setSidebarCollapsedState] = useState(() => { + if (typeof window === 'undefined') return false; + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; + }); + + const setSidebarCollapsed = (v: boolean) => { + setSidebarCollapsedState(v); + if (typeof window !== 'undefined') { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '1' : '0'); + } + }; + // Handle session timeout useSessionTimeout(); @@ -40,6 +53,8 @@ export const AdminLayout: React.FC = () => { @@ -49,10 +64,12 @@ export const AdminLayout: React.FC = () => { interface AdminLayoutInnerProps { sidebarOpen: boolean; setSidebarOpen: (v: boolean) => void; + sidebarCollapsed: boolean; + setSidebarCollapsed: (v: boolean) => void; mustChangePassword: boolean; } -const AdminLayoutInner: React.FC = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => { +const AdminLayoutInner: React.FC = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => { return (
{/* Mandatory Password Change Modal */} @@ -68,21 +85,40 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid {/* Sidebar - disabled when password change required */}
- setSidebarOpen(false)} /> + setSidebarOpen(false)} + collapsed={sidebarCollapsed} + onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} + />
- {/* Main content */} -
+ {/* Main content. `scrollbar-gutter: stable` on the column itself + (via the inline style) reserves the scrollbar gutter once at + the column level — so the header sits in the full column + width AND lines up with the sidebar's right edge, while +
's scroll content honors the same gutter and never + shifts when content overflows. Without this, the header and + main each made their own decisions about the gutter, leaving + a visible ~15px notch on the right edge of the header's + border between the column's content area and the scrollbar. */} +
{/* Header - disabled when password change required */}
setSidebarOpen(true)} />
- + {/* Maintenance mode banner */} - {/* Page content - disabled when password change required */} -
+ {/* Page content - disabled when password change required. + overflow moved up to the column so the scrollbar gutter is + reserved once at the column level (see above). main now + just contributes its content + padding. */} +
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index ffff92df..1f81e94b 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -9,6 +9,8 @@ import { X, Users, Briefcase, + PanelLeftClose, + PanelLeftOpen, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -16,10 +18,16 @@ import { settingsService } from '../../services/settings.service'; import { VersionInfo } from './VersionInfo'; import { usePermissions } from '../../contexts/PermissionsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { buildResourceUrl } from '../../utils/url'; interface AdminSidebarProps { isOpen: boolean; onClose: () => void; + /** Desktop-only: collapse to icon-rail when true. Persisted by parent. */ + collapsed?: boolean; + /** Desktop-only: toggle for the collapse button rendered in the title bar. */ + onToggleCollapse?: () => void; } interface NavItem { @@ -74,18 +82,45 @@ const navigation: NavItem[] = [ permission: 'customers.view', featureFlag: 'clients', // Hide the entry when the parent is on but no sub-feature is — - // there's nothing inside ClientsLayout to link to. Add future - // sub-flags (calendar, quotes, bills, messaging) here as they - // ship; the entry reappears the moment any of them is enabled. - featureFlagsAny: ['customerPortal'], + // there's nothing inside ClientsLayout to link to. Mirror the same + // set used to derive the parent `clients` flag in + // FeatureFlagsContext (see clientsDependsOn) so the two checks + // can't disagree: any sub-feature on lights up the entry, all off + // hides it. Future siblings (e.g. `messaging`) get appended here + // AND in the context derivation. + featureFlagsAny: [ + 'customerPortal', 'crmDevelopment', 'quotes', 'bills', + 'taxReport', 'hoursLogging', 'contracts', 'calendar', + ], }, ]; -export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { +export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { const location = useLocation(); const { t } = useTranslation(); const { hasPermission } = usePermissions(); const { flags } = useFeatureFlags(); + // Branding lookup for the "logo_position = sidepanel" mode — when + // chosen, the logo replaces the "PicPeak Admin" text in the brand + // row, and the favicon takes over in the collapsed icon rail. + const { data: publicSettings } = usePublicSettings(); + const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel'; + const rawLogoUrl = publicSettings?.branding_logo_url?.trim(); + const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim(); + const resolvedLogoUrl = rawLogoUrl + ? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl)) + : null; + const resolvedFaviconUrl = rawFaviconUrl + ? (rawFaviconUrl.startsWith('http') ? rawFaviconUrl : buildResourceUrl(rawFaviconUrl)) + : null; + // In collapsed rail, prefer the favicon (it's already a square, + // tight crop). Fall back to the logo when no favicon is set, then + // to nothing — better an empty rail than a stretched logo. + const sidebarBrandImageUrl = collapsed + ? (resolvedFaviconUrl || resolvedLogoUrl) + : (resolvedLogoUrl || resolvedFaviconUrl); + const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl; + const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title'); const filteredNavigation = navigation.filter((item) => { if (item.permission && !hasPermission(item.permission as string)) return false; @@ -101,38 +136,99 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) = return true; }); + // Desktop width: full nav (w-64) vs icon rail (w-16). Mobile is always + // w-64 since the collapse affordance only applies on lg+ viewports. + const widthClasses = collapsed ? 'w-64 lg:w-16' : 'w-64'; + const showLabels = !collapsed; + return (
- {/* Brand */} -
-
- {t('admin.title')} + {/* Brand row: title on the left, mobile close (X) on the + right. The desktop collapse toggle used to live here but + was moved down next to the version / storage widgets so + it sits in admins' muscle-memory zone for chrome controls. + When collapsed on desktop the title hides and the row + becomes an empty spacer (no rail-width fight). */} +
+
+ {showLogoBrand ? ( + <> + {/* Logo brand variant — fed by Branding > Logo + Position = "Sidebar". On the collapsed rail, only + the favicon (or logo as fallback) is shown — sized + to fit the 64px-wide rail. Expanded shows the full + logo at the same h-8 the admin header uses for + visual continuity. */} + {brandAlt} + {/* On mobile the rail-narrow style only applies at + lg+, so when collapsed=true the mobile view still + has the regular w-64 width — show the company name + next to the logo so the brand row doesn't feel + empty there. */} + {collapsed && ( + + {brandAlt} + + )} + + ) : ( + <> + {showLabels && ( + {t('admin.title')} + )} + {/* When collapsed on desktop the title is hidden; on mobile we + always show it because the rail-narrow style only applies at lg+ */} + {collapsed && ( + {t('admin.title')} + )} + + )}
{/* Navigation */} -