dei mutter #25

Merged
Seazn merged 1 commits from refactor/reverseChargeTemplate into dev 2026-05-21 18:32:12 +00:00
6 changed files with 264 additions and 50 deletions

View File

@ -197,11 +197,11 @@ class DocumentTemplateRepository {
}
}
// Deactivate other active invoice templates for the same language.
// Invoices always use user_type='both', so only lang matters.
async deactivateOtherActiveInvoices({ excludeId, lang }, conn) {
logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang });
// 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 });
const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en';
const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both';
const query = `
UPDATE document_templates
@ -209,9 +209,10 @@ class DocumentTemplateRepository {
WHERE id <> ?
AND type = 'invoice'
AND lang = ?
AND COALESCE(user_type, 'both') = ?
AND state = 'active'
`;
const params = [excludeId, safeLang];
const params = [excludeId, safeLang, safeUserType];
try {
if (conn) {
const [res] = await conn.execute(query, params);

View File

@ -119,6 +119,138 @@ class InvoiceService {
return `${numeric.toFixed(2)} ${currency}`;
}
_firstNonEmpty(...values) {
for (const value of values) {
if (value === undefined || value === null) continue;
const normalized = String(value).trim();
if (normalized) return normalized;
}
return '';
}
_normalizeInvoiceUserType(value) {
return String(value || '').trim().toLowerCase() === 'company' ? 'company' : 'personal';
}
async _loadInvoiceUserProfile(userId) {
if (!userId) return null;
try {
const [rows] = await pool.query(
`SELECT u.id, u.email, u.user_type,
cp.company_name, cp.registration_number, cp.atu_number, cp.country AS company_country
FROM users u
LEFT JOIN company_profiles cp ON cp.user_id = u.id
WHERE u.id = ?
LIMIT 1`,
[userId],
);
return rows?.[0] || null;
} catch (error) {
logger.warn('InvoiceService._loadInvoiceUserProfile:error', {
userId,
message: error?.message,
});
return null;
}
}
async _buildInvoiceBillingContext({ abonement, invoice = null, invoiceUserId = null, userProfile = null } = {}) {
const profile = userProfile || await this._loadInvoiceUserProfile(invoiceUserId);
const userType = this._normalizeInvoiceUserType(profile?.user_type);
const shippingFullName = this._firstNonEmpty(
[abonement?.first_name, abonement?.last_name].filter(Boolean).join(' '),
invoice?.buyer_name,
profile?.company_name,
abonement?.email,
invoice?.buyer_email,
'Customer',
);
const customerName = userType === 'company'
? this._firstNonEmpty(
abonement?.invoice_full_name,
profile?.company_name,
invoice?.buyer_name,
shippingFullName,
)
: this._firstNonEmpty(
abonement?.invoice_full_name,
invoice?.buyer_name,
shippingFullName,
);
const email = this._firstNonEmpty(
abonement?.invoice_email,
invoice?.buyer_email,
abonement?.email,
profile?.email,
);
const street = this._firstNonEmpty(
abonement?.invoice_street,
invoice?.buyer_street,
abonement?.street,
);
const postalCode = this._firstNonEmpty(
abonement?.invoice_postal_code,
invoice?.buyer_postal_code,
abonement?.postal_code,
);
const city = this._firstNonEmpty(
abonement?.invoice_city,
invoice?.buyer_city,
abonement?.city,
);
const country = this._firstNonEmpty(
invoice?.buyer_country,
abonement?.country,
profile?.company_country,
);
return {
customerName: customerName || shippingFullName || '-',
email,
street,
postalCode,
city,
country,
postalCity: [postalCode, city].filter(Boolean).join(' '),
userType,
companyName: this._firstNonEmpty(profile?.company_name, abonement?.invoice_full_name),
uidNumber: this._normalizeUid(profile?.atu_number || profile?.registration_number || ''),
profile,
};
}
_selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal' } = {}) {
if (!Array.isArray(templates) || !templates.length) return null;
const safeLang = lang === 'de' ? 'de' : 'en';
const safeUserType = this._normalizeInvoiceUserType(userType);
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',
() => true,
];
for (const matches of priorities) {
const selected = templates.find(matches);
if (selected) return selected;
}
return templates[0];
}
async _s3BodyToString(body) {
if (!body) return '';
if (typeof body.transformToString === 'function') {
@ -161,7 +293,7 @@ class InvoiceService {
_buildInvoiceText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '-';
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
return [
@ -177,7 +309,7 @@ class InvoiceService {
_buildInvoiceMailText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '-';
return [
isDe ? `Vielen Dank für Ihr Abonnement, ${customerName}.` : `Thank you for your subscription, ${customerName}.`,
isDe ? 'Ihre Rechnung ist als PDF im Anhang enthalten.' : 'Your invoice is attached as a PDF.',
@ -191,7 +323,7 @@ class InvoiceService {
_buildInvoiceMailHtml({ invoice, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || 'Customer';
const customerName = invoice?.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || 'Customer';
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
return `<!doctype html>
@ -252,12 +384,12 @@ class InvoiceService {
}).join('');
}
async _loadInvoiceHtmlTemplate() {
async _loadInvoiceHtmlTemplate({ lang = 'en', userType = 'personal' } = {}) {
// Load the latest active invoice template from the contract manager (S3)
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice');
if (!Array.isArray(templates) || !templates.length) return null;
const selected = templates[0]; // latest active version
const selected = this._selectInvoiceTemplate(templates, { lang, userType });
if (!selected?.storageKey) return null;
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
@ -332,6 +464,15 @@ class InvoiceService {
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
const isReverseCharge = taxMode === 'reverse_charge';
const invoiceUserId = invoice?.user_id || abonement?.user_id || abonement?.purchaser_user_id || null;
const billingContext = await this._buildInvoiceBillingContext({
abonement,
invoice,
invoiceUserId,
});
const reverseChargeNoticeText = isDe
? 'Bei dieser Rechnung handelt es sich um eine Rechnung nach dem Reverse Charge Verfahren. Demnach wird keine Umsatzsteuer ausgewiesen. Die Steuerschuldnerschaft liegt beim Leistungsempfänger. Die Umsatzsteuer ist entsprechend vom Leistungsempfänger anzumelden und abzuführen.'
: 'This invoice is issued under the reverse charge procedure. Accordingly, no VAT is shown. The tax liability is transferred to the recipient of the service. The recipient must declare and pay the VAT in accordance with the applicable regulations.';
// Hardcoded bank info (Profit Planet)
const bankAccountHolder = 'Profit Planet GmbH';
@ -365,8 +506,11 @@ class InvoiceService {
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
// For self subscriptions: "Bill To" = the subscriber
let customerName;
let customerEmail = '';
let customerName = billingContext.customerName || invoice.buyer_name || '-';
let customerEmail = billingContext.email || '';
let customerStreet = billingContext.street || invoice.buyer_street || '';
let customerPostalCity = billingContext.postalCity || [invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ');
let customerCountry = billingContext.country || invoice.buyer_country || '';
let orderedByBlock = '';
if (isGift) {
@ -375,16 +519,16 @@ class InvoiceService {
const recipientEmail = abonement?.email || invoice.buyer_email || '';
customerName = recipientName || recipientEmail || '-';
customerEmail = recipientName ? recipientEmail : '';
customerStreet = abonement?.street || invoice?.buyer_street || '';
customerPostalCity = [abonement?.postal_code || invoice?.buyer_postal_code, abonement?.city || invoice?.buyer_city].filter(Boolean).join(' ');
customerCountry = abonement?.country || invoice?.buyer_country || '';
// Purchaser info for "Ordered by"
const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
const purchaserName = billingContext.customerName || invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
if (purchaserName) {
const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By';
orderedByBlock = `<div class="meta-block"><h3>${this._escapeHtml(orderedByLabel)}</h3><p><span class="highlight">${this._escapeHtml(purchaserName)}</span></p></div>`;
}
} else {
customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
customerEmail = abonement?.email || invoice.buyer_email || '';
}
const qrCodeImage = await this._buildQrCodeImageTag({ abonement });
@ -406,11 +550,14 @@ class InvoiceService {
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
companyLogo: this._buildCompanyLogoTag(companyInfo),
invoiceUserType: billingContext.userType,
customerName: this._escapeHtml(customerName),
customerEmail: this._escapeHtml(customerEmail),
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')),
customerCountry: this._escapeHtml(invoice.buyer_country || ''),
customerStreet: this._escapeHtml(customerStreet),
customerPostalCity: this._escapeHtml(customerPostalCity),
customerCountry: this._escapeHtml(customerCountry),
customerCompanyName: this._escapeHtml(billingContext.companyName || ''),
customerUidNumber: this._escapeHtml(invoice?.context?.uid_number || billingContext.uidNumber || ''),
orderedByBlock,
issuedAt: this._escapeHtml(issuedAt),
dueAt: this._escapeHtml(dueAt),
@ -434,6 +581,10 @@ class InvoiceService {
: (isReverseCharge
? 'Reverse charge applies: VAT liability shifts to the recipient. Please transfer the total amount stating the invoice number as reference.'
: 'Please transfer the total amount stating the invoice number as reference.'),
reverseChargeClass: isReverseCharge ? 'reverse-charge-active' : 'reverse-charge-inactive',
reverseChargeSectionClass: isReverseCharge ? '' : 'is-hidden',
standardTaxSectionClass: isReverseCharge ? 'is-hidden' : '',
reverseChargeNoticeText: isReverseCharge ? this._escapeHtml(reverseChargeNoticeText) : '',
bankAccountHolder: this._escapeHtml(bankAccountHolder),
bankIban: this._escapeHtml(bankIban),
bankBic: this._escapeHtml(bankBic),
@ -447,7 +598,10 @@ class InvoiceService {
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const template = await this._loadInvoiceHtmlTemplate();
const template = await this._loadInvoiceHtmlTemplate({
lang,
userType: variables.invoiceUserType,
});
if (template) {
const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
return this._renderTemplate(template, varsForTemplate);
@ -469,12 +623,12 @@ class InvoiceService {
</html>`;
}
async _loadInvoiceTemplateHtml({ lang = 'en' } = {}) {
async _loadInvoiceTemplateHtml({ lang = 'en', userType = 'personal' } = {}) {
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice');
if (!Array.isArray(templates) || !templates.length) return null;
const selected = templates.find((t) => t.lang === lang) || templates.find((t) => t.lang === 'en') || templates[0];
const selected = this._selectInvoiceTemplate(templates, { lang, userType });
if (!selected?.storageKey) return null;
const command = new GetObjectCommand({
@ -543,7 +697,10 @@ class InvoiceService {
// Build the full set of template variables once used by both S3 and local paths
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const templateHtml = await this._loadInvoiceTemplateHtml({ lang });
const templateHtml = await this._loadInvoiceTemplateHtml({
lang,
userType: variables.invoiceUserType,
});
let html = null;
if (templateHtml) {
@ -606,18 +763,10 @@ class InvoiceService {
}
async _loadCompanyTaxProfile(userId) {
if (!userId) return null;
const [rows] = await pool.query(
`SELECT registration_number, atu_number, country
FROM company_profiles
WHERE user_id = ?
LIMIT 1`,
[userId],
);
return rows?.[0] || null;
return this._loadInvoiceUserProfile(userId);
}
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId }) {
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId, invoiceOwnerProfile = null }) {
const uow = new UnitOfWork();
await uow.start();
@ -635,20 +784,22 @@ class InvoiceService {
await uow.commit();
const companyProfile = await this._loadCompanyTaxProfile(invoiceOwnerUserId);
const companyProfile = invoiceOwnerProfile || await this._loadCompanyTaxProfile(invoiceOwnerUserId);
const uidCandidate = companyProfile?.atu_number || companyProfile?.registration_number || '';
const normalizedUid = this._normalizeUid(uidCandidate);
const hasValidUid = this._isLikelyValidUid(normalizedUid);
const countryCode = String(country?.country_code || '').toUpperCase();
const invoiceUserType = this._normalizeInvoiceUserType(companyProfile?.user_type);
// Reverse charge for company customers with a valid UID outside seller country (AT).
const isReverseCharge = Boolean(hasValidUid && countryCode && countryCode !== 'AT');
const isReverseCharge = Boolean(invoiceUserType === 'company' && hasValidUid && countryCode && countryCode !== 'AT');
return {
vatRate: isReverseCharge ? 0 : vatRate,
isReverseCharge,
countryCode: countryCode || null,
uid: hasValidUid ? normalizedUid : null,
userType: invoiceUserType,
};
} catch (e) {
await uow.rollback();
@ -675,20 +826,28 @@ class InvoiceService {
periodEnd,
});
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
const buyerEmail = abonement.email || null;
const userIdForInvoice = actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
const invoiceUserProfile = await this._loadInvoiceUserProfile(userIdForInvoice);
const billingContext = await this._buildInvoiceBillingContext({
abonement,
invoiceUserId: userIdForInvoice,
userProfile: invoiceUserProfile,
});
const buyerName = billingContext.customerName || null;
const buyerEmail = billingContext.email || null;
const addr = {
street: abonement.street || null,
postal_code: abonement.postal_code || null,
city: abonement.city || null,
country: abonement.country || null,
street: billingContext.street || null,
postal_code: billingContext.postalCode || null,
city: billingContext.city || null,
country: billingContext.country || null,
};
const currency = abonement.currency || 'EUR';
// CHANGED: resolve tax mode for this subscription (standard VAT vs reverse charge)
const taxDecision = await this.resolveTaxDecisionForSubscription({
buyerCountry: addr.country,
invoiceOwnerUserId: actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null,
invoiceOwnerUserId: userIdForInvoice,
invoiceOwnerProfile: invoiceUserProfile,
});
const vat_rate = taxDecision?.vatRate ?? null;
@ -703,12 +862,9 @@ class InvoiceService {
tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
customer_country_code: taxDecision?.countryCode || null,
uid_number: taxDecision?.uid || null,
invoice_user_type: billingContext.userType,
};
// CHANGED: prioritize token user id for invoice ownership
const userIdForInvoice =
actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice);
const invoice = await this.repo.createInvoiceWithItems({

View File

@ -140,11 +140,12 @@ class DocumentTemplateService {
}, uow.connection);
}
// Enforce singleton active invoice template per lang
// Enforce singleton active invoice template per (lang + user_type)
if (state === 'active' && current.type === 'invoice') {
await DocumentTemplateRepository.deactivateOtherActiveInvoices({
excludeId: id,
lang: current.lang,
user_type: current.user_type || 'both',
}, uow.connection);
}
@ -199,12 +200,13 @@ class DocumentTemplateService {
}, uow.connection);
}
// If new template is active and is an invoice template, deactivate other active invoices for same lang
// 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'),
}, uow.connection);
}

View File

@ -157,6 +157,21 @@
font-weight: 800;
color: #1C2B4A;
}
.reverse-charge-note {
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 24px;
}
.reverse-charge-note p {
font-size: 12px;
color: #9a3412;
line-height: 1.7;
}
.is-hidden {
display: none !important;
}
/* ── Payment Info ──────────────────────────── */
.payment-info {
@ -305,6 +320,10 @@
</div>
</div>
<div class="reverse-charge-note {{reverseChargeClass}} {{reverseChargeSectionClass}}">
<p>{{reverseChargeNoticeText}}</p>
</div>
<!-- Payment Info -->
<div class="payment-info">
<h3>PAYMENT INFORMATION</h3>

View File

@ -161,6 +161,20 @@
margin: 0;
color: #334155;
}
.reverse-charge-note {
margin-bottom: 22px;
padding: 15px 18px;
border: 1px solid #fdba74;
border-radius: 18px;
background: linear-gradient(180deg, #fff7ed 0%, #fffbeb 100%);
}
.reverse-charge-note p {
margin: 0;
color: #9a3412;
}
.is-hidden {
display: none !important;
}
.items-table {
width: 100%;
border-collapse: collapse;
@ -332,6 +346,10 @@
<p><strong>{{taxLabel}}</strong> ({{vatRateDisplay}})</p>
</div>
<div class="reverse-charge-note {{reverseChargeClass}} {{reverseChargeSectionClass}}">
<p>{{reverseChargeNoticeText}}</p>
</div>
<table class="items-table">
<thead>
<tr>

View File

@ -161,6 +161,20 @@
margin: 0;
color: #334155;
}
.reverse-charge-note {
margin-bottom: 22px;
padding: 15px 18px;
border: 1px solid #fdba74;
border-radius: 18px;
background: linear-gradient(180deg, #fff7ed 0%, #fffbeb 100%);
}
.reverse-charge-note p {
margin: 0;
color: #9a3412;
}
.is-hidden {
display: none !important;
}
.items-table {
width: 100%;
border-collapse: collapse;
@ -332,6 +346,10 @@
<p><strong>{{taxLabel}}</strong> ({{vatRateDisplay}})</p>
</div>
<div class="reverse-charge-note {{reverseChargeClass}} {{reverseChargeSectionClass}}">
<p>{{reverseChargeNoticeText}}</p>
</div>
<table class="items-table">
<thead>
<tr>