alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
438 lines (380 loc) • 13.5 kB
text/typescript
import { $hook, $inject, Alepha } from "alepha";
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 {
type SubscriptionEntity,
subscriptions,
} from "../entities/subscriptions.ts";
import { SubscriptionConfig } from "./SubscriptionConfig.ts";
// -----------------------------------------------------------------------------------------------------------------
interface PaymentEvent {
intentId: string;
amount: number;
currency: string;
metadata?: unknown;
}
// -----------------------------------------------------------------------------------------------------------------
interface EventContext {
previousStatus?: string;
newStatus?: string;
previousPlanId?: string;
newPlanId?: string;
paymentIntentId?: string;
amount?: number;
currency?: string;
triggeredBy?: string;
userId?: string;
note?: string;
}
// -----------------------------------------------------------------------------------------------------------------
export class BillingService {
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly dateTime = $inject(DateTimeProvider);
protected readonly subscriptionRepo = $repository(subscriptions);
protected readonly eventRepo = $repository(subscriptionEvents);
protected readonly paymentService = $inject(PaymentService);
protected readonly config = $inject(SubscriptionConfig);
// ---------------------------------------------------------------------------------------------------------------
// Payment hook listeners
// ---------------------------------------------------------------------------------------------------------------
/**
* React to successful payment capture.
* Routes to the appropriate handler based on subscription status.
*/
protected readonly onPaymentCaptured = $hook({
on: "payments:captured",
handler: async (event) => {
const sub = await this.findByPaymentIntent(event.intentId);
if (!sub) return;
if (sub.status === "trialing") {
await this.activate(sub, event);
} else if (sub.status === "active") {
await this.renew(sub, event);
} else if (sub.status === "past_due") {
await this.recoverFromDunning(sub, event);
} else if (sub.status === "suspended") {
await this.reactivateFromPayment(sub, event);
}
},
});
/**
* React to failed payment.
* Starts or advances the dunning flow.
*/
protected readonly onPaymentFailed = $hook({
on: "payments:failed",
handler: async (event) => {
const sub = await this.findByPaymentIntent(event.intentId);
if (!sub) return;
await this.handlePaymentFailure(sub, event);
},
});
// ---------------------------------------------------------------------------------------------------------------
// Lookup
// ---------------------------------------------------------------------------------------------------------------
/**
* Find a subscription by its last payment intent ID.
* Returns null if no subscription matches.
*/
protected async findByPaymentIntent(
intentId: string,
): Promise<SubscriptionEntity | null> {
const sub = await this.subscriptionRepo.findOne({
where: { lastPaymentIntentId: { eq: intentId } },
});
return sub ?? null;
}
// ---------------------------------------------------------------------------------------------------------------
// Lifecycle transitions
// ---------------------------------------------------------------------------------------------------------------
/**
* Trial to active transition.
* Sets the first paid billing period and records activation events.
*/
protected async activate(
sub: SubscriptionEntity,
event: PaymentEvent,
): Promise<void> {
const orgId = sub.organizationId as string;
const now = this.dateTime.now();
const nowISO = now.toISOString();
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
await this.subscriptionRepo.updateById(sub.id, {
status: "active",
lastPaymentAt: nowISO,
lastPaymentIntentId: event.intentId,
currentPeriodStart: nowISO,
currentPeriodEnd: periodEnd,
nextBillingAt: periodEnd,
});
await this.recordEvent(sub.id, orgId, "trial_ended", {
previousStatus: "trialing",
newStatus: "active",
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
});
await this.recordEvent(sub.id, orgId, "activated", {
previousStatus: "trialing",
newStatus: "active",
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
});
this.log.info("Subscription activated from trial", {
id: sub.id,
organizationId: orgId,
planId: sub.planId,
});
await this.alepha.events.emit("subscription:activated" as any, {
subscriptionId: sub.id,
organizationId: orgId,
planId: sub.planId,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Active to active cycle renewal.
* Applies any pending plan change, then advances the billing period.
*/
protected async renew(
sub: SubscriptionEntity,
event: PaymentEvent,
): Promise<void> {
const orgId = sub.organizationId as string;
let effectivePlanId = sub.planId;
let effectiveInterval = sub.interval;
if (sub.pendingPlanId) {
effectivePlanId = sub.pendingPlanId;
effectiveInterval = sub.pendingInterval ?? sub.interval;
await this.subscriptionRepo.updateById(sub.id, {
planId: effectivePlanId,
interval: effectiveInterval,
pendingPlanId: undefined,
pendingInterval: undefined,
});
await this.recordEvent(sub.id, orgId, "plan_changed", {
previousPlanId: sub.planId,
newPlanId: effectivePlanId,
note: `Plan changed on renewal from '${sub.planId}' to '${effectivePlanId}'`,
});
}
const newPeriodStart = sub.currentPeriodEnd;
const newPeriodEnd = this.computeIntervalEnd(
newPeriodStart,
effectiveInterval,
);
await this.subscriptionRepo.updateById(sub.id, {
currentPeriodStart: newPeriodStart,
currentPeriodEnd: newPeriodEnd,
lastPaymentAt: this.dateTime.now().toISOString(),
lastPaymentIntentId: event.intentId,
nextBillingAt: newPeriodEnd,
});
await this.recordEvent(sub.id, orgId, "renewed", {
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
});
this.log.info("Subscription renewed", {
id: sub.id,
organizationId: orgId,
planId: effectivePlanId,
periodEnd: newPeriodEnd,
});
await this.alepha.events.emit("subscription:renewed" as any, {
subscriptionId: sub.id,
organizationId: orgId,
planId: effectivePlanId,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Recover from dunning: past_due to active.
* Resets all dunning state and records reactivation.
*/
protected async recoverFromDunning(
sub: SubscriptionEntity,
event: PaymentEvent,
): Promise<void> {
const orgId = sub.organizationId as string;
const nowISO = this.dateTime.now().toISOString();
await this.subscriptionRepo.updateById(sub.id, {
status: "active",
lastPaymentAt: nowISO,
lastPaymentIntentId: event.intentId,
dunningStartedAt: undefined,
dunningAttempt: 0,
dunningNextRetryAt: undefined,
});
await this.recordEvent(sub.id, orgId, "reactivated", {
previousStatus: "past_due",
newStatus: "active",
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
});
this.log.info("Subscription recovered from dunning", {
id: sub.id,
organizationId: orgId,
});
await this.alepha.events.emit("subscription:reactivated" as any, {
subscriptionId: sub.id,
organizationId: orgId,
planId: sub.planId,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Reactivate from suspended state after a successful payment.
* Resets dunning, sets a fresh billing period, and records reactivation.
*/
protected async reactivateFromPayment(
sub: SubscriptionEntity,
event: PaymentEvent,
): Promise<void> {
const orgId = sub.organizationId as string;
const now = this.dateTime.now();
const nowISO = now.toISOString();
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
await this.subscriptionRepo.updateById(sub.id, {
status: "active",
currentPeriodStart: nowISO,
currentPeriodEnd: periodEnd,
nextBillingAt: periodEnd,
lastPaymentAt: nowISO,
lastPaymentIntentId: event.intentId,
dunningStartedAt: undefined,
dunningAttempt: 0,
dunningNextRetryAt: undefined,
});
await this.recordEvent(sub.id, orgId, "reactivated", {
previousStatus: "suspended",
newStatus: "active",
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
});
this.log.info("Subscription reactivated from suspended", {
id: sub.id,
organizationId: orgId,
planId: sub.planId,
});
await this.alepha.events.emit("subscription:reactivated" as any, {
subscriptionId: sub.id,
organizationId: orgId,
planId: sub.planId,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Handle a failed payment: start or advance dunning.
* Updates dunning state and transitions to past_due if needed.
*/
protected async handlePaymentFailure(
sub: SubscriptionEntity,
event: PaymentEvent,
): Promise<void> {
const orgId = sub.organizationId as string;
const now = this.dateTime.now();
const nowISO = now.toISOString();
const settings = await this.config.getSettings();
const schedule = settings.dunningSchedule;
let attempt: number;
if (sub.dunningAttempt === 0) {
attempt = 1;
await this.subscriptionRepo.updateById(sub.id, {
dunningStartedAt: nowISO,
dunningAttempt: attempt,
});
} else {
attempt = sub.dunningAttempt + 1;
await this.subscriptionRepo.updateById(sub.id, {
dunningAttempt: attempt,
});
}
if (sub.status !== "past_due") {
await this.subscriptionRepo.updateById(sub.id, {
status: "past_due",
});
await this.recordEvent(sub.id, orgId, "past_due", {
previousStatus: sub.status,
newStatus: "past_due",
paymentIntentId: event.intentId,
});
}
const scheduleDays = schedule[attempt - 1];
if (scheduleDays !== undefined) {
const nextRetry = now.add(scheduleDays, "days").toISOString();
await this.subscriptionRepo.updateById(sub.id, {
dunningNextRetryAt: nextRetry,
});
} else {
await this.subscriptionRepo.updateById(sub.id, {
dunningNextRetryAt: undefined,
});
}
await this.recordEvent(sub.id, orgId, "payment_failed", {
paymentIntentId: event.intentId,
amount: event.amount,
currency: event.currency,
note: `Dunning attempt ${attempt}/${schedule.length}`,
});
this.log.warn("Subscription payment failed", {
id: sub.id,
organizationId: orgId,
attempt,
maxAttempts: schedule.length,
});
await this.alepha.events.emit("subscription:payment_failed" as any, {
subscriptionId: sub.id,
organizationId: orgId,
planId: sub.planId,
attempt,
});
}
// ---------------------------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------------------------
/**
* Compute the end of a billing interval from a start date.
*/
protected computeIntervalEnd(
start: string,
interval: "monthly" | "yearly",
): string {
const startDate = this.dateTime.of(start);
const unit = interval === "monthly" ? "months" : "years";
return startDate.add(1, unit).toISOString();
}
/**
* 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,
previousPlanId: context?.previousPlanId,
newPlanId: context?.newPlanId,
paymentIntentId: context?.paymentIntentId,
amount: context?.amount,
currency: context?.currency,
triggeredBy: context?.triggeredBy,
userId: context?.userId,
note: context?.note,
});
}
}