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/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 ``;
@@ -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) =>
- `
| - - |