1065 lines
43 KiB
JavaScript
1065 lines
43 KiB
JavaScript
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
|
||
const UnitOfWork = require('../../database/UnitOfWork'); // 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 pool = require('../../database/database');
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
_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;
|
||
const taxMode = String(invoice?.context?.tax_mode || 'standard').toLowerCase();
|
||
const isReverseCharge = taxMode === 'reverse_charge';
|
||
|
||
// 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: isReverseCharge ? (isDe ? 'Reverse Charge' : 'Reverse charge') : (isDe ? 'MwSt.' : 'Tax'),
|
||
vatRateDisplay: isReverseCharge ? (isDe ? 'nicht ausgewiesen' : 'not charged') : (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
|
||
? (isReverseCharge
|
||
? 'Reverse-Charge-Verfahren: Steuerschuldnerschaft des Leistungsempfängers. Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
|
||
: 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.')
|
||
: (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.'),
|
||
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 _resolveCountryByInput(conn, countryInput) {
|
||
const raw = String(countryInput || '').trim();
|
||
if (!raw) return null;
|
||
|
||
const code = raw.toUpperCase();
|
||
const [byCode] = await conn.query(
|
||
`SELECT id, country_code, country_name FROM countries WHERE UPPER(country_code) = ? LIMIT 1`,
|
||
[code],
|
||
);
|
||
if (byCode?.[0]) return byCode[0];
|
||
|
||
const [byName] = await conn.query(
|
||
`SELECT id, country_code, country_name FROM countries WHERE LOWER(country_name) = LOWER(?) LIMIT 1`,
|
||
[raw],
|
||
);
|
||
return byName?.[0] || null;
|
||
}
|
||
|
||
_normalizeUid(value) {
|
||
return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||
}
|
||
|
||
_isLikelyValidUid(value) {
|
||
const uid = this._normalizeUid(value);
|
||
return /^[A-Z]{2}[A-Z0-9]{6,14}$/.test(uid);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId }) {
|
||
const uow = new UnitOfWork();
|
||
await uow.start();
|
||
|
||
try {
|
||
const country = await this._resolveCountryByInput(uow.getConnection(), buyerCountry);
|
||
|
||
let vatRate = null;
|
||
if (country?.id) {
|
||
const [rows] = await uow.getConnection().query(
|
||
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
|
||
[country.id],
|
||
);
|
||
vatRate = rows?.[0]?.standard_rate == null ? null : Number(rows[0].standard_rate);
|
||
}
|
||
|
||
await uow.commit();
|
||
|
||
const companyProfile = 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();
|
||
|
||
// Reverse charge for company customers with a valid UID outside seller country (AT).
|
||
const isReverseCharge = Boolean(hasValidUid && countryCode && countryCode !== 'AT');
|
||
|
||
return {
|
||
vatRate: isReverseCharge ? 0 : vatRate,
|
||
isReverseCharge,
|
||
countryCode: countryCode || null,
|
||
uid: hasValidUid ? normalizedUid : null,
|
||
};
|
||
} catch (e) {
|
||
await uow.rollback();
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async resolveVatRateForCountry(countryCode) {
|
||
const decision = await this.resolveTaxDecisionForSubscription({
|
||
buyerCountry: countryCode,
|
||
invoiceOwnerUserId: null,
|
||
});
|
||
return decision?.vatRate ?? null;
|
||
}
|
||
|
||
// 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';
|
||
|
||
// 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,
|
||
});
|
||
const vat_rate = taxDecision?.vatRate ?? null;
|
||
|
||
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,
|
||
tax_mode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
|
||
customer_country_code: taxDecision?.countryCode || null,
|
||
uid_number: taxDecision?.uid || 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 };
|
||
}
|
||
|
||
async adminCreateManual(fields, pdfBuffer = null) {
|
||
const { uploadBuffer } = require('../../utils/exoscaleUploader');
|
||
|
||
const invoice = await this.repo.createManualInvoice({
|
||
source_type: 'manual',
|
||
buyer_name: fields.buyer_name || null,
|
||
buyer_email: fields.buyer_email || null,
|
||
buyer_street: fields.buyer_street || null,
|
||
buyer_postal_code: fields.buyer_postal_code || null,
|
||
buyer_city: fields.buyer_city || null,
|
||
buyer_country: fields.buyer_country || null,
|
||
currency: fields.currency || 'EUR',
|
||
total_net: fields.total_net != null ? Number(fields.total_net) : 0,
|
||
total_tax: fields.total_tax != null ? Number(fields.total_tax) : 0,
|
||
total_gross: Number(fields.total_gross || 0),
|
||
vat_rate: fields.vat_rate != null ? Number(fields.vat_rate) : null,
|
||
status: fields.status || 'issued',
|
||
issued_at: fields.issued_at ? new Date(fields.issued_at) : new Date(),
|
||
due_at: fields.due_at ? new Date(fields.due_at) : null,
|
||
context: { source: 'admin_manual_upload' },
|
||
});
|
||
|
||
if (pdfBuffer && pdfBuffer.length > 0) {
|
||
const { objectKey } = await uploadBuffer(
|
||
pdfBuffer,
|
||
`invoice-${invoice.invoice_number}.pdf`,
|
||
'application/pdf',
|
||
`invoices/admin/${invoice.id}`,
|
||
);
|
||
await this.repo.updateStorageKey(invoice.id, objectKey);
|
||
return this.repo.getById(invoice.id);
|
||
}
|
||
|
||
return invoice;
|
||
}
|
||
}
|
||
|
||
module.exports = InvoiceService;
|