alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
383 lines (329 loc) • 11.2 kB
text/typescript
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);
}
},
});
}