Skip to content
bitzorcas
中EN

Guide

Billing 套餐、版本与订阅生命周期

说明 PlatformBilling 当前的套餐发布状态、严格定价快照、订阅管理端点、续费与宽限期作业,并标出仍需收口的商业语义。

Last updated

PlatformBilling 已经具备可调用的套餐与订阅生命周期:套餐先以草稿创建,经过发布后才能开通或切换;当前租户可以暂停、恢复、换套餐、续费和取消;JobHost 可以执行自动续费与宽限期清扫。它仍是“每租户一条当前订阅”的模型,没有订阅历史、预定变更、席位数量或同租户多订阅。

1. Plan 是完整商业快照

字段当前语义与边界
Code / Name去首尾空格后必须保持原值;最长 64/120;拒绝控制字符
PlanVersion正整数商业修订号,与 ORM 乐观并发 Version 分列
MonthlyPrice非负 decimal(18,2),最多两位小数
Currency恰好三个大写英文字母;这是格式检查,不是完整 ISO 4217 目录
PricingModelFlatRate、Metered 或 Hybrid
TaxRate非负 decimal(5,4),最大 9.9999
StatusDraft、Published 或 Retired
Entitlements最多 128 条;FeatureCode 非空、最长 128、区分大小写去重
Quotas最多 128 条;MeterCode 唯一、Limit 非负、Unit 必填
RateCard最多 128 条;每个 MeterCode 唯一,价格、包含量和溢出价均非负

权益、配额与费率卡分别持久化为 JSON 快照。创建和更新都提交完整集合;无效 JSON 或不符合不变量的持久化数据会阻止聚合恢复,而不是被悄悄替换为空集合。

创建一份 Hybrid 套餐草稿
// 商业配置集合在一次聚合创建调用中完成统一校验。
var created = Plan.Create(
code: "standard-cn",
name: "Standard China",
version: 3, // 商业版本,不是数据库并发 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 只得到 Draft;发布前不能用于新订阅。
return created.Value!;

2. 套餐状态机与管理 API

Create / production seedUpdateDefinitionPublishRetire

Draft

Published

Retired

  • 只有 Draft 可以修改定义。
  • 只有 Published 能用于 StartSubscription 或 ChangeSubscriptionPlan。
  • Retired 拒绝新订阅和套餐切换,但已有订阅仍可读取该套餐并解析权益。
  • 重复发布、草稿直接退役、退役后回退都会返回稳定 Conflict。
Method 与 route用例授权动作
POST /api/platform-billing/plans创建 Draftplatform-billing.plan.create
PUT /api/platform-billing/plans/{planId}更新 Draft 完整定义platform-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/plans按搜索、状态分页platform-billing.plan.view
GET /api/platform-billing/plans/{planId}读取单个套餐platform-billing.plan.view

列表读路径支持状态筛选,并通过 ReadModelStore 下推分页;它不再等同于“列出全部已发布套餐”。

3. 生产种子并不会自动发布

PlatformBillingPlanSeedStep 以 SeedScope.ProductionSafe 写入 Free、Trial、Standard、Enterprise 四个稳定 ID。新种子行通过 Plan.Restore 进入 Draft;已有 Published/Retired 行会原样保留,种子不会把它们降回草稿,也不会覆盖其商业定义。

PlanIdCodeUSD/月当前特别注意
plan_freefree0初次种子后仍需显式发布
plan_trialtrial0没有独立试用时间窗或自动转订阅语义
plan_standardstandard99发布后才可开通
plan_enterpriseenterprise499两个配额 Limit 都是 0

Trial 目前只是免费套餐目录项。Settings Registry 中的 platform.billing.trialDurationDays=14 没有被开通、续费或清扫用例读取,不能据此承诺 14 天自动到期。

4. 开通与当前订阅

POST /api/platform-billing/subscriptions 的处理顺序如下:

  1. 从 ICurrentUser.User.TenantId 取得可信租户;
  2. 要求 ITenantStore.IsActiveAsync 为 true;
  3. 读取目标 Plan,并要求其处于 Published;
  4. 把 PlanId 与 PlanVersion 写入新订阅;
  5. 依赖 TenantId 唯一索引保存当前订阅。
为当前租户开通已发布套餐
var result = await mediator.Send(
new StartSubscriptionCommand("plan_standard"),
cancellationToken);
if (result.IsFailure)
return result.Error; // TenantNotActive、PlanNotFound、PlanNotPublished 或持久化失败。
// 当前源码在这里不设置首个账期 EndedAt。
return Result.Success();

GET /api/platform-billing/subscriptions/current 返回当前租户的唯一订阅。Application 在插入前没有先检查“已有订阅”,并发开通最终由 TenantId 唯一索引裁决;调用方是否得到稳定 Conflict,仍取决于持久化异常映射。

5. 当前公开的订阅操作

Route行为
POST /subscriptions/current/pauseActive → Paused;重复暂停幂等
POST /subscriptions/current/resumePaused 或 Grace → Active
POST /subscriptions/current/change-planActive/Paused/Grace 立即覆盖 PlanId 与 PlanVersion;目标必须 Published
POST /subscriptions/current/renew生成并签发发票;免费套餐标记 Paid,付费套餐创建支付订单;结束时间延后一个月
POST /subscriptions/current/cancelActive/Paused/Grace → Cancelled,并把 EndedAt 写为当前时刻

所有管理命令使用 platform-billing.subscription.update,租户来自当前用户上下文,不接受请求体指定 TenantId。

StartPauseResumerenewal failureResume or successfulRenewCancelCancelCancelGraceSweep/domain callGraceSweep/domain callRenewRenew

Active

Paused

Grace

Cancelled

Expired

Cancelled 与 Expired 是终态。ChangePlan 只拒绝终态。手动 Renew 也只拒绝终态,所以 Paused 可以续费并保持 Paused;而自动续费只扫描 Active/Grace。

6. 手动续费、自动续费与宽限期

手动和自动续费共享同一基本步骤:加载订阅与 Plan、生成 Draft、Issue、保存发票;免费套餐直接 MarkPaid,付费套餐调用 IPaymentGateway.CreatePaymentOrderAsync;然后把 EndedAt 从“未来结束时间或当前时间”起延后一个月。

JobHost 还注册两条订阅作业:

Job默认开关选择范围失败行为
auto-renewalPayment:AutoRenewal:Enabled=falseEndedAt 在未来三天内或已过期的 Active/Grace单项失败进入/保持 Grace,发布失败通知;整批返回首个失败
grace-sweepPayment:GraceSweep:Enabled=falseGrace 且 EndedAt 早于“当前时间减 7 天”转 Expired,发布过期通知;整批返回首个失败

配置默认关闭,必须在 JobHost 显式启用并提供支付 Provider。作业对租户逐项处理,但没有认领租约或条件更新来阻止多实例同时处理同一订阅。

7. 当前仍需收口的商业与可靠性边界

7.1 首个账期与 Trial

Start 不写 EndedAt,Trial 设置也未接入。产品需要明确首个周期、试用期限、转付费选择、到期通知和失败后的数据访问级别,再把这些决定建成领域状态,而不是依靠套餐名称。

7.2 套餐切换没有历史与生效日

ChangeSubscriptionPlan 立即覆盖两个套餐字段,不保存旧套餐、计划生效日、价格快照、差价或 proration。降级是否延迟、在途用量按哪个版本计价、撤销怎样恢复,都没有可查询证据。

7.3 续费幂等键需要重新设计

  • 自动续费的 purpose 使用 auto-renewal:{subscriptionId},而发票幂等键还包含当前 yyyy-MM Period,因此不同月份不会命中同一键。缺口在于它没有持久化目标续费周期、订阅预期版本或 RenewalAttempt,无法把“发票、下单、订阅延长”绑定成一次可恢复操作。
  • 手动续费把精确到秒的当前时间写进键;同一秒重试会折叠,不同秒重试会创建新发票。
  • SaveInvoiceAsync 遇到“同键、不同 InvoiceId”时返回成功,但调用方继续使用本次未落库的 Invoice 对象创建支付订单并推进订阅。

因此生产门禁应增加跨月自动续费、并发重复、保存后崩溃、支付成功但订阅保存失败,以及“重复键返回既有发票”合同。理想幂等身份至少绑定订阅、账期、动作类型和可重放的业务请求 ID。

7.4 外部支付与数据库不是一个原子事务

支付网关下单和数据库状态更新无法放进同一 ACID 事务。当前流程没有显式 PaymentAttempt/Saga 来记录“发票已保存、网关已受理、订阅尚未推进”的中间状态。上线前需要对账、补偿和人工恢复手册,而不是把一次 Handler 成功当成端到端结算完成。

8. 测试与发布证据

至少固定以下合同:

  • Draft/Published/Retired 的全部合法边和非法边;
  • 种子新增保持 Draft,且不覆盖 Published/Retired;
  • 只有 Published 能开通或切换,Retired 仍服务已有订阅;
  • Active/Paused/Grace/Cancelled/Expired 的命令与 HTTP 错误合同;
  • Start 的 Tenant 状态、唯一键竞争和首个 EndedAt 语义;
  • 手动/自动续费在免费、付费、网关失败、保存失败下的结果;
  • 多实例 Job 并发、跨月幂等和 GraceSweep 时间边界;
  • SqlSugar 与 EF Core 对状态、JSON、唯一索引和乐观并发的等价行为。

9. 源码复核命令

Terminal window
# 套餐定义、发布状态与严格集合校验。
rg -n "PlanStatus|ValidateDefinition|ValidateSubscribable|RateCard" \
src/Platform/PlatformBilling -g '*.cs'
# 当前全部订阅命令、状态迁移和结束时间写入点。
rg -n "StartSubscription|PauseSubscription|ResumeSubscription|ChangeSubscriptionPlan|RenewSubscription|CancelSubscription|EndedAt" \
src/Platform/PlatformBilling -g '*.cs'
# 续费选择条件、幂等键和宽限期清扫。
rg -n "auto-renewal:|manual-renewal:|FindExpiringSubscriptions|GraceSweep|SaveInvoiceAsync" \
src/Platform/PlatformBilling src/Hosts/BitzOrcas.JobHost -g '*.cs'

返回 Billing 总览 · 权益、配额与用量 · 账单与幂等

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%