450 lines
18 KiB
JavaScript
450 lines
18 KiB
JavaScript
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
|
|
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
|
|
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
|
|
const PoolInflowService = require('../pool/PoolInflowService');
|
|
const DocumentTemplateService = require('../template/DocumentTemplateService');
|
|
const MailService = require('../email/MailService');
|
|
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
|
const { logger } = require('../../middleware/logger');
|
|
const puppeteer = require('puppeteer');
|
|
|
|
class InvoiceService {
|
|
constructor() {
|
|
this.repo = new InvoiceRepository();
|
|
}
|
|
|
|
_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>`;
|
|
}
|
|
|
|
_buildFallbackInvoiceHtml({ 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 `<!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'}: ${this._escapeHtml(customerName)}</p>
|
|
<p>${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}</p>
|
|
<h3>${isDe ? 'Positionen' : 'Items'}</h3>
|
|
<ul>${this._buildItemsHtml(items, invoice.currency)}</ul>
|
|
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</strong></p>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async _loadInvoiceTemplateHtml({ userType = 'personal', lang = 'en' } = {}) {
|
|
try {
|
|
const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice');
|
|
if (!Array.isArray(templates) || !templates.length) return null;
|
|
|
|
const selected = templates.find((t) => t.lang === lang) || templates.find((t) => t.lang === 'en') || templates[0];
|
|
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);
|
|
return html || null;
|
|
} 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, userType = 'personal', 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);
|
|
|
|
const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang });
|
|
let html = null;
|
|
|
|
if (templateHtml) {
|
|
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);
|
|
const variables = {
|
|
invoiceNumber: this._escapeHtml(invoice.invoice_number || ''),
|
|
customerName: this._escapeHtml(customerName),
|
|
issuedAt: this._escapeHtml(issuedAt),
|
|
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
|
|
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
|
|
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
|
|
itemsHtml: this._buildItemsHtml(items, invoice.currency),
|
|
};
|
|
html = this._renderTemplate(templateHtml, variables);
|
|
|
|
if (html && !html.includes('<li>')) {
|
|
html += `<hr><h3>${lang === 'de' ? 'Positionen' : 'Items'}</h3><ul>${variables.itemsHtml}</ul>`;
|
|
}
|
|
}
|
|
|
|
const htmlForPdf = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
|
|
const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
|
|
await this._storeInvoicePdf(invoice, pdfBuffer);
|
|
|
|
const mailHtml = this._buildInvoiceMailHtml({ invoice, abonement, lang });
|
|
|
|
await MailService.sendInvoiceEmail({
|
|
email: recipientEmail,
|
|
subject,
|
|
text,
|
|
html: mailHtml,
|
|
attachments: [{
|
|
name: `${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`,
|
|
content: pdfBuffer.toString('base64')
|
|
}],
|
|
lang,
|
|
});
|
|
}
|
|
|
|
// NEW: resolve current standard VAT rate for a buyer country code
|
|
async resolveVatRateForCountry(countryCode) {
|
|
if (!countryCode) return null;
|
|
const uow = new UnitOfWork();
|
|
await uow.start();
|
|
const taxRepo = new TaxRepository(uow);
|
|
try {
|
|
const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase());
|
|
if (!country) {
|
|
await uow.commit();
|
|
return null;
|
|
}
|
|
// get current vat row for this country
|
|
const [rows] = await taxRepo.conn.query(
|
|
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
|
|
[country.id]
|
|
);
|
|
await uow.commit();
|
|
const rate = rows?.[0]?.standard_rate;
|
|
return rate == null ? null : Number(rate);
|
|
} catch (e) {
|
|
await uow.rollback();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Issue invoice for a subscription period, with items from pack_breakdown
|
|
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, userType = 'personal', lang = 'en' } = {}) {
|
|
console.log('[INVOICE ISSUE] Inputs:', {
|
|
abonement_id: abonement?.id,
|
|
abonement_user_id: abonement?.user_id,
|
|
abonement_purchaser_user_id: abonement?.purchaser_user_id,
|
|
actorUserId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
|
|
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
|
|
const buyerEmail = abonement.email || null;
|
|
const addr = {
|
|
street: abonement.street || null,
|
|
postal_code: abonement.postal_code || null,
|
|
city: abonement.city || null,
|
|
country: abonement.country || null,
|
|
};
|
|
const currency = abonement.currency || 'EUR';
|
|
|
|
// NEW: resolve invoice vat_rate (standard) from buyer country
|
|
const vat_rate = await this.resolveVatRateForCountry(addr.country);
|
|
|
|
const 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) : vat_rate, // CHANGED: default to invoice vat_rate
|
|
}))
|
|
: [
|
|
{
|
|
product_id: null,
|
|
sku: 'SUBSCRIPTION',
|
|
description: `Subscription ${abonement.pack_group || ''}`,
|
|
quantity: 1,
|
|
unit_price: Number(abonement.price || 0),
|
|
tax_rate: vat_rate, // CHANGED
|
|
},
|
|
];
|
|
|
|
const context = {
|
|
source: 'abonement',
|
|
pack_group: abonement.pack_group || null,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
referred_by: abonement.referred_by || null,
|
|
};
|
|
|
|
// CHANGED: prioritize token user id for invoice ownership
|
|
const userIdForInvoice =
|
|
actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
|
|
|
|
console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice);
|
|
|
|
const invoice = await this.repo.createInvoiceWithItems({
|
|
source_type: 'subscription',
|
|
source_id: abonement.id,
|
|
user_id: userIdForInvoice,
|
|
buyer_name: buyerName,
|
|
buyer_email: buyerEmail,
|
|
buyer_street: addr.street,
|
|
buyer_postal_code: addr.postal_code,
|
|
buyer_city: addr.city,
|
|
buyer_country: addr.country,
|
|
currency,
|
|
items,
|
|
status: 'issued',
|
|
issued_at: new Date(),
|
|
due_at: periodEnd,
|
|
context,
|
|
vat_rate, // NEW: persist on invoice
|
|
});
|
|
|
|
console.log('[INVOICE ISSUE] Created invoice:', {
|
|
id: invoice?.id,
|
|
user_id: invoice?.user_id,
|
|
source_type: invoice?.source_type,
|
|
source_id: invoice?.source_id,
|
|
total_net: invoice?.total_net,
|
|
total_tax: invoice?.total_tax,
|
|
total_gross: invoice?.total_gross,
|
|
});
|
|
|
|
try {
|
|
await this._sendInvoiceEmail({
|
|
invoice,
|
|
abonement,
|
|
userType,
|
|
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,
|
|
});
|
|
}
|
|
|
|
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 });
|
|
|
|
try {
|
|
const inflowResult = await PoolInflowService.bookForPaidInvoice({
|
|
invoiceId: paidInvoice?.id,
|
|
paidAt: paid_at,
|
|
actorUserId: null,
|
|
});
|
|
console.log('[INVOICE PAID] Pool inflow booking result:', {
|
|
invoiceId: paidInvoice?.id,
|
|
...inflowResult,
|
|
});
|
|
} catch (e) {
|
|
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
module.exports = InvoiceService;
|