mcpField
P
paddle-subscription-cancel

paddle-subscription-cancel

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

paddle-subscription-cancel について

認証済みの請求画面にサブスクリプション解約を実装する、Next.js 15 App Router向けのスキルです。解約時期、解約予約の取り消し、認証と所有権の確認、状態変化への対応を説明します。

主な機能

  1. 01Server Actionでサブスクリプションを解約
  2. 02期間終了時または即時の解約を予約
  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-cancel

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

詳細

Next.js から Paddle サブスクリプションをキャンセルする

このスキルを使う場面

認証済みユーザーの請求またはアカウントページに「サブスクリプションをキャンセル」ボタン(または同等の機能)を実装するときに、このスキルを使います。適切なオプションを指定して paddle.subscriptions.cancel() を呼び出す Next.js 15(App Router)の Server Action、キャンセル処理ごとに必要なセキュリティチェック、キャンセル成功後の処理、Paddle の canceled ステータスと scheduledChange ブロックの関係について説明します。

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

  • subscription-sync は_受信する_側の処理です。Webhook ハンドラーは、スケジュール変更を反映する subscription.updated イベントを受信し、期間が実際に終了したときに subscription.updated イベントと subscription.canceled イベントを受信します。
  • Webhook エンドポイントをまだ設定していない場合は、webhooks を参照してください。

独自のキャンセルボタンは不要ですか?代わりに次の方法があります。

  • カスタマーポータルのディープリンク。 paddle.customerPortalSessions.create(customerId, [subscriptionId]) でポータルセッションを作成し、session.urls.subscriptions[0].cancelSubscription にリダイレクトします(customer-portal を参照)。キャンセル UI は Paddle がホストします。
  • Paddle Retain のキャンセルフロー。 Paddle Retain でキャンセルフローを設定済みの場合は、Paddle.js を使ってブラウザーから起動できます。paddle.Retain.initCancellationFlow({ subscriptionId }) を実行します。まず解約を防ぐオファーが表示され、顧客がキャンセルを進めると、Retain が Paddle 上でサブスクリプションをキャンセルします。Promise は retained、aborted、chose_to_cancel、error のいずれかの status で解決します。Retain は sandbox では利用できないため、このフローは live でテストしてください。

前提条件

  • 少なくとも 1 つの有効なサブスクリプションがある、動作中の Paddle アカウント(sandbox でもかまいません)。
  • サーバー側で PADDLE_API_KEY が利用可能であること。このアクションは Server Action で実行され、ブラウザーでは実行されません。このキーには subscription.write 権限(cancel と update で使用)が必要です。
  • Webhook と同期された customers テーブルと subscriptions テーブルがあること(subscription-sync を参照)。認証済みユーザーがどの Paddle 顧客なのか、またどのサブスクリプションをキャンセルしようとしているのかを把握する必要があります。
  • 認証システムがあること。以下の例では 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 の関数名です。

paddle.subscriptions.cancel の仕組み

const subscription = await paddle.subscriptions.cancel(subscriptionId, {
  effectiveFrom: "next_billing_period", // or "immediately"
});

effectiveFrom は、キャンセルが いつ 有効になるかを制御します。

値意味使用するタイミング
next_billing_period現在の請求期間の終了時にキャンセルが 予約 されます。その時点まではステータス(active、trialing、past_due)は変わらず、scheduledChange に action: "cancel" が設定され、nextBilledAt は null になります。一般的な「サブスクリプションをキャンセル」ボタンのデフォルトです。ユーザーはその期間分の料金を支払っているため、期間終了まで利用できるようにします。
immediatelyキャンセルが ただちに 有効になります。Paddle は返金やクレジット付与を行わず、自動回収のサブスクリプションにおける未払いの取引をキャンセルします。まれなケースです。デフォルトとして使うとユーザーを驚かせます。「キャンセルして返金」する場合は、返金調整も作成してください。

ユーザーが期間終了時にのみキャンセルできるようにする場合(一般的な UX です)は、'next_billing_period' を渡し、'immediately' は一切公開しないでください。

例外は paused 状態のサブスクリプションです。この場合は immediately でのみキャンセルできます。next_billing_period を渡すと、effective_from のエラーとともに 400 bad_request が返されるため、ステータスに応じて処理を分岐してください。

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 these actions expect to see.
const CANCEL_ERRORS: Record<string, string> = {
  subscription_locked_renewal: "Your subscription is about to renew. Try again after it renews.",
  subscription_locked_pending_changes: "This subscription already has a scheduled change.",
};

export async function cancelSubscription(subscriptionId: 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. The check
  //    bridges through the customers table (matched on the user's email)
  //    and the subscriptions table (matched on customer_id).
  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. Cancel via the Paddle Node SDK. Schedule for end-of-period (the safe
  //    default for a generic Cancel button — the user keeps service through
  //    what they've already paid for). Paused subscriptions can only be
  //    canceled immediately.
  const effectiveFrom =
    subRow.subscription_status === "paused" ? "immediately" : "next_billing_period";

  try {
    const paddle = getPaddleInstance();
    const subscription = await paddle.subscriptions.cancel(subscriptionId, { effectiveFrom });

    // 4. Refresh any cached UI that depends on subscription state.
    revalidatePath("/dashboard/subscriptions");

    // 5. Return a slim DTO. Don't return the raw Subscription object —
    //    it's large and leaks Paddle internal fields.
    return {
      success: true,
      status: subscription.status,
      scheduledChange: subscription.scheduledChange?.effectiveAt ?? null,
    };
  } catch (error) {
    // 6. Turn Paddle API errors into a message the UI can show; rethrow anything else.
    if (error instanceof ApiError) {
      return { error: CANCEL_ERRORS[error.code] ?? "Couldn't cancel. Please try again." };
    }
    throw error;
  }
}

予約済みのキャンセルを取り消す

期間が終了するまでは、ユーザーは気が変わることがあります。subscriptions.update で予約済みの変更を削除してください。

// src/actions/subscription.ts (same file as cancelSubscription)
export async function undoCancelSubscription(subscriptionId: string) {
  // 1–2. Run the same auth and ownership checks as cancelSubscription.

  try {
    const paddle = getPaddleInstance();
    const subscription = await paddle.subscriptions.update(subscriptionId, {
      scheduledChange: null,
    });

    revalidatePath("/dashboard/subscriptions");
    return { success: true, status: subscription.status };
  } catch (error) {
    if (error instanceof ApiError) {
      return { error: CANCEL_ERRORS[error.code] ?? "Couldn't undo. Please try again." };
    }
    throw error;
  }
}

キャンセルと取り消しのどちらにも、同じ 30 分間のロックが適用されます。次回更新まで 30 分を切っている場合、どちらも 409 subscription_locked_renewal を返します。

ユーザーに表示される内容とデータベースに保存される内容

キャンセルが成功しても、サブスクリプションの status がただちに canceled に切り替わるわけではありません。イベントは時間の経過とともに発生します。

  1. ただちに(同期): SDK 呼び出しが戻ります。Paddle は現在の status(active、trialing、past_due)を維持し、scheduledChange.action: 'cancel' と scheduledChange.effectiveAt: '<period-end-date>' を設定して、nextBilledAt を null にします。
  2. ただちに(非同期): Paddle は同じペイロードを含む subscription.updated webhook を発行します。subscription-sync ハンドラーが行を upsert します。ミラーされた行の subscription_status はそのまま維持され、scheduled_change: <date> が設定されます。
  3. 期間終了時: Paddle は subscription.updated と subscription.canceled の両方を発行し、どちらにも status: 'canceled' が含まれます。どちらが先に到着するかに依存しないでください。ミラーされた行の subscription_status は 'canceled' になり、scheduled_change はクリアされます。

アクセス制御ロジックでは、このアクション内で楽観的に計算した値ではなく、ミラーされた 行を参照してください。ステップ 3 が実際に発生するまでは、ユーザーはアクセスを維持します。

よくある落とし穴

  • 所有者チェックがない。 アクション入力のsubscriptionIdを信頼すると、認証済みのユーザーなら誰でも、IDを推測または取得できるサブスクリプションをキャンセルできてしまいます。認証済みユーザーのcustomer_idとサブスクリプションのcustomer_idを照合する必要があります。
  • 認証チェックがない。 これは前の問題よりさらに深刻です。誰でも(未認証のリクエストも含めて)任意のサブスクリプションをキャンセルできてしまいます。サーバーアクションはどこからでも呼び出せます。「UIがないからアクセスできない」と決して思い込まないでください。
  • effectiveFrom: 'immediately'をデフォルトにする。 ユーザーが押したのは「キャンセル」であり、「今すぐキャンセル」ではありません。即時キャンセルすると、返金なしでアクセス権がただちに失われます。UXで「すぐにキャンセルしますか?」と明示的にユーザーに確認する場合を除き、'next_billing_period'を使用してください。
  • SDKからの戻り値を、新しい恒久的な状態として扱う。 キャンセル呼び出しは、スケジュールが設定された_現在の_サブスクリプションを返します。ステータス(active、trialing、past_due)は変わりません。UIに「サブスクリプションのステータス:{return.status}」と表示するのは正しいですが、楽観的に「キャンセル済み」と表示すると、ミラーからページを再読み込みしたときに、ユーザーには矛盾した情報が表示されます。
  • revalidatePathがない。 DBミラーから読み取るサーバーコンポーネントは、自動では再取得しません。キャンセルが成功したら、対象のルートに対してrevalidatePath(またはrevalidateTag)を呼び出してください。呼び出さないと、ユーザーがキャンセルをクリックしても、更新ボタンを押すまでUIに「active」と表示され続けます。
  • Subscriptionをそのまま返す。 SDKのSubscriptionインスタンスには、内部のPaddleメタデータを含む約30個のフィールドがあります。クライアントが知る必要があるのは「成功したかどうか」と、場合によっては「実際にいつ終了するか」だけです。DTOに絞り込んでください。
  • canceledとscheduledChange.action: 'cancel'を混同する。 canceledは期間終了後に設定される_最終_状態です。scheduledChange.action: 'cancel'は「ユーザーがキャンセルを押し、まもなく終了する」状態です。アクセス制御でアクセス権を取り消すのは前者の場合だけであり、後者では取り消してはいけません。
  • 顧客レコードがまだない。 新規ユーザー(チェックアウトを完了していない)が何らかの理由でキャンセルアクションを呼び出すと、参照するcustomer_idがありません。customerRow == nullの場合を明示的に処理し、nullを比較に渡すのではなく、エラーを返してください。

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

  1. 開発用アプリにサインインし、サンドボックスでチェックアウトを完了して、実際のサブスクリプションを作成します。
  2. subscription.createdウェブフックによってsubscriptionsテーブルにデータが登録されるまで待ちます。
  3. UIで「キャンセル」をクリックします。サーバーアクションは次の処理を行う必要があります。
    • { success: true, status: 'active', scheduledChange: '<some date>' }を返します。
    • revalidatePathを呼び出して、UIを更新します。
  4. Paddleダッシュボードで、サブスクリプションに予定日とともに「キャンセル予定」と表示されていることを確認します。
  5. 「取り消す」をクリックします。アクションが{ success: true }を返し、サブスクリプションにスケジュールされた変更がなくなっていることを確認します。
  6. ログアウト中(または別の顧客に属する偽造したsubscriptionIdを使用した状態)にアクションを試します。エラーが返され、paddle.subscriptions.cancelが呼び出されていないことを確認します。
  7. 期間終了後、subscription.updatedイベントとsubscription.canceledイベントのどちらが先に到着しても、ミラー上の行のsubscription_status: 'canceled'が更新されることを確認します。

関連ドキュメント

コメント