決済フロー詳解
本ページでは、1 件の支払いがクリックから台帳に記録されるまでに通る経路をすべて説明します。
フローの全体像
① 料金ページでクリック components/pricing/CheckoutButton.tsx
│ planId
▼
② 統一チェックアウト入口 actions/billing/checkout.ts
│ すべてのガードを通過後、plan.provider で振り分け
▼
③ プロバイダーのホスト型決済画面 Stripe Checkout / Creem Checkout / PayPal 承認画面
│ ユーザーが支払う
├────────────────────────────────┐
▼ ▼
④ 成功ページへ戻る ⑤ プロバイダーの Webhook
/payment/success app/api/{provider}/webhook
│ │
└──────────┬─────────────────────┘
▼
lib/billing/fulfillment.ts 冪等なフルフィルメント。先に書いた方が勝つ
│
▼
orders + subscriptions + クレジット台帳④ と ⑤ は二者択一ではなく二重の保険です。どちらも同じ冪等なフルフィルメント関数を呼び、先に到着した方が有効になり、もう一方は何もしません。Webhook が失われてもユーザーが成功ページを再読み込みすれば付与されますし、ユーザーがすぐブラウザを閉じても Webhook が付与します。
① 料金ページでのクリック
CheckoutButton が createCheckoutSession({ planId, organizationId?, toltReferral? }) を呼び、返ってきた URL へ遷移します。
PayPal のクレジットパックは例外で、このアクションを通らず PayPalCheckoutButton の公式 PayPal ボタンで完結します(後述の「PayPal クレジットパックの特殊経路」を参照)。
② 統一チェックアウト入口
actions/billing/checkout.ts のガードは順番に実行されます。プロバイダー非依存のチェックがすべて先、振り分けが最後です。
ガードの順序
1. 認証チェック —— 未ログインは UNAUTHORIZED を返します。
2. プランが存在し、提供終了していない —— getPlanById で見つからない、または active === false のプランは即座に拒否されます。
3. チームワークスペースではクレジットパックを禁止 —— クレジットパックは個人の資産で、購入者個人の購入バケットにしか入りません。チームワークスペースで通してしまうと「支払ったのに現在のワークスペースの残高が動かない」ことになるため、TEAM_WORKSPACE_CREDIT_PACK で拒否し、クライアントが個人ワークスペースへの切り替えを促します。
4. チームプランの組織解決 —— チームプランの課金主体は組織であり、契約できるのはオーナーだけです。解決順序:
明示的に渡された organizationId
↓ なし
現在のワークスペースの組織(呼び出し元がオーナーである場合)
↓ なし
このユーザーが所有する唯一の組織
↓ それでも決まらない
ORGANIZATION_REQUIRED を返す5. 課金主体ごとに有効なサブスクリプションは 1 つ —— 同一の課金主体が同時に保持できる有効なサブスクリプションは 1 つだけです。
理由は、サブスクリプションのクレジット付与がリセットのセマンティクスを持つためです。2 つの並行したサブスクリプションは互いのクレジットを上書きしてしまいます。プラン変更は 2 つ目を購入するのではなく、プロバイダーのセルフサービスポータルを通します。
ここでの「有効」は ENTITLED の範囲(active / trialing / past_due / unpaid)を指します。督促中のサブスクリプションも枠を占有します —— プロバイダーがまだリトライ中で、回復する可能性があるためです。督促中のユーザーは 2 つ目を重ねるのではなく、ポータルでカードを更新すべきです。
個人とチームの枠は独立しており、個人のサブスクリプションとチームのサブスクリプションが競合することはありません。
このガードを通過できない場合は SUBSCRIPTION_EXISTS を返します。
プロバイダー別の振り分け
| provider | サブスクリプション | クレジットパック |
|---|---|---|
stripe | Checkout Session(mode: 'subscription') | Checkout Session(mode: 'payment') |
creem | Creem Checkout | Creem Checkout |
paypal | Billing Subscription の承認リンク | ❌ 本アクションは通らず、PAYPAL_CREDIT_PACK_BUTTON を返す |
Stripe の重複サブスクリプション防御
Stripe のサブスクリプション session を作成する前に、その customer に属する status: 'open' のサブスクリプション session をすべて expire します。
これが第 1 の防御です。Stripe の checkout session は約 24 時間支払い可能なため、ユーザーが A を開いて支払わず、B を購入して支払い、その後 A に戻って支払うと、サブスクリプションが 2 つできてしまいます。この窓を発生源で閉じるのが最も安上がりです。(第 2 の防御はフルフィルメント側の「先勝ち + 自動返金」です。Webhook 処理を参照してください。)
ここでの失敗はチェックアウトを妨げません —— 返金が 1 件増えるほうが、購入できないよりましだからです。
metadata に入るもの
{
userId, // 購入者
planId, // プランの slug
organizationId, // チームサブスクリプションのみ。クレジットを組織プールへ振り向ける
tolt_referral, // アフィリエイト経由の場合のみ
}チームサブスクリプションは購入者個人の customer に紐づきます(組織レベルの customer は存在しません)。そのため、クレジットを組織プールへ振り向ける唯一の根拠が metadata.organizationId です。Stripe 側では session・subscription・payment intent の 3 か所すべてに固定されます。
戻り先 URL と言語
成功・キャンセルの戻り先 URL はいずれも現在のロケールプレフィックスを保持します。本プロジェクトは as-needed のプレフィックス戦略で localeDetection を無効にしているため、URL のプレフィックスが言語の唯一の担い手です。これを失うと、ユーザーは支払い後に英語サイトへ戻ってしまいます。
③ プロバイダーのホスト型決済画面
ユーザーはプロバイダーのページで支払いを完了します。プロモーションコードが設定されていれば Stripe と Creem は自動適用し、設定がなければプロバイダー標準の手入力欄が表示されます(料金設定を参照)。
④ 成功ページへの復帰
/payment/success は 3 プロバイダー共通の success_url で、クエリパラメータで区別します。
| provider | パラメータ |
|---|---|
| Stripe | session_id |
| Creem | checkout_id |
| PayPal | order_id(= capture id) |
このページを開くこと自体がフルフィルメントを 1 回トリガーします。Webhook と同じ冪等関数(fulfillCheckoutSessionById / fulfillCreemCheckoutById / fulfillPayPalCaptureById)を呼びます。
ページには 3 つの終了状態があります。
- 成功 —— プラン名と付与クレジットを表示します。チームプランの場合、2 つ目のボタンは「メンバーを招待」で、
/dashboard/teamへ直行します - 処理中 —— PayPal の保留中キャプチャ(eCheck)でこの状態が描画され、再読み込みで再検証されます。COMPLETED の Webhook が失われた際の、ユーザー側の自己修復チャネルです
- 失敗 —— 終局的な失敗(DECLINED / FAILED)は失敗カードを描画し、誤解を招く「処理中」は決して表示しません
未ログインの訪問者は、ロケールプレフィックスを保ったまま /login へリダイレクトされます。
⑤ Webhook によるフルフィルメント
こちらが主経路です。詳細は Webhook 処理 を参照してください。要点は次の通りです。
- ルートは署名検証と振り分けのみを行い、業務ロジックを持ちません
- アダプターがプロバイダーのペイロードを統一入力へ変換し、フルフィルメントコアが 1 トランザクションで注文・サブスクリプション・クレジットを書き込みます
- リトライ可能なエラーは 5xx を返してプロバイダーに再送させ、
PermanentFulfillmentError(購入者がアカウントを削除した場合など)は警告を出したうえで ack し、リトライループに入れません
PayPal クレジットパックの特殊経路
PayPal の買い切り購入は createCheckoutSession を通らず、公式の JS ボタンを使います。
PayPalCheckoutButton(クライアント)
│
├─▶ POST /api/paypal/create-order plan.price から動的に注文を作成
│
│ ユーザーが PayPal のポップアップ内で承認
│
└─▶ POST /api/paypal/capture-order 資金をキャプチャ
│
├─ COMPLETED → フルフィルメントし、成功ページへ
├─ PENDING → pending の注文行を書き、成功ページで「処理中」を表示
└─ DENIED → 失敗としてマークこの pending 注文行が、定期実行タスクが追跡する永続的なローカルの痕跡です。/api/cron/credits がこれを completed(クレジット付与)または failed(クレジット 0、監査証跡のみ)へ前進させ続けます。
ガードコード早見表
createCheckoutSession は失敗時に customCode でこれらを返し、CheckoutButton が遷移先を振り分けます。
| customCode | 意味 | クライアントの挙動 |
|---|---|---|
UNAUTHORIZED | 未ログイン | ログインページへ |
SUBSCRIPTION_EXISTS | その主体が既に有効なサブスクリプションを保持 | /dashboard/billing へ |
TEAM_WORKSPACE_CREDIT_PACK | チームワークスペースでクレジットパックを購入しようとした | 個人ワークスペースへの切り替えを促す |
ORGANIZATION_REQUIRED | チームプランだが組織を決定できない | /dashboard/team へ |
PAYPAL_CREDIT_PACK_BUTTON | PayPal クレジットパックが誤った経路を通った | PayPal ボタンの利用を促す |
決済フローのテスト
- 3 プロバイダーすべてでテスト/サンドボックスのキーを使い、
config/pricing.tsのtest列にテスト環境の価格 ID を入れます - ローカルへ Webhook を転送します(Stripe の場合:
stripe listen --forward-to localhost:3000/api/stripe/webhook) - Stripe のテストカード:
4242 4242 4242 4242、任意の将来日付、任意の CVC - 支払い後は 3 か所を確認します:
/payment/successの表示、/dashboard/billingの残高と履歴、管理画面/dashboard/admin/ordersの注文行