Skip to content

Subscriptions — Plans, Seats & Billing Orchestration

Granit.Subscriptions is the billing cycle orchestrator of the SaaS ecosystem. It manages plan catalogs, subscription lifecycles, seat assignments, and coordinates with Invoicing and Metering to produce invoices at the right time with the right line items.

  • DirectoryGranit.Subscriptions/ Domain: Plan, Subscription, seats, workflows, provider abstraction
    • Granit.Subscriptions.EntityFrameworkCore EF Core store (SQL Server / PostgreSQL)
    • Granit.Subscriptions.Endpoints Minimal API (plans, subscriptions, seats) with RBAC
    • Granit.Subscriptions Event handlers (provider sync, usage → invoice)
    • Granit.Subscriptions.BackgroundJobs Trial expiration, period end, cancel-at-period-end scans
    • Granit.Subscriptions.Features Bridge to Granit.Features (IPlanIdProvider + IPlanFeatureStore)
    • Granit.Subscriptions.Internal No-op provider for self-hosted mode (zero dependencies)

Plans follow a WorkflowLifecycleStatus managed by Granit.Workflow:

stateDiagram-v2
    [*] --> Draft: Create()
    Draft --> Published: Publish
    Published --> Archived: Archive
    Archived --> [*]
  • Draft: mutable — prices, features, metadata can be modified
  • Published: available for purchase, immutable
  • Archived: not purchasable, but existing subscribers keep their plan
var plan = Plan.Create(
Guid.NewGuid(), "Pro", "Professional plan",
PricingModel.PerSeat, BillingInterval.Monthly,
trialDays: 14, seatLimit: 50);
plan.AddPrice(PlanPrice.Create(Guid.NewGuid(), 29.99m, "EUR", BillingInterval.Monthly, clock.Now));
plan.AddPrice(PlanPrice.Create(Guid.NewGuid(), 299.99m, "EUR", BillingInterval.Yearly, clock.Now));
ModelDescription
FlatFixed price per interval
PerSeatPrice multiplied by seat count
PerUnitPrice per unit of consumption
TieredVolume-based pricing tiers

Two workflow definitions depending on whether the plan has a trial:

stateDiagram-v2
    [*] --> Trial: Create(trialEndsAt)
    Trial --> Active: Activate()
    Trial --> Expired: Expire()
    Active --> PastDue: MarkPastDue()
    Active --> Cancelled: Cancel()
    PastDue --> Active: Activate()
    PastDue --> Suspended: Suspend()
    Suspended --> Cancelled: Cancel()

All state transition methods are idempotent — they return bool (true if state actually changed, false if already in target state). This prevents webhook ping-pong between Granit and external providers.

Granit is the source of truth for subscription status. Providers are executors with declared capabilities:

public interface ISubscriptionProvider
{
string Name { get; }
SubscriptionProviderCapabilities Capabilities { get; }
Task CreateExternalAsync(Subscription subscription, CancellationToken ct);
Task CancelExternalAsync(SubscriptionExternalMapping mapping,
bool atPeriodEnd, CancellationToken ct);
}
[Flags]
public enum SubscriptionProviderCapabilities
{
None = 0,
Provisioning = 1,
Cancellation = 2,
PlanChange = 4,
TrialManagement = 8,
SeatManagement = 16,
}

Built-in providers:

ProviderPackageCapabilities
InternalGranit.Subscriptions.InternalNone (no-op, zero dependency)
StripeGranit.Subscriptions.StripeAll (Phase 2)

Groups the billing period boundaries and the anchor date used to compute future periods:

public sealed record SubscriptionPeriod(
DateTimeOffset Start,
DateTimeOffset End,
DateTimeOffset BillingCycleAnchor);

The BillingCycleAnchor is the reference date for calculating period boundaries. For example, an anchor of January 15 means periods always align on the 15th (Feb 15, Mar 15…) regardless of when the subscription was actually created.

Each subscription stores the currency selected at creation (Currency property, ISO 4217). All invoice creation uses subscription.Currency — no hardcoded values. Plans can have multiple PlanPrice entries for different currency/interval combinations.

var subscription = Subscription.Create(
id, tenantId, planId,
currency: "EUR", // ← stored on the subscription
new SubscriptionPeriod(now, now.AddMonths(1), BillingCycleAnchor: now));

IPricingResolver resolves prices from the plan’s PlanPrice entries:

public interface IPricingResolver
{
Task<decimal> ResolveBasePriceAsync(
PlanId planId, string currency, BillingInterval interval, CancellationToken ct);
Task<decimal> ResolveUsageUnitPriceAsync(
PlanId planId, string currency, BillingInterval interval,
string meterId, CancellationToken ct);
}

Subscriptions acts as the billing cycle orchestrator (Stripe Billing model). Exclusive routing prevents double invoicing (split-brain):

PeriodEndScan detects end of cycle
├── Flat/PerSeat plan → BillingCycleCompletedHandler creates invoice
└── PerUnit/Tiered plan → wait for UsageSummaryReadyHandler (consolidated invoice)
  • BillingCycleCompletedHandler: Flat/PerSeat only. Skips plans with usage component.
  • UsageSummaryReadyHandler: PerUnit/Tiered. Creates a consolidated invoice with fixed charges (base price) + usage charges (metered data).

Both handlers use IPricingResolver and subscription.Currency.

PeriodEndScanHandler reads Plan.DefaultInterval to advance periods correctly:

IntervalPeriod advance
MonthlyAddMonths(1)
QuarterlyAddMonths(3)
YearlyAddMonths(12)

When a payment fails, the dunning system automatically handles retries:

PaymentFailedEto → PaymentFailedHandler
├── MarkPastDue() + IncrementDunningAttempt()
├── Attempt ≤ 3: schedule RetryPaymentPayload via IScheduler
│ ├── Attempt 1: +3 days
│ ├── Attempt 2: +7 days
│ └── Attempt 3: +14 days
└── Attempt > 3: Suspend()
InvoicePaidEto → InvoicePaidHandler
└── Activate() + ResetDunning()

Retry attempts use the exact same provider and method type as the original payment (propagated via ProviderName + MethodType in the retry chain).

// Assign a seat (seatLimit from the plan prevents over-allocation)
var seat = SubscriptionSeat.Create(Guid.NewGuid(), userId, clock.Now);
subscription.AssignSeat(seat, plan.SeatLimit);
// Revoke a seat
subscription.RevokeSeat(userId);

AssignSeat enforces two invariants:

  1. Duplicate guard — a user cannot hold two seats on the same subscription (backed by a unique DB index on (SubscriptionId, UserId))
  2. Seat limit — when seatLimit is provided, the current seat count is checked before adding

CQRS split: ISeatReader (query) / ISeatWriter (command).

Business logic is encapsulated in domain services, keeping all handlers as thin pass-through delegates.

InterfaceResponsibilityPackage
IPeriodAdvancementServiceAdvances billing periods for subscriptions past their period endGranit.Subscriptions
ICancelAtPeriodEndServiceCancels subscriptions flagged for end-of-period cancellationGranit.Subscriptions
ISubscriptionReactivationServiceReactivates PastDue subscriptions after payment, resets dunningGranit.Subscriptions
IDunningServiceHandles payment failures: PastDue transition, retry scheduling, suspensionGranit.Subscriptions
ISubscriptionProviderSyncServiceSyncs cancellations to external providers (Stripe, etc.)Granit.Subscriptions

All five services are registered automatically by AddGranitSubscriptions(). Application-level orchestrators (billing cycle invoice creation, usage invoice creation, payment retry dispatch) are internal to their respective .Wolverine / .BackgroundJobs packages.

JobCronDescription
TrialExpirationScanJob0 */4 * * *Detects expiring/expired trials
PeriodEndScanJob0 */1 * * *Detects subscriptions at billing period end
CancelAtPeriodEndScanJob0 */2 * * *Processes cancel-at-period-end flag

Granit.Subscriptions.Features implements IPlanIdProvider and IPlanFeatureStore from Granit.Features. This enables automatic feature resolution per plan — existing feature flag cascade works without any additional configuration.

When a subscription is suspended, cancelled, or expired, a Wolverine handler (SubscriptionLifecycleCacheInvalidationHandler) automatically expires all cached feature values for the affected tenant. This prevents a suspended tenant from retaining premium feature access through stale cache entries.

MethodRoutePermission
GET/api/granit/subscriptions/plansSubscriptions.Plans.Read
POST/api/granit/subscriptions/plansSubscriptions.Plans.Manage
GET/api/granit/subscriptions/subscriptionsSubscriptions.Subscriptions.Read
POST/api/granit/subscriptions/subscriptionsSubscriptions.Subscriptions.Manage
GET/api/granit/subscriptions/seatsSubscriptions.Seats.Read
POST/api/granit/subscriptions/seatsSubscriptions.Seats.Manage

Granit.Subscriptions.Notifications defines 6 notification types:

TypeNameSeverityOpt-out
TrialExpiringNotificationTypeSubscriptions.TrialExpiringWarningYes
TrialExpiredNotificationTypeSubscriptions.TrialExpiredInfoNo
PlanChangedNotificationTypeSubscriptions.PlanChangedInfoYes
CancellationConfirmedNotificationTypeSubscriptions.CancellationConfirmedInfoNo
SuspensionWarningNotificationTypeSubscriptions.SuspensionWarningErrorNo
ScheduledChangeReminderNotificationTypeSubscriptions.ScheduledChangeReminderInfoYes

Default channels: Email + InApp. Email templates are Phase 2 (requires Granit.Templating).