決済単位でのトランザクション管理モデルを用意すると調査にも開発にも役立つ

Web Engineer の @kachick です。 今回はスタディサプリのクレジットカード決済を堅牢化するために行っている工夫の一部を抜粋して紹介したいと思います。

前提

本記事で紹介する内容は、過去に我々が提供していたサービス(以下、サービスAとします)において直面した決済バグから学び、スタディサプリに導入したものです。

決済バグとは

決済というものはその性質上、セキュリティに次いでバグへの高い温度感を求められがちです。 サービスプロバイダの目線からは決済のバグを2つに大別出来ます。

本来よりも多く請求しまい、ユーザーからの信頼を損ねる。

  • 決済の金額を増やしてしまった。
  • 決済の時期を早めてしまった。
  • 決済は通ったがサービスを提供できていなかった。

本来よりも少なく請求してしまい、事業運営に支障を来す。

  • 決済の金額を少なくしてしまった。
  • 決済の時期を遅くしてしまった。
  • 実際には決済が通っていなかったのにサービスを提供してしまった。

サービスAにおいてクレジットカード決済では、これらの内 多重課金(=決済の金額を増やしてしまった)決済は通ったがサービスを提供できていなかった というバグが発生していました。 サービスAが提供していた決済種別は何種類かあるのですが、その中でも一番高い比重を占めていたのがクレジットカード決済です。 比重が高いということは全体を通した発生件数も比例して増えるという事で、エッジケースという一言では済ませられない件数に上っていました。

喫緊の対応

申告が無かった場合にもこのような不整合を検知できるスクリプトを作成し、定期的に流すようにしました。 当然検知する都度返金を含めた対応は行ったのですが、問題が発生してからの対応になっておりこれだけだと完全とは言えません。 とはいえこの処置により、実装の調査と改修に取り掛かる余裕を得ました。 またこのスクリプトが存在する事で、改修の内容が本当に効果をもたらしたかを迅速に判断する事ができるようになったメリットも大きいです。

初期の実装

まずは問題が発生していた時点での実装ですが、Ruby擬似コードで示すと以下のようになっていました。 Rubyの各ブロックは、ブロックの中で例外が発生した場合に以前の状態へ巻き戻す事を意図しています。

db_transaction do
  provide_service do
    commit_to_payment_gateway # 決済ゲートウェイを通して課金処理
    prepare_next_payment # 月額/年額課金の次回課金のレコードの作成
  end
end

勿論すべてが理想的に動いているうちは、こういったコードでも問題ありません。 問題はどこかのポイントで例外が発生した場合です。 仮に commit_to_payment_gateway で例外が発生した場合を考えてみましょう。可能性としては

  • コミットリクエストが payment gateway に届かずエラーになった場合
  • コミットリクエストが payment gateway に届いた後レスポンス待ち中にエラーになった場合

等が考えられます。 (ここでコミットリクエストというのは決済を確定させる処理を、payment gateway とは種々の決済種別に対応した外部の決済代行会社を指します。)

上記のコードだといずれの場合も provide_service と db_transaction 双方を巻き戻すことになるわけですが、コミットリクエストが payment gateway に届かずエラーになった場合は問題ありません。 ですがコミットリクエストが payment gateway に届いていた場合ではどうでしょう。 payment gateway 上にコミットが残る、つまり実請求が発生しつつサービス提供を取り消してしまっているので、これは不整合が発生しているということになります。 またその不整合自体もユーザーに不利益をもたらすものですので、これは許容できません。 全てはpayment gateway とローカルのDBをatomicに扱えない点に根ざしているため、違うアプローチを考えないといけません。

改修内容

巻き戻しきれない処理まで、巻き戻す処理の中に混ぜてしまっているというのがここでは問題になっています。 解消にあたっては自動で巻き戻すような処理を挟まず、発生し得る状態が不整合の場合も含めて許容できるものとなるように調整しました。

provide_service
commit_to_payment_gateway
prepare_next_payment

このようにすると、まず最初にサービス提供を行っている以上、その後に例外が発生してもお金だけ取りつつサービスを提供しないというユーザーにとって最悪な状況に陥らず済みます。 逆に不整合の発生具合によっては無料でサービスを提供するという状況が起こりかねないので、そこは別途仕組みを用意して後から修正できるようにしておく必要があります。 具体的には、各トランザクション単位で何が発生したかを記録しておくことにしました。

billing = Billing.start_for(user)
provide_service
billing.record_provide_service
commit_to_payment_gateway
billing.record_commit_to_payment_gateway
prepare_next_payment
billing.record_prepare_next_payment
billing.finalized = true
billing.save!

そのため、後から不整合の発生した決済トランザクションを簡単に追いかけることができるようになっています。

Billing.where(finalized: false)

これに関しては弊社 @ohbarye が 以下のスライドでも紹介していますので、併せてご一読ください。

また、このようにトランザクションを管理するModelを導入したことにより、多重課金も検知・防止ができるようになりました。

class Billing
  def self.start_for(user)
    raise if where(finalized: false, user_id: user.id).exists?
    create user_id: user.id, finalized: false
  end
end

一石二鳥ですね。

まとめ

以上のポイントをまとめると、次の3つになります。

  • 不具合発生時に自動で巻き戻したいといった開発者のWANTに引きずられ過ぎず、ユーザーに不利益が出ないようにするといったサービス提供者としてのMUSTに重点を置く。
  • トランザクション単位での記録があれば状態遷移の状況を後から追いやすくなり、発生した不整合の修正ができる。
  • トランザクション単位での記録があれば多重課金の検知・防止にも役立つ。

今回はDBトランザクションに頼りきれないパターンと、代替として決済のトランザクション単位で記録するモデルを設ける事でもたらされるメリットについて記載しました。 改修している時は自分でも把握してなかったのですが、こういったトランザクションの戦略は Compensating Transaction pattern の一種とも考えられるようです。

Quipperでは決済に興味があるエンジニアを募集しています。