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 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); }); } 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 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, '''); } _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']); 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) { 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) => `