📜

【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