From 04a032992a72a52cbf0b78c54274d0565fc98d22 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Wed, 18 Feb 2026 11:16:54 +0100 Subject: [PATCH] feat: abo --- .../abonemments/AbonemmentController.js | 43 ++++++ .../ReferralRegistrationController.js | 5 + .../register/CompanyRegisterController.js | 5 + .../register/PersonalRegisterController.js | 5 + database/createDb.js | 48 +++++++ .../abonemments/AbonemmentRepository.js | 31 ++++- routes/getRoutes.js | 2 + scripts/createAdminUser.js | 4 +- services/abonemments/AbonemmentService.js | 128 +++++++++++++++++- services/email/MailService.js | 86 +++++++++++- services/invoice/InvoiceService.js | 113 ++++++++++++++-- 11 files changed, 447 insertions(+), 23 deletions(-) diff --git a/controller/abonemments/AbonemmentController.js b/controller/abonemments/AbonemmentController.js index b68d468..5341c54 100644 --- a/controller/abonemments/AbonemmentController.js +++ b/controller/abonemments/AbonemmentController.js @@ -15,6 +15,10 @@ module.exports = { billingInterval: req.body.billing_interval, intervalCount: req.body.interval_count, isAutoRenew: req.body.is_auto_renew, + isForSelf: req.body.is_for_self, + recipientName: req.body.recipient_name, + recipientEmail: req.body.recipient_email, + recipientNotes: req.body.recipient_notes, firstName: req.body.firstName, lastName: req.body.lastName, email: req.body.email, @@ -86,6 +90,9 @@ module.exports = { try { const rawUser = req.user || {}; const id = rawUser.id ?? rawUser.userId; + if (!id) { + return res.status(401).json({ success: false, message: 'Unauthorized: missing user id' }); + } console.log('[CONTROLLER GET MINE] Using user id:', id); const data = await service.getMyAbonements({ userId: id }); return res.json({ success: true, data }); @@ -95,6 +102,42 @@ module.exports = { } }, + async getMineStatus(req, res) { + try { + const rawUser = req.user || {}; + const id = rawUser.id ?? rawUser.userId; + if (!id) { + return res.status(401).json({ success: false, message: 'Unauthorized: missing user id' }); + } + const data = await service.getMyAbonementStatus({ userId: id }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT MINE STATUS]', err); + return res.status(500).json({ success: false, message: 'Internal error' }); + } + }, + + async getInvoices(req, res) { + try { + const rawUser = req.user || {}; + const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null }; + const data = await service.getInvoicesForAbonement({ + abonementId: req.params.id, + actorUser, + }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT INVOICES]', err); + if (err?.message === 'Not found') { + return res.status(404).json({ success: false, message: 'Abonement not found' }); + } + if (err?.message === 'Forbidden') { + return res.status(403).json({ success: false, message: 'Forbidden' }); + } + return res.status(400).json({ success: false, message: err.message }); + } + }, + async getHistory(req, res) { try { return res.json({ success: true, data: await service.getHistory({ abonementId: req.params.id }) }); diff --git a/controller/referral/ReferralRegistrationController.js b/controller/referral/ReferralRegistrationController.js index d3de7dd..e922ab1 100644 --- a/controller/referral/ReferralRegistrationController.js +++ b/controller/referral/ReferralRegistrationController.js @@ -1,7 +1,10 @@ const UnitOfWork = require('../../database/UnitOfWork'); const ReferralService = require('../../services/referral/ReferralService'); +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); const { logger } = require('../../middleware/logger'); +const abonemmentService = new AbonemmentService(); + class ReferralRegistrationController { static async getReferrerInfo(req, res) { const { token } = req.params; @@ -53,6 +56,7 @@ class ReferralRegistrationController { { ...registrationData, lang }, refToken, unitOfWork ); await unitOfWork.commit(); + await abonemmentService.linkGiftFlagsToUser(user.email, user.id); logger.info('ReferralRegistrationController:registerPersonalReferral:success', { userId: user.id, email: user.email }); res.json({ success: true, userId: user.id, email: user.email }); } catch (error) { @@ -97,6 +101,7 @@ class ReferralRegistrationController { lang }, refToken, unitOfWork); await unitOfWork.commit(); + await abonemmentService.linkGiftFlagsToUser(user.email, user.id); logger.info('ReferralRegistrationController:registerCompanyReferral:success', { userId: user.id, email: user.email }); res.json({ success: true, userId: user.id, email: user.email }); } catch (error) { diff --git a/controller/register/CompanyRegisterController.js b/controller/register/CompanyRegisterController.js index 1ac6615..d07e59e 100644 --- a/controller/register/CompanyRegisterController.js +++ b/controller/register/CompanyRegisterController.js @@ -1,6 +1,9 @@ const CompanyUserService = require('../../services/user/company/CompanyUserService'); +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); const { logger } = require('../../middleware/logger'); // add logger import +const abonemmentService = new AbonemmentService(); + class CompanyRegisterController { static async register(req, res) { logger.info('CompanyRegisterController.register:start'); @@ -69,6 +72,8 @@ class CompanyRegisterController { contactPersonPhone, password }); + + await abonemmentService.linkGiftFlagsToUser(companyEmail, newCompany.id); logger.info('CompanyRegisterController.register:success', { companyId: newCompany.id }); console.log('✅ Company user created successfully:', { diff --git a/controller/register/PersonalRegisterController.js b/controller/register/PersonalRegisterController.js index 6bd43c6..8bad8a9 100644 --- a/controller/register/PersonalRegisterController.js +++ b/controller/register/PersonalRegisterController.js @@ -1,6 +1,9 @@ const PersonalUserService = require('../../services/user/personal/PersonalUserService'); +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); const { logger } = require('../../middleware/logger'); // add logger import +const abonemmentService = new AbonemmentService(); + class PersonalRegisterController { static async register(req, res) { logger.info('PersonalRegisterController.register:start'); @@ -69,6 +72,8 @@ class PersonalRegisterController { password, referralEmail }); + + await abonemmentService.linkGiftFlagsToUser(email, newUser.id); logger.info('PersonalRegisterController.register:success', { userId: newUser.id }); console.log('✅ Personal user created successfully:', { diff --git a/database/createDb.js b/database/createDb.js index d340415..c5c0c45 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -418,6 +418,22 @@ const createDatabase = async () => { `); console.log('✅ Document templates table created/verified'); + await connection.query(` + CREATE TABLE IF NOT EXISTS no_user_abo_mails ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + abonement_id INT NOT NULL, + status ENUM('pending','linked') DEFAULT 'pending', + source VARCHAR(50) DEFAULT 'subscribe', + created_by_user_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_no_user_abo_email_abonement (email, abonement_id), + INDEX idx_no_user_abo_email_status (email, status) + ); + `); + console.log('✅ no_user_abo_mails table created/verified'); + // 8b. user_id_documents table: Stores ID-specific metadata (front/back object storage IDs) await connection.query(` CREATE TABLE IF NOT EXISTS user_id_documents ( @@ -836,6 +852,38 @@ const createDatabase = async () => { `); console.log('✅ Coffee abonements table updated'); + // Ownership columns for "self" and "gift" subscriptions + await addColumnIfMissing( + connection, + 'coffee_abonements', + 'user_id', + `INT NULL AFTER referred_by` + ); + await addColumnIfMissing( + connection, + 'coffee_abonements', + 'purchaser_user_id', + `INT NULL AFTER user_id` + ); + + await ensureIndex(connection, 'coffee_abonements', 'idx_abon_user_id', '`user_id`'); + await ensureIndex(connection, 'coffee_abonements', 'idx_abon_purchaser_user_id', '`purchaser_user_id`'); + + await addForeignKeyIfMissing( + connection, + 'coffee_abonements', + 'fk_abon_user', + `ALTER TABLE \`coffee_abonements\` + ADD CONSTRAINT \`fk_abon_user\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` + ); + await addForeignKeyIfMissing( + connection, + 'coffee_abonements', + 'fk_abon_purchaser_user', + `ALTER TABLE \`coffee_abonements\` + ADD CONSTRAINT \`fk_abon_purchaser_user\` FOREIGN KEY (\`purchaser_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` + ); + // --- Coffee Abonement History --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_abonement_history ( diff --git a/repositories/abonemments/AbonemmentRepository.js b/repositories/abonemments/AbonemmentRepository.js index 673d189..a770996 100644 --- a/repositories/abonemments/AbonemmentRepository.js +++ b/repositories/abonemments/AbonemmentRepository.js @@ -129,14 +129,39 @@ class AbonemmentRepository { } async listByUser(userId, { status, limit = 50, offset = 0 } = {}) { - const params = [userId]; - let sql = `SELECT * FROM coffee_abonements WHERE user_id = ?`; + const safeLimit = Number(limit); + const safeOffset = Number(offset); + const hasUserId = await this.hasColumn('user_id'); + const hasPurchaserUserId = await this.hasColumn('purchaser_user_id'); + const hasReferredBy = await this.hasColumn('referred_by'); + + let sql = `SELECT * FROM coffee_abonements WHERE `; + const params = []; + + if (hasUserId && hasPurchaserUserId) { + sql += `(user_id = ? OR purchaser_user_id = ?)`; + params.push(userId, userId); + } else if (hasUserId) { + sql += `user_id = ?`; + params.push(userId); + } else if (hasPurchaserUserId) { + sql += `purchaser_user_id = ?`; + params.push(userId); + } else if (hasReferredBy) { + // Legacy fallback for older schema where ownership was not persisted in user_id. + sql += `referred_by = ?`; + params.push(userId); + } else { + // Legacy schema fallback: no owner columns available + return []; + } + if (status) { sql += ` AND status = ?`; params.push(status); } sql += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`; - params.push(Number(limit), Number(offset)); + params.push(safeLimit, safeOffset); const [rows] = await pool.query(sql, params); return rows.map((r) => new Abonemment(r)); } diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 9ddb91d..503da52 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -158,6 +158,8 @@ router.get('/affiliates/active', AffiliateController.listActive); // Abonement GETs router.get('/abonements/mine', authMiddleware, AbonemmentController.getMine); +router.get('/abonements/mine/status', authMiddleware, AbonemmentController.getMineStatus); +router.get('/abonements/:id/invoices', authMiddleware, AbonemmentController.getInvoices); router.get('/abonements/:id/history', authMiddleware, AbonemmentController.getHistory); router.get('/admin/abonements', authMiddleware, adminOnly, AbonemmentController.adminList); diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index 0cdcf4c..1f25dc8 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -4,8 +4,8 @@ const argon2 = require('argon2'); async function createAdminUser() { // 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'; + const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; + // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; const firstName = process.env.ADMIN_FIRST_NAME || 'Admin'; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 5e74265..cade16c 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -1,6 +1,10 @@ const pool = require('../../database/database'); const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository'); const InvoiceService = require('../invoice/InvoiceService'); // NEW +const UnitOfWork = require('../../database/UnitOfWork'); +const ReferralService = require('../referral/ReferralService'); +const ReferralTokenRepository = require('../../repositories/referral/ReferralTokenRepository'); +const MailService = require('../email/MailService'); class AbonemmentService { constructor() { @@ -40,6 +44,10 @@ class AbonemmentService { billingInterval, intervalCount, isAutoRenew, + isForSelf, + recipientName, + recipientEmail, + recipientNotes, firstName, lastName, email, @@ -67,6 +75,12 @@ class AbonemmentService { }); const normalizedEmail = this.normalizeEmail(email); + const normalizedRecipientEmail = this.normalizeEmail(recipientEmail); + const forSelf = isForSelf !== false && !normalizedRecipientEmail; + + if (!forSelf && !normalizedRecipientEmail) { + throw new Error('recipient_email is required when subscription is for another person'); + } if (!Array.isArray(items) || items.length === 0) throw new Error('items must be a non-empty array'); @@ -99,6 +113,9 @@ class AbonemmentService { const startDateObj = startDate ? new Date(startDate) : now; const nextBilling = this.addInterval(startDateObj, billingInterval || 'month', intervalCount || 1); + const effectiveRecipientName = recipientName || `${firstName || ''} ${lastName || ''}`.trim() || null; + const effectiveEmail = forSelf ? normalizedEmail : normalizedRecipientEmail; + const snapshot = { status: 'active', started_at: startDateObj, @@ -109,18 +126,24 @@ class AbonemmentService { currency: breakdown[0]?.currency || 'EUR', is_auto_renew: isAutoRenew !== false, actor_user_id: actorUser?.id, - details: { origin: 'subscribe_order', total_packs: totalPacks }, + details: { + origin: 'subscribe_order', + total_packs: totalPacks, + is_for_self: forSelf, + recipient_name: forSelf ? null : effectiveRecipientName, + recipient_notes: forSelf ? null : (recipientNotes || null) + }, pack_breakdown: breakdown, - first_name: firstName, - last_name: lastName, - email: normalizedEmail, + first_name: forSelf ? firstName : (effectiveRecipientName || firstName), + last_name: forSelf ? lastName : null, + email: effectiveEmail, street, postal_code: postalCode, city, country, frequency, - referred_by: referredBy || null, // Pass referred_by to snapshot - user_id: actorUser?.id ?? null, // NEW: set owner (purchaser acts as owner here) + referred_by: referredBy || (forSelf ? (actorUser?.id ?? null) : null), + user_id: forSelf ? (actorUser?.id ?? null) : null, purchaser_user_id: actorUser?.id ?? null, // NEW: also store purchaser }; @@ -170,9 +193,82 @@ class AbonemmentService { // intentionally not throwing to avoid blocking subscription; adjust if you want transactional consistency } + if (!forSelf && effectiveEmail) { + const existingUser = await this.findUserByEmail(effectiveEmail); + if (!existingUser) { + await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null); + const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null); + if (referralLink) { + try { + await MailService.sendSubscriptionInvitationEmail({ + email: effectiveEmail, + inviterName: actorUser?.email || 'ProfitPlanet user', + referralLink, + lang: actorUser?.lang || actorUser?.language || 'en' + }); + } catch (mailError) { + console.error('[SUBSCRIBE ORDER] Invitation email failed:', mailError); + } + } + } + } + return abonement; } + async findUserByEmail(email) { + const normalized = this.normalizeEmail(email); + if (!normalized) return null; + const [rows] = await pool.query( + `SELECT id, email FROM users WHERE email = ? LIMIT 1`, + [normalized] + ); + return rows && rows[0] ? rows[0] : null; + } + + async getOrCreateReferralLink(userId) { + if (!userId) return null; + const unitOfWork = new UnitOfWork(); + await unitOfWork.start(); + try { + const repo = new ReferralTokenRepository(unitOfWork); + const tokens = await repo.getTokensByUser(userId); + const now = Date.now(); + const active = (tokens || []).find((t) => { + const statusOk = t.status === 'active'; + const remaining = Number(t.uses_remaining); + const unlimited = Number(t.max_uses) === -1 || remaining === -1; + const remainingOk = unlimited || remaining > 0; + const exp = t.expires_at ? new Date(t.expires_at).getTime() : NaN; + const expiryOk = Number.isNaN(exp) ? true : exp > now; + return statusOk && remainingOk && expiryOk; + }); + + let tokenValue; + if (active?.token) { + tokenValue = active.token; + } else { + const created = await ReferralService.createReferralToken({ + userId, + expiresInDays: 7, + maxUses: 1, + unitOfWork, + }); + tokenValue = created?.token; + } + + await unitOfWork.commit(); + if (!tokenValue) return null; + + const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, ''); + return `${base}/register?ref=${tokenValue}`; + } catch (error) { + await unitOfWork.rollback(error); + console.error('[ABONEMENT] getOrCreateReferralLink failed:', error); + return null; + } + } + async subscribe({ userId, coffeeId, @@ -394,6 +490,26 @@ class AbonemmentService { return this.repo.listByUser(userId); } + async getMyAbonementStatus({ userId }) { + const list = await this.repo.listByUser(userId); + const current = list.find((a) => ['active', 'paused'].includes(a.status)) || list[0] || null; + return { + hasAbo: Boolean(current), + abonement: current, + }; + } + + async getInvoicesForAbonement({ abonementId, actorUser }) { + const abon = await this.repo.getAbonementById(abonementId); + if (!abon) { + throw new Error('Not found'); + } + if (!this.canManageAbonement(abon, actorUser)) { + throw new Error('Forbidden'); + } + return this.invoiceService.listByAbonement(abonementId); + } + async getHistory({ abonementId }) { return this.repo.listHistory(abonementId); } diff --git a/services/email/MailService.js b/services/email/MailService.js index e184cc2..d737d16 100644 --- a/services/email/MailService.js +++ b/services/email/MailService.js @@ -231,8 +231,8 @@ class MailService { } } - async sendInvoiceEmail({ email, subject, text, html, lang }) { - logger.info('MailService.sendInvoiceEmail:start', { email, lang, hasHtml: Boolean(html) }); + async sendInvoiceEmail({ email, subject, text, html, lang, attachments = [] }) { + logger.info('MailService.sendInvoiceEmail:start', { email, lang, hasHtml: Boolean(html), attachments: attachments.length }); try { const payload = { sender: this.sender, @@ -245,8 +245,12 @@ class MailService { payload.htmlContent = html; } + if (Array.isArray(attachments) && attachments.length) { + payload.attachment = attachments; + } + const data = await this.brevo.transactionalEmails.sendTransacEmail(payload); - logger.info('MailService.sendInvoiceEmail:email_sent', { email, lang, hasHtml: Boolean(html) }); + logger.info('MailService.sendInvoiceEmail:email_sent', { email, lang, hasHtml: Boolean(html), attachments: attachments.length }); return data; } catch (error) { const brevoError = this._extractBrevoErrorDetails(error); @@ -260,6 +264,82 @@ class MailService { throw error; } } + + async sendSubscriptionInvitationEmail({ email, inviterName, referralLink, lang = 'en' }) { + logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang }); + const isDe = lang === 'de'; + const subject = isDe + ? 'ProfitPlanet: Einladung zur Registrierung' + : 'ProfitPlanet: Invitation to register'; + + const text = isDe + ? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich hier: ${referralLink}\n\nViele Grüße\nProfitPlanet Team` + : `Hi,\n\n${inviterName || 'A user'} created a coffee subscription for you.\nPlease register here: ${referralLink}\n\nBest regards\nProfitPlanet Team`; + + const html = ` + + + + + + + +
+ + + + + + + +
+

${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}

+
+

${isDe + ? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.` + : `${this._escapeForHtml(inviterName || 'A user')} created a coffee subscription for you.`}

+

${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}

+

+ ${isDe ? 'Jetzt registrieren' : 'Register now'} +

+
+
+ +`; + + try { + const payload = { + sender: this.sender, + to: [{ email }], + subject, + textContent: text, + htmlContent: html, + }; + + const data = await this.brevo.transactionalEmails.sendTransacEmail(payload); + logger.info('MailService.sendSubscriptionInvitationEmail:email_sent', { email, lang }); + return data; + } catch (error) { + const brevoError = this._extractBrevoErrorDetails(error); + logger.error('MailService.sendSubscriptionInvitationEmail:error', { + email, + lang, + message: error?.message, + brevoStatus: brevoError.status, + brevoData: brevoError.data + }); + throw error; + } + } + + _escapeForHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } } module.exports = new MailService(); \ No newline at end of file diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 6555c3c..143e10a 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -7,6 +7,7 @@ const MailService = require('../email/MailService'); const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { logger } = require('../../middleware/logger'); +const puppeteer = require('puppeteer'); class InvoiceService { constructor() { @@ -83,6 +84,70 @@ class InvoiceService { ].join('\n'); } + _buildInvoiceMailText({ invoice, items, abonement, lang }) { + const isDe = lang === 'de'; + const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-'; + return [ + isDe ? `Vielen Dank für Ihr Abonnement, ${customerName}.` : `Thank you for your subscription, ${customerName}.`, + isDe ? 'Ihre Rechnung ist als PDF im Anhang enthalten.' : 'Your invoice is attached as a PDF.', + `${isDe ? 'Rechnungsnummer' : 'Invoice number'}: ${invoice.invoice_number}`, + `${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._formatAmount(invoice.total_gross, invoice.currency)}`, + '', + `${isDe ? 'Positionen' : 'Items'}:`, + this._buildItemsText(items, invoice.currency), + ].join('\n'); + } + + _buildInvoiceMailHtml({ invoice, abonement, lang }) { + const isDe = lang === 'de'; + const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || 'Customer'; + const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || ''; + + return ` + + + + + ${this._escapeHtml(invoice.invoice_number)} + + + + + + +
+ + + + + + + +
+ ${logoUrl ? `ProfitPlanet` : ''} +

${isDe ? 'Danke für Ihr Abonnement!' : 'Thank you for your subscription!'}

+
+

${isDe ? 'Hallo' : 'Hi'} ${this._escapeHtml(customerName)},

+

${isDe ? 'vielen Dank für Ihr Abonnement. Ihre Rechnung haben wir als PDF angehängt.' : 'thank you for your subscription. We attached your invoice as a PDF.'}

+ + + + + + + + + + +
${isDe ? 'Rechnungsnummer' : 'Invoice number'}${this._escapeHtml(invoice.invoice_number || '')}
${isDe ? 'Gesamtbetrag' : 'Total'}${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}
+ +

${isDe ? 'Falls diese E-Mail nicht korrekt angezeigt wird, nutzen Sie bitte den Textinhalt oder kontaktieren Sie unseren Support.' : 'If this email is not displayed correctly, please use the text version or contact support.'}

+
+
+ +`; + } + _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) { const isDe = lang === 'de'; const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-'; @@ -127,15 +192,34 @@ class InvoiceService { return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => variables[key] ?? ''); } - async _storeInvoiceHtml(invoice, html) { - if (!html) return null; + async _renderPdfFromHtml(html) { + const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'networkidle0' }); + const pdfBuffer = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' } + }); + return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer); + } finally { + await browser.close(); + } + } + + async _storeInvoicePdf(invoice, pdfBuffer) { + if (!pdfBuffer) return null; const safeUser = invoice.user_id || 'unknown'; - const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.html`; + const key = `invoices/${safeUser}/${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`; await sharedExoscaleClient.send(new PutObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, Key: key, - Body: Buffer.from(html, 'utf8'), - ContentType: 'text/html; charset=utf-8', + Body: pdfBuffer, + ContentType: 'application/pdf', })); await this.repo.updateStorageKey(invoice.id, key); return key; @@ -149,7 +233,7 @@ class InvoiceService { } const items = await this.repo.getItemsByInvoiceId(invoice.id); - const text = this._buildInvoiceText({ invoice, items, abonement, lang }); + const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const subject = this._getEmailSubject(lang, invoice.invoice_number); const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang }); @@ -174,14 +258,21 @@ class InvoiceService { } } - const htmlForStorage = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); - await this._storeInvoiceHtml(invoice, htmlForStorage); + const htmlForPdf = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); + const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf); + await this._storeInvoicePdf(invoice, pdfBuffer); + + const mailHtml = this._buildInvoiceMailHtml({ invoice, abonement, lang }); await MailService.sendInvoiceEmail({ email: recipientEmail, subject, text, - html, + html: mailHtml, + attachments: [{ + name: `${invoice.invoice_number || `invoice-${invoice.id}`}.pdf`, + content: pdfBuffer.toString('base64') + }], lang, }); } @@ -346,6 +437,10 @@ class InvoiceService { return this.repo.listByUser(userId, { status, limit, offset }); } + async listByAbonement(abonementId) { + return this.repo.findByAbonement(abonementId); + } + async adminList({ status, limit = 200, offset = 0 } = {}) { return this.repo.listAll({ status, limit, offset }); }