1225 lines
50 KiB
JavaScript
1225 lines
50 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 pool = require('../../database/database');
|
||
|
||
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
|
||
|
||
class InvoiceService {
|
||
constructor() {
|
||
this.repo = new InvoiceRepository();
|
||
}
|
||
|
||
_templateHasVars(template, varNames) {
|
||
if (!template) return false;
|
||
return varNames.every((name) => {
|
||
const re = new RegExp(`{{\\s*${name}\\s*}}`);
|
||
return re.test(template);
|
||
});
|
||
}
|
||
|
||
_resolvePieceCountForQr(abonement) {
|
||
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
|
||
const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || item?.quantity || 0), 0);
|
||
const piecesByPack = totalPacks ? totalPacks * 10 : null;
|
||
if (piecesByPack != null) {
|
||
if (piecesByPack >= 120) return 120;
|
||
if (piecesByPack >= 60) return 60;
|
||
return null;
|
||
}
|
||
|
||
const packGroup = String(abonement?.pack_group || '').toLowerCase();
|
||
if (packGroup.includes('120')) return 120;
|
||
if (packGroup.includes('60')) return 60;
|
||
|
||
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 _buildQrCodeImageTag({ abonement }) {
|
||
return '';
|
||
}
|
||
|
||
_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}`;
|
||
}
|
||
|
||
_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';
|
||
}
|
||
|
||
_normalizeInvoiceTemplateTaxMode(value) {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
if (normalized === 'standard' || normalized === 'reverse_charge' || normalized === 'both') {
|
||
return normalized;
|
||
}
|
||
return 'both';
|
||
}
|
||
|
||
_matchesLegacyReverseChargeTemplate(template, taxMode) {
|
||
if (taxMode !== 'reverse_charge' || !template) return false;
|
||
const haystack = [template?.name, template?.description, template?.storageKey].filter(Boolean).join(' ').toLowerCase();
|
||
return /reverse[\s_-]*charge/.test(haystack);
|
||
}
|
||
|
||
async _loadInvoiceUserProfile(userId) {
|
||
if (!userId) return null;
|
||
|
||
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', taxMode = 'standard' } = {}) {
|
||
if (!Array.isArray(templates) || !templates.length) return null;
|
||
|
||
const safeLang = lang === 'de' ? 'de' : 'en';
|
||
const safeUserType = this._normalizeInvoiceUserType(userType);
|
||
const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode);
|
||
const matchesTaxMode = (template, mode) => this._normalizeInvoiceTemplateTaxMode(template?.tax_mode) === mode;
|
||
const priorities = [
|
||
(template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.lang === safeLang && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, 'both'),
|
||
(template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.lang === safeLang && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, 'both'),
|
||
(template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.lang === 'en' && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, 'both'),
|
||
(template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.lang === 'en' && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, 'both'),
|
||
(template) => template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.user_type === safeUserType && matchesTaxMode(template, 'both'),
|
||
(template) => template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode),
|
||
(template) => template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode),
|
||
(template) => template?.user_type === 'both' && matchesTaxMode(template, 'both'),
|
||
() => true,
|
||
];
|
||
|
||
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') {
|
||
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 = 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 [
|
||
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 = 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.',
|
||
`${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 = 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>
|
||
<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(options = {}) {
|
||
return this._loadInvoiceTemplateHtml(options);
|
||
}
|
||
|
||
_getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) {
|
||
return `<strong>${this._escapeHtml(bankAccountHolder)}</strong><br>${this._escapeHtml(bankIban)}<br>${this._escapeHtml(bankBic)}`;
|
||
}
|
||
|
||
async _loadCompanyInfo() {
|
||
try {
|
||
const [rows] = await pool.query(
|
||
`SELECT company_name, company_street, company_postal_city, company_country,
|
||
company_logo_base64, company_logo_mime_type
|
||
FROM company_settings
|
||
WHERE id = 1
|
||
LIMIT 1`
|
||
);
|
||
return rows?.[0] || {};
|
||
} catch (e) {
|
||
logger.warn('InvoiceService._loadCompanyInfo:error', { message: e?.message });
|
||
return {};
|
||
}
|
||
}
|
||
|
||
_buildCompanyLogoTag(companyInfo) {
|
||
const base64 = typeof companyInfo?.company_logo_base64 === 'string' ? companyInfo.company_logo_base64.trim() : '';
|
||
const mimeType = typeof companyInfo?.company_logo_mime_type === 'string' ? companyInfo.company_logo_mime_type.trim() : '';
|
||
if (!base64 || !mimeType || !mimeType.startsWith('image/')) return '';
|
||
|
||
const alt = this._escapeHtml(companyInfo?.company_name || 'Company logo');
|
||
return `<img src="data:${mimeType};base64,${base64}" alt="${alt}">`;
|
||
}
|
||
|
||
_prepareVariablesForTemplate(templateHtml, variables) {
|
||
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
|
||
// by injecting the Profit Planet bank block into paymentInfoText.
|
||
if (!templateHtml) return variables;
|
||
|
||
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
|
||
|
||
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;
|
||
}
|
||
|
||
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';
|
||
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';
|
||
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>');
|
||
|
||
const storedCompanyInfo = await this._loadCompanyInfo();
|
||
const companyInfo = {
|
||
company_name: storedCompanyInfo.company_name || 'Profit Planet GmbH',
|
||
company_street: storedCompanyInfo.company_street || 'Kärntner Straße 227',
|
||
company_postal_city: storedCompanyInfo.company_postal_city || '8053 Graz',
|
||
company_country: storedCompanyInfo.company_country || 'Austria',
|
||
company_logo_base64: storedCompanyInfo.company_logo_base64 || null,
|
||
company_logo_mime_type: storedCompanyInfo.company_logo_mime_type || null,
|
||
};
|
||
|
||
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
|
||
// For self subscriptions: "Bill To" = the subscriber
|
||
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) {
|
||
// Recipient info for "Bill To"
|
||
const recipientName = abonement?.details?.recipient_name || '';
|
||
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 = 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>`;
|
||
}
|
||
}
|
||
|
||
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'),
|
||
companyLogo: this._buildCompanyLogoTag(companyInfo),
|
||
invoiceUserType: billingContext.userType,
|
||
invoiceTaxMode: isReverseCharge ? 'reverse_charge' : 'standard',
|
||
customerName: this._escapeHtml(customerName),
|
||
customerEmail: this._escapeHtml(customerEmail),
|
||
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),
|
||
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.'),
|
||
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),
|
||
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({
|
||
lang,
|
||
userType: variables.invoiceUserType,
|
||
taxMode: variables.invoiceTaxMode,
|
||
});
|
||
if (template) {
|
||
const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
|
||
return this._renderTemplate(template, varsForTemplate);
|
||
}
|
||
|
||
// Absolute fallback if no active contract-manager template is available
|
||
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', userType = 'personal', taxMode = 'standard' } = {}) {
|
||
try {
|
||
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice', null, taxMode);
|
||
if (!Array.isArray(templates) || !templates.length) return null;
|
||
|
||
const selected = this._selectInvoiceTemplate(templates, { lang, userType, taxMode });
|
||
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 null;
|
||
return html;
|
||
} catch (error) {
|
||
logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
_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 templates and the emergency HTML fallback
|
||
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
|
||
|
||
const templateHtml = await this._loadInvoiceTemplateHtml({
|
||
lang,
|
||
userType: variables.invoiceUserType,
|
||
taxMode: variables.invoiceTaxMode,
|
||
});
|
||
let html = null;
|
||
|
||
if (templateHtml) {
|
||
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
|
||
logger.info('InvoiceService._sendInvoiceEmail:template_compat', {
|
||
invoiceId: invoice?.id,
|
||
lang,
|
||
supportsBankVars,
|
||
});
|
||
|
||
const varsForTemplate = this._prepareVariablesForTemplate(templateHtml, variables);
|
||
html = this._renderTemplate(templateHtml, varsForTemplate);
|
||
}
|
||
|
||
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) {
|
||
return this._loadInvoiceUserProfile(userId);
|
||
}
|
||
|
||
async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId, invoiceOwnerProfile = null, uidOverride = null }) {
|
||
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 = invoiceOwnerProfile || await this._loadCompanyTaxProfile(invoiceOwnerUserId);
|
||
const uidCandidate = uidOverride || 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(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();
|
||
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', uidOverride = null, taxModeHint = null } = {}) {
|
||
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 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: 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: userIdForInvoice,
|
||
invoiceOwnerProfile: invoiceUserProfile,
|
||
uidOverride,
|
||
});
|
||
const vat_rate = taxDecision?.vatRate ?? null;
|
||
|
||
logger.info('InvoiceService.issueForAbonement:tax_decision', {
|
||
userId: userIdForInvoice,
|
||
buyerCountry: addr.country || null,
|
||
profileUserType: invoiceUserProfile?.user_type || null,
|
||
providedUidOverride: uidOverride || null,
|
||
taxModeHint: taxModeHint || null,
|
||
taxMode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard',
|
||
resolvedUid: taxDecision?.uid || 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,
|
||
invoice_user_type: billingContext.userType,
|
||
};
|
||
|
||
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 syncOverdueStatuses() {
|
||
return this.repo.markIssuedPastDueAsOverdue();
|
||
}
|
||
|
||
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
|
||
await this.syncOverdueStatuses();
|
||
return this.repo.listByUser(userId, { status, limit, offset });
|
||
}
|
||
|
||
async listByAbonement(abonementId) {
|
||
await this.syncOverdueStatuses();
|
||
return this.repo.findByAbonement(abonementId);
|
||
}
|
||
|
||
async adminList({ status, limit = 200, offset = 0 } = {}) {
|
||
await this.syncOverdueStatuses();
|
||
return this.repo.listAll({ status, limit, offset });
|
||
}
|
||
|
||
async getRevenueSummary() {
|
||
const summary = await this.repo.getPaidRevenueSummary();
|
||
return {
|
||
totalPaidAllTime: Number(summary?.total_paid_all_time || 0),
|
||
currency: summary?.currency || 'EUR',
|
||
paidInvoiceCount: Number(summary?.paid_invoice_count || 0),
|
||
};
|
||
}
|
||
|
||
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) {
|
||
await this.syncOverdueStatuses();
|
||
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;
|