工单是这么写的:
用户把 MPChat 卡片余额清到了零,商户侧的订阅接口却仍然返回ACTIVE。截图下面只有一句话:
“你们系统是不是坏了?”
值班群里很快有人给出修复方案:
if card.available_balance == 0: subscription.status = "CANCELLED"这行代码看起来无害。真正让人停下来的是:卡片余额和订阅状态,描述的是同一件事吗?
卡片余额记录当前是否具备付款条件。订阅状态记录商户如何认定续费关系。两条数据属于不同业务对象,也由不同系统更新。
如果这行代码上线,问题可能几周后才出现:仍在有效期内的用户被提前停权,商户账单与本地记录无法对应,客服收到“我明明还在订阅期,为什么用不了”的投诉。
很多支付事故,都从一个试图让数据变整齐的if开始。
本文中的状态和代码仅用于架构说明,不代表 MPChat 现网接口或内部实现。
一、一次扣款失败,不等于合同终止
订阅链路至少涉及四类状态:
| 业务对象 | 回答的问题 | 参考状态 |
|---|---|---|
| 卡片资金 | 当前是否具备付款条件 | FUNDED、ZERO_BALANCE |
| 卡片控制 | 卡片是否允许交易 | ENABLED、FROZEN |
| 商户合同 | 续费关系处于什么阶段 | ACTIVE、PAST_DUE、CANCELLED |
| 软件权益 | 用户当前能否使用服务 | ACTIVE、GRACE、EXPIRED |
下面这组状态完全可能同时存在:
card_funding_state = ZERO_BALANCE card_control_state = ENABLED contract_state = ACTIVE entitlement_state = GRACE翻译成人话:MPChat 卡片当前没钱,卡片本身没有被冻结;商户尚未结束订阅关系,软件暂时保留宽限期权益。
三个系统各自记录的都是真实状态,只是范围不同。
ACTIVE的准确含义仍要以具体商户或应用商店的定义为准。它不能单独证明本期已经结算,也不能证明商户一定会再次扣款。
余额归零、冻结卡片或删除软件,都不能替代在原计费渠道取消订阅。
二、别把四个对象挤进一个字段
更稳妥的设计,是让每类事实都有明确归属。
type CardObservation = { cardRef: string merchantDescriptor?: string amount?: number currency?: string transactionStatus?: string observedAt: string } type PaymentAttempt = { attemptId: string subscriptionId: string status: "CREATED" | "AUTHORIZED" | "DECLINED" | "SETTLED" attemptedAt: string } type SubscriptionContract = { subscriptionId: string billingChannel: "MERCHANT" | "APP_STORE" | "GOOGLE_PLAY" | "OTHER" contractStatus: | "ACTIVE" | "PAST_DUE" | "CANCEL_AT_PERIOD_END" | "CANCELLED" currentPeriodEnd?: string cancelledAt?: string } type Entitlement = { subscriptionId: string userId: string status: "ACTIVE" | "GRACE" | "SUSPENDED" | "EXPIRED" validUntil?: string }卡片观察记录只保存卡片侧可见的事实,不加入subscriptionCancelled。卡片交易记录无法证明商户已经取消订阅。
同一个续费周期可能出现多次支付尝试,因此支付尝试与合同应是一对多关系。
合同状态来自原计费渠道的订阅事件或经过验证的主动查询结果。权益则单独管理。用户设置到期不续后,仍可能出现:
contract_status = CANCEL_AT_PERIOD_END entitlement_status = ACTIVE这通常表示用户还能使用完当前周期。
三、付款失败事件,只更新它能证明的事实
支付平台可能重复发送同一个 Webhook。去重逻辑需要由数据库唯一约束支撑,不能只在事务外查询一次。
async function handlePaymentAttempt(event: PaymentEvent) { await database.transaction(async tx => { const inserted = await tx.eventStore.insertIfAbsent({ provider: event.provider, eventId: event.eventId, occurredAt: event.occurredAt }) if (!inserted) return await tx.paymentAttempts.upsert({ attemptId: event.attemptId, subscriptionId: event.subscriptionId, status: normalizePaymentStatus(event.status), attemptedAt: event.occurredAt }) // 不在这里把合同直接写成 CANCELLED }) }建议为provider + eventId设置唯一约束,避免重复增加权益、重复提醒或重复触发补偿任务。
还要处理事件乱序。较早的ACTIVE事件如果晚于CANCELLED到达,不能把合同重新改回有效状态。可以使用渠道对象版本、事件时间或明确的状态迁移规则判断。
重试策略也不应写死在公共逻辑里。有些商户会重试,有些会暂停权益,有些会提供宽限期。如果重试由商户管理,本地系统负责记录事件,不自行增加扣款尝试。
四、再遇到这类工单,按这个顺序查
查看 MPChat 卡片记录核对实际卡片、商户、金额、币种、时间和可见交易状态。这一步不能证明订阅已经取消。
确认原始计费渠道查看订单或收据,确认购买来自商户官网、App Store、Google Play 还是其他渠道。
核对合同字段检查
billing_channel、contract_status、current_period_end和cancelled_at。查看最近一次支付尝试区分
CREATED、AUTHORIZED、DECLINED和SETTLED,不要把一次失败直接映射成CANCELLED。检查权益期限合同进入到期不续后,如果权益仍为
ACTIVE,继续查看validUntil,避免提前停权。
上线前至少验证四件事:余额为零不会自动取消合同,重复事件不会重复发放权益,到期不续期间可以保留本期权益,旧事件不能覆盖较新的取消状态。
写在最后
工程上最危险的做法,是让一个系统替其他系统下结论。
卡片余额为零,只是一条资金侧事实。DECLINED只说明某次支付尝试没有完成。用户是否已经取消,需要查看原计费渠道的明确状态或确认记录。
用户侧的处理顺序也很清楚:
先在 MPChat 检查卡片及可见交易记录,再回到原购买渠道核对和取消订阅,保存确认记录,最后调整卡片余额和后续预算。
ZERO_BALANCE与ACTIVE从来没有互相打架。真正危险的,是为了消除页面上的“不一致”,让一行代码越过系统边界。
不同服务商的状态名称、回调机制、重试政策和取消规则可能不同。实际接入前应核对对应渠道的最新文档,并避免在日志中保存完整卡号、验证码、邮箱和完整交易编号。