#Subscriptions
alepha/api/subscriptions is a SaaS-billing layer built on top of alepha/api/payments. It owns plans, trials, dunning, proration, and entitlements — the recurring billing logic stays in Alepha rather than being delegated to the PSP. The same subscription code works against any PaymentProvider.
1import { Alepha } from "alepha";2import { AlephaApiPayments } from "alepha/api/payments";3import { AlephaApiSubscriptions } from "alepha/api/subscriptions";4import { AlephaPaymentsStripe } from "@alepha/payments-stripe";5 6const alepha = Alepha.create()7 .with(AlephaApiPayments)8 .with(AlephaApiSubscriptions)9 .with(AlephaPaymentsStripe);
#Architecture
The dependency graph is one-way:
subscriptions → payments → PaymentProvider ← payments-stripe / payments-mollie
SubscriptionJobsruns cron jobs (billing cycle, trial expiry, dunning retry, grace-period sweep, expiration sweep) that create payment intents throughPaymentService.createIntent.BillingServicelistens topayments:capturedandpayments:failedhooks and advances each subscription's lifecycle: trial → active, active → renewed, past_due → recovered, etc.SubscriptionConfigreads plan definitions and global settings from the parameters store.
The PSP knows nothing about subscriptions; the subscriptions module knows nothing about Stripe or Mollie.
#Defining plans
Plans live in the parameters store, not in code, so they can be edited at runtime:
1import { ParameterStore } from "alepha/api/parameters"; 2 3const params = alepha.inject(ParameterStore); 4 5await params.set("subscriptions.plans", { 6 plans: [ 7 { 8 id: "starter", 9 name: "Starter",10 available: true,11 pricing: [12 { interval: "monthly", amount: 1900, currency: "EUR" },13 { interval: "yearly", amount: 19000, currency: "EUR" },14 ],15 features: ["api-access", "basic-analytics"],16 limits: { seats: 5, projects: 10 },17 trial: { days: 14 },18 },19 {20 id: "pro",21 name: "Pro",22 available: true,23 pricing: [24 { interval: "monthly", amount: 4900, currency: "EUR" },25 { interval: "yearly", amount: 49000, currency: "EUR" },26 ],27 features: ["api-access", "basic-analytics", "advanced-analytics", "sso"],28 limits: { seats: -1, projects: -1 }, // -1 = unlimited29 },30 ],31});
Global settings (trial defaults, dunning schedule, grace period) live under subscriptions.settings:
1await params.set("subscriptions.settings", {2 trialDays: 14,3 gracePeriodDays: 7,4 dunningSchedule: [1, 3, 5, 7], // retry days after first failure5 cancelAtPeriodEnd: true,6 prorateOnChange: true,7});
#Subscribing
Subscriptions are scoped to organizations (user.organization). The built-in SubscriptionController exposes the user-facing actions:
| Endpoint | Purpose |
|---|---|
GET /api/subscriptions/plans |
List available plans. |
GET /api/subscriptions/mine |
Current org's subscription. |
POST /api/subscriptions |
Create a new subscription. |
POST /api/subscriptions/mine/change-plan |
Upgrade or downgrade. |
POST /api/subscriptions/mine/cancel |
Cancel (immediate or at period end). |
POST /api/subscriptions/mine/resume |
Resume a cancelled subscription before it ends. |
GET /api/subscriptions/mine/history |
Event log. |
GET /api/subscriptions/mine/entitlements |
Features + limits snapshot. |
Programmatic use:
1import { SubscriptionService } from "alepha/api/subscriptions";2 3const subs = $inject(SubscriptionService);4 5const sub = await subs.subscribe(orgId, "pro", "monthly", { trialDays: 7 });6// → status: "trialing", trialEnd: now + 7d, nextBillingAt: trialEnd
When the trial ends, SubscriptionJobs.billingCycle (cron hourly) creates a payment intent. The user pays via the standard checkout flow. payments:captured fires, BillingService.activate() flips the status to active and sets the next billing period.
#Entitlements
Use entitlements to gate features at the API layer:
1import { $requirePlan, $requireLimit } from "alepha/api/subscriptions"; 2 3class ProjectController { 4 create = $action({ 5 method: "POST", 6 path: "/projects", 7 use: [ 8 $secure(), 9 $requirePlan({ feature: "advanced-analytics" }),10 $requireLimit({ resource: "projects" }),11 ],12 schema: { /* ... */ },13 handler: async () => { /* ... */ },14 });15}
Or check imperatively:
1const canExport = await subs.can(orgId, "advanced-analytics");2const seatLimit = await subs.limit(orgId, "seats"); // -1 = unlimited, 0 = no access3const ent = await subs.getEntitlements(orgId);
#Lifecycle hooks
Subscriptions emit their own hooks that other modules listen to:
1"subscription:created" | "subscription:activated" | "subscription:renewed"2"subscription:cancelled" | "subscription:expired" | "subscription:resumed"3"subscription:plan_changed" | "subscription:payment_failed"4"subscription:suspended" | "subscription:reactivated" | "subscription:trial_ending"
Wire your notifications and audit log here, not in the controllers:
1import { $hook } from "alepha"; 2 3class BillingNotifications { 4 protected readonly onActivated = $hook({ 5 on: "subscription:activated", 6 handler: async (event) => { 7 await this.emails.send("subscription-activated", { 8 organizationId: event.organizationId, 9 planId: event.planId,10 });11 },12 });13}
#Dunning and grace period
When a renewal payment fails:
BillingService.handlePaymentFailureflips the subscription topast_dueand starts dunning.SubscriptionJobs.dunningRetry(cron hourly) creates a new payment intent on each scheduled retry day (default[1, 3, 5, 7]).- If a retry succeeds,
BillingService.recoverFromDunningclears state and returns the subscription toactive. - If the grace period (default 7 days from the first failure) elapses without recovery,
SubscriptionJobs.gracePeriodSweep(daily) flips the subscription tosuspended. - Suspended subscriptions can be revived by an admin or by a successful payment from the customer.
Throughout this flow, isAccessible(sub) returns true for trialing | active | past_due (so the customer keeps access during dunning). Suspended and expired subscriptions return false.
#Why Alepha-side and not native PSP subscriptions?
The trade-off is deliberate:
- Alepha-side (current design): one set of plans/lifecycle/dunning across every PSP. Clean dependency graph. Plans live in your parameters store, not Stripe's dashboard. Migration between PSPs is a config change.
- Native PSP subscriptions: invoice PDFs, smart retry timing, customer portals out of the box — but plans are duplicated (Stripe has its own products/prices), the lifecycle is split between two systems, and switching providers is a project.
For SaaS apps that want full control over billing logic, the Alepha-side approach is the right default.