关于 paddle-subscription-cancel
面向 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-cancel安装后在 Claude Code 中输入/paddle-subscription-cancel确认已加载。
详细介绍
从 Next.js 取消 Paddle 订阅
何时使用此技能
在已通过身份验证的用户账单或账户页面中构建“取消订阅”按钮(或同等功能)时,请使用此技能。内容涵盖 Next.js 15(App Router)Server Action 如何使用正确的选项调用 paddle.subscriptions.cancel()、每个取消操作都必须进行的安全检查、取消成功后该如何处理,以及 Paddle 的 canceled 状态与 scheduledChange 区块之间的关系。
这是订阅取消流程的_发起_端。请搭配以下内容使用:
subscription-sync:了解_接收_端——你的 webhook 处理程序会收到反映计划变更的subscription.updated事件;到订阅周期实际结束时,还会收到subscription.updated和subscription.canceled事件。- 如果你尚未设置 webhook 端点,请参阅
webhooks。
不需要自己构建取消按钮?还有两种替代方案:
- 客户门户深层链接。 使用
paddle.customerPortalSessions.create(customerId, [subscriptionId])创建门户会话,并重定向到session.urls.subscriptions[0].cancelSubscription(请参阅customer-portal)。取消界面由 Paddle 托管。 - Paddle Retain 取消流程。 如果已在 Paddle Retain 中设置取消流程,可以在浏览器中使用 Paddle.js 启动流程:
paddle.Retain.initCancellationFlow({ subscriptionId })。它会先显示挽留优惠;如果客户仍决定继续,Retain 会替你在 Paddle 中取消订阅。该 Promise 会以retained、aborted、chose_to_cancel或error状态解析。Retain 在沙盒环境中不可用,因此请在正式环境中测试此流程。
前置条件
- 有可正常使用的 Paddle 账户,且至少有一个有效订阅(可以使用沙盒环境)。
- 服务端可用的
PADDLE_API_KEY——此操作在 Server Action 中运行,绝不会在浏览器中运行。密钥需要具备subscription.write权限(用于cancel和update)。 - 有从 webhook 镜像而来的
customers表和subscriptions表(请参阅subscription-sync)。你需要知道已通过身份验证的用户对应哪个 Paddle 客户,以及他们要取消哪个订阅。 - 有一套身份验证系统。示例使用 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 函数名称。
paddle.subscriptions.cancel 的工作原理
const subscription = await paddle.subscriptions.cancel(subscriptionId, {
effectiveFrom: "next_billing_period", // or "immediately"
});
effectiveFrom 控制取消_何时_生效:
| 值 | 含义 | 适用场景 |
|---|---|---|
next_billing_period | 取消将在当前计费周期结束时安排生效。在此之前,状态保持不变(active、trialing 或 past_due);scheduledChange 会设置为 action: "cancel",且 nextBilledAt 会变为 null。 | 通用“取消订阅”按钮的默认选项——用户已为当前周期付费,应允许其继续使用。 |
immediately | 取消立即生效。Paddle 不会退款或返还抵扣额;对于自动收款的订阅,Paddle 会取消所有未付款的交易。 | 较少使用。若设为默认选项,会让用户感到意外。对于“取消并退款”,还需创建退款调整。 |
如果只希望用户能在周期结束时取消(这是常见的 UX),请传入 'next_billing_period',完全不要提供 'immediately' 选项。
paused 订阅是个例外:只能立即取消。传入 next_billing_period 会返回 400 bad_request,并在 effective_from 上报告错误,因此请根据状态进行分支处理。
完整的 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 these actions expect to see.
const CANCEL_ERRORS: Record<string, string> = {
subscription_locked_renewal: "Your subscription is about to renew. Try again after it renews.",
subscription_locked_pending_changes: "This subscription already has a scheduled change.",
};
export async function cancelSubscription(subscriptionId: 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. The check
// bridges through the customers table (matched on the user's email)
// and the subscriptions table (matched on customer_id).
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. Cancel via the Paddle Node SDK. Schedule for end-of-period (the safe
// default for a generic Cancel button — the user keeps service through
// what they've already paid for). Paused subscriptions can only be
// canceled immediately.
const effectiveFrom =
subRow.subscription_status === "paused" ? "immediately" : "next_billing_period";
try {
const paddle = getPaddleInstance();
const subscription = await paddle.subscriptions.cancel(subscriptionId, { effectiveFrom });
// 4. Refresh any cached UI that depends on subscription state.
revalidatePath("/dashboard/subscriptions");
// 5. Return a slim DTO. Don't return the raw Subscription object —
// it's large and leaks Paddle internal fields.
return {
success: true,
status: subscription.status,
scheduledChange: subscription.scheduledChange?.effectiveAt ?? null,
};
} catch (error) {
// 6. Turn Paddle API errors into a message the UI can show; rethrow anything else.
if (error instanceof ApiError) {
return { error: CANCEL_ERRORS[error.code] ?? "Couldn't cancel. Please try again." };
}
throw error;
}
}
撤销已安排的取消
在周期结束之前,用户可以改变主意。使用 subscriptions.update 移除已安排的更改:
// src/actions/subscription.ts (same file as cancelSubscription)
export async function undoCancelSubscription(subscriptionId: string) {
// 1–2. Run the same auth and ownership checks as cancelSubscription.
try {
const paddle = getPaddleInstance();
const subscription = await paddle.subscriptions.update(subscriptionId, {
scheduledChange: null,
});
revalidatePath("/dashboard/subscriptions");
return { success: true, status: subscription.status };
} catch (error) {
if (error instanceof ApiError) {
return { error: CANCEL_ERRORS[error.code] ?? "Couldn't undo. Please try again." };
}
throw error;
}
}
取消和撤销都受相同的 30 分钟锁定规则限制:距离下一次续订不到 30 分钟时,两者都会返回 409 subscription_locked_renewal。
用户看到的内容与数据库存储的内容
取消成功后,订阅的 status 不会立即变为 canceled。相关事件会随时间触发:
- 此刻(同步): SDK 调用返回。Paddle 保留当前的
status(active、trialing或past_due),将scheduledChange.action设为'cancel',将scheduledChange.effectiveAt设为'<period-end-date>',并将nextBilledAt设为null。 - 此刻(异步): Paddle 会发送包含相同载荷的
subscription.updatedwebhook。你的subscription-sync处理程序会对该行执行 upsert。镜像行保留其subscription_status,并将scheduled_change设为<date>。 - 周期结束时: Paddle 会同时发送
subscription.updated和subscription.canceled,二者都包含status: 'canceled'。不要依赖它们的到达顺序。镜像行的subscription_status会变为'canceled',并清除scheduled_change。
你的访问权限判定逻辑应读取_镜像_行,而不是读取此 Action 中乐观计算出的值。直到步骤 3 实际发生,用户都能继续访问。
常见陷阱
- 未检查归属关系。 信任操作输入中的
subscriptionId,会让任何已通过身份验证的用户都能取消其 ID 可以猜到或抓取到的任意订阅。检查时必须比较已通过身份验证的用户的customer_id与订阅的customer_id。 - 未检查身份验证状态。 这比上一种情况更糟——任何人(包括未通过身份验证的请求)都能取消任意订阅。Server Actions 可以从任何地方调用;绝不要假设“没有 UI = 没有访问权限”。
- 将
effectiveFrom: 'immediately'设为默认值。 用户按下的是“取消”,而不是“立即取消”。立即取消会立刻移除用户的访问权限,且不退款。除非你的 UX 明确询问用户“是否要立即取消?”,否则请使用'next_billing_period'。 - 把 SDK 的返回值当成新的永久状态。 取消调用会返回附加了计划变更的 当前 订阅。状态不会改变(
active、trialing或past_due)。如果你的 UI 显示“Subscription status: {return.status}”,这是正确的;但如果你乐观地显示“Canceled”,当页面从镜像数据刷新时,用户会看到相互矛盾的信息。 - 遗漏
revalidatePath。 读取数据库镜像数据的 Server Components 不会自行重新获取数据。取消成功后,请对受影响的路由调用revalidatePath(或revalidateTag)。否则,用户点击“取消”后,UI 会继续显示“active”,直到他们手动刷新。 - 返回原始
Subscription。 SDK 的Subscription实例包含约 30 个字段,包括 Paddle 内部元数据。客户端只需知道“是否成功”,以及可能的“实际结束时间”。请将其精简为 DTO。 - 混淆
canceled和scheduledChange.action: 'cancel'。canceled是周期结束后设置的 终止 状态。scheduledChange.action: 'cancel'表示“用户按下了取消,订阅即将结束”。只有前者应该撤销访问权限,不能对后者这样做。 - 尚无客户记录。 如果某个新用户(未完成结账)以某种方式触发了取消操作,就没有可供查询的
customer_id。请明确处理customerRow == null的情况——返回错误,而不是让null流入比较逻辑。
验证集成
- 登录你的开发应用并完成一次沙盒结账,以获得一个真实订阅。
- 等待
subscription.createdwebhook 将数据写入subscriptions表。 - 在 UI 中点击“取消”。服务端操作应当:
- 返回
{ success: true, status: 'active', scheduledChange: '<some date>' }。 - 触发
revalidatePath,以刷新 UI。
- 返回
- 在 Paddle 控制面板中,确认订阅显示“计划取消”,且日期符合预期。
- 点击“撤销”。确认操作返回
{ success: true },且订阅不再有计划变更。 - 在退出登录的情况下(或使用属于其他客户的伪造
subscriptionId)尝试执行操作。确认收到错误,且未调用paddle.subscriptions.cancel。 - 周期结束后,确认
subscription.updated和subscription.canceled事件会将镜像行的subscription_status: 'canceled'更新到位,无论哪个事件先到达。
相关文档
- 取消订阅
- 取消订阅 - API
- 创建调整项 - API(用于退款)
- Paddle.Retain.initCancellationFlow()
- 参考实现:paddle-nextjs-starter-kit — 参见
src/app/dashboard/subscriptions/actions.ts。