📜
【Rails】複雑なキャンセルポリシーを柔軟にテーブルで管理したい
if 文だらけのキャンセル料ロジックから、DB設計での表現へ
背景:ビジネスロジックの複雑化と if 文地獄
Railsアプリでキャンセル料を計算する際、現状では以下のような if 文で条件分岐を管理しています。
def calculate_normal_cancellation_fee(will_cancel_at: Time.current)
return 0 if will_cancel_at < date.beginning_of_day - 10.days
order_price_big_decimal = BigDecimal(price.to_s)
if will_cancel_at.between?(date.beginning_of_day - 10.days, date.end_of_day - 3.days)
(order_price_big_decimal * BigDecimal("0.3")).floor
elsif will_cancel_at.between?(date.beginning_of_day - 2.days, date.end_of_day - 1.day)
(order_price_big_decimal * BigDecimal("0.5")).floor
else
order_price_big_decimal.floor
end
end
この実装でも当初は問題ありませんでしたが、以下のような課題が徐々に浮かび上がってきます:
- ポリシーの変更・追加がコード修正を伴う ため運用コストが高い
- プランごとの例外対応 が増えることで、分岐がさらに複雑化
目指す姿:キャンセルポリシーをDBで表現する
このような課題を解決するために、キャンセル料のロジックを「コード」ではなく「データ」で定義・管理する設計へ移行することを考えています。
テーブル名などはまだ仮ですが、以下のようなイメージです。
設計の概要
plan_cancellation_policies
各プランにどのポリシーがいつからいつまで適用されるかを定義:
| ID | プランID | ポリシーID | 適用開始日 | 適用終了日 |
|---|---|---|---|---|
| 1 | 192 | 1 | 2025/4/1 |
cancellation_policies
「何日前まで無料か」など、ポリシーの基本情報:
| ID | 名前 | 何日前まで無料か | デフォルトかどうか |
|---|---|---|---|
| 1 | 11日前まで無料 | 11 | true |
cancellation_policy_tiers
何日前のキャンセルかに応じた料率を定義:
| ID | policy_id | min日数前 | max日数前 | 種類 | 値 | 説明 |
|---|---|---|---|---|---|---|
| 1 | 1 | 3 | 10 | percentage | 0.3 | 3~10日前 30% |
| 2 | 1 | 1 | 2 | percentage | 0.5 | 前日・2日前 50% |
| 3 | 1 | 0 | 0 | percentage | 1.0 | 当日 100% |
この設計により、キャンセル料のルールを テーブル定義により表現可能 になり、新しいルールが必要になったときでも、コードに手を入れる必要がなくなります。
実装イメージ:何日前かをもとにキャンセル料を算出
キャンセル料の計算は、以下のように実装できそうです。
class CancellationPolicy < ApplicationRecord
def calculate_cancellation_fee(shooting_date:, canceled_at: Time.zone.now, amount:,)
days_before = (shooting_date - canceled_at.to_date).to_i # 何日前のキャンセルか
return 0 if days_before >= self.no_fee_days_before
# 対応する CancellationPolicyTier を取得
tier = self.tiers.find do |tier|
days_before.between?(tier.min_days_before_inclusive, tier.max_days_before_inclusive)
end
return 0 unless tier
case tier.fee_type
when 'percentage'
(BigDecimal(amount.to_s) * tier.fee_value).floor
when 'fixed'
tier.fee_value
end
end
end
実装イメージ:バリデーション
管理画面から誰でもさわれるようにするなら、バリデーションも必要ですね。
CancellationPolicyTier に空きがあってはいけないので、それをチェックします。
class CancellationPolicy < ApplicationRecord
validates :no_fee_days_before, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
# ポリシーが持つ tiers 全体の整合性をチェック
validate :tiers_cover_expected_range, on: :update
private
# 0〜no_fee_days_before までをカバーしていること
def tiers_cover_expected_range
return unless tiers.empty?
full = (0..no_fee_days_before).to_a
covered = tiers.flat_map { |tier| (tier.min_days_before_inclusive..tier.max_days_before_inclusive).to_a }.uniq
missing = full - covered
if missing.any?
errors.add(:tiers, "#{missing.min}〜#{missing.max}日前の条件が設定されていません")
end
end
end
テーブルで管理するメリット
この設計により、次のようなメリットが得られます。
- 非エンジニアでも 管理できるようになる
- コードの変更を伴わない ポリシー変更が実現
テーブルで管理するデメリット
デメリットとしては以下のようなものが考えられますね。
- 管理画面を操作できれば誰でも変更できてしまう ため、想定外の影響が出る可能性がある
- ビジネスロジックがコード外に出る ことにより、変更の意図が見えにくくなる
まとめ
「条件分岐の山」は、長年運用している Rails アプリにおけるよくある悩みです。
ビジネスルールをデータベースで定義することにより、コードの保守性と柔軟性を両立できるようになります。
キャンセル料のような複雑なロジックが増えていくほど、 データ駆動での設計 により柔軟な対応が可能になるのではないかと考えているため、これからも改善できる部分を探し続けます。
Discussion