# T-634 过期会员允许购买其他套餐 ## 背景 线上 `dev` 用户的“测试”权益已经超过宽限截止时间,但 `SoftwareEntitlement.status` 仍为 `active`。用户购买“99元/月”套餐时, 软件订单服务只按数据库状态查找已有权益,没有同时判断 `grace_expires_at`,因此把时间上已经失效的权益误判为当前套餐,并返回 `plan_change_not_supported`(“当前套餐变更请联系运营处理”)。 订阅授权服务已经按 `status + grace_expires_at` 判断真实有效性,下单与支付 入账使用不同口径,导致状态接口显示已过期,但用户仍无法购买新套餐。 ## 目标 1. 已超过宽限截止时间的权益不得阻止用户购买其他套餐。 2. 尚在有效期或宽限期内的不同套餐仍拒绝自助切换,继续由运营处理。 3. 支付回调首次发放新套餐时,把同产品已超过宽限期但仍标记为 `active` 的历史权益收敛为 `expired`。 4. 同套餐有效权益的正常续订、支付幂等、金额校验和点数账本隔离保持不变。 ## 范围 ### 下单前校验 - `create_software_order()` 查询当前套餐时必须同时满足: `status=active` 且 `grace_expires_at > now`。 - 只有当前仍可用的权益与目标套餐不同,才返回 `plan_change_not_supported`。 - 已超过宽限期的 `active` 历史权益不阻止创建其他套餐的待支付订单。 - 创建支付订单阶段不提前改写历史权益;只有支付确认并发放权益时才完成 状态收敛,避免用户未付款就改变历史数据。 ### 支付入账 - `apply_software_payment()` 继续在数据库事务内锁软件订单和同产品权益。 - 发放前把 `status=active` 且 `grace_expires_at <= now` 的同产品历史权益 更新为 `expired`,再查询当前仍可用权益。 - 当前没有可用权益时,按订单快照创建一条新权益;不得续期已过期的旧权益。 - 当前存在同套餐可用权益时沿用现有续订逻辑。 - 当前存在其他套餐可用权益时继续拒绝入账,不实现升级、降级、差价、退款 或剩余天数折算。 ## 安全与兼容 - 不修改 API 路由、请求或响应结构,不新增数据库字段或迁移。 - 不修改 `RechargeOrder`、`UserWallet`、`PointsLedger` 或点数余额。 - 不修改微信验签、金额比对、交易号唯一约束和同订单幂等逻辑。 - 历史权益只做自然到期状态收敛,不删除权益、席位、事件或订单。 - 通用生成接口、产品专属生成接口和订阅强制模式保持不变。 ## 验收条件 1. 已超过宽限期但状态仍为 `active` 的旧套餐不阻止创建其他套餐订单。 2. 上述订单支付成功后创建目标套餐新权益,旧权益状态变为 `expired`。 3. 已超过宽限期的同套餐权益也创建新权益,不在旧权益上续期。 4. 仍在有效期或宽限期内的其他套餐在下单和支付入账两个阶段均继续返回 `plan_change_not_supported`。 5. 仍可用的同套餐权益继续正常续订,重复回调最多发放一次。 6. 支付失败或校验失败不提前修改旧权益状态。 7. `manage.py check`、目标测试、licensing 回归、迁移一致性、编译检查和 `git diff --check` 通过。 ## 状态 TODO。