新規事業の決済機能としてStripeを導入する上で考えたこと全て

こんにちは、タイミーデリバリー開発チームの宮城です。
この記事はJP_Stripes Advent Calendar 2020の10日目の記事です。

タイミーデリバリーはデリバリーを頼みたい人が安い価格で注文でき、飲食店も安い利用料で注文を受けられるデリバリープラットフォームです。
その決済機能として今回はStripeを導入しました。
この記事では、決済基盤の技術選定/Stripeを活用したクレジットカード決済と各事業者への入金までの流れ/Railsでの具体的な実装内容 をそれぞれタイミーデリバリーでの活用事例として紹介します。

導入にあたった背景

タイミーデリバリーでは、RailsによるAPIサーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向けiOSアプリとしてSwiftを採用しています。
1つのモノリスRailsアプリで利用者別にネームスペースを区切り、それぞれJSONを返すAPIを提供しています。
タイミーデリバリーはプラットフォームビジネスであり、注文者はアプリ上で複数の事業者の商品を閲覧し商品を購入します。
注文者が決済した金額にはプラットフォーム利用料が含まれており、タイミーと事業者双方に分配する必要があります。
まだ弊社には決済機能を導入するノウハウがなかったため、Railsを使ってどのようにこのビジネスモデルを実現するのかや、そもそも決済機能に求められる通常の要件をどのように達成するのかもわからない状態からスタートしました。
まずは決済基盤に求められる技術選定の基準を作るところから始めました。

決済基盤の技術選定基準

弊社経理チームやCSチーム、法務チームと相談しながら要件をまとめ技術選定基準を作り、以下の要件が達成できる状態を目指すことにしました。

  • 注文者の体験
    • 注文者がクレジットカードを使ってアプリ上から商品を事前購入することができる
    • 決済情報(クレジットカードなど)はタイミーが保持せず、ログにも残らない
    • 不正利用やマネーロンダリングの対策ができている
    • 注文者が操作に迷わない(UXが高い)
  • 弊社経理チーム、導入事業者の体験
    • 決済された金額から事業者とタイミーに分配できる、またはタイミーから事業者に請求できる
    • 事業者の導入にあたって、契約書の受領からアプリ上に商品を掲載し決済できるようになるまでが簡単で短い
    • 特定の期間で「締め」て、締めたデータは変更されなくなる
    • 必要なデータを後から取り出すことができる
    • 注文者への領収書や店舗ごとの利用明細が表示できる
  • サポートチームの体験
    • 返金のオペレーションが容易にできる
    • 必要なデータを後から取り出すことができる
  • 開発チームの体験
    • 実装コストができるだけ低い
    • サービスが利用できない時間が可能な限り短い(可用性が高い)

この時点で「決済を行う」とはここまで考えることがあるのか…と深い闇に迷い込んだ気がしましたが、ここで丁寧に要件をまとめたことで各ステークホルダーと認識のズレを減らすことができたように思います。

Stripeでできること

上記の技術選定基準をStripeで達成できるか当てはめたのが以下です。

利用者 項目 結果
注文者の体験 注文者がクレジットカードを使ってアプリ上から商品を事前購入することができる クレジットカード、ApplePay、GooglePayなどが可能。
決済情報(クレジットカードなど)はタイミーが保持せず、ログにも残らず、セキュアに管理される APIで問い合わせ、決済情報の登録・閲覧・削除が可能。
秘匿情報は全てStripe側で管理し、タイミーが保持するものは結果のみ。
不正利用やマネーロンダリングの対策ができている Stripeに蓄積されているデータを用い、クレジットカードのリスク審査ができる
Radarと呼ばれる不正行為検知の機能がある(別料金)
注文者が操作に迷わない(UXが高い) StripeのSwift向けSDKが優秀
カードの入力は1度きりでよく、次回決済時にシームレスに利用できる
弊社経理チーム、導入事業者の体験 決済された金額から事業者とタイミーに分配できる、またはタイミーから事業者に請求できる 可能。方法は後述
導入にあたって、契約書の受領からアプリ上で決済できるようになるまでが簡単で短い アカウントを登録するための情報は多いが、入力次第すぐに決済が可能。Stripeが並行で審査を進めている
特定の期間で「締め」て、締めたデータは変更されなくなる これはできなさそうだった。だが経理上のオペレーションとしては問題なく処理できたので方法を後述
必要なデータを後から取り出すことができる Stripeのダッシュボード上で可能
注文者への領収書や事業者ごとの利用明細が表示できる 注文者向けの領収書は発行可能。デザインも良い。
事業者ごとの利用明細も可能ではあるが、少々面倒だった。これも後述
サポートチームの体験 返金のオペレーションが容易にできる 返金処理自体は簡単だった。むしろダッシュボード上でいつでもできてしまうので、弊社アプリケーション側で制限する仕組みを用意した。
必要なデータを後から取り出すことができる Stripeのダッシュボード上で可能
開発チームの体験 実装コストができるだけ低い 開発者フレンドリーを謳っており、API経由での利用や拡張が簡単にできる。
サービスが利用できない時間が可能な限り短い(可用性が高い) SLA, SLOは特に公開しているわけではないようだったのですが、担当してくれていたStripeの営業の方に確認したところ「ほぼ落ちないようなもの」とのこと。
プロダクトとしては単一障害点になってしまっていることをステークホルダーとの合意を得ています。

このように概ね要件を満たせていたのと、開発者体験が良いという噂は聞いていたため自分もエンジニアとして興味もあり、今回はStripeを利用することを決めました。

PCI DSSについて

ここに書いてある内容は正確性に欠けている可能性があるので、実際に対応を進める場合は必ず専門家に相談しながら進めてください。
決済機能を提供する上で避けて通れないのがPCI DSSへの対応です。PCI DSSとは、クレジットカード情報を事業者がどのように扱うべきかを定めた情報セキュリティ基準です。クレジットカード情報を自社で保持する場合は300 件を超えるPCI DSSの各セキュリティ制御要件を満たさなければなりませんが、カード情報の処理をStripeなどの外部SaaSに任せる事で事業者に求められるセキュリティ制御要件は22件にまで減ります。そのためにカード情報の「非保持・非通過」を目指します。
その上でPCIに準拠していることの検証を進めていく事になりますが、求められる要件は年間のクレジットカード取引量によってレベルが変わります。新規事業であるタイミーデリバリーは最も低いレベルであるレベル4だったため、Stripe上での本番環境のセットアップを進める最中に要求される自己問診だけで済みました。
PCI DSSについて全てを理解することは困難であり、今回は担当してくださったStripeの営業の方に何度も相談させていただきました。 Stripeを利用する上でどのようにPCIに準拠していくかはまずこの資料を読むことをオススメします。

stripe.com

利用したStripeの機能

今回はStripe Connectを利用しました。 Stripe Connectはプラットフォームビジネスに最適なサービスであり、1人の注文者が任意の事業者の商品を購入し、事業者の口座へ入金しつつプラットフォーム手数料の徴収を行うことができます。

Stripe Connectの詳細や主な利用方法についてはこの記事が参考になりました。一部情報が古い部分があるものの、全体像を掴みやすい記事です。

qiita.com

Custom Account

Stripe Connectを利用する上で重要なのはアカウントタイプを決定することです。現在はStandard/Express/Customの3タイプが提供されています。詳細な説明はこの記事では割愛します。
アカウントの選び方は公式のドキュメントを見るのが良いかと思います。

stripe.com

今回タイミーでは以下の理由からCustomアカウントを選定しました。

  • 導入事業者に課すプラットフォーム手数料の計算をアプリケーションロジックで行いたかったこと
  • 弊社経理チームの都合や導入事業者の経理面からの要望により、返金等の請求内容の変更が発生するタイミングを制御したかったこと
  • ネットで検索したところ、Expressアカウントの採用事例がまだ少なかったこと

しかし、運用フェーズに入った今思えば、Expressアカウントを採用した方がよかったのではないかと考えています。こちらについては後述する失敗したことで記します。

Stripe SDKを利用したRails/Swiftでの実装内容

ここからは実際にどのように実装したのかを紹介します。StripeはREST APIで決済を行える事に加えて、各言語ごとにSDKを提供しています。タイミーデリバリーはフロントエンドとしてSwift、バックエンドとしてRubyを利用しており、どちらもSDKが対応していました。

github.com

github.com

また公式ドキュメントにはSDKを利用してPaymentIntentを作成する決済の流れが紹介されているので、ここを熟読することをオススメします。(PaymentIntentについては次項で説明します。)

stripe.com

PaymentIntent

Stripeで決済を行う場合、それぞれの決済を表すのがPaymentIntent APIです。ネット上の記事ではCharge APIを利用した記事も多く存在していますが、現在はPaymentIntentを利用することが推奨されています
とはいえCharge APIにしかサポートしていない決済方法もあるため、自身のユースケースと照らし合わせて対応しているか確認しつつ、どちらを選択しても問題ない場合はPaymentIntentを利用するのが妥当でしょう。
タイミーデリバリーの場合はファーストリリースではクレジットカードしかサポートしないことを決めていたためPaymentIntent APIを選択しました。
PaymentIntentを作成しただけでは決済処理は行われず、その後「承認」を行う事によって決済処理が行われます。

stripe.com

Customer

決済を行う主体を指します。タイミーデリバリーの場合は「注文者」と呼んでいます。
Customerオブジェクトには数多くのプロパティがありますが、タイミーデリバリーのユースケースでは、注文者の一意な特定ができかつ領収書の送信ができさえすればよかったため、メールアドレスのプロパティしか利用していません。 stripe.com

Account

プラットフォームを利用してお金を受け取る主体です。タイミーデリバリーでは、プラットフォームであるタイミーが親アカウント、タイミーデリバリー上で商品を公開する事業者が子アカウント(Connected Accountとも呼ばれます)にあたります。
上述したとおり、タイミーデリバリーではアカウントタイプとしてCustomアカウントを選択しました。
Stripe Connectでは、「プラットフォーム上での売り上げはまず親アカウントの残高に集まり、その後子アカウントの残高へ送金する」という形を取っています。事業者がプラットフォームを利用する際の手数料(ApplicationFee)はこの送金のタイミングで差し引くことが可能です。

Connected Accountが決済を行えるようになるまで

導入した企業が契約から決済を行えるようになるまでのリードタイムは、熱量を下げないためにも重要な観点です。
Stripeの場合、Accountに事業者の各種情報を登録すればその瞬間から決済を行うことができます。まずは本人確認中ステータスとなりますが、本人確認が済む前から利用可能です。
しかしこの情報を入力する項目がかなり多く、事業者ごとに情報を用意していただき入力してもらうのがかなり大変だったので注意しておいた方が良いかと思います。法人の場合は企業情報だけではなく代表者の身分証明書画像のアップロードまで必要でした。

stripe.com

決済の全体像

商品を選択し決済するまでのシーケンス図がこちらです。

f:id:MH4GF:20201011161021p:plain
決済の流れ
ここで考慮すべきは決済情報をどのようにサーバーで保持せずにStripeに送信するかですが、そこはStripeのSwift SDKに全て任せることができます。クレジットカード情報の登録、決済の承認の処理前にはAPIからStripeに向けて一時キーを要求し、Swift SDKに返します。Swift SDKのViewControllerからStripeへ直接リクエストすることで処理を確定します。

DBに保存する情報とStripeで保持する情報の整合性担保

上記の全体シーケンス図から、注文情報の確定とPaymentIntentの作成、承認処理をより細かく記載したのが下記のシーケンス図です。

f:id:MH4GF:20201011224333p:plain
注文の確定処理のシーケンス図

アプリケーション側では注文情報をデータベースに保存し、Stripe側にはPaymentIntentを作成するリクエストを送ります。複数サービスを跨いだトランザクションでは、個々のデータの整合性を担保することが大きな課題となります。注文情報は保存できたがStripeへのリクエストがタイムアウトなどで失敗した場合、注文情報がロールバックされなければ注文者は引き落としがないまま商品を受け取ることができてしまいます。

そのため、今回は仮登録フェーズと確定フェーズの2段階のトランザクションを行う設計にしました。分散トランザクションにおけるデザインパターンTCC(Try/Confirm/Cancel)パターンに近いです。

仮登録フェーズ(Try)

仮登録フェーズのトランザクションでは、フロントエンドから送られてきた注文情報をもとにPaymentIntentを作成するリクエストを行い、その後データベースにpayment_intent_idを含めた注文情報を保存します。
PaymentIntentは作成しただけでは決済は行われないため、現時点では仮登録の状態といえます。
アプリケーション側ではOrderモデルを作成し、各種注文情報を保存します。payment_succeeded_atというnullableのdatetime型のカラムを用意しておき、現時点ではnullにしておきます。このカラムがnullの場合は注文者のアプリや店舗側の管理画面には表示しないロジックにしておきます。 実際のコードに近いサービスクラスがこちらです。

class User::Order::PaymentPrepareService
  def initialize(user, params)
    @user = user
    @params = params
  end

  def run!
    ActiveRecord::Base.transaction do
      find_resources
      validate!
      create_payment_intent!
      create_order!
    end

    @payment_intent.client_secret
  end

  private

  ~ 省略 ~

PaymentIntentの作成リクエストが失敗した場合はordersのレコードは作成されません。一方ordersの保存処理が失敗した場合は作成したPaymentIntentはキャンセル処理をするべきですが、上述した通りPaymentIntentは承認処理をしなければ実際に決済が行われないため、存在したままでも特に支障もないためそのままにしています。

仮登録フェーズでやるべきことは、PaymentItentとordersのどちらの登録処理も成功していれば確定フェーズでConfirm処理が確実に成功する もしくはCancel処理によるロールバックができる という状態にしておくことがポイントです。

確定フェーズ(Confirm/Cancel)

仮登録フェーズのレスポンスで返した一時キーを利用し、フロントエンドのStripe SDKが確定処理を実行します。Stripeでは各イベントの発火ごとにWebhookを送信することができるため、確定処理の成功/失敗のイベントにおけるWebhook処理を実装します。

ほぼそのままのコードのサービスクラスの実装がこちらです。

class PaymentIntent::Webhook::UpdateOrderService
  VALID_EVENT_TYPE = %w[payment_intent.succeeded payment_intent.payment_failed].freeze

  # @param [String] payload
  # @param [String] sig_header
  # @param [String] endpoint_secret
  def initialize(payload, sig_header, endpoint_secret) # 全てStripeのWebhookが送信してくる内容。ドキュメントに従えば良い
    @payload = payload 
    @sig_header = sig_header
    @endpoint_secret = endpoint_secret
  end

  # @raise [Service::ValidationError]
  # @raise [ActiveRecord::RecordInvalid]
  def run!
    validate!

    ActiveRecord::Base.transaction do
      @order = Order.lock.find_by(stripe_payment_intent_code: payment_intent_code)
      return logging_order_not_presence if @order.nil?

      send(:"update_order_#{event.type.split('.').last}!")
    end
  end

  private

  def event
    @event ||= Stripe::Webhook.construct_event(@payload, @sig_header, @endpoint_secret)
  rescue JSON::ParserError
    raise Service::ValidationError.new([:invalid_payload], self) # アプリケーションで定義している独自の例外クラス
  rescue Stripe::SignatureVerificationError
    raise Service::ValidationError.new([:invalid_signature], self)
  end

  def update_order_succeeded!
    @order.update!(payment_succeeded_at: Time.zone.now)
    NotifySlack::Order::PaymentSucceededNotifyJob.perform_later(@order.id)
  end

  def update_order_payment_failed!
    @order.discard! # 論理削除
  end

  def validate!
    check_valid_event_type!
  end

  def check_valid_event_type!
    raise Service::ValidationError.new([:unexpected_event_type], self) unless VALID_EVENT_TYPE.include?(event.type)
  end

  def logging_order_not_presence
    return if @order.present?

    Rails.logger.warn("couldn't find Order with webhook, stripe_payment_intent_code=#{payment_intent_code}")
    Rails.logger.warn(event)
  end

  def payment_intent_code
    event.data.object.id
  end
end

成功時はOrderモデルをpayment_succeeded_atに現在時刻を入れて更新し、失敗時はOrderモデルを論理削除しています。

決済処理のトランザクションについてはメルカリさんの記事を参考にしています。

engineering.mercari.com

stripe-ruby-mockを使ったTesting

外部APIを利用する上ではRSpecやローカル環境ではリクエストにモックを差し込めるようにしたくなります。WebMockなどを使って自前実装する手もありますが、Stripeの場合はモック化のためのgemがいくつかサードパーティで開発されており、今回はその中でもstripe-ruby-mockを利用することにしました。

github.com

利用方法の詳細はここでは省略しますが、それっぽいモックデータを返してくれることはもちろんのこと、リクエストで送ったパラメータをインメモリに保持し、その値をレスポンスとして返してくれるので非常に使い勝手が良いです。
しかし入力値バリデーションは完璧ではないため、あくまでレスポンスの型の検証として使うのが良さそうです。
モックデータはこの辺りのコードにまとまっています。
stripe-ruby-mock/data.rb at master · stripe-ruby-mock/stripe-ruby-mock · GitHub

返金処理・経理業務

返金はStripeのダッシュボード上、またはAPI経由でも行うことができます。

Stripeダッシュボードでの返金処理

f:id:MH4GF:20201025232929p:plain
ダッシュボード上での返金処理
全額返金することも一部返金することもできます。
「関連する送金を差戻す」とは、決済で発生した金額のうち、子アカウントの残高に送金した金額を差戻すことを指します。チェックがない場合子アカウントの残高は売り上げが立ったまま減らないため、この返金はプラットフォーム側が立て替えることになります。
「プラットフォーム手数料を返金」とは、決済で発生した金額のうち、子アカウントから徴収したプラットフォーム手数料を子アカウントに返金することを指します。「関連する送金を差戻す」にチェックを入れつつ「プラットフォーム手数料を返金」にチェックを入れなかった場合、プラットフォーム側の利益が残ったまま子アカウントの残高が減るので、この返金は子アカウントが立て替えることになります。
少しややこしいですが、この二つのチェックボックスにチェックを入れて返金することで注文者が支払いをする前の状態に戻ることになります。テスト環境で返金を複数パターン試してみて理解するのが良いと思います。

API経由による返金処理

API経由であっても、ダッシュボードと同じような制御をしつつ返金することが可能です。 Refundオブジェクトを利用します。

stripe.com

返金のタイミング

Stripeでは返金はいつでも可能です。いつでもというのは子アカウントの銀行口座に残高を振り込んだ後でも可能であり、アカウントの残高はマイナスになることも許容されます。
しかし経理チームからの要求としては「特定の期間で"締め"て、締めたデータは変更されなくなる」ことが望まれていました。月次決算として売り上げを計上した後に返金され、計上された売り上げが変わってしまうと困ります。
そのためサービス方針として返金は24時間以内のみ可能であることを明記し、オペレーションとしてもStripeのダッシュボードからの返金は行わずに基本的にAPI経由で行う方針としました。
Stripeの決済金額は最短4日後に利益として確定されます(この件については次節で解説します)。そのため毎月5日には前月の売り上げが確定されることになる形で経理チームと合意しました。5日よりも早い方が経理としては嬉しいものの、Stripeの仕様上ここまでしかできないと結論づけました。

入金について

上記でStripeの仕様上と書きましたが、これはStripeの自動入金スケジュールが関わってきます。日本の口座の場合「週ごと」と「月ごと」が選択でき、スケジュール予定日時点で振込可能な金額が自動で入金されます。
「振込可能な金額」になるのが最短4日かかるため、例えば5/30が振込予定日だった場合は5/29の決済金額は含まれないことになります。
このStripeの入金の仕様は弊社経理チームとして辛く、導入企業からも問い合わせが多かった箇所でもありました。経理業務として考えると月内の売上がまとめて入金されることが望ましいですが、Stripeの入金を毎月5日に設定していた場合は月初め1日の売上も含まれてしまうことになります。これを4日にしていたとしても、Stripeの入金処理が始まるのはおそらくUTC時間の0時ごろであり(Stripeのサポートに確認しましたが実際の時間は答えられないという回答でした。)、完璧なしきい値で入金対象を選ぶことは難しいです。経理チームと相談の上、タイミーデリバリーでは毎月5日の入金とし、ダッシュボード上でダウンロードできる利用明細と入金額を突合して処理してもらう形とすることにしました。

一応この問題を解決する方法はあるにはあり、Stripeの自動入金ではなくAPIによる制御での手動入金を行うことで解決できます。他社さんでStripeを利用しつつ、月末締め翌月末払いを実現しているサービスは手動入金を活用しているようです。
しかし手動入金をしようとなるとアプリケーション側のロジックで入金金額の計算とAPIリクエストなどの完全性を担保しなければならず、PMFしていない状態で自前で実装するのはコストやリスクが高いと判断し、今回は利用しませんでした。

よかったこと

ここまででStripeの導入について説明してきました。Stripeを利用していてよかったことを説明します。

決済に関するトラブルが非常に少なかった

運用していて半年程度経ちますが、決済にまつわるトラブルや問い合わせはかなり少ないです。特に「クレジットカード決済ができない」ような問い合わせは0でした。StripeのSDKのUXが高いのもそうですし、カード情報の間違いなどによるエラーはそれぞれ適切にメッセージを返してくれるからだと言えます。

ドキュメント、サポート、開発ツールが手厚い

開発者フレンドリーを謳っている通り、実装していて迷った場合は基本的に全て公式ドキュメントを読むことで解決できました。
ドキュメントを読んでも分からないことはサポートへ問い合わせていましたが、こちらもかなり助けられました。チャット、電話、メールによる問い合わせを受け付けており、メールでの問い合わせは日本語でやり取りすることができます。24時間以内の返信が保証されているのも安心できます。
開発ツールとして管理画面からAPIのリクエストログやWebHookの結果を閲覧することができ、こちらも非常に見やすく便利でした。

失敗したこと

一方失敗したなと思えたことはいくつかあり、Customアカウントを選ばない方がよかったかもしれない、と今では思っています。理由は以下の2点です。

事業者に直接Stripeダッシュボードを見てもらった方が楽なケースが多かった

CustomアカウントではStripeを利用していることをほぼ完全に隠蔽できるほどにカスタマイズできますが、裏を返せば自身で実装しなければならない箇所がグンと増えます。CustomアカウントではStripeのダッシュボードを顧客に提供することができないため、ダッシュボードで提供されている売り上げや各種決済情報の明細、CSVエクスポートなどの便利な機能を丸々使うことができず、自身で実装しアプリケーション内で提供する必要がありました。これは最小限の実装で済ませたいスタートアップとしては非常に重く、そもそもStripeを利用していることを隠したいモチベーションもないため、Expressアカウントの方がよかったのではないかと考えています。

事業者のStripeへの登録における実装がかなり重く、Expressで提供されているAccountLinkを使った方が良さそう

上述した通りStripeではAccountに事業者の各種情報を登録すればその瞬間から決済を行うことができますが、その情報が会社情報、代表者情報(身分証明証も含む)など多岐にわたり、導入事業者にその情報を収集してもらうのがかなり大変でした。加えてCustomアカウントではその情報を登録するフォームを自身で実装しなければならず、それぞれの情報のバリデーションまで含めるとかなりの工数がかかってしまい、リリース前に想定していた工数を想定以上に上回ってしまいました。
Expressアカウントの場合この登録フォームを提供できるAccountLinkと呼ばれる機能が提供されているので、それを使えば上記の工数は必要なかったのに…と後悔しています。

今アカウントを選ぶとした場合の判断基準

元々Customアカウントを選んだ理由は以下の3点でした。

  • 導入事業者に課すプラットフォーム手数料の計算をアプリケーションロジックで行いたかったこと
  • 弊社経理チームの都合や導入事業者の経理面からの要望により、返金等の請求内容の変更が発生するタイミングを制御したかったこと
  • ネットで検索したところ、Expressアカウントの採用事例がまだ少なかったこと

Standardアカウントでは返金の判断も事業者に任せてしまうため採用は難しいですが、上2つに関してはExpressアカウントでも可能です。
それに加えて事業者向け利用明細や事業者情報の登録フォームなどのSaaSに任せられる機能もExpressアカウントでは利用できるため、今からStripeを導入するとしたらExpressアカウントを利用すると思います。
どの機能は自身のアプリケーションで実装しなければならず、どの機能をSaaSに任せられるのか、を判断軸として持っておいた方が良いと書き残しこの記事を締めくくりたいと思います。

終わりに

いかがだったでしょうか。新規事業でStripeを利用する際に考えたことのほぼ全てをこの記事に集約したつもりです。ぜひ参考にしていただければ幸いです。
15000字を超える超長文になってしまいましたが、ここまでお読みくださり誠にありがとうございました。