From bd21801c41d5719bf5fb3fc5e11f59b4270bb909 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 17 Mar 2026 18:59:24 +0100 Subject: [PATCH 1/4] refactor: remove local QR data URI caching and streamline QR code retrieval --- services/invoice/InvoiceService.js | 33 ++---------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 998821c..cfbd226 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -16,7 +16,6 @@ const CompanySettingsRepository = require('../../repositories/settings/CompanySe class InvoiceService { constructor() { this.repo = new InvoiceRepository(); - this._qrDataUriCache = new Map(); } _inferImageMimeFromBase64(base64) { @@ -59,17 +58,12 @@ class InvoiceService { return null; } - _getLocalQrImagePath(pieceCount) { - const safePieceCount = pieceCount === 120 ? 120 : 60; - const fileName = safePieceCount === 120 ? 'qr_120.png' : 'qr_60.png'; - return path.resolve(__dirname, '../../templates/invoice/qr', fileName); - } - 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; @@ -85,34 +79,11 @@ class InvoiceService { } } - async _getLocalQrDataUri(pieceCount) { - const safePieceCount = pieceCount === 120 ? 120 : 60; - - if (this._qrDataUriCache.has(safePieceCount)) { - return this._qrDataUriCache.get(safePieceCount); - } - - const filePath = this._getLocalQrImagePath(safePieceCount); - try { - const buffer = await fs.readFile(filePath); - const dataUri = `data:image/png;base64,${buffer.toString('base64')}`; - this._qrDataUriCache.set(safePieceCount, dataUri); - return dataUri; - } catch (e) { - logger.warn('InvoiceService._getLocalQrDataUri:missing_qr_file', { - pieceCount: safePieceCount, - filePath, - message: e?.message, - }); - return null; - } - } - async _buildQrCodeImageTag({ abonement }) { const pieceCount = this._resolvePieceCountForQr(abonement); if (!pieceCount) return ''; - const dataUri = await this._getCompanySettingsQrDataUri(pieceCount) || await this._getLocalQrDataUri(pieceCount); + const dataUri = await this._getCompanySettingsQrDataUri(pieceCount); if (!dataUri) return ''; return `QR Code`; -- 2.39.5 From b87b9994557f1a81cc200d66e18bb4db6510d2a4 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Fri, 20 Mar 2026 16:27:37 +0100 Subject: [PATCH 2/4] feat: add shipping fee resolution to invoice items and refactor item building logic --- services/invoice/InvoiceService.js | 73 ++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index cfbd226..3592b61 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -12,6 +12,7 @@ const fs = require('fs/promises'); const path = require('path'); const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); +const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService'); class InvoiceService { constructor() { @@ -58,6 +59,54 @@ class InvoiceService { 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 _getCompanySettingsQrDataUri(pieceCount) { const safePieceCount = pieceCount === 120 ? 120 : 60; try { @@ -598,26 +647,7 @@ class InvoiceService { // 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 items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, lang }); const context = { source: 'abonement', @@ -677,6 +707,9 @@ class InvoiceService { 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, }); } -- 2.39.5 From aabcd8a75360e3b744f86a9d963e8acc21bd6a70 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Fri, 20 Mar 2026 16:27:46 +0100 Subject: [PATCH 3/4] feat: add validation for signingCity and signatureDataUrl in subscription methods --- services/abonemments/AbonemmentService.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 79e1b82..dd1c46d 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -92,6 +92,14 @@ class AbonemmentService { const normalizedRecipientEmail = this.normalizeEmail(recipientEmail); const forSelf = isForSelf !== false && !normalizedRecipientEmail; + if (typeof signingCity !== 'string' || signingCity.trim() === '') { + throw new Error('signingCity is required'); + } + + if (typeof signatureDataUrl !== 'string' || signatureDataUrl.trim() === '') { + throw new Error('signatureDataUrl is required'); + } + if (!forSelf && !normalizedRecipientEmail) { throw new Error('recipient_email is required when subscription is for another person'); } @@ -349,6 +357,14 @@ class AbonemmentService { const normalizedRecipientEmail = this.normalizeEmail(recipientEmail); console.log('[SUBSCRIBE] Normalized recipient email:', normalizedRecipientEmail); // NEW + if (typeof signingCity !== 'string' || signingCity.trim() === '') { + throw new Error('signingCity is required'); + } + + if (typeof signatureDataUrl !== 'string' || signatureDataUrl.trim() === '') { + throw new Error('signatureDataUrl is required'); + } + if (coffeeId === undefined || coffeeId === null) throw new Error('coffeeId is required'); const hasRecipientFields = recipientName || normalizedRecipientEmail || recipientNotes; -- 2.39.5 From 449a83473c1d9f516dbf7ab94e12462627c6e383 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Tue, 7 Apr 2026 17:05:14 +0200 Subject: [PATCH 4/4] 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; -- 2.39.5