Merge pull request 'dev' (#23) from dev into main

Reviewed-on: #23
This commit is contained in:
Seazn 2026-04-07 15:12:28 +00:00
commit 4905dd6990
4 changed files with 200 additions and 0 deletions

View File

@ -69,4 +69,31 @@ module.exports = {
return res.status(400).json({ success: false, message: e.message }); 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 });
}
},
}; };

View File

@ -188,6 +188,7 @@ router.get('/news/:slug', NewsController.getPublic);
// NEW: Invoice GETs // NEW: Invoice GETs
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine); 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', authMiddleware, adminOnly, InvoiceController.adminList);
router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail); router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail);

View File

@ -187,6 +187,7 @@ router.post('/abonements/referred', authMiddleware, ensureUserFromBody, Abonemme
// NEW: Invoice POSTs // NEW: Invoice POSTs
router.post('/invoices/:id/pay', authMiddleware, adminOnly, InvoiceController.pay); router.post('/invoices/:id/pay', authMiddleware, adminOnly, InvoiceController.pay);
router.post('/admin/invoices/email-report', authMiddleware, adminOnly, InvoiceController.sendEmailReport);
// Existing registration handlers (keep) // Existing registration handlers (keep)
router.post('/register/personal', (req, res) => { router.post('/register/personal', (req, res) => {

View File

@ -778,6 +778,177 @@ class InvoiceService {
const payments = await this.repo.getPaymentsByInvoiceId(invoiceId); const payments = await this.repo.getPaymentsByInvoiceId(invoiceId);
return { invoice, items, payments }; 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) =>
`<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${this._escapeHtml(inv.invoice_number || '')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${this._escapeHtml(inv.buyer_name || '-')}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${inv.issued_at ? new Date(inv.issued_at).toISOString().slice(0, 10) : '-'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${this._formatAmount(inv.total_gross, inv.currency)}</td>
</tr>`,
)
.join('');
const subject = `ProfitPlanet Paid Invoices Report (${dateRange})`;
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr><td style="padding:24px 28px;background:#111827;color:#ffffff;">
<h1 style="margin:0;font-size:22px;">Paid Invoices Report</h1>
<p style="margin:6px 0 0 0;font-size:13px;color:#9ca3af;">${this._escapeHtml(dateRange)}</p>
</td></tr>
<tr><td style="padding:24px 28px;">
<p style="margin:0 0 16px 0;font-size:15px;">This report contains <strong>${paidInvoices.length}</strong> paid invoice(s).</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<thead>
<tr style="background:#f9fafb;">
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Invoice #</th>
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Customer</th>
<th style="padding:10px 12px;text-align:left;font-size:13px;color:#6b7280;">Date</th>
<th style="padding:10px 12px;text-align:right;font-size:13px;color:#6b7280;">Total</th>
</tr>
</thead>
<tbody>${invoiceRows}</tbody>
<tfoot>
<tr style="background:#f9fafb;">
<td colspan="3" style="padding:10px 12px;font-weight:700;font-size:13px;">Total</td>
<td style="padding:10px 12px;text-align:right;font-weight:700;font-size:13px;">${this._formatAmount(totalGross, currency)}</td>
</tr>
</tfoot>
</table>
${attachments.length ? '<p style="margin:16px 0 0 0;font-size:13px;color:#6b7280;">The individual invoice PDFs are attached to this email.</p>' : '<p style="margin:16px 0 0 0;font-size:13px;color:#6b7280;">No PDF attachments were available at this time.</p>'}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
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; module.exports = InvoiceService;