如何使用 Openpay subscription update API 更改套餐
结论
Openpay 的 subscription update API 不支持直接修改已有订阅的 plan_id,因此不能把同一个 subscription 从 monthly plan 原地切换到 yearly plan。
需要按以下流程处理:
- 结束旧订阅,或将其设置为在当前周期结束时取消。
- 使用 yearly plan 创建新的 subscription。
- 根据切换策略处理生效时间、重复扣款,以及未使用周期的退款或抵扣。
新订阅会生成新的 subscription_id,旧订阅的付款记录仍保留在原订阅下。
为什么不能直接更新
订阅创建后,plan_id 决定扣款金额、扣款周期和重试规则等核心属性。Openpay 的更新接口只能修改其明确支持的订阅属性,不能假定以下请求可以更换套餐:
{
"plan_id": "yearly-plan-id"
}
提交该字段后,接口可能忽略它,也可能返回参数错误。具体响应取决于使用的 Openpay API 版本,请以当前账户对应的官方 API 文档为准。
在当前周期结束后切换
升级或降级时,安排在当前周期结束后切换通常更稳妥,也能避免同一用户在一个周期内被重复扣款。
先将 monthly subscription 设置为在周期结束时取消:
curl --request PUT \
--user "${OPENPAY_PRIVATE_KEY}:" \
--header "Content-Type: application/json" \
--data '{
"cancel_at_period_end": true
}' \
"https://api.openpay.mx/v1/${MERCHANT_ID}/customers/${CUSTOMER_ID}/subscriptions/${SUBSCRIPTION_ID}"
业务系统还需要记录待切换的目标套餐:
{
"customer_id": "customer-id",
"current_subscription_id": "monthly-subscription-id",
"next_plan_id": "yearly-plan-id",
"switch_at": "current-period-end"
}
确认旧订阅结束后,再创建 yearly subscription:
curl --request POST \
--user "${OPENPAY_PRIVATE_KEY}:" \
--header "Content-Type: application/json" \
--data '{
"plan_id": "yearly-plan-id",
"source_id": "source-or-card-id"
}' \
"https://api.openpay.mx/v1/${MERCHANT_ID}/customers/${CUSTOMER_ID}/subscriptions"
是否必须提供 source_id,取决于客户是否已有可复用的支付方式,以及当前 Openpay 接口对此场景的要求。
创建成功后,保存接口返回的新 subscription_id:
{
"old_subscription_id": "monthly-subscription-id",
"new_subscription_id": "yearly-subscription-id",
"change_type": "upgrade"
}
立即切换套餐
如果 yearly plan 必须立即生效,可以先取消旧订阅,再马上创建新订阅:
curl --request DELETE \
--user "${OPENPAY_PRIVATE_KEY}:" \
"https://api.openpay.mx/v1/${MERCHANT_ID}/customers/${CUSTOMER_ID}/subscriptions/${SUBSCRIPTION_ID}"
随后创建 yearly subscription:
curl --request POST \
--user "${OPENPAY_PRIVATE_KEY}:" \
--header "Content-Type: application/json" \
--data '{
"plan_id": "yearly-plan-id",
"source_id": "source-or-card-id"
}' \
"https://api.openpay.mx/v1/${MERCHANT_ID}/customers/${CUSTOMER_ID}/subscriptions"
这样处理可能导致用户刚支付 monthly 费用,又立即被收取 yearly 费用。monthly plan 未使用部分是退款还是折算为余额,需要由业务系统计算并执行,不能默认 Openpay 会自动按比例结算。
后端实现示例
下面的 Node.js 示例演示了在当前周期结束后切换套餐的基本流程。字段和错误结构需要根据实际使用的 Openpay SDK 或 HTTP 客户端进行调整。
async function schedulePlanChange({
merchantId,
customerId,
subscriptionId,
newPlanId,
privateKey
}) {
const auth = Buffer.from(`${privateKey}:`).toString("base64");
const baseUrl = `https://api.openpay.mx/v1/${merchantId}`;
const response = await fetch(
`${baseUrl}/customers/${customerId}/subscriptions/${subscriptionId}`,
{
method: "PUT",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
cancel_at_period_end: true
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Unable to update subscription: ${error}`);
}
const subscription = await response.json();
// 将目标套餐和计划切换时间保存到业务数据库。
await savePendingPlanChange({
customerId,
oldSubscriptionId: subscriptionId,
newPlanId,
switchAt: subscription.current_period_end
});
return subscription;
}
确认旧订阅结束后,创建新订阅:
async function createReplacementSubscription({
merchantId,
customerId,
planId,
sourceId,
privateKey
}) {
const auth = Buffer.from(`${privateKey}:`).toString("base64");
const response = await fetch(
`https://api.openpay.mx/v1/${merchantId}/customers/${customerId}/subscriptions`,
{
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
plan_id: planId,
source_id: sourceId
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Unable to create subscription: ${error}`);
}
return response.json();
}
注意事项
- 需要 private key 的 Openpay API 不能在前端调用,套餐切换必须由服务端执行。
- monthly 和 yearly 应使用两个独立的 plan,不能通过更新订阅来修改原 plan 的周期或金额。
- 创建新订阅前,要确认旧订阅已经取消或处于预期状态,以免重复扣款。
- 升级和降级可以采用不同策略。升级通常可以立即生效,降级通常安排在当前周期结束后生效。
- 不能只根据前端回调判断订阅状态。应结合 Openpay 通知事件或服务端查询结果更新本地状态。
- Webhook 可能重复投递,因此创建新订阅的处理逻辑必须具备幂等性。
- 不要覆盖旧的
subscription_id。应保留新旧订阅之间的关联,便于退款、对账和审计。 cancel_at_period_end、周期结束时间字段和支付方式复用规则可能因 API 版本或地区产品而异。接入前应核对当前 Openpay 文档,并确认测试环境的实际响应。
备注:内容仅供参考。
