🏪

Stripeコンビニ決済をRailsで導入してみる

2023/04/06に公開

こんにちは、株式会社スペースマーケットのエンジニアのtchmrです。

コンビニ決済(以下、コンビニ支払い)について検証したので知見を共有できればと思います。
コンビニで利用料を支払うことができればクレジットカードを持っていない方にもサービスを利用いただきやすくなるかと思っています。

公式ドキュメント
https://stripe.com/docs/payments/konbini/accept-a-payment

コンビニ支払いの流れ

コンビニ支払いがどのような流れで行われるのかをざっくり示すと以下となります。

スペースの予約時

  1. 利用者がコンビニ支払いを選択してスペースの予約をする
  2. 予約ステータスが支払い待ちとなるので、利用者はコンビニで支払いを行う
  3. Stripe側で支払いを受け付けた旨の情報がスペースマーケットに連携される
  4. 予約ステータスが予約完了に更新される

予約キャンセルに伴う返金時

  1. 管理者が予約キャンセルを行う
  2. 利用者宛にStripeから返金口座登録メールが送信される
  3. 利用者が返金口座を登録する
  4. Stripeから返金ステータス更新イベントがスペースマーケットに連携される
  5. Stripeと銀行間で送金処理が行われる
  6. Stripeから返金成功または失敗の返金ステータス更新イベントがスペースマーケットに連携される
  7. 予約ステータスが払い戻しに更新される

コンビニ支払いAPI

支払情報の作成

予約(購入)時にPaymentIntentを作成します。
PaymentIntentは支払いに関する情報を管理するためのオブジェクトで、Charge(課金情報)とTransfer(送金情報)が紐付いています。
直訳すると支払いの意思となりあまりピンとこないですが、支払いに関する情報をまとめて管理している抽象的なオブジェクトなのだなくらいに認識しています。

以下、Rubyのサンプルリクエストとなります。

Stripe::PaymentIntent.create(
  {
    amount: 1000,
    currency: 'jpy',
    payment_method_types: ['konbini'],
    payment_method_options: {
      konbini: {
        product_description: 'Tシャツ',  # 商品情報
        expires_after_days: 3,  # コンビニで支払いを行うまでの有効期限
      },
    },
    payment_method_data: {
      type: 'konbini',
      billing_details: {
        name: '利用者名', 
	email: 'customer@examle.com',  # 返金時の口座情報入力メールの宛先
      },
    },
    on_behalf_of: 連結アカウントID,  # 支払い記録対象※1
    transfer_data: {  # このプロパティを設定しておくとTrasferも同時作成される
      amount: 1000,
      destination: 連結アカウントID,  # 送金先アカウント
    },
  }
)

※1 on_behalf_ofプロパティを指定することで支払いの記録対象をプラットフォームではなく、各連結アカウントにすることができます。コンビニ支払いではStripeによる審査が必要となりますが、この指定をする場合は連結アカウントごとにコンビニ支払いのケイパビリティを有効化する必要があります。
https://stripe.com/docs/connect/charges-transfers#on-behalf-of

上記のリクエストを実行するとStripe上にPaymentIntentが作成されます。

ただし、この時点では利用者は支払いを完了していないため、ステータスがrequires_action(コンビニ支払い待ち)となります。
利用者がコンビニ支払いを完了または有効期限が切れるとStripeはpayment_intent.succeeded/failedイベントを送信します。
システム側でWebhookハンドラーを構築して連携されたWebhookを処理できるようにする必要があります。

こちらについては別記事を参照いただければと思います。
https://zenn.dev/spacemarket/articles/80516387f92ddd

現実的には、PaymentIntentを作成する際にDB登録などの処理も行うことが多いと思われるためServiceクラスで処理をまとめたサンプルコードを用意しました。

class CreateKonbiniPaymentService
  require "stripe"
  attr_accessor :options
  attr_accessor :stripe_service

  # optionsにリクエストに必要な情報が含まれていると仮定
  # stripe_serviceはテストしやすいようにmockを挿入できるようにしています
  def self.call(options, stripe_service: Stripe::PaymentIntent)
    new(options, stripe_service).call
  end

  def initialize(options, stripe_service)
    self.options = options
    self.stripe_service = stripe_service
  end

  # コンビニ支払い 課金作成処理
  def call
    set_stripe_api_setting

    begin
      response = create_payment_intent(generate_payment_intent_param)
      result = JSON.parse(response.to_json)
    rescue => e
      Rails.logger.error("支払オブジェクト作成に失敗しました。#{e.message}")
      raise e
    end

    begin
      # DB登録
      KonbiniPayment.create!(
        payment_intent_id: result[:payment_intent_id],
        payment_expires_at: result[:payment_expires_at],
        charge_amount: result[:charge_amount],
        voucher_url: result[:voucher_url] # コンビニ支払いコードを表示するためのURL。予約ページに表示したり、予約受付メールに記載して支払動線を作る際に使用します。
	# ...省略
      )
    rescue => e
      Rails.logger.error("支払情報のDB登録に失敗しました。#{e.message}")

      # DB登録に失敗した場合はStripe上のPaymentIntentをロールバックする
      payment_intent_id = result[:payment_intent_id]
      payment_intent_obj = retrieve_payment_intent(payment_intent_id)
      cancel_payment_intent(payment_intent_id) if payment_intent_obj.present?
      Rails.logger.info("支払オブジェクトをキャンセルしました。")
      raise e
    end
    result
  end

  private

  def set_stripe_api_setting
    Stripe.api_key = ENV["STRIPE_API_KEY"] # 環境変数に設定しておく
    Stripe.api_version = "xxxx-xx-xx" # APIバージョンを指定
  end

  def generate_payment_intent_param
    {
      amount: options[:charge_amount],
      currency: 'jpy',
      payment_method_types: ['konbini'],
      payment_method_options: {
        konbini: {
	  product_description: options[:product_description],
	  expires_after_days: options[:expires_after_days]
	},
      },
      payment_method_data: {
        type: 'konbini',
        billing_details: {
	  name: options[:customer_name], 
	  email: options[:customer_email]
	},
      },
      on_behalf_of: options[:connect_id],
      transfer_data: {
        amount: options[:transfer_amount],
        destination: options[:connect_id],
      }
    }
  end

  # 以下、Stripe APIリクエスト処理
  # エラーハンドリングできるようにメソッドを定義しています(実際はもっと丁寧なエラーハンドリングをしたほうが良いです)
  def create_payment_intent(params)
    stripe_service.create(params)
  rescue => e
    Rails.logger.error "Stripe API Error: #{e.message}"
    raise e
  end

  def retrieve_payment_intent(payment_intent_id)
    stripe_service.retrieve(payment_intent_id)
  rescue => e
    Rails.logger.error "Stripe API Error: #{e.message}"
    raise e
  end

  def cancel_payment_intent(payment_intent_id)
    stripe_service.cancel(payment_intent_id)
  rescue => e
    Rails.logger.error "Stripe API Error: #{e.message}"
    raise e
  end
end

予約時にCreateKonbiniPaymentService.call(options)を呼び出してあげればStripeとDBに支払オブジェクトを作成することができます。

返金

コンビニ支払い後にキャンセルとなる場合などは返金を行う必要があります。
コンビニ支払いでは、利用者が登録した銀行口座に返金額が入金される形となります。

Stripe::Refund.create(
  {
    payment_intent: PaymentIntentID,
    amount: 1000,  # 返金額
    reverse_transfer: true,  # Transferの返金も併せて作成※2
  }
)

※2 reverse_transferプロパティをtrueとすることでPaymentIntentに紐づくTransferについても同額の差し戻しを実行します。
金額が相違する場合は別途Transferの差し戻しを行う必要があります。

上記のリクエストを実行するとRefundオブジェクトが作成されるとともに、自動的にStripeから返金口座登録メールが利用者のメールアドレス宛に送信されます。
※ 利用書のメールアドレスはPaymentIntentを作成した際に登録したpayment_method_data.billing_details.emailです。

口座情報を入力するとStripeから銀行口座への送金処理が行われます。(実際に利用者の口座に入金されるまでにはある程度の日数がかかります)
返金オブジェクトのステータスが変更される際にはcharge.refund.updatedイベントが送信されるのでPaymentIntentの作成時と同様にWebhookハンドラを構築することで追跡することが可能です。

その他

StripeはAPIが充実しているため様々な操作ができます。
詳しくはAPIドキュメントをご覧ください。
https://stripe.com/docs/api/payment_intents

感想

決済手段の多様化に伴ってサービスに複数の決済手段を導入する必要性が高まってきています。
Spacemarketでは、クレジットカード以外にもAmazonPayや楽天ペイ、Paidy、Paidなどを導入しています。

決済手段を追加することはビジネスインパクトがある一方でプロダクトコードが複雑化してしまうリスクもあります。
キャッシュレス決済が進展している現在の状況からして今後も決済手段が増加していく可能性は高く、そのことを見越した設計・リファクタリングをしていく必要があると感じました。

最後に

スペースマーケットでは一緒に働いてくれる仲間を探しています。
少しでも興味を持っていただけたらぜひ採用ページをご覧ください。

https://www.wantedly.com/projects/1113570

https://www.wantedly.com/projects/1113544

https://www.wantedly.com/projects/1061116

https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion