mcpField
P
paddle-billing-history

paddle-billing-history

github.com/paddlehq開発9105 回インストール6 日前に更新
リポジトリ

paddle-billing-history について

認証済みアカウント画面にPaddleの請求履歴を実装する、Next.js 15 App Router向けのスキルです。安全な取引一覧、カーソルページネーション、ステータス絞り込み、通貨表示、請求書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)のサーバーアクションを扱います。

これは取引データの 読み取り 側です。以下と組み合わせてください。

  • subscription-sync — ユーザーの Paddle customer_id を特定するために結合する customers テーブルを提供します。
  • webhooks — customer.created イベントから customers テーブルにデータを登録します。
  • customer-portal — Paddle に請求 UI 全体をホストさせる場合の代替手段です(下記の「自分で構築するべきか?」を参照)。

自分で構築するべきか?

Paddle のカスタマーポータルには、ホストされた請求履歴表示機能がすでに用意されています(customer-portal を参照)。次の場合は独自に構築してください。

  • 請求画面を、ナビゲーションやスタイルに一貫性を持たせてダッシュボード内に表示したい場合。
  • 請求データをアプリ固有の情報(例:使用状況の詳細、カスタムの請求書フィールド)と組み合わせたい場合。
  • 表示内容を制御したい場合 — ポータルではすべてが表示されますが、カスタム画面では、対象ユーザーに合わせて取引を省略、グループ化、または名称変更できます。

どれにも当てはまらない場合は、ユーザーをポータルに誘導し、このスキルはスキップしてください。

前提条件

  • 取引のある顧客が少なくとも 1 人いる Paddle アカウント(サンドボックスでも構いません)。
  • 取引の一覧表示と請求書 PDF の取得をカバーする transaction.read 権限を持つサーバーサイドの PADDLE_API_KEY。これらの処理はサーバーアクションとして実行され、ブラウザー内では実行されません。
  • 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 に対応する、サーバーサイドのヘルパーを 2 つインポートします。プロジェクトに同等のものがすでにある場合は、それを使用してください。独自に作成する場合、ラッパーに 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 をバイパスするため、ユーザーを確認した後、サーバー上でのみ使用してください。意図的に Cookie をスキップするため、サインイン済みユーザーのセッションを取得することはありません。

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 です。 1 ページを取得するには .next() を呼び出します。このコレクションを配列として扱ったり、すべてのデータが読み込まれていると想定したりすると、何も取得できないか、古いデータが返されます。
  • 実際には customerId フィルターが必須です。 API 上は必須ではなく、指定しなくてもアカウント内のすべての取引を一覧表示できます(管理者向け画面では便利です)。しかし、顧客向け のページで省略すると、ログインしているユーザーに他のすべての顧客の履歴が漏えいします。必ず指定してください。
  • ステータスフィルターには文字列リテラルを使います。 Node SDK の TransactionStatus は型のみのユニオンであり、メンバーを持つ列挙型ではありません。"completed" のような文字列リテラルを渡してください。必要に応じて TransactionStatus[] として型付けできます。このスキルでは、含めるべきステータスを後述します。
  • ページネーションはカーソル方式です。 .next() を呼び出すたびにカーソルが進みます。「さらに読み込む」機能をサポートするには、カーソル(最後の項目から取得する after、または SDK の組み込み処理)を返し、再度リクエストしてください。次のページがあるかどうかの判断には estimatedTotal ではなく hasMore を使ってください。estimatedTotal は 100001 が上限で、-1 になる場合もあります。

サーバーアクションの全体

// 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 呼び出しが必要で、有効期限は 1 時間です。ユーザーが Invoice をクリックしたときに 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 を含むすべてのステータスを返します。指定してください。

表示:生の金額を自分でフォーマットする

事前フォーマット済みの文字列を formattedTotals.total で公開する Paddle.js の PricePreview とは異なり、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)通貨として書式設定する必要があります。2段階に分かれているのは、(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 で確認できるため、自分で税金を加算する必要はありません。変換して書式設定するだけです。

よくある書式設定の落とし穴
  • 小数点以下の桁数が0の通貨の処理を省略する。 JPY/KRW/VND/CLPを100で割ると、¥1,200が¥12になってしまいます。Paddleで小数点以下の桁数が0の通貨は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) のループを実行しないでください。顧客のすべての取引履歴をメモリに読み込むことになり、レート制限が適用される可能性もあります。ページネーションは_ユーザー操作に応じて_実行してください。

よくある落とし穴

  • 顧客向けのアクションで customerId フィルターなしに paddle.transactions.list({}) を呼び出すこと。 アカウント内のすべての顧客のトランザクションが返されます。このフィルターがセキュリティを保証します。
  • アクションの入力から渡された customerId を信頼すること。 キャンセル/更新/ポータルのアクションと同様に、customerId は認証済みユーザーのレコードからサーバー側で解決する必要があります。パラメーターとして受け取らないでください。
  • TransactionCollection を配列として扱うこと。 .next() を介して遅延取得するイテレーターです。collection.length は未定義で、collection.map(...) も存在しません。
  • すべてのページを一括で読み込むこと。 while (collection.hasMore) { items.push(...await collection.next()) } のループはもっともらしく見えますが、上限のないレスポンスを生成します。何百件ものトランザクションがある顧客では、メモリ制限やPaddleのレート制限に達します。
  • 金額の換算時にゼロ小数通貨の分岐を忘れること。 parseFloat(amount) / 100 はUSD/EUR/GBPなどでは正しいものの、¥1,200/₩1,200/₫12,000/CLP$1,200を100分の1の値にしてしまいます(これらの通貨には最小単位がなく、元の値がすでに通貨単位全体の金額だからです)。Paddleがゼロ小数として扱う通貨は JPY、KRW、VND、CLP です。GET /currencies の decimal_places を使用し、フォールバックとして値をハードコードしてください。上記の convertAmountFromLowestUnit ヘルパーを参照してください。
  • details.totals.grandTotal を使わず、明細項目を手動で合計すること。 Paddleの合計額には、明細項目の小計、割引、税金、クレジットがすでに反映されています。独自の税計算で再集計すると、実際の請求額と食い違い、UIと銀行明細を照らし合わせる顧客を混乱させます。
  • Transactionの formattedTotals を参照すること。 このフィールドはPaddle.jsの PricePreview の結果(価格ページ向け)にはありますが、Node SDKの Transaction エンティティにはありません。t.details.totals.formattedTotals.total を読み取ると、undefined が返されます。details.totals.grandTotal と currencyCode を使って、自分でフォーマットしてください。
  • ステータスフィルターに draft や ready を含めること。 これらは処理中の内部状態であり、顧客向けデータではありません。除外してください。
  • SDKの生の Transaction オブジェクトを返すこと。 明細項目、顧客の住所、事業者情報に加えて、多くの内部Paddleメタデータが含まれています。UIで実際に必要な情報(id、日付、ステータス、フォーマット済み金額、請求書が利用可能かどうか)だけを持つDTOに絞り込んでください。
  • 一覧内で請求書URLを事前計算すること。 計算のたびにAPI呼び出しが必要となり、URLは1時間後に期限切れになります。ユーザーがクリックしたときに、トランザクションがそのユーザーのものであることを確認してから取得してください。
  • 金額の計算で parseFloat と Intl.NumberFormat 以外の独自計算を追加すること。 parseFloat(amount) / 100 が標準的な最小単位からの換算方法です(上記のゼロ小数の処理を除きます)。追加の丸め処理(Math.round、Math.floor、Math.ceil、toFixed)を重ねたり、合計を再計算したりしてはいけません。これらは1単位のずれや通貨の丸めに関するバグを引き起こします。Intl.NumberFormat がロケールに応じた小数桁数をすでに処理します。計算はできるだけ抑え、Paddleの合計額とIntlのフォーマッターに任せてください。

インテグレーションを確認する

  1. サンドボックスで完了済みの取引が少なくとも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. 完了済みの取引にある 請求書 をクリックします。PDF がダウンロードされるはずです。

関連ドキュメント

コメント