- Added RenewalCronService to handle automatic subscription renewals and reactivations. - Introduced listPausedAutoRenew method in AbonemmentRepository to fetch paused subscriptions eligible for reactivation. - Created test script for renewal cron job to simulate subscription renewal scenarios. - Updated MailService to send renewal confirmation and payment reminder emails. - Enhanced EmailVerificationService to auto-grant 'can_subscribe' permission upon email verification. - Modified createAdminUser script to allow different admin email configurations. - Added node-cron dependency for scheduling tasks.
394 lines
13 KiB
JavaScript
394 lines
13 KiB
JavaScript
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;
|