CentralBackend/services/invoice/InvoiceService.js

955 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
const PoolInflowService = require('../pool/PoolInflowService');
const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService');
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 CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
class InvoiceService {
constructor() {
this.repo = new InvoiceRepository();
}
_inferImageMimeFromBase64(base64) {
const s = String(base64 || '').trim();
if (!s) return 'image/png';
if (s.startsWith('iVBORw0KGgo')) return 'image/png';
if (s.startsWith('/9j/')) return 'image/jpeg';
if (s.startsWith('R0lGOD')) return 'image/gif';
return 'image/png';
}
_templateHasVars(template, varNames) {
if (!template) return false;
return varNames.every((name) => {
const re = new RegExp(`{{\\s*${name}\\s*}}`);
return re.test(template);
});
}
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 packGroup = String(abonement?.pack_group || '').toLowerCase();
if (packGroup.includes('120')) return 120;
if (packGroup.includes('60')) return 60;
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || 0), 0);
const piecesByPack = totalPacks ? totalPacks * 10 : null;
if (piecesByPack === 60 || piecesByPack === 120) return piecesByPack;
return null;
}
async _resolveShippingFeeItem({ abonement, vatRate, lang }) {
const pieceCount = this._resolvePieceCountForQr(abonement);
if (!pieceCount) return null;
const shippingFee = await CoffeeShippingFeeService.get(pieceCount);
const unitPrice = Number(shippingFee?.price || 0);
if (!(unitPrice > 0)) return null;
return {
product_id: null,
sku: `SHIPPING-${pieceCount}`,
description: lang === 'de' ? `Versandkosten (${pieceCount} Stk.)` : `Shipping fee (${pieceCount} pcs)` ,
quantity: 1,
unit_price: unitPrice,
tax_rate: vatRate,
};
}
async _buildInvoiceItems({ abonement, vatRate, lang }) {
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
const items = breakdown.length
? breakdown.map((b) => ({
product_id: Number(b.coffee_table_id) || null,
sku: `COFFEE-${b.coffee_table_id || 'N/A'}`,
description: b.coffee_title || `Coffee subscription: ${b.coffee_table_id}`,
quantity: Number(b.packs || 1),
unit_price: Number(b.price_per_pack || 0),
tax_rate: b.tax_rate != null ? Number(b.tax_rate) : vatRate,
}))
: [
{
product_id: null,
sku: 'SUBSCRIPTION',
description: `Subscription ${abonement?.pack_group || ''}`,
quantity: 1,
unit_price: Number(abonement?.price || 0),
tax_rate: vatRate,
},
];
const shippingItem = await this._resolveShippingFeeItem({ abonement, vatRate, lang });
if (shippingItem) {
items.push(shippingItem);
}
return items;
}
async _getCompanySettingsQrDataUri(pieceCount) {
const safePieceCount = pieceCount === 120 ? 120 : 60;
try {
const repo = new CompanySettingsRepository();
const row = await repo.get();
if (!row) return null;
const raw = safePieceCount === 120 ? row?.qr_code_120_base64 : row?.qr_code_60_base64;
const value = (raw == null) ? '' : String(raw).trim();
if (!value) return null;
if (value.startsWith('data:image/')) return value;
const mime = this._inferImageMimeFromBase64(value);
return `data:${mime};base64,${value}`;
} catch (e) {
logger.warn('InvoiceService._getCompanySettingsQrDataUri:error', {
pieceCount: safePieceCount,
message: e?.message,
});
return null;
}
}
async _buildQrCodeImageTag({ abonement }) {
const pieceCount = this._resolvePieceCountForQr(abonement);
if (!pieceCount) return '';
const dataUri = await this._getCompanySettingsQrDataUri(pieceCount);
if (!dataUri) return '';
return `<img alt="QR Code" src="${this._escapeHtml(dataUri)}" />`;
}
_escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_formatAmount(amount, currency = 'EUR') {
const numeric = Number(amount || 0);
return `${numeric.toFixed(2)} ${currency}`;
}
async _s3BodyToString(body) {
if (!body) return '';
if (typeof body.transformToString === 'function') {
return body.transformToString();
}
const chunks = [];
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
_getEmailSubject(lang, invoiceNumber) {
return lang === 'de'
? `ProfitPlanet Rechnung ${invoiceNumber}`
: `ProfitPlanet Invoice ${invoiceNumber}`;
}
_buildItemsText(items, currency) {
if (!Array.isArray(items) || !items.length) return '- Subscription item';
return items.map((item, index) => {
const qty = Number(item.quantity || 0);
const lineGross = Number(item.line_gross || 0);
return `${index + 1}. ${item.description || 'Coffee'} | qty: ${qty} | total: ${this._formatAmount(lineGross, currency)}`;
}).join('\n');
}
_buildItemsHtml(items, currency) {
if (!Array.isArray(items) || !items.length) {
return '<li>Subscription item</li>';
}
return items.map((item) => {
const desc = this._escapeHtml(item.description || 'Coffee');
const qty = Number(item.quantity || 0);
const unit = this._formatAmount(item.unit_price || 0, currency);
const total = this._formatAmount(item.line_gross || 0, currency);
return `<li><strong>${desc}</strong> — ${qty} x ${this._escapeHtml(unit)} = ${this._escapeHtml(total)}</li>`;
}).join('');
}
_buildInvoiceText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
return [
isDe ? 'Ihre Rechnung wurde erstellt.' : 'Your invoice has been created.',
`${isDe ? 'Rechnungsnummer' : 'Invoice number'}: ${invoice.invoice_number}`,
`${isDe ? 'Kunde' : 'Customer'}: ${customerName}`,
`${isDe ? 'Datum' : 'Date'}: ${issuedAt}`,
`${isDe ? 'Positionen' : 'Items'}:`,
this._buildItemsText(items, invoice.currency),
`${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._formatAmount(invoice.total_gross, invoice.currency)}`,
].join('\n');
}
_buildInvoiceMailText({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
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.',
`${isDe ? 'Rechnungsnummer' : 'Invoice number'}: ${invoice.invoice_number}`,
`${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._formatAmount(invoice.total_gross, invoice.currency)}`,
'',
`${isDe ? 'Positionen' : 'Items'}:`,
this._buildItemsText(items, invoice.currency),
].join('\n');
}
_buildInvoiceMailHtml({ invoice, abonement, lang }) {
const isDe = lang === 'de';
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || 'Customer';
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this._escapeHtml(invoice.invoice_number)}</title>
</head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#111827;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Danke für Ihr Abonnement!' : 'Thank you for your subscription!'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${this._escapeHtml(customerName)},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe ? 'vielen Dank für Ihr Abonnement. Ihre Rechnung haben wir als PDF angehängt.' : 'thank you for your subscription. We attached your invoice as a PDF.'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(invoice.invoice_number || '')}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Gesamtbetrag' : 'Total'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</td>
</tr>
</table>
<p style="margin:0;font-size:13px;color:#6b7280;">${isDe ? 'Falls diese E-Mail nicht korrekt angezeigt wird, nutzen Sie bitte den Textinhalt oder kontaktieren Sie unseren Support.' : 'If this email is not displayed correctly, please use the text version or contact support.'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
_buildItemsTableRows(items, currency) {
if (!Array.isArray(items) || !items.length) {
return `<tr><td>1</td><td>Subscription item</td><td>1</td><td>-</td><td>-</td></tr>`;
}
return items.map((item, i) => {
const desc = this._escapeHtml(item.description || 'Coffee');
const qty = Number(item.quantity || 0);
const unit = this._formatAmount(item.unit_price || 0, currency);
const total = this._formatAmount(item.line_gross || 0, currency);
return `<tr><td>${i + 1}</td><td>${desc}</td><td>${qty}</td><td>${unit}</td><td>${total}</td></tr>`;
}).join('');
}
async _loadInvoiceHtmlTemplate() {
// Load the latest active invoice template from the contract manager (S3)
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice');
if (!Array.isArray(templates) || !templates.length) return null;
const selected = templates[0]; // latest active version
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;
}
}
_getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) {
return `<strong>${this._escapeHtml(bankAccountHolder)}</strong><br>${this._escapeHtml(bankIban)}<br>${this._escapeHtml(bankBic)}`;
}
_prepareVariablesForTemplate(templateHtml, variables) {
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
// by injecting the Profit Planet bank block (and optionally QR) into paymentInfoText.
if (!templateHtml) return variables;
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
const bankBlock = this._getProfitPlanetBankBlockHtml({
bankAccountHolder: variables.bankAccountHolder || 'Profit Planet GmbH',
bankIban: variables.bankIban || '',
bankBic: variables.bankBic || '',
});
const next = { ...variables };
if (!supportsBankVars) {
// Replace the default instruction text entirely with bank info
next.paymentInfoText = bankBlock;
}
if (!supportsQrVar && variables.qrCodeImage) {
// Append QR under payment info text when there's no dedicated placeholder
next.paymentInfoText = `${next.paymentInfoText || ''}<br><br>${variables.qrCodeImage}`;
}
return next;
}
async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
const isGift = abonement?.details?.is_for_self === false;
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-';
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
// Hardcoded bank info (Profit Planet)
const bankAccountHolder = 'Profit Planet GmbH';
const bankIban = 'AT16 2081 5000 4639 9507';
const bankBic = 'STSPAT2GXXX';
// Hardcoded footer/contact info (Profit Planet)
const footerText = [
'Profit Planet GmbH',
'Kärntner Straße 227',
'8053 Graz',
'',
'Kontakt',
'Telefon: 0676 344 0274',
'E-Mail: office@profit-planet.com',
'',
'Profit Planet GmbH',
bankIban,
bankBic,
].join('<br>');
// Hardcoded company address (Profit Planet)
const companyInfo = {
company_name: 'Profit Planet GmbH',
company_street: 'Kärntner Straße 227',
company_postal_city: '8053 Graz',
company_country: '',
};
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
// For self subscriptions: "Bill To" = the subscriber
let customerName;
let customerEmail = '';
let orderedByBlock = '';
if (isGift) {
// Recipient info for "Bill To"
const recipientName = abonement?.details?.recipient_name || '';
const recipientEmail = abonement?.email || invoice.buyer_email || '';
customerName = recipientName || recipientEmail || '-';
customerEmail = recipientName ? recipientEmail : '';
// Purchaser info for "Ordered by"
const purchaserName = 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 });
return {
lang: isDe ? 'de' : 'en',
documentTitle: isDe ? 'Rechnung' : 'Invoice',
invoiceNumber: this._escapeHtml(invoice.invoice_number || ''),
invoiceNumberLabel: isDe ? 'Rechnungsnummer' : 'Invoice Number',
fromLabel: isDe ? 'Von' : 'From',
toLabel: isDe ? 'An' : 'Bill To',
detailsLabel: isDe ? 'Details' : 'Details',
dateLabel: isDe ? 'Datum' : 'Date',
dueDateLabel: isDe ? 'Fällig am' : 'Due Date',
statusLabel: 'Status',
invoiceStatus: this._escapeHtml((invoice.status || 'issued').toUpperCase()),
companyName: this._escapeHtml(companyInfo.company_name || 'ProfitPlanet GmbH'),
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
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 || ''),
orderedByBlock,
issuedAt: this._escapeHtml(issuedAt),
dueAt: this._escapeHtml(dueAt),
descriptionHeader: isDe ? 'Beschreibung' : 'Description',
qtyHeader: isDe ? 'Menge' : 'Qty',
unitPriceHeader: isDe ? 'Stückpreis' : 'Unit Price',
totalHeader: isDe ? 'Gesamt' : 'Total',
itemsRows: this._buildItemsTableRows(items, invoice.currency),
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
taxLabel: isDe ? 'MwSt.' : 'Tax',
vatRateDisplay: vatRate ? `${vatRate}%` : '0%',
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)',
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information',
paymentInfoText: isDe
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
: 'Please transfer the total amount stating the invoice number as reference.',
bankAccountHolder: this._escapeHtml(bankAccountHolder),
bankIban: this._escapeHtml(bankIban),
bankBic: this._escapeHtml(bankBic),
qrCodeImage,
footerText,
// Legacy key used by S3-stored templates
itemsHtml: this._buildItemsHtml(items, invoice.currency),
};
}
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const template = await this._loadInvoiceHtmlTemplate();
if (template) {
const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
return this._renderTemplate(template, varsForTemplate);
}
// Absolute fallback if template file is missing
const isDe = lang === 'de';
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head>
<body>
<h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2>
<p>${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}</p>
<p>${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}</p>
<h3>${isDe ? 'Positionen' : 'Items'}</h3>
<ul>${variables.itemsHtml}</ul>
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}</strong></p>
</body>
</html>`;
}
async _loadInvoiceTemplateHtml({ lang = 'en' } = {}) {
try {
const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', '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];
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);
if (!html) return await this._loadLocalInvoiceTemplateHtml();
return html;
} catch (error) {
logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message });
return await this._loadLocalInvoiceTemplateHtml();
}
}
_renderTemplate(template, variables) {
if (!template) return null;
return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => variables[key] ?? '');
}
async _renderPdfFromHtml(html) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' }
});
return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
}
async _storeInvoicePdf(invoice, pdfBuffer) {
if (!pdfBuffer) return null;
const safeUser = invoice.user_id || 'unknown';
const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`;
await sharedExoscaleClient.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}));
await this.repo.updateStorageKey(invoice.id, key);
return key;
}
async _sendInvoiceEmail({ invoice, abonement, lang = 'en' }) {
const recipientEmail = invoice.buyer_email || abonement?.email;
if (!recipientEmail) {
logger.warn('InvoiceService._sendInvoiceEmail:missing_recipient', { invoiceId: invoice.id });
return;
}
const items = await this.repo.getItemsByInvoiceId(invoice.id);
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
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
const templateHtml = await this._loadInvoiceTemplateHtml({ lang });
let html = null;
if (templateHtml) {
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
const pieceCountForQr = this._resolvePieceCountForQr(abonement);
logger.info('InvoiceService._sendInvoiceEmail:template_compat', {
invoiceId: invoice?.id,
lang,
supportsBankVars,
supportsQrVar,
pieceCountForQr,
hasQrImage: Boolean(variables?.qrCodeImage),
});
const varsForTemplate = this._prepareVariablesForTemplate(templateHtml, variables);
html = this._renderTemplate(templateHtml, varsForTemplate);
// Final guard: if we still didn't embed QR but we expected one, force local template
const missingQr = variables.qrCodeImage && !html.includes('data:image/png;base64,');
if (missingQr) {
const localTemplate = await this._loadLocalInvoiceTemplateHtml();
if (localTemplate) {
const varsForLocal = this._prepareVariablesForTemplate(localTemplate, variables);
html = this._renderTemplate(localTemplate, varsForLocal);
}
}
}
const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
await this._storeInvoicePdf(invoice, pdfBuffer);
const mailHtml = this._buildInvoiceMailHtml({ invoice, abonement, lang });
await MailService.sendInvoiceEmail({
email: recipientEmail,
subject,
text,
html: mailHtml,
attachments: [{
name: `${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`,
content: pdfBuffer.toString('base64')
}],
lang,
});
}
// NEW: resolve current standard VAT rate for a buyer country code
async resolveVatRateForCountry(countryCode) {
if (!countryCode) return null;
const uow = new UnitOfWork();
await uow.start();
const taxRepo = new TaxRepository(uow);
try {
const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase());
if (!country) {
await uow.commit();
return null;
}
// get current vat row for this country
const [rows] = await taxRepo.conn.query(
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
[country.id]
);
await uow.commit();
const rate = rows?.[0]?.standard_rate;
return rate == null ? null : Number(rate);
} catch (e) {
await uow.rollback();
throw e;
}
}
// Issue invoice for a subscription period, with items from pack_breakdown
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) {
console.log('[INVOICE ISSUE] Inputs:', {
abonement_id: abonement?.id,
abonement_user_id: abonement?.user_id,
abonement_purchaser_user_id: abonement?.purchaser_user_id,
actorUserId,
periodStart,
periodEnd,
});
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
const buyerEmail = abonement.email || null;
const addr = {
street: abonement.street || null,
postal_code: abonement.postal_code || null,
city: abonement.city || null,
country: abonement.country || null,
};
const currency = abonement.currency || 'EUR';
// NEW: resolve invoice vat_rate (standard) from buyer country
const vat_rate = await this.resolveVatRateForCountry(addr.country);
const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang });
const context = {
source: 'abonement',
pack_group: abonement.pack_group || null,
period_start: periodStart,
period_end: periodEnd,
referred_by: abonement.referred_by || null,
};
// 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({
source_type: 'subscription',
source_id: abonement.id,
user_id: userIdForInvoice,
buyer_name: buyerName,
buyer_email: buyerEmail,
buyer_street: addr.street,
buyer_postal_code: addr.postal_code,
buyer_city: addr.city,
buyer_country: addr.country,
currency,
items,
status: 'issued',
issued_at: new Date(),
due_at: periodEnd,
context,
vat_rate, // NEW: persist on invoice
});
console.log('[INVOICE ISSUE] Created invoice:', {
id: invoice?.id,
user_id: invoice?.user_id,
source_type: invoice?.source_type,
source_id: invoice?.source_id,
total_net: invoice?.total_net,
total_tax: invoice?.total_tax,
total_gross: invoice?.total_gross,
});
try {
await this._sendInvoiceEmail({
invoice,
abonement,
lang,
});
logger.info('InvoiceService.issueForAbonement:invoice_email_sent', {
invoiceId: invoice?.id,
userId: invoice?.user_id,
hasEmail: Boolean(invoice?.buyer_email || abonement?.email),
});
} catch (mailError) {
logger.error('InvoiceService.issueForAbonement:invoice_email_error', {
invoiceId: invoice?.id,
message: mailError?.message,
stack: mailError?.stack,
brevoStatus: mailError?.statusCode ?? mailError?.response?.status ?? null,
brevoData: mailError?.body ?? mailError?.response?.data ?? mailError?.response?.text ?? null,
});
}
return invoice;
}
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
let poolResult = null;
try {
const inflowResult = await PoolInflowService.bookForPaidInvoice({
invoiceId: paidInvoice?.id,
paidAt: paid_at,
actorUserId: null,
});
poolResult = inflowResult;
console.log('[INVOICE PAID] Pool inflow booking result:', {
invoiceId: paidInvoice?.id,
...inflowResult,
});
} catch (e) {
poolResult = { error: e?.message || 'Pool inflow booking failed' };
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
}
// Attach pool result to returned data so the frontend can display it
if (paidInvoice) {
paidInvoice._poolResult = poolResult;
}
return paidInvoice;
}
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
return this.repo.listByUser(userId, { status, limit, offset });
}
async listByAbonement(abonementId) {
return this.repo.findByAbonement(abonementId);
}
async adminList({ status, limit = 200, offset = 0 } = {}) {
return this.repo.listAll({ status, limit, offset });
}
async updateStatus(invoiceId, newStatus) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
// If transitioning to 'paid', use the full markPaid flow for pool inflow booking
if (newStatus === 'paid' && invoice.status !== 'paid') {
return this.markPaid(invoiceId, {
payment_method: 'admin_manual',
amount: invoice.total_gross ?? 0,
paid_at: new Date(),
});
}
return this.repo.updateStatus(invoiceId, newStatus);
}
async getInvoiceDetail(invoiceId) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
const items = await this.repo.getItemsByInvoiceId(invoiceId);
const payments = await this.repo.getPaymentsByInvoiceId(invoiceId);
return { invoice, items, payments };
}
async getInvoicePdfStream(invoiceId, user) {
const invoice = await this.repo.getById(invoiceId);
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
// Non-admin users can only access their own invoices
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
if (!isAdmin && String(invoice.user_id) !== String(user?.id)) {
throw new Error('Invoice not found.');
}
if (!invoice.pdf_storage_key) {
throw new Error('No PDF available for this invoice.');
}
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: invoice.pdf_storage_key,
});
const obj = await sharedExoscaleClient.send(command);
return obj.Body;
}
/**
* Send an email report of all paid invoices to a given email.
* Optionally filter by date range.
* @param {{ email: string, from?: string, to?: string }} opts
* @returns {{ sentCount: number }}
*/
async sendEmailReport({ email, from, to }) {
if (!email) throw new Error('email is required');
const allInvoices = await this.repo.listAll({ status: 'paid', limit: 10000, offset: 0 });
// Optionally filter by date range
let paidInvoices = allInvoices;
if (from) {
const fromDate = new Date(from);
paidInvoices = paidInvoices.filter((inv) => {
const d = new Date(inv.issued_at || inv.created_at);
return d >= fromDate;
});
}
if (to) {
const toDate = new Date(to);
toDate.setHours(23, 59, 59, 999);
paidInvoices = paidInvoices.filter((inv) => {
const d = new Date(inv.issued_at || inv.created_at);
return d <= toDate;
});
}
if (!paidInvoices.length) {
throw new Error('No paid invoices found matching the criteria.');
}
// Collect PDF attachments for each paid invoice
const attachments = [];
for (const inv of paidInvoices) {
if (inv.pdf_storage_key) {
try {
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: inv.pdf_storage_key,
});
const obj = await sharedExoscaleClient.send(command);
if (typeof obj.Body.transformToByteArray === 'function') {
const bytes = await obj.Body.transformToByteArray();
attachments.push({
name: `${inv.invoice_number || `invoice-${inv.id}`}.pdf`,
content: Buffer.from(bytes).toString('base64'),
});
} else {
const chunks = [];
for await (const chunk of obj.Body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
attachments.push({
name: `${inv.invoice_number || `invoice-${inv.id}`}.pdf`,
content: Buffer.concat(chunks).toString('base64'),
});
}
} catch (e) {
logger.warn('InvoiceService.sendEmailReport:pdf_download_error', {
invoiceId: inv.id,
storageKey: inv.pdf_storage_key,
message: e?.message,
});
}
}
}
// Build email body with a summary table
const totalGross = paidInvoices.reduce((sum, inv) => sum + Number(inv.total_gross || 0), 0);
const currency = paidInvoices[0]?.currency || 'EUR';
const dateRange = [from, to].filter(Boolean).join(' ') || 'All time';
const invoiceRows = paidInvoices
.map(
(inv) =>
`<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${this._escapeHtml(inv.invoice_number || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${this._escapeHtml(inv.buyer_name || '-')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${inv.issued_at ? new Date(inv.issued_at).toISOString().slice(0, 10) : '-'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${this._formatAmount(inv.total_gross, inv.currency)}</td>
</tr>`,
)
.join('');
const subject = `ProfitPlanet Paid Invoices Report (${dateRange})`;
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr><td style="padding:24px 28px;background:#111827;color:#ffffff;">
<h1 style="margin:0;font-size:22px;">Paid Invoices Report</h1>
<p style="margin:6px 0 0 0;font-size:13px;color:#9ca3af;">${this._escapeHtml(dateRange)}</p>
</td></tr>
<tr><td style="padding:24px 28px;">
<p style="margin:0 0 16px 0;font-size:15px;">This report contains <strong>${paidInvoices.length}</strong> paid invoice(s).</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<thead>
<tr style="background:#f9fafb;">
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Invoice #</th>
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Customer</th>
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Date</th>
<th style="padding:10px 12px;text-align:right;font-size:13px;color:#6b7280;">Total</th>
</tr>
</thead>
<tbody>${invoiceRows}</tbody>
<tfoot>
<tr style="background:#f9fafb;">
<td colspan="3" style="padding:10px 12px;font-weight:700;font-size:13px;">Total</td>
<td style="padding:10px 12px;text-align:right;font-weight:700;font-size:13px;">${this._formatAmount(totalGross, currency)}</td>
</tr>
</tfoot>
</table>
${attachments.length ? '<p style="margin:16px 0 0 0;font-size:13px;color:#6b7280;">The individual invoice PDFs are attached to this email.</p>' : '<p style="margin:16px 0 0 0;font-size:13px;color:#6b7280;">No PDF attachments were available at this time.</p>'}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = paidInvoices
.map((inv) => `${inv.invoice_number} | ${inv.buyer_name || '-'} | ${inv.issued_at ? new Date(inv.issued_at).toISOString().slice(0, 10) : '-'} | ${this._formatAmount(inv.total_gross, inv.currency)}`)
.join('\n');
await MailService.sendInvoiceEmail({
email,
subject,
text: `Paid Invoices Report (${dateRange})\n\n${text}\n\nTotal: ${this._formatAmount(totalGross, currency)}`,
html,
lang: 'en',
attachments,
});
logger.info('InvoiceService.sendEmailReport:sent', {
recipientEmail: email,
sentCount: paidInvoices.length,
attachmentCount: attachments.length,
dateRange,
});
return { sentCount: paidInvoices.length };
}
}
module.exports = InvoiceService;