/>
S
Shineos Tech Blog

目次

SaaS課金における「中途解約・変更」の複雑な按分処理と実装パターン

SaaS課金における「中途解約・変更」の複雑な按分処理と実装パターン

| Shineos Dev Team
Share:

この記事の要点

Q: 按分処理(Proration)とは何ですか?

A: 期間に対する金額を日割りや秒割りなどで計算し、利用期間に応じた正当な料金を算出するプロセスです。プラン変更時の旧プラン未使用分と新プラン利用分の相殺に使用されます。

Q: 標準日割りと秒単位按分の違いは?

A: 標準日割りは実装が簡単でユーザーに理解しやすい一方、月によって日数が異なるため単価が変動します。秒単位按分は極めて公平ですが実装コストが高いです。

Q: 金額計算で最も重要なポイントは?

A: 必ず浮動小数点数ではなく10進数のDecimal型を使用し、丸め誤差を排除することです。

Q: プラン変更をどのように設計すべき?

A: 課金変更処理を「解約」と「新規契約」のアトミックなトランザクションとしてモデル化し、不整合な状態を防ぎます。

はじめに

SaaSサービスの課金ロジック実装において、エンジニアが最頭を悩ませるのが「中途解約」や「プラン変更」時の按分処理ではないでしょうか。月額課金であれば単純に固定額を請求すれば済みますが、ユーザーが月中にプランをアップグレードしたり、解約したりする状況となると話は一変します。1日単位の計算なのか、秒単位の計算なのか、あるいは月単位の切り捨て・切り上げを行うのか。その決定一つで、数円のズレが積み重なり、最終的には会計監査で重大な指摘を受ける事態になりかねません。

私も過去に、ある急成長中のスタートアップで課金システムのリニューアルに携わった際、按分計算のロジックが複雑化しすぎて、解約処理だけで数秒間タイムアウトしてしまうというスレッドデッドロックの問題に直面した経験があります。この苦い経験から、私は「課金ロジックはビジネスのルールをそのままコードに落とし込むのではなく、数学的に厳密かつ計算効率の良いモデルに抽象化する必要がある」と痛感しました。本記事では、その際に得た知見をもとに、正確で拡張性の高い按分処理の実装パターンについて解説します。

按分処理(Proration)とは?

按分処理とは、期間に対する金額を日割りや秒割りなどで計算し、利用期間に応じた正当な料金を算出するプロセスを指します。SaaSのコンテキストでは、ユーザーが月の途中でプランを変更した際に、旧プランの「未使用分」を計算し、新プランの「追加利用分」と相殺する際によく使われます。

実際に試してみた

以前のプロジェクトでは、単純な日割り計算(月額÷30日)を採用していましたが、大の月(31日)と小の月(28日)が存在するため、1日あたりの単価が月によって変動するという問題が発生しました。これにより、「月末に解約すると1日あたりの単価が安くなる」という不条理なユーザー体験を生んでしまい、カスタマーサポートからの苦情が殺到したことを覚えています。

まとめ

本記事で解説する按分処理の要点は以下の通りです。

  • 計算単位の厳密な定義: 日割り計算における「1ヶ月」の定義(30日固定か、暦日か)を明確にし、ビジネス要件と整合させる。
  • 精度管理の重要性: 金額計算には必ず浮動小数点数ではなく、10進数のDecimal型を使用し、丸め誤差を排除する。
  • トランザクション設計: 課金変更処理を「解約」と「新規契約」のアトミックなトランザクションとしてモデル化し、不整合な状態を防ぐ。
  • 監査ログの実装: 誰が、いつ、どのような計算式で課金額を決定したかを追跡可能なログ構造を設計する。

按分計算のアーキテクチャとビジネスユースケース

ビジネスユースケース:プランアップグレード時の請求

具体的なシナリオとして、月額1,000円の「ベーシックプラン」から月額3,000円の「プロプラン」への変更を考えます。ユーザーが30日間の課金サイクルの10日目に変更をリクエストした場合、システムは以下の手順で処理を行う必要があります。

  1. 旧プランの未使用分を計算: 残り20日分の料金を算出し、ユーザーの残高またはクレジットとして計上。
  2. 新プランの利用分を計算: 残り20日分をプロプランの単価で算出。
  3. 差分の請求: 新プランの利用額から旧プランの未使用額を差し引いた金額を即時請求、または次回の請求に加算。

この一連のフローをシステム内でどのように表現するかが、アーキテクチャの鍵となります。上図は、このデータの流れと計算ロジックの関係性を示したものです。単一のテーブルを更新するのではなく、変更履歴を不可変なログとして残す「イベントソーシング」的なアプローチを採用することで、過去の計算結果が変更された際の影響範囲を限定できます。

計算ロジックの選択:標準日割り vs 秒単位按分

按分計算には大きく分けて「標準日割り」と「秒単位按分」の2つのアプローチがあります。

標準日割りは、実装が簡単でユーザーにも理解しやすいのが特徴です。しかし、先ほど述べたように月によって日数が異なるため、1日あたりの単価が変動します。一方、秒単位按分は、1ヶ月を「30日 × 24時間 × 60分 × 60秒」として固定するか、実際の経過秒数に基づいて計算します。これにより、極めて公平な計算が可能になりますが、実装コストが高くなります。

実装例:Pythonによる堅牢な按分処理

ここでは、Pythonを用いて、実際のプロダクション環境で使用できるレベルの按分計算ロジックを実装します。要件として以下の点を満たす必要があります。

  • 金額計算にはdecimalモジュールを使用し、丸め誤差を防ぐ。
  • 計算過程で例外が発生した場合でも、ログに出力して原因を特定できるようにする。
  • プラン変更を「旧契約の終了」と「新契約の開始」として扱う。
import logging
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from dataclasses import dataclass
from enum import Enum

# ロギングの設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class PlanType(Enum):
    BASIC = "basic"
    PRO = "pro"

@dataclass
class Plan:
    id: PlanType
    monthly_price: Decimal
    name: str

@dataclass
class Subscription:
    plan: Plan
    start_date: date
    end_date: date

class ProrationCalculator:
    def __init__(self):
        # 丸め処理の設定:四捨五入
        self.quant = Decimal('0.01')
        self.rounding = ROUND_HALF_UP

    def calculate_daily_rate(self, plan: Plan, target_date: date) -> Decimal:
        """
        特定の日付を含む月における1日あたりの単価を計算する。
        暦日ベース(その月の日数で割る)を採用。
        """
        try:
            # その月の日数を取得
            if target_date.month == 12:
                days_in_month = (date(target_date.year + 1, 1, 1) - date(target_date.year, 12, 1)).days
            else:
                days_in_month = (date(target_date.year, target_date.month + 1, 1) - date(target_date.year, target_date.month, 1)).days
            
            if days_in_month == 0:
                raise ValueError("Days in month cannot be zero")

            daily_rate = (plan.monthly_price / Decimal(days_in_month)).quantize(self.quant, self.rounding)
            logger.info(f"Calculated daily rate for {plan.name} in {target_date}: {daily_rate}")
            return daily_rate
        except InvalidOperation as e:
            logger.error(f"Decimal calculation error for plan {plan.id}: {e}")
            raise ProrationCalculationError("Failed to calculate daily rate due to invalid operation") from e
        except Exception as e:
            logger.error(f"Unexpected error calculating daily rate: {e}")
            raise

    def calculate_prorated_amount(self, plan: Plan, start: date, end: date) -> Decimal:
        """
        指定期間の按分金額を計算する。
        複数の月にまたがる場合、月ごとの日割り計算を行い合算する。
        """
        if start >= end:
            return Decimal('0')

        total_amount = Decimal('0')
        current_date = start

        while current_date < end:
            # 月ごとの計算
            if current_date.month == 12:
                next_month_first = date(current_date.year + 1, 1, 1)
            else:
                next_month_first = date(current_date.year, current_date.month + 1, 1)
            
            # 期間の終了日と次月1日のどちらか早い方をその月の計算終了日とする
            period_end = min(end, next_month_first)
            days_in_period = (period_end - current_date).days

            daily_rate = self.calculate_daily_rate(plan, current_date)
            period_amount = daily_rate * Decimal(days_in_period)
            
            total_amount += period_amount
            logger.debug(f"Period {current_date} to {period_end}: {days_in_period} days * {daily_rate} = {period_amount}")

            current_date = period_end

        return total_amount.quantize(self.quant, self.rounding)

class ProrationCalculationError(Exception):
    pass

# ユースケースの実行
def execute_plan_change():
    basic_plan = Plan(PlanType.BASIC, Decimal('1000'), "Basic Plan")
    pro_plan = Plan(PlanType.PRO, Decimal('3000'), "Pro Plan")
    
    calculator = ProrationCalculator()

    # 仮定:今月が30日で、10日目に変更したとする
    cycle_start = date(2026, 2, 1)
    change_date = date(2026, 2, 11) # 11日変更(10日分はBasic)
    cycle_end = date(2026, 3, 1)

    try:
        # 1. 旧プランの未使用分(11日〜月末)
        unused_refund = calculator.calculate_prorated_amount(basic_plan, change_date, cycle_end)
        
        # 2. 新プランの利用分(11日〜月末)
        new_charge = calculator.calculate_prorated_amount(pro_plan, change_date, cycle_end)
        
        # 3. 差分計算
        balance_due = new_charge - unused_refund
        
        print(f"--- 課金計算結果 ---")
        print(f"旧プラン({basic_plan.name}) 未使用分(返還): -{unused_refund}円")
        print(f"新プラン({pro_plan.name}) 追加利用分(請求): +{new_charge}円")
        print(f"今回の請求差分: {balance_due}円")
        
        if balance_due > 0:
            print(f"-> ユーザーへ {balance_due}円 を請求します。")
        else:
            print(f"-> ユーザーへ {abs(balance_due)}円 を払い戻し(または次回繰越)します。")

    except ProrationCalculationError:
        print("課金計算中にエラーが発生しました。管理者に通知してください。")

if __name__ == "__main__":
    execute_plan_change()

このコードでは、月をまたがる計算にも対応するため、whileループで月ごとに日数と単価を計算し直しています。また、Decimal型を使用することで、浮動小数点数特有の「0.1 + 0.2 != 0.3」といった問題を回避し、金銭計算としての整合性を保証しています。

よくある質問

Q: うるう年(2月29日)がある場合の按分計算はどうすればよいですか?

A: うるう年を含む月の計算は、暦日ベース(Calendar Day Basis)で実装していれば、システム側で特別なロジックを追加する必要はありません。2月の日数が29日として自動的に計算されるため、1日あたりの単価は「月額 ÷ 29日」となり、他の月と同様に処理されます。もし「1ヶ月を常に30日とみなす」固定日数法を採用している場合は、2月29日や31日が存在する月の末日処理において、特別な調整ロジックが必要になる可能性があります。

Q: プラン変更と同時に解約された場合はどう処理すべきですか?

A: プラン変更リクエストと解約リクエストがほぼ同時に届いた場合、一般的には「タイムスタンプが古いリクエストを優先する」か、あるいは「解約を優先する」というルールを設ける必要があります。実装上は、変更リクエストを処理する前に、現在のサブスクリプションステータスを再確認し、既に「active」でない場合は処理をスキップするような楽観的ロック(Optimistic Locking)の導入が有効です。

Q: 按分計算の結果、1円未満の端数が出た場合はどうしますか?

A: 会計上のルールや決済代行会社の仕様に依存しますが、一般的には「切捨て」「四捨五入」「切り上げ」のいずれかをシステム全体で統一します。コード例では`ROUND_HALF_UP`(四捨五入)を採用しましたが、日本の消費税計算などでは切り捨てが慣習的な場合もあります。重要なのは、どのルールを採用するかをドキュメント化し、一貫性を保つことです。

おわりに

SaaSにおける按分処理は、単なる数学的な計算にとどまらず、ビジネスの公平性と信頼性を支える重要な基盤です。実装の複雑さに圧倒されそうになることもありますが、計算単位と丸めルールを明確に定義し、Decimal型を活用することで、堅牢なシステムを構築できます。

株式会社シャイノスでは、こうした課金システムの複雑性を吸収し、エンジニアがプロダクトのコア機能に集中できるようなバックオフィス基盤の構築支援を行っています。もし、自社の課金ロジックに課題を感じている場合は、ぜひ一度お問い合わせください。

参考リンク

[1] Python Decimalドキュメント - 10進数固定小数点および浮動小数点演算 [2] Stripe Prorations Guide


関連記事

「使いにくい」と言わせない!SaaS管理画面におけるUI/UX設計のShineos流ベストプラクティス

SaaS管理画面の使いにくさは解約率に直結します。Shineosが実践する、直感的で効率的な管理画面UI/UX設計の実践パターンを実装例とともに詳しく解説します

SaaS

マルチテナントSaaSアーキテクチャの設計指針 - スケーラブルなSaaS基盤の構築

マルチテナントSaaSの設計パターン、データ分離戦略、スケーラビリティの実現方法を実装例とともに解説します。実務で使える実践的なアーキテクチャ設計を学べます。

SaaS

エンタープライズSaaSに必須な「堅牢なテナント分離」の設計パターンとセキュリティ実装

マルチテナントSaaSでデータ漏洩を防ぐための、実践的なテナント分離戦略とセキュリティ実装パターンを具体的なコード例とともに解説します。

SaaS

サブスクリプションビジネスを支える柔軟な課金システムのデータベース設計 - 拡張性とメンテナンス性を両立する実践手法

SaaSプロダクトの成長に対応できる課金システムのデータベース設計パターンを解説。プラン変更、従量課金、割引適用など複雑な要件に対応できる拡張性の高い設計を実装例とともに紹介します。

SaaS

エンタープライズ導入を加速する、SaaSプロダクトのSSO/SAML認証実装ガイド

SaaSプロダクトをエンタープライズ企業に提案する際に必須となるSSO/SAML認証の実装方法を、実践的なコード例とともに解説します。

SaaS