MPChat 卡片余额为 0,订阅却还是 ACTIVE:这次我们差点改错了代码

发布时间:2026/9/14 23:40:19
MPChat 卡片余额为 0,订阅却还是 ACTIVE:这次我们差点改错了代码 工单是这么写的用户把 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从来没有互相打架。真正危险的是为了消除页面上的“不一致”让一行代码越过系统边界。不同服务商的状态名称、回调机制、重试政策和取消规则可能不同。实际接入前应核对对应渠道的最新文档并避免在日志中保存完整卡号、验证码、邮箱和完整交易编号。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询