feat: update renewal process and email notifications for subscriptions
+ test script for sub renewal
This commit is contained in:
parent
c2bbb1df15
commit
432f5a3225
@ -207,7 +207,7 @@ class InvoiceRepository {
|
||||
}
|
||||
|
||||
async updateStatus(invoiceId, newStatus) {
|
||||
const allowed = ['draft', 'issued', 'paid', 'overdue', 'canceled'];
|
||||
const allowed = ['draft', 'issued', 'paid', 'canceled'];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`);
|
||||
}
|
||||
|
||||
@ -5,13 +5,9 @@
|
||||
* node scripts/testRenewalCron.js
|
||||
*
|
||||
* This will immediately run the full cron logic:
|
||||
* Phase 1: Active subscriptions due for renewal
|
||||
* - Renew if last invoice is 'paid' (or no invoice exists)
|
||||
* - Pause if user_id is NULL (gift recipient not registered)
|
||||
* - Skip + send reminder if last invoice is unpaid (7+ days: overdue, 30+ days: pause)
|
||||
* Phase 2: Paused subscriptions with paid invoices
|
||||
* - Reactivate for current cycle if paid before the 11th
|
||||
* - Reactivate for next month if paid on/after the 11th
|
||||
* - Renew if last invoice is 'paid' (or no invoice exists) → generate new invoice + advance next_billing_at
|
||||
* - Skip if last invoice is NOT paid → just advance next_billing_at, no new invoice
|
||||
* - Skip if gift-abo without registered user (user_id NULL) → don't advance
|
||||
*
|
||||
* Test setup (run in MySQL/phpMyAdmin):
|
||||
*
|
||||
@ -23,7 +19,7 @@
|
||||
*
|
||||
* -- 3. To test "unpaid" flow: leave invoice status as 'issued'
|
||||
*
|
||||
* -- 4. To test gift-pause: set user_id to NULL:
|
||||
* -- 4. To test gift skip: set user_id to NULL:
|
||||
* UPDATE coffee_abonements SET user_id = NULL WHERE id = <ID>;
|
||||
*/
|
||||
require('dotenv').config();
|
||||
|
||||
@ -30,7 +30,12 @@ class RenewalCronService {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// PHASE 1 — Process active subscriptions due for renewal
|
||||
// Process active subscriptions due for renewal
|
||||
//
|
||||
// Simple prepaid flow:
|
||||
// - Previous invoice paid (or first cycle) → generate new invoice + advance next_billing_at
|
||||
// - Previous invoice NOT paid → skip (advance next_billing_at so we don't get stuck)
|
||||
// - Gift-abo without registered user → skip (don't advance)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
async processActiveRenewals(now) {
|
||||
@ -48,63 +53,60 @@ class RenewalCronService {
|
||||
}
|
||||
|
||||
logger.info('RenewalCron:found_due', { count: dueAbonements.length });
|
||||
const results = { renewed: 0, skipped: 0, paused: 0, reminded: 0, errors: [] };
|
||||
const results = { renewed: 0, skipped_unpaid: 0, skipped_gift: 0, skipped_other: 0, errors: [] };
|
||||
|
||||
for (const abon of dueAbonements) {
|
||||
try {
|
||||
// Re-check status (race-condition protection)
|
||||
const fresh = await this.repo.getAbonementById(abon.id);
|
||||
if (!fresh || fresh.status !== 'active') {
|
||||
results.skipped++;
|
||||
results.skipped_other++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gift-abo where recipient hasn't registered yet → pause
|
||||
// Gift-abo where recipient hasn't registered yet → skip, don't advance
|
||||
if (!fresh.user_id) {
|
||||
await this.pauseAbonement(fresh, 'no_registered_user');
|
||||
results.paused++;
|
||||
logger.info('RenewalCron:gift_no_user', { abonementId: fresh.id });
|
||||
results.skipped_gift++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check last invoice payment status
|
||||
const lastInvoice = await this.getLatestInvoice(fresh.id);
|
||||
|
||||
if (!lastInvoice) {
|
||||
// First cycle, no invoice yet → renew
|
||||
if (!lastInvoice || lastInvoice.status === 'paid') {
|
||||
// First cycle or previous invoice paid → renew (generate invoice + advance)
|
||||
await this.renewSingleAbonement(fresh);
|
||||
results.renewed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastInvoice.status === 'paid') {
|
||||
// Previous invoice paid → renew
|
||||
await this.renewSingleAbonement(fresh);
|
||||
results.renewed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Previous invoice NOT paid — do NOT renew, do NOT shift next_billing_at
|
||||
// Check how overdue it is
|
||||
const issuedAt = lastInvoice.issued_at ? new Date(lastInvoice.issued_at) : new Date(lastInvoice.created_at);
|
||||
const daysSinceIssued = Math.floor((now - issuedAt) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysSinceIssued >= 30) {
|
||||
// 30+ days unpaid → pause subscription
|
||||
if (lastInvoice.status !== 'overdue') {
|
||||
await this.invoiceRepo.updateStatus(lastInvoice.id, 'overdue');
|
||||
}
|
||||
await this.pauseAbonement(fresh, 'unpaid_30_days');
|
||||
results.paused++;
|
||||
} else if (daysSinceIssued >= 7) {
|
||||
// 7+ days → mark overdue + send reminder
|
||||
if (lastInvoice.status !== 'overdue') {
|
||||
await this.invoiceRepo.updateStatus(lastInvoice.id, 'overdue');
|
||||
}
|
||||
await this.sendPaymentReminder(fresh, lastInvoice, daysSinceIssued);
|
||||
results.reminded++;
|
||||
} else {
|
||||
// < 7 days — just skip, wait longer
|
||||
results.skipped++;
|
||||
// Previous invoice NOT paid → skip this cycle, just advance next_billing_at
|
||||
const oldNextBilling = new Date(fresh.next_billing_at);
|
||||
const newNextBilling = this.addInterval(
|
||||
oldNextBilling,
|
||||
fresh.billing_interval || 'month',
|
||||
fresh.interval_count || 1,
|
||||
);
|
||||
|
||||
await this.repo.transitionBilling(fresh.id, newNextBilling, {
|
||||
event_type: 'skipped',
|
||||
actor_user_id: null,
|
||||
details: {
|
||||
pack_group: fresh.pack_group,
|
||||
trigger: 'auto_renewal_cron',
|
||||
reason: 'previous_invoice_unpaid',
|
||||
unpaid_invoice_id: lastInvoice.id,
|
||||
previous_next_billing_at: oldNextBilling.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('RenewalCron:skipped_unpaid', {
|
||||
abonementId: fresh.id,
|
||||
unpaidInvoiceId: lastInvoice.id,
|
||||
invoiceStatus: lastInvoice.status,
|
||||
advancedTo: newNextBilling.toISOString(),
|
||||
});
|
||||
|
||||
results.skipped_unpaid++;
|
||||
}
|
||||
} catch (err) {
|
||||
results.errors.push({ abonementId: abon.id, message: err?.message });
|
||||
@ -116,75 +118,7 @@ class RenewalCronService {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('RenewalCron:active_phase_complete', results);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// PHASE 2 — Reactivate paused subscriptions that have been paid
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
async processPausedReactivations(now) {
|
||||
let pausedAbonements;
|
||||
try {
|
||||
pausedAbonements = await this.repo.listPausedAutoRenew();
|
||||
} catch (err) {
|
||||
logger.error('RenewalCron:fetch_paused_error', { message: err?.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pausedAbonements.length) {
|
||||
logger.info('RenewalCron:no_paused_to_reactivate');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('RenewalCron:found_paused', { count: pausedAbonements.length });
|
||||
const dayOfMonth = now.getDate();
|
||||
const results = { reactivated: 0, deferred: 0, skipped: 0 };
|
||||
|
||||
for (const abon of pausedAbonements) {
|
||||
try {
|
||||
// Gift-abo still without user → skip
|
||||
if (!abon.user_id) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastInvoice = await this.getLatestInvoice(abon.id);
|
||||
if (!lastInvoice || lastInvoice.status !== 'paid') {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invoice is paid — check if before 11th of current month
|
||||
if (dayOfMonth <= 10) {
|
||||
// Before the 11th → reactivate for this cycle
|
||||
const nextBilling = this.addInterval(
|
||||
now,
|
||||
abon.billing_interval || 'month',
|
||||
abon.interval_count || 1,
|
||||
);
|
||||
await this.reactivateAbonement(abon, nextBilling);
|
||||
results.reactivated++;
|
||||
} else {
|
||||
// On or after the 11th → defer to next month
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const nextBilling = this.addInterval(
|
||||
nextMonth,
|
||||
abon.billing_interval || 'month',
|
||||
abon.interval_count || 1,
|
||||
);
|
||||
await this.reactivateAbonement(abon, nextBilling);
|
||||
results.deferred++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('RenewalCron:reactivation_failed', {
|
||||
abonementId: abon.id,
|
||||
message: err?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('RenewalCron:paused_phase_complete', results);
|
||||
logger.info('RenewalCron:phase_complete', results);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
@ -246,7 +180,7 @@ class RenewalCronService {
|
||||
});
|
||||
}
|
||||
|
||||
// Send renewal confirmation email
|
||||
// Send renewal email with invoice
|
||||
try {
|
||||
const recipientEmail = abon.email || invoice?.buyer_email;
|
||||
if (recipientEmail) {
|
||||
@ -274,88 +208,6 @@ class RenewalCronService {
|
||||
return { abonementId: abonId, invoiceId: invoice?.id || null };
|
||||
}
|
||||
|
||||
async pauseAbonement(abon, reason) {
|
||||
logger.info('RenewalCron:pausing', { abonementId: abon.id, reason });
|
||||
|
||||
await this.repo.transitionStatus(abon.id, 'paused', {
|
||||
event_type: 'paused',
|
||||
actor_user_id: null,
|
||||
details: {
|
||||
pack_group: abon.pack_group,
|
||||
trigger: 'auto_renewal_cron',
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the subscriber (or purchaser)
|
||||
try {
|
||||
const recipientEmail = abon.email;
|
||||
if (recipientEmail) {
|
||||
const lang = abon.language || abon.lang || 'de';
|
||||
const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail;
|
||||
await MailService.sendSubscriptionPausedEmail({ email: recipientEmail, customerName, reason, lang });
|
||||
}
|
||||
} catch (mailErr) {
|
||||
logger.error('RenewalCron:pause_email_error', {
|
||||
abonementId: abon.id,
|
||||
message: mailErr?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reactivateAbonement(abon, nextBillingAt) {
|
||||
logger.info('RenewalCron:reactivating', {
|
||||
abonementId: abon.id,
|
||||
nextBillingAt: nextBillingAt.toISOString(),
|
||||
});
|
||||
|
||||
// Set status back to active + update billing date
|
||||
await this.repo.transitionStatus(abon.id, 'active', {
|
||||
event_type: 'resumed',
|
||||
actor_user_id: null,
|
||||
details: {
|
||||
pack_group: abon.pack_group,
|
||||
trigger: 'auto_renewal_cron',
|
||||
reason: 'payment_received',
|
||||
next_billing_at: nextBillingAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.repo.updateBilling(abon.id, nextBillingAt);
|
||||
}
|
||||
|
||||
async sendPaymentReminder(abon, invoice, daysSinceIssued) {
|
||||
const recipientEmail = abon.email || invoice.buyer_email;
|
||||
if (!recipientEmail) return;
|
||||
|
||||
const lang = abon.language || abon.lang || 'de';
|
||||
const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail;
|
||||
|
||||
try {
|
||||
await MailService.sendPaymentReminderEmail({
|
||||
email: recipientEmail,
|
||||
customerName,
|
||||
invoiceNumber: invoice.invoice_number || '-',
|
||||
totalGross: invoice.total_gross
|
||||
? `${Number(invoice.total_gross).toFixed(2)} ${invoice.currency || abon.currency || 'EUR'}`
|
||||
: '-',
|
||||
daysOverdue: daysSinceIssued,
|
||||
lang,
|
||||
});
|
||||
|
||||
logger.info('RenewalCron:payment_reminder_sent', {
|
||||
abonementId: abon.id,
|
||||
invoiceId: invoice.id,
|
||||
daysOverdue: daysSinceIssued,
|
||||
});
|
||||
} catch (mailErr) {
|
||||
logger.error('RenewalCron:payment_reminder_error', {
|
||||
abonementId: abon.id,
|
||||
message: mailErr?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Main entry point
|
||||
// ──────────────────────────────────────────────
|
||||
@ -365,7 +217,6 @@ class RenewalCronService {
|
||||
logger.info('RenewalCron:tick_start', { now: now.toISOString() });
|
||||
|
||||
await this.processActiveRenewals(now);
|
||||
await this.processPausedReactivations(now);
|
||||
|
||||
logger.info('RenewalCron:tick_end', { now: now.toISOString() });
|
||||
}
|
||||
|
||||
@ -476,247 +476,6 @@ class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendPaymentReminderEmail({ email, customerName, invoiceNumber, totalGross, daysOverdue, lang = 'en' }) {
|
||||
logger.info('MailService.sendPaymentReminderEmail:start', { email, lang, invoiceNumber, daysOverdue });
|
||||
const isDe = lang === 'de';
|
||||
|
||||
const subject = isDe
|
||||
? `ProfitPlanet: Zahlungserinnerung – Rechnung ${invoiceNumber}`
|
||||
: `ProfitPlanet: Payment reminder – Invoice ${invoiceNumber}`;
|
||||
|
||||
const safeName = this._escapeForHtml(customerName || '');
|
||||
const safeInvoice = this._escapeForHtml(invoiceNumber || '');
|
||||
const safeTotal = this._escapeForHtml(totalGross || '-');
|
||||
const safeDays = this._escapeForHtml(String(daysOverdue || 0));
|
||||
|
||||
const text = isDe
|
||||
? [
|
||||
`Hallo ${customerName || ''},`,
|
||||
'',
|
||||
`wir möchten Sie daran erinnern, dass Ihre Rechnung ${invoiceNumber} noch offen ist.`,
|
||||
'',
|
||||
`Rechnungsnummer: ${invoiceNumber}`,
|
||||
`Offener Betrag: ${totalGross}`,
|
||||
`Überfällig seit: ${daysOverdue} Tagen`,
|
||||
'',
|
||||
'Bitte begleichen Sie den offenen Betrag, damit Ihr Abonnement weiterhin aktiv bleibt.',
|
||||
'Falls die Zahlung bereits erfolgt ist, können Sie diese Erinnerung ignorieren.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr ProfitPlanet Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hi ${customerName || ''},`,
|
||||
'',
|
||||
`This is a reminder that your invoice ${invoiceNumber} is still unpaid.`,
|
||||
'',
|
||||
`Invoice number: ${invoiceNumber}`,
|
||||
`Outstanding amount: ${totalGross}`,
|
||||
`Overdue by: ${daysOverdue} days`,
|
||||
'',
|
||||
'Please settle the outstanding amount to keep your subscription active.',
|
||||
'If payment has already been made, you can disregard this reminder.',
|
||||
'',
|
||||
'Best regards,',
|
||||
'Your ProfitPlanet Team',
|
||||
].join('\n');
|
||||
|
||||
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></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:#92400e;color:#ffffff;">
|
||||
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
|
||||
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Zahlungserinnerung' : 'Payment Reminder'}</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'} ${safeName},</p>
|
||||
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
|
||||
? 'wir möchten Sie daran erinnern, dass folgende Rechnung noch offen ist:'
|
||||
: 'This is a friendly reminder that the following invoice is still unpaid:'}</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:#fef3c7;font-size:13px;color:#92400e;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
|
||||
<td style="padding:12px 14px;background:#fef3c7;font-size:13px;text-align:right;font-weight:700;color:#92400e;">${safeInvoice}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Offener Betrag' : 'Outstanding amount'}</td>
|
||||
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${safeTotal}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Überfällig seit' : 'Overdue by'}</td>
|
||||
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeDays} ${isDe ? 'Tagen' : 'days'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;line-height:1.5;">${isDe
|
||||
? 'Bitte begleichen Sie den Betrag, damit Ihr Abonnement aktiv bleibt. Bei Nicht-Zahlung wird Ihr Abonnement automatisch pausiert.'
|
||||
: 'Please settle the amount to keep your subscription active. Your subscription will be automatically paused if payment is not received.'}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}</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.sendPaymentReminderEmail:email_sent', { email, lang, invoiceNumber });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const brevoError = this._extractBrevoErrorDetails(error);
|
||||
logger.error('MailService.sendPaymentReminderEmail:error', {
|
||||
email, lang, invoiceNumber,
|
||||
message: error?.message,
|
||||
brevoStatus: brevoError.status,
|
||||
brevoData: brevoError.data,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendSubscriptionPausedEmail({ email, customerName, reason, lang = 'en' }) {
|
||||
logger.info('MailService.sendSubscriptionPausedEmail:start', { email, lang, reason });
|
||||
const isDe = lang === 'de';
|
||||
|
||||
const subject = isDe
|
||||
? 'ProfitPlanet: Ihr Abonnement wurde pausiert'
|
||||
: 'ProfitPlanet: Your subscription has been paused';
|
||||
|
||||
const safeName = this._escapeForHtml(customerName || '');
|
||||
|
||||
const reasonTextDe = reason === 'no_registered_user'
|
||||
? 'Der Empfänger hat sich noch nicht als Gastkunde registriert.'
|
||||
: 'Wir konnten leider keinen Zahlungseingang für Ihre letzte Rechnung feststellen.';
|
||||
const reasonTextEn = reason === 'no_registered_user'
|
||||
? 'The recipient has not yet registered as a guest customer.'
|
||||
: 'We were unable to confirm payment for your latest invoice.';
|
||||
|
||||
const text = isDe
|
||||
? [
|
||||
`Hallo ${customerName || ''},`,
|
||||
'',
|
||||
'Ihr ProfitPlanet Kaffee-Abonnement wurde vorübergehend pausiert.',
|
||||
'',
|
||||
`Grund: ${reasonTextDe}`,
|
||||
'',
|
||||
reason === 'no_registered_user'
|
||||
? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.'
|
||||
: 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr ProfitPlanet Team',
|
||||
].join('\n')
|
||||
: [
|
||||
`Hi ${customerName || ''},`,
|
||||
'',
|
||||
'Your ProfitPlanet coffee subscription has been temporarily paused.',
|
||||
'',
|
||||
`Reason: ${reasonTextEn}`,
|
||||
'',
|
||||
reason === 'no_registered_user'
|
||||
? 'Once the recipient registers, the subscription will automatically resume.'
|
||||
: 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.',
|
||||
'',
|
||||
'Best regards,',
|
||||
'Your ProfitPlanet Team',
|
||||
].join('\n');
|
||||
|
||||
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></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:#dc2626;color:#ffffff;">
|
||||
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
|
||||
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Abonnement pausiert' : 'Subscription Paused'}</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'} ${safeName},</p>
|
||||
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
|
||||
? 'Ihr Kaffee-Abonnement wurde vorübergehend pausiert.'
|
||||
: 'Your coffee subscription has been temporarily paused.'}</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #fecaca;border-radius:8px;overflow:hidden;margin-bottom:18px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px;background:#fef2f2;font-size:14px;color:#991b1b;">
|
||||
<strong>${isDe ? 'Grund:' : 'Reason:'}</strong> ${this._escapeForHtml(isDe ? reasonTextDe : reasonTextEn)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px 0;font-size:14px;line-height:1.6;">${isDe
|
||||
? (reason === 'no_registered_user'
|
||||
? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.'
|
||||
: 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.')
|
||||
: (reason === 'no_registered_user'
|
||||
? 'Once the recipient registers, the subscription will automatically resume.'
|
||||
: 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.')}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}</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.sendSubscriptionPausedEmail:email_sent', { email, lang, reason });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const brevoError = this._extractBrevoErrorDetails(error);
|
||||
logger.error('MailService.sendSubscriptionPausedEmail:error', {
|
||||
email, lang, reason,
|
||||
message: error?.message,
|
||||
brevoStatus: brevoError.status,
|
||||
brevoData: brevoError.data,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_escapeForHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user