From a250608131c3993c057a2a3d05218685ceb582d6 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Thu, 21 May 2026 20:50:02 +0200 Subject: [PATCH 1/2] template fix maybe --- services/invoice/InvoiceService.js | 4 + templates/invoice/invoice_company_DE.html | 114 ++++++++++++++++++++- templates/invoice/invoice_company_EN.html | 116 ++++++++++++++++++++-- 3 files changed, 223 insertions(+), 11 deletions(-) diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index dfb49fd..4bc264d 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -368,6 +368,9 @@ class InvoiceService { let customerName; let customerEmail = ''; let orderedByBlock = ''; + const taxTreatmentBlock = isReverseCharge + ? '' + : `
${isDe ? 'Steuerhinweis' : 'Tax treatment'}

${this._escapeHtml(isDe ? 'MwSt.' : 'Tax')} (${this._escapeHtml(vatRate ? `${vatRate}%` : '0%')})

`; if (isGift) { // Recipient info for "Bill To" @@ -412,6 +415,7 @@ class InvoiceService { customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')), customerCountry: this._escapeHtml(invoice.buyer_country || ''), orderedByBlock, + taxTreatmentBlock, issuedAt: this._escapeHtml(issuedAt), dueAt: this._escapeHtml(dueAt), descriptionHeader: isDe ? 'Beschreibung' : 'Description', diff --git a/templates/invoice/invoice_company_DE.html b/templates/invoice/invoice_company_DE.html index 13a02d4..f18567b 100644 --- a/templates/invoice/invoice_company_DE.html +++ b/templates/invoice/invoice_company_DE.html @@ -21,7 +21,7 @@ border: 1px solid #dbe3f0; border-radius: 20px; overflow: hidden; - box-shadow: 0 24px 60px -38px rgba(15, 23, 42, 0.35); + box-shadow: none; } .hero { padding: 34px 38px 28px; @@ -161,6 +161,9 @@ margin: 0; color: #334155; } + .tax-banner:empty { + display: none; + } .items-table { width: 100%; border-collapse: collapse; @@ -277,6 +280,110 @@ text-align: left; } } + @page { + size: A4; + margin: 12mm; + } + @media print { + html, + body { + width: 210mm; + } + body { + background: #ffffff; + color: #111827; + font-size: 10.8px; + line-height: 1.3; + } + .page { + max-width: none; + width: 100%; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; + overflow: visible; + } + .hero { + padding: 14px 16px 12px; + } + .hero h1 { + font-size: 22px; + } + .invoice-card { + min-width: 160px; + padding: 10px 12px; + } + .invoice-card .number { + font-size: 18px; + } + .content { + padding: 14px 16px 16px; + } + .info-grid, + .summary-grid { + gap: 10px; + } + .info-grid { + margin-bottom: 10px; + } + .info-card, + .payment-card, + .totals-card, + .tax-banner { + padding: 10px; + } + .tax-banner { + margin-bottom: 10px; + } + .items-table { + margin-bottom: 10px; + } + .items-table thead th, + .items-table tbody td { + padding: 6px 7px; + } + .bank-list { + margin-top: 8px; + gap: 4px; + } + .totals-row { + padding: 4px 0; + } + .totals-row.total { + margin-top: 3px; + padding-top: 6px; + } + .footer { + margin-top: 10px; + padding-top: 8px; + font-size: 9.4px; + } + .hero, + .info-grid, + .info-card, + .tax-banner, + .summary-grid, + .payment-card, + .totals-card, + .footer { + page-break-inside: avoid; + break-inside: avoid; + } + .summary-grid { + grid-template-columns: 1fr; + } + .items-table { + page-break-inside: auto; + } + .items-table thead { + display: table-header-group; + } + .items-table tr { + page-break-inside: avoid; + break-inside: avoid; + } + } @@ -327,10 +434,7 @@ {{orderedByBlock}} -
-
Steuerhinweis
-

{{taxLabel}} ({{vatRateDisplay}})

-
+ {{taxTreatmentBlock}} diff --git a/templates/invoice/invoice_company_EN.html b/templates/invoice/invoice_company_EN.html index 880bc0f..9dc696d 100644 --- a/templates/invoice/invoice_company_EN.html +++ b/templates/invoice/invoice_company_EN.html @@ -21,7 +21,7 @@ border: 1px solid #dbe3f0; border-radius: 20px; overflow: hidden; - box-shadow: 0 24px 60px -38px rgba(15, 23, 42, 0.35); + box-shadow: none; } .hero { padding: 34px 38px 28px; @@ -161,6 +161,9 @@ margin: 0; color: #334155; } + .tax-banner:empty { + display: none; + } .items-table { width: 100%; border-collapse: collapse; @@ -277,6 +280,110 @@ text-align: left; } } + @page { + size: A4; + margin: 12mm; + } + @media print { + html, + body { + width: 210mm; + } + body { + background: #ffffff; + color: #111827; + font-size: 10.8px; + line-height: 1.3; + } + .page { + max-width: none; + width: 100%; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; + overflow: visible; + } + .hero { + padding: 14px 16px 12px; + } + .hero h1 { + font-size: 22px; + } + .invoice-card { + min-width: 160px; + padding: 10px 12px; + } + .invoice-card .number { + font-size: 18px; + } + .content { + padding: 14px 16px 16px; + } + .info-grid, + .summary-grid { + gap: 10px; + } + .info-grid { + margin-bottom: 10px; + } + .info-card, + .payment-card, + .totals-card, + .tax-banner { + padding: 10px; + } + .tax-banner { + margin-bottom: 10px; + } + .items-table { + margin-bottom: 10px; + } + .items-table thead th, + .items-table tbody td { + padding: 6px 7px; + } + .bank-list { + margin-top: 8px; + gap: 4px; + } + .totals-row { + padding: 4px 0; + } + .totals-row.total { + margin-top: 3px; + padding-top: 6px; + } + .footer { + margin-top: 10px; + padding-top: 8px; + font-size: 9.4px; + } + .hero, + .info-grid, + .info-card, + .tax-banner, + .summary-grid, + .payment-card, + .totals-card, + .footer { + page-break-inside: avoid; + break-inside: avoid; + } + .summary-grid { + grid-template-columns: 1fr; + } + .items-table { + page-break-inside: auto; + } + .items-table thead { + display: table-header-group; + } + .items-table tr { + page-break-inside: avoid; + break-inside: avoid; + } + } @@ -327,10 +434,7 @@ {{orderedByBlock}} -
-
Tax treatment
-

{{taxLabel}} ({{vatRateDisplay}})

-
+ {{taxTreatmentBlock}}
@@ -370,4 +474,4 @@ - \ No newline at end of file +> \ No newline at end of file -- 2.39.5 From d553d056e1dc28e34fdff982cbb58ecaf17d2521 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Thu, 21 May 2026 20:59:15 +0200 Subject: [PATCH 2/2] feat: add tax_mode support for document templates and invoice processing + remove local template fallback --- .../DocumentTemplateController.js | 21 ++++- database/createDb.js | 22 +++++ models/DocumentTemplate.js | 2 +- .../template/DocumentTemplateRepository.js | 78 +++++++++++----- services/invoice/InvoiceService.js | 92 ++++++++++--------- services/template/DocumentTemplateService.js | 81 +++++++++++----- 6 files changed, 202 insertions(+), 94 deletions(-) diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index c7d9965..4749b7e 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -34,6 +34,13 @@ function saveDebugFile(filename, data, encoding = 'utf8') { } } +function normalizeInvoiceTaxMode(rawValue) { + const normalized = rawValue !== undefined && rawValue !== null + ? String(rawValue).trim().toLowerCase() + : rawValue; + return ['standard', 'reverse_charge', 'both'].includes(normalized) ? normalized : 'both'; +} + // Helper to remove/empty placeholders except allow-list // Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized. // allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'companyLogo', 'profitplanetSignature'). @@ -468,6 +475,7 @@ exports.uploadTemplate = async (req, res) => { const { name, type, description, lang } = req.body; const rawContractType = req.body.contract_type || req.body.contractType; const rawUserType = req.body.user_type || req.body.userType; + const rawTaxMode = req.body.tax_mode || req.body.taxMode; const allowed = ['personal','company','both']; const user_type = allowed.includes(rawUserType) ? rawUserType : 'both'; const file = req.file; @@ -481,6 +489,7 @@ exports.uploadTemplate = async (req, res) => { const contract_type = (type === 'contract' && allowedContractTypes.includes(normalizedContractType)) ? normalizedContractType : (type === 'contract' ? 'contract' : null); + const tax_mode = type === 'invoice' ? normalizeInvoiceTaxMode(rawTaxMode) : null; // Use "english" for en, "german" for de const langFolder = lang === 'en' ? 'english' : 'german'; @@ -501,7 +510,7 @@ exports.uploadTemplate = async (req, res) => { ContentType: file.mimetype })); - const template = await DocumentTemplateService.uploadTemplate({ name, type, contract_type, storageKey: key, description, lang, version: 1, user_type }); + const template = await DocumentTemplateService.uploadTemplate({ name, type, contract_type, storageKey: key, description, lang, version: 1, user_type, tax_mode }); // Enrich with previewUrl, fileUrl, html const enriched = await enrichTemplate(template, s3); res.status(201).json(enriched); @@ -532,6 +541,7 @@ exports.updateTemplate = async (req, res) => { const { name, type, description, lang } = req.body; const rawUserType = req.body.user_type || req.body.userType; const rawContractType = req.body.contract_type || req.body.contractType; + const rawTaxMode = req.body.tax_mode || req.body.taxMode; const allowed = ['personal','company','both']; const user_type = allowed.includes(rawUserType) ? rawUserType : undefined; let storageKey; @@ -552,6 +562,9 @@ exports.updateTemplate = async (req, res) => { contract_type = 'contract'; } } + const tax_mode = nextType === 'invoice' + ? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : current.tax_mode) + : null; // Use "english" for en, "german" for de const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german'); @@ -577,6 +590,7 @@ exports.updateTemplate = async (req, res) => { name: name !== undefined ? name : current.name, type: nextType, contract_type, + tax_mode, description: description !== undefined ? description : current.description, lang: lang !== undefined ? lang : current.lang, storageKey: storageKey || current.storageKey, @@ -604,6 +618,7 @@ exports.reviseTemplate = async (req, res) => { const { name, type, description, lang } = req.body; const rawUserType = req.body.user_type || req.body.userType; const rawContractType = req.body.contract_type || req.body.contractType; + const rawTaxMode = req.body.tax_mode || req.body.taxMode; const requestedState = req.body.state; // optional: 'active' | 'inactive' const file = req.file; @@ -628,6 +643,9 @@ exports.reviseTemplate = async (req, res) => { const normalizedCandidate = candidate !== undefined && candidate !== null ? String(candidate).trim().toLowerCase() : candidate; contract_type = allowedContractTypes.includes(normalizedCandidate) ? normalizedCandidate : 'contract'; } + const tax_mode = nextType === 'invoice' + ? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : previous.tax_mode) + : null; // Use "english" for en, "german" for de const langFolder = nextLang === 'en' ? 'english' : 'german'; @@ -652,6 +670,7 @@ exports.reviseTemplate = async (req, res) => { name: name !== undefined ? name : previous.name, type: nextType, contract_type, + tax_mode, storageKey, description: description !== undefined ? description : previous.description, lang: nextLang, diff --git a/database/createDb.js b/database/createDb.js index 827c5d7..da26650 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -446,6 +446,7 @@ const createDatabase = async () => { description TEXT, lang VARCHAR(10) NOT NULL, user_type ENUM('personal','company','both') DEFAULT 'both', + tax_mode ENUM('standard','reverse_charge','both') NULL DEFAULT NULL, version INT DEFAULT 1, state ENUM('active','inactive') DEFAULT 'inactive', createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -493,6 +494,26 @@ const createDatabase = async () => { console.log("â„šī¸ document_templates.contract_type ENUM ALTER skipped:", e.message); } + try { + await connection.query(` + ALTER TABLE document_templates + ADD COLUMN tax_mode ENUM('standard','reverse_charge','both') NULL DEFAULT NULL AFTER user_type; + `); + console.log("🔧 Added document_templates.tax_mode"); + } catch (e) { + console.log("â„šī¸ document_templates.tax_mode add skipped:", e.message); + } + + try { + await connection.query(` + ALTER TABLE document_templates + MODIFY COLUMN tax_mode ENUM('standard','reverse_charge','both') NULL DEFAULT NULL; + `); + console.log("🔧 Ensured document_templates.tax_mode enum"); + } catch (e) { + console.log("â„šī¸ document_templates.tax_mode enum alter skipped:", e.message); + } + // Ensure CHECK constraint includes 'abo' (best-effort; some MySQL/MariaDB versions ignore/limit CHECK) try { const [checks] = await connection.query(` @@ -1864,6 +1885,7 @@ const createDatabase = async () => { await ensureIndex(connection, 'rate_limit', 'idx_rate_limit_rate_key', 'rate_key'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_user_type', 'user_type'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_state_user_type', 'state, user_type'); + await ensureIndex(connection, 'document_templates', 'idx_document_templates_invoice_scope', 'type, state, lang, user_type, tax_mode'); await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type', 'template_type'); await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_active', 'is_active'); await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_archived', 'is_archived'); diff --git a/models/DocumentTemplate.js b/models/DocumentTemplate.js index af81dc5..83023f5 100644 --- a/models/DocumentTemplate.js +++ b/models/DocumentTemplate.js @@ -1,4 +1,4 @@ // This file is now just a field reference for document_templates table module.exports = { - fields: ['id', 'name', 'type', 'storageKey', 'description', 'lang', 'user_type', 'version', 'state', 'createdAt', 'updatedAt'] + fields: ['id', 'name', 'type', 'contract_type', 'storageKey', 'description', 'lang', 'user_type', 'tax_mode', 'version', 'state', 'createdAt', 'updatedAt'] }; \ No newline at end of file diff --git a/repositories/template/DocumentTemplateRepository.js b/repositories/template/DocumentTemplateRepository.js index fa79f0d..b8d93b9 100644 --- a/repositories/template/DocumentTemplateRepository.js +++ b/repositories/template/DocumentTemplateRepository.js @@ -1,6 +1,24 @@ const db = require('../../database/database'); const { logger } = require('../../middleware/logger'); +const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']); +const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']); +const ALLOWED_INVOICE_TAX_MODES = new Set(['standard', 'reverse_charge', 'both']); + +function normalizeTemplateUserType(value) { + return ALLOWED_USER_TYPES.has(value) ? value : 'both'; +} + +function normalizeContractType(value) { + return (value === undefined || value === null) ? value : String(value).trim().toLowerCase(); +} + +function normalizeInvoiceTaxMode(value) { + if (value === undefined || value === null || String(value).trim() === '') return 'both'; + const normalized = String(value).trim().toLowerCase(); + return ALLOWED_INVOICE_TAX_MODES.has(normalized) ? normalized : 'both'; +} + class DocumentTemplateRepository { async create(data, conn) { logger.info('DocumentTemplateRepository.create:start', { name: data.name, type: data.type }); @@ -20,33 +38,33 @@ class DocumentTemplateRepository { const storageKey = String(data.storageKey); const description = data.description === undefined ? null : data.description; // avoid undefined bind const lang = String(data.lang); - const allowedUserTypes = new Set(['personal', 'company', 'both']); - const user_type = allowedUserTypes.has(data.user_type || data.userType) ? (data.user_type || data.userType) : 'both'; - const allowedContractTypes = new Set(['contract', 'gdpr', 'abo']); - const normalizeContractType = (value) => (value === undefined || value === null) ? value : String(value).trim().toLowerCase(); + const user_type = normalizeTemplateUserType(data.user_type || data.userType); const contract_type = type === 'contract' - ? (allowedContractTypes.has(normalizeContractType(data.contract_type || data.contractType)) ? normalizeContractType(data.contract_type || data.contractType) : null) + ? (ALLOWED_CONTRACT_TYPES.has(normalizeContractType(data.contract_type || data.contractType)) ? normalizeContractType(data.contract_type || data.contractType) : null) : null; const finalContractType = type === 'contract' ? (contract_type || 'contract') : null; + const tax_mode = type === 'invoice' + ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode) + : null; const version = Number.isFinite(Number(data.version)) ? Math.max(1, Number(data.version)) : 1; const state = (data.state === 'active' || data.state === 'inactive') ? data.state : 'inactive'; const query = ` - INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, tax_mode, version, state, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) `; try { if (conn) { - const [res] = await conn.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, version, state]); + const [res] = await conn.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, tax_mode, version, state]); const insertId = res && (res.insertId || res[0]?.insertId); logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.insertId }); - return { id: insertId || res.insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version, state }; + return { id: insertId || res.insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, tax_mode, version, state }; } - const result = await db.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, version, state]); + const result = await db.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, tax_mode, version, state]); const insertId = result && result.insertId ? result.insertId : (Array.isArray(result) && result[0] && result[0].insertId ? result[0].insertId : undefined); logger.info('DocumentTemplateRepository.create:success', { id: insertId }); - return { id: insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version, state }; + return { id: insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, tax_mode, version, state }; } catch (error) { logger.error('DocumentTemplateRepository.create:error', { error: error.message }); throw error; @@ -99,7 +117,7 @@ class DocumentTemplateRepository { // data: { name, type, storageKey, description, lang, version } const fields = []; const values = []; - for (const key of ['name', 'type', 'contract_type', 'storageKey', 'description', 'lang', 'version', 'user_type']) { + for (const key of ['name', 'type', 'contract_type', 'storageKey', 'description', 'lang', 'version', 'user_type', 'tax_mode']) { if (data[key] !== undefined) { fields.push(`${key} = ?`); values.push(data[key]); @@ -110,6 +128,10 @@ class DocumentTemplateRepository { fields.push(`user_type = ?`); values.push(data.userType); } + if (data.taxMode !== undefined && data.tax_mode === undefined) { + fields.push(`tax_mode = ?`); + values.push(data.taxMode); + } // Do not update state here if (!fields.length) return false; const query = ` @@ -198,10 +220,11 @@ class DocumentTemplateRepository { } // Deactivate other active invoice templates for the same language and user_type. - async deactivateOtherActiveInvoices({ excludeId, lang, user_type }, conn) { - logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang, user_type }); + async deactivateOtherActiveInvoices({ excludeId, lang, user_type, tax_mode }, conn) { + logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang, user_type, tax_mode }); const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en'; - const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both'; + const safeUserType = normalizeTemplateUserType(user_type); + const safeTaxMode = normalizeInvoiceTaxMode(tax_mode); const query = ` UPDATE document_templates @@ -210,9 +233,10 @@ class DocumentTemplateRepository { AND type = 'invoice' AND lang = ? AND COALESCE(user_type, 'both') = ? + AND COALESCE(tax_mode, 'both') = ? AND state = 'active' `; - const params = [excludeId, safeLang, safeUserType]; + const params = [excludeId, safeLang, safeUserType, safeTaxMode]; try { if (conn) { const [res] = await conn.execute(query, params); @@ -242,21 +266,31 @@ class DocumentTemplateRepository { } } - async findActiveByUserType(userType, templateType = null, contractType = null, conn) { - logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType, contractType }); - const safeType = (userType === 'both') ? 'both' : (userType === 'personal' || userType === 'company') ? userType : 'personal'; + async findActiveByUserType(userType, templateType = null, contractType = null, taxMode = null, conn) { + logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType, contractType, taxMode }); + const safeType = userType === 'both' ? 'both' : (userType === 'personal' || userType === 'company') ? userType : 'personal'; + const normalizedTemplateType = templateType ? String(templateType).trim().toLowerCase() : null; let query = safeType === 'both' ? `SELECT * FROM document_templates WHERE state = 'active' AND user_type = 'both'` : `SELECT * FROM document_templates WHERE state = 'active' AND (user_type = ? OR user_type = 'both')`; const params = safeType === 'both' ? [] : [safeType]; - if (templateType) { + if (normalizedTemplateType) { query += ` AND type = ?`; - params.push(templateType); + params.push(normalizedTemplateType); } - if (contractType && templateType === 'contract') { + if (contractType && normalizedTemplateType === 'contract') { query += ` AND contract_type = ?`; params.push(contractType); } + if (normalizedTemplateType === 'invoice' && taxMode) { + const safeTaxMode = normalizeInvoiceTaxMode(taxMode); + if (safeTaxMode === 'both') { + query += ` AND COALESCE(tax_mode, 'both') = 'both'`; + } else { + query += ` AND COALESCE(tax_mode, 'both') IN (?, 'both')`; + params.push(safeTaxMode); + } + } query += ` ORDER BY createdAt DESC`; try { if (conn) { diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 5d88152..19d6e1c 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -7,8 +7,6 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { logger } = require('../../middleware/logger'); const puppeteer = require('puppeteer'); -const fs = require('fs/promises'); -const path = require('path'); const pool = require('../../database/database'); const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService'); @@ -26,16 +24,6 @@ class InvoiceService { }); } - async _loadLocalInvoiceTemplateHtml() { - try { - const filePath = path.resolve(__dirname, '../../templates/invoice/invoiceTemplate.html'); - return await fs.readFile(filePath, 'utf8'); - } catch (e) { - logger.warn('InvoiceService._loadLocalInvoiceTemplateHtml:error', { message: e?.message }); - return null; - } - } - _resolvePieceCountForQr(abonement) { const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : []; const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || item?.quantity || 0), 0); @@ -132,6 +120,20 @@ class InvoiceService { return String(value || '').trim().toLowerCase() === 'company' ? 'company' : 'personal'; } + _normalizeInvoiceTemplateTaxMode(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'standard' || normalized === 'reverse_charge' || normalized === 'both') { + return normalized; + } + return 'both'; + } + + _matchesLegacyReverseChargeTemplate(template, taxMode) { + if (taxMode !== 'reverse_charge' || !template) return false; + const haystack = [template?.name, template?.description, template?.storageKey].filter(Boolean).join(' ').toLowerCase(); + return /reverse[\s_-]*charge/.test(haystack); + } + async _loadInvoiceUserProfile(userId) { if (!userId) return null; @@ -228,18 +230,32 @@ class InvoiceService { }; } - _selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal' } = {}) { + _selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) { if (!Array.isArray(templates) || !templates.length) return null; const safeLang = lang === 'de' ? 'de' : 'en'; const safeUserType = this._normalizeInvoiceUserType(userType); + const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode); + const matchesTaxMode = (template, mode) => this._normalizeInvoiceTemplateTaxMode(template?.tax_mode) === mode; const priorities = [ - (template) => template?.lang === safeLang && template?.user_type === safeUserType, - (template) => template?.lang === safeLang && template?.user_type === 'both', - (template) => template?.lang === 'en' && template?.user_type === safeUserType, - (template) => template?.lang === 'en' && template?.user_type === 'both', - (template) => template?.user_type === safeUserType, - (template) => template?.user_type === 'both', + (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), + (template) => template?.lang === safeLang && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), + (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), + (template) => template?.lang === safeLang && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, 'both'), + (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), + (template) => template?.lang === 'en' && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), + (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), + (template) => template?.lang === 'en' && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, 'both'), + (template) => template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), + (template) => template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.user_type === safeUserType && matchesTaxMode(template, 'both'), + (template) => template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), + (template) => template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), + (template) => template?.user_type === 'both' && matchesTaxMode(template, 'both'), () => true, ]; @@ -384,25 +400,8 @@ class InvoiceService { }).join(''); } - async _loadInvoiceHtmlTemplate({ lang = 'en', userType = 'personal' } = {}) { - // Load the latest active invoice template from the contract manager (S3) - try { - const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice'); - if (!Array.isArray(templates) || !templates.length) return null; - const selected = this._selectInvoiceTemplate(templates, { lang, userType }); - if (!selected?.storageKey) return null; - const command = new GetObjectCommand({ - Bucket: process.env.EXOSCALE_BUCKET, - Key: selected.storageKey, - }); - const obj = await sharedExoscaleClient.send(command); - const html = await this._s3BodyToString(obj.Body) || null; - if (!html) return null; - return html; - } catch (e) { - logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message }); - return null; - } + async _loadInvoiceHtmlTemplate(options = {}) { + return this._loadInvoiceTemplateHtml(options); } _getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) { @@ -551,6 +550,7 @@ class InvoiceService { companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'), companyLogo: this._buildCompanyLogoTag(companyInfo), invoiceUserType: billingContext.userType, + invoiceTaxMode: isReverseCharge ? 'reverse_charge' : 'standard', customerName: this._escapeHtml(customerName), customerEmail: this._escapeHtml(customerEmail), customerStreet: this._escapeHtml(customerStreet), @@ -601,13 +601,14 @@ class InvoiceService { const template = await this._loadInvoiceHtmlTemplate({ lang, userType: variables.invoiceUserType, + taxMode: variables.invoiceTaxMode, }); if (template) { const varsForTemplate = this._prepareVariablesForTemplate(template, variables); return this._renderTemplate(template, varsForTemplate); } - // Absolute fallback if template file is missing + // Absolute fallback if no active contract-manager template is available const isDe = lang === 'de'; return ` @@ -623,12 +624,12 @@ class InvoiceService { `; } - async _loadInvoiceTemplateHtml({ lang = 'en', userType = 'personal' } = {}) { + async _loadInvoiceTemplateHtml({ lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) { try { - const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice'); + const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice', null, taxMode); if (!Array.isArray(templates) || !templates.length) return null; - const selected = this._selectInvoiceTemplate(templates, { lang, userType }); + const selected = this._selectInvoiceTemplate(templates, { lang, userType, taxMode }); if (!selected?.storageKey) return null; const command = new GetObjectCommand({ @@ -637,11 +638,11 @@ class InvoiceService { }); const obj = await sharedExoscaleClient.send(command); const html = await this._s3BodyToString(obj.Body); - if (!html) return await this._loadLocalInvoiceTemplateHtml(); + if (!html) return null; return html; } catch (error) { logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message }); - return await this._loadLocalInvoiceTemplateHtml(); + return null; } } @@ -694,12 +695,13 @@ class InvoiceService { const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const subject = this._getEmailSubject(lang, invoice.invoice_number); - // Build the full set of template variables once – used by both S3 and local paths + // Build the full set of template variables once – used by both S3 templates and the emergency HTML fallback const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); const templateHtml = await this._loadInvoiceTemplateHtml({ lang, userType: variables.invoiceUserType, + taxMode: variables.invoiceTaxMode, }); let html = null; diff --git a/services/template/DocumentTemplateService.js b/services/template/DocumentTemplateService.js index 5dfb51d..36cfb60 100644 --- a/services/template/DocumentTemplateService.js +++ b/services/template/DocumentTemplateService.js @@ -2,6 +2,24 @@ const DocumentTemplateRepository = require('../../repositories/template/Document const UnitOfWork = require('../../database/UnitOfWork'); const { logger } = require('../../middleware/logger'); +const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']); +const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']); +const ALLOWED_INVOICE_TAX_MODES = new Set(['standard', 'reverse_charge', 'both']); + +function normalizeTemplateUserType(value) { + return ALLOWED_USER_TYPES.has(value) ? value : 'both'; +} + +function normalizeContractType(value) { + return (value === undefined || value === null) ? value : String(value).trim().toLowerCase(); +} + +function normalizeInvoiceTaxMode(value) { + if (value === undefined || value === null || String(value).trim() === '') return 'both'; + const normalized = String(value).trim().toLowerCase(); + return ALLOWED_INVOICE_TAX_MODES.has(normalized) ? normalized : 'both'; +} + class DocumentTemplateService { async listTemplates() { logger.info('DocumentTemplateService.listTemplates:start'); @@ -23,17 +41,16 @@ class DocumentTemplateService { const uow = new UnitOfWork(); try { await uow.start(); - const allowed = ['personal','company','both']; - const user_type = allowed.includes(data.user_type || data.userType) ? (data.user_type || data.userType) : 'both'; - const allowedContractTypes = ['contract', 'gdpr', 'abo']; const rawContractType = (data.contract_type || data.contractType); - const normalizedContractType = rawContractType !== undefined && rawContractType !== null - ? String(rawContractType).trim().toLowerCase() - : rawContractType; - const contract_type = (data.type === 'contract' && allowedContractTypes.includes(normalizedContractType)) + const normalizedContractType = normalizeContractType(rawContractType); + const user_type = normalizeTemplateUserType(data.user_type || data.userType); + const contract_type = (data.type === 'contract' && ALLOWED_CONTRACT_TYPES.includes(normalizedContractType)) ? normalizedContractType : (data.type === 'contract' ? 'contract' : null); - const created = await DocumentTemplateRepository.create({ ...data, user_type, contract_type }, uow.connection); + const tax_mode = data.type === 'invoice' + ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode) + : null; + const created = await DocumentTemplateRepository.create({ ...data, user_type, contract_type, tax_mode }, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id }); return created; @@ -88,21 +105,20 @@ class DocumentTemplateService { await uow.rollback(); return null; } - const allowed = ['personal','company','both']; - if (data.userType && !allowed.includes(data.userType)) delete data.userType; - if (data.user_type && !allowed.includes(data.user_type)) delete data.user_type; const nextType = data.type !== undefined ? data.type : current.type; - const allowedContractTypes = ['contract', 'gdpr', 'abo']; - const normalizeContractType = (value) => (value === undefined || value === null) ? value : String(value).trim().toLowerCase(); const contract_type = nextType === 'contract' - ? (allowedContractTypes.includes(normalizeContractType(data.contract_type || data.contractType || current.contract_type)) + ? (ALLOWED_CONTRACT_TYPES.includes(normalizeContractType(data.contract_type || data.contractType || current.contract_type)) ? normalizeContractType(data.contract_type || data.contractType || current.contract_type) : 'contract') : null; + const user_type = normalizeTemplateUserType(data.user_type || data.userType || current.user_type); + const tax_mode = nextType === 'invoice' + ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode || current.tax_mode) + : null; const newVersion = (current.version || 1) + 1; await DocumentTemplateRepository.update( id, - { ...data, version: newVersion, user_type: data.user_type || data.userType || current.user_type, contract_type }, + { ...data, version: newVersion, user_type, contract_type, tax_mode }, uow.connection ); const updated = await DocumentTemplateRepository.findById(id, uow.connection); @@ -146,6 +162,7 @@ class DocumentTemplateService { excludeId: id, lang: current.lang, user_type: current.user_type || 'both', + tax_mode: current.tax_mode || 'both', }, uow.connection); } @@ -180,10 +197,24 @@ class DocumentTemplateService { const nextState = (data.state === 'active' || data.state === 'inactive') ? data.state : (previous.state === 'active' ? 'active' : 'inactive'); + const effectiveType = data.type || previous.type; + const effectiveUserType = normalizeTemplateUserType(data.user_type || data.userType || previous.user_type); + const effectiveContractType = effectiveType === 'contract' + ? (ALLOWED_CONTRACT_TYPES.includes(normalizeContractType(data.contract_type || data.contractType || previous.contract_type)) + ? normalizeContractType(data.contract_type || data.contractType || previous.contract_type) + : 'contract') + : null; + const effectiveTaxMode = effectiveType === 'invoice' + ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode || previous.tax_mode) + : null; const created = await DocumentTemplateRepository.create( { ...data, + type: effectiveType, + user_type: effectiveUserType, + contract_type: effectiveContractType, + tax_mode: effectiveTaxMode, version: nextVersion, state: nextState, }, @@ -194,19 +225,19 @@ class DocumentTemplateService { if (nextState === 'active' && (data.type === 'contract' || previous.type === 'contract')) { await DocumentTemplateRepository.deactivateOtherActiveContracts({ excludeId: created.id, - contract_type: (data.contract_type || previous.contract_type || 'contract'), + contract_type: (effectiveContractType || previous.contract_type || 'contract'), lang: (data.lang || previous.lang), - user_type: (data.user_type || previous.user_type || 'both') + user_type: effectiveUserType, }, uow.connection); } // If new template is active and is an invoice template, deactivate other active invoices for same lang + user_type - const effectiveType = data.type || previous.type; if (nextState === 'active' && effectiveType === 'invoice') { await DocumentTemplateRepository.deactivateOtherActiveInvoices({ excludeId: created.id, lang: (data.lang || previous.lang), - user_type: (data.user_type || previous.user_type || 'both'), + user_type: effectiveUserType, + tax_mode: effectiveTaxMode, }, uow.connection); } @@ -223,10 +254,10 @@ class DocumentTemplateService { } } - async getActiveTemplatesForUserType(userType, templateType = null, contractType = null) { - logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType }); + async getActiveTemplatesForUserType(userType, templateType = null, contractType = null, taxMode = null) { + logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType, taxMode }); try { - const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType); + const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length }); return rows.map(t => ({ ...t, lang: t.lang })); } catch (error) { @@ -236,10 +267,10 @@ class DocumentTemplateService { } // Convenience: return the most recent active template for a user type (by createdAt desc) - async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null) { - logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType }); + async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null, taxMode = null) { + logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType, taxMode }); try { - const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType); + const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode); const latest = Array.isArray(list) && list.length ? list[0] : null; logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id }); return latest; -- 2.39.5