UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

383 lines (329 loc) 11.2 kB
import { $inject } from "alepha"; import { $job } from "alepha/api/jobs"; import { PaymentService } from "alepha/api/payments"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $repository } from "alepha/orm"; import type { SubscriptionEventEntity } from "../entities/subscriptionEvents.ts"; import { subscriptionEvents } from "../entities/subscriptionEvents.ts"; import { subscriptions } from "../entities/subscriptions.ts"; import { SubscriptionConfig } from "../services/SubscriptionConfig.ts"; // ----------------------------------------------------------------------------------------------------------------- interface EventContext { previousStatus?: string; newStatus?: string; paymentIntentId?: string; amount?: number; currency?: string; triggeredBy?: string; note?: string; } // ----------------------------------------------------------------------------------------------------------------- export class SubscriptionJobs { protected readonly log = $logger(); protected readonly dateTime = $inject(DateTimeProvider); protected readonly paymentService = $inject(PaymentService); protected readonly config = $inject(SubscriptionConfig); protected readonly subscriptionRepo = $repository(subscriptions); protected readonly eventRepo = $repository(subscriptionEvents); // --------------------------------------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------------------------------------- /** * Record a subscription event in the event log. */ protected async recordEvent( subscriptionId: string, organizationId: string, type: SubscriptionEventEntity["type"], context?: EventContext, ): Promise<void> { await this.eventRepo.create({ subscriptionId, organizationId, type, previousStatus: context?.previousStatus, newStatus: context?.newStatus, paymentIntentId: context?.paymentIntentId, amount: context?.amount, currency: context?.currency, triggeredBy: context?.triggeredBy, note: context?.note, }); } // --------------------------------------------------------------------------------------------------------------- // Jobs // --------------------------------------------------------------------------------------------------------------- /** * Creates payment intents for subscriptions due for renewal. * Runs hourly. */ public readonly billingCycle = $job({ cron: "0 * * * *", lock: true, timeout: [10, "minute"], handler: async ({ now }) => { const nowISO = now.toISOString(); const due = await this.subscriptionRepo.findMany({ where: { nextBillingAt: { lte: nowISO }, status: { inArray: ["active", "trialing"] }, }, }); this.log.info(`Billing cycle: processing ${due.length} subscription(s)`); for (const sub of due) { try { const pricing = await this.config.getPlanPricing( sub.planId, sub.interval, ); const intent = await this.paymentService.createIntent( pricing.amount, pricing.currency, { subscriptionId: sub.id }, ); await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id, }); this.log.debug("Created payment intent for subscription", { subscriptionId: sub.id, intentId: intent.id, }); } catch (err) { this.log.error("Failed to create payment intent for subscription", { subscriptionId: sub.id, error: err, }); } } }, }); // --------------------------------------------------------------------------------------------------------------- /** * Retries failed payments on the dunning schedule. * Runs hourly. */ public readonly dunningRetry = $job({ cron: "0 * * * *", lock: true, timeout: [10, "minute"], handler: async ({ now }) => { const nowISO = now.toISOString(); const pastDue = await this.subscriptionRepo.findMany({ where: { dunningNextRetryAt: { lte: nowISO }, status: { eq: "past_due" }, }, }); this.log.info( `Dunning retry: processing ${pastDue.length} subscription(s)`, ); const settings = await this.config.getSettings(); for (const sub of pastDue) { try { const pricing = await this.config.getPlanPricing( sub.planId, sub.interval, ); const intent = await this.paymentService.createIntent( pricing.amount, pricing.currency, { subscriptionId: sub.id }, ); const newAttempt = sub.dunningAttempt + 1; const scheduleDays = settings.dunningSchedule[newAttempt - 1]; const nextRetry = scheduleDays !== undefined ? now.add(scheduleDays, "days").toISOString() : undefined; await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id, dunningAttempt: newAttempt, dunningNextRetryAt: nextRetry, }); await this.recordEvent( sub.id, sub.organizationId as string, "payment_retried", { paymentIntentId: intent.id, note: `Dunning retry attempt ${newAttempt}`, }, ); this.log.debug("Dunning retry payment intent created", { subscriptionId: sub.id, attempt: newAttempt, }); } catch (err) { this.log.error("Failed to create dunning retry intent", { subscriptionId: sub.id, error: err, }); } } }, }); // --------------------------------------------------------------------------------------------------------------- /** * Handles trial expirations. * Runs hourly. */ public readonly trialExpiry = $job({ cron: "0 * * * *", lock: true, handler: async ({ now }) => { const nowISO = now.toISOString(); const expired = await this.subscriptionRepo.findMany({ where: { trialEnd: { lte: nowISO }, status: { eq: "trialing" }, }, }); this.log.info( `Trial expiry: processing ${expired.length} subscription(s)`, ); for (const sub of expired) { try { const pricing = await this.config.getPlanPricing( sub.planId, sub.interval, ); const intent = await this.paymentService.createIntent( pricing.amount, pricing.currency, { subscriptionId: sub.id }, ); await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id, }); this.log.debug("Created payment intent for trial expiry", { subscriptionId: sub.id, intentId: intent.id, }); } catch (err) { this.log.error("Failed to process trial expiry", { subscriptionId: sub.id, error: err, }); } } }, }); // --------------------------------------------------------------------------------------------------------------- /** * Expires cancelled subscriptions that reached period end. * Runs hourly. */ public readonly expirationSweep = $job({ cron: "0 * * * *", lock: true, handler: async ({ now }) => { const nowISO = now.toISOString(); const toExpire = await this.subscriptionRepo.findMany({ where: { currentPeriodEnd: { lte: nowISO }, status: { eq: "cancelled" }, cancelAtPeriodEnd: { eq: true }, }, }); this.log.info( `Expiration sweep: expiring ${toExpire.length} subscription(s)`, ); for (const sub of toExpire) { try { await this.subscriptionRepo.updateById(sub.id, { status: "expired", }); await this.recordEvent( sub.id, sub.organizationId as string, "expired", { previousStatus: "cancelled", newStatus: "expired", }, ); this.log.debug("Subscription expired", { subscriptionId: sub.id }); } catch (err) { this.log.error("Failed to expire subscription", { subscriptionId: sub.id, error: err, }); } } }, }); // --------------------------------------------------------------------------------------------------------------- /** * Suspends past_due subscriptions where grace period has elapsed. * Runs daily at 2 AM. */ public readonly gracePeriodSweep = $job({ cron: "0 3 * * *", lock: true, handler: async ({ now }) => { const settings = await this.config.getSettings(); const gracePeriodDays = settings.gracePeriodDays; const pastDueSubs = await this.subscriptionRepo.findMany({ where: { status: { eq: "past_due" }, }, }); const toSuspend = pastDueSubs.filter((sub) => { if (!sub.dunningStartedAt) return false; const graceEnd = this.dateTime .of(sub.dunningStartedAt) .add(gracePeriodDays, "days"); return !now.isBefore(graceEnd.toISOString()); }); this.log.info( `Grace period sweep: suspending ${toSuspend.length} subscription(s)`, ); for (const sub of toSuspend) { try { await this.subscriptionRepo.updateById(sub.id, { status: "suspended", }); await this.recordEvent( sub.id, sub.organizationId as string, "suspended", { previousStatus: "past_due", newStatus: "suspended", }, ); this.log.debug("Subscription suspended after grace period", { subscriptionId: sub.id, }); } catch (err) { this.log.error("Failed to suspend subscription", { subscriptionId: sub.id, error: err, }); } } }, }); // --------------------------------------------------------------------------------------------------------------- /** * Purges old subscription events older than 365 days. * Runs daily at 3 AM. */ public readonly purgeEvents = $job({ cron: "0 3 * * *", lock: true, handler: async ({ now }) => { const cutoff = now.subtract(365, "days").toISOString(); const old = await this.eventRepo.findMany({ where: { createdAt: { lt: cutoff }, }, }); this.log.info(`Purge events: removing ${old.length} old event(s)`); for (const event of old) { await this.eventRepo.deleteById(event.id); } }, }); }