From 432f5a3225c61ea24bb633e6ab6d21dc9729b32a Mon Sep 17 00:00:00 2001 From: Seazn Date: Mon, 16 Mar 2026 16:04:36 +0100 Subject: [PATCH] feat: update renewal process and email notifications for subscriptions + test script for sub renewal --- repositories/invoice/InvoiceRepository.js | 2 +- scripts/testRenewalCron.js | 12 +- services/abonemments/RenewalCronService.js | 235 ++++---------------- services/email/MailService.js | 241 --------------------- 4 files changed, 48 insertions(+), 442 deletions(-) diff --git a/repositories/invoice/InvoiceRepository.js b/repositories/invoice/InvoiceRepository.js index 0d30f30..292a7aa 100644 --- a/repositories/invoice/InvoiceRepository.js +++ b/repositories/invoice/InvoiceRepository.js @@ -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(', ')}`); } diff --git a/scripts/testRenewalCron.js b/scripts/testRenewalCron.js index 8930ed1..eee56ab 100644 --- a/scripts/testRenewalCron.js +++ b/scripts/testRenewalCron.js @@ -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 = ; */ require('dotenv').config(); diff --git a/services/abonemments/RenewalCronService.js b/services/abonemments/RenewalCronService.js index dea48fb..36f4e6b 100644 --- a/services/abonemments/RenewalCronService.js +++ b/services/abonemments/RenewalCronService.js @@ -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() }); } diff --git a/services/email/MailService.js b/services/email/MailService.js index 25f5df0..8f0a1e5 100644 --- a/services/email/MailService.js +++ b/services/email/MailService.js @@ -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 = ` - - - - - - - -
- - - - - - - - - - -
- ${logoUrl ? `ProfitPlanet` : ''} -

${isDe ? 'Zahlungserinnerung' : 'Payment Reminder'}

-
-

${isDe ? 'Hallo' : 'Hi'} ${safeName},

-

${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:'}

- - - - - - - - - - - - - -
${isDe ? 'Rechnungsnummer' : 'Invoice number'}${safeInvoice}
${isDe ? 'Offener Betrag' : 'Outstanding amount'}${safeTotal}
${isDe ? 'Überfällig seit' : 'Overdue by'}${safeDays} ${isDe ? 'Tagen' : 'days'}
-

${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.'}

-
-

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

-
-
- -`; - - 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 = ` - - - - - - - -
- - - - - - - - - - -
- ${logoUrl ? `ProfitPlanet` : ''} -

${isDe ? 'Abonnement pausiert' : 'Subscription Paused'}

-
-

${isDe ? 'Hallo' : 'Hi'} ${safeName},

-

${isDe - ? 'Ihr Kaffee-Abonnement wurde vorübergehend pausiert.' - : 'Your coffee subscription has been temporarily paused.'}

- - - - -
- ${isDe ? 'Grund:' : 'Reason:'} ${this._escapeForHtml(isDe ? reasonTextDe : reasonTextEn)} -
-

${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.')}

-
-

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

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