User Subscriptions
Tier 2 user-to-plan binding with full lifecycle management, feature resolution, reconciliation job, and self-service /me endpoint.
User Subscriptions bind end-users to TenantPlans. Each user can have one active subscription at a time. The system handles the full lifecycle from assignment through trial, active billing, past-due recovery, cancellation, expiry, and auto-renewal — mirroring the Tier 1 TenantSubscription lifecycle.
UserSubscription Entity
A UserSubscription links a User to a TenantPlan. It captures the full lifecycle with optional billing fields, trial tracking, and auto-renewal configuration.
| Field | Type | Description |
|---|---|---|
| UserId | Guid | The subscribing user (Identity.User.Id — no FK nav prop) |
| TenantPlanId | Guid | The TenantPlan being subscribed to |
| TenantId | Guid | Tenant owning this subscription |
| Status | UserSubscriptionStatus | Active | Trialing | Cancelled | Expired | PastDue |
| StartedAt | DateTime | When the subscription became active (after trial if applicable) |
| ExpiresAt | DateTime? | When the subscription expires (null = Lifetime plan) |
| TrialEndsAt | DateTime? | When the trial period expires. Null if no trial. |
| IsAutoRenew | bool | Whether to automatically renew this subscription on expiry |
| CancelledAt | DateTime? | When the user explicitly cancelled the subscription |
| TenantPlanVersionNumber | int? | Version the subscriber is pinned to for grandfathering. Null = latest. |
| PromotionId | Guid? | The promotion applied at subscription time. Null = no promo. |
| PromotionCode | string? | Snapshot of the promo code used (survives promotion deletion) |
| DiscountAmount | decimal(18,2) | Discount amount applied from the promotion |
| OriginalPrice | decimal(18,2)? | Original pre-discount price |
| ExternalRef | string? | External payment reference (e.g. Stripe subscription ID — reserved for Phase 10) |
| Notes | string? | Admin notes about this subscription (internal only) |
Subscription Status Lifecycle
User subscriptions move through 6 states. Terminal states (Cancelled, Expired) cannot be reactivated — a new subscription must be created instead.
| Status | Description | Terminal? |
|---|---|---|
| Free | User is on a free plan with no billing | No |
| Trial | User is in a trial period (expires at TrialEndsAt) | No |
| Active | User has a paid, active subscription | No |
| PastDue | Payment has failed, grace period in progress | No |
| Cancelled | Subscription was manually cancelled | Yes |
| Expired | Subscription period ended without renewal | Yes |
UserFeatureCheckerService
The UserFeatureCheckerService resolves which features a user can access based on their active UserSubscription. For Active and Trial subscribers, features come from the TenantPlan's TenantPlanFeature records. For Free subscribers, features come from the Free plan if one exists.
// Resolve features for a user
var features = await _userFeatureChecker.GetFeaturesAsync(userId);
// Returns key/value dict from the user's active TenantPlanFeature records
// Check a specific feature
bool hasApiAccess = features.ContainsKey("apiAccess") && features["apiAccess"] == "true";
int maxProjects = int.Parse(features.GetValueOrDefault("maxProjects", "-1"));UserSubscriptionReconciliationJob
A daily background job (5:00 AM UTC) handles automatic subscription transitions: Trial → Active (when trial ends and auto-renew is on), Active → Expired (when subscription period ends and auto-renew is off), and Auto-Renew (create a new subscription row for the next period). This mirrors the SubscriptionReconciliationJob for Tier 1.
| Transition | Condition | Action |
|---|---|---|
| Trial → Active | TrialEndsAt ≤ now, AutoRenew = true | Create new Active row for next period |
| Trial → Expired | TrialEndsAt ≤ now, AutoRenew = false | Mark current row as Expired |
| Active → Expired | EndDate ≤ now, AutoRenew = false | Mark current row as Expired |
| Auto-Renew | EndDate ≤ now, AutoRenew = true | Create new Active row for next period |
Immutable Design (Cancel + Replace)
Following the 'New Row Pattern', renewals create a NEW UserSubscription row rather than updating the existing record. Cancellations mark the current subscription as Cancelled. This preserves a complete audit trail per billing cycle.
Self-Service /me Endpoint
Users can view their own subscription status via GET /api/v1/user-subscriptions/me. This endpoint resolves the current user's active subscription without needing an explicit subscription ID, making it suitable for client-facing applications.
GET /api/v1/user-subscriptions/me
Authorization: Bearer <user-jwt>
// Response
{
"id": "encrypted-id",
"planId": "encrypted-id",
"planName": "Pro",
"status": "Active",
"startDate": "2026-04-01T00:00:00Z",
"endDate": "2026-05-01T00:00:00Z",
"autoRenew": true
}Domain Events
The following domain events are fired by user subscription commands and can be consumed by webhook handlers or notification systems:
- UserSubscriptionCreatedEvent — Fired when a new subscription is assigned
- UserSubscriptionCancelledEvent — Fired when a subscription is cancelled
- UserSubscriptionRenewedEvent — Fired when a subscription is renewed
Tenant Context Required
User Subscriptions require a valid tenant context. The menu item is gated by RequiresTenantContext = true. System admins must drill down into a tenant or use impersonation to access this page.
API Endpoints
The UserSubscriptionsController exposes 11 endpoints. All require the user_subscriptions.* permission:
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/user-subscriptions | List subscriptions (paginated, filterable by status/plan/user) | 🔒 Required |
| GET | /api/v1/user-subscriptions/{id} | Get subscription details by ID | 🔒 Required |
| POST | /api/v1/user-subscriptions | Assign a user to a plan (create subscription) | 🔒 Required |
| POST | /api/v1/user-subscriptions/{id}/cancel | Cancel a subscription | 🔒 Required |
| POST | /api/v1/user-subscriptions/{id}/renew | Renew a cancelled subscription (creates new row) | 🔒 Required |
| GET | /api/v1/user-subscriptions/me | Get the current user's active subscription (self-service) | 🔒 Required |