diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js index b74c99b..d4a7628 100644 --- a/controller/invoice/InvoiceController.js +++ b/controller/invoice/InvoiceController.js @@ -69,31 +69,4 @@ 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 9f32e0a..436795e 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -188,7 +188,6 @@ 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 dc4b1fd..d04e3ef 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -187,7 +187,6 @@ 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/scripts/createAdminUser.js b/scripts/createAdminUser.js index c4c6d88..f5af814 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,7 +3,7 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - return; + // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index dd1c46d..79e1b82 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -92,14 +92,6 @@ 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'); } @@ -357,14 +349,6 @@ 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; diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 78d2609..998821c 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -12,11 +12,11 @@ const fs = require('fs/promises'); const path = require('path'); const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); -const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService'); class InvoiceService { constructor() { this.repo = new InvoiceRepository(); + this._qrDataUriCache = new Map(); } _inferImageMimeFromBase64(base64) { @@ -59,52 +59,10 @@ 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; + _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) { @@ -112,7 +70,6 @@ class InvoiceService { 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; @@ -128,11 +85,34 @@ 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); + const dataUri = await this._getCompanySettingsQrDataUri(pieceCount) || await this._getLocalQrDataUri(pieceCount); if (!dataUri) return ''; return `QR Code`; @@ -647,7 +627,26 @@ class InvoiceService { // NEW: resolve invoice vat_rate (standard) from buyer country const vat_rate = await this.resolveVatRateForCountry(addr.country); - const items = await this._buildInvoiceItems({ abonement, vatRate: vat_rate, 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) : 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', @@ -707,9 +706,6 @@ 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, }); } @@ -778,177 +774,6 @@ 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;