feat: abo

This commit is contained in:
DeathKaioken 2026-02-18 11:16:54 +01:00
parent 09a6004875
commit 04a032992a
11 changed files with 447 additions and 23 deletions

View File

@ -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 }) });

View File

@ -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) {

View File

@ -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:', {

View File

@ -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:', {

View File

@ -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 (

View File

@ -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));
}

View File

@ -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);

View File

@ -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';

View File

@ -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);
}

View File

@ -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 = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
<tr>
<td style="padding:20px 24px;background:#111827;color:#ffffff;">
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}</h2>
</td>
</tr>
<tr>
<td style="padding:22px 24px;">
<p style="margin:0 0 12px 0;line-height:1.6;">${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.`}</p>
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}</p>
<p style="margin:0;">
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:700;">${isDe ? 'Jetzt registrieren' : 'Register now'}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
module.exports = new MailService();

View File

@ -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 `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this._escapeHtml(invoice.invoice_number)}</title>
</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;">
${logoUrl ? `<img src="${this._escapeHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Danke für Ihr Abonnement!' : 'Thank you for your subscription!'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${this._escapeHtml(customerName)},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${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.'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(invoice.invoice_number || '')}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Gesamtbetrag' : 'Total'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</td>
</tr>
</table>
<p style="margin:0;font-size:13px;color:#6b7280;">${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.'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
_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 });
}