diff --git a/repositories/template/DocumentTemplateRepository.js b/repositories/template/DocumentTemplateRepository.js index 0d7c708..fa79f0d 100644 --- a/repositories/template/DocumentTemplateRepository.js +++ b/repositories/template/DocumentTemplateRepository.js @@ -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); diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 4bc264d..e84e16f 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -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 ` @@ -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 = ''; const taxTreatmentBlock = isReverseCharge ? '' @@ -378,16 +522,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 = `
`; } - } 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 }); @@ -409,11 +553,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, taxTreatmentBlock, issuedAt: this._escapeHtml(issuedAt), @@ -438,6 +585,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), @@ -451,7 +602,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); @@ -473,12 +627,12 @@ class InvoiceService {