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.
Package structure
Section titled “Package structure”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)
Plan lifecycle
Section titled “Plan lifecycle”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));Pricing models
Section titled “Pricing models”| Model | Description |
|---|---|
Flat | Fixed price per interval |
PerSeat | Price multiplied by seat count |
PerUnit | Price per unit of consumption |
Tiered | Volume-based pricing tiers |
Subscription FSM
Section titled “Subscription FSM”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() stateDiagram-v2
[*] --> Active: Create()
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.
Hybrid provider model
Section titled “Hybrid provider model”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:
| Provider | Package | Capabilities |
|---|---|---|
| Internal | Granit.Subscriptions.Internal | None (no-op, zero dependency) |
| Stripe | Granit.Subscriptions.Stripe | All (Phase 2) |
Value Objects
Section titled “Value Objects”SubscriptionPeriod
Section titled “SubscriptionPeriod”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.
Multi-currency
Section titled “Multi-currency”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));Pricing resolution
Section titled “Pricing resolution”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);}Billing cycle orchestration
Section titled “Billing cycle orchestration”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.
BillingInterval-aware period calculation
Section titled “BillingInterval-aware period calculation”PeriodEndScanHandler reads Plan.DefaultInterval to advance periods correctly:
| Interval | Period advance |
|---|---|
| Monthly | AddMonths(1) |
| Quarterly | AddMonths(3) |
| Yearly | AddMonths(12) |
Dunning (payment retry)
Section titled “Dunning (payment retry)”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).
Seat management
Section titled “Seat management”// 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 seatsubscription.RevokeSeat(userId);AssignSeat enforces two invariants:
- Duplicate guard — a user cannot hold two seats on the same subscription
(backed by a unique DB index on
(SubscriptionId, UserId)) - Seat limit — when
seatLimitis provided, the current seat count is checked before adding
CQRS split: ISeatReader (query) / ISeatWriter (command).
Domain services
Section titled “Domain services”Business logic is encapsulated in domain services, keeping all handlers as thin pass-through delegates.
| Interface | Responsibility | Package |
|---|---|---|
IPeriodAdvancementService | Advances billing periods for subscriptions past their period end | Granit.Subscriptions |
ICancelAtPeriodEndService | Cancels subscriptions flagged for end-of-period cancellation | Granit.Subscriptions |
ISubscriptionReactivationService | Reactivates PastDue subscriptions after payment, resets dunning | Granit.Subscriptions |
IDunningService | Handles payment failures: PastDue transition, retry scheduling, suspension | Granit.Subscriptions |
ISubscriptionProviderSyncService | Syncs 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.
Background jobs
Section titled “Background jobs”| Job | Cron | Description |
|---|---|---|
TrialExpirationScanJob | 0 */4 * * * | Detects expiring/expired trials |
PeriodEndScanJob | 0 */1 * * * | Detects subscriptions at billing period end |
CancelAtPeriodEndScanJob | 0 */2 * * * | Processes cancel-at-period-end flag |
Features bridge
Section titled “Features bridge”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.
Cache invalidation on lifecycle changes
Section titled “Cache invalidation on lifecycle changes”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.
Admin API
Section titled “Admin API”| Method | Route | Permission |
|---|---|---|
| GET | /api/granit/subscriptions/plans | Subscriptions.Plans.Read |
| POST | /api/granit/subscriptions/plans | Subscriptions.Plans.Manage |
| GET | /api/granit/subscriptions/subscriptions | Subscriptions.Subscriptions.Read |
| POST | /api/granit/subscriptions/subscriptions | Subscriptions.Subscriptions.Manage |
| GET | /api/granit/subscriptions/seats | Subscriptions.Seats.Read |
| POST | /api/granit/subscriptions/seats | Subscriptions.Seats.Manage |
Notifications
Section titled “Notifications”Granit.Subscriptions.Notifications defines 6 notification types:
| Type | Name | Severity | Opt-out |
|---|---|---|---|
TrialExpiringNotificationType | Subscriptions.TrialExpiring | Warning | Yes |
TrialExpiredNotificationType | Subscriptions.TrialExpired | Info | No |
PlanChangedNotificationType | Subscriptions.PlanChanged | Info | Yes |
CancellationConfirmedNotificationType | Subscriptions.CancellationConfirmed | Info | No |
SuspensionWarningNotificationType | Subscriptions.SuspensionWarning | Error | No |
ScheduledChangeReminderNotificationType | Subscriptions.ScheduledChangeReminder | Info | Yes |
Default channels: Email + InApp. Email templates are Phase 2 (requires Granit.Templating).
See also
Section titled “See also”- SaaS Overview — ecosystem architecture and choreography
- Invoicing — invoice creation and payment tracking
- Payments — provider-agnostic payment processing
- Metering — usage-based metering
- Scheduling — scheduled plan changes
- Feature Flags — plan-driven cascade