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'); class InvoiceService { constructor() { this.repo = new InvoiceRepository(); } _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'); } _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 ` ${this._escapeHtml(invoice.invoice_number)}

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

    ${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}

    ${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}

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

    ${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}

    `; } 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 _storeInvoiceHtml(invoice, html) { if (!html) return null; const safeUser = invoice.user_id || 'unknown'; const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.html`; await sharedExoscaleClient.send(new PutObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key, Body: Buffer.from(html, 'utf8'), ContentType: 'text/html; charset=utf-8', })); 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._buildInvoiceText({ 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('
  • ')) { html += `

    ${lang === 'de' ? 'Positionen' : 'Items'}

    `; } } const htmlForStorage = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); await this._storeInvoiceHtml(invoice, htmlForStorage); await MailService.sendInvoiceEmail({ email: recipientEmail, subject, text, html, 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 adminList({ status, limit = 200, offset = 0 } = {}) { return this.repo.listAll({ status, limit, offset }); } } module.exports = InvoiceService;