关于 paddle-billing-history
面向 Next.js 15 App Router 开发者的技能,指导在账户页构建 Paddle 账单历史。涵盖安全读取交易、游标分页、状态筛选、金额格式化及按需获取发票 PDF。
主要功能
- 01服务端读取客户交易
- 02游标分页与状态筛选
- 03本地化格式化交易金额
- 04按需获取发票 PDF
- 05校验用户与客户归属
适用场景
- 01在账户页添加账单历史
- 02为交易列表实现加载更多
- 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表,你可以通过联结该表找到用户的 Paddlecustomer_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)。你将通过电子邮件关联在此查找已通过身份验证的用户的 Paddlecustomer_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 | 应用于此笔交易的抵扣金额。 |
grandTotal | total 减去 credit — 客户已支付或仍需支付的金额。应将此金额作为交易金额显示。 |
grandTotalTax | grandTotal 中包含的税费(Node SDK 3.6.0+)。 |
balance | grandTotal 减去已收到的付款 — 尚待支付的金额。 |
所有总额均以交易使用的货币表示(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。 这些是内部处理中状态,不是面向客户的数据。请排除它们。 - 直接返回原始的
TransactionSDK 对象。 它包含行项目、客户地址、企业记录以及大量 Paddle 内部元数据。请精简为只包含界面实际需要内容的 DTO(id、日期、状态、格式化后的金额,以及是否有可用发票)。 - 在列表中预先生成发票 URL。 每个 URL 都需要一次 API 调用,并会在一小时后过期。应在用户点击时获取,并先检查交易是否属于该用户。
- 对金额使用
parseFloat和Intl.NumberFormat之外的自定义计算。parseFloat(amount) / 100是标准的最小货币单位转换方式(同时要按上文处理零小数位货币)。不应该做的是叠加额外的舍入操作(Math.round、Math.floor、Math.ceil、toFixed)或重新计算总额——这些会引入相差一个最小单位的错误和货币舍入错误。Intl.NumberFormat已能处理符合区域设置的小数位数。尽可能少做计算;让 Paddle 的总额和 Intl 格式化器完成工作。
验证集成
- 以至少有一笔已完成沙盒交易的用户身份登录。
- 调用
getBillingHistory()。你应会得到{ items: [...], hasMore: false (or true) }。 - 验证响应中的格式化金额是否与 Paddle 控制面板中该笔交易显示的总金额一致(币种、金额、税费处理)。
- 以尚无 Paddle 客户记录的用户身份登录。该操作应返回
{ items: [], hasMore: false },且不报错。 - 在未登录时尝试执行该操作。应返回
{ error: 'Not authenticated' }。 - 关键: 创建一笔属于_其他_客户的沙盒交易(或通过控制面板查看其他客户的交易)。确认响应中不会出现这些交易——
customerId筛选条件必须正确限定范围。然后使用该客户的一笔交易 ID 调用getInvoiceUrl,必须返回{ error: 'Forbidden' }。 - 如果你的账户有超过 10 笔交易,请确认
hasMore: true,并确认调用getBillingHistory(lastId)会推进游标。 - 点击已完成交易上的 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]/。