Files
picpeak/backend/src/services/_renderContext.js
T
Luca dc7b87bb87 feat(accounting): consolidate VAT/financial config into Settings → Accounting
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
  are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
  is now code-only — options are exactly the Accounting output codes, no
  free-text custom rate. Off-list legacy values on existing invoices are
  preserved as a read-only "(not configured)" option so issued documents
  aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
  AccountingProfileFields card; storage stays on business_profile, own save).
  Wire vat_label onto the PDF VAT-line label via the issuer block (covers
  invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
  dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
  preserved). Add a moved-note callout. Strip the moved fields from the
  Business-profile save so it can't clobber an Accounting-tab edit.
2026-06-18 15:10:44 +02:00

177 lines
8.2 KiB
JavaScript

/**
* 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,
// VAT-line label on the totals block (e.g. "MwSt.", "VAT"). Falls back to
// the per-locale default in pdfService when blank. Configured under
// Settings → Accounting.
vatLabel: profile.vat_label || 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,
};