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 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); }); } _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}`; } _firstNonEmpty(...values) { for (const value of values) { if (value === undefined || value === null) continue; const normalized = String(value).trim(); if (normalized) return normalized; } return ''; } _normalizeInvoiceUserType(value) { return String(value || '').trim().toLowerCase() === 'company' ? 'company' : 'personal'; } _normalizeInvoiceTemplateTaxMode(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'standard' || normalized === 'reverse_charge' || normalized === 'both') { return normalized; } return 'both'; } _matchesLegacyReverseChargeTemplate(template, taxMode) { if (taxMode !== 'reverse_charge' || !template) return false; const haystack = [template?.name, template?.description, template?.storageKey].filter(Boolean).join(' ').toLowerCase(); return /reverse[\s_-]*charge/.test(haystack); } async _loadInvoiceUserProfile(userId) { if (!userId) return null; try { const [rows] = await pool.query( `SELECT u.id, u.email, u.user_type, cp.company_name, cp.registration_number, cp.atu_number, cp.country AS company_country FROM users u LEFT JOIN company_profiles cp ON cp.user_id = u.id WHERE u.id = ? LIMIT 1`, [userId], ); return rows?.[0] || null; } catch (error) { logger.warn('InvoiceService._loadInvoiceUserProfile:error', { userId, message: error?.message, }); return null; } } async _buildInvoiceBillingContext({ abonement, invoice = null, invoiceUserId = null, userProfile = null } = {}) { const profile = userProfile || await this._loadInvoiceUserProfile(invoiceUserId); const userType = this._normalizeInvoiceUserType(profile?.user_type); const shippingFullName = this._firstNonEmpty( [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' '), invoice?.buyer_name, profile?.company_name, abonement?.email, invoice?.buyer_email, 'Customer', ); const customerName = userType === 'company' ? this._firstNonEmpty( abonement?.invoice_full_name, profile?.company_name, invoice?.buyer_name, shippingFullName, ) : this._firstNonEmpty( abonement?.invoice_full_name, invoice?.buyer_name, shippingFullName, ); const email = this._firstNonEmpty( abonement?.invoice_email, invoice?.buyer_email, abonement?.email, profile?.email, ); const street = this._firstNonEmpty( abonement?.invoice_street, invoice?.buyer_street, abonement?.street, ); const postalCode = this._firstNonEmpty( abonement?.invoice_postal_code, invoice?.buyer_postal_code, abonement?.postal_code, ); const city = this._firstNonEmpty( abonement?.invoice_city, invoice?.buyer_city, abonement?.city, ); const country = this._firstNonEmpty( invoice?.buyer_country, abonement?.country, profile?.company_country, ); return { customerName: customerName || shippingFullName || '-', email, street, postalCode, city, country, postalCity: [postalCode, city].filter(Boolean).join(' '), userType, companyName: this._firstNonEmpty(profile?.company_name, abonement?.invoice_full_name), uidNumber: this._normalizeUid(profile?.atu_number || profile?.registration_number || ''), profile, }; } _selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) { if (!Array.isArray(templates) || !templates.length) return null; const safeLang = lang === 'de' ? 'de' : 'en'; const safeUserType = this._normalizeInvoiceUserType(userType); const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode); const matchesTaxMode = (template, mode) => this._normalizeInvoiceTemplateTaxMode(template?.tax_mode) === mode; const priorities = [ (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), (template) => template?.lang === safeLang && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), (template) => template?.lang === safeLang && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, 'both'), (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), (template) => template?.lang === 'en' && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), (template) => template?.lang === 'en' && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, 'both'), (template) => template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), (template) => template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.user_type === safeUserType && matchesTaxMode(template, 'both'), (template) => template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), (template) => template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), (template) => template?.user_type === 'both' && matchesTaxMode(template, 'both'), () => true, ]; for (const matches of priorities) { const selected = templates.find(matches); if (selected) return selected; } return templates[0]; } 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', userType = 'personal', taxMode = 'standard' } = {}) { try { const templates = await DocumentTemplateService.getActiveTemplatesForUserType(userType, 'invoice', null, taxMode); if (!Array.isArray(templates) || !templates.length) return null; const selected = this._selectInvoiceTemplate(templates, { lang, userType, taxMode }); 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 null; return html; } 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, 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 templates and the emergency HTML fallback const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); const templateHtml = await this._loadInvoiceTemplateHtml({ lang, userType: variables.invoiceUserType, taxMode: variables.invoiceTaxMode, }); 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) { return this._loadInvoiceUserProfile(userId); } async resolveTaxDecisionForSubscription({ buyerCountry, invoiceOwnerUserId, invoiceOwnerProfile = null, uidOverride = null }) { 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 = invoiceOwnerProfile || await this._loadCompanyTaxProfile(invoiceOwnerUserId); const uidCandidate = uidOverride || companyProfile?.atu_number || companyProfile?.registration_number || ''; const normalizedUid = this._normalizeUid(uidCandidate); const hasValidUid = this._isLikelyValidUid(normalizedUid); const countryCode = String(country?.country_code || '').toUpperCase(); const invoiceUserType = this._normalizeInvoiceUserType(companyProfile?.user_type); // Reverse charge for company customers with a valid UID outside seller country (AT). const isReverseCharge = Boolean(invoiceUserType === 'company' && hasValidUid && countryCode && countryCode !== 'AT'); return { vatRate: isReverseCharge ? 0 : vatRate, isReverseCharge, countryCode: countryCode || null, uid: hasValidUid ? normalizedUid : null, userType: invoiceUserType, }; } 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', uidOverride = null, taxModeHint = null } = {}) { 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 userIdForInvoice = actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null; const invoiceUserProfile = await this._loadInvoiceUserProfile(userIdForInvoice); const billingContext = await this._buildInvoiceBillingContext({ abonement, invoiceUserId: userIdForInvoice, userProfile: invoiceUserProfile, }); const buyerName = billingContext.customerName || null; const buyerEmail = billingContext.email || null; const addr = { street: billingContext.street || null, postal_code: billingContext.postalCode || null, city: billingContext.city || null, country: billingContext.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: userIdForInvoice, invoiceOwnerProfile: invoiceUserProfile, uidOverride, }); const vat_rate = taxDecision?.vatRate ?? null; logger.info('InvoiceService.issueForAbonement:tax_decision', { userId: userIdForInvoice, buyerCountry: addr.country || null, profileUserType: invoiceUserProfile?.user_type || null, providedUidOverride: uidOverride || null, taxModeHint: taxModeHint || null, taxMode: taxDecision?.isReverseCharge ? 'reverse_charge' : 'standard', resolvedUid: taxDecision?.uid || 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, invoice_user_type: billingContext.userType, }; 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 syncOverdueStatuses() { return this.repo.markIssuedPastDueAsOverdue(); } async listMine(userId, { status, limit = 50, offset = 0 } = {}) { await this.syncOverdueStatuses(); return this.repo.listByUser(userId, { status, limit, offset }); } async listByAbonement(abonementId) { await this.syncOverdueStatuses(); return this.repo.findByAbonement(abonementId); } async adminList({ status, limit = 200, offset = 0 } = {}) { await this.syncOverdueStatuses(); return this.repo.listAll({ status, limit, offset }); } async getRevenueSummary() { const summary = await this.repo.getPaidRevenueSummary(); return { totalPaidAllTime: Number(summary?.total_paid_all_time || 0), currency: summary?.currency || 'EUR', paidInvoiceCount: Number(summary?.paid_invoice_count || 0), }; } 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) { await this.syncOverdueStatuses(); 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) => `