alepha@docs:~/docs/guides/payments$
cat 3-subscriptions.md | pretty
3 min read
Last commit:

#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.

typescript
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:

bash
subscriptions  →  payments  →  PaymentProvider  ←  payments-stripe / payments-mollie
  • SubscriptionJobs runs cron jobs (billing cycle, trial expiry, dunning retry, grace-period sweep, expiration sweep) that create payment intents through PaymentService.createIntent.
  • BillingService listens to payments:captured and payments:failed hooks and advances each subscription's lifecycle: trial → active, active → renewed, past_due → recovered, etc.
  • SubscriptionConfig reads 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:

typescript
 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:

typescript
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:

typescript
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:

typescript
 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:

typescript
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:

typescript
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:

typescript
 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:

  1. BillingService.handlePaymentFailure flips the subscription to past_due and starts dunning.
  2. SubscriptionJobs.dunningRetry (cron hourly) creates a new payment intent on each scheduled retry day (default [1, 3, 5, 7]).
  3. If a retry succeeds, BillingService.recoverFromDunning clears state and returns the subscription to active.
  4. If the grace period (default 7 days from the first failure) elapses without recovery, SubscriptionJobs.gracePeriodSweep (daily) flips the subscription to suspended.
  5. 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.