const cron = require('node-cron'); const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository'); const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository'); const InvoiceService = require('../invoice/InvoiceService'); const MailService = require('../email/MailService'); const { logger } = require('../../middleware/logger'); class RenewalCronService { constructor() { this.repo = new AbonemmentRepository(); this.invoiceRepo = new InvoiceRepository(); this.invoiceService = new InvoiceService(); } addInterval(date, interval, count) { const d = new Date(date); if (interval === 'day') d.setDate(d.getDate() + count); if (interval === 'week') d.setDate(d.getDate() + 7 * count); if (interval === 'month') d.setMonth(d.getMonth() + count); if (interval === 'year') d.setFullYear(d.getFullYear() + count); return d; } /** * Get the latest invoice for a subscription. */ async getLatestInvoice(abonementId) { const invoices = await this.invoiceRepo.findByAbonement(abonementId); return invoices.length ? invoices[0] : null; // already sorted DESC } // ────────────────────────────────────────────── // PHASE 1 — Process active subscriptions due for renewal // ────────────────────────────────────────────── async processActiveRenewals(now) { let dueAbonements; try { dueAbonements = await this.repo.listDueForBilling(now); } catch (err) { logger.error('RenewalCron:fetch_due_error', { message: err?.message }); return; } if (!dueAbonements.length) { logger.info('RenewalCron:no_due_subscriptions'); return; } logger.info('RenewalCron:found_due', { count: dueAbonements.length }); const results = { renewed: 0, skipped: 0, paused: 0, reminded: 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++; continue; } // Gift-abo where recipient hasn't registered yet → pause if (!fresh.user_id) { await this.pauseAbonement(fresh, 'no_registered_user'); results.paused++; continue; } // Check last invoice payment status const lastInvoice = await this.getLatestInvoice(fresh.id); if (!lastInvoice) { // First cycle, no invoice yet → renew 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++; } } catch (err) { results.errors.push({ abonementId: abon.id, message: err?.message }); logger.error('RenewalCron:renewal_failed', { abonementId: abon.id, message: err?.message, stack: err?.stack, }); } } 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); } // ────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────── async renewSingleAbonement(abon) { const abonId = abon.id; const oldNextBilling = new Date(abon.next_billing_at); const newNextBilling = this.addInterval( oldNextBilling, abon.billing_interval || 'month', abon.interval_count || 1, ); logger.info('RenewalCron:renewing', { abonementId: abonId, oldNextBilling: oldNextBilling.toISOString(), newNextBilling: newNextBilling.toISOString(), }); // Update billing date + history const renewed = await this.repo.transitionBilling(abonId, newNextBilling, { event_type: 'renewed', actor_user_id: null, details: { pack_group: abon.pack_group, trigger: 'auto_renewal_cron', previous_next_billing_at: oldNextBilling.toISOString(), }, }); // Issue new invoice let invoice = null; try { const lang = abon.language || abon.lang || 'de'; invoice = await this.invoiceService.issueForAbonement( renewed, oldNextBilling, newNextBilling, { actorUserId: null, lang }, ); logger.info('RenewalCron:invoice_issued', { abonementId: abonId, invoiceId: invoice?.id, invoiceNumber: invoice?.invoice_number, }); await this.repo.appendHistory(abonId, 'invoice_issued', null, { pack_group: renewed.pack_group, invoiceId: invoice.id, trigger: 'auto_renewal_cron', }, new Date()); } catch (invoiceErr) { logger.error('RenewalCron:invoice_error', { abonementId: abonId, message: invoiceErr?.message, }); } // Send renewal confirmation email try { const recipientEmail = abon.email || invoice?.buyer_email; if (recipientEmail) { const lang = abon.language || abon.lang || 'de'; const customerName = [abon.first_name, abon.last_name].filter(Boolean).join(' ') || recipientEmail; await MailService.sendRenewalEmail({ email: recipientEmail, customerName, invoiceNumber: invoice?.invoice_number || '-', totalGross: invoice?.total_gross ? `${Number(invoice.total_gross).toFixed(2)} ${invoice.currency || abon.currency || 'EUR'}` : '-', nextBillingDate: newNextBilling.toISOString().slice(0, 10), lang, }); } } catch (mailErr) { logger.error('RenewalCron:renewal_email_error', { abonementId: abonId, message: mailErr?.message, }); } 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 // ────────────────────────────────────────────── async processDueRenewals() { const now = new Date(); logger.info('RenewalCron:tick_start', { now: now.toISOString() }); await this.processActiveRenewals(now); await this.processPausedReactivations(now); logger.info('RenewalCron:tick_end', { now: now.toISOString() }); } /** * Start the cron schedule. * Runs every day at 02:00 AM. */ start() { cron.schedule('0 2 * * *', async () => { try { await this.processDueRenewals(); } catch (err) { logger.error('RenewalCron:unhandled_error', { message: err?.message, stack: err?.stack, }); } }); logger.info('RenewalCron:scheduled', { schedule: '0 2 * * * (daily at 02:00)' }); } } module.exports = RenewalCronService;