2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

決済を外部にする場合、キャンセル・在庫管理について考えてみた【Stripe】

Posted at

1. 前提:外部決済では「非同期」と「離脱」が前提

Stripe Checkout セッションを使うと、Stripe側で決済ページの有効期限や放置時のキャンセルを自動的に処理できます。
そのため、アプリ側で複雑なタイマーや状態監視を行う必要はありません。

外部決済では以下のような状況を想定する必要があります:

  • 決済途中で「戻る」や「ブラウザを閉じる」などの離脱
  • 通信エラーやページリロード
  • Webhookが遅延または失敗するケース

これらを踏まえた在庫管理、ステータスの基本設計は、次の3段階です:

  1. 決済前(在庫仮確保)
  2. 決済中(放置・離脱検知)
  3. 決済完了後(Webhookによる確定・キャンセル処理)

2. 決済画面前(在庫の仮確保とCheckoutセッション作成)

ユーザーが「注文する」を押した時点で、在庫を仮確保し、Checkout セッションを作成します。
Checkout セッションの expires_at パラメータを指定すると、Stripeが自動的に期限切れ(例: 30分後)にしてくれます。

// Checkout セッション作成(在庫仮確保付き)
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price_data: { currency: "jpy", unit_amount: amount }, quantity: 1 }],
  success_url: `${BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${BASE_URL}/cancel`,
  expires_at: Math.floor(Date.now() / 1000) + 60 * 30, // 30分後に期限切れ
  metadata: { orderId },
});

3. 放置・離脱(checkout.session.expired Webhookで自動キャンセル)

Checkout セッションが期限切れ(expires_at 経過)になると、Stripeが checkout.session.expired Webhookを送信します。
このイベントをトリガーに、注文をキャンセルし、在庫を戻します。

// checkout.session.expired で自動キャンセル
if (event.type === "checkout.session.expired") {
  await db.order.update({ where: { id: orderId }, data: { status: "canceled" } });
}

// checkout.session.completed で注文確定
if (event.type === "checkout.session.completed") {
  await db.order.update({ where: { id: orderId }, data: { status: "paid" } });
}

4. 戻るボタン(cancel_url)時の注意点

Checkoutページでユーザーが「戻る」ボタンを押した場合、
Stripeは cancel_url にリダイレクトするだけで、Webhookは送信されません。

そのため、/cancel ページ側でキャンセル処理をトリガーする必要があります。

// /pages/cancel.tsx 内などでAPI呼び出し
await fetch("/api/cancel-order", {
  method: "POST",
  body: JSON.stringify({ orderId }),
});

このように「戻る操作」はStripe側では検知できないため、アプリで明示的に注文をキャンセルし、在庫を戻す実装を追加しておきましょう。

5. 決済完了後(Webhookによる確定処理)

  • checkout.session.completed イベントで決済完了を検知し、注文を確定 (paid) に更新します。
  • checkout.session.expired イベントで未完了セッションをキャンセル (canceled) にします。

この2種類のWebhookを処理すれば、放置・離脱・完了のすべてをカバーできます。

6. ステータス更新の信頼性について

Webhookだけに頼るのは不安に見えますが、Stripeは 「Webhookの自動再送」機能 を備えています。
もし通信が失敗した場合でも、指数関数的リトライにより最大3日間自動再送されます。

そのため、通常の障害範囲ではcronなどの定期ジョブを追加せずとも、確実に状態は同期されます。

さらに安全にするための3つの実装ポイント

  1. Webhookの冪等性チェック

    • Stripeの event.id をDBに保存して、同じイベントを2回処理しないようにする
    // 冪等性チェック(重複イベント防止)
    if (await db.webhookEvent.findUnique({ where: { id: event.id } })) return;
    await db.webhookEvent.create({ data: { id: event.id, type: event.type } });
    
  2. 状態遷移制御

    • pending → paid / pending → canceled のみ許可
    • paid → canceled は禁止など、DBレベルで整合性を担保
  3. Stripeイベント再送対応

    • Stripe Dashboardから手動でWebhookを再送可能
    • 管理者が障害時に再送すれば、注文状態が復旧可能

7. まとめ

状況 トリガー 処理
決済開始 Checkout セッション作成時 在庫仮確保(pending
戻る操作 cancel_url リダイレクト時 アプリ側でキャンセル処理を実行
離脱・放置 checkout.session.expired Webhook 自動キャンセル・在庫戻す
決済完了 checkout.session.completed Webhook 注文確定(paid
信頼性担保 冪等性 + 状態制御 + 再送対応 cron不要・整合性保証

結論

Webhook + 冪等性 + 状態制御 の3点を実装しておけば、
Stripeを使用する場合は基本的にcronによる定期チェックは不要

Checkoutセッションの expires_at による自動キャンセルと、
Webhookの自動再送機構により、時間経過や障害発生時でも整合性が保証される

さらに、戻るボタン操作(cancel_url遷移)はWebhookで検知できないため、
アプリ側で明示的にキャンセル処理を呼び出す設計を加えることで、すべてのケースを完全にカバーできる


この構成で「放置」「離脱」「戻る」「完了」「障害時再送」までを自動管理可能
定期ジョブを追加しなくても、Stripe公式設計に沿った堅牢な実装になります。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?