Subscriptions

Tenant-to-edition binding with full lifecycle management, multi-currency pricing, promotions, trials, downgrades, expiry behavior, and advanced analytics export.

Subscriptions link tenants to editions (plans). Each tenant has a base subscription that determines their edition, and optionally add-on subscriptions for extra capabilities. The subscription system handles the full lifecycle from assignment through renewal, downgrade, suspension, and cancellation — complete with multi-currency pricing and promotional discount support.

Subscription Entity

A TenantSubscription binds a tenant to an edition with lifecycle tracking. It supports multiple subscription types and statuses for comprehensive lifecycle management.

PropertyTypeDescription
IdGuidPrimary key (auto-generated)
TenantIdGuidThe tenant this subscription belongs to
EditionIdGuidThe edition (plan) the tenant is subscribed to
TypeenumBase (primary), Trial, or AddOn
StatusenumActive, Suspended, Cancelled, Expired, PendingActivation
StartDateDateTimeWhen the subscription started
EndDateDateTime?When the subscription ends (null for indefinite)
TrialEndsAtDateTime?When the trial period expires
GracePeriodEndsAtDateTime?Grace period end after trial/payment failure
ExpiryBehaviorenumWhat happens on expiry: Fallback, Suspend, Downgrade
DowngradedFromEditionIdGuid?If this is a fallback/downgrade subscription, the original edition ID
SuspensionReasonstring?Human-readable reason for suspension (e.g. 'Late payment')
CurrencyenumBilling currency for this subscription (USD, EUR, etc.)
BaseAmountdecimalEdition base price locked at subscription time
TotalAmountdecimalFinal billed amount after adjustments
IsCustomPriceboolTrue if an admin set a custom negotiated price
PaymentGatewayenumWhich payment gateway manages this subscription (Stripe, PayPal, Paymob, Manual)
GatewaySubscriptionIdstring?Gateway subscription ID (e.g., Stripe sub_xxx)
GatewayCheckoutSessionIdstring?The hosted checkout session ID expected to pay for this subscription
ConcurrencyStampGuidOptimistic concurrency stamp to prevent race conditions
CreatedAtDateTimeWhen the subscription was created
UpdatedAtDateTime?Last modification timestamp

Status Lifecycle

Subscriptions move through a series of statuses during their lifecycle:

Pending — Awaiting activation
Activate
Active — Full access
Suspend
Suspended — Access paused
Cancelled — Terminated
Expired — Past end date

Subscription Types

Each subscription has a type that determines its billing cycle and behavior:

TypeDurationBillingAuto-RenewUse Case
Monthly30 daysRecurring monthlyYes (configurable)Standard SaaS billing
Annual365 daysRecurring yearlyYes (configurable)Discounted yearly plans
LifetimeUnlimitedOne-timeN/APerpetual licenses, early adopter deals
Trial7-30 days (configurable)FreeNoFree trial before committing

Expiry Behavior

When a subscription expires, the ExpiryBehavior setting determines what happens next:

BehaviorWhen TriggeredWhat HappensIdeal For
DowngradeSubscription end date passesTenant auto-assigned to a free/basic editionFreemium models — always have a free tier
SuspendSubscription end date passesTenant access is fully blocked, data preservedStrict enforcement — pay or lose access
GraceSubscription end date passesFull access for GracePeriodDays, then suspendGive tenants time to renew (e.g. 7-day grace)
ExpiryBehavior EnumC#
public enum ExpiryBehavior
{
    /// <summary>Auto-assign tenant to the default/free edition</summary>
    Downgrade = 0,
    
    /// <summary>Suspend the tenant's access entirely</summary>
    Suspend = 1,
    
    /// <summary>Allow a grace period before suspending</summary>
    Grace = 2,
}

Subscription Operations

The subscription module supports a comprehensive set of lifecycle operations. Each operation transitions the subscription to a new state with full audit tracking.

Assign Subscription

Create a new subscription linking a tenant to an edition. If the tenant already has an active subscription, the previous one is automatically cancelled. Supports optional currency, promo code, and expiry behavior parameters.

POST /api/v1/subscriptionsJSON
{
  "tenantId": "550e8400-e29b-41d4-a716-446655440000",
  "editionId": "660e8400-e29b-41d4-a716-446655440001",
  "subscriptionType": "Monthly",
  "autoRenew": true,
  "expiryBehavior": "Grace",
  "gracePeriodDays": 7
}

Upgrade & Downgrade

Tenants can move between editions. Upgrades apply immediately with the new edition's features taking effect right away. Downgrades check the OverflowPolicy first to handle resources that exceed new limits.

Downgrade Impact Analysis

Before changing a tenant's edition, use the Downgrade Impact endpoint to preview which resources would overflow. The response lists every feature that would exceed the new edition's limits, along with the current usage vs. new limit.

GET /api/v1/subscriptions/{id}/downgrade-impact?targetEditionId={edId}JSON
{
  "canDowngrade": false,
  "impacts": [
    {
      "featureName": "MaxAdmins",
      "currentValue": "50",
      "newValue": "5",
      "currentUsage": 12,
      "isOverflow": true,
      "overflowAmount": 7,
      "resolution": "Must remove 7 admins before downgrading"
    },
    {
      "featureName": "Chat.Enabled",
      "currentValue": "true",
      "newValue": "false",
      "currentUsage": null,
      "isOverflow": false,
      "resolution": "Feature will be disabled"
    }
  ],
  "overflowPolicy": "Block",
  "recommendation": "Remove 7 admin users or choose a plan with ≥12 admin slots"
}

Trial Conversion

Trial subscriptions have a TrialEndDate. When a trial is upgraded to a paid plan, IsTrialConverted is set to true and the subscription transitions to the new type. If the trial expires without conversion, ExpiryBehavior determines what happens next.

Trial — Free trial period
Upgrade
Convert — Choose paid plan
Expired — Trial not converted
Paid — Full subscription

Renewal — New Row Pattern (B2)

Renewals create a NEW TenantSubscription row instead of overwriting the existing record in-place (Stripe pattern). The old subscription is marked Expired (IsActive=false), while a new row is created with a fresh Id, StartDate=UtcNow, recalculated pricing, and carried-forward promotion details. This preserves a complete revenue audit trail per billing cycle.

Revenue Audit Trail

Each billing cycle produces its own immutable database row with locked-in pricing at the time of renewal. This enables precise financial reporting: MRR trends, churn analysis by period, and refund tracking per cycle — never losing historical pricing data.

Promotion Expiry Tracking (A1)

When a promotion with DurationDays > 0 is applied, the system calculates a PromotionExpiresAt timestamp. On each renewal, the handler checks if UtcNow > PromotionExpiresAt — if the promo has expired, the discount is stripped and NOT carried forward to the new subscription row. Null PromotionExpiresAt means the promotion lasts forever.

Optimistic Concurrency (E1)

Each TenantSubscription has a ConcurrencyStamp (Guid) marked with [ConcurrencyCheck]. The stamp is refreshed (Guid.NewGuid()) on every write operation. This prevents race conditions — for example, a concurrent cancel + reconciliation job — by throwing DbUpdateConcurrencyException on mid-air collisions.

Input Validation (G1)

All 8 subscription commands have dedicated FluentValidation validators in SubscriptionCommandValidators.cs. Validators inject ILocalizer for localized error messages (EN + AR). Business rules include: cannot renew as Trial, cannot convert to Trial, positive refund amounts, string length limits, and required field checks. Validation runs in the SCRIPE mediator pipeline before the handler executes.

Cross-Module Integration (H1)

Subscription lifecycle events publish domain events consumed by the Identity module. When a subscription is suspended, all tenant admins are deactivated with DeactivationReason='SubscriptionSuspended'. On resume, only admins with that specific reason are reactivated — manually-deactivated admins stay off. Expiry cascades deactivation to all descendant tenants.

API Endpoints

The Subscriptions controller provides 13 endpoints covering the full subscription lifecycle:

MethodEndpointDescriptionAuth
GET/api/v1/subscriptionsList all subscriptions (paginated, filterable by status/type/tenant)🔒 Required
GET/api/v1/subscriptions/{id}Get subscription details by ID🔒 Required
POST/api/v1/subscriptionsCreate a new subscription (assign tenant to edition with currency/promo)🔒 Required
POST/api/v1/subscriptions/{id}/upgradeUpgrade to a higher edition🔒 Required
POST/api/v1/subscriptions/{id}/downgradeDowngrade to a lower edition (checks OverflowPolicy)🔒 Required
GET/api/v1/subscriptions/{id}/downgrade-impactPreview downgrade impact before executing🔒 Required
POST/api/v1/subscriptions/{id}/suspendSuspend subscription (block tenant access)🔒 Required
POST/api/v1/subscriptions/{id}/resumeResume a suspended subscription🔒 Required
POST/api/v1/subscriptions/{id}/cancelCancel subscription permanently🔒 Required
POST/api/v1/subscriptions/{id}/renewRenew an expiring subscription🔒 Required
GET/api/v1/tenants/{tenantId}/subscriptionGet the active subscription for a specific tenant🔒 Required