UNPKG

alepha

Version:

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

1,527 lines 62.5 kB
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