決済フローとカスタム開発
テンプレート購入者がより簡単に開始し、独自の決済ロジックを実装できるように、この章ではNexty.devの組み込み決済フローのコード実装を紹介し、カスタムロジック開発の開始方法を説明します。
決済フロー
このセクションでは、ユーザーが認識できる決済フローに焦点を当て、Webhook部分は次のセクションで別途説明します。
料金プランの表示
料金カードを表示するコンポーネントは components/home/Pricing.tsx
とその子コンポーネントです。
getPublicPricingPlans
メソッドを使用して、管理者が設定したアクティブな料金カードを取得します。
ユーザー決済
ユーザーが購入ボタンをクリックすると、/api/stripe/checkout-session
エンドポイントにリクエストが送信されます。
/api/stripe/checkout-session
エンドポイントでは、Stripeアカウント内の現在のユーザーの一意識別子(customer_id)を照会し、ユーザーが購入したいプランに基づいて、Stripeに決済ページ(checkoutページ)のアドレスを要求します。
Stripeに送信されるパラメータには success_url
と cancel_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テンプレートのデフォルトの決済成功ページは、ユーザーが購入した料金プランを表示するためです。より良いユーザーエクスペリエンスを提供するため、ビジネスに応じて表示内容を変更してください。
ユーザー特典の表示
まず、組み込み初期化された料金プランを購入してみましょう。一回限りの支払いと定期購読の両方を購入してください。完了後、/dashboard/subscription
と右上角のドロップダウンメニューで現在の特典を確認できます。
SupabaseのTable Editorを開くと、orders
、subscriptions
、usage
テーブルにそれぞれ対応するデータが表示されます。
ユーザー特典を取得するコアメソッドは lib/stripe/actions.ts
の getUserBenefits
で、返される特典情報は以下のように定義されています:
export interface UserBenefits {
activePlanId: string | null;
subscriptionStatus: string | null; // 例:'active', 'trialing', 'past_due', 'canceled', null
totalAvailableCredits: number;
subscriptionCreditsBalance: number;
oneTimeCreditsBalance: number;
// 必要に応じて他のプラン固有の特典を追加、planIdで取得
}
特典をカスタマイズする場合、以下に注意してください:
activePlanId
とsubscriptionStatus
はsubscriptions
から取得され、ユーザーが購入したプランと現在の購読状況を反映しており、変更すべきではありませんtotalAvailableCredits
、subscriptionCreditsBalance
、oneTimeCreditsBalance
はusage
テーブルから取得され、組み込みの一回限り支払いと定期購読のクレジット残高を反映しています。必要ない場合でも、保持することをお勧めしますが使用しない、つまりgetUserBenefits
メソッドを呼び出す場所でこれら3つのパラメータの戻り値を取得しないだけです- カスタム特典は既存の
UserBenefits
を拡張し、同時にgetUserBenefits
のusage
テーブルクエリメソッドと戻りオブジェクト(つまり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統合の手順を参照してください
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_type
を refund
に割り当てます。
その後、特典回復処理を実行します:
// --- [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_at
と ended_at
には現在時刻が割り当てられます。
customer.subscription.deleted
イベントは handleSubscriptionUpdate
に isDeleted
フラグも渡し、特典回復メソッドをトリガーします:
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ページで直接返金処理を行うことができます。
返金完了後、Webhookでcharge.refunded
イベントがトリガーされ、上記で分析したワークフローに入ります。
定期購読のキャンセルと返金
定期購読の返金は通常、購読のキャンセルを同時に行う必要があるため、Subscriptionsページで購読をキャンセルし、返金処理を行う必要があります。
キャンセルと返金完了後、Webhookでcustomer.subscription.deleted
イベントがトリガーされ、上記で分析したワークフローに入ります。
カスタム開発チェックリスト
app/[locale]payment/success/page.tsx
ページを変更し、ビジネスと組み合わせて決済成功表示情報をデザインする- ユーザー特典をカスタマイズする場合
- 特典取得メソッドを変更する必要があります。つまり、
lib/stripe/actions.ts
のgetUserBenefits
- ユーザー特典表示を変更する必要があります。つまり、
components/layout/CurrentUserBenefitsDisplay.tsx
コンポーネント - 一回限り支払いの場合
- 特典アップグレードメソッドを再実装する必要があります。つまり、
lib/stripe/webhook-handlers.ts
のupgradeOneTimeCredits
- 払い戻し特典回復メソッドを再実装する必要があります。つまり、
lib/stripe/webhook-handlers.ts
のrevokeOneTimeCredits
- 特典アップグレードメソッドを再実装する必要があります。つまり、
- 定期購読支払いの場合
- 特典アップグレードメソッドを再実装する必要があります。つまり、
lib/stripe/webhook-handlers.ts
のupgradeSubscriptionCredits
- 払い戻し特典回復メソッドを再実装する必要があります。つまり、
lib/stripe/webhook-handlers.ts
のrevokeSubscriptionCredits
- 特典アップグレードメソッドを再実装する必要があります。つまり、
- プロダクトのユーザー特典がクレジットシステムを使用する場合
- 組み込みの
usage
テーブルとone_time_credits_balance
およびsubscription_credits_balance
フィールドを使用して、actions/usage/deduct.ts
が提供するメソッドを直接使用してクレジット控除を行うことができます - クレジットシステムを再設計する場合、
actions/usage/deduct.ts
を参考にして、カスタムクレジット控除メソッドを実装できます
- 組み込みの
- 特典取得メソッドを変更する必要があります。つまり、
- 定期購読更新失敗のメール通知を変更する(オプション、デフォルトテンプレートは汎用的)。つまり、
emails/invoice-payment-failed.tsx