feat: abo
This commit is contained in:
parent
09a6004875
commit
04a032992a
@ -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 }) });
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
@ -70,6 +73,8 @@ class CompanyRegisterController {
|
||||
password
|
||||
});
|
||||
|
||||
await abonemmentService.linkGiftFlagsToUser(companyEmail, newCompany.id);
|
||||
|
||||
logger.info('CompanyRegisterController.register:success', { companyId: newCompany.id });
|
||||
console.log('✅ Company user created successfully:', {
|
||||
companyId: newCompany.id,
|
||||
|
||||
@ -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');
|
||||
@ -70,6 +73,8 @@ class PersonalRegisterController {
|
||||
referralEmail
|
||||
});
|
||||
|
||||
await abonemmentService.linkGiftFlagsToUser(email, newUser.id);
|
||||
|
||||
logger.info('PersonalRegisterController.register:success', { userId: newUser.id });
|
||||
console.log('✅ Personal user created successfully:', {
|
||||
userId: newUser.id,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MailService();
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user