本文へスキップ
すべての記事

Subly の裏側:サブスクリプション更新日を支えるカレンダー計算

サブスクの次回請求日を予測するのは、月末請求・うるう年・トライアル移行にぶつかるまでは単純に見える。Subly が端末上でどうそれを正しく処理しているかを解説する。

MFKAPPS 1 分で読めます

「これはいつ更新されるのか」は計算ではなく、単なる参照のように聞こえる。だが実際はそうではない。あるサブスクが毎月請求され、1月31日に始まった場合、あるいは毎年請求され、2月29日に始まった場合、答えは途端に自明ではなくなる — そして Subly は毎回それを正しく導き出す必要がある。誤った更新リマインダーは、リマインダーが全くないより悪いからだ。

Subly の「次回請求」日の裏で静かに動いているカレンダー計算と、それを見た目以上に難しくしたエッジケースを紹介する。

素朴な実装は2ヶ月目で破綻する

最初に思いつくのは、開始日を保存してそこに単純に間隔を足すことだ。

fun naiveNextDate(start: LocalDate, cycle: BillingCycle): LocalDate =
    when (cycle) {
        BillingCycle.MONTHLY -> start.plusMonths(1)
        BillingCycle.YEARLY -> start.plusYears(1)
        BillingCycle.WEEKLY -> start.plusWeeks(1)
    }

これは10日に始まったサブスクには機能する。しかし1月31日に始まったものでは静かに破綻する。Kotlin の日付ライブラリの LocalDate.plusMonths は、2月31日をすでに28日(またはうるう年なら29日)に丸め込んでいる — これはその遷移については正しいが、バグは翌月に現れる。plusMonths(1)元の請求日ではなく直前に計算された日付から連鎖させ続けると、31日に始まったサブスクは恒久的に28日側にずれてしまい、31日がある月でも二度と31日に戻らない。

実際の請求システムはずれない。Netflix が2月に28日で請求した後、31日ある月に31日で請求するのは、正常で予想される挙動であり — 縮んだ日付を覚えておいて「修正」すべきバグではない。

直前の請求ではなく、請求日そのものに固定する

Subly はアンカー日(サブスクが本来請求されるべき月の日)を、計算済み日付の実行中の履歴とは別に保存する。

@Entity(tableName = "subscriptions")
data class Subscription(
    @PrimaryKey val id: String,
    val cycle: BillingCycle,
    val anchorDay: Int,       // 1..31、"本来の"請求日
    val anchorMonth: Int?,    // YEARLY サイクルでのみ使用
    val startDate: LocalDate,
)

fun nextChargeDate(sub: Subscription, from: LocalDate): LocalDate {
    var candidate = from.withDayOfMonthClamped(sub.anchorDay)
    while (!candidate.isAfter(from)) {
        candidate = candidate.plusMonths(1).withDayOfMonthClamped(sub.anchorDay)
    }
    return candidate
}

private fun LocalDate.withDayOfMonthClamped(day: Int): LocalDate {
    val lastDayOfThisMonth = lengthOfMonth()
    return withDayOfMonth(minOf(day, lastDayOfThisMonth))
}

すべての計算はアンカー日から始まり、その月についてのみ丸め込まれる。2月は28日(またはうるう年なら29日)を得て、3月はそのまま31日に戻る。前月の縮小を覚えているものは何もない。テスト中に出た「なぜ3月の更新が28日になっているのか」というケースをすべて解決したのは、この一つの変更だった。

年次サイクルにもアンカー月が必要

年次サブスクには独自の落とし穴がある:2月29日だ。うるう日にアンカーされたサブスクは、その日付が存在しない4年のうち3年について明示的なルールを必要とする。Subly はうるう年以外の年では静かに3月1日へ飛ぶのではなく、2月28日に丸め込む — これは実際の請求プロバイダーの多くがやっていることを反映しており、たまに丸ごと1日先に飛ぶ日付よりも「2月末に更新される」というユーザーの期待に合致する。

年次サイクルについて anchorMonthanchorDay と一緒に保存すること — それ自体がうるう日かもしれない単一の startDate から月を再導出するのではなく — これによって、コード中に散らばる特殊ケースの一つになるのではなく、単純な参照のままで済む。

トライアルは新しいサブスクではなくサイクルの変化

無料トライアルが有料に変わるのは、独自のアンカーを持つ新しいサブスクではない — 最初の有料請求がアンカー日を確定させる、同じサブスクだ。これを2つの別々の行としてモデル化するのが、テスト中に破綻した最初の設計だった。更新リマインダーが二重になり、「トライアルが始まった」と「カードが請求された」の関連が失われた。代わりに Subly は1つの行と trialEndsAt フィールドを保持する。リマインダーのロジックはまずこのフィールドを確認し、トライアルが実際に変換された後にのみ、繰り返しの nextChargeDate 計算に戻る。

リマインダーはタイマーではなく計算済み日付に基づいてスケジュールされる

nextChargeDate は保存データの純粋な関数であるため、Subly はカウントダウンするバックグラウンドジョブを一切必要としない。WorkManager タスクが1日に一度、すべてのサブスクについて今後数回分の更新日を再導出し、すでにスケジュールされているものと差分を取り、変わった部分だけ通知キューに触れる。サブスクの請求日を編集すれば、次の日次パスがすぐに修正済みのリマインダーを生成する — 無効化すべきずれたタイマーは存在しない。そもそも何もカウントダウンされていなかったからだ。

これを丁寧にやる価値がある理由

これらのどれもサーバーを必要としない。LocalDate に対する純粋な日付演算であり、サブスク1件あたりミリ秒の何分の一かで実行される — 少しだけ間違えても気づかれにくい類のものだが、年払いで先払いしている誰かに対して更新リマインダーが1日遅れて発火するまでは。エッジケースを一度、たった一つの関数の中で正しく処理しておく方が、「毎年違う日に更新される」サブスクについてのユーザー報告をデバッグするより安上がりだ。

受け取る側でリマインダーを見てみたいなら、Subly は App Store と Google Play で公開中 だ。