commit
4905dd6990
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) =>
|
||||
`<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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user