mcpField
P
paddle-subscription-update

paddle-subscription-update

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

paddle-subscription-update について

認証済みのPaddle請求画面でプラン変更を実装する、Next.js 15 App Router向けのスキルです。按分請求モード、項目の全置換、ステータス制約、安全確認、更新前のプレビューを扱います。

主な機能

  1. 01Server Actionでプランを変更
  2. 025種類の按分請求モードを選択
  3. 03サブスクリプション項目を全件置換
  4. 04ステータスに応じて更新を処理
  5. 05請求額と継続価格をプレビュー

活用シーン

  1. 01プランのアップグレードやダウングレードを実装
  2. 02請求期間の切り替えを可能にする
  3. 03変更前に即時請求額と継続価格を確認

インストール

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

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-subscription-update

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

詳細

Next.js から Paddle サブスクリプションのプランを更新する

このスキルを使う場面

認証済みユーザー向けの請求 UI に「プランをアップグレード」「ティアを切り替え」「サブスクリプションを変更」といった操作を実装するときに、このスキルを使います。paddle.subscriptions.update() を呼び出す Next.js 15(App Router)のサーバーアクション、5 種類の prorationBillingMode とそれぞれを選ぶ場面、items 配列は追加ではなく置き換えであるという仕様、そして各プラン変更アクションで必要なセキュリティチェックについて説明します。

これは、サブスクリプション変更を開始する側の処理です。次のスキルと併せて使ってください。

  • subscription-sync — 変更後、Webhook ハンドラーに subscription.updated イベントが届きます。DB ミラーには新しいプラン、ステータス、料金が反映されます。
  • subscription-cancel — 認証と所有権の確認方法は同じです。両方を実装する場合は、ヘルパーを共有してください。

前提条件

  • ユーザーが切り替えられる価格が少なくとも 2 つある Paddle アカウント(例:月額の Starter と月額の Pro)。Sandbox でも問題ありません。
  • サーバー側で PADDLE_API_KEY を利用できること(サーバーアクション専用)。このキーには subscription.write 権限が必要です(update と activate 用)。previewUpdate に必要なのは subscription.read のみで、write にはこれが含まれます。
  • Webhook から同期された顧客とサブスクリプションの状態(subscription-sync を参照)。
  • 認証システム。例では 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 クライアントを 2 つ使用します。これらは Paddle Next.js starter kit に合わせたものです。プロジェクトに同等のヘルパーがすでにある場合は、そちらを使ってください。

// 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 の関数名です。

prorationBillingModeを選択する

多くの人が最も間違えやすいパラメーターです。Paddle の subscriptions.update では 5 つの値を指定できます。有効なサブスクリプションで一般的な「Pro にアップグレード」するフローでは、prorated_immediately が適切です。

値動作使用するケース
prorated_immediately旧プランと新プランの差額を日割り計算して今すぐ請求します(ダウングレードの場合はクレジットが発生します)。プランは直ちに切り替わります。「プランをアップグレード」—新しいプランをすぐに利用でき、今すぐ請求されることをユーザーが期待している場合。
prorated_next_billing_period日割り計算した金額を、次回の更新時に請求します。プランは直ちに切り替わります。「プランを切り替えるが、請求は後にしてほしい」—まれですが、妥当なケースです。
full_immediately新しいプランの全額を今すぐ請求します。プランは直ちに切り替わります。あまり使われません。一部期間分に対してユーザーに過剰請求することになります。
full_next_billing_period追加されたアイテムは更新時に全額請求され、削除されたアイテムに対するクレジットは発生しません。プランは直ちに切り替わります。まれなケースです。新しいプランの無料トライアルではなく、全額の請求が先送りされるだけです。
do_not_bill今すぐプランを切り替えます。請求は発生しません。trialing および paused 状態のサブスクリプションでは必須です。それ以外では、社内やチームでのアップグレード、カスタマーサービスによる無償提供向けであり、セルフサービス UX には適しません。

デフォルト値はありません。5 つの値のいずれかを指定する必要があります。どの値を選んでも、新しいアイテムは直ちに古いアイテムに置き換わります。このモードによって決まるのは、請求の内容とタイミングだけです。

アップグレード、ダウングレード、または契約期間の変更?

subscriptions.update は、ユーザーから見える複数の異なるフローに対応する単一のエンドポイントです。適切な prorationBillingMode は、どのフローかによって異なります。

フロー例一般的な prorationBillingMode
プランのアップグレードStarter → Pro(同じ契約期間)prorated_immediately — ユーザーはすぐに上位プランを利用でき、差額を今すぐ支払うことを期待します。
プランのダウングレードPro → Starter(同じ契約期間)prorated_immediately または prorated_next_billing_period。どちらの場合も、Starter はすぐに Pro に置き換わります。未使用の Pro の期間はクレジットとなり、カードへの返金はありません。クレジットはすぐに適用され(超過分は顧客のクレジット残高に加算されます)、または次回の更新時に適用されます。
契約期間の変更(長くする)Pro 月額 → Pro 年額prorated_immediately — 年額料金は大幅に高くなるため、今すぐ請求するのが適切です。
契約期間の変更(短くする)Pro 年額 → Pro 月額prorated_immediately — 契約期間の変更では *_next_billing_period は使用できません。未使用の年額契約期間分は新しいプランの料金に充当され、超過分は顧客のクレジット残高に加算されます。カードへの返金はありません。
トライアル中のプラン変更Starter トライアル → Pro トライアルdo_not_bill — 有料トライアルを含め、trialing 状態のサブスクリプションでアイテムを変更するときに使用できる唯一のモードです。今すぐ請求するには、まず paddle.subscriptions.activate(subscriptionId) でトライアルを終了します。
アイテムの請求間隔を揃えるルール

サブスクリプションのアイテムはすべて、同じ請求間隔である必要があります。そのため、ユーザーがPro 月額プランと月額アドオンを利用していて、Pro 年額に切り替える場合、アドオンも同時に年額にする必要があり、月額のままにはできません。実際には、次のとおりです。

  • プランとアドオンを販売している場合、更新時の items 配列には、新しい契約期間に一致する新しいアドオンの価格 ID を含める必要があります。
  • プランのみを販売している場合、この制約の影響はありません。

これは SDK に送信する内容に関する制約であり、UX 上の考慮事項だけではありません。API は、請求間隔が混在する items 配列を拒否します。

請求間隔の変更には、さらに 2 つのルールがあります。すべてのアイテムを置き換える必要があり、prorationBillingMode は prorated_immediately、full_immediately、または do_not_bill(トライアル中は do_not_bill のみ)でなければなりません。いずれかのルールに違反すると subscription_new_items_not_valid が返されます。エラーの errors 配列には、違反したルールが記載されます。

サブスクリプションのステータスを確認する

subscriptions.update が受け付ける内容は、サブスクリプションの現在の status によって異なります。

ステータスupdate が受け付ける内容
activeこのスキル内のすべて。
trialingdo_not_bill のみを指定したアイテムの変更です。有料トライアルも含まれます(それ以外の場合は subscription_trialing_items_update_invalid_options が返されます)。トライアルを終了して今すぐ請求するには、先に paddle.subscriptions.activate(subscriptionId) を呼び出します。自動回収が有効で、保存済みの支払い方法が必要です。
past_due{ scheduledChange: null } のみです。これにより、スケジュールされた変更が削除されます。それ以外は subscription_update_when_past_due が返されるため、まず支払い方法を更新するようユーザーに依頼してください。
pauseddo_not_bill を指定したアイテムの変更のみです。
canceled何も受け付けません(subscription_update_when_canceled)。ユーザーは新たにチェックアウトする必要があります。

また、次の点も考慮してください。

  • 更新ロック。 次回更新の30分以内に更新を行うと、409 subscription_locked_renewal が返されます。更新後にもう一度試すようユーザーに依頼してください。
  • レート制限。 即時に請求する変更では、429 が返されることがあります。時間をおいて再試行してください。
  • スケジュールされた変更。 サブスクリプションにキャンセル、一時停止、または再開がスケジュールされている場合、*_next_billing_period モードは subscription_invalid_billing_mode_for_scheduled_change で失敗します。

確定前にプレビューする

複雑なフローでは、特に契約期間の変更、日割り計算額が明らかでない場合、またはユーザーに確認を求める場合は、確定前にプレビューを表示してください。Paddle の subscriptions.previewUpdate は update と同じリクエスト本文を受け付けますが、変更は適用しません。

const preview = await paddle.subscriptions.previewUpdate(subscriptionId, {
  items: [{ priceId: newPriceId, quantity: 1 }],
  prorationBillingMode: "prorated_immediately",
});

// preview.immediateTransaction        — the prorated charge details (if any)
// preview.recurringTransactionDetails — the new ongoing price
// preview.nextBilledAt                — when the next renewal will hit
// preview.updateSummary               — credit, charge, and the net result of the change

確認画面には金額を表示します(「本日 $X が請求され、その後 から年間 $Y が請求されます」)。1行で確認内容を示す場合は、preview.updateSummary.result を使用してください。action は "charge" または "credit"、amount は currencyCode で示される通貨の最小単位による正味金額です(文字列)。ユーザーが確認したら、同じ本文で subscriptions.update を実行します。日割り計算額を自分で計算しようとしないでください。プレビューが正しい金額の基準であり、税金、通貨、既存のクレジット残高など、把握していない可能性がある要素も計算に反映されます。

価格差が小さく明らかな単純な「Pro にアップグレード」フローでは、ボタンのラベルからユーザーが同意内容を把握できると考えて、プレビューを省略できます。それ以外の場合、プレビューを表示すれば、問い合わせを未然に防げるため十分に価値があります。

paddle.subscriptions.update がアイテムを処理する方法

更新時の items フィールドは、サブスクリプションの明細項目を追加するのではなく、完全に置き換えます。

// BEFORE: subscription has items: [{ priceId: 'pri_starter_monthly', quantity: 1 }]

await paddle.subscriptions.update(subscriptionId, {
  items: [{ priceId: "pri_pro_monthly", quantity: 1 }],
  prorationBillingMode: "prorated_immediately",
});

// AFTER: subscription has items: [{ priceId: 'pri_pro_monthly', quantity: 1 }]
//        the Starter item is gone — replaced by Pro.

単一アイテムのプラン変更であれば、これがまさに望ましい動作です。既存のアイテムを取得して新しいアイテムを連結しないでください。そうすると、両方のプランの料金が同時に請求されるサブスクリプションになってしまいます。

複数アイテムのサブスクリプション(ベースプラン+アドオン)の場合は、どのアイテムを残すかを考える必要があります。その場合は、現在のサブスクリプションを取得し、アイテム配列を慎重に変更してから、変更後の配列を渡してください。

Server Action 全体

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

import { revalidatePath } from "next/cache";
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";

// Messages for the Paddle API errors this action expects to see.
const UPDATE_ERRORS: Record<string, string> = {
  subscription_payment_declined: "Your payment was declined. Update your payment method and retry.",
  subscription_locked_renewal: "Your subscription is about to renew. Try again after it renews.",
  subscription_update_when_past_due: "Update your payment method before changing plan.",
};

export async function updateSubscription(subscriptionId: string, newPriceId: string) {
  // 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. Verify the authenticated user owns this subscriptionId. Same email-bridge
  //    pattern as subscription-cancel.
  const admin = await createAdminClient();

  const { data: customerRow } = await admin
    .from("customers")
    .select("customer_id")
    .eq("email", user.email)
    .single();
  if (!customerRow) return { error: "No Paddle customer" };

  const { data: subRow } = await admin
    .from("subscriptions")
    .select("customer_id, subscription_status")
    .eq("subscription_id", subscriptionId)
    .single();
  if (!subRow || subRow.customer_id !== customerRow.customer_id) {
    return { error: "Forbidden" };
  }

  // 3. Apply the plan change. Replace items with the single new item. Trialing
  //    subscriptions (paid trials too) only accept `do_not_bill`; otherwise bill
  //    the prorated difference now (the right call for an "Upgrade" button).
  //    The default behaviour on payment failure is `prevent_change` — the
  //    upgrade only applies if the prorated charge succeeds. That's the safe
  //    choice for self-serve. See "Handling payment failure" below for when
  //    'apply_change' is the right override.
  const prorationBillingMode =
    subRow.subscription_status === "trialing" ? "do_not_bill" : "prorated_immediately";

  try {
    const paddle = getPaddleInstance();
    const subscription = await paddle.subscriptions.update(subscriptionId, {
      items: [{ priceId: newPriceId, quantity: 1 }],
      prorationBillingMode,
    });

    // 4. Refresh cached UI.
    revalidatePath("/dashboard/subscriptions");

    // 5. Slim DTO — don't leak the raw Subscription.
    return {
      success: true,
      priceId: subscription.items[0]?.price?.id ?? null,
      status: subscription.status,
    };
  } catch (error) {
    // 6. Turn Paddle API errors into a message the UI can show; rethrow anything else.
    if (error instanceof ApiError) {
      return { error: UPDATE_ERRORS[error.code] ?? "Couldn't change your plan. Please try again." };
    }
    throw error;
  }
}

ユーザーに表示される内容と請求される内容

prorated_immediately によるアップグレードでは、Paddle は次の処理を行います。

  1. 旧プランと新プランの差額を日割り計算したトランザクションを作成し、顧客の既存の支払い方法に請求します。
  2. transaction.created を送信します。その後、請求に成功した場合は transaction.completed を、失敗した場合は transaction.payment_failed を送信します。
  3. 新しいアイテムを反映し、新プランに合わせて再計算した next_billed_at を含む subscription.updated を送信します。

日割り計算された請求が失敗した場合(カードが拒否された、支払い方法の有効期限が切れているなど)、何が起きるかは onPaymentFailure によって決まります。次のセクションを参照してください。

支払い失敗への対処

即時の日割り計算モード(prorated_immediately または full_immediately)を選択すると、Paddle は更新呼び出し中に同期的に顧客への請求を試みます。請求に失敗した場合、プラン変更を適用するかどうかは onPaymentFailure フィールドで決まります。

値支払い失敗時の動作
prevent_change(デフォルト)プラン変更は適用されません。サブスクリプションは旧プランのままです。API は subscription_payment_declined を返すため、UI に「支払いに失敗しました。もう一度お試しください」という状態を明確に表示できます。
apply_changeプラン変更はそのまま適用されます。サブスクリプションは active のままで、トランザクションのみ past_due になります。そのため、日割り計算分は後から回収できます。ユーザーがこの結果を明示的に受け入れているため、_サブスクリプション自体_は督促プロセスに入りません。

ワークフローに応じて選択してください。

  • デフォルトの prevent_change は、ほとんどのセルフサービス UX に適しています。ユーザーが「アップグレード」をクリックし、カードが拒否されると、「支払いに失敗しました」と表示され、カード情報を更新して再試行できます。すべての手順が明示的です。明示的に設定する必要はありません。省略すれば、この動作になります。
  • apply_change は、管理者主導のワークフロー(一時的にカードでの支払いに失敗している顧客のプランを CS 担当者がアップグレードする場合など)や、「先にティアを切り替え、後から回収する」パターンに適しています。顧客はすぐに新プランを利用でき、未払いのトランザクションは非同期で回収できます。回収プロセスをよく検討せずに選択しないでください。実質的に顧客に信用を供与することになります。

管理者主導の場合は、onPaymentFailure を明示的に設定します。

// Admin-driven upgrade: apply the change even if the card fails. The customer
// gets the new plan; the prorated charge sits in past_due until you collect.
await paddle.subscriptions.update(subscriptionId, {
  items: [{ priceId: newPriceId, quantity: 1 }],
  prorationBillingMode: "prorated_immediately",
  onPaymentFailure: "apply_change",
});

Node SDK ではキャメルケースの onPaymentFailure を使用します。ワイヤーフォーマットでは on_payment_failure です。

よくある落とし穴

  • prorationBillingModeの指定ミス。 デフォルト値の表は、このスキルの要です。有効なサブスクリプションをセルフサービスでアップグレードする際にdo_not_billを使うと、請求会計に抜けが生じます。full_immediatelyでは過剰請求になり、prorated_next_billing_periodでは請求を想定していたユーザーを驚かせます。有効なサブスクリプションで「Proにアップグレード」のUXを提供する場合は、prorated_immediatelyを使ってください。トライアル中および一時停止中のサブスクリプションは例外で、do_not_billのみ受け付けます。
  • アイテムを置き換えずに追加する。 ミラーからsubscription.itemsを取得し、新しいアイテムを連結して結合済みの配列を送信すると、2つのプランに対して請求されるサブスクリプションになります。このエンドポイントはアイテムを置き換えます。必要なものだけを渡してください。
  • 所有権の確認をしない。 subscription-cancelと同様、subscriptionIdの入力を無条件に信頼すると、認証済みのユーザーなら誰でも、任意のサブスクリプションのプランをアップグレードできてしまいます。
  • 認証を確認しない。 Server Actionsは匿名ユーザーやスクリプト、直接のPOSTなど、どこからでも呼び出せます。必ずセッションを確認してください。
  • apply_change使用時に、アクションの戻り値をもとに楽観的UIを表示する。 デフォルトのprevent_changeでは、即時モードで{ success: true }が返れば、按分請求が完了しています。apply_changeでは、プランが変更されたことだけを意味し、請求はpast_dueのままかもしれません。支払いの成功に依存する機能を有効にする前に、ウェブフック経由でtransaction.completedを待ってください。
  • revalidatePathを呼び出さない。 UIには、手動で更新するまで古いプランが表示されます。
  • 生のSubscriptionを返す。 キャンセル時と同様、SDKのインスタンスは大きいため、必要な情報だけに絞ってください。
  • 単純でない変更で、プレビュー手順を省略する。 特に契約期間の変更では、按分計算が分かりにくく、ざっくりとした手計算では見落とすような既存残高へのクレジット適用、税金の適用、通貨の換算がPaddleで行われる場合があります。確定前にpreviewUpdateの実際の数値を確認画面でユーザーに示せば、「待って、なぜこんな金額を請求されたの?」という問い合わせを避けられます。価格への影響が小さく明らかな場合に限り、プレビューを省略してください。
  • onPaymentFailureの存在を知らない。 デフォルトのprevent_changeはセルフサービスに適切なため、上書きする必要はありません。落とし穴は、apply_changeが必要なフロー(たとえば、一時的にカード決済に失敗している顧客をCS担当者がアップグレードする場合)で、このオプションの存在に気づかないことです。このフィールドがあると知らなければ、もっと扱いにくい方法で制限を回避することになります。
  • アイテムの請求間隔が混在している。 ティアとアドオンを販売し、ユーザーが月払いと年払いを切り替えられるようにする場合、アドオンもティアに合わせて変更する必要があります。[{ priceId: 'pri_pro_annual' }, { priceId: 'pri_addon_monthly' }]を送信すると失敗します。Paddleは請求間隔が混在するitems配列を拒否します。「契約期間の切り替え」フローを設計する際は、ティアとともにアドオンの価格IDも入れ替えるようにしてください。
  • ダウングレード時に返金されると思い込む。 カードに返金するモードはありません。旧プランの未使用期間分はクレジットになります。prorated_immediatelyではすぐに適用され(超過分は顧客のクレジット残高に加算されます)、prorated_next_billing_periodでは更新時に適用されます。また、どのモードでもアイテムはすぐに切り替わります。*_next_billing_periodが先送りするのは請求であり、プラン変更ではありません。

統合を検証する

  1. 2つのプランのうち安価なプラン(例:月額 Starter)のサンドボックスサブスクリプションを用意します。
  2. アップグレードのリクエスト本文を指定して paddle.subscriptions.previewUpdate を呼び出します。返された数値(即時のトランザクション、継続課金の詳細、次回請求日時、更新の概要)が想定どおりであることを確認します。
  3. 「Pro にアップグレード」をクリックします。この操作から { success: true, priceId: 'pri_pro_monthly', status: 'active' } が返されるはずです。
  4. Paddle ダッシュボードで、次の点を確認します。
    • サブスクリプションのアイテムが Pro のみになっている(Starter がなくなっている)。
    • 比例配分された差額を含む新しいトランザクションが表示され、ステップ 2 のプレビューと一致している。
  5. subscription.updated Webhook が実行された後、DB ミラーを確認します。行の price_id と product_id が新しい値になっていることを確認します。
  6. サンドボックスのカードを決済が拒否されるテストカードに切り替えて、アップグレードを再試行します。onPaymentFailure: 'prevent_change' の場合、サブスクリプションが旧プランのままで、操作から決済拒否エラーが返されることを確認します。onPaymentFailure: 'apply_change' に切り替え、サブスクリプションが新プランに切り替わり、active のままになり、past_due のトランザクションが表示されることを確認します。
  7. トライアル中のサブスクリプションでは、操作が do_not_bill を送信し、請求なしで変更が適用されることを確認します。
  8. 実際には提供していない newPriceId を指定して操作を試します。Paddle が拒否することを確認します(または、より望ましい方法として、SDK を呼び出す前に操作が newPriceId を PricingTier 定数と照合して検証することを確認します)。
  9. ログアウトした状態、または別のユーザーに属する subscriptionId を偽造した状態で操作を試します。SDK が呼び出される前に、どちらも拒否されることを確認します。

関連ドキュメント

コメント