feat: add tax_mode support for document templates and invoice processing + remove local template fallback #27
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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']
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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 `<!doctype html>
|
||||
<html>
|
||||
@ -623,12 +624,12 @@ class InvoiceService {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user