From 449a83473c1d9f516dbf7ab94e12462627c6e383 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 7 Apr 2026 17:05:14 +0200 Subject: [PATCH] feat: add sendEmailReport method to InvoiceService and corresponding route --- controller/invoice/InvoiceController.js | 27 ++++ routes/getRoutes.js | 1 + routes/postRoutes.js | 1 + services/invoice/InvoiceService.js | 171 ++++++++++++++++++++++++ 4 files changed, 200 insertions(+) diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js index d4a7628..b74c99b 100644 --- a/controller/invoice/InvoiceController.js +++ b/controller/invoice/InvoiceController.js @@ -69,4 +69,31 @@ module.exports = { return res.status(400).json({ success: false, message: e.message }); } }, + + async downloadPdf(req, res) { + try { + const stream = await service.getInvoicePdfStream(req.params.id, req.user); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="invoice-${req.params.id}.pdf"`); + stream.pipe(res); + } catch (e) { + console.error('[INVOICE DOWNLOAD PDF]', e); + if (e.message?.includes('not found') || e.message?.includes('No PDF')) { + return res.status(404).json({ success: false, message: e.message }); + } + return res.status(400).json({ success: false, message: e.message }); + } + }, + + async sendEmailReport(req, res) { + try { + const { email, from, to } = req.body; + if (!email) return res.status(400).json({ success: false, message: 'email is required' }); + const data = await service.sendEmailReport({ email, from, to }); + return res.json({ success: true, data }); + } catch (e) { + console.error('[INVOICE EMAIL REPORT]', e); + return res.status(400).json({ success: false, message: e.message }); + } + }, }; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 436795e..9f32e0a 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -188,6 +188,7 @@ router.get('/news/:slug', NewsController.getPublic); // NEW: Invoice GETs router.get('/invoices/mine', authMiddleware, InvoiceController.listMine); +router.get('/invoices/:id/pdf', authMiddleware, InvoiceController.downloadPdf); router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList); router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index d04e3ef..dc4b1fd 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -187,6 +187,7 @@ router.post('/abonements/referred', authMiddleware, ensureUserFromBody, Abonemme // NEW: Invoice POSTs router.post('/invoices/:id/pay', authMiddleware, adminOnly, InvoiceController.pay); +router.post('/admin/invoices/email-report', authMiddleware, adminOnly, InvoiceController.sendEmailReport); // Existing registration handlers (keep) router.post('/register/personal', (req, res) => { diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 3592b61..78d2609 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -778,6 +778,177 @@ class InvoiceService { 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) => + ` + ${this._escapeHtml(inv.invoice_number || '')} + ${this._escapeHtml(inv.buyer_name || '-')} + ${inv.issued_at ? new Date(inv.issued_at).toISOString().slice(0, 10) : '-'} + ${this._formatAmount(inv.total_gross, inv.currency)} + `, + ) + .join(''); + + const subject = `ProfitPlanet – Paid Invoices Report (${dateRange})`; + + const html = ` + + + + + +
+ + + +
+

Paid Invoices Report

+

${this._escapeHtml(dateRange)}

+
+

This report contains ${paidInvoices.length} paid invoice(s).

+ + + + + + + + + + ${invoiceRows} + + + + + + +
Invoice #CustomerDateTotal
Total${this._formatAmount(totalGross, currency)}
+ ${attachments.length ? '

The individual invoice PDFs are attached to this email.

' : '

No PDF attachments were available at this time.

'} +
+
+ +`; + + const text = paidInvoices + .map((inv) => `${inv.invoice_number} | ${inv.buyer_name || '-'} | ${inv.issued_at ? new Date(inv.issued_at).toISOString().slice(0, 10) : '-'} | ${this._formatAmount(inv.total_gross, inv.currency)}`) + .join('\n'); + + await MailService.sendInvoiceEmail({ + email, + subject, + text: `Paid Invoices Report (${dateRange})\n\n${text}\n\nTotal: ${this._formatAmount(totalGross, currency)}`, + html, + lang: 'en', + attachments, + }); + + logger.info('InvoiceService.sendEmailReport:sent', { + recipientEmail: email, + sentCount: paidInvoices.length, + attachmentCount: attachments.length, + dateRange, + }); + + return { sentCount: paidInvoices.length }; + } } module.exports = InvoiceService;