Menu

決済フローとカスタム開発

テンプレート購入者がより簡単に開始し、独自の決済ロジックを実装できるように、この章ではNexty.devの組み込み決済フローのコード実装を紹介し、カスタムロジック開発の開始方法を説明します。

決済フロー

このセクションでは、ユーザーが認識できる決済フローに焦点を当て、Webhook部分は次のセクションで別途説明します。

料金プランの表示

料金カードを表示するコンポーネントは components/home/Pricing.tsx とその子コンポーネントです。

getPublicPricingPlans メソッドを使用して、管理者が設定したアクティブな料金カードを取得します。

pricing

ユーザー決済

ユーザーが購入ボタンをクリックすると、/api/stripe/checkout-session エンドポイントにリクエストが送信されます。

/api/stripe/checkout-session エンドポイントでは、Stripeアカウント内の現在のユーザーの一意識別子(customer_id)を照会し、ユーザーが購入したいプランに基づいて、Stripeに決済ページ(checkoutページ)のアドレスを要求します。

Stripeに送信されるパラメータには success_urlcancel_url も含まれ、決済成功時と決済キャンセル時にリダイレクトするページをそれぞれ指定します。

フロントエンドがcheckoutアドレスを受信すると、Stripeの決済フローに入ります。決済が成功すると、success_url で指定されたページにリダイレクトされます。

注目すべき点

  • success_url は変更する必要はありませんが、app/[locale]payment/success/page.tsx ページの内容をビジネスに合わせて変更する必要があります
  • cancel_url も通常変更する必要はありません。ホームページの料金カードエリアを指しています

注目すべき点

  • 決済フローをテストする前に、Stripe統合章でWebhookの作成やForward Portの有効化などの重要な手順を完了する必要があります。

  • Stripeが提供するテスト情報を使用して決済フローをテストしてください:

    • クレジットカード:4242 4242 4242 4242
    • 12/34など、任意の有効な将来の日付を使用
    • 任意の3桁のCVC(American Expressカードの場合は4桁)を使用
    • その他のフォームフィールドには任意の値を使用

決済成功

app/[locale]payment/success/page.tsx ページを変更する必要があります。Nexty.devテンプレートのデフォルトの決済成功ページは、ユーザーが購入した料金プランを表示するためです。より良いユーザーエクスペリエンスを提供するため、ビジネスに応じて表示内容を変更してください。

payment success

ユーザー特典の表示

まず、組み込み初期化された料金プランを購入してみましょう。一回限りの支払いと定期購読の両方を購入してください。完了後、/dashboard/subscription と右上角のドロップダウンメニューで現在の特典を確認できます。

benefits

SupabaseのTable Editorを開くと、orderssubscriptionsusage テーブルにそれぞれ対応するデータが表示されます。

ユーザー特典を取得するコアメソッドは lib/stripe/actions.tsgetUserBenefits で、返される特典情報は以下のように定義されています:

export interface UserBenefits {
  activePlanId: string | null;
  subscriptionStatus: string | null; // 例:'active', 'trialing', 'past_due', 'canceled', null
  totalAvailableCredits: number;
  subscriptionCreditsBalance: number;
  oneTimeCreditsBalance: number;
  // 必要に応じて他のプラン固有の特典を追加、planIdで取得
}

特典をカスタマイズする場合、以下に注意してください:

  • activePlanIdsubscriptionStatussubscriptions から取得され、ユーザーが購入したプランと現在の購読状況を反映しており、変更すべきではありません
  • totalAvailableCreditssubscriptionCreditsBalanceoneTimeCreditsBalanceusage テーブルから取得され、組み込みの一回限り支払いと定期購読のクレジット残高を反映しています。必要ない場合でも、保持することをお勧めしますが使用しない、つまり getUserBenefits メソッドを呼び出す場所でこれら3つのパラメータの戻り値を取得しないだけです
  • カスタム特典は既存の UserBenefits を拡張し、同時に getUserBenefitsusage テーブルクエリメソッドと戻りオブジェクト(つまり return オブジェクト)を変更する必要があります

getUserBenefits への変更完了後、components/layout/CurrentUserBenefitsDisplay.tsx を確認する必要があります。このコンポーネントは /dashboard/subscription ページと右上角に表示されるユーザー特典に影響するためです。

注目すべき点

UserBenefits の元の定義を変更することを主張する場合、以下のファイルの対応フィールドも変更が必要かどうかを確認する必要があります:

  • app/[locale]/(protected)/dashboard/(user)/subscription/page.tsx
  • actions/usage/deduct.ts

ユーザークレジット使用のシミュレート

コードリポジトリv1.1.4以降、Nexty.devはユーザークレジット使用をシミュレートする例を提供しています。

プロジェクトをローカルで起動し、/dashboard/credit-usage-example ページに移動して、組み込みのクレジット控除フローをテストできます。このページは開発環境でのみ開くことができるため、本番環境での悪用を心配する必要はありません。

/dashboard/credit-usage-example と連携するクレジット控除メソッドは actions/usage/deduct.ts にあり、SupabaseのRPC(Remote Procedure Call)を呼び出します。対応するRPC定義は data/5、usage(deduct_rpc_demo).sql ファイルにあります。

注目すべき点

ユーザークレジット使用をシミュレートする前に、以下を確認してください:

  • SupabaseのSQL Editorで data/5、usage(deduct_rpc_demo).sql を実行済み
  • ローカルでSupabaseデータベース定義を更新済み。Supabase統合の手順を参照してください

credit-usage-example

actions/usage/deduct.ts は4つのクレジット控除戦略を提供します:

// 1. 一回限りクレジットのみ控除
await deductOneTimeCredits(amount, locale);
 
// 2. 購読クレジットのみ控除
await deductSubscriptionCredits(amount, locale);
 
// 3. 購読クレジットを優先して控除
await deductCreditsPrioritizingSubscription(amount, locale);
 
// 4. 一回限りクレジットを優先して控除
await deductCreditsPrioritizingOneTime(amount, locale);

これら4つのメソッドは直接使用できますが、実際の開発では、これらのメソッドはフロントエンドで呼び出すべきではなく、ユーザーが機能を使用完了したサーバーサイドで呼び出すべきです。

クレジットシステムと控除メソッドをカスタマイズする必要がある場合、これらのメソッドを参考に新しいカスタムメソッドを実装できます。

Webhook

Stripe統合章では、Stripeコンソールで以下の7つのWebhookイベントを既に選択しています:

  • charge.refunded
  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.payment_failed

Nexty.devコードでは、Webhookを受信するエントリーポイントは app/api/stripe/webhook/route.ts で、主な処理ロジックは processWebhookEvent メソッドにあります。

以下のドキュメントとコードを比較しながら、Nexty.devが提供する決済システムを深く理解してください。

一回限り支払い

一回限り支払いでは、checkout.session.completed イベントの受信はユーザーが決済を完了し、Stripe側で注文が生成されたことを示すため、これを一回限り支払い特典の処理基準として使用できます。

Stripeを含む決済プラットフォームでは、Webhookイベントの重複プッシュが発生する可能性があるため、orders テーブルに注文データを書き込む前に、データベースに同じ注文データが既に存在するかを最初に確認する必要があります。これは「冪等性チェック」で、handleCheckoutSessionCompleted メソッドで実行されます。

const { data: existingOrder, error: queryError } = await supabaseAdmin
  .from('orders')
  .select('id')
  .eq('provider', 'stripe')
  .eq('provider_order_id', paymentIntentId)
  .maybeSingle();
 
if (existingOrder) {
  return;
}

冪等性チェックを通過したイベントに対して、Stripeがプッシュした必要な情報をデータベースに書き込み、同時に status を通じて注文を完了としてマークし、order_type を通じてこれを一回限り支払い注文としてマークします。

const orderData = {
  user_id: userId,
  status: 'succeeded',
  order_type: 'one_time_purchase',
  // ...その他...
};

orders テーブルへの注文データ書き込み完了後、一回限り支払いユーザーの特典アップグレードを開始できます。つまり、upgradeOneTimeCredits メソッドです。

// --- [custom] ユーザーの特典をアップグレード ---
upgradeOneTimeCredits(userId, planId);
// --- 終了:[custom] ユーザーの特典をアップグレード ---

upgradeOneTimeCredits ロジックでは、ユーザーが購入した有料プランのplan_idフィールドに基づいてデータベースをクエリし、管理者が benefits_jsonb(つまり、料金プラン作成/編集ページのBenefitsアイテム)で設定した特典を取得します。デフォルトでは、組み込みの one_time_credits を使用してユーザーにクレジットを追加します。

const { data: planData, error: planError } = await supabaseAdmin
  .from('pricing_plans')
  .select('benefits_jsonb')
  .eq('id', planId)
  .single();
 
const creditsToGrant = (planData.benefits_jsonb as any)?.one_time_credits || 0;
 
if (creditsToGrant && creditsToGrant > 0) {
  const { error: usageError } = await supabaseAdmin.rpc('upsert_and_increment_one_time_credits', {
    p_user_id: userId,
    p_credits_to_add: creditsToGrant
  });
}

カスタマイズされた benefits_jsonb で料金プランを作成する場合、要件を満たすために upgradeOneTimeCredits メソッドを再実装する必要があります。

定期購読と更新

定期購読では、最初の支払いのみが checkout.session.completed イベントをトリガーし、その後の更新はトリガーしないため、定期購読シナリオではこのイベントを処理しません。

最初の購読と更新支払い状況を追跡するため、ユーザー支払いが受信されたことを示す invoice.paid を聞くべきです。さらに、最新の購読情報を取得するため、最初の購読の customer.subscription.created と購読更新の customer.subscription.updated も聞く必要があります。

注目すべき点

StripeのSubscriptionオブジェクトでは、購読ステータスフィールドに以下の値が含まれる場合があります:

'active' | 'canceled' | 'incomplete' | 'incomplete_expired' | 'past_due' | 'paused' | 'trialing' | 'unpaid';

invoice.paid

invoice.paid のコア処理ロジックは handleInvoicePaid メソッドにあります。これも冪等性チェックが必要です:

const { data: existingOrder, error: queryError } = await supabaseAdmin
  .from('orders')
  .select('id')
  .eq('provider', 'stripe')
  .eq('provider_order_id', invoiceId)
  .maybeSingle();
 
if (existingOrder) {
  return;
}

冪等性チェックを通過後、Stripeがプッシュした必要な情報をデータベースに書き込み、status を通じて注文を完了としてマークし、order_type を通じてこれを購読支払い注文としてマークします。

const orderType = invoice.billing_reason === 'subscription_create' ? 'subscription_initial' : 'subscription_renewal';
 
const orderData = {
  user_id: userId,
  status: 'succeeded',
  order_type: orderType,
  // ...その他...
};

orders テーブルへの注文データ書き込み完了後、定期購読ユーザーの特典のアップグレードまたは更新を開始できます。つまり、upgradeSubscriptionCredits メソッドです。

// --- [custom] ユーザーの特典をアップグレード ---
upgradeSubscriptionCredits(userId, planId, invoiceId);
// --- 終了:[custom] ユーザーの特典をアップグレード ---

upgradeSubscriptionCredits ロジックでは、ユーザーが購入した有料プランのplan_idフィールドに基づいてデータベースをクエリし、管理者が benefits_jsonb(つまり、料金プラン作成/編集ページのBenefitsアイテム)で設定した特典を取得します。デフォルトでは、組み込みの monthly_credits を使用してユーザーにクレジットを追加します。

const { data: planData, error: planError } = await supabaseAdmin
  .from('pricing_plans')
  .select('benefits_jsonb')
  .eq('id', planId)
  .single();
 
const creditsToGrant = (planData.benefits_jsonb as any)?.monthly_credits || 0;
 
if (creditsToGrant && creditsToGrant > 0) {
  const { error: usageError } = await supabaseAdmin.rpc('upsert_and_set_subscription_credits', {
    p_user_id: userId,
    p_credits_to_set: creditsToGrant
  });
}

カスタマイズされた benefits_jsonb で料金プランを作成する場合、要件を満たすために upgradeSubscriptionCredits メソッドを再実装する必要があります。

handleInvoicePaid メソッドの最後では、syncSubscriptionData メソッドも呼び出して、Stripeからユーザーの購読情報を取得し、データベースに保存します。この手順は、実際の運用では、ネットワークなどの理由により、Stripeがプッシュするイベントの順序と実際にWebhookで受信する順序が異なる場合があるためです。Webhookが運ぶ情報のみに基づいて更新すると、不正なデータの上書きが発生する可能性があります。syncSubscriptionData はベストプラクティスに従い、Stripeから最新データを積極的に取得するため、Stripeがプッシュするイベントの実際の順序を心配する必要がありません。

try {
  await syncSubscriptionData(subscriptionId, customerId);
} catch (syncError) {
  console.error(`Error during post-invoice sync for sub ${subscriptionId}:`, syncError);
}

customer.subscription.created

customer.subscription.created は最初の購読時にトリガーされ、リスニングする理由も最新のユーザー購読情報を取得することであるため、上記と同じ理由で syncSubscriptionData も呼び出します。

customer.subscription.updated

customer.subscription.updated は購読更新時にトリガーされ、リスニングする理由も最新のユーザー購読情報を取得することであるため、上記と同じ理由で syncSubscriptionData も呼び出します。

定期購読更新失敗

一回限り支払い失敗シナリオを処理する必要はありません。一回限り支払いが失敗すると、Checkoutページでプロンプトが表示されるためです。

定期購読では、残高不足、カード期限切れなどの理由により、ユーザーが購読更新時に支払い失敗を経験する可能性があるため、更新失敗シナリオを適切に処理する必要があります。つまり、invoice.payment_failed イベントです。

invoice.payment_failed イベントのコア処理ロジックは handleInvoicePaymentFailed メソッドにあります。

このメソッドの最も重要な点は、syncSubscriptionData を呼び出して購読ステータスを更新することです。更新失敗後、ステータス値は以下に変更される可能性があります:

  • past_due:延滞未払い
  • unpaid:未払い

さらに、sendInvoicePaymentFailedEmail メソッドを呼び出して、ユーザーに支払い失敗通知を送信し、ユーザーを取り戻そうとします。メールテンプレートは emails/invoice-payment-failed.tsx にあり、ニーズに応じてカスタマイズできます。

一回限り支払い注文の払い戻し

注文の払い戻しは通常、Stripeコンソールで手動操作されますが、払い戻し後の特典回復はコードによって自動実行される必要があるため、一回限り支払いの払い戻しについて、Nexty.devは charge.refunded イベントのリスニングを提供し、コア処理ロジックは handleRefund メソッドにあります。

charge.refunded のリスニングでも冪等性チェックが必要です:

const { data: existingRefundOrder, error: queryError } = await supabaseAdmin
  .from('orders')
  .select('id')
  .eq('provider', 'stripe')
  .eq('provider_order_id', refundId)
  .eq('order_type', 'refund')
  .maybeSingle();
 
if (existingRefundOrder) {
  return;
}

冪等性チェック通過後、orders テーブルで払い戻し注文の charge.payment_intent に対応するデータを見つけ、このデータの order_typerefund に割り当てます。

その後、特典回復処理を実行します:

// --- [custom] ユーザーの特典を取り消し(一回限り購入のみ) ---
if (originalOrder) {
  revokeOneTimeCredits(charge, originalOrder, refundId);
}
// --- 終了:[custom] ユーザーの特典を取り消し ---

revokeOneTimeCredits ロジックでは、ユーザーが購入した有料プランのplan_idフィールドに基づいてデータベースをクエリし、管理者がbenefits_jsonb(つまり、料金プラン作成/編集ページのBenefitsアイテム)で設定した特典を取得し、特典を回復します:

let oneTimeToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
 
if (benefits?.one_time_credits > 0) {
  oneTimeToRevoke = benefits.one_time_credits;
}
 
if (oneTimeToRevoke > 0) {
  const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
    p_user_id: originalOrder.user_id,
    p_revoke_one_time: oneTimeToRevoke,
    p_revoke_subscription: 0
  });
}

カスタマイズされた benefits_jsonb で料金プランを作成する場合、要件を満たすために revokeOneTimeCredits メソッドを再実装する必要があります。

定期購読のキャンセルと払い戻し

定期購読では、払い戻しのみでは一般的にユーザーの争議を解決できません。正しいアプローチは、ユーザーの購読をキャンセルして払い戻すことです。したがって、定期購読シナリオでは、customer.subscription.deleted イベントをリスニングすべきです。これは一般的にStripeコンソールでの手動操作によってトリガーされます。

customer.subscription.deleted イベントの処理ロジックは handleSubscriptionUpdate にあり、これは最初の購読と更新と同じメソッドで、購読ステータスを更新します。購読をキャンセルすると、ステータスは canceled になり、canceled_atended_at には現在時刻が割り当てられます。

customer.subscription.deleted イベントは handleSubscriptionUpdateisDeleted フラグも渡し、特典回復メソッドをトリガーします:

if (isDeleted && userId && planId) {
  // --- [custom] ユーザーの特典を取り消し(一回限り購入のみ) ---
  revokeSubscriptionCredits(userId, planId, subscription.id);
  // --- 終了:[custom] ユーザーの特典を取り消し ---
}

revokeSubscriptionCredits ロジックでは、ユーザーが購入した有料プランの plan_id フィールドに基づいてデータベースをクエリし、管理者が benefits_jsonb(つまり、料金プラン作成/編集ページのBenefitsアイテム)で設定した特典を取得し、特典を回復します:

let subscriptionToRevoke = 0;
const benefits = planData.benefits_jsonb as any;
 
if (benefits?.monthly_credits > 0) {
  subscriptionToRevoke = benefits.monthly_credits;
}
 
if (subscriptionToRevoke >= 0) {
  const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
    p_user_id: userId,
    p_revoke_one_time: 0,
    p_revoke_subscription: subscriptionToRevoke
  });
}

カスタマイズされた benefits_jsonb で料金プランを作成する場合、要件を満たすために revokeSubscriptionCredits メソッドを再実装する必要があります。

返金処理の方法

顧客とのトラブルに対処する際、返金はStripeコンソールページから開始する必要があります。一回限りの決済と定期購読では、処理方法が異なります。

一回限り決済の返金

一回限り決済の返金の場合、Transactionsページで直接返金処理を行うことができます。

refund-onetime-1

返金完了後、Webhookでcharge.refundedイベントがトリガーされ、上記で分析したワークフローに入ります。

定期購読のキャンセルと返金

定期購読の返金は通常、購読のキャンセルを同時に行う必要があるため、Subscriptionsページで購読をキャンセルし、返金処理を行う必要があります。

refund-sub-1

refund-sub-2

キャンセルと返金完了後、Webhookでcustomer.subscription.deletedイベントがトリガーされ、上記で分析したワークフローに入ります。

カスタム開発チェックリスト

  • app/[locale]payment/success/page.tsx ページを変更し、ビジネスと組み合わせて決済成功表示情報をデザインする
  • ユーザー特典をカスタマイズする場合
    • 特典取得メソッドを変更する必要があります。つまり、lib/stripe/actions.tsgetUserBenefits
    • ユーザー特典表示を変更する必要があります。つまり、components/layout/CurrentUserBenefitsDisplay.tsx コンポーネント
    • 一回限り支払いの場合
      • 特典アップグレードメソッドを再実装する必要があります。つまり、lib/stripe/webhook-handlers.tsupgradeOneTimeCredits
      • 払い戻し特典回復メソッドを再実装する必要があります。つまり、lib/stripe/webhook-handlers.tsrevokeOneTimeCredits
    • 定期購読支払いの場合
      • 特典アップグレードメソッドを再実装する必要があります。つまり、lib/stripe/webhook-handlers.tsupgradeSubscriptionCredits
      • 払い戻し特典回復メソッドを再実装する必要があります。つまり、lib/stripe/webhook-handlers.tsrevokeSubscriptionCredits
    • プロダクトのユーザー特典がクレジットシステムを使用する場合
      • 組み込みの usage テーブルと one_time_credits_balance および subscription_credits_balance フィールドを使用して、actions/usage/deduct.ts が提供するメソッドを直接使用してクレジット控除を行うことができます
      • クレジットシステムを再設計する場合、actions/usage/deduct.ts を参考にして、カスタムクレジット控除メソッドを実装できます
  • 定期購読更新失敗のメール通知を変更する(オプション、デフォルトテンプレートは汎用的)。つまり、emails/invoice-payment-failed.tsx