mcpField
P
paddle-subscription-cancel

paddle-subscription-cancel

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

关于 paddle-subscription-cancel

面向 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-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。相关事件会随时间触发:

  1. 此刻(同步): SDK 调用返回。Paddle 保留当前的 status(active、trialing 或 past_due),将 scheduledChange.action 设为 'cancel',将 scheduledChange.effectiveAt 设为 '<period-end-date>',并将 nextBilledAt 设为 null。
  2. 此刻(异步): Paddle 会发送包含相同载荷的 subscription.updated webhook。你的 subscription-sync 处理程序会对该行执行 upsert。镜像行保留其 subscription_status,并将 scheduled_change 设为 <date>。
  3. 周期结束时: 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 流入比较逻辑。

验证集成

  1. 登录你的开发应用并完成一次沙盒结账,以获得一个真实订阅。
  2. 等待 subscription.created webhook 将数据写入 subscriptions 表。
  3. 在 UI 中点击“取消”。服务端操作应当:
    • 返回 { success: true, status: 'active', scheduledChange: '<some date>' }。
    • 触发 revalidatePath,以刷新 UI。
  4. 在 Paddle 控制面板中,确认订阅显示“计划取消”,且日期符合预期。
  5. 点击“撤销”。确认操作返回 { success: true },且订阅不再有计划变更。
  6. 在退出登录的情况下(或使用属于其他客户的伪造 subscriptionId)尝试执行操作。确认收到错误,且未调用 paddle.subscriptions.cancel。
  7. 周期结束后,确认 subscription.updated 和 subscription.canceled 事件会将镜像行的 subscription_status: 'canceled' 更新到位,无论哪个事件先到达。

相关文档

评论

支持 Markdown