mcpField
P
paddle-customer-portal

paddle-customer-portal

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

关于 paddle-customer-portal

这项技能介绍如何在 Next.js 15 应用中接入 Paddle 托管的客户自助门户,面向需要订阅管理功能的开发者。内容涵盖经身份验证的 Server Action、临时门户 URL、安全检查及订阅深层链接。

主要功能

  1. 01配置 Next.js 15 Server Action
  2. 02经身份验证查询客户
  3. 03创建临时门户会话 URL
  4. 04生成订阅专属深层链接
  5. 05安全与错误处理说明

适用场景

  1. 01添加“管理订阅”按钮
  2. 02让客户查看发票和付款选项
  3. 03直接链接到取消或付款更新页面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-customer-portal

安装后在 Claude Code 中输入/paddle-customer-portal确认已加载。

详细介绍

从 Next.js 创建 Mint Paddle 客户门户会话 URL

何时使用此技能

当你希望 由 Paddle 托管 UI,让客户自行查看发票、更新付款方式、取消订阅等,而你只想让用户通过应用中的“管理订阅”按钮跳转到该托管 UI 时,请使用此技能。本技能介绍如何在 Next.js 15(App Router)中使用 Server Action 验证用户身份、查找其 Paddle 客户 ID、调用 paddle.customerPortalSessions.create,并返回用于重定向用户的 URL。

这是 门户 方案。另一种方案是直接使用 Paddle API 构建自定义账单页面——请参阅下文“是否应该使用门户?”了解两者的取舍。

是否应该使用门户?

Paddle 客户门户是让用户管理订阅的 一种选择。另一种选择是构建自己的账单 UI,直接调用 Paddle Node SDK。两种方式各有适用场景:

选项适用场景
Paddle 客户门户(本技能)你希望尽快实现“用户可以自行管理订阅”。Paddle 会提供 UI、处理本地化和布局,并随着功能推出持续更新。如果你已设置 Paddle Retain 取消流程,客户取消自动收款的订阅时,门户可以显示这些流程。适合早期产品、MVP,或任何账单功能并非差异化优势的产品。
自定义账单页面(参见 subscription-cancel、subscription-update,以及用于列出交易、下载发票等操作的直接 API 调用)你需要全面控制品牌呈现、与应用设计系统深度集成、自定义流程(应用内升级提示、取消流程中自定义的挽留逻辑、自定义发票展示),或与非账单 UI 紧密结合。Paddle Next.js starter kit 采用了这种方式——其仪表板以原生方式呈现订阅和交易,并使用 Server Action 执行取消和更新操作。

你也可以 混合使用:使用门户处理不常见的管理操作(下载发票、更改付款方式),并为高价值流程(升级,以及显示按比例计算费用的确认信息)构建自定义 UI。如果希望嵌入门户会话 URL,而不是将用户带到概览页面,它还提供了针对特定订阅操作的深层链接。

这个选择并非一成不变。常见的演进方式是:先上线门户,让客户尽快能够自助服务;之后随着特定流程对产品变得越来越重要,再逐步用自定义 UI 替换这些流程。

前提条件

  • 一个至少有一条客户记录的 Paddle 账户(沙盒环境也可以)。
  • 可在服务端使用的 PADDLE_API_KEY——此操作在 Server Action 中运行,绝不会在浏览器中运行。该密钥需要具备 customer_portal_session.write 权限。
  • 一个通过 Webhook 同步的 customers 表(请参阅 subscription-sync)。你需要用户的 Paddle customer_id 来创建会话,并通过邮箱关联进行查询。
  • 可选:一个 subscriptions 表——如果需要按订阅创建深层链接,请将该客户拥有的订阅 ID 传递给 SDK。
  • 一个身份验证系统。示例使用 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 入门套件中的客户端一致;如果你的项目已有等效的辅助函数,请改用它们。

// 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 函数名。

门户会话的工作方式

const session = await paddle.customerPortalSessions.create(
  customerId, // "ctm_01h..."
  subscriptionIds, // string[] — e.g. ["sub_01h...", "sub_02h..."], or [] for overview only
);

返回的 session 提供:

  • session.urls.general.overview — “打开我的门户”的主链接。 对于标准自助服务流程,应将用户重定向到此链接。用户会进入门户主页,并可从那里进行导航。
  • session.urls.subscriptions[] — 按订阅划分的深层链接数组,每个传入的 subscriptionId 对应一个条目:
    • id — 与输入的订阅 ID 相匹配。
    • cancelSubscription — 直接进入对应订阅取消界面的链接。
    • updateSubscriptionPaymentMethod — 直接进入对应订阅付款方式界面的链接。
    • view_subscription(API 字段)— 指向门户中该订阅页面的链接,不会预先打开任何表单。Node SDK(3.10.0)尚未提供此字段;如有需要,请直接调用 POST /customers/{customer_id}/portal-sessions。

subscriptionIds 最多可包含 25 个不重复的 ID,且每个 ID 都必须属于该客户,否则 API 会返回 400。若只需要概览链接,请传入空数组:门户仍可用于访问账户和发票,但不会提供按订阅划分的深层链接。

这些 URL 具有时效性,并会过期。请将其视为敏感信息:任何持有链接的人都能在链接过期前打开该客户的门户。不要缓存或存储这些 URL——每次用户点击“管理”时,都要创建一个新会话。

完整的 Server Action

// src/actions/portal.ts
"use server";

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";

export async function createPortalSession() {
  // 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. Look up the authenticated user's Paddle customer_id via the email
  //    bridge. The customers table is the mirror your webhook handler
  //    populates from customer.created / customer.updated events.
  const admin = await createAdminClient();
  const { data: customerRow } = await admin
    .from("customers")
    .select("customer_id")
    .eq("email", user.email)
    .single();

  if (!customerRow?.customer_id) {
    // The user signed up but hasn't completed a checkout yet, so no Paddle
    // customer record exists. Don't pass an empty string to the SDK — it
    // will 400. Either redirect them to a "subscribe first" page or return
    // a clear error the UI can render.
    return { error: "No Paddle customer" };
  }

  // 3. Mint the session. Notice the customerId comes from step 2 (the
  //    authenticated user's record), NOT from any input parameter — a user
  //    must never be able to request a portal session for someone else's ID.
  //    This action only needs the overview URL, so it passes no subscription
  //    IDs. For deep links, pass up to 25 IDs this customer owns.
  try {
    const paddle = getPaddleInstance();
    const session = await paddle.customerPortalSessions.create(customerRow.customer_id, []);

    // 4. Return ONLY the URL. Don't leak the raw session object — it includes
    //    the customer_id, session id, and full deep-link table that the client
    //    doesn't need for a "redirect to portal" flow.
    return { url: session.urls.general.overview };
  } catch (error) {
    // 5. Turn Paddle API errors into a message the UI can show; rethrow anything else.
    if (error instanceof ApiError) {
      console.error("Portal session failed", error.code, error.detail);
      return { error: "Couldn't open the billing portal. Please try again." };
    }
    throw error;
  }
}

调用该操作的客户端组件很简单:

// src/components/manage-subscription-button.tsx
"use client";
import { createPortalSession } from "@/actions/portal";

export function ManageSubscriptionButton() {
  return (
    <button
      onClick={async () => {
        const result = await createPortalSession();
        if ("error" in result) {
          // surface the error to the user
          return;
        }
        window.location.href = result.url;
      }}
    >
      Manage subscription
    </button>
  );
}

安全模型

有三点需要注意:

  1. 首先进行身份验证。 在任何数据库查询或 SDK 调用之前,操作必须检查会话。门户 URL 是访问客户账单数据的钥匙——为未经身份验证的请求创建 URL 是严重错误。
  2. customerId 必须来自已验证的用户,而不是输入。 操作不接受 customerId 参数(也不接受任何会影响为哪个客户创建门户的客户端提供的标识符)。customerId 会根据身份验证会话,通过 customers 表在服务端解析。这样可防止已验证的用户请求“ctm_01h-someone-else 的门户 URL”。
  3. 只返回 URL。 完整的 session 对象包含 customerId、会话 ID 和深层链接表。客户端只需要重定向 URL——只返回该 URL。

常见陷阱

  • 不检查身份验证。 Server Actions 可以从任何地方调用,包括匿名调用、脚本调用和直接 POST。若不进行身份验证,任何人都能生成门户 URL。
  • 信任操作输入中的 customerId。 任何客户端传入 customerId、操作直接使用该值的模式都是安全漏洞——已通过身份验证的用户可能会为任何他们知道 ID 的客户请求门户。
  • 缓存 URL。 门户链接会过期,因此缓存的链接最终会失效;在失效之前,任何拿到存储链接的人都能打开该客户的账单页面。每次点击都应生成新的会话。
  • 返回原始会话对象。 这会泄露客户端不需要的 customer_id、会话元数据和每个订阅的深层链接。只返回 urls.general.overview(如果确实需要用于嵌入的深层链接,则返回特定 URL——绝不要返回整个会话)。
  • 未处理没有客户的情况。 尚未结账的新用户没有 Paddle customer_id。向 customerPortalSessions.create 传入空字符串或 null 会返回 400,并附带令人困惑的错误。应明确检测客户信息缺失,并提示“请先订阅”。
  • 传入未经检查的订阅 ID。 ID 超过 25 个、存在重复项,或 ID 属于其他客户,都会导致整个请求失败并返回 400。只传入已通过身份验证的客户对应行中的 ID;若要获取概览链接,则不传 ID。
  • 混淆“打开门户”和“通过门户取消”。 urls.general.overview 是门户的_首页_——用户可以在其中进行门户支持的任何操作。如果想让用户_直接_进入取消界面(针对特定订阅),请改用 urls.subscriptions[i].cancelSubscription;如果要打开该订阅的页面但不显示表单,则使用 view_subscription。大多数应用只使用概览页面。
  • 该用自定义界面时却用了门户,或反之。 选择门户还是自定义界面是实际的产品决策(见上文“是否应该使用门户?”),而不只是图省事的编码捷径。如果想在取消时加入挽留步骤,设置好 Paddle Retain 取消流程后,门户可以显示这些流程(仅适用于自动收集的订阅);若要实现除此之外的挽留逻辑,则应自行构建。如果没有品牌或流程方面的要求,在有此类需求之前,门户是合适的选择。

验证集成

  1. 以拥有 Paddle 客户记录的已登录用户身份操作(即已完成沙盒结账的用户),点击“管理订阅”。该操作应返回 { url: '<some https url>' }。访问该 URL 会打开门户首页。
  2. 确认每次点击生成的 URL 都不同——urls.general.overview 的值每次都应不同。如果没有变化,说明你缓存了会话。
  3. 在已登出状态下尝试调用该操作。确认它返回 { error: 'Not authenticated' },且未调用 SDK。
  4. 以全新用户身份尝试调用该操作(未完成结账,且 customers 中没有对应行)。确认它返回 { error: 'No Paddle customer' },且未调用 SDK。
  5. (如果你为该操作添加了输入参数——不要这样做;但如果出于其他原因确实添加了——)确认该操作无法为已通过身份验证的用户不拥有的客户生成门户 URL。
  6. 如果你为深层链接传入订阅 ID,请直接检查 customerPortalSessions.create 的响应。确认 session.urls.subscriptions 中为你传入的每个 ID 都包含一项,其中有可用于其他位置深层链接的 cancelSubscription 和 updateSubscriptionPaymentMethod URL。

相关文档

评论

支持 Markdown