mcpField
P
paddle-subscription-update

paddle-subscription-update

github.com/paddlehq开发9105 次安装6 天前更新
代码仓库

关于 paddle-subscription-update

面向 Next.js 15 App Router 开发者的技能,指导在已登录用户的账单界面更改 Paddle 订阅计划。涵盖按比例计费选项、项目替换语义、状态限制、安全校验及更新预览。

主要功能

  1. 01通过服务端操作更改订阅计划
  2. 02选择五种按比例计费模式
  3. 03按完整项目列表替换订阅项目
  4. 04按订阅状态处理计划更新
  5. 05预览费用与后续价格

适用场景

  1. 01实现升级或降级计划
  2. 02让用户切换订阅计费周期
  3. 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:

  1. 根据新旧方案之间的按比例计费差额生成一笔交易,并从客户现有的付款方式中扣款。
  2. 发送 transaction.created(如果扣款成功,随后发送 transaction.completed;如果失败,则发送 transaction.payment_failed)。
  3. 发送 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 延后的是扣款,而非方案变更。

验证集成

  1. 使用较便宜的两个套餐之一订阅沙盒套餐(例如 Starter 月付)。
  2. 使用升级请求体调用 paddle.subscriptions.previewUpdate。确认返回的数字(即时交易、定期付款详情、下次计费时间、更新摘要)符合预期。
  3. 点击“升级到 Pro”。该操作应返回 { success: true, priceId: 'pri_pro_monthly', status: 'active' }。
  4. 在 Paddle 控制面板中:
    • 订阅项目现在只有 Pro(Starter 已移除)。
    • 会出现一笔新的交易,金额为按比例计算的差额,且与步骤 2 中的预览一致。
  5. subscription.updated webhook 触发后,检查数据库镜像:该行的 price_id 和 product_id 已更新。
  6. 将沙盒银行卡换成一张会拒绝付款的测试卡,然后重试升级。将 onPaymentFailure: 'prevent_change' 设为该值时,确认订阅仍保留旧套餐,且操作返回付款遭拒错误。将其切换为 onPaymentFailure: 'apply_change',并确认订阅切换到新套餐、保持 active 状态,且出现一笔 past_due 交易。
  7. 对处于试用期的订阅,确认操作发送 do_not_bill,且变更生效时不会产生费用。
  8. 使用你实际并未提供的 newPriceId 尝试执行该操作。确认 Paddle 拒绝该请求(或者更好的是,在调用 SDK 前,操作会根据你的 PricingTier 常量验证 newPriceId)。
  9. 在未登录时,或使用伪造的、属于其他用户的 subscriptionId 执行该操作。确认这两种情况都会在调用 SDK 前被拒绝。

相关文档

评论

支持 Markdown