const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
const PoolInflowService = require('../pool/PoolInflowService');
const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService');
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
class InvoiceService {
constructor() {
this.repo = new InvoiceRepository();
}
_inferImageMimeFromBase64(base64) {
const s = String(base64 || '').trim();
if (!s) return 'image/png';
if (s.startsWith('iVBORw0KGgo')) return 'image/png';
if (s.startsWith('/9j/')) return 'image/jpeg';
if (s.startsWith('R0lGOD')) return 'image/gif';
return 'image/png';
}
_templateHasVars(template, varNames) {
if (!template) return false;
return varNames.every((name) => {
const re = new RegExp(`{{\\s*${name}\\s*}}`);
return re.test(template);
});
}
async _loadLocalInvoiceTemplateHtml() {
try {
const filePath = path.resolve(__dirname, '../../templates/invoice/invoiceTemplate.html');
return await fs.readFile(filePath, 'utf8');
} catch (e) {
logger.warn('InvoiceService._loadLocalInvoiceTemplateHtml:error', { message: e?.message });
return null;
}
}
_resolvePieceCountForQr(abonement) {
const packGroup = String(abonement?.pack_group || '').toLowerCase();
if (packGroup.includes('120')) return 120;
if (packGroup.includes('60')) return 60;
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || 0), 0);
const piecesByPack = totalPacks ? totalPacks * 10 : null;
if (piecesByPack === 60 || piecesByPack === 120) return piecesByPack;
return null;
}
async _resolveShippingFeeItem({ abonement, vatRate, lang }) {
const pieceCount = this._resolvePieceCountForQr(abonement);
if (!pieceCount) return null;
const shippingFee = await CoffeeShippingFeeService.get(pieceCount);
const unitPrice = Number(shippingFee?.price || 0);
if (!(unitPrice > 0)) return null;
return {
product_id: null,
sku: `SHIPPING-${pieceCount}`,
description: lang === 'de' ? `Versandkosten (${pieceCount} Stk.)` : `Shipping fee (${pieceCount} pcs)` ,
quantity: 1,
unit_price: unitPrice,
tax_rate: vatRate,
};
}
async _buildInvoiceItems({ abonement, vatRate, lang }) {
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
const items = breakdown.length
? breakdown.map((b) => ({
product_id: Number(b.coffee_table_id) || null,
sku: `COFFEE-${b.coffee_table_id || 'N/A'}`,
description: b.coffee_title || `Coffee subscription: ${b.coffee_table_id}`,
quantity: Number(b.packs || 1),
unit_price: Number(b.price_per_pack || 0),
tax_rate: b.tax_rate != null ? Number(b.tax_rate) : vatRate,
}))
: [
{
product_id: null,
sku: 'SUBSCRIPTION',
description: `Subscription ${abonement?.pack_group || ''}`,
quantity: 1,
unit_price: Number(abonement?.price || 0),
tax_rate: vatRate,
},
];
const shippingItem = await this._resolveShippingFeeItem({ abonement, vatRate, lang });
if (shippingItem) {
items.push(shippingItem);
}
return items;
}
async _getCompanySettingsQrDataUri(pieceCount) {
const safePieceCount = pieceCount === 120 ? 120 : 60;
try {
const repo = new CompanySettingsRepository();
const row = await repo.get();
if (!row) return null;
const raw = safePieceCount === 120 ? row?.qr_code_120_base64 : row?.qr_code_60_base64;
const value = (raw == null) ? '' : String(raw).trim();
if (!value) return null;
if (value.startsWith('data:image/')) return value;
const mime = this._inferImageMimeFromBase64(value);
return `data:${mime};base64,${value}`;
} catch (e) {
logger.warn('InvoiceService._getCompanySettingsQrDataUri:error', {
pieceCount: safePieceCount,
message: e?.message,
});
return null;
}
}
async _buildQrCodeImageTag({ abonement }) {
const pieceCount = this._resolvePieceCountForQr(abonement);
if (!pieceCount) return '';
const dataUri = await this._getCompanySettingsQrDataUri(pieceCount);
if (!dataUri) return '';
return ``;
}
_escapeHtml(value) {
return String(value ?? '')
.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 '
${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}
${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}
${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}
`; } async _loadInvoiceTemplateHtml({ lang = 'en' } = {}) { try { const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice'); if (!Array.isArray(templates) || !templates.length) return null; const selected = templates.find((t) => t.lang === lang) || templates.find((t) => t.lang === 'en') || templates[0]; if (!selected?.storageKey) return null; const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: selected.storageKey, }); const obj = await sharedExoscaleClient.send(command); const html = await this._s3BodyToString(obj.Body); if (!html) return await this._loadLocalInvoiceTemplateHtml(); return html; } catch (error) { logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message }); return await this._loadLocalInvoiceTemplateHtml(); } } _renderTemplate(template, variables) { if (!template) return null; return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => variables[key] ?? ''); } async _renderPdfFromHtml(html) { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true, margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' } }); return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer); } finally { await browser.close(); } } async _storeInvoicePdf(invoice, pdfBuffer) { if (!pdfBuffer) return null; const safeUser = invoice.user_id || 'unknown'; const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`; await sharedExoscaleClient.send(new PutObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key, Body: pdfBuffer, ContentType: 'application/pdf', })); await this.repo.updateStorageKey(invoice.id, key); return key; } async _sendInvoiceEmail({ invoice, abonement, lang = 'en' }) { const recipientEmail = invoice.buyer_email || abonement?.email; if (!recipientEmail) { logger.warn('InvoiceService._sendInvoiceEmail:missing_recipient', { invoiceId: invoice.id }); return; } const items = await this.repo.getItemsByInvoiceId(invoice.id); const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const subject = this._getEmailSubject(lang, invoice.invoice_number); // Build the full set of template variables once – used by both S3 and local paths const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); const templateHtml = await this._loadInvoiceTemplateHtml({ lang }); let html = null; if (templateHtml) { const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']); const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']); const pieceCountForQr = this._resolvePieceCountForQr(abonement); logger.info('InvoiceService._sendInvoiceEmail:template_compat', { invoiceId: invoice?.id, lang, supportsBankVars, supportsQrVar, pieceCountForQr, hasQrImage: Boolean(variables?.qrCodeImage), }); const varsForTemplate = this._prepareVariablesForTemplate(templateHtml, variables); html = this._renderTemplate(templateHtml, varsForTemplate); // Final guard: if we still didn't embed QR but we expected one, force local template const missingQr = variables.qrCodeImage && !html.includes('data:image/png;base64,'); if (missingQr) { const localTemplate = await this._loadLocalInvoiceTemplateHtml(); if (localTemplate) { const varsForLocal = this._prepareVariablesForTemplate(localTemplate, variables); html = this._renderTemplate(localTemplate, varsForLocal); } } } const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf); await this._storeInvoicePdf(invoice, pdfBuffer); const mailHtml = this._buildInvoiceMailHtml({ invoice, abonement, lang }); await MailService.sendInvoiceEmail({ email: recipientEmail, subject, text, html: mailHtml, attachments: [{ name: `${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`, content: pdfBuffer.toString('base64') }], lang, }); } // NEW: resolve current standard VAT rate for a buyer country code async resolveVatRateForCountry(countryCode) { if (!countryCode) return null; const uow = new UnitOfWork(); await uow.start(); const taxRepo = new TaxRepository(uow); try { const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase()); if (!country) { await uow.commit(); return null; } // get current vat row for this country const [rows] = await taxRepo.conn.query( `SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`, [country.id] ); await uow.commit(); const rate = rows?.[0]?.standard_rate; return rate == null ? null : Number(rate); } catch (e) { await uow.rollback(); throw e; } } // Issue invoice for a subscription period, with items from pack_breakdown async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId, lang = 'en' } = {}) { console.log('[INVOICE ISSUE] Inputs:', { abonement_id: abonement?.id, abonement_user_id: abonement?.user_id, abonement_purchaser_user_id: abonement?.purchaser_user_id, actorUserId, periodStart, periodEnd, }); const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null; const buyerEmail = abonement.email || null; const addr = { street: abonement.street || null, postal_code: abonement.postal_code || null, city: abonement.city || null, country: abonement.country || null, }; const currency = abonement.currency || 'EUR'; // NEW: resolve invoice vat_rate (standard) from buyer country const vat_rate = await this.resolveVatRateForCountry(addr.country); const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang }); const context = { source: 'abonement', pack_group: abonement.pack_group || null, period_start: periodStart, period_end: periodEnd, referred_by: abonement.referred_by || null, }; // CHANGED: prioritize token user id for invoice ownership const userIdForInvoice = actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null; console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice); const invoice = await this.repo.createInvoiceWithItems({ source_type: 'subscription', source_id: abonement.id, user_id: userIdForInvoice, buyer_name: buyerName, buyer_email: buyerEmail, buyer_street: addr.street, buyer_postal_code: addr.postal_code, buyer_city: addr.city, buyer_country: addr.country, currency, items, status: 'issued', issued_at: new Date(), due_at: periodEnd, context, vat_rate, // NEW: persist on invoice }); console.log('[INVOICE ISSUE] Created invoice:', { id: invoice?.id, user_id: invoice?.user_id, source_type: invoice?.source_type, source_id: invoice?.source_id, total_net: invoice?.total_net, total_tax: invoice?.total_tax, total_gross: invoice?.total_gross, }); try { await this._sendInvoiceEmail({ invoice, abonement, lang, }); logger.info('InvoiceService.issueForAbonement:invoice_email_sent', { invoiceId: invoice?.id, userId: invoice?.user_id, hasEmail: Boolean(invoice?.buyer_email || abonement?.email), }); } catch (mailError) { logger.error('InvoiceService.issueForAbonement:invoice_email_error', { invoiceId: invoice?.id, message: mailError?.message, stack: mailError?.stack, brevoStatus: mailError?.statusCode ?? mailError?.response?.status ?? null, brevoData: mailError?.body ?? mailError?.response?.data ?? mailError?.response?.text ?? null, }); } return invoice; } async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) { const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details }); let poolResult = null; try { const inflowResult = await PoolInflowService.bookForPaidInvoice({ invoiceId: paidInvoice?.id, paidAt: paid_at, actorUserId: null, }); poolResult = inflowResult; console.log('[INVOICE PAID] Pool inflow booking result:', { invoiceId: paidInvoice?.id, ...inflowResult, }); } catch (e) { poolResult = { error: e?.message || 'Pool inflow booking failed' }; console.error('[INVOICE PAID] Pool inflow booking failed:', e); } // Attach pool result to returned data so the frontend can display it if (paidInvoice) { paidInvoice._poolResult = poolResult; } return paidInvoice; } async listMine(userId, { status, limit = 50, offset = 0 } = {}) { return this.repo.listByUser(userId, { status, limit, offset }); } async listByAbonement(abonementId) { return this.repo.findByAbonement(abonementId); } async adminList({ status, limit = 200, offset = 0 } = {}) { return this.repo.listAll({ status, limit, offset }); } async updateStatus(invoiceId, newStatus) { const invoice = await this.repo.getById(invoiceId); if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`); // If transitioning to 'paid', use the full markPaid flow for pool inflow booking if (newStatus === 'paid' && invoice.status !== 'paid') { return this.markPaid(invoiceId, { payment_method: 'admin_manual', amount: invoice.total_gross ?? 0, paid_at: new Date(), }); } return this.repo.updateStatus(invoiceId, newStatus); } async getInvoiceDetail(invoiceId) { const invoice = await this.repo.getById(invoiceId); if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`); const items = await this.repo.getItemsByInvoiceId(invoiceId); const payments = await this.repo.getPaymentsByInvoiceId(invoiceId); return { invoice, items, payments }; } } module.exports = InvoiceService;