PlatformBilling now has callable plan and subscription lifecycles. A plan starts as Draft and must be published before a tenant can subscribe or switch to it. The current tenant can pause, resume, change plan, renew, and cancel; JobHost can run auto-renewal and grace-period expiry. The model still keeps one current subscription per tenant, with no history, scheduled changes, seat quantity, or multiple subscriptions.
1. Plan is a complete commercial snapshot
| Field | Current semantics and boundary |
|---|---|
Code / Name | Must already be trimmed; lengths 64/120; control characters rejected |
PlanVersion | Positive commercial revision, separate from ORM concurrency Version |
MonthlyPrice | Nonnegative decimal(18,2) with at most two decimals |
Currency | Exactly three uppercase ASCII letters; this is format validation, not a full ISO 4217 catalog |
PricingModel | FlatRate, Metered, or Hybrid |
TaxRate | Nonnegative decimal(5,4), at most 9.9999 |
Status | Draft, Published, or Retired |
Entitlements | At most 128; nonblank FeatureCode up to 128 characters, case-sensitive uniqueness |
Quotas | At most 128; unique MeterCode, nonnegative Limit, required Unit |
RateCard | At most 128; unique MeterCode and nonnegative unit, included, and overage values |
Entitlements, quotas, and rate cards are separate JSON snapshots. Creation and update replace complete collections. Invalid persisted JSON or an invariant violation blocks aggregate restoration instead of silently becoming an empty collection.
// All commercial collections enter the aggregate in one validated creation call.var created = Plan.Create( code: "standard-cn", name: "Standard China", version: 3, // Commercial revision, not persistence concurrency Version. monthlyPrice: 299m, currency: "CNY", pricingModel: PricingModel.Hybrid, taxRate: 0.06m, entitlements: [ new Entitlement("platform.tickets", true), new Entitlement("platform.webhooks", true), ], quotas: [ new Quota("tickets.maxOpen", 5_000m, "count"), ], rateCard: [ new PricingRule("tickets.created", 0m, 10_000m, 0.05m, "count"), ]);
if (created.IsFailure) return created.Error;
// Create yields Draft; it cannot accept new subscriptions before publication.return created.Value!;2. Plan lifecycle and management API
- Only Draft can be edited.
- Only Published can be used by
StartSubscriptionorChangeSubscriptionPlan. - Retired rejects new subscriptions and switches, while existing subscriptions may still resolve its entitlements.
- Repeated publication, retiring a draft, and reversing retirement fail with stable conflicts.
| Method and route | Use case | Authorization action |
|---|---|---|
POST /api/platform-billing/plans | create Draft | platform-billing.plan.create |
PUT /api/platform-billing/plans/{planId} | replace a Draft definition | platform-billing.plan.update |
POST /api/platform-billing/plans/{planId}/publish | Draft → Published | platform-billing.plan.update |
POST /api/platform-billing/plans/{planId}/retire | Published → Retired | platform-billing.plan.update |
GET /api/platform-billing/plans | search and page by status | platform-billing.plan.view |
GET /api/platform-billing/plans/{planId} | read one plan | platform-billing.plan.view |
The list read model supports status filtering and provider-side paging. It no longer means “return every published plan.”
3. Production seeds are not auto-published
PlatformBillingPlanSeedStep uses SeedScope.ProductionSafe for stable Free, Trial, Standard, and Enterprise IDs. A newly restored seed is Draft. Existing Published or Retired rows are preserved; the seed neither demotes them nor overwrites their commercial definitions.
| PlanId | Code | USD/month | Current concern |
|---|---|---|---|
plan_free | free | 0 | requires explicit publication after first seed |
plan_trial | trial | 0 | no distinct trial window or conversion semantics |
plan_standard | standard | 99 | subscribable only after publication |
plan_enterprise | enterprise | 499 | both quota limits are zero |
Trial is currently a free catalog row. The Settings Registry entry platform.billing.trialDurationDays=14 is not read by start, renewal, or sweep use cases, so it does not establish an automatic 14-day expiry contract.
4. Starting and reading the current subscription
POST /api/platform-billing/subscriptions:
- reads trusted TenantId from
ICurrentUser.User.TenantId; - requires
ITenantStore.IsActiveAsync; - loads the target Plan and requires Published;
- snapshots PlanId and PlanVersion;
- relies on the unique TenantId index to save the current subscription.
var result = await mediator.Send( new StartSubscriptionCommand("plan_standard"), cancellationToken);
if (result.IsFailure) return result.Error; // TenantNotActive, PlanNotFound, PlanNotPublished, or persistence failure.
// Current source does not establish the first EndedAt here.return Result.Success();GET /api/platform-billing/subscriptions/current returns the unique current row. The application does not preflight an existing subscription before insert. Concurrent starts ultimately race on the TenantId unique index; whether callers receive a stable Conflict still depends on persistence-exception translation.
5. Public subscription operations
| Route | Behavior |
|---|---|
POST /subscriptions/current/pause | Active → Paused; repeated pause is idempotent |
POST /subscriptions/current/resume | Paused or Grace → Active |
POST /subscriptions/current/change-plan | immediately replaces PlanId and PlanVersion in Active/Paused/Grace; target must be Published |
POST /subscriptions/current/renew | creates and issues an invoice; marks free plans paid or creates a paid-plan order; extends EndedAt by one month |
POST /subscriptions/current/cancel | Active/Paused/Grace → Cancelled and sets EndedAt to now |
All management commands use platform-billing.subscription.update. Tenant scope comes from current-user context and is never accepted from the request body.
Cancelled and Expired are terminal. ChangePlan rejects only terminal states. Manual Renew also rejects only terminal states, so a Paused subscription can renew and remain Paused; automatic renewal scans only Active/Grace.
6. Manual renewal, auto-renewal, and grace expiry
Manual and automatic renewal share the same outline: load subscription and Plan, generate a Draft invoice, Issue and save it, MarkPaid for a free plan or call IPaymentGateway.CreatePaymentOrderAsync, then extend EndedAt by one month from the later of current EndedAt and now.
JobHost registers two subscription jobs:
| Job | Default switch | Selection | Failure behavior |
|---|---|---|---|
auto-renewal | Payment:AutoRenewal:Enabled=false | Active/Grace with EndedAt due within three days or already past | item enters/remains Grace and emits a failure notification; batch returns the first failure |
grace-sweep | Payment:GraceSweep:Enabled=false | Grace with EndedAt earlier than now minus seven days | marks Expired and emits a notification; batch returns the first failure |
Both are disabled by default and require explicit JobHost configuration; renewal also needs a payment Provider. Processing is item-by-item, with no claim lease or conditional update that prevents two JobHost replicas from handling the same subscription concurrently.
7. Commercial and reliability boundaries still open
7.1 Initial period and Trial
Start does not set EndedAt, and trial settings are disconnected. Product policy must define the first period, trial deadline, paid conversion choice, expiry notification, and post-expiry access level, then represent those decisions as domain state rather than a plan name.
7.2 Plan change has no history or effective date
ChangeSubscriptionPlan immediately overwrites two plan fields. It records no previous plan, effective date, price snapshot, proration, or pending decision. Downgrade timing, late usage rating, and rollback therefore have no queryable evidence.
7.3 Renewal idempotency needs redesign
- Auto-renewal uses
auto-renewal:{subscriptionId}as its purpose, while the invoice key also includes the currentyyyy-MMPeriod, so different months do not collide. The missing identity is a persisted target-renewal period, expected subscription version, or RenewalAttempt that binds invoice, order, and entitlement extension into one recoverable operation. - Manual renewal includes wall-clock time to the second. Retries within a second collapse; retries in another second create a new invoice.
SaveInvoiceAsyncreturns success for “same key, different InvoiceId,” while the caller continues with the new, unpersisted Invoice object when creating the payment order and advancing the subscription.
Production evidence therefore needs multi-month renewal, concurrent retries, crash-after-save, payment-success/subscription-save-failure, and “duplicate key returns the existing invoice” contracts. A durable idempotency identity should bind subscription, billing period, action type, and a replayable business request ID.
7.4 External payment and database state are not atomic
A payment-provider order and database mutations cannot share one ACID transaction. The current path has no explicit PaymentAttempt/Saga for “invoice saved, provider accepted, subscription not advanced.” Release operations need reconciliation, compensation, and a manual recovery runbook; a successful handler invocation is not evidence of completed settlement.
8. Test and release evidence
Pin at least these contracts:
- every legal and illegal Draft/Published/Retired transition;
- a new seed remains Draft and never overwrites Published/Retired;
- only Published can start or switch, while Retired still serves existing subscriptions;
- command and HTTP behavior across Active/Paused/Grace/Cancelled/Expired;
- tenant status, unique-index races, and first EndedAt on Start;
- free/paid manual and automatic renewal under gateway and persistence failures;
- multi-replica job concurrency, multi-month idempotency, and GraceSweep time boundaries;
- SqlSugar/EF Core equivalence for status, JSON, unique indexes, and optimistic concurrency.
9. Source review commands
# Plan definition, publication state, and strict collection validation.rg -n "PlanStatus|ValidateDefinition|ValidateSubscribable|RateCard" \ src/Platform/PlatformBilling -g '*.cs'
# Every current subscription command, transition, and EndedAt writer.rg -n "StartSubscription|PauseSubscription|ResumeSubscription|ChangeSubscriptionPlan|RenewSubscription|CancelSubscription|EndedAt" \ src/Platform/PlatformBilling -g '*.cs'
# Renewal selection, idempotency, and grace expiry.rg -n "auto-renewal:|manual-renewal:|FindExpiringSubscriptions|GraceSweep|SaveInvoiceAsync" \ src/Platform/PlatformBilling src/Hosts/BitzOrcas.JobHost -g '*.cs'Back to Billing · Entitlements and usage · Invoices and idempotency