alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,527 lines • 62.5 kB
JavaScript
import { $context, $hook, $inject, $module, Alepha, createMiddleware, z } from "alepha";
import { $secure } from "alepha/security";
import { $action, BadRequestError, ForbiddenError, NotFoundError, okSchema } from "alepha/server";
import { $entity, $repository, db, pageQuerySchema } from "alepha/orm";
import { $parameter } from "alepha/api/parameters";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { $job } from "alepha/api/jobs";
import { PaymentService } from "alepha/api/payments";
import { $notification } from "alepha/api/notifications";
import { CacheProvider } from "alepha/cache";
//#region ../../src/api/subscriptions/schemas/cancelSubscriptionSchema.ts
const cancelSubscriptionSchema = z.object({
reason: z.string().optional(),
immediate: z.boolean().optional()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/changePlanSchema.ts
const changePlanSchema = z.object({
planId: z.string(),
interval: z.enum(["monthly", "yearly"]).optional(),
immediate: z.boolean().optional()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/mrrSchema.ts
const mrrSchema = z.object({
total: z.integer(),
byPlan: z.record(z.text(), z.integer()),
growth: z.integer(),
newMrr: z.integer(),
expansionMrr: z.integer(),
contractionMrr: z.integer(),
churnMrr: z.integer()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/subscriptionQuerySchema.ts
const subscriptionQuerySchema = pageQuerySchema.extend({
status: z.enum([
"trialing",
"active",
"past_due",
"suspended",
"cancelled",
"expired"
]).optional(),
planId: z.string().optional(),
organizationId: z.uuid().optional()
});
//#endregion
//#region ../../src/api/subscriptions/entities/subscriptions.ts
const subscriptions = $entity({
name: "subscriptions",
schema: z.object({
id: db.primaryKey(z.uuid()),
version: db.version(),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
organizationId: db.organization(),
planId: z.string(),
interval: z.enum(["monthly", "yearly"]),
status: z.enum([
"trialing",
"active",
"past_due",
"suspended",
"cancelled",
"expired"
]),
currentPeriodStart: z.datetime(),
currentPeriodEnd: z.datetime(),
trialStart: z.datetime().optional(),
trialEnd: z.datetime().optional(),
cancelledAt: z.datetime().optional(),
cancelReason: z.string().optional(),
cancelAtPeriodEnd: z.boolean().default(false),
lastPaymentIntentId: z.uuid().optional(),
lastPaymentAt: z.datetime().optional(),
nextBillingAt: z.datetime().optional(),
dunningStartedAt: z.datetime().optional(),
dunningAttempt: z.integer().default(0),
dunningNextRetryAt: z.datetime().optional(),
pendingPlanId: z.string().optional(),
pendingInterval: z.enum(["monthly", "yearly"]).optional(),
metadata: z.record(z.text(), z.any()).optional()
}),
indexes: [
{
columns: ["organizationId"],
unique: true
},
{ columns: ["status"] },
{ columns: ["planId", "status"] },
{ columns: ["nextBillingAt"] },
{ columns: ["trialEnd"] },
{ columns: ["dunningNextRetryAt"] },
{ columns: ["currentPeriodEnd"] }
]
});
//#endregion
//#region ../../src/api/subscriptions/schemas/subscriptionResourceSchema.ts
const subscriptionResourceSchema = subscriptions.schema;
//#endregion
//#region ../../src/api/subscriptions/schemas/subscriptionStatsSchema.ts
const subscriptionStatsSchema = z.object({
total: z.integer(),
trialing: z.integer(),
active: z.integer(),
pastDue: z.integer(),
suspended: z.integer(),
cancelled: z.integer(),
expired: z.integer(),
trialConversionRate: z.number(),
churnRate: z.number(),
byPlan: z.record(z.text(), z.object({
active: z.integer(),
trialing: z.integer(),
total: z.integer()
}))
});
//#endregion
//#region ../../src/api/subscriptions/schemas/planDefinitionSchema.ts
const planDefinitionSchema = z.object({
/**
* Unique plan identifier (e.g., "free", "starter", "pro", "enterprise").
*/
id: z.string().min(1).max(50),
/**
* Display name (e.g., "Pro Plan").
*/
name: z.string(),
/**
* Optional description.
*/
description: z.string().optional(),
/**
* Whether this plan is available for new subscriptions.
*/
available: z.boolean().default(true),
/**
* Pricing per billing interval.
* Multiple entries for monthly/yearly.
*/
pricing: z.array(z.object({
interval: z.enum(["monthly", "yearly"]),
amount: z.integer().min(0),
currency: z.string().min(3).max(3)
})),
/**
* Trial configuration for this plan.
* Overrides global settings.trialDays if set.
*/
trial: z.object({
days: z.integer().min(0).max(365),
requirePaymentMethod: z.boolean().default(false)
}).optional(),
/**
* Feature entitlements. Boolean flags for feature access.
* Checked via SubscriptionService.can("feature-name").
*/
features: z.array(z.string()),
/**
* Usage limits. Numeric caps on resources.
* Checked via SubscriptionService.limit("resource-name").
* -1 = unlimited.
*/
limits: z.record(z.text(), z.integer()),
/**
* Sort order for display (lower = first).
*/
order: z.integer().default(0),
/**
* Metadata for app-specific plan data.
*/
metadata: z.record(z.text(), z.any()).optional()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/subscriptionSettingsSchema.ts
const subscriptionSettingsSchema = z.object({
/**
* Default trial days (overridden per-plan if plan.trial.days is set).
*/
trialDays: z.integer().min(0).max(365).default(14),
/**
* Days after payment failure before suspension.
* During grace period, subscription remains active but flagged.
*/
gracePeriodDays: z.integer().min(0).max(30).default(7),
/**
* Days after first payment failure to retry, relative to the failure date.
* e.g., [1, 3, 5, 7] means retry on day 1, 3, 5, 7 after failure.
*/
dunningSchedule: z.array(z.integer().min(1)),
/**
* When user cancels, wait until period end (true) or cancel immediately (false).
*/
cancelAtPeriodEnd: z.boolean().default(true),
/**
* Prorate charges when changing plans mid-cycle.
*/
prorateOnChange: z.boolean().default(true)
});
//#endregion
//#region ../../src/api/subscriptions/services/SubscriptionConfig.ts
var SubscriptionConfig = class {
plans = $parameter({
name: "subscriptions.plans",
description: "Subscription plan definitions",
schema: z.object({ plans: z.array(planDefinitionSchema) }),
default: { plans: [] }
});
settings = $parameter({
name: "subscriptions.settings",
description: "Global subscription settings",
schema: subscriptionSettingsSchema,
default: {
trialDays: 14,
gracePeriodDays: 7,
dunningSchedule: [
1,
3,
5,
7
],
cancelAtPeriodEnd: true,
prorateOnChange: true
}
});
async getPlans() {
return (await this.plans.get()).plans;
}
async getSettings() {
return this.settings.get();
}
async getPlan(planId) {
const plan = (await this.getPlans()).find((p) => p.id === planId);
if (!plan) throw new BadRequestError(`Plan '${planId}' not found`);
return plan;
}
async getPlanPricing(planId, interval) {
const pricing = (await this.getPlan(planId)).pricing.find((p) => p.interval === interval);
if (!pricing) throw new BadRequestError(`No ${interval} pricing for plan '${planId}'`);
return pricing;
}
};
//#endregion
//#region ../../src/api/subscriptions/entities/subscriptionEvents.ts
const subscriptionEvents = $entity({
name: "subscription_events",
schema: z.object({
id: db.primaryKey(z.uuid()),
createdAt: db.createdAt(),
subscriptionId: db.ref(z.uuid(), () => subscriptions.cols.id, { onDelete: "cascade" }),
organizationId: db.organization(),
type: z.enum([
"created",
"trial_started",
"trial_ended",
"activated",
"renewed",
"payment_failed",
"payment_retried",
"past_due",
"suspended",
"reactivated",
"plan_changed",
"plan_change_scheduled",
"cancelled",
"expired",
"resumed"
]),
previousStatus: z.string().optional(),
newStatus: z.string().optional(),
previousPlanId: z.string().optional(),
newPlanId: z.string().optional(),
paymentIntentId: z.uuid().optional(),
amount: z.integer().optional(),
currency: z.string().optional(),
triggeredBy: z.string().optional(),
userId: z.uuid().optional(),
note: z.string().optional()
}),
indexes: [
{ columns: ["subscriptionId", "createdAt"] },
{ columns: ["organizationId", "createdAt"] },
{ columns: ["type"] }
]
});
//#endregion
//#region ../../src/api/subscriptions/services/SubscriptionService.ts
var SubscriptionService = class {
alepha = $inject(Alepha);
log = $logger();
dateTime = $inject(DateTimeProvider);
subscriptionRepo = $repository(subscriptions);
eventRepo = $repository(subscriptionEvents);
config = $inject(SubscriptionConfig);
/**
* Find a subscription by organization ID.
* Returns null if no subscription exists.
*/
async getByOrganization(organizationId) {
return await this.subscriptionRepo.findOne({ where: { organizationId: { eq: organizationId } } }) ?? null;
}
/**
* Get a subscription by ID. Throws NotFoundError if not found.
*/
async getSubscription(id) {
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.
*/
isAccessible(sub) {
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.
*/
async recordEvent(subscriptionId, organizationId, type, context) {
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.
*/
computeIntervalEnd(start, interval) {
const startDate = this.dateTime.of(start);
const unit = interval === "monthly" ? "months" : "years";
return startDate.add(1, unit).toISOString();
}
/**
* Create a new subscription for an organization.
*/
async subscribe(organizationId, planId, interval, options) {
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);
if (await this.subscriptionRepo.findOne({ where: {
organizationId: { eq: organizationId },
status: { inArray: [
"trialing",
"active",
"past_due"
] }
} })) 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
});
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", { 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
});
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", { 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.
*/
async cancel(subscriptionId, options) {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId;
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 nowISO = this.dateTime.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", {
subscription: sub,
immediate,
reason: options?.reason
});
}
/**
* Resume a cancelled subscription before its period ends.
* Only valid for subscriptions cancelled with cancelAtPeriodEnd.
*/
async resume(subscriptionId) {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId;
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: void 0,
cancelReason: void 0,
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", { 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).
*/
async changePlan(subscriptionId, newPlanId, newInterval, options) {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId;
if (sub.status !== "active" && sub.status !== "trialing") throw new BadRequestError(`Cannot change plan for subscription with status '${sub.status}'`);
if (!(await this.config.getPlan(newPlanId)).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();
if (!(options?.immediate ?? true)) {
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", {
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: void 0,
pendingInterval: void 0,
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) : void 0,
note: netAmount > 0 ? `Proration charge: ${netAmount}` : netAmount < 0 ? `Proration credit: ${Math.abs(netAmount)}` : void 0
});
this.log.info("Plan changed immediately", {
id: subscriptionId,
organizationId: orgId,
previousPlanId,
newPlanId,
netAmount
});
await this.alepha.events.emit("subscription:plan_changed", {
subscription: sub,
previousPlanId,
newPlanId,
immediate: true,
netAmount
});
return netAmount;
}
/**
* Reactivate a suspended subscription (admin action).
* Resets dunning state and starts a new billing period.
*/
async reactivate(subscriptionId) {
const sub = await this.subscriptionRepo.getById(subscriptionId);
const orgId = sub.organizationId;
if (sub.status !== "suspended") throw new BadRequestError(`Cannot reactivate subscription with status '${sub.status}', must be 'suspended'`);
const nowISO = this.dateTime.now().toISOString();
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
await this.subscriptionRepo.updateById(subscriptionId, {
status: "active",
currentPeriodStart: nowISO,
currentPeriodEnd: periodEnd,
nextBillingAt: periodEnd,
dunningStartedAt: void 0,
dunningAttempt: 0,
dunningNextRetryAt: void 0
});
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", { subscription: sub });
}
/**
* Extend the trial period of a trialing subscription.
*/
async extendTrial(subscriptionId, days) {
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 newTrialEnd = this.dateTime.of(sub.trialEnd).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,
days,
newTrialEnd
});
}
/**
* Check if an organization has access to a specific feature.
*/
async can(organizationId, feature) {
const sub = await this.getByOrganization(organizationId);
if (!sub || !this.isAccessible(sub)) return false;
return (await this.config.getPlan(sub.planId)).features.includes(feature);
}
/**
* Get the usage limit for a resource.
* Returns -1 for unlimited, 0 for no access.
*/
async limit(organizationId, resource) {
const sub = await this.getByOrganization(organizationId);
if (!sub || !this.isAccessible(sub)) return 0;
return (await this.config.getPlan(sub.planId)).limits[resource] ?? 0;
}
/**
* Get the full entitlements snapshot for an organization.
*/
async getEntitlements(organizationId) {
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
};
}
/**
* Find subscriptions with pagination and filtering.
*/
async findSubscriptions(query = {}) {
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.
*/
async getHistory(subscriptionId) {
return this.eventRepo.findMany({
where: { subscriptionId: { eq: subscriptionId } },
orderBy: {
column: "createdAt",
direction: "desc"
}
});
}
/**
* Get aggregated subscription statistics.
*/
async getStats() {
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 = {};
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.
*/
async getRevenue(days = 30) {
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
};
}
/**
* Calculate proration for a mid-cycle plan change.
* Returns the net amount: positive = charge, negative = credit.
*/
async calculateProration(sub, newPlanId, newInterval) {
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 daysInPeriod = this.dateTime.of(sub.currentPeriodEnd).diff(periodStart, "days");
if (daysInPeriod <= 0) return 0;
const daysRemaining = daysInPeriod - now.diff(periodStart, "days");
const oldDailyRate = oldPricing.amount / daysInPeriod;
const newDailyRate = newPricing.amount / daysInPeriod;
const credit = Math.round(daysRemaining * oldDailyRate);
return Math.round(daysRemaining * newDailyRate) - credit;
}
};
//#endregion
//#region ../../src/api/subscriptions/controllers/AdminSubscriptionController.ts
var AdminSubscriptionController = class {
url = "/subscriptions";
group = "admin:subscriptions";
service = $inject(SubscriptionService);
config = $inject(SubscriptionConfig);
/**
* Find subscriptions with pagination and filtering.
*/
findSubscriptions = $action({
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:read"] })],
description: "Find subscriptions with pagination and filtering",
schema: {
query: subscriptionQuerySchema,
response: z.page(subscriptionResourceSchema)
},
handler: ({ query }) => this.service.findSubscriptions(query)
});
/**
* Get a subscription by ID.
*/
getSubscription = $action({
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:read"] })],
description: "Get a subscription by ID",
schema: {
params: z.object({ id: z.uuid() }),
response: subscriptionResourceSchema
},
handler: ({ params }) => this.service.getSubscription(params.id)
});
/**
* Get aggregated subscription statistics.
*/
getStats = $action({
path: `${this.url}/stats`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:read"] })],
description: "Get aggregated subscription statistics",
schema: { response: subscriptionStatsSchema },
handler: () => this.service.getStats()
});
/**
* Get revenue data from recent subscription events.
*/
getRevenue = $action({
path: `${this.url}/revenue`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:read"] })],
description: "Get revenue data from recent subscription events",
schema: {
query: z.object({ days: z.integer().min(1).max(365).optional() }),
response: z.object({
total: z.integer(),
count: z.integer()
})
},
handler: ({ query }) => this.service.getRevenue(query.days)
});
/**
* Get Monthly Recurring Revenue breakdown.
*/
getMrr = $action({
path: `${this.url}/mrr`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:read"] })],
description: "Get Monthly Recurring Revenue breakdown",
schema: { response: mrrSchema },
handler: async () => {
const activeSubs = await this.service.findSubscriptions({
status: "active",
size: 1e3
});
const plans = await this.config.getPlans();
const byPlan = {};
let total = 0;
for (const sub of activeSubs.content) {
const plan = plans.find((p) => p.id === sub.planId);
if (!plan) continue;
const pricing = plan.pricing.find((p) => p.interval === sub.interval);
if (!pricing) continue;
const monthlyAmount = sub.interval === "yearly" ? Math.round(pricing.amount / 12) : pricing.amount;
byPlan[sub.planId] = (byPlan[sub.planId] ?? 0) + monthlyAmount;
total += monthlyAmount;
}
return {
total,
byPlan,
growth: 0,
newMrr: 0,
expansionMrr: 0,
contractionMrr: 0,
churnMrr: 0
};
}
});
/**
* Force a plan change for a subscription (admin action).
*/
adminChangePlan = $action({
method: "POST",
path: `${this.url}/:id/change-plan`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:update"] })],
description: "Force a plan change for a subscription",
schema: {
params: z.object({ id: z.uuid() }),
body: changePlanSchema,
response: subscriptionResourceSchema
},
handler: async ({ params, body }) => {
await this.service.changePlan(params.id, body.planId, body.interval, { immediate: body.immediate });
return this.service.getSubscription(params.id);
}
});
/**
* Force cancel a subscription (admin action).
*/
adminCancel = $action({
method: "POST",
path: `${this.url}/:id/cancel`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:update"] })],
description: "Force cancel a subscription",
schema: {
params: z.object({ id: z.uuid() }),
body: cancelSubscriptionSchema,
response: okSchema
},
handler: async ({ params, body }) => {
await this.service.cancel(params.id, {
reason: body.reason,
immediate: body.immediate
});
return { ok: true };
}
});
/**
* Reactivate a suspended subscription (admin action).
*/
adminReactivate = $action({
method: "POST",
path: `${this.url}/:id/reactivate`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:update"] })],
description: "Reactivate a suspended subscription",
schema: {
params: z.object({ id: z.uuid() }),
response: okSchema
},
handler: async ({ params }) => {
await this.service.reactivate(params.id);
return { ok: true };
}
});
/**
* Extend the trial period for a trialing subscription (admin action).
*/
adminExtendTrial = $action({
method: "POST",
path: `${this.url}/:id/extend-trial`,
group: this.group,
use: [$secure({ permissions: ["admin:subscription:update"] })],
description: "Extend the trial period for a subscription",
schema: {
params: z.object({ id: z.uuid() }),
body: z.object({ days: z.integer().min(1).max(365) }),
response: okSchema
},
handler: async ({ params, body }) => {
await this.service.extendTrial(params.id, body.days);
return { ok: true };
}
});
};
//#endregion
//#region ../../src/api/subscriptions/schemas/createSubscriptionSchema.ts
/**
* Public body for the self-service subscribe endpoint.
*
* Deliberately does NOT expose `skipTrial` (or a `paymentMethodId`): skipping
* the trial goes straight to a paid `active` subscription with no captured
* payment, so it must never be client-controlled. Trusted server-side callers
* pass `skipTrial` through the `SubscriptionService.subscribe` options after a
* payment is captured — it is not part of the public request.
*/
const createSubscriptionSchema = z.object({
planId: z.string(),
interval: z.enum(["monthly", "yearly"]),
metadata: z.record(z.text(), z.any()).optional()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/entitlementsSchema.ts
const entitlementsSchema = z.object({
planId: z.string(),
planName: z.string(),
status: z.enum([
"trialing",
"active",
"past_due",
"suspended",
"cancelled",
"expired"
]),
features: z.array(z.string()),
limits: z.record(z.text(), z.integer()),
trialEndsAt: z.datetime().optional(),
periodEndsAt: z.datetime(),
cancelledAt: z.datetime().optional()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/planResourceSchema.ts
const planResourceSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
pricing: z.array(z.object({
interval: z.enum(["monthly", "yearly"]),
amount: z.integer(),
currency: z.string()
})),
features: z.array(z.string()),
limits: z.record(z.text(), z.integer()),
trial: z.object({
days: z.integer(),
requirePaymentMethod: z.boolean()
}).optional(),
order: z.integer()
});
//#endregion
//#region ../../src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts
const subscriptionEventResourceSchema = subscriptionEvents.schema;
//#endregion
//#region ../../src/api/subscriptions/controllers/SubscriptionController.ts
var SubscriptionController = class {
url = "/subscriptions";
group = "subscriptions";
service = $inject(SubscriptionService);
config = $inject(SubscriptionConfig);
/**
* List available subscription plans with pricing.
*/
getPlans = $action({
path: `${this.url}/plans`,
group: this.group,
description: "List available subscription plans",
schema: { response: z.array(planResourceSchema) },
handler: async () => {
return (await this.config.getPlans()).filter((p) => p.available).map((p) => ({
id: p.id,
name: p.name,
description: p.description,
pricing: p.pricing,
features: p.features,
limits: p.limits,
trial: p.trial,
order: p.order
}));
}
});
/**
* Get the current organization's subscription.
*/
getMySubscription = $action({
path: `${this.url}/mine`,
group: this.group,
use: [$secure()],
description: "Get the current organization subscription",
schema: { response: subscriptionResourceSchema },
handler: async ({ user }) => {
const sub = await this.service.getByOrganization(user.organization);
if (!sub) throw new NotFoundError("No subscription found for your organization");
return sub;
}
});
/**
* Create a new subscription for the current organization.
*/
subscribe = $action({
method: "POST",
path: this.url,
group: this.group,
use: [$secure({ permissions: ["subscription:create"] })],
description: "Create a new subscription",
schema: {
body: createSubscriptionSchema,
response: subscriptionResourceSchema
},
handler: ({ body, user }) => this.service.subscribe(user.organization, body.planId, body.interval, { metadata: body.metadata })
});
/**
* Change the plan for the current organization's subscription.
*/
changePlan = $action({
method: "POST",
path: `${this.url}/mine/change-plan`,
group: this.group,
use: [$secure({ permissions: ["subscription:update"] })],
description: "Upgrade or downgrade the subscription plan",
schema: {
body: changePlanSchema,
response: subscriptionResourceSchema
},
handler: async ({ body, user }) => {
const sub = await this.service.getByOrganization(user.organization);
if (!sub) throw new NotFoundError("No subscription found for your organization");
await this.service.changePlan(sub.id, body.planId, body.interval, { immediate: body.immediate });
return this.service.getSubscription(sub.id);
}
});
/**
* Cancel the current organization's subscription.
*/
cancel = $action({
method: "POST",
path: `${this.url}/mine/cancel`,
group: this.group,
use: [$secure({ permissions: ["subscription:update"] })],
description: "Cancel the current subscription",
schema: {
body: cancelSubscriptionSchema,
response: okSchema
},
handler: async ({ body, user }) => {
const sub = await this.service.getByOrganization(user.organization);
if (!sub) throw new NotFoundError("No subscription found for your organization");
await this.service.cancel(sub.id, {
reason: body.reason,
immediate: body.immediate
});
return { ok: true };
}
});
/**
* Resume a cancelled subscription before the period ends.
*/
resume = $action({
method: "POST",
path: `${this.url}/mine/resume`,
group: this.group,
use: [$secure({ permissions: ["subscription:update"] })],
description: "Resume a cancelled subscription",
schema: { response: okSchema },
handler: async ({ user }) => {
const sub = await this.service.getByOrganization(user.organization);
if (!sub) throw new NotFoundError("No subscription found for your organization");
await this.service.resume(sub.id);
return { ok: true };
}
});
/**
* Get the billing event history for the current organization's subscription.
*/
getSubscriptionHistory = $action({
path: `${this.url}/mine/history`,
group: this.group,
use: [$secure()],
description: "Get the subscription billing event history",
schema: { response: z.array(subscriptionEventResourceSchema) },
handler: async ({ user }) => {
const sub = await this.service.getByOrganization(user.organization);
if (!sub) throw new NotFoundError("No subscription found for your organization");
return this.service.getHistory(sub.id);
}
});
/**
* Get the feature and usage limit entitlements for the current organization.
*/
getEntitlements = $action({
path: `${this.url}/mine/entitlements`,
group: this.group,
use: [$secure()],
description: "Get the feature and limit entitlements for the current organization",
schema: { response: entitlementsSchema },
handler: ({ user }) => this.service.getEntitlements(user.organization)
});
};
//#endregion
//#region ../../src/api/subscriptions/jobs/SubscriptionJobs.ts
var SubscriptionJobs = class {
log = $logger();
dateTime = $inject(DateTimeProvider);
paymentService = $inject(PaymentService);
config = $inject(SubscriptionConfig);
subscriptionRepo = $repository(subscriptions);
eventRepo = $repository(subscriptionEvents);
/**
* Record a subscription event in the event log.
*/
async recordEvent(subscriptionId, organizationId, type, context) {
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
});
}
/**
* Creates payment intents for subscriptions due for renewal.
* Runs hourly.
*/
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.
*/
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 !== void 0 ? now.add(scheduleDays, "days").toISOString() : void 0;
await this.subscriptionRepo.updateById(sub.id, {
lastPaymentIntentId: intent.id,
dunningAttempt: newAttempt,
dunningNextRetryAt: nextRetry
});
await this.recordEvent(sub.id, sub.organizationId, "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.
*/
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.
*/
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, "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.
*/
gracePeriodSweep = $job({
cron: "0 3 * * *",
lock: true,
handler: async ({ now }) => {
const gracePeriodDays = (await this.config.getSettings()).gracePeriodDays;
const toSuspend = (await this.subscriptionRepo.findMany({ where: { status: { eq: "past_due" } } })).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, "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.
*/
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);
}
});
};
//#endregion
//#region ../../src/api/subscriptions/notifications/SubscriptionNotifications.ts
var SubscriptionNotifications = class {
/**
* Sent when a trial is ending soon.
*/
trialEnding = $notification({
name: "subscription-trial-ending",
category: "subscriptions",
schema: z.object({
planName: z.text(),
trialEndDate: z.text(),
amount: z.text(),
interval: z.text()
}),
email: {
subject: "Your trial is ending soon",
body: (v) => `Your ${v.planName} trial is ending on ${v.trialEndDate}. You'll be charged ${v.amount}/${v.interval}.`
}
});
/**
* Sent when a payment fails. Critical notification.
*/
paymentFailed = $notification({
name: "subscription-payment-failed",
category: "subscriptions",
critical: true,
schema: z.object({
planName: z.text(),
amount: z.text(),
retryDate: z.text().optional()
}),
email: {
subject: "Payment failed for your subscription",
body: (v) => `We couldn't charge your card for ${v.planName} (${v.amount}). ${v.retryDate ? `We'll retry on ${v.retryDate}.` : "Please update your payment method."}`
}
});
/**
* Sent when a subscription is suspended due to failed payments. Critical notification.
*/
subscriptionSuspended = $notification({
name: "subscription-suspended",
category: "subscriptions",
critical: true,
schema: z.object({ planName: z.text() }),
email: {
subject: "Your subscription has been suspended",
body: (v) => `Your ${v.planName} subscription has been suspended due to failed payments. Update your payment method to reactivate.`
}
});
/**
* Sent when a subscription is successfully renewed.
*/
subscriptionRenewed = $notification({
name: "subscription-renewed",
category: "subscriptions",
schema: z.object({
planName: z.text(),
amount: z.text(),
nextBillingDate: z.text()
}),
email: {
subject: "Payment received — subscription renewed",
body: (v) => `Your ${v.planName} subscription has been renewed. Amount: ${v.amount}. Next billing: ${v.nextBillingDate}.`
}
});
/**
* Sent when a subscription plan is changed.
*/
planChanged = $notification({
name: "subscription-plan-changed",
category: "subscriptions",
schema: z.object({
oldPlanName: z.text(),
newPlanName: z.text(),
effectiveDate: z.text()
}),
email: {
subject: "Your subscription plan has been changed",
body: (v) => `Your plan has been changed from ${v.oldPlanName} to ${v.newPlanName}, effective ${v.effectiveDate}.`
}
});
/**
* Sent when a subscription is cancelled.
*/
cancellationConfirmed = $notification({
name: "subscription-cancelled",
category: "subscriptions",
schema: z.object({
planName: z.text(),
accessUntil: z.text().optional()
}),
email: {
subject: "Your subscription has been cancelled",
body: (v) => `Your ${v.planName} subscription has been cancelled.${v.accessUntil ? ` You'll have access until ${v.accessUntil}.` : ""}`
}
});
};
//#endregion
//#region ../../src/api/subscriptions/services/BillingService.ts
var BillingService = class {
alepha = $inject(Alepha);
log = $logger();
dateTime = $inject(DateTimeProvider);
subscriptionRepo = $repository(subscriptions);
eventRepo = $repository(subscriptionEvents);
paymentService = $inject(PaymentService);
config = $inject(SubscriptionConfig);
/**
* React to successful payment capture.
* Routes to the appropriate handler based on subscription status.
*/
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.
*/
onPaymentFailed = $hook({
on: "payments:failed",
handler: async (event) => {
const sub = await this.findByPaymentIntent(event.intentId);
if (!sub) return;
await this.handlePaymentFailure(sub, event);
}
});
/**
* Find a subscription by its last payment intent ID.
* Returns null if no subscription matches.
*/
async findByPaymentIntent(intentId) {
return await this.subscriptionRepo.findOne({ where: { lastPaymentIntentId: { eq: intentId } } }) ?? null;
}
/**
* Trial to active transition.
* Sets the first paid billing period and records activation events.
*/
async activate(sub, event) {
const orgId = sub.organizationId;
const nowISO = this.dateTime.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.em