決済を実装していて本当に怖いのは、エラーが出ることではありません。
同じ支払いが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つで、二重課金の経路はほぼ塞げます。