Skip to content
SaaS4Builders
Billing

Subscriptions & Lifecycle

Subscription states, transitions, creation flow, cancellation, resumption, plan changes, and proration in SaaS4Builders.

A subscription binds a tenant to a plan. It tracks the billing cycle, current price, quantity (for seat-based plans), and lifecycle state. All state transitions are driven by Stripe webhooks — the application never forces subscription state changes directly.


Subscription Statuses

The SubscriptionStatus enum defines all possible states:

backend/app/Domain/Billing/Enums/SubscriptionStatus.php
enum SubscriptionStatus: string
{
    case Active = 'active';
    case Trialing = 'trialing';
    case PastDue = 'past_due';
    case Canceled = 'canceled';
    case Unpaid = 'unpaid';
    case Paused = 'paused';
    case Incomplete = 'incomplete';
    case IncompleteExpired = 'incomplete_expired';

    public function isActive(): bool
    {
        return in_array($this, [self::Active, self::Trialing], true);
    }

    public function canUpgrade(): bool
    {
        return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
    }

    public function canCancel(): bool
    {
        return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
    }
}
StatusMeaningAccess Granted
activeSubscription is paid and runningYes
trialingTrial period, no charge yetYes
past_duePayment attempt failed, retryingYes (grace period)
canceledSubscription terminatedNo
unpaidAll payment retries exhaustedNo
pausedTemporarily pausedNo
incompleteCreated but initial payment not completedNo
incomplete_expiredIncomplete subscription expired (24h timeout)No

State Transitions

Only the following transitions are valid. Any transition not listed here is forbidden and will throw an exception.

flowchart TD
    A["incomplete"] --> B["active"]
    A --> C["incomplete_expired<br/>(terminal)"]
    D["trialing"] --> B
    D --> G["canceled<br/>(terminal)"]
    B --> E["past_due"]
    E --> B
    E --> F["unpaid"]
    B --> G
    E --> G

Key transitions:

FromToTrigger
incompleteactiveInitial payment succeeds
incompleteincomplete_expired24h timeout, payment never completed
trialingactiveTrial ends, first payment succeeds
trialingcanceledUser cancels during trial
activepast_duePayment fails (Stripe retries)
past_dueactiveRetry payment succeeds
past_dueunpaidAll retries exhausted
activecanceledUser cancels (immediately or at period end)
past_duecanceledUser cancels while payment is failing
Subscription state is driven by Stripe webhooks, not by direct database manipulation. The application updates local state only in response to webhook events (customer.subscription.updated, customer.subscription.deleted). See Webhooks for the full event list.

Subscription Creation

Subscriptions are created via Stripe Checkout (the default path). See Stripe Integration for the full checkout flow.

The Creation Flow

  1. User selects a plan and currency on your pricing or billing page
  2. Frontend calls POST /api/v1/tenant/{tenantId}/checkout with the plan ID, currency, and redirect URLs
  3. Backend validates billing readiness, plan configuration, and currency availability
  4. Stripe Checkout Session is created via PaymentGatewayInterface::createCheckoutSession()
  5. User is redirected to Stripe's hosted checkout page
  6. User completes payment (or starts trial)
  7. Stripe fires checkout.session.completed webhook
  8. Webhook handler creates the subscription record locally, syncing status and billing period from Stripe

After step 8, the subscription is queryable via the API and the frontend composables.

Trial Periods

If the plan has trial_days > 0, the subscription starts in trialing status:

  • No charge is made during the trial
  • The trial end date is set to now + trial_days
  • When the trial ends, Stripe attempts the first payment
  • On success: trialingactive
  • On failure: trialingpast_due (or canceled depending on Stripe retry settings)

Cancellation

Two cancellation modes are available:

Cancel at Period End (Default)

The subscription remains active until the current billing period ends, then transitions to canceled. This is the default and recommended approach.

POST /api/v1/tenant/{tenantId}/subscription/cancel
{
  "immediately": false,
  "reason": "Switching to a competitor"
}

The CancelSubscription action sets cancel_at_period_end = true on both the Stripe subscription and the local record:

backend/app/Application/Billing/Actions/CancelSubscription.php
final class CancelSubscription
{
    public function execute(CancelSubscriptionData $data): Subscription
    {
        // Guard: only Active, Trialing, or PastDue can be canceled
        if (! $data->subscription->status->canCancel()) {
            throw SubscriptionCannotBeCanceledException::forStatus(
                $data->subscription->id,
                $data->subscription->status
            );
        }

        $subscriptionId = $this->providerIdResolver->getSubscriptionId($data->subscription);
        $this->gateway->cancelSubscription($subscriptionId, $data->immediately);

        if ($data->immediately) {
            $data->subscription->status = SubscriptionStatus::Canceled;
            $data->subscription->canceled_at = now();
        } else {
            $data->subscription->cancel_at_period_end = true;
        }

        $data->subscription->cancellation_reason = $data->reason;
        $data->subscription->save();

        return $data->subscription;
    }
}

Cancel Immediately

Setting immediately: true terminates the subscription right away. The status is set to canceled and no further invoices are generated. There is no prorated refund in the current release.

The reason field is optional and stored for analytics. It appears in the subscription record but is not sent to Stripe.

Resuming a Subscription

A subscription can be resumed only if it was scheduled for cancellation at period end (cancel_at_period_end = true) and hasn't actually been canceled yet.

POST /api/v1/tenant/{tenantId}/subscription/resume
backend/app/Application/Billing/Actions/ResumeSubscription.php
final class ResumeSubscription
{
    public function execute(Subscription $subscription): Subscription
    {
        // Guard: must be pending cancellation
        if (! $subscription->cancel_at_period_end) {
            throw SubscriptionNotPendingCancellationException::forSubscription(
                $subscription->id
            );
        }

        $subscriptionId = $this->providerIdResolver->getSubscriptionId($subscription);
        $this->gateway->resumeSubscription($subscriptionId);

        $subscription->cancel_at_period_end = false;
        $subscription->cancellation_reason = null;
        $subscription->save();

        return $subscription;
    }
}

Once a subscription has fully transitioned to canceled, it cannot be resumed. The user must create a new subscription.


Plan Changes

The ChangePlan action handles upgrades, downgrades, and quantity changes. It supports three distinct paths depending on the current and target plan prices.

Three Paths

PathWhenMechanism
Free → PaidCurrent plan has price_cents = 0, target plan has price_cents > 0Stripe Checkout redirect (needs payment method)
Paid → PaidBoth plans have price_cents > 0Direct Stripe subscription update with proration
Paid → FreeCurrent plan has price_cents > 0, target plan has price_cents = 0Direct Stripe subscription update (credit applied)
POST /api/v1/tenant/{tenantId}/subscription/change-plan
{
  "new_plan_id": "uuid-of-target-plan",
  "quantity": null,
  "success_url": "https://app.example.com/docs/billing/success",
  "cancel_url": "https://app.example.com/docs/billing/cancel"
}

The response is a discriminated union — the action field tells the frontend what happened:

Direct update (Paid → Paid or Paid → Free):

{
  "data": {
    "action": "updated",
    "subscription": { /* full subscription resource */ }
  }
}

Checkout required (Free → Paid):

{
  "data": {
    "action": "checkout_required",
    "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_xxx",
    "session_id": "cs_test_xxx"
  }
}

Validation Guards

The ChangePlan action enforces several guards before allowing a plan change:

  1. Status check — Subscription must be in active, trialing, or past_due status
  2. No-op detection — Changing to the same plan with the same quantity throws an error
  3. No usage-based plans — Neither the source nor target plan can be usage-based (proration is not supported)
  4. Currency availability — The target plan must have a price in the subscription's currency
  5. No cross-pricing-type — You cannot switch between flat and seat when both are paid plans
  6. Plan eligibility — The tenant's current usage must not exceed the target plan's quotas

Proration Preview

Before executing a plan change, you can preview the proration calculation:

GET /api/v1/tenant/{tenantId}/subscription/preview-change?new_plan_id=uuid

Response (200):

{
  "data": {
    "credit": { "amount": 2000, "currency": "EUR" },
    "charge": { "amount": 4000, "currency": "EUR" },
    "net": { "amount": 2000, "currency": "EUR" },
    "breakdown": {
      "method": "calendar_day",
      "currency": "EUR",
      "period_start": "2026-03-01",
      "period_end": "2026-03-31",
      "change_date": "2026-03-11",
      "total_days": 30,
      "used_days": 10,
      "remaining_days": 20,
      "old_plan_price_cents": 3000,
      "new_plan_price_cents": 6000,
      "old_daily_rate": 100,
      "new_daily_rate": 200
    }
  }
}

Frontend: useCheckout for Plan Changes

The useCheckout() composable handles both new subscriptions and plan changes:

frontend/features/core/docs/billing/composables/useCheckout.ts
const {
  previewData,        // ProrationPreview after calling previewChange()
  isLoadingPreview,   // true during preview calculation
  isChangingPlan,     // true during plan change execution

  previewChange,      // Preview proration: previewChange({ newPlanId })
  changePlan,         // Execute change: changePlan({ newPlanId, successUrl?, cancelUrl? })
} = useCheckout()

// Preview proration before showing confirmation modal
const preview = await previewChange({ newPlanId: 'uuid-of-plan' })
// preview.net.amount = 2000 (customer owes €20.00)

// Execute the plan change
const result = await changePlan({ newPlanId: 'uuid-of-plan' })
if (result?.action === 'checkout_required') {
  // Free→Paid: browser will redirect to Stripe Checkout
} else {
  // Paid→Paid: subscription updated, refresh data
}

Proration

When changing between paid plans, SaaS4Builders calculates proration using the calendar-day method.

Formula

Daily Rate = Plan Price (cents) / Total Days in Billing Period

Credit = Old Daily Rate × Remaining Days
Charge = New Daily Rate × Remaining Days
Net    = Charge - Credit

Example: Upgrade Mid-Cycle

  • Period: March 1–31 (30 days)
  • Change date: March 11
  • Old plan: €30/month → daily rate = 3000 / 30 = 100 cents/day
  • New plan: €60/month → daily rate = 6000 / 30 = 200 cents/day
  • Used days: 10, Remaining days: 20
LineCalculationAmount
Credit (old plan)100 cents × 20 days€20.00
Charge (new plan)200 cents × 20 days€40.00
Net (customer owes)4000 - 2000€20.00

For downgrades, the net is negative (credit applied to the next invoice).

Proration Rules

TransitionSupportedNotes
Flat → Flat (same currency)YesStandard proration
Seat → Seat (same currency)YesBased on new quantity × price
Any → Usage-basedNoCancel and resubscribe
Usage-based → AnyNoCancel and resubscribe
Flat ↔ Seat (both paid)NoCross-pricing-type not supported
Any currency changeNoSubscription currency is immutable
The internal proration calculation uses integer arithmetic on minor units (cents) and may differ from Stripe's calculation by ±1 cent due to rounding. This is expected and documented in the proration breakdown as rounding_note.

Currency Immutability

A subscription's currency is frozen at creation and can never change. This is a core invariant enforced at the domain level.

  • Attempting to change plans to one in a different currency throws PlanNotAvailableInCurrencyException
  • There is no currency conversion, no FX rates, no implicit conversion
  • To switch currencies: cancel the current subscription and create a new one in the desired currency

See Currency Rules for the complete set of currency invariants.


Frontend: useSubscription Composable

The useSubscription() composable provides reactive access to the current subscription with computed helpers:

frontend/features/core/docs/billing/composables/useSubscription.ts
const {
  // Data
  subscription,          // Subscription | null
  plan,                  // Subscription plan data

  // Computed helpers
  isActive,              // true for 'active' or 'trialing' (and not pending cancellation)
  canCancel,             // true for 'active', 'trialing', 'past_due' (not already canceling)
  canResume,             // true when cancel_at_period_end is set
  hasProblem,            // true for 'past_due', 'unpaid', 'incomplete', 'incomplete_expired'
  daysRemaining,         // Days until current period ends (or null)
  trialDaysRemaining,    // Days until trial ends (or null)
  statusLabel,           // Human-readable status (e.g., "Active", "Past Due")
  statusColor,           // UI badge color: 'success', 'warning', 'error', 'neutral', 'info'

  // Loading
  isLoading,
  error,

  // Actions
  cancel,                // cancel({ immediately?, reason? })
  resume,                // resume()
  refresh,               // Refetch subscription data
} = useSubscription()

The composable wraps the API client with reactive state management. After calling cancel() or resume(), the subscription data is automatically refreshed.


API Endpoints Summary

MethodPathActionAuth
GET/api/v1/tenant/{tenantId}/subscriptionGet current subscriptionBearer + tenant member
GET/api/v1/tenant/{tenantId}/plansList available plansBearer + tenant member
POST/api/v1/tenant/{tenantId}/checkoutCreate checkout sessionBearer + tenant member
POST/api/v1/tenant/{tenantId}/subscription/cancelCancel subscriptionBearer + tenant member
POST/api/v1/tenant/{tenantId}/subscription/resumeResume pending cancellationBearer + tenant member
GET/api/v1/tenant/{tenantId}/subscription/preview-changePreview prorationBearer + tenant member
POST/api/v1/tenant/{tenantId}/subscription/change-planChange plan or quantityBearer + tenant member
GET/api/v1/tenant/{tenantId}/subscription/seatsGet seat billing infoBearer + tenant member
POST/api/v1/tenant/{tenantId}/docs/billing/portalCreate billing portal sessionBearer + tenant member

All endpoints require Sanctum authentication and the tenant.resolve middleware that scopes the request to the authenticated user's tenant.


What's Next

  • Invoices — How invoices are synced from Stripe, queried, and downloaded
  • Webhooks — The event-driven architecture that keeps subscription state in sync
  • Pricing Models — How flat, seat-based, and usage-based pricing work
  • Currency Rules — Multi-currency invariants and the immutability rule