mcpField
P
paddle-billing-history

paddle-billing-history

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

关于 paddle-billing-history

面向 Next.js 15 App Router 开发者的技能,指导在账户页构建 Paddle 账单历史。涵盖安全读取交易、游标分页、状态筛选、金额格式化及按需获取发票 PDF。

主要功能

  1. 01服务端读取客户交易
  2. 02游标分页与状态筛选
  3. 03本地化格式化交易金额
  4. 04按需获取发票 PDF
  5. 05校验用户与客户归属

适用场景

  1. 01在账户页添加账单历史
  2. 02为交易列表实现加载更多
  3. 03向用户提供发票 PDF 下载

安装

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

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-billing-history

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

详细介绍

在 Next.js 中呈现用户的账单历史

何时使用此技能

当你要在已通过身份验证的用户账户页面中添加“账单历史”或“发票”版块时,请使用此技能。用户希望查看以往的交易及其日期、金额,并能够下载发票。此技能介绍了一个 Next.js 15(App Router)Server Action,可列出已通过身份验证的客户的交易,并正确处理分页、状态筛选和货币显示。

这是交易数据的_读取_端。请搭配以下技能使用:

  • subscription-sync — 提供 customers 表,你可以通过联结该表找到用户的 Paddle customer_id。
  • webhooks — 根据 customer.created 事件填充 customers 表。
  • customer-portal — 如果你希望 Paddle 托管整个账单界面,则可使用此替代方案(见下方的“应该自行构建吗?”)。

应该自行构建吗?

Paddle 客户门户已提供托管的账单历史视图(见 customer-portal)。在以下情况下自行构建:

  • 你希望账单界面位于自己的控制面板中,并与导航和样式保持一致。
  • 你希望将账单数据与应用专属上下文结合起来(例如用量详情、自定义发票字段)。
  • 你希望控制显示的内容——门户会显示所有内容;自定义视图则可以省略、分组或更改面向用户的交易标签。

如果以上情况都不适用,请将用户引导至门户,并跳过此技能。

前提条件

  • 一个至少有一位拥有交易的客户的 Paddle 账户(使用 sandbox 也可以)。
  • 具有 transaction.read 权限的服务端 PADDLE_API_KEY,该权限涵盖列出交易和获取发票 PDF。这些操作会以 Server Action 的形式运行,绝不会在浏览器中运行。
  • 一个通过 webhook 同步的 customers 表(见 subscription-sync)。你将通过电子邮件关联在此查找已通过身份验证的用户的 Paddle customer_id。
  • 一个身份验证系统。示例使用 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 辅助函数

示例导入了两个服务端辅助函数,与 Paddle Next.js starter kit 一致。如果项目已有等效函数,请使用这些函数。如果要自行编写,请不要将封装函数命名为 createServerClient——它封装的 @supabase/ssr 函数就叫这个名称。

// 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: () => {} } },
  );
}

以 { createClient as createAdminClient } 的形式导入。服务角色客户端会绕过 RLS,因此只能在服务端、确认用户身份后使用。它会有意跳过 cookies,因此永远不会获取已登录用户的会话。

paddle.transactions.list 的工作方式

const collection = paddle.transactions.list({
  customerId: ["ctm_01h..."], // REQUIRED — without this, returns ALL transactions
  status: ["billed", "paid", "past_due", "completed", "canceled"],
  perPage: 10, // default and maximum: 30
  after: undefined, // cursor for "load more"
});

const items = await collection.next(); // one page
const more = collection.hasMore; // boolean — drive "Load more" from this
const total = collection.estimatedTotal; // exact up to 100,000; 100001 above that; -1 if not counted

需要牢记以下几点:

  • paddle.transactions.list(...) 返回的是 TransactionCollection,而不是数组。 调用 .next() 才能获取一页数据。若将该集合当作数组处理(或假设它已包含全部数据),将会什么也得不到,或者得到过时的数据。
  • 实际上,customerId 筛选条件是必需的。 API 并不_要求_传入它(你可以不传并列出账户中的所有交易——这对管理界面很有用),但对于面向_客户_的页面,省略它会将每位客户的历史记录泄露给任何已登录用户。务必传入它。
  • 状态筛选器:字符串字面量。 在 Node SDK 中,TransactionStatus 是仅用于类型检查的联合类型,并非包含成员的枚举——请传入 "completed" 这样的字符串字面量;如果需要,也可将其标注为 TransactionStatus[]。下文会介绍应包含哪些状态。
  • 分页基于游标。 每次调用 .next() 都会推进游标。如果要支持“加载更多”,请提供游标(最后一项的 after,或使用 SDK 的内置处理方式)并重新发起请求。应使用 hasMore,而非 estimatedTotal,判断是否还有下一页——estimatedTotal 的上限为 100001,也可能是 -1。

完整的 Server Action

// src/actions/billing-history.ts
"use server";

import { headers } from "next/headers";
import { ApiError, type TransactionStatus } 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";
import { getDecimalPlaces, parseMoney } from "@/utils/parse-money";

export type BillingHistoryItem = {
  id: string;
  billedAt: string | null;
  status: TransactionStatus;
  amount: string; // formatted grandTotal: what was charged, after credits
  tax: string; // formatted grandTotalTax: the tax within amount
  invoiceAvailable: boolean;
};

const VISIBLE_STATUSES: TransactionStatus[] = [
  "billed",
  "paid",
  "past_due",
  "completed",
  "canceled",
];

// Resolve the signed-in user's Paddle customer ID server-side. Never accept it
// from the client.
async function getSignedInCustomerId() {
  // 1. Authenticate. Reject anonymous requests before any DB or SDK call.
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user?.email) return { error: "Not authenticated" } as const;

  // 2. Look up the Paddle customer_id via the email bridge. No row means no
  //    Paddle customer yet — not an error, just nothing to show.
  const admin = await createAdminClient();
  const { data: customerRow } = await admin
    .from("customers")
    .select("customer_id")
    .eq("email", user.email)
    .single();

  return { customerId: (customerRow?.customer_id as string | undefined) ?? null };
}

export async function getBillingHistory(after?: string) {
  const customer = await getSignedInCustomerId();
  if ("error" in customer) return { error: customer.error };
  if (!customer.customerId) return { items: [], hasMore: false };

  // 3. Format for the user's locale. This runs on the server, where
  //    `navigator` (Node 21+) reports the server's locale, so read the
  //    browser's Accept-Language from the request instead.
  const format = {
    locale: (await headers()).get("accept-language")?.split(/[,;]/)[0] || "en-US",
    decimalPlaces: await getDecimalPlaces(),
  };

  try {
    // 4. List transactions, scoped to the authenticated customer. The
    //    customerId filter is the security guarantee — without it, the SDK
    //    returns every customer's transactions in your account.
    const collection = getPaddleInstance().transactions.list({
      customerId: [customer.customerId],
      status: VISIBLE_STATUSES,
      perPage: 10,
      after,
    });

    // 5. Fetch one page. Don't loop over hasMore — the UI will call this
    //    again with a cursor for "Load more."
    const transactions = await collection.next();

    // 6. Slim each transaction down to what the UI renders. Totals are
    //    strings in lowest currency units with no pre-formatted version, so
    //    format them here (see "Display: format the raw amount" below).
    const items: BillingHistoryItem[] = transactions.map((t) => {
      const totals = t.details?.totals;
      return {
        id: t.id,
        billedAt: t.billedAt,
        status: t.status,
        amount: parseMoney(totals?.grandTotal, t.currencyCode, format),
        tax: parseMoney(totals?.grandTotalTax, t.currencyCode, format),
        // Invoice PDFs exist for completed transactions, and for billed
        // manually-collected ones, but never for zero-value transactions.
        invoiceAvailable:
          Number(totals?.subtotal) > 0 &&
          (t.status === "completed" || (t.status === "billed" && t.collectionMode === "manual")),
      };
    });

    return { items, hasMore: collection.hasMore };
  } catch (error) {
    if (error instanceof ApiError) return { error: error.code };
    throw error;
  }
}

发票 PDF — 按需获取

不要将发票 URL 放入列表 DTO 中。每个 URL 都会产生一次 API 调用,而且一小时后就会过期。单独提供一个操作,让 UI 在用户点击 发票 时调用;获取发票前,请检查该交易是否属于已登录的客户——ID 来自客户端。

// src/actions/billing-history.ts (continued)
export async function getInvoiceUrl(transactionId: string) {
  const customer = await getSignedInCustomerId();
  if ("error" in customer) return { error: customer.error };
  if (!customer.customerId) return { error: "No Paddle customer" };

  try {
    const paddle = getPaddleInstance();
    const transaction = await paddle.transactions.get(transactionId);
    if (transaction.customerId !== customer.customerId) return { error: "Forbidden" };

    const { url } = await paddle.transactions.getInvoicePDF(transactionId, {
      disposition: "attachment", // or "inline" to open the PDF in the browser
    });
    return { url }; // valid for 1 hour
  } catch (error) {
    if (error instanceof ApiError) return { error: error.code };
    throw error;
  }
}

以下交易可获取发票 PDF:

  • 自动收款的交易(结账和订阅续费)在状态变为 completed 后可获取。
  • 手动收款的交易(发票)在状态变为 billed 或 completed 后可获取。

零金额交易永远无法获取发票,例如开启免费试用的 $0 交易。

状态筛选 — 应包含哪些状态

paddle.transactions.list 接受一个 status: TransactionStatus[] 筛选器,其中的值为字符串字面量。账单历史视图通常会传入以下值:

状态是否包含在账单历史中?含义
billed是已开具发票,但尚未付款。已开票的交易有发票编号,且属于法律记录。手动收款的发票通常处于此状态。
paid是已全额付款,但尚未由 Paddle 处理。
completed是已全额付款并完成处理。
past_due是付款已逾期:自动收款失败且订阅处于催款流程中,或发票在到期日后 24 小时仍未付款。应显示此状态,以便用户更新付款方式。
canceled是已取消。如果这是发票,则不再需要付款。
draft否缺少必填字段——通常是尚未收集客户详细信息的结账记录。
ready否所需信息齐全,但尚未开票或付款。

重要规则是排除 draft 和 ready——它们代表客户不应看到的部分状态。

如果完全省略 status 筛选器,API 会返回所有状态,包括 draft / ready。不要这样做。

显示:自行格式化原始金额

与 Paddle.js 的 PricePreview(在 formattedTotals.total 中提供预格式化字符串)不同,Node SDK 中的 Transaction 实体不会提供预格式化字符串——它只提供以最小货币单位表示的原始金额和货币代码:

transaction.details?.totals?.grandTotal; // "3000"  (string, lowest unit)
transaction.currencyCode; // "USD"
要显示哪个总额
字段含义
total折扣和税费之后、抵扣之前的金额。
credit应用于此笔交易的抵扣金额。
grandTotaltotal 减去 credit — 客户已支付或仍需支付的金额。应将此金额作为交易金额显示。
grandTotalTaxgrandTotal 中包含的税费(Node SDK 3.6.0+)。
balancegrandTotal 减去已收到的付款 — 尚待支付的金额。

所有总额均以交易使用的货币表示(currencyCode)。

转换并格式化

你需要 (a) 将最小货币单位转换为数值,并 (b) 将其格式化为货币。分为两步是因为步骤 (a) 取决于 Paddle 对该货币采用的小数位数,而 Intl.NumberFormat 并不知道这一点。

// utils/parse-money.ts

// Paddle's zero-decimal currencies: "1200" JPY is ¥1,200, not ¥12.00. Used as a
// fallback when GET /currencies is unavailable.
const ZERO_DECIMAL_CURRENCIES = new Set(["JPY", "KRW", "VND", "CLP"]);

const PADDLE_API_URL =
  process.env.NEXT_PUBLIC_PADDLE_ENV === "production"
    ? "https://api.paddle.com"
    : "https://sandbox-api.paddle.com";

// GET /currencies is public (no API key) and returns decimal_places for every
// currency Paddle supports. It isn't in the Node SDK, so call it with fetch.
export async function getDecimalPlaces(): Promise<Record<string, number>> {
  try {
    const res = await fetch(`${PADDLE_API_URL}/currencies`, { next: { revalidate: 86400 } });
    const { data } = (await res.json()) as { data: { code: string; decimal_places: number }[] };
    return Object.fromEntries(data.map((c) => [c.code, c.decimal_places]));
  } catch {
    return {}; // fall back to ZERO_DECIMAL_CURRENCIES
  }
}

export function convertAmountFromLowestUnit(
  amount: string,
  currency: string,
  decimalPlaces?: number,
): number {
  const places = decimalPlaces ?? (ZERO_DECIMAL_CURRENCIES.has(currency) ? 0 : 2);
  return parseFloat(amount) / 10 ** places;
}

export function formatMoney(amount: number, currency: string, locale = "en-US"): string {
  try {
    return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
  } catch {
    // Malformed locale tag, e.g. from a bad Accept-Language header
    return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
  }
}

type MoneyFormat = { locale?: string; decimalPlaces?: Record<string, number> };

export function parseMoney(
  amount = "0",
  currency = "USD",
  { locale, decimalPlaces }: MoneyFormat = {},
): string {
  return formatMoney(
    convertAmountFromLowestUnit(amount, currency, decimalPlaces?.[currency]),
    currency,
    locale,
  );
}

上面的操作会调用 parseMoney(totals?.grandTotal, t.currencyCode, format)。输出是符合区域设置的字符串,例如 "$30.00"、"€29,99" 或 "¥1,200" — 货币符号、分隔符和小数位处理均由 Intl.NumberFormat 负责。税费已包含在 grandTotal 中,且 grandTotalTax 会单独列出税额,因此你无需自行加税 — 只需转换并格式化。

常见格式化陷阱
  • 跳过零小数位货币的处理分支。 将 JPY/KRW/VND/CLP 除以 100 会把 ¥1,200 变成 ¥12。Paddle 的零小数位货币包括 JPY、KRW、VND 和 CLP。优先使用 GET /currencies 返回的 decimal_places,并将硬编码集合用作备用方案。
  • 将 total 显示为已收金额。 total 尚未扣除抵扣;grandTotal 才是客户已支付的金额。使用自己的税费计算重新汇总 lineItems,也会与实际扣款金额不符。
  • 在 Server Action 中读取 navigator.language。 Node 21+ 虽然定义了 navigator,但它报告的是服务器的区域设置,而不是用户的。请使用请求的 Accept-Language 标头(如上所示),或在客户端进行格式化。
  • 混淆 Transaction 和 PricePreview 的数据结构。 Paddle.PricePreview()(用于定价页面的客户端方法)返回的 formattedTotals.total 已经格式化 — 参见 pricing-pages。Transaction 则不会。请根据具体情况使用正确的处理方式。

分页 — 游标模式

.list(...) 集合使用基于游标的分页。after 是一个交易 ID,表示从该交易之后开始。要支持“加载更多”:

// Client component
"use client";

import { useState } from "react";
import {
  getBillingHistory,
  getInvoiceUrl,
  type BillingHistoryItem,
} from "@/actions/billing-history";

type Props = { initialItems: BillingHistoryItem[]; initialHasMore: boolean };

export function BillingHistory({ initialItems, initialHasMore }: Props) {
  const [items, setItems] = useState(initialItems);
  const [hasMore, setHasMore] = useState(initialHasMore);

  async function loadMore() {
    const lastId = items[items.length - 1]?.id;
    const result = await getBillingHistory(lastId);
    if ("error" in result) return;
    setItems((prev) => [...prev, ...result.items]);
    setHasMore(result.hasMore);
  }

  async function downloadInvoice(transactionId: string) {
    const result = await getInvoiceUrl(transactionId);
    if (result.url) window.location.assign(result.url);
  }

  return (
    <>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            {item.billedAt} · {item.amount} · {item.status}
            {item.invoiceAvailable && (
              <button onClick={() => downloadInvoice(item.id)}>Invoice</button>
            )}
          </li>
        ))}
      </ul>
      {hasMore && <button onClick={loadMore}>Load more</button>}
    </>
  );
}

不要在服务器端循环执行 while (collection.hasMore) — 这会将客户的全部交易历史加载到内存中,还可能触发速率限制。分页应由_用户主动触发_。

常见陷阱

  • 在面向客户的操作中调用 paddle.transactions.list({}),却不添加 customerId 筛选条件。 这会返回账户中所有客户的交易记录。该筛选条件是安全保障。
  • 信任操作输入中的 customerId。 与取消、更新和门户操作一样,必须在服务器端根据经过身份验证的用户记录解析 customerId。绝不要将其作为参数接受。
  • 把 TransactionCollection 当作数组处理。 它是一个通过 .next() 按需获取数据的迭代器。collection.length 未定义;也不存在 collection.map(...)。
  • 一次性加载所有页面。 while (collection.hasMore) { items.push(...await collection.next()) } 这样的循环看似合理,但会产生无上限的响应。对于有数百笔交易的客户,这会触及内存限制和 Paddle 速率限制。
  • 转换金额时忘记处理零小数位货币。 对 USD/EUR/GBP 等货币,parseFloat(amount) / 100 是正确的;但对 ¥1,200 / ₩1,200 / ₫12,000 / CLP$1,200,这会得到小 100 倍的数字(因为这些货币没有最小货币单位;原始值已经是整单位金额)。Paddle 视为零小数位的货币是 JPY、KRW、VND 和 CLP。请使用 GET /currencies 返回的 decimal_places,并准备一个硬编码的回退值——参见上文的 convertAmountFromLowestUnit 助手函数。
  • 手动汇总行项目,而不使用 details.totals.grandTotal。 Paddle 的总额已计入行项目小计、折扣、税费和抵扣额。自行使用自己的税费计算重新汇总,会与实际扣款金额不一致,也会让对照界面和银行账单的客户感到困惑。
  • 试图从 Transaction 中读取 formattedTotals。 该字段存在于 Paddle.js 的 PricePreview 结果中(定价页面场景),但不存在于 Node SDK 的 Transaction 实体中。读取 t.details.totals.formattedTotals.total 会返回 undefined。请使用 details.totals.grandTotal 和 currencyCode,并自行格式化。
  • 在状态筛选条件中包含 draft 或 ready。 这些是内部处理中状态,不是面向客户的数据。请排除它们。
  • 直接返回原始的 Transaction SDK 对象。 它包含行项目、客户地址、企业记录以及大量 Paddle 内部元数据。请精简为只包含界面实际需要内容的 DTO(id、日期、状态、格式化后的金额,以及是否有可用发票)。
  • 在列表中预先生成发票 URL。 每个 URL 都需要一次 API 调用,并会在一小时后过期。应在用户点击时获取,并先检查交易是否属于该用户。
  • 对金额使用 parseFloat 和 Intl.NumberFormat 之外的自定义计算。 parseFloat(amount) / 100 是标准的最小货币单位转换方式(同时要按上文处理零小数位货币)。不应该做的是叠加额外的舍入操作(Math.round、Math.floor、Math.ceil、toFixed)或重新计算总额——这些会引入相差一个最小单位的错误和货币舍入错误。Intl.NumberFormat 已能处理符合区域设置的小数位数。尽可能少做计算;让 Paddle 的总额和 Intl 格式化器完成工作。

验证集成

  1. 以至少有一笔已完成沙盒交易的用户身份登录。
  2. 调用 getBillingHistory()。你应会得到 { items: [...], hasMore: false (or true) }。
  3. 验证响应中的格式化金额是否与 Paddle 控制面板中该笔交易显示的总金额一致(币种、金额、税费处理)。
  4. 以尚无 Paddle 客户记录的用户身份登录。该操作应返回 { items: [], hasMore: false },且不报错。
  5. 在未登录时尝试执行该操作。应返回 { error: 'Not authenticated' }。
  6. 关键: 创建一笔属于_其他_客户的沙盒交易(或通过控制面板查看其他客户的交易)。确认响应中不会出现这些交易——customerId 筛选条件必须正确限定范围。然后使用该客户的一笔交易 ID 调用 getInvoiceUrl,必须返回 { error: 'Forbidden' }。
  7. 如果你的账户有超过 10 笔交易,请确认 hasMore: true,并确认调用 getBillingHistory(lastId) 会推进游标。
  8. 点击已完成交易上的 Invoice。PDF 应会下载。

相关文档

  • 列出交易 - API 参考
  • 获取交易的 PDF 发票 — getInvoiceUrl 调用的接口
  • 列出币种 — 每种币种的 decimal_places
  • 列出订阅历史记录 — 用于显示订阅随时间发生的变化(套餐变更、取消、付款方式更新)。需要 subscription_history.read。Node SDK 尚未支持此接口,因此请直接调用 API;Paddle MCP 将其作为 client.subscriptions.history.list 提供。
  • 参考实现:paddle-nextjs-starter-kit — 查看 src/utils/paddle/get-transactions.ts 和 src/app/dashboard/payments/[subscriptionId]/。

评论

支持 Markdown