feat(crm): backend code — services + routes + utilities + tests

Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
This commit is contained in:
Luca
2026-05-26 18:18:51 +02:00
parent 60abe8c76d
commit d543949188
91 changed files with 23578 additions and 123 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* Shared render-context helpers for the three document services
* (quoteService, invoiceService, contractService).
*
* **Why this file exists**
*
* The audit flagged that the issuer + recipient blocks of
* `buildRenderContext` were copy-pasted across all three services and
* had already drifted (contractService's recipient gated `attentionLine`
* on `trimmedCompany` while quote/invoice fire it whenever a person
* name is present). The PDF renderer happens to gate on `hasCompany`
* downstream, so neither variant produced wrong output — but the drift
* is a maintenance trap and any future renderer change relying on the
* raw string would fail surprisingly on one document type.
*
* Two helpers live here:
*
* - `buildIssuerBlock(profile, resolvedLogoPath, options?)` — the
* full issuer shape consumed by pdfService.drawIssuerBlock. Honors
* the existing pdf_show_logo / pdf_show_company_name visibility
* toggles, logo-height, folding-marks, etc. `options.quoteToggles`
* adds the two quote-only fields (`quoteShowNetDays`,
* `quoteShowSkonto`) — they are silently dropped for invoice and
* contract callers so the same helper serves all three doc types.
*
* - `buildRecipientBlock(profile, customer)` — the recipient address
* block. Honors the maintainer spec: companies bold the company
* name on line 1 + "z. Hd. <person>" on line 2; private customers
* bold the person name on line 1 with no z.Hd. line at all. The
* attentionLine string is always populated when a person+salutation
* exists (the downstream renderer gates emission on hasCompany);
* keeping the string non-empty preserves the back-pointer for
* audit/debug surfaces that read the context directly.
*
* **What stayed in each service**
*
* Doc-type-specific fields (line items, totals, payment-term resolution,
* Skonto fallback chain, doc/title block, contract signatures + audit
* trail, source-quote line-items table) all stay where they are. Only
* the issuer + recipient blocks are extracted, since those are
* verbatim duplicates across all three services.
*/
/**
* Build the `issuer` field for a render context. The shape mirrors the
* legacy inline construction exactly so existing callers + the
* pdfService.drawIssuerBlock consumer don't need to change.
*
* @param {object} profile business_profile row (may be empty)
* @param {string|null} logoPath pre-resolved absolute logo path (see resolveLogoFile)
* @param {object} [options]
* @param {boolean} [options.quoteToggles] include pdf_quote_show_net_days
* + pdf_quote_show_skonto fields
* @returns {object}
*/
function buildIssuerBlock(profile, logoPath, options = {}) {
if (!profile) return {};
const base = {
companyName: profile.company_name,
addressLine1: profile.address_line1,
addressLine2: profile.address_line2,
postalCode: profile.postal_code,
city: profile.city,
state: profile.state,
countryCode: profile.country_code,
phone: profile.phone,
mobile: profile.mobile,
email: profile.email,
website: profile.website,
footerLine: profile.footer_line,
vatId: profile.vat_id,
// Steuernummer (migration 139). Rendered alongside VAT-ID on the
// PDF issuer block — §14 UStG requires one or both on every
// invoice. Kleinunternehmer without a USt-IdNr. carry only this.
taxId: profile.tax_id || null,
// pre-resolved absolute path; renderer never re-resolves.
logoPath,
pdfFontTtfPath: profile.pdf_font_ttf_path,
// Bundled fonts dropdown (migration 121). When set, pdfService loads
// <family>/400.ttf + <family>/700.ttf from backend/assets/fonts/.
// Priority: pdfFontTtfPath wins if both are present.
pdfFontFamily: profile.pdf_font_family || null,
// Free-text country name override (migration 107).
countryName: profile.country_name || null,
// Visibility toggles (migration 106). Default true when the column
// is missing on older installs that haven't migrated yet — keeps
// the previously implicit "always show" behavior pinned.
showLogo: profile.pdf_show_logo == null ? true
: (profile.pdf_show_logo === true || profile.pdf_show_logo === 1 || profile.pdf_show_logo === '1'),
showCompanyName: profile.pdf_show_company_name == null ? true
: (profile.pdf_show_company_name === true || profile.pdf_show_company_name === 1 || profile.pdf_show_company_name === '1'),
// Layout customisation (migration 108).
logoHeight: profile.pdf_logo_height == null ? 56 : Number(profile.pdf_logo_height),
companyNameInline: profile.pdf_company_name_inline === true || profile.pdf_company_name_inline === 1 || profile.pdf_company_name_inline === '1',
foldingMarks: profile.pdf_folding_marks || 'none',
};
if (options.quoteToggles) {
// Quote payment-block toggles (migration 110). Quote-only — invoices
// ignore these and always show the payment block. Default FALSE
// when the column is missing (a quote is an offer, not a demand
// for payment; admins opt IN via the Business profile UI).
base.quoteShowNetDays = profile.pdf_quote_show_net_days === true
|| profile.pdf_quote_show_net_days === 1 || profile.pdf_quote_show_net_days === '1';
base.quoteShowSkonto = profile.pdf_quote_show_skonto === true
|| profile.pdf_quote_show_skonto === 1 || profile.pdf_quote_show_skonto === '1';
}
return base;
}
/**
* Build the `recipient` field for a render context. Maintainer spec:
*
* - customer.company_name set → bold company on line 1, then
* "z. Hd. <person>" on line 2 (rendered by pdfService when
* hasCompany is true).
* - else → bold person/display_name/email on line 1, NO z.Hd. line
* (avoids "Luca Bresch / z. Hd. Luca Bresch" duplication).
*
* Empty-string trim guard: customer rows saved with company_name = ""
* (not NULL) used to engage the company-header path with a blank line
* before the trim was added.
*
* @param {object} profile business_profile row (may be empty)
* @param {object} customer customer_accounts row (may be null)
* @returns {object}
*/
function buildRecipientBlock(profile, customer) {
const trimmedCompany = (customer?.company_name || '').trim();
const personFull = [customer?.first_name, customer?.last_name]
.map((s) => (s || '').trim()).filter(Boolean).join(' ');
const headerWithCompany = !!trimmedCompany;
const header = trimmedCompany
|| personFull
|| (customer?.display_name || '').trim()
|| customer?.email
|| '';
// Always populate the attention string when we have a person —
// pdfService.drawRecipientBlock gates emission on hasCompany so the
// dead-data case (no company, has person) doesn't end up on the
// PDF, but having the string available means audit views can show
// it. This unifies the previously-drifted contractService and
// quote/invoice behavior under the renderer-aware contract.
const attentionParts = [customer?.salutation, personFull].filter(Boolean);
const attentionLine = attentionParts.length > 0
? `z. Hd. ${attentionParts.join(' ')}`
: '';
return {
issuerLine: profile?.company_name
? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}`
: '',
companyName: header,
hasCompany: headerWithCompany,
attentionLine,
// Honorific + last name for personalised salutation
// ("Sehr geehrter Herr Bresch,"). Renderer requires BOTH.
salutation: customer?.salutation || null,
lastName: (customer?.last_name || '').trim() || null,
addressLine1: customer?.address_line1,
addressLine2: customer?.address_line2,
postalCode: customer?.postal_code,
city: customer?.city,
// Country name override (migration 107); falls back to the
// locale-aware COUNTRY_NAMES lookup on countryCodeIso in pdfService.
country: customer?.country_name || null,
countryCodeIso: customer?.country_code,
};
}
module.exports = {
buildIssuerBlock,
buildRecipientBlock,
};