開発・技術選定

Django で決済を実装する — 二重課金を防ぐ冪等性の設計

決済を実装していて本当に怖いのは、エラーが出ることではありません。
同じ支払いが2回通ってしまうことです。エラーは検知できますが、
二重課金はユーザーからの連絡で初めて気づくことがあります。

二重課金が起きる3つの経路

  1. ユーザーの二度押し — 送信ボタンを連打される
  2. 通信断によるリトライ — レスポンスが返る前にユーザーがリロード
  3. Webhook の重複配信 — 決済代行側は「最低1回」の配信を保証している(=複数回来る)

JavaScript でボタンを disable にするのは 1 の対策にしかなりません。
2 と 3 はサーバー側でしか防げません。

冪等性キーで防ぐ

考え方はシンプルで、「この操作は1回きり」という印をリクエストに持たせるだけです。

class Payment(models.Model):
    idempotency_key = models.CharField(max_length=64, unique=True, db_index=True)
    status = models.CharField(max_length=20)
    amount = models.PositiveIntegerField()

unique=True が肝です。アプリ側の if 存在しなければ作る は競合状態に負けますが、
DBの一意制約は負けません

from django.db import IntegrityError, transaction

def create_payment(key, amount):
    try:
        with transaction.atomic():
            return Payment.objects.create(
                idempotency_key=key, amount=amount, status="pending"
            )
    except IntegrityError:
        # 同じキーが既にある = 2回目以降のリクエスト。既存を返すだけにする
        return Payment.objects.get(idempotency_key=key)

transaction.atomic() で囲むのを忘れると、IntegrityError の後に
同じトランザクションで別のクエリを投げられなくなります。

Webhook 側も同じ考え方で守る

決済代行からの通知も、イベントIDで重複を弾きます。

def handle_webhook(event_id, payload):
    _, created = WebhookEvent.objects.get_or_create(event_id=event_id)
    if not created:
        return  # 処理済み。200 を返して終わり
    ...

ここで 重複を受け取ったときも 200 を返すのが大事です。
エラーを返すと決済代行はリトライを続け、通知が延々と飛んできます。

「決済成功」を信じる場所を1か所にする

実装が荒れる最大の原因は、決済成功の判定が複数箇所に散らばることでした。

  • 決済APIのレスポンス
  • Webhook
  • 画面のリダイレクト(?success=true

このうち リダイレクトは絶対に信用してはいけません。URLは誰でも作れます。
最終的に「決済が成功した」と判断してよいのは Webhook だけ、と決めて、
API のレスポンスは画面表示のためだけに使うようにしました。

まとめ

  • 一意制約付きの冪等性キーを最初から持つ(アプリ側の存在チェックは競合に負ける)
  • Webhook はイベントIDで重複排除し、重複時も 200 を返す
  • 「成功の正」を Webhook 1か所に集約し、リダイレクトパラメータは信用しない

この3つで、二重課金の経路はほぼ塞げます。