feat: update renewal process and email notifications for subscriptions

+ test script for sub renewal
This commit is contained in:
Seazn 2026-03-16 16:04:36 +01:00
parent c2bbb1df15
commit 432f5a3225
4 changed files with 48 additions and 442 deletions

View File

@ -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(', ')}`);
}

View File

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

View File

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

View File

@ -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, '&amp;')