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 ? ` ` : ''}
+ ${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 });
}