关于 paddle-subscription-update
面向 Next.js 15 App Router 开发者的技能,指导在已登录用户的账单界面更改 Paddle 订阅计划。涵盖按比例计费选项、项目替换语义、状态限制、安全校验及更新预览。
主要功能
- 01通过服务端操作更改订阅计划
- 02选择五种按比例计费模式
- 03按完整项目列表替换订阅项目
- 04按订阅状态处理计划更新
- 05预览费用与后续价格
适用场景
- 01实现升级或降级计划
- 02让用户切换订阅计费周期
- 03确认计划变更的当前费用和续费价格
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ paddlehq/ paddle-agent-skills --skill paddle-subscription-update安装后在 Claude Code 中输入/paddle-subscription-update确认已加载。
详细介绍
在 Next.js 中更新 Paddle 订阅的方案
何时使用此技能
在已认证用户的账单界面中构建“升级方案”“切换层级”或“更改订阅”操作时,请使用此技能。它涵盖调用 paddle.subscriptions.update() 的 Next.js 15(App Router)Server Action、五种 prorationBillingMode 选项及其适用场景、items 数组采用_替换而非追加_的语义,以及每个方案变更操作所需的安全检查。
这是发起订阅变更的一方。请搭配使用:
subscription-sync——变更后,Webhook 处理程序会收到subscription.updated事件。数据库镜像会同步更新方案、状态和价格。subscription-cancel——使用相同的身份验证和所有权检查方式;如果你同时构建这两项功能,请复用辅助函数。
前置条件
- Paddle 账户中至少有两个可供用户切换的价格(例如月付 Starter 和月付 Pro)。Sandbox 环境也可以。
- 服务端可用
PADDLE_API_KEY——仅限 Server Action 使用。该密钥需要subscription.write权限(用于update和activate)。previewUpdate只需subscription.read权限,而write已包含该权限。 - 通过 Webhook 镜像的客户和订阅状态(参见
subscription-sync)。 - 身份验证系统。示例使用 Supabase。
NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_...
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=... # or SUPABASE_SECRET_KEY (new opaque sb_secret_*)
Supabase 辅助函数
此操作使用两个服务端 Supabase 客户端。这些客户端与 Paddle Next.js starter kit 一致;如果你的项目已有等效的辅助函数,请改用它们。
// src/utils/supabase/server.ts — the signed-in user's session (anon key + cookies)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component. Safe to ignore if middleware refreshes sessions.
}
},
},
},
);
}
// src/utils/supabase/server-internal.ts — service role, no user session. Server-only.
import { createServerClient } from "@supabase/ssr";
export async function createClient() {
// No cookies: with the signed-in user's session attached, requests would run
// as that user and row-level security (RLS) would still apply.
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } },
);
}
服务角色客户端会绕过 RLS,因此只能在服务端、确认用户身份后使用。它会刻意跳过 Cookie,因此不会获取已登录用户的会话。如果你自行编写辅助函数,请不要将封装函数命名为 createServerClient——这是它调用的 @supabase/ssr 函数名称。
选择你的 prorationBillingMode
这是最容易选错的参数。Paddle 的 subscriptions.update 接受五个值;对于典型的活跃订阅「升级到 Pro」流程,正确的选择是 prorated_immediately。
| 值 | 会发生什么 | 适用场景 |
|---|---|---|
prorated_immediately | 立即收取新旧套餐之间按比例计算的差额(降级时则会产生抵扣)。套餐立即生效。 | 「升级套餐」——用户期望立即使用新套餐并支付费用。 |
prorated_next_billing_period | 计算按比例分摊的金额,但在下次续订时收取。套餐立即切换。 | 「切换套餐,但稍后再收费」——不常见,但合理。 |
full_immediately | 立即收取新套餐的全额价格。套餐立即切换。 | 不常用。会让用户为部分计费周期支付过高费用。 |
full_next_billing_period | 新增项目在续订时按全额收费;移除的项目不予抵扣。套餐立即切换。 | 不常见。这并不是新套餐的免费试用——只是将全额费用延后收取。 |
do_not_bill | 立即切换套餐。不收取任何费用。 | trialing 和 paused 订阅必须使用。其他情况下,适用于内部/团队套餐升级和客服补偿,不适用于自助服务体验。 |
没有默认值。你必须传入这五个值之一。无论选择哪一个,新项目都会立即替换旧项目——该模式只决定收取什么费用以及何时收取。
升级、降级还是更改订阅期限?
subscriptions.update 是一个端点,涵盖多种用户可见的流程。应根据具体流程选择合适的 prorationBillingMode:
| 流程 | 示例 | 典型的 prorationBillingMode |
|---|---|---|
| 套餐等级升级 | Starter → Pro(期限相同) | prorated_immediately — 用户希望立即享受更好的套餐,并立即支付差价。 |
| 套餐等级降级 | Pro → Starter(期限相同) | prorated_immediately 或 prorated_next_billing_period。两种情况下,Starter 都会立即取代 Pro。未使用的 Pro 时长会变成抵扣额,不会退还到银行卡——立即抵扣(超出部分计入客户的余额),或在下次续订时抵扣。 |
| 更改期限(延长) | Pro 月付 → Pro 年付 | prorated_immediately — 年付金额高得多,因此立即扣款才合适。 |
| 更改期限(缩短) | Pro 年付 → Pro 月付 | prorated_immediately — 更改计费周期不支持 *_next_billing_period。未使用的年付时长会抵扣新套餐费用,超出部分计入客户余额,不会退还到银行卡。 |
| 试用期间更改套餐 | Starter 试用 → Pro 试用 | do_not_bill — trialing 订阅更改项目时唯一接受的模式,付费试用也包括在内。若要立即收费,请先使用 paddle.subscriptions.activate(subscriptionId) 结束试用。 |
项目的计费周期必须相同
订阅中的所有项目都必须使用相同的计费周期。因此,如果用户订阅了月付 Pro 套餐,外加一个月付附加项,并切换到年付 Pro,附加项也必须同时改为年付,不能保留月付。实际操作中:
- 如果你销售套餐等级和附加项,更新时的
items数组必须包含与新期限匹配的附加项价格 ID。 - 如果你只销售套餐等级,则不受影响。
这是你提交给 SDK 的内容必须满足的限制,不只是 UX 方面的考虑。API 会拒绝包含混合计费周期的 items 数组。
更改计费周期还有两条规则:必须替换所有项目;prorationBillingMode 必须是 prorated_immediately、full_immediately 或 do_not_bill(仅在试用期间可使用 do_not_bill)。违反任一规则都会返回 subscription_new_items_not_valid;错误中的 errors 数组会指出违反了哪条规则。
检查订阅状态
subscriptions.update 接受的内容取决于订阅当前的 status:
| 状态 | update 接受的内容 |
|---|---|
active | 本技能中的所有内容。 |
trialing | 仅接受带有 do_not_bill 的项目变更,包括付费试用(否则会返回 subscription_trialing_items_update_invalid_options)。若要结束试用并立即扣款,请先调用 paddle.subscriptions.activate(subscriptionId);此操作要求使用自动收款方式,并且已保存付款方式。 |
past_due | 仅接受 { scheduledChange: null },用于移除计划变更。其他任何内容都会返回 subscription_update_when_past_due——请先让用户更新付款方式。 |
paused | 仅接受带有 do_not_bill 的项目变更。 |
canceled | 不接受任何内容(subscription_update_when_canceled)。用户需要重新结账。 |
另请考虑:
- 续订锁定。 距离下次续订不到 30 分钟时,更新会返回 409
subscription_locked_renewal。请让用户在续订后重试。 - 速率限制。 会立即扣款的变更可能返回 429。请稍后再重试。
- 计划变更。 如果订阅已有计划取消、暂停或恢复,
*_next_billing_period模式会失败,并返回subscription_invalid_billing_mode_for_scheduled_change。
提交前预览
对于复杂流程——尤其是变更期限、按比例计算的金额不明确,或需要请用户确认的情况——请先展示预览,再提交。Paddle 的 subscriptions.previewUpdate 接受与 update 相同的请求正文,但不会应用任何变更:
const preview = await paddle.subscriptions.previewUpdate(subscriptionId, {
items: [{ priceId: newPriceId, quantity: 1 }],
prorationBillingMode: "prorated_immediately",
});
// preview.immediateTransaction — the prorated charge details (if any)
// preview.recurringTransactionDetails — the new ongoing price
// preview.nextBilledAt — when the next renewal will hit
// preview.updateSummary — credit, charge, and the net result of the change
在确认页面上展示这些金额(“您今天将支付 $X,之后从 起每年支付 $Y。”)。如果只需一句话确认,请使用 preview.updateSummary.result:action 为 "charge" 或 "credit",amount 是以 currencyCode 表示的净金额(字符串,单位为该货币的最小单位)。用户确认后,使用相同的请求正文调用 subscriptions.update。不要尝试自行计算按比例分摊的金额——预览才是准确依据,其计算会考虑税费、货币以及您可能不了解的现有余额。
对于简单的“升级到 Pro”流程,如果价格差异较小且一目了然,可以跳过预览,并相信用户能根据按钮文案了解自己同意的内容。其他情况下,预览能帮您省下后续支持工单。
paddle.subscriptions.update 如何处理项目
更新请求中的 items 字段会完全替换订阅的明细项目,而不是追加:
// BEFORE: subscription has items: [{ priceId: 'pri_starter_monthly', quantity: 1 }]
await paddle.subscriptions.update(subscriptionId, {
items: [{ priceId: "pri_pro_monthly", quantity: 1 }],
prorationBillingMode: "prorated_immediately",
});
// AFTER: subscription has items: [{ priceId: 'pri_pro_monthly', quantity: 1 }]
// the Starter item is gone — replaced by Pro.
对于单项目方案变更,这正是你想要的。不要获取现有项目并将新项目拼接进去,否则订阅将同时按两个方案计费。
对于多项目订阅(基础方案 + 附加项),你需要考虑要保留哪些项目。此时,获取当前订阅,谨慎修改 items 数组,然后将修改后的数组传回。
完整的 Server Action
// src/actions/subscription.ts
"use server";
import { revalidatePath } from "next/cache";
import { ApiError } from "@paddle/paddle-node-sdk";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { createClient } from "@/utils/supabase/server";
import { createClient as createAdminClient } from "@/utils/supabase/server-internal";
// Messages for the Paddle API errors this action expects to see.
const UPDATE_ERRORS: Record<string, string> = {
subscription_payment_declined: "Your payment was declined. Update your payment method and retry.",
subscription_locked_renewal: "Your subscription is about to renew. Try again after it renews.",
subscription_update_when_past_due: "Update your payment method before changing plan.",
};
export async function updateSubscription(subscriptionId: string, newPriceId: string) {
// 1. Authenticate. Reject anonymous requests before any DB query or SDK call.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !user.email) {
return { error: "Not authenticated" };
}
// 2. Verify the authenticated user owns this subscriptionId. Same email-bridge
// pattern as subscription-cancel.
const admin = await createAdminClient();
const { data: customerRow } = await admin
.from("customers")
.select("customer_id")
.eq("email", user.email)
.single();
if (!customerRow) return { error: "No Paddle customer" };
const { data: subRow } = await admin
.from("subscriptions")
.select("customer_id, subscription_status")
.eq("subscription_id", subscriptionId)
.single();
if (!subRow || subRow.customer_id !== customerRow.customer_id) {
return { error: "Forbidden" };
}
// 3. Apply the plan change. Replace items with the single new item. Trialing
// subscriptions (paid trials too) only accept `do_not_bill`; otherwise bill
// the prorated difference now (the right call for an "Upgrade" button).
// The default behaviour on payment failure is `prevent_change` — the
// upgrade only applies if the prorated charge succeeds. That's the safe
// choice for self-serve. See "Handling payment failure" below for when
// 'apply_change' is the right override.
const prorationBillingMode =
subRow.subscription_status === "trialing" ? "do_not_bill" : "prorated_immediately";
try {
const paddle = getPaddleInstance();
const subscription = await paddle.subscriptions.update(subscriptionId, {
items: [{ priceId: newPriceId, quantity: 1 }],
prorationBillingMode,
});
// 4. Refresh cached UI.
revalidatePath("/dashboard/subscriptions");
// 5. Slim DTO — don't leak the raw Subscription.
return {
success: true,
priceId: subscription.items[0]?.price?.id ?? null,
status: subscription.status,
};
} catch (error) {
// 6. Turn Paddle API errors into a message the UI can show; rethrow anything else.
if (error instanceof ApiError) {
return { error: UPDATE_ERRORS[error.code] ?? "Couldn't change your plan. Please try again." };
}
throw error;
}
}
用户看到的内容与实际计费内容
prorated_immediately 升级会触发 Paddle:
- 根据新旧方案之间的按比例计费差额生成一笔交易,并从客户现有的付款方式中扣款。
- 发送
transaction.created(如果扣款成功,随后发送transaction.completed;如果失败,则发送transaction.payment_failed)。 - 发送
subscription.updated,反映新的项目以及针对新方案重新计算的next_billed_at。
如果按比例计费的扣款失败(卡片被拒或付款方式已过期),具体后果取决于 onPaymentFailure——请参阅下一节。
处理付款失败
选择即时按比例计费模式(prorated_immediately 或 full_immediately)时,Paddle 会在更新调用期间同步尝试向客户扣款。如果扣款失败,onPaymentFailure 字段将决定方案变更是否仍会生效:
| 值 | 付款失败时的处理方式 |
|---|---|
prevent_change(默认值) | 不会应用方案变更。订阅仍保留旧方案。API 会返回 subscription_payment_declined,以便 UI 显示明确的“付款失败,请重试”状态。 |
apply_change | 仍然会应用方案变更。订阅保持 active;只有交易会变为 past_due,这样你就可以稍后收取按比例计费的金额。_订阅本身_不会进入催收流程——因为用户已明确接受这一结果。 |
请根据具体流程选择:
- 对于大多数自助服务 UX,默认的
prevent_change都很合适:用户点击升级,卡片扣款失败,随后看到“付款失败”,便可更新卡片并重试。每一步都由用户明确操作。无需显式设置此选项——省略它即可使用这一行为。 apply_change适用于由管理员驱动的流程(例如,客户成功代表为付款卡暂时无法使用的客户升级),或“先切换等级,之后再收款”的模式。客户会立即获得新方案;你可以异步收取逾期交易。选择此选项前,务必考虑你的催收流程——实际上,这相当于向客户提供了赊账。
对于由管理员驱动的情况,请显式设置 onPaymentFailure:
// Admin-driven upgrade: apply the change even if the card fails. The customer
// gets the new plan; the prorated charge sits in past_due until you collect.
await paddle.subscriptions.update(subscriptionId, {
items: [{ priceId: newPriceId, quantity: 1 }],
prorationBillingMode: "prorated_immediately",
onPaymentFailure: "apply_change",
});
Node SDK 使用驼峰命名法(onPaymentFailure);请求传输格式使用 on_payment_failure。
常见陷阱
prorationBillingMode错误。 默认值表是此技能的核心。对于有效订阅的自助升级,使用do_not_bill会造成计费核算漏洞;full_immediately会过度收费;prorated_next_billing_period则会让原本预期会立即扣款的用户感到意外。对于有效订阅的“升级到 Pro”体验,请使用prorated_immediately。试用中和已暂停的订阅是例外:它们只接受do_not_bill。- 追加而非替换项目。 从镜像中获取
subscription.items,拼接新项目,然后发送合并后的数组,会导致订阅按两个方案计费。该端点会替换项目——只传入你想要的项目。 - 未检查所有权。 与
subscription-cancel相同:信任subscriptionId输入会让任何已通过身份验证的用户都能升级任意订阅的方案。 - 未检查身份验证。 Server Actions 可以从任何地方调用——匿名调用、脚本调用或通过直接 POST 调用。始终验证会话。
- 使用
apply_change时,根据操作返回结果乐观更新 UI。 使用默认的prevent_change时,立即模式返回{ success: true }表示按比例计算的费用已成功扣款。使用apply_change时,它只表示方案已更改——费用可能仍处于past_due状态。授予依赖付款成功的功能前,请等待transaction.completed(通过 webhook 接收)。 - 遗漏
revalidatePath。 UI 会一直显示旧方案,直到手动刷新。 - 返回原始
Subscription。 与取消操作相同——SDK 实例很大,应精简后再返回。 - 对复杂变更跳过预览步骤。 尤其是变更计费周期时,按比例计算的结果并不直观——Paddle 可能会抵扣现有余额、应用税费或转换货币,而这些情况都可能与你的粗略估算不同。在用户确认前,展示包含
previewUpdate实际数据的确认页面,可以避免收到“等等,为什么扣了这么多?”之类的工单。只有价格影响很小且一目了然时才跳过预览。 - 不知道存在
onPaymentFailure。 默认值(prevent_change)适用于自助服务,无需覆盖。陷阱在于 没有意识到该选项的存在,尤其是在需要使用apply_change的流程中——例如,客户成功代表要为银行卡暂时扣款失败的客户升级方案。如果不知道这个字段存在,你就会用更笨拙的方式绕过限制。 - 项目的计费周期混用。 如果你销售套餐加附加组件,并允许用户在月付和年付之间切换,附加组件也必须随套餐一起切换。提交
[{ priceId: 'pri_pro_annual' }, { priceId: 'pri_addon_monthly' }]会失败——Paddle 会拒绝计费周期混用的items数组。设计“切换计费周期”流程时,应规划好如何随套餐一起替换附加组件的价格 ID。 - 以为降级会退款。 没有任何模式会向银行卡退款。旧方案未使用的时长会转为余额:使用
prorated_immediately时立即抵扣(超出部分会进入客户的余额);使用prorated_next_billing_period时则在续费时抵扣。并且在所有模式下,项目都会立即切换:*_next_billing_period延后的是扣款,而非方案变更。
验证集成
- 使用较便宜的两个套餐之一订阅沙盒套餐(例如 Starter 月付)。
- 使用升级请求体调用
paddle.subscriptions.previewUpdate。确认返回的数字(即时交易、定期付款详情、下次计费时间、更新摘要)符合预期。 - 点击“升级到 Pro”。该操作应返回
{ success: true, priceId: 'pri_pro_monthly', status: 'active' }。 - 在 Paddle 控制面板中:
- 订阅项目现在只有 Pro(Starter 已移除)。
- 会出现一笔新的交易,金额为按比例计算的差额,且与步骤 2 中的预览一致。
subscription.updatedwebhook 触发后,检查数据库镜像:该行的price_id和product_id已更新。- 将沙盒银行卡换成一张会拒绝付款的测试卡,然后重试升级。将
onPaymentFailure: 'prevent_change'设为该值时,确认订阅仍保留旧套餐,且操作返回付款遭拒错误。将其切换为onPaymentFailure: 'apply_change',并确认订阅切换到新套餐、保持active状态,且出现一笔past_due交易。 - 对处于试用期的订阅,确认操作发送
do_not_bill,且变更生效时不会产生费用。 - 使用你实际并未提供的
newPriceId尝试执行该操作。确认 Paddle 拒绝该请求(或者更好的是,在调用 SDK 前,操作会根据你的PricingTier常量验证newPriceId)。 - 在未登录时,或使用伪造的、属于其他用户的
subscriptionId执行该操作。确认这两种情况都会在调用 SDK 前被拒绝。