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.

FieldTypeDescription
UserIdGuidThe subscribing user (Identity.User.Id — no FK nav prop)
TenantPlanIdGuidThe TenantPlan being subscribed to
TenantIdGuidTenant owning this subscription
StatusUserSubscriptionStatusActive | Trialing | Cancelled | Expired | PastDue
StartedAtDateTimeWhen the subscription became active (after trial if applicable)
ExpiresAtDateTime?When the subscription expires (null = Lifetime plan)
TrialEndsAtDateTime?When the trial period expires. Null if no trial.
IsAutoRenewboolWhether to automatically renew this subscription on expiry
CancelledAtDateTime?When the user explicitly cancelled the subscription
TenantPlanVersionNumberint?Version the subscriber is pinned to for grandfathering. Null = latest.
PromotionIdGuid?The promotion applied at subscription time. Null = no promo.
PromotionCodestring?Snapshot of the promo code used (survives promotion deletion)
DiscountAmountdecimal(18,2)Discount amount applied from the promotion
OriginalPricedecimal(18,2)?Original pre-discount price
ExternalRefstring?External payment reference (e.g. Stripe subscription ID — reserved for Phase 10)
Notesstring?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.

Free
assign trial plan
Trial
trial ends + auto-renew
Active
payment fails
PastDue
Cancelled
Expired
StatusDescriptionTerminal?
FreeUser is on a free plan with no billingNo
TrialUser is in a trial period (expires at TrialEndsAt)No
ActiveUser has a paid, active subscriptionNo
PastDuePayment has failed, grace period in progressNo
CancelledSubscription was manually cancelledYes
ExpiredSubscription period ended without renewalYes

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.

UserFeatureCheckerService.csC#
// 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.

TransitionConditionAction
Trial → ActiveTrialEndsAt ≤ now, AutoRenew = trueCreate new Active row for next period
Trial → ExpiredTrialEndsAt ≤ now, AutoRenew = falseMark current row as Expired
Active → ExpiredEndDate ≤ now, AutoRenew = falseMark current row as Expired
Auto-RenewEndDate ≤ now, AutoRenew = trueCreate 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.

HTTP
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:

MethodEndpointDescriptionAuth
GET/api/v1/user-subscriptionsList subscriptions (paginated, filterable by status/plan/user)🔒 Required
GET/api/v1/user-subscriptions/{id}Get subscription details by ID🔒 Required
POST/api/v1/user-subscriptionsAssign a user to a plan (create subscription)🔒 Required
POST/api/v1/user-subscriptions/{id}/cancelCancel a subscription🔒 Required
POST/api/v1/user-subscriptions/{id}/renewRenew a cancelled subscription (creates new row)🔒 Required
GET/api/v1/user-subscriptions/meGet the current user's active subscription (self-service)🔒 Required