Merge pull request 'feat: add tax_mode support for document templates and invoice processing + remove local template fallback' (#27) from refactor/reverseChargeTemplate into dev

Reviewed-on: #27
This commit is contained in:
Seazn 2026-05-21 18:59:51 +00:00
commit 129669d2d6
6 changed files with 202 additions and 94 deletions

View File

@ -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,

View File

@ -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');

View File

@ -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']
};

View File

@ -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) {

View File

@ -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 }) {
@ -554,6 +553,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),
@ -605,13 +605,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>
@ -627,12 +628,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({
@ -641,11 +642,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;
}
}
@ -698,12 +699,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;

View File

@ -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;