CentralBackend/services/abonemments/RenewalCronService.js
2026-03-16 16:04:36 +01:00

245 lines
8.4 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
}
// ──────────────────────────────────────────────
// 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) {
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_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_other++;
continue;
}
// Gift-abo where recipient hasn't registered yet → skip, don't advance
if (!fresh.user_id) {
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 || lastInvoice.status === 'paid') {
// First cycle or previous invoice paid → renew (generate invoice + advance)
await this.renewSingleAbonement(fresh);
results.renewed++;
} else {
// 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 });
logger.error('RenewalCron:renewal_failed', {
abonementId: abon.id,
message: err?.message,
stack: err?.stack,
});
}
}
logger.info('RenewalCron: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 email with invoice
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 };
}
// ──────────────────────────────────────────────
// Main entry point
// ──────────────────────────────────────────────
async processDueRenewals() {
const now = new Date();
logger.info('RenewalCron:tick_start', { now: now.toISOString() });
await this.processActiveRenewals(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;