mcpField
P
paddle-pricing-pages

paddle-pricing-pages

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

paddle-pricing-pages について

Paddle.js を使い、Next.js の料金ページやアップグレード画面をローカライズするスキルです。地域別の通貨・税額のプレビュー、国の選択、請求周期の切り替え、チェックアウトとの価格同期を扱います。

主な機能

  1. 01ローカライズ価格と税額のプレビュー
  2. 02国の検出または選択
  3. 03月払い・年払いの切り替え
  4. 04小数桁のない通貨への対応
  5. 05表示価格とチェックアウトの同期

活用シーン

  1. 01公開料金ページの構築
  2. 02アプリ内アップグレード価格の表示
  3. 03国ごとの価格確認

MCP 対応

Paddle MCP サーバーが利用できる場合、価格プレビューツールで同等の価格データをサーバー側から取得し、国ごとの表示価格を確認できます。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-pricing-pages

インストール後、Claude Code で/paddle-pricing-pagesを入力して読み込まれたか確認します。

詳細

Next.js でローカライズされた価格を表示する

このスキルを使う場面

ユーザーの現地通貨で、適切な税の扱いを反映した価格を表示する公開価格ページ(またはアプリ内のアップグレード画面)を構築するときに使います。Paddle.PricePreview() API、国の検出、請求頻度の切り替え(月次/年次)、小数点以下の桁数がない通貨の表示形式に関する注意点(JPY/KRW/VND/CLP)、および表示価格をチェックアウトで請求される金額と一致させる方法を扱います。

これはクライアントサイドの処理です。チェックアウト自体については、checkout-web を参照してください。ここで表示する価格 ID は、Paddle.Checkout.open() に渡すものと同じです。

PricePreview を使う理由

UI に「月額 $10」とハードコードすることもできますが、Paddle には地域別価格が組み込まれています。同じプランでも、米国では $10、EU では(VAT 込みで)€9、英国では £8、日本では ¥1,200 になることがあります。同じ通貨を使う国でも、購買力平価を考慮して国ごとに異なる価格を設定できます。ハードコードでは一つの市場にしか対応できません。

PricePreview() は、指定した場所(場所を指定しない場合は訪問者の IP アドレス)に応じた価格を返します。通貨、表示形式、適用される税金がすでに計算されているため、クライアントサイドで計算する必要はありません。

Paddle MCP サーバーを利用できる場合は、execute 内で client.pricingPreview.preview({ items: [{ price_id: "pri_...", quantity: 1 }], address: { country_code: "US" } }) を呼び出すと、同じデータをサーバーサイドで取得できます。クライアントサイドのフックを組み込む前に、国ごとにユーザーに表示される内容を確認するのに便利です。pricingPreview はキャメルケースですが、price_id と country_code はスネークケースです。currency_code は渡さないでください。通貨が固定され、Paddle が現地通貨を選択できなくなります。

Paddle MCP は、サーバーごとに 3 つのツール(search、execute、report_missing_tool)を公開します。ワークフローは、まず search を呼び出して正確なメソッド名とパラメーターの形式を確認し、次に execute で client.<resource>.<operation>(...) を呼び出す非同期関数を実行します。メソッドのパスはキャメルケースです(client.clientTokens.create、client.pricingPreview.preview)。リクエスト本文のパラメーターとレスポンスのフィールドはスネークケースです(tax_category、product_id、unit_price、currency_code)。リストメソッドは { pagination: { hasMore, estimatedTotal }, <resource>: [...] } を返し、data は返しません。配列のキーはリソースの API パスのセグメント名であり、メソッドのパスがキャメルケースの場合はケバブケースです。例:(await client.products.list()).products、(await client.clientTokens.list())["client-tokens"]。pagination.hasMore が true の間は、.next() ではなく { after: "<last_id>" } を使って次のページに進みます。client.currencies.list() のようにページ分割されないリストは、通常の配列を返します。単一エンティティのメソッドはエンティティを返します。複数の手順からなるワークフローは、1 回の execute 内でつなげてください。呼び出し間で変数は保持されません。上限は、1 回の execute あたり API 呼び出し 50 回、タイムアウト 30 秒、コード 32,000 文字です。

前提条件

  • checkout-web と同じ Paddle.js のセットアップ:
npm install @paddle/paddle-js
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...
NEXT_PUBLIC_PADDLE_ENV=sandbox
  • Paddle カタログに 1 つ以上の価格を登録しておきます。販売対象の市場に合わせた価格オーバーライドを設定することをおすすめします。
    • オーバーライドのない基本価格は、Paddle > My account > Settings > Currencies で有効にした通貨にのみ自動換算されます。それ以外の場合、オーバーライドのない国の顧客には基本通貨で価格が表示されます。
    • 明示的なオーバーライドを設定すると、丸め方や心理的価格設定(例:€9 であり、€9.13 ではない)を調整できます。
    • まだ価格がない場合は catalog-setup を使ってください。このスキルには地域別オーバーライドのセクションがあります。

価格プランを定義する

一般的な構成では、ティアのメタデータ(名前、機能)を定数ファイルに保存し、Paddle の価格 ID と並べて管理します。

// constants/pricing-tier.ts
export interface Tier {
  name: string;
  id: "starter" | "pro" | "advanced";
  description: string;
  features: string[];
  featured: boolean;
  priceId: { month: string; year: string };
}

export const PricingTier: Tier[] = [
  {
    name: "Starter",
    id: "starter",
    description: "Get going.",
    features: ["1 workspace", "Limited collaboration"],
    featured: false,
    priceId: {
      month: "pri_01h...",
      year: "pri_02h...",
    },
  },
  // ...
];

PricePreview フック

小さなカスタムフックを使うと、価格設定のロジックを1か所にまとめられます。Paddle インスタンスと国コードを受け取り、priceId → formatted total のマップに加えて、読み込み状態とエラー状態を返します。

// hooks/usePaddlePrices.ts
import { type Paddle, type PricePreviewParams, type PricePreviewResponse } from "@paddle/paddle-js";
import { useEffect, useState } from "react";
import { PricingTier } from "@/constants/pricing-tier";

export type PaddlePrices = Record<string, string>;

function getLineItems(): PricePreviewParams["items"] {
  return PricingTier.flatMap((tier) =>
    [tier.priceId.month, tier.priceId.year].map((priceId) => ({
      priceId,
      quantity: 1,
    })),
  );
}

function getPriceAmounts(prices: PricePreviewResponse): PaddlePrices {
  return prices.data.details.lineItems.reduce<PaddlePrices>((acc, item) => {
    acc[item.price.id] = item.formattedTotals.total;
    return acc;
  }, {});
}

export function usePaddlePrices(
  paddle: Paddle | undefined,
  country: string,
): { prices: PaddlePrices; loading: boolean; error: string | null } {
  const [prices, setPrices] = useState<PaddlePrices>({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!paddle) return;

    const params: PricePreviewParams = {
      items: getLineItems(), // up to 100 items per call
      // 'OTHERS' is a sentinel meaning "let Paddle infer from IP".
      ...(country !== "OTHERS" && { address: { countryCode: country } }),
    };

    setLoading(true);
    setError(null);
    paddle
      .PricePreview(params)
      .then((response) => {
        setPrices((prev) => ({ ...prev, ...getPriceAmounts(response) }));
      })
      .catch((err: unknown) => {
        // API errors reject with the API's error body: { error: { type, code, detail, ... } }.
        console.error("Paddle.PricePreview failed", err);
        setError("We couldn't load prices. Please refresh the page.");
      })
      .finally(() => setLoading(false));
  }, [country, paddle]);

  return { prices, loading, error };
}

item.formattedTotals.total は、"$9.99" や "¥1,200" のように、通貨記号とロケールに応じた桁区切りを含む、完全に書式設定済みの文字列です。そのまま使用し、再度書式設定しないでください。この値は指定した地域(または訪問者の IP アドレス)を反映するため、チェックアウトで同じ地域を使用した場合にのみ、チェックアウト時の価格と一致します。詳しくは、以下の「チェックアウトとの組み合わせ」を参照してください。

生の金額(計算、比較、独自の表示に使用する場合)が必要な場合は、item.totals.total から通貨の最小単位で表された文字列を取得できます($9.99 の場合は "999"、小数点以下を使用しない通貨の場合は整数単位です。詳しくは、以下の「通貨単位の書式設定」を参照してください)。

価格にトライアルがある場合(item.price.trialPeriod が null ではない場合)、トライアルが無料か有料かによって、顧客が今日支払う金額は定期購入の価格と異なることがあります。カード上でトライアルの条件を明確にしてください。

価格ページのコンポーネント

フックを Paddle.js の初期化および請求頻度の切り替えと組み合わせます。

// components/pricing.tsx
"use client";

import { type Environments, initializePaddle, type Paddle } from "@paddle/paddle-js";
import { useEffect, useState } from "react";
import { usePaddlePrices } from "@/hooks/usePaddlePrices";
import { PricingTier } from "@/constants/pricing-tier";

interface Props {
  country: string;
}

export function Pricing({ country }: Props) {
  const [frequency, setFrequency] = useState<"month" | "year">("month");
  const [paddle, setPaddle] = useState<Paddle | undefined>();

  const { prices, loading, error } = usePaddlePrices(paddle, country);

  useEffect(() => {
    if (!process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN) return;
    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
    }).then((p) => p && setPaddle(p));
  }, []);

  return (
    <div>
      <FrequencyToggle value={frequency} onChange={setFrequency} />
      {error && <p role="alert">{error}</p>}
      <div className="grid grid-cols-3 gap-6">
        {PricingTier.map((tier) => {
          const priceId = tier.priceId[frequency];
          const formatted = prices[priceId];
          return (
            <div key={tier.id}>
              <h3>{tier.name}</h3>
              <p className="text-3xl">
                {loading || !formatted ? "..." : formatted}
                <span className="text-sm">/{frequency}</span>
              </p>
              {/* ... features, CTA button ... */}
            </div>
          );
        })}
      </div>
    </div>
  );
}

このパターンでは、チェックアウト設定なしで Paddle.js を初期化します。PricePreview() を呼び出すには、クライアントトークンだけが必要です。ユーザーが「Subscribe」をクリックしたときは、initializePaddle を再度呼び出すのではなく、チェックアウト設定をそのまま Paddle.Checkout.open({ settings }) に渡してください(詳しくは、以下の「チェックアウトとの組み合わせ」を参照してください)。

国の検出

精度とコストが低い順に、3つの方法があります。

A. 適切な市場をデフォルトにします。 米国や米国の訪問者、JavaScript が無効な場合、地域によるブロックがある場合などは、単に OTHERS をデフォルトにします(Paddle はチェックアウト時に IP アドレスから地域を推定します)。

<Pricing country="OTHERS" />

B. リクエストヘッダーから読み取ります。 App Router では、Server Component 内の headers() を使うと、CDN からの地域情報を含むヘッダーを取得できます(例:Vercel は x-vercel-ip-country を設定します)。

// app/pricing/page.tsx
import { headers } from "next/headers";
import { Pricing } from "@/components/pricing";

export default async function PricingPage() {
  const h = await headers();
  const country = h.get("x-vercel-ip-country") ?? "OTHERS";
  return <Pricing country={country} />;
}

C. ユーザーに選択してもらいます。 対応する国のリストを含む <select> を用意し、IP アドレスから検出した国をデフォルトにします。世界中のユーザーに価格を表示し、ユーザーが国を切り替えられるようにする場合に便利です(例:米国の購入者がドイツの同僚のために価格を調べる場合)。ユーザーが最も安い価格になる国を選ぶ可能性があるため、おすすめしません。

国セレクター

const COUNTRIES = [
  { code: "OTHERS", label: "Default (auto)" },
  { code: "US", label: "United States" },
  { code: "GB", label: "United Kingdom" },
  { code: "DE", label: "Germany" },
  { code: "JP", label: "Japan" },
  // ...
];

export function CountrySelect({
  value,
  onChange,
}: {
  value: string;
  onChange: (v: string) => void;
}) {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value)}>
      {COUNTRIES.map((c) => (
        <option key={c.code} value={c.code}>
          {c.label}
        </option>
      ))}
    </select>
  );
}

国が変更されると、usePaddlePrices フックが再取得します。追加の設定は不要です。

通貨単位の書式設定

通貨は最小単位で保存されます。1099 は $10.99、850 は £8.50、2500 は €25.00 を意味します。

小数点を使用しない通貨は4種類あります。JPY、KRW、VND、CLP です。Paddle では補助通貨単位がないため、1200 は ¥1,200/₩1,200/₫1,200/CLP$1,200 を意味します(¥12.00 などではありません)。

生の item.totals.total 文字列を取得し、独自の UI で使用するために数値へ変換する場合は、これが重要になります。

// utils/parse-money.ts
export function convertAmountFromLowestUnit(amount: string, currency: string): number {
  switch (currency) {
    case "JPY":
    case "KRW":
    case "VND":
    case "CLP":
      return parseFloat(amount);
    default:
      return parseFloat(amount) / 100;
  }
}

export function formatMoney(amount: number, currency: string): string {
  const language = typeof navigator !== "undefined" ? navigator.language : "en-US";
  return new Intl.NumberFormat(language, {
    style: "currency",
    currency,
  }).format(amount);
}

export function parseMoney(amount = "0", currency = "USD"): string {
  return formatMoney(convertAmountFromLowestUnit(amount, currency), currency);
}

リストをハードコードしないようにするには、各通貨の decimal_places を GET https://api.paddle.com/currencies(サンドボックスの場合は https://sandbox-api.paddle.com/currencies)から取得してください。これは公開されており、API キーは不要です。Node SDK ではラップされていないため、サーバーから fetch を呼び出して結果をキャッシュしてください。Paddle MCP では、client.currencies.list() を呼び出すと、同じリストが通常の配列として返されます。

// utils/currency-decimals.ts (server-side)
export async function getCurrencyDecimals(): Promise<Record<string, number>> {
  const res = await fetch("https://api.paddle.com/currencies", { next: { revalidate: 86400 } });
  if (!res.ok) throw new Error(`Failed to load currencies: ${res.status}`);
  const { data } = (await res.json()) as { data: { code: string; decimal_places: number }[] };
  return Object.fromEntries(data.map((c) => [c.code, c.decimal_places]));
}

// parseFloat(amount) / 10 ** decimals[currency]

API の formattedTotals.total を使う場合、この処理は不要です。Paddle が書式設定してくれます。独自の金額を計算する場合(例:月払いと年払いの間で「20%お得」と表示する場合)にのみ、解析用ヘルパーが必要です。

解析用ヘルパーを使う代わりに、Intl.NumberFormat を使って金額を直接書式設定することも検討してください。参照先:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat

チェックアウトとの組み合わせ

ユーザーがティアの「Subscribe」をクリックしたら、PricePreviewで使用したものと同じpriceIdを渡し、チェックアウトの設定があればCheckout.open()に直接渡します:

function handleSubscribe(tier: Tier, frequency: "month" | "year") {
  paddle?.Checkout.open({
    items: [{ priceId: tier.priceId[frequency], quantity: 1 }],
    settings: {
      variant: "one-page",
      successUrl: `${window.location.origin}/checkout/success`,
    },
  });
}

チェックアウトでは、ページからPricePreviewに渡された国を再利用せず、顧客のIPアドレスから国を自動で判定します。通常は一致しますが、ページで別の国を使用した場合(たとえば、国セレクターを使用した場合)、チェックアウトに異なる通貨や税額が表示されることがあります。一致させるには、国を事前入力します:

paddle?.Checkout.open({
  items: [{ priceId: tier.priceId[frequency], quantity: 1 }],
  customer: { email: user.email, address: { countryCode: country } },
});

国を事前入力するには、customerにemailまたはidが必要です。郵便番号を使用する国(例:GB、DE、CA)では、postalCodeも渡してください。そうしないと国が除外され、チェックアウトからcheckout.warningが出力されます。

よくある落とし穴

  • formattedTotals.totalの再フォーマット。 これはすでにロケールに応じてフォーマットされた文字列です。Intl.NumberFormatでラップすると、誤った結果("$$9.99")になったり、エラーが発生したりします。
  • 小数点以下の桁数がゼロの通貨の例外を忘れる。 JPY/KRW/VND/CLPを100で割ると、元の値の100分の1になってしまいます。必ず通貨に応じた処理を行うか、/currenciesのdecimal_placesを使用してください。
  • PricePreviewに.catchがない。 失敗時(価格IDが無効、環境が異なる、ネットワークエラーなど)に拒否されるPromiseを返します。ハンドラーがないと、loadingが解除されず、カードにいつまでも...が表示されます。
  • 元の金額を使って独自に計算する。 これは推奨されません。グローバルな税務コンプライアンスを含め、Paddleが計算を自動で処理します。
  • API呼び出しでaddress.countryCodeに'OTHERS'を設定する。 'OTHERS'は「住所を渡さない」という意味で_コード内で使用する_センチネル値です。Paddleはこれを認識しません。代わりに、addressフィールドを省略してください(上記のフックの例を参照)。
  • Paddle.jsの初期化前に価格を表示する。 ページの読み込み後約200msは、prices[priceId]がundefinedになります。読み込み状態を明示的に処理してください('...'のプレースホルダー、スケルトンなど)。
  • レンダーのたびにPricePreviewを呼び出す。 上記のフックはcountryが変更されたときに再取得します。frequencyの変更時にも再取得すると、処理が重複します。itemsにpriceId.monthとpriceId.yearの両方を含めれば、1回の呼び出しで両方の価格が返されます。1回の呼び出しで最大100項目を取得できます。それを超えるカタログは分割してください。
  • サンドボックスと本番環境の価格IDを混在させる。 サンドボックスのpri_01h...は本番環境には存在しないため、PricePreviewのPromiseは拒否されます。
  • 価格オーバーライドを作成しない。 オーバーライドがない場合、Paddleが自動換算するのは、Paddle > My account > Settings > Currenciesで有効にした通貨のみです。それ以外のユーザーには基本通貨が表示されます。すべてのユーザーにUSDの価格を提示するよりも自動換算のほうが購入者にとって使いやすいですが、主要な市場(US、EU、UK、JPなど)には明示的なオーバーライドを設定することをおすすめします。

統合を確認する

  1. 国のプロパティを指定せずに価格ページを開き、約500ms以内に価格が表示されることを確認します。
  2. 開発者ツールを開き、Networkに移動してpaddle.comでフィルターします。PricePreviewリクエストが1件表示され、各価格のformattedTotalsを含むJSONペイロードが返されるはずです。
  3. 国セレクターをJP、KR、VN、またはCLに切り替えます。これらの通貨でオーバーライドを設定しているか、Paddle > My account > Settings > Currenciesで有効にしている場合は、小数点以下の桁数なしでJPY/KRW/VND/CLPの価格が表示されることを確認します。そうでない場合は、基本通貨のまま表示されます。
  4. DEに切り替え、価格にVATが含まれていることを確認します(VATが含まれないUSと比較してください)。
  5. ティアの「Subscribe」をクリックし、同じ通貨と金額でチェックアウトが開くことを確認します。そうでない場合は、チェックアウトで検出された国がプレビュー時の国と異なります。事前入力してください(「チェックアウトとの組み合わせ」を参照)。
  6. (本番環境のトークンを使って)NEXT_PUBLIC_PADDLE_ENV=productionに設定し、本番環境の価格IDが解決され、サンドボックスのIDでエラー状態が表示されることを確認します。

関連ドキュメント

コメント