Skip to content
bitzorcas
中EN

Guide

Billing Plans, Versions, and Subscriptions

Explains current plan publication, strict pricing snapshots, subscription management endpoints, renewal and grace jobs, and the commercial semantics that remain open.

Last updated

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

FieldCurrent semantics and boundary
Code / NameMust already be trimmed; lengths 64/120; control characters rejected
PlanVersionPositive commercial revision, separate from ORM concurrency Version
MonthlyPriceNonnegative decimal(18,2) with at most two decimals
CurrencyExactly three uppercase ASCII letters; this is format validation, not a full ISO 4217 catalog
PricingModelFlatRate, Metered, or Hybrid
TaxRateNonnegative decimal(5,4), at most 9.9999
StatusDraft, Published, or Retired
EntitlementsAt most 128; nonblank FeatureCode up to 128 characters, case-sensitive uniqueness
QuotasAt most 128; unique MeterCode, nonnegative Limit, required Unit
RateCardAt 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.

Create a Hybrid plan draft
// 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

Create / production seedUpdateDefinitionPublishRetire

Draft

Published

Retired

  • Only Draft can be edited.
  • Only Published can be used by StartSubscription or ChangeSubscriptionPlan.
  • 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 routeUse caseAuthorization action
POST /api/platform-billing/planscreate Draftplatform-billing.plan.create
PUT /api/platform-billing/plans/{planId}replace a Draft definitionplatform-billing.plan.update
POST /api/platform-billing/plans/{planId}/publishDraft → Publishedplatform-billing.plan.update
POST /api/platform-billing/plans/{planId}/retirePublished → Retiredplatform-billing.plan.update
GET /api/platform-billing/planssearch and page by statusplatform-billing.plan.view
GET /api/platform-billing/plans/{planId}read one planplatform-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.

PlanIdCodeUSD/monthCurrent concern
plan_freefree0requires explicit publication after first seed
plan_trialtrial0no distinct trial window or conversion semantics
plan_standardstandard99subscribable only after publication
plan_enterpriseenterprise499both 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:

  1. reads trusted TenantId from ICurrentUser.User.TenantId;
  2. requires ITenantStore.IsActiveAsync;
  3. loads the target Plan and requires Published;
  4. snapshots PlanId and PlanVersion;
  5. relies on the unique TenantId index to save the current subscription.
Subscribe the current tenant to a published plan
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

RouteBehavior
POST /subscriptions/current/pauseActive → Paused; repeated pause is idempotent
POST /subscriptions/current/resumePaused or Grace → Active
POST /subscriptions/current/change-planimmediately replaces PlanId and PlanVersion in Active/Paused/Grace; target must be Published
POST /subscriptions/current/renewcreates and issues an invoice; marks free plans paid or creates a paid-plan order; extends EndedAt by one month
POST /subscriptions/current/cancelActive/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.

StartPauseResumerenewal failureResume or successfulRenewCancelCancelCancelGraceSweep/domain callGraceSweep/domain callRenewRenew

Active

Paused

Grace

Cancelled

Expired

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:

JobDefault switchSelectionFailure behavior
auto-renewalPayment:AutoRenewal:Enabled=falseActive/Grace with EndedAt due within three days or already pastitem enters/remains Grace and emits a failure notification; batch returns the first failure
grace-sweepPayment:GraceSweep:Enabled=falseGrace with EndedAt earlier than now minus seven daysmarks 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 current yyyy-MM Period, 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.
  • SaveInvoiceAsync returns 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

Terminal window
# 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

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%