alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
868 lines (732 loc) • 25.6 kB
text/typescript
import { $inject, Alepha } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { $repository, type Page } from "alepha/orm";
import { BadRequestError, NotFoundError } from "alepha/server";
import {
type SubscriptionEventEntity,
subscriptionEvents,
} from "../entities/subscriptionEvents.ts";
import {
type SubscriptionEntity,
subscriptions,
} from "../entities/subscriptions.ts";
import type { Entitlements } from "../schemas/entitlementsSchema.ts";
import type { SubscriptionQuery } from "../schemas/subscriptionQuerySchema.ts";
import type { SubscriptionStats } from "../schemas/subscriptionStatsSchema.ts";
import { SubscriptionConfig } from "./SubscriptionConfig.ts";
// -----------------------------------------------------------------------------------------------------------------
interface SubscribeOptions {
/**
* Override plan/global trial days.
*/
trialDays?: number;
/**
* Go straight to active (requires payment).
*/
skipTrial?: boolean;
/**
* Metadata to attach to the subscription.
*/
metadata?: Record<string, unknown>;
}
// -----------------------------------------------------------------------------------------------------------------
interface CancelOptions {
/**
* Cancellation reason.
*/
reason?: string;
/**
* Cancel immediately instead of at period end.
*/
immediate?: boolean;
/**
* User who initiated the cancellation.
*/
cancelledBy?: string;
}
// -----------------------------------------------------------------------------------------------------------------
interface ChangePlanOptions {
/**
* Apply now (with proration) or at period end.
*/
immediate?: boolean;
/**
* Override settings.prorateOnChange.
*/
prorate?: boolean;
}
// -----------------------------------------------------------------------------------------------------------------
interface EventContext {
previousStatus?: string;
newStatus?: string;
previousPlanId?: string;
newPlanId?: string;
paymentIntentId?: string;
amount?: number;
currency?: string;
triggeredBy?: string;
userId?: string;
note?: string;
}
// -----------------------------------------------------------------------------------------------------------------
export class SubscriptionService {
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 config = $inject(SubscriptionConfig);
// ---------------------------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------------------------
/**
* Find a subscription by organization ID.
* Returns null if no subscription exists.
*/
public async getByOrganization(
organizationId: string,
): Promise<SubscriptionEntity | null> {
const result = await this.subscriptionRepo.findOne({
where: { organizationId: { eq: organizationId } },
});
return result ?? null;
}
/**
* Get a subscription by ID. Throws NotFoundError if not found.
*/
public async getSubscription(id: string): Promise<SubscriptionEntity> {
return this.subscriptionRepo.getById(id);
}
/**
* Returns true if the subscription currently grants access.
* Accessible statuses: trialing, active, past_due (grace period),
* or cancelled with cancelAtPeriodEnd before period end.
*/
public isAccessible(sub: SubscriptionEntity): boolean {
if (
sub.status === "trialing" ||
sub.status === "active" ||
sub.status === "past_due"
) {
return true;
}
if (
sub.status === "cancelled" &&
sub.cancelAtPeriodEnd &&
this.dateTime.now().isBefore(sub.currentPeriodEnd)
) {
return true;
}
return false;
}
/**
* Record a subscription event in the event log.
*/
public 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,
});
}
/**
* Compute the end of a billing interval from a start date.
*/
public 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();
}
// ---------------------------------------------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------------------------------------------
/**
* Create a new subscription for an organization.
*/
public async subscribe(
organizationId: string,
planId: string,
interval: "monthly" | "yearly",
options?: SubscribeOptions,
): Promise<SubscriptionEntity> {
const plan = await this.config.getPlan(planId);
if (!plan.available) {
throw new BadRequestError(
`Plan '${planId}' is not available for new subscriptions`,
);
}
await this.config.getPlanPricing(planId, interval);
const existing = await this.subscriptionRepo.findOne({
where: {
organizationId: { eq: organizationId },
status: { inArray: ["trialing", "active", "past_due"] },
},
});
if (existing) {
throw new BadRequestError(
"Organization already has an active subscription",
);
}
const settings = await this.config.getSettings();
const trialDays =
options?.trialDays ?? plan.trial?.days ?? settings.trialDays;
const skipTrial = options?.skipTrial ?? false;
const now = this.dateTime.now();
const nowISO = now.toISOString();
if (trialDays > 0 && !skipTrial) {
const trialEnd = now.add(trialDays, "days").toISOString();
const entity = await this.subscriptionRepo.create({
organizationId,
planId,
interval,
status: "trialing",
currentPeriodStart: nowISO,
currentPeriodEnd: trialEnd,
trialStart: nowISO,
trialEnd,
nextBillingAt: trialEnd,
cancelAtPeriodEnd: false,
dunningAttempt: 0,
metadata: options?.metadata as any,
});
await this.recordEvent(entity.id, organizationId, "created", {
newStatus: "trialing",
});
await this.recordEvent(entity.id, organizationId, "trial_started", {
newStatus: "trialing",
});
this.log.info("Subscription created with trial", {
id: entity.id,
organizationId,
planId,
trialDays,
});
await this.alepha.events.emit("subscription:created" as any, {
subscription: entity,
});
return entity;
}
const periodEnd = this.computeIntervalEnd(nowISO, interval);
const entity = await this.subscriptionRepo.create({
organizationId,
planId,
interval,
status: "active",
currentPeriodStart: nowISO,
currentPeriodEnd: periodEnd,
nextBillingAt: periodEnd,
cancelAtPeriodEnd: false,
dunningAttempt: 0,
metadata: options?.metadata as any,
});
await this.recordEvent(entity.id, organizationId, "created", {
newStatus: "active",
});
this.log.info("Subscription created", {
id: entity.id,
organizationId,
planId,
});
await this.alepha.events.emit("subscription:created" as any, {
subscription: entity,
});
return entity;
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Cancel a subscription.
* If immediate, the subscription expires right away.
* If at period end, the subscription remains accessible until the period ends.
*/
public async cancel(
subscriptionId: string,
options?: CancelOptions,
): Promise<void> {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId as string;
if (
sub.status !== "trialing" &&
sub.status !== "active" &&
sub.status !== "past_due"
) {
throw new BadRequestError(
`Cannot cancel subscription with status '${sub.status}'`,
);
}
const settings = await this.config.getSettings();
const immediate = options?.immediate ?? !settings.cancelAtPeriodEnd;
const now = this.dateTime.now();
const nowISO = now.toISOString();
const previousStatus = sub.status;
if (immediate) {
await this.subscriptionRepo.updateById(subscriptionId, {
status: "expired",
cancelledAt: nowISO,
cancelReason: options?.reason,
cancelAtPeriodEnd: false,
});
await this.recordEvent(subscriptionId, orgId, "cancelled", {
previousStatus,
newStatus: "expired",
triggeredBy: options?.cancelledBy ? "user" : "system",
userId: options?.cancelledBy,
note: options?.reason,
});
this.log.info("Subscription cancelled immediately", {
id: subscriptionId,
organizationId: orgId,
});
} else {
await this.subscriptionRepo.updateById(subscriptionId, {
status: "cancelled",
cancelledAt: nowISO,
cancelReason: options?.reason,
cancelAtPeriodEnd: true,
});
await this.recordEvent(subscriptionId, orgId, "cancelled", {
previousStatus,
newStatus: "cancelled",
triggeredBy: options?.cancelledBy ? "user" : "system",
userId: options?.cancelledBy,
note: options?.reason,
});
this.log.info("Subscription cancelled at period end", {
id: subscriptionId,
organizationId: orgId,
periodEnd: sub.currentPeriodEnd,
});
}
await this.alepha.events.emit("subscription:cancelled" as any, {
subscription: sub,
immediate,
reason: options?.reason,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Resume a cancelled subscription before its period ends.
* Only valid for subscriptions cancelled with cancelAtPeriodEnd.
*/
public async resume(subscriptionId: string): Promise<void> {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId as string;
if (sub.status !== "cancelled") {
throw new BadRequestError(
`Cannot resume subscription with status '${sub.status}', must be 'cancelled'`,
);
}
if (!sub.cancelAtPeriodEnd) {
throw new BadRequestError(
"Cannot resume a subscription that was not cancelled at period end",
);
}
if (!this.dateTime.now().isBefore(sub.currentPeriodEnd)) {
throw new BadRequestError(
"Cannot resume subscription, period has already ended",
);
}
await this.subscriptionRepo.updateById(subscriptionId, {
status: "active",
cancelledAt: undefined,
cancelReason: undefined,
cancelAtPeriodEnd: false,
});
await this.recordEvent(subscriptionId, orgId, "resumed", {
previousStatus: "cancelled",
newStatus: "active",
});
this.log.info("Subscription resumed", {
id: subscriptionId,
organizationId: orgId,
});
await this.alepha.events.emit("subscription:resumed" as any, {
subscription: sub,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Change the plan of a subscription.
* If immediate, proration is calculated and the plan changes now.
* If at period end, the change is scheduled for the next renewal.
* Returns the net proration amount (positive = charge, negative = credit).
*/
public async changePlan(
subscriptionId: string,
newPlanId: string,
newInterval?: "monthly" | "yearly",
options?: ChangePlanOptions,
): Promise<number> {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId as string;
if (sub.status !== "active" && sub.status !== "trialing") {
throw new BadRequestError(
`Cannot change plan for subscription with status '${sub.status}'`,
);
}
const newPlan = await this.config.getPlan(newPlanId);
if (!newPlan.available) {
throw new BadRequestError(
`Plan '${newPlanId}' is not available for new subscriptions`,
);
}
const effectiveInterval = newInterval ?? sub.interval;
await this.config.getPlanPricing(newPlanId, effectiveInterval);
const settings = await this.config.getSettings();
const immediate = options?.immediate ?? true;
if (!immediate) {
await this.subscriptionRepo.updateById(subscriptionId, {
pendingPlanId: newPlanId,
pendingInterval: effectiveInterval,
});
await this.recordEvent(subscriptionId, orgId, "plan_change_scheduled", {
previousPlanId: sub.planId,
newPlanId,
note: `Scheduled change to '${newPlanId}' (${effectiveInterval}) at period end`,
});
this.log.info("Plan change scheduled for period end", {
id: subscriptionId,
organizationId: orgId,
newPlanId,
newInterval: effectiveInterval,
});
await this.alepha.events.emit("subscription:plan_changed" as any, {
subscription: sub,
previousPlanId: sub.planId,
newPlanId,
immediate: false,
});
return 0;
}
const shouldProrate = options?.prorate ?? settings.prorateOnChange;
let netAmount = 0;
if (shouldProrate && sub.status === "active") {
netAmount = await this.calculateProration(
sub,
newPlanId,
effectiveInterval,
);
}
const previousPlanId = sub.planId;
await this.subscriptionRepo.updateById(subscriptionId, {
planId: newPlanId,
interval: effectiveInterval,
pendingPlanId: undefined,
pendingInterval: undefined,
metadata:
netAmount < 0
? { ...sub.metadata, credit: Math.abs(netAmount) }
: sub.metadata,
});
await this.recordEvent(subscriptionId, orgId, "plan_changed", {
previousPlanId,
newPlanId,
amount: netAmount !== 0 ? Math.abs(netAmount) : undefined,
note:
netAmount > 0
? `Proration charge: ${netAmount}`
: netAmount < 0
? `Proration credit: ${Math.abs(netAmount)}`
: undefined,
});
this.log.info("Plan changed immediately", {
id: subscriptionId,
organizationId: orgId,
previousPlanId,
newPlanId,
netAmount,
});
await this.alepha.events.emit("subscription:plan_changed" as any, {
subscription: sub,
previousPlanId,
newPlanId,
immediate: true,
netAmount,
});
return netAmount;
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Reactivate a suspended subscription (admin action).
* Resets dunning state and starts a new billing period.
*/
public async reactivate(subscriptionId: string): Promise<void> {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId as string;
if (sub.status !== "suspended") {
throw new BadRequestError(
`Cannot reactivate subscription with status '${sub.status}', must be 'suspended'`,
);
}
const now = this.dateTime.now();
const nowISO = now.toISOString();
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
await this.subscriptionRepo.updateById(subscriptionId, {
status: "active",
currentPeriodStart: nowISO,
currentPeriodEnd: periodEnd,
nextBillingAt: periodEnd,
dunningStartedAt: undefined,
dunningAttempt: 0,
dunningNextRetryAt: undefined,
});
await this.recordEvent(subscriptionId, orgId, "reactivated", {
previousStatus: "suspended",
newStatus: "active",
});
this.log.info("Subscription reactivated", {
id: subscriptionId,
organizationId: orgId,
});
await this.alepha.events.emit("subscription:reactivated" as any, {
subscription: sub,
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Extend the trial period of a trialing subscription.
*/
public async extendTrial(
subscriptionId: string,
days: number,
): Promise<void> {
const sub = await this.subscriptionRepo.getById(subscriptionId);
if (sub.status !== "trialing") {
throw new BadRequestError(
`Cannot extend trial for subscription with status '${sub.status}', must be 'trialing'`,
);
}
if (!sub.trialEnd) {
throw new BadRequestError("Subscription has no trial end date set");
}
const currentTrialEnd = this.dateTime.of(sub.trialEnd);
const newTrialEnd = currentTrialEnd.add(days, "days").toISOString();
await this.subscriptionRepo.updateById(subscriptionId, {
trialEnd: newTrialEnd,
currentPeriodEnd: newTrialEnd,
nextBillingAt: newTrialEnd,
});
this.log.info("Trial extended", {
id: subscriptionId,
organizationId: sub.organizationId as string,
days,
newTrialEnd,
});
}
// ---------------------------------------------------------------------------------------------------------------
// Entitlements
// ---------------------------------------------------------------------------------------------------------------
/**
* Check if an organization has access to a specific feature.
*/
public async can(organizationId: string, feature: string): Promise<boolean> {
const sub = await this.getByOrganization(organizationId);
if (!sub || !this.isAccessible(sub)) return false;
const plan = await this.config.getPlan(sub.planId);
return plan.features.includes(feature);
}
/**
* Get the usage limit for a resource.
* Returns -1 for unlimited, 0 for no access.
*/
public async limit(
organizationId: string,
resource: string,
): Promise<number> {
const sub = await this.getByOrganization(organizationId);
if (!sub || !this.isAccessible(sub)) return 0;
const plan = await this.config.getPlan(sub.planId);
return plan.limits[resource] ?? 0;
}
/**
* Get the full entitlements snapshot for an organization.
*/
public async getEntitlements(organizationId: string): Promise<Entitlements> {
const sub = await this.getByOrganization(organizationId);
if (!sub) {
throw new NotFoundError(
`No subscription found for organization '${organizationId}'`,
);
}
const plan = await this.config.getPlan(sub.planId);
return {
planId: plan.id,
planName: plan.name,
status: sub.status,
features: plan.features,
limits: plan.limits,
trialEndsAt: sub.trialEnd,
periodEndsAt: sub.currentPeriodEnd,
cancelledAt: sub.cancelledAt,
};
}
// ---------------------------------------------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------------------------------------------
/**
* Find subscriptions with pagination and filtering.
*/
public async findSubscriptions(
query: SubscriptionQuery = {},
): Promise<Page<SubscriptionEntity>> {
query.sort ??= "-createdAt";
const where = this.subscriptionRepo.createQueryWhere();
if (query.status) {
where.status = { eq: query.status };
}
if (query.planId) {
where.planId = { eq: query.planId };
}
if (query.organizationId) {
where.organizationId = { eq: query.organizationId };
}
return this.subscriptionRepo.paginate(query, { where }, { count: true });
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Get the event history for a subscription, ordered by most recent first.
*/
public async getHistory(
subscriptionId: string,
): Promise<SubscriptionEventEntity[]> {
return this.eventRepo.findMany({
where: { subscriptionId: { eq: subscriptionId } },
orderBy: { column: "createdAt", direction: "desc" },
});
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Get aggregated subscription statistics.
*/
public async getStats(): Promise<SubscriptionStats> {
const [trialing, active, pastDue, suspended, cancelled, expired] =
await Promise.all([
this.subscriptionRepo.count({ status: { eq: "trialing" } }),
this.subscriptionRepo.count({ status: { eq: "active" } }),
this.subscriptionRepo.count({ status: { eq: "past_due" } }),
this.subscriptionRepo.count({ status: { eq: "suspended" } }),
this.subscriptionRepo.count({ status: { eq: "cancelled" } }),
this.subscriptionRepo.count({ status: { eq: "expired" } }),
]);
const total = trialing + active + pastDue + suspended + cancelled + expired;
const trialEndedEvents = await this.eventRepo.count({
type: { eq: "trial_ended" },
});
const activatedEvents = await this.eventRepo.count({
type: { eq: "activated" },
});
const trialConversionRate =
trialEndedEvents > 0 ? activatedEvents / trialEndedEvents : 0;
const cancelledEvents = await this.eventRepo.count({
type: { eq: "cancelled" },
});
const totalSubscribed = active + trialing + pastDue;
const churnRate =
totalSubscribed + cancelledEvents > 0
? cancelledEvents / (totalSubscribed + cancelledEvents)
: 0;
const plans = await this.config.getPlans();
const byPlan: Record<
string,
{ active: number; trialing: number; total: number }
> = {};
for (const plan of plans) {
const [planActive, planTrialing] = await Promise.all([
this.subscriptionRepo.count({
planId: { eq: plan.id },
status: { eq: "active" },
}),
this.subscriptionRepo.count({
planId: { eq: plan.id },
status: { eq: "trialing" },
}),
]);
byPlan[plan.id] = {
active: planActive,
trialing: planTrialing,
total: planActive + planTrialing,
};
}
return {
total,
trialing,
active,
pastDue,
suspended,
cancelled,
expired,
trialConversionRate,
churnRate,
byPlan,
};
}
// ---------------------------------------------------------------------------------------------------------------
/**
* Get revenue data from recent subscription events.
* Sums amounts from renewed and activated events within the specified window.
*/
public async getRevenue(
days = 30,
): Promise<{ total: number; count: number }> {
const cutoff = this.dateTime.now().subtract(days, "days").toISOString();
const events = await this.eventRepo.findMany({
where: {
type: { inArray: ["renewed", "activated"] },
createdAt: { gt: cutoff },
},
});
let total = 0;
for (const event of events) {
total += event.amount ?? 0;
}
return { total, count: events.length };
}
// ---------------------------------------------------------------------------------------------------------------
// Protected helpers
// ---------------------------------------------------------------------------------------------------------------
/**
* Calculate proration for a mid-cycle plan change.
* Returns the net amount: positive = charge, negative = credit.
*/
protected async calculateProration(
sub: SubscriptionEntity,
newPlanId: string,
newInterval: "monthly" | "yearly",
): Promise<number> {
const oldPricing = await this.config.getPlanPricing(
sub.planId,
sub.interval,
);
const newPricing = await this.config.getPlanPricing(newPlanId, newInterval);
const now = this.dateTime.now();
const periodStart = this.dateTime.of(sub.currentPeriodStart);
const periodEnd = this.dateTime.of(sub.currentPeriodEnd);
const daysInPeriod = periodEnd.diff(periodStart, "days");
if (daysInPeriod <= 0) return 0;
const daysUsed = now.diff(periodStart, "days");
const daysRemaining = daysInPeriod - daysUsed;
const oldDailyRate = oldPricing.amount / daysInPeriod;
const newDailyRate = newPricing.amount / daysInPeriod;
const credit = Math.round(daysRemaining * oldDailyRate);
const charge = Math.round(daysRemaining * newDailyRate);
return charge - credit;
}
}