Subscriptions & Lifecycle
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:
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);
}
}
| Status | Meaning | Access Granted |
|---|---|---|
active | Subscription is paid and running | Yes |
trialing | Trial period, no charge yet | Yes |
past_due | Payment attempt failed, retrying | Yes (grace period) |
canceled | Subscription terminated | No |
unpaid | All payment retries exhausted | No |
paused | Temporarily paused | No |
incomplete | Created but initial payment not completed | No |
incomplete_expired | Incomplete 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.
Key transitions:
| From | To | Trigger |
|---|---|---|
incomplete | active | Initial payment succeeds |
incomplete | incomplete_expired | 24h timeout, payment never completed |
trialing | active | Trial ends, first payment succeeds |
trialing | canceled | User cancels during trial |
active | past_due | Payment fails (Stripe retries) |
past_due | active | Retry payment succeeds |
past_due | unpaid | All retries exhausted |
active | canceled | User cancels (immediately or at period end) |
past_due | canceled | User cancels while payment is failing |
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
- User selects a plan and currency on your pricing or billing page
- Frontend calls
POST /api/v1/tenant/{tenantId}/checkoutwith the plan ID, currency, and redirect URLs - Backend validates billing readiness, plan configuration, and currency availability
- Stripe Checkout Session is created via
PaymentGatewayInterface::createCheckoutSession() - User is redirected to Stripe's hosted checkout page
- User completes payment (or starts trial)
- Stripe fires
checkout.session.completedwebhook - 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:
trialing→active - On failure:
trialing→past_due(orcanceleddepending 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:
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.
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
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
| Path | When | Mechanism |
|---|---|---|
| Free → Paid | Current plan has price_cents = 0, target plan has price_cents > 0 | Stripe Checkout redirect (needs payment method) |
| Paid → Paid | Both plans have price_cents > 0 | Direct Stripe subscription update with proration |
| Paid → Free | Current plan has price_cents > 0, target plan has price_cents = 0 | Direct 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:
- Status check — Subscription must be in
active,trialing, orpast_duestatus - No-op detection — Changing to the same plan with the same quantity throws an error
- No usage-based plans — Neither the source nor target plan can be usage-based (proration is not supported)
- Currency availability — The target plan must have a price in the subscription's currency
- No cross-pricing-type — You cannot switch between
flatandseatwhen both are paid plans - 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:
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
| Line | Calculation | Amount |
|---|---|---|
| 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
| Transition | Supported | Notes |
|---|---|---|
| Flat → Flat (same currency) | Yes | Standard proration |
| Seat → Seat (same currency) | Yes | Based on new quantity × price |
| Any → Usage-based | No | Cancel and resubscribe |
| Usage-based → Any | No | Cancel and resubscribe |
| Flat ↔ Seat (both paid) | No | Cross-pricing-type not supported |
| Any currency change | No | Subscription currency is immutable |
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:
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
| Method | Path | Action | Auth |
|---|---|---|---|
GET | /api/v1/tenant/{tenantId}/subscription | Get current subscription | Bearer + tenant member |
GET | /api/v1/tenant/{tenantId}/plans | List available plans | Bearer + tenant member |
POST | /api/v1/tenant/{tenantId}/checkout | Create checkout session | Bearer + tenant member |
POST | /api/v1/tenant/{tenantId}/subscription/cancel | Cancel subscription | Bearer + tenant member |
POST | /api/v1/tenant/{tenantId}/subscription/resume | Resume pending cancellation | Bearer + tenant member |
GET | /api/v1/tenant/{tenantId}/subscription/preview-change | Preview proration | Bearer + tenant member |
POST | /api/v1/tenant/{tenantId}/subscription/change-plan | Change plan or quantity | Bearer + tenant member |
GET | /api/v1/tenant/{tenantId}/subscription/seats | Get seat billing info | Bearer + tenant member |
POST | /api/v1/tenant/{tenantId}/docs/billing/portal | Create billing portal session | Bearer + 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
Stripe Integration
How SaaS4Builders integrates with Stripe: API setup, the provider abstraction, checkout flow, customer management, price sync, and billing portal.
Invoices
Invoice management in SaaS4Builders: Stripe as invoicing authority, invoice sync, status tracking, PDF download, and the useInvoices composable.