Rails + sidekiqでサブスク課金機能を作ってみた


## はじめに
こんにちは。[kosukein38](https://twitter.com/kosukein38)です。
今回は、**一定期間で課金が走るサブスクサービス**を仮定して、サンプルコードを用いながら紹介しつつ、**sidekiqを用いた非同期処理**についてまとめていきます。

以下のような機能を実装します。
- 契約から3日間は500円の定額
- 4日目から1日ごと200円が課金
- ユーザーが解約をしない限り、課金される
- 解約したらsidekiqのキューも削除される

**細かい説明は省略していますので、雰囲気だけ掴んでいただければと思います。**
**マサカリ大歓迎です**:pick::dash:

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1697316/1c29f5af-7c7f-b86f-b164-af07444881bb.png" width="70%">


## 対象者
- sidekiqをざっくり知りたい方
- サブスクサービスでの課金機能を実装したい方



## sidekiqとは
結論、非同期処理（ジョブキュー）を管理するためのライブラリです。

[sidekiq gem](https://github.com/sidekiq/sidekiq)

>Simple, efficient background processing for Ruby.
>Sidekiq uses threads to handle many jobs at the same time in the same process. It does not require Rails but will integrate tightly with Rails to make background processing dead simple.

>シンプルで効率的なRubyのバックグラウンド処理。
>Sidekiqはスレッドを使用して、同じプロセスで同時に多くのジョブを処理します。Railsを必要としませんが、Railsと緊密に統合してバックグラウンド処理を非常にシンプルにします。(by DeepL翻訳)

例えば、サブスクサービスのように**契約開始から一定期間後に課金処理を走らせる**など、非同期処理をしたい時に役立ちます。


## インストール方法や使い方
[公式Wiki](https://github.com/sidekiq/sidekiq/wiki)や他の記事で詳しく解説されていますので、本記事では細かなメソッドの使い方は省略します:bow:

## サブスク課金機能の実装
### ER図（簡易版なので参考程度に）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1697316/59026ce0-995a-7c22-3aba-d7859417f4c9.png" width="70%">


### 各テーブル説明


- `users`テーブル（名前, メールアドレス, password_digestなどユーザー情報を管理）
- `plans`テーブル（サブスクプラン名、価格などプラン情報を管理）
- `contracts`テーブル（ユーザーとプランなどを紐づける中間テーブル、契約情報を管理）
- `payments`テーブル（支払い金額、クレジットカード決済に必要なトークンなど、ユーザーの支払い情報を管理）

以下はsidekiqでjob実行する際に使うテーブルです。今回は**決済予約job**(`payment_job`)と**決済job**（`create_payment_job`）の2つのジョブを作ります。

**決済予約job** ： 決済タイミングコントロール、sidekqへのキューイング、決済job呼び出し
**決済job** ： 決済処理のみのjob（契約情報からpaymentオブジェクトを作成するjob）

- `payment_job_logging_jids`テーブル（sidekiqに保存する決済予約jobのjidを管理）
- `payment_job_histories`テーブル（sidekiqで実行した決済予約jobの履歴※エラーも含む）
- `create_payment_job_histories`テーブル（sidekiqで実行した決済jobの履歴※エラーも含む）


決済予約jobと決済jobは同じjobにまとめても良いかと思いますが、うまくjobが走らなかった際のデバッグをしやすくするためにも、大きなjobにするのは避けたほうが良いかもしれません。
また、運用を考えると**履歴**を残しておくことは重要です。`~~histories`テーブルに、エラーメッセージ含め、sidekiqを実行した履歴を保存します。



### 契約時のロジック
- ユーザーがサービスを契約ボタンをクリックすると、`contracts_controller#create`が走る
- そのcreateアクション中で決済予約job実行`PaymentJob.exec_perform(@contract.id)`
- 決済予約job中でsidekqへのキューイング
- ユーザーに登録完了のメールを送る


```ruby:app/controllers/contracts_controller.rb
  def create
    @contract = current_user.contracts.build(contract_params)
    ActiveRecord::Base.transaction do
        @contract.save!
        @contract.create_payment!(Date.current)
        PaymentJob.exec_perform(@contract.id) #24時間ごとの請求job登録
    end
    UserMailer.finish_registration(current_user, @contract).deliver_now
    redirect_to contracts_path, notice: "登録が完了しました。メールをご確認ください"
  rescue => e
    logger.error e
    redirect_to contracts_path, alert: "エラーが発生しました。お手数ですが、もう一度登録を行ってください"
  end

  def contract_params
    params.require(:contract).permit(:plan_id)
  end
```

Contractモデルの`create_payment!(dt)`メソッドでpaymentsテーブルへ請求情報を登録

```ruby:app/models/contract.rb

  def create_payment!(dt)
    base_price = plan.price
    payment = user.payments.new(
      contract_id: id,
      price: (base_price + (base_price * Payment::TAX_RATE)).round(0),
      plan_id: plan_id,
    )
    payment.save!
  end

```

`payment_job`で2つのメソッドに分かれており、
- `self.exec_perform(contract_id)`：実行タイミングを調整し、sidekiqへキューを登録します。
- `perform(contract_id, dt_string)`：決済jobを実行します。


```ruby:app/jobs/payment_job.rb

class PaymentJob
  class FailedPaymentJobError < StandardError; end

  include Sidekiq::Worker
  sidekiq_options queue: 'payment', backtrace: 5
  # 万が一ジョブが失敗しても二重請求されないように。デッドセットへ移行
  sidekiq_options retry: 0 

  def self.exec_perform(contract_id)
    contract =  Contract.find_by(id: contract_id)
    # 契約が削除されていたら次回のジョブ予約はせずreturn
    return if contract.nil? 
    payment_job_logging_jid = PaymentJobLoggingJid.find_by(contract_id: contract_id)
    if payment_job_logging_jid.nil? # 初回のjob予約（最初の3日間は定額）
      payment_job_logging_jid = PaymentJobLoggingJid.new(contract_id: contract_id)
      dt = Time.current + 3.day
      PaymentJob.perform_in(3.day, contract_id, dt.strftime("%Y-%m-%d %H:%M:%S")) 
    else
      dt = Time.current + 1.day  # 4日目以降は1日ごとに課金
      PaymentJob.perform_in(1.day, contract_id, dt.strftime("%Y-%m-%d %H:%M:%S")) 
    end
    payment_job_logging_jid.assign_attributes(jid: Sidekiq::ScheduledSet.new.entries.last.jid, target_datetime: dt)
    payment_job_logging_jid.save!
  end

  def perform(contract_id, dt_string)
    contract =  Contract.find_by(id: contract_id)
    # 契約が削除されていたら請求処理も次回のジョブ予約もせずreturn
    return if contract.nil? 
    jid = PaymentJobLoggingJid.find_by(contract_id: contract_id).jid
    # 決済ジョブを実行
    CreatePaymentJob.perform_async(contract_id, dt_string)
    # ジョブ実行履歴を作成
    PaymentJobHistory.create!(contract_id: contract_id, jid: jid, target_datetime: dt_string) 
    PaymentJob.exec_perform(contract_id)
  rescue => e
    logger.error e
    PaymentJobHistory.create!(contract_id: contract_id, jid: jid, target_datetime: dt_string, error_messages: "#{e.class} #{e.message}")
    raise FailedPaymentJobError.new("#{e.class} #{e.message}")
  end
end

```

`create_payment_job`で決済情報を登録します。
（実際に課金するには別途、クレジットカード決済のためのapiを叩くなどの処理が必要です。）

```ruby:app/jobs/create_payment_job.rb
class CreatePaymentJob
  class FailedCreatePaymentJobError < StandardError; end

  include Sidekiq::Worker
  sidekiq_options backtrace: 5, queue: 'create_payment'

  # リトライ設定はSidekiqのデフォルト

  def perform(contract_id, dt_string)
    contract = Contract.find_by(id: contract_id)
    # 契約が削除されていたら請求処理をせずreturn
    return if contract.nil? 

    dt = dt_string.to_date
    payment = contract.create_penalty_payment!(dt)
    raise FailedCreatePaymentJobError.new("支払いに必要なtokenなし、管理画面を確認してください") if payment.token.nil?
    CreatePaymentJobHistory.create(contract_id: contract_id, target_datetime: dt_string)
  rescue => e 
    logger.error e
    CreatePaymentJobHistory.create(contract_id: contract_id, target_datetime: dt_string, error_messages: "#{e.class} #{e.message}")
    # レスキュー内で例外を発生させることで、リトライセットへ移行
    raise FailedCreatePaymentJobError.new("#{e.class} #{e.message}")
  end
end
```
これで「最初3日間は定額、4日目からは1日毎課金する」が実現できます。

### 解約時のロジック
いろいろな方法があると思いますが、例えば、以下のようにcontractsコントローラーにdestoroyアクションを定義し、

```ruby:app/controllers/contracts_controller.rb
#・・・（略）・・・
    def destroy
        @contract = current_user.contracts.find(params[:id])
        @contract.destroy!
    end

#・・・（略）・・・
```

contractオブジェクトが削除されると、`dependent: :destroy`でcontractに紐づくpayment_job_logging_jidも削除され、（続く）

```ruby:app/models/contract.rb
#・・・（略）・・・
  has_one :payment_job_logging_jid, dependent: :destroy
#・・・（略）・・・
```

PaymentJobLoggingJidモデルのコールバックでsideiqのキューも削除、といったロジック


```ruby:app/models/payment_job_logging_jid.rb
#・・・（略）・・・

  before_destroy :delete_sidekiq_que

  # キューが残らないようにするコールバック処理
  def delete_sidekiq_que
    Sidekiq::ScheduledSet.new.find_job(jid).try(:delete)
  end

#・・・（略）・・・
```

## 非同期処理のあれこれ

「rakeタスクなど、バッチ処理を実現する方法はいろいろあるけど、どんな違いや使い分けがあるんだろう？」と思っていたところ、以下のOffersさんの記事を読んで、なるほどな~~と思いました。
https://zenn.dev/overflow_offers/articles/20230216-how-to-create-batch-in-rails

> - rails コマンドの機能である runner コマンドを経由してRuby スクリプトを書く
> - タスク実行のライブラリである rake を呼び出す
> - バックグラウンドジョブサーバ(Sidekiq/Resque/DelayedJob など)に委ねる


## まとめ
サブスク課金機能を通じて、sidekiqの使い方を紹介しました。

今回、紹介したサブスクの課金機能は、あるイベント（契約開始）をフックに3日後に課金、1日後に課金などを厳密に行わなければなりません。非同期なバッチ処理を実現する方法は色々ありますが、エラー時のリトライもうまいことしてくれるバックグラウンドジョブサーバ(sidekiq)に任せるのが適しているかなと。あと、パフォーマンス面を考え出すとより奥深い世界だなとも思いました:innocent:


## その他、参考記事

[Sidekiqってどんなキック？ iCAREブログ](https://dev.icare.jpn.com/dev_cat/sidekiq/)
[Rails: SidekiqはActive Jobを経由せずに直接使おう（翻訳）](https://techracho.bpsinc.jp/hachi8833/2023_03_06/127675)