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.
| Property | Type | Description |
|---|---|---|
| Id | Guid | Primary key (auto-generated) |
| TenantId | Guid | The tenant this subscription belongs to |
| EditionId | Guid | The edition (plan) the tenant is subscribed to |
| Type | enum | Base (primary), Trial, or AddOn |
| Status | enum | Active, Suspended, Cancelled, Expired, PendingActivation |
| StartDate | DateTime | When the subscription started |
| EndDate | DateTime? | When the subscription ends (null for indefinite) |
| TrialEndsAt | DateTime? | When the trial period expires |
| GracePeriodEndsAt | DateTime? | Grace period end after trial/payment failure |
| ExpiryBehavior | enum | What happens on expiry: Fallback, Suspend, Downgrade |
| DowngradedFromEditionId | Guid? | If this is a fallback/downgrade subscription, the original edition ID |
| SuspensionReason | string? | Human-readable reason for suspension (e.g. 'Late payment') |
| Currency | enum | Billing currency for this subscription (USD, EUR, etc.) |
| BaseAmount | decimal | Edition base price locked at subscription time |
| TotalAmount | decimal | Final billed amount after adjustments |
| IsCustomPrice | bool | True if an admin set a custom negotiated price |
| PaymentGateway | enum | Which payment gateway manages this subscription (Stripe, PayPal, Paymob, Manual) |
| GatewaySubscriptionId | string? | Gateway subscription ID (e.g., Stripe sub_xxx) |
| GatewayCheckoutSessionId | string? | The hosted checkout session ID expected to pay for this subscription |
| ConcurrencyStamp | Guid | Optimistic concurrency stamp to prevent race conditions |
| CreatedAt | DateTime | When the subscription was created |
| UpdatedAt | DateTime? | Last modification timestamp |
Status Lifecycle
Subscriptions move through a series of statuses during their lifecycle:
Subscription Types
Each subscription has a type that determines its billing cycle and behavior:
| Type | Duration | Billing | Auto-Renew | Use Case |
|---|---|---|---|---|
| Monthly | 30 days | Recurring monthly | Yes (configurable) | Standard SaaS billing |
| Annual | 365 days | Recurring yearly | Yes (configurable) | Discounted yearly plans |
| Lifetime | Unlimited | One-time | N/A | Perpetual licenses, early adopter deals |
| Trial | 7-30 days (configurable) | Free | No | Free trial before committing |
Expiry Behavior
When a subscription expires, the ExpiryBehavior setting determines what happens next:
| Behavior | When Triggered | What Happens | Ideal For |
|---|---|---|---|
| Downgrade | Subscription end date passes | Tenant auto-assigned to a free/basic edition | Freemium models — always have a free tier |
| Suspend | Subscription end date passes | Tenant access is fully blocked, data preserved | Strict enforcement — pay or lose access |
| Grace | Subscription end date passes | Full access for GracePeriodDays, then suspend | Give tenants time to renew (e.g. 7-day grace) |
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.
{
"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.
{
"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.
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:
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/subscriptions | List all subscriptions (paginated, filterable by status/type/tenant) | 🔒 Required |
| GET | /api/v1/subscriptions/{id} | Get subscription details by ID | 🔒 Required |
| POST | /api/v1/subscriptions | Create a new subscription (assign tenant to edition with currency/promo) | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/upgrade | Upgrade to a higher edition | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/downgrade | Downgrade to a lower edition (checks OverflowPolicy) | 🔒 Required |
| GET | /api/v1/subscriptions/{id}/downgrade-impact | Preview downgrade impact before executing | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/suspend | Suspend subscription (block tenant access) | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/resume | Resume a suspended subscription | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/cancel | Cancel subscription permanently | 🔒 Required |
| POST | /api/v1/subscriptions/{id}/renew | Renew an expiring subscription | 🔒 Required |
| GET | /api/v1/tenants/{tenantId}/subscription | Get the active subscription for a specific tenant | 🔒 Required |