MPChat 卡片余额为 0,订阅却还是 ACTIVE:这次我们差点改错了代码
2026/9/14 23:40:04 网站建设 项目流程

工单是这么写的:

用户把 MPChat 卡片余额清到了零,商户侧的订阅接口却仍然返回ACTIVE。截图下面只有一句话:

“你们系统是不是坏了?”

值班群里很快有人给出修复方案:

if card.available_balance == 0: subscription.status = "CANCELLED"

这行代码看起来无害。真正让人停下来的是:卡片余额和订阅状态,描述的是同一件事吗?

卡片余额记录当前是否具备付款条件。订阅状态记录商户如何认定续费关系。两条数据属于不同业务对象,也由不同系统更新。

如果这行代码上线,问题可能几周后才出现:仍在有效期内的用户被提前停权,商户账单与本地记录无法对应,客服收到“我明明还在订阅期,为什么用不了”的投诉。

很多支付事故,都从一个试图让数据变整齐的if开始。

本文中的状态和代码仅用于架构说明,不代表 MPChat 现网接口或内部实现。

一、一次扣款失败,不等于合同终止

订阅链路至少涉及四类状态:

业务对象回答的问题参考状态
卡片资金当前是否具备付款条件FUNDEDZERO_BALANCE
卡片控制卡片是否允许交易ENABLEDFROZEN
商户合同续费关系处于什么阶段ACTIVEPAST_DUECANCELLED
软件权益用户当前能否使用服务ACTIVEGRACEEXPIRED

下面这组状态完全可能同时存在:

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到达,不能把合同重新改回有效状态。可以使用渠道对象版本、事件时间或明确的状态迁移规则判断。

重试策略也不应写死在公共逻辑里。有些商户会重试,有些会暂停权益,有些会提供宽限期。如果重试由商户管理,本地系统负责记录事件,不自行增加扣款尝试。

四、再遇到这类工单,按这个顺序查

  1. 查看 MPChat 卡片记录核对实际卡片、商户、金额、币种、时间和可见交易状态。这一步不能证明订阅已经取消。

  2. 确认原始计费渠道查看订单或收据,确认购买来自商户官网、App Store、Google Play 还是其他渠道。

  3. 核对合同字段检查billing_channelcontract_statuscurrent_period_endcancelled_at

  4. 查看最近一次支付尝试区分CREATEDAUTHORIZEDDECLINEDSETTLED,不要把一次失败直接映射成CANCELLED

  5. 检查权益期限合同进入到期不续后,如果权益仍为ACTIVE,继续查看validUntil,避免提前停权。

上线前至少验证四件事:余额为零不会自动取消合同,重复事件不会重复发放权益,到期不续期间可以保留本期权益,旧事件不能覆盖较新的取消状态。

写在最后

工程上最危险的做法,是让一个系统替其他系统下结论。

卡片余额为零,只是一条资金侧事实。DECLINED只说明某次支付尝试没有完成。用户是否已经取消,需要查看原计费渠道的明确状态或确认记录。

用户侧的处理顺序也很清楚:

先在 MPChat 检查卡片及可见交易记录,再回到原购买渠道核对和取消订阅,保存确认记录,最后调整卡片余额和后续预算。

ZERO_BALANCEACTIVE从来没有互相打架。真正危险的,是为了消除页面上的“不一致”,让一行代码越过系统边界。

不同服务商的状态名称、回调机制、重试政策和取消规则可能不同。实际接入前应核对对应渠道的最新文档,并避免在日志中保存完整卡号、验证码、邮箱和完整交易编号。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询