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'); 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 _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 `QR Code`; } _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 '
  • Subscription item
  • '; } 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 `
  • ${desc} — ${qty} x ${this._escapeHtml(unit)} = ${this._escapeHtml(total)}
  • `; }).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 ` ${this._escapeHtml(invoice.invoice_number)}
    ${logoUrl ? `ProfitPlanet` : ''}

    ${isDe ? 'Danke für Ihr Abonnement!' : 'Thank you for your subscription!'}

    ${isDe ? 'Hallo' : 'Hi'} ${this._escapeHtml(customerName)},

    ${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.'}

    ${isDe ? 'Rechnungsnummer' : 'Invoice number'} ${this._escapeHtml(invoice.invoice_number || '')}
    ${isDe ? 'Gesamtbetrag' : 'Total'} ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}

    ${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.'}

    `; } _buildItemsTableRows(items, currency) { if (!Array.isArray(items) || !items.length) { return `1Subscription item1--`; } 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 `${i + 1}${desc}${qty}${unit}${total}`; }).join(''); } async _loadInvoiceHtmlTemplate() { // Load the latest active invoice template from the contract manager (S3) try { const templates = await DocumentTemplateService.getActiveTemplatesForUserType('both', 'invoice'); if (!Array.isArray(templates) || !templates.length) return null; const selected = templates[0]; // latest active version if (!selected?.storageKey) return null; const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: selected.storageKey, }); const obj = await sharedExoscaleClient.send(command); const html = await this._s3BodyToString(obj.Body) || null; if (!html) return null; return html; } catch (e) { logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message }); return null; } } _getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) { return `${this._escapeHtml(bankAccountHolder)}
    ${this._escapeHtml(bankIban)}
    ${this._escapeHtml(bankBic)}`; } _prepareVariablesForTemplate(templateHtml, variables) { // Ensure backwards compatibility with older templates that only contain {{paymentInfoText}} // by injecting the Profit Planet bank block (and optionally QR) into paymentInfoText. if (!templateHtml) return variables; const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']); const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']); const bankBlock = this._getProfitPlanetBankBlockHtml({ bankAccountHolder: variables.bankAccountHolder || 'Profit Planet GmbH', bankIban: variables.bankIban || '', bankBic: variables.bankBic || '', }); const next = { ...variables }; if (!supportsBankVars) { // Replace the default instruction text entirely with bank info next.paymentInfoText = bankBlock; } if (!supportsQrVar && variables.qrCodeImage) { // Append QR under payment info text when there's no dedicated placeholder next.paymentInfoText = `${next.paymentInfoText || ''}

    ${variables.qrCodeImage}`; } return next; } async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) { const isDe = lang === 'de'; const isGift = abonement?.details?.is_for_self === false; const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-'; const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0; // 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('
    '); // Hardcoded company address (Profit Planet) const companyInfo = { company_name: 'Profit Planet GmbH', company_street: 'Kärntner Straße 227', company_postal_city: '8053 Graz', company_country: '', }; // For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser // For self subscriptions: "Bill To" = the subscriber let customerName; let customerEmail = ''; let orderedByBlock = ''; if (isGift) { // Recipient info for "Bill To" const recipientName = abonement?.details?.recipient_name || ''; const recipientEmail = abonement?.email || invoice.buyer_email || ''; customerName = recipientName || recipientEmail || '-'; customerEmail = recipientName ? recipientEmail : ''; // Purchaser info for "Ordered by" const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || ''; if (purchaserName) { const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By'; orderedByBlock = `

    ${this._escapeHtml(orderedByLabel)}

    ${this._escapeHtml(purchaserName)}

    `; } } else { customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-'; customerEmail = abonement?.email || invoice.buyer_email || ''; } const qrCodeImage = await this._buildQrCodeImageTag({ abonement }); return { lang: isDe ? 'de' : 'en', documentTitle: isDe ? 'Rechnung' : 'Invoice', invoiceNumber: this._escapeHtml(invoice.invoice_number || ''), invoiceNumberLabel: isDe ? 'Rechnungsnummer' : 'Invoice Number', fromLabel: isDe ? 'Von' : 'From', toLabel: isDe ? 'An' : 'Bill To', detailsLabel: isDe ? 'Details' : 'Details', dateLabel: isDe ? 'Datum' : 'Date', dueDateLabel: isDe ? 'Fällig am' : 'Due Date', statusLabel: 'Status', invoiceStatus: this._escapeHtml((invoice.status || 'issued').toUpperCase()), companyName: this._escapeHtml(companyInfo.company_name || 'ProfitPlanet GmbH'), companyStreet: this._escapeHtml(companyInfo.company_street || ''), companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''), companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'), customerName: this._escapeHtml(customerName), customerEmail: this._escapeHtml(customerEmail), customerStreet: this._escapeHtml(invoice.buyer_street || ''), customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')), customerCountry: this._escapeHtml(invoice.buyer_country || ''), orderedByBlock, issuedAt: this._escapeHtml(issuedAt), dueAt: this._escapeHtml(dueAt), descriptionHeader: isDe ? 'Beschreibung' : 'Description', qtyHeader: isDe ? 'Menge' : 'Qty', unitPriceHeader: isDe ? 'Stückpreis' : 'Unit Price', totalHeader: isDe ? 'Gesamt' : 'Total', itemsRows: this._buildItemsTableRows(items, invoice.currency), subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)', taxLabel: isDe ? 'MwSt.' : 'Tax', vatRateDisplay: 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 ? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.' : 'Please transfer the total amount stating the invoice number as reference.', bankAccountHolder: this._escapeHtml(bankAccountHolder), bankIban: this._escapeHtml(bankIban), bankBic: this._escapeHtml(bankBic), qrCodeImage, footerText, // Legacy key used by S3-stored templates itemsHtml: this._buildItemsHtml(items, invoice.currency), }; } async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) { const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); const template = await this._loadInvoiceHtmlTemplate(); if (template) { const varsForTemplate = this._prepareVariablesForTemplate(template, variables); return this._renderTemplate(template, varsForTemplate); } // Absolute fallback if template file is missing const isDe = lang === 'de'; return ` ${this._escapeHtml(invoice.invoice_number)}

    ${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}

    ${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}

    ${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}

    ${isDe ? 'Positionen' : 'Items'}

    ${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 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, 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 }); 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;