如何改进 Subscription-based billing system 的数据库设计?
结论
不要让 TransactionController 根据“用户是否返回网站”来激活订阅,也不要用 is_active = null 表示支付处理中。
更稳妥的做法是增加一层明确的购买单据:
Order -> OrderItem -> Subscription
|
Payment / Transaction
transactions 只记录资金流并关联 orders;subscriptions 记录订阅生命周期;order_items 则说明这笔钱购买的是订阅还是额外服务。
这样不必在 transactions 中设置多个 nullable foreign key,支付完成后也能准确找到需要激活的订阅。
推荐的数据关系
users
├── subscriptions
└── orders
├── order_items
│ ├── subscription_order_items
│ └── extra_service_order_items
└── transactions
各表的职责如下:
orders:一次结算请求,记录应付总额。order_items:订单中购买的项目。transactions:支付平台产生的支付、退款等资金记录。subscriptions:订阅本身及其有效期。subscription_order_items:将订阅类订单项关联到具体订阅。extra_service_order_items:记录额外服务,避免混入订阅关系。
示例 schema:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
amount_due BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMP NOT NULL,
paid_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
item_type VARCHAR(30) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
unit_amount BIGINT NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id)
);
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
valid_from TIMESTAMP NULL,
valid_until TIMESTAMP NULL,
activated_at TIMESTAMP NULL,
cancelled_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (plan_id) REFERENCES plans(id)
);
CREATE TABLE subscription_order_items (
order_item_id BIGINT PRIMARY KEY,
subscription_id BIGINT NOT NULL,
operation VARCHAR(20) NOT NULL,
FOREIGN KEY (order_item_id) REFERENCES order_items(id),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);
CREATE TABLE transactions (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
provider VARCHAR(30) NOT NULL,
provider_transaction_id VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
processed_at TIMESTAMP NULL,
FOREIGN KEY (order_id) REFERENCES orders(id),
UNIQUE (provider, provider_transaction_id)
);
金额应使用最小货币单位的整数保存,例如人民币分,不要使用 FLOAT:
¥99.00 -> 9900
如果业务能保证一张订单只购买一个对象,也可以直接增加 subscription_purchases、extra_service_purchases 等业务表,并让 transactions 统一关联 order_id。重点在于,交易应关联结算单据,而不是直接关联不同类型的业务对象。
首次购买时如何处理
创建支付请求前,依次完成以下操作:
- 创建状态为
pending的 subscription。 - 创建状态为
pending的 order。 - 创建 subscription 类型的 order item。
- 通过
subscription_order_items关联 subscription。 - 使用 order 创建支付平台的支付请求。
这些记录最好在同一个数据库事务中创建:
DB::transaction(function () use ($user, $plan) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => 'pending',
]);
$order = Order::create([
'user_id' => $user->id,
'status' => 'pending',
'amount_due' => $plan->price,
'currency' => 'CNY',
]);
$item = OrderItem::create([
'order_id' => $order->id,
'item_type' => 'subscription',
'quantity' => 1,
'unit_amount' => $plan->price,
]);
SubscriptionOrderItem::create([
'order_item_id' => $item->id,
'subscription_id' => $subscription->id,
'operation' => 'initial',
]);
});
此时订阅还没有生效,但订单与订阅之间已经建立了清晰、可追踪的关系。
不要用 null 表示 status 正在处理中。建议至少定义以下状态:
pending
active
past_due
cancelled
expired
是否允许 pending 状态的用户使用部分功能,应由权限规则决定,不能依赖 nullable boolean。
如何判断“首次购买”
不能根据当前是否存在 active subscription 来判断“首次购买”,因为用户可能以前买过订阅,后来取消了,或者订阅已经过期。
如果“首次购买”指用户历史上第一次成功获得订阅,应检查是否存在曾经激活过的订阅:
SELECT EXISTS (
SELECT 1
FROM subscriptions
WHERE user_id = :user_id
AND activated_at IS NOT NULL
);
没有记录,说明这是首次成功购买;存在记录,则属于再次购买或续订。
不过,许多场景并不需要在运行时推断。创建订单项时,可以直接记录操作类型:
initial 首次开通
renewal 续订
upgrade 升级
downgrade 降级
例如:
subscription_order_items.operation = 'initial'
这个值必须由订阅服务根据历史记录确定,不能直接采用客户端提交的参数。
如果业务对“首次购买”还有“从未试用”“从未退款”等限制,应分别定义规则,不能只看 subscription 是否存在。
如何确认已经付清
不要只检查某一条 transaction 是否为 paid。一张订单可能遇到以下情况:
- 多次支付尝试;
- 部分支付;
- 支付失败后重试;
- 重复回调;
- 退款或部分退款;
- 不同币种或金额异常。
订单付清至少需要满足:
currency 与订单一致
已成功 capture 的金额合计 - 已退款金额合计 >= amount_due
例如:
SELECT
COALESCE(SUM(
CASE
WHEN type = 'payment' AND status = 'succeeded' THEN amount
WHEN type = 'refund' AND status = 'succeeded' THEN -amount
ELSE 0
END
), 0) AS net_paid
FROM transactions
WHERE order_id = :order_id
AND currency = :currency;
只有 net_paid >= orders.amount_due 时,订单才能进入 paid 状态。是否允许超额支付,需要另外制定退款或余额处理规则。
即使支付平台只提供“一次性完整支付”,也要验证回调中的以下信息:
- 商户订单号;
- 支付平台交易号;
- 金额;
- 币种;
- 支付状态;
- 回调签名;
- 商户或应用标识。
支付完成后如何激活订阅
浏览器返回地址只能用于展示支付结果,不能作为到账依据。用户可以伪造请求,也可能付款成功后直接关闭页面,不再返回网站。
应以支付平台经过签名验证的 webhook 为准:
public function handlePaymentWebhook(array $payload): void
{
$event = $this->gateway->verifyWebhook($payload);
DB::transaction(function () use ($event) {
$order = Order::where('id', $event->orderId)
->lockForUpdate()
->firstOrFail();
Transaction::firstOrCreate(
[
'provider' => $event->provider,
'provider_transaction_id' => $event->transactionId,
],
[
'order_id' => $order->id,
'type' => 'payment',
'status' => $event->status,
'amount' => $event->amount,
'currency' => $event->currency,
'processed_at' => now(),
]
);
if (! $this->paymentService->isFullyPaid($order)) {
return;
}
if ($order->status === 'paid') {
return;
}
$order->update([
'status' => 'paid',
'paid_at' => now(),
]);
$items = $order->items()
->where('item_type', 'subscription')
->get();
foreach ($items as $item) {
$subscription = $item->subscriptionPurchase->subscription;
$subscription->update([
'status' => 'active',
'valid_from' => now(),
'valid_until' => now()->addMonth(),
'activated_at' => $subscription->activated_at ?? now(),
]);
}
});
}
示例中的 addMonth() 只用于说明流程。实际有效期必须根据 plan 的计费周期计算,同时明确月末、时区、闰年和续订叠加规则。
不要让 Controller 承担业务协调
TransactionController 和 SubscriptionController 分开,并不妨碍数据库建立关联。Controller 只是入口,支付确认和订阅激活应由应用服务协调,例如:
PaymentWebhookController
-> PaymentService
-> OrderService
-> SubscriptionService
-> ExtraServiceService
确认订单付清后,可以同步履约,也可以发布 OrderPaid 事件:
event(new OrderPaid($order->id));
对应的监听器再分别处理订阅、额外服务和付款凭证:
ActivateSubscription
ProvisionExtraService
SendPaymentReceipt
如果使用异步队列,建议配合 outbox pattern,避免数据库已经提交订单状态,事件却因进程中断而未能发出。
需要特别注意的事项
- 支付 webhook 必须具备幂等性,重复通知不能导致订阅被重复延长。
provider_transaction_id应设置唯一约束。- 激活订阅前应锁定 order,防止并发回调造成重复履约。
- 不要用一个
is_active字段承载完整生命周期,应优先使用含义明确的status。 - 支付完成前,
valid_from和valid_until不应表示订阅已经生效。 - 首次购买、续订、升级和额外服务,都应保存为明确的订单项类型或操作类型。
- 浏览器回跳页面只负责查询订单状态,不能把订单改成
paid。 - 对账应以支付平台记录和本地 transactions 为依据,并处理退款、拒付和回调丢失。
- 如果支付平台支持 Payment Intent、Invoice 或 Checkout Session,应保存对应的外部 ID,并通过唯一约束防止重复绑定。
最终,subscription 是否激活不再取决于某个 Controller 的执行顺序,而是由清晰的状态转换决定:
订单创建
-> 支付平台确认到账
-> 本地核对金额与币种
-> order 变为 paid
-> 根据 order_items 履约
-> subscription 变为 active
这套关系也适用于首次购买、续订和额外服务,无需为每一种商品类型在 transactions 中增加 nullable foreign key。
备注:内容仅供参考。
