JavaScript で月額課金の更新日を計算する — 月末日とタイムゾーンの罠

2026.02.10
Share:

はじめに — 「次回更新日いつだっけ?」を自動化したい

サブスクリプションサービスを管理するアプリを作っていると、「次回更新日」の計算は避けて通れません。ユーザーが毎月手動で更新日を入力するのは面倒ですし、忘れがちです。

CancelNavi(サブスクリプション管理ツール)の開発中に、この自動計算を実装しました。「基準日と課金サイクル(月払い/年払い)を設定すれば、次回更新日が自動で表示される」機能です。

一見シンプルに聞こえるこの機能が、JavaScript の Date オブジェクトの挙動に起因する厄介な問題を含んでいました。

背景 — CancelNavi の更新日管理

2025年12月26日の開発日記にはこう書いています:

更新日の自動計算機能を追加

  • 月払い・年払いのサブスクで、次回更新日を自動算出
  • 請求サイクル(monthly/yearly)と基準日を設定できるように

毎月「次の更新日いつだっけ?」と手動で更新するのは面倒 一度設定すれば自動で次回更新日が計算される方がユーザーに優しい

やりたいことは明確でした。「1月15日に契約して月払い → 次回更新日は2月15日」を計算するだけです。ところが、すぐにエッジケースに遭遇しました。

問題1 — 月末日の自動調整

開発日記にはこう記録しています:

月末日の扱い

  • 例: 1月31日契約の月払い → 2月は28日(or29日)になる問題
  • JavaScriptのDateオブジェクトは自動で調整してくれるが、意図した動作か確認が必要だった

JavaScript の Date オブジェクトで「月を1足す」と何が起きるか、具体例で見てみましょう。

// 1月31日の翌月は?
const date = new Date(2026, 0, 31); // 2026-01-31
date.setMonth(date.getMonth() + 1);
console.log(date); // 2026-03-03 ← 2月31日は存在しない → 3月3日に繰り越し

2月は28日(うるう年は29日)までしかないため、「2月31日」は存在しません。JavaScript はこれをエラーにせず、3月3日に自動調整します。

これが「意図した動作」かどうかは、ビジネスの文脈次第です。多くのサブスクリプションサービスでは、1月31日契約の月払いの次回更新日は「2月28日」(その月の最終日)であるべきです。3月3日は明らかに誤りです。

この問題に気づいたとき、Claude Code に対処方針を相談しました。

ZeronovaZeronova
月末日の扱いどうする? 1月31日契約で月払いだと、JavaScript の setMonth では3月3日になってしまう。date-fns の addMonths なら正しく2月28日になるけど、CancelNavi の依存を増やしたくない。
Claude Code
date-fns を入れなくても同じロジックは自作できます。setMonth で月を進めた後、元の日と結果の日を比較して、日が変わっていたら繰り越しが発生しているので setDate(0) で前月の最終日に戻す方法が使えます。
ZeronovaZeronova
setDate(0) で前月の最終日が取れるのか。それはシンプルだ。ただ、2ヶ月後の場合はどうなる? 1月31日 + 2ヶ月は3月31日に戻るべきだけど。
Claude Code
正しくは3月31日になります。setMonth(0 + 2) で3月になり、31日は3月に存在するので繰り越しは発生しません。元の日と一致するかのチェックだけで、月末日の調整と通常ケースの両方を正しく処理できます。

この対話のおかげで、date-fns と同じロジックの自作関数をシンプルに実装できました。テストケースも網羅的に作成し、うるう年と月末日の組み合わせも検証済みです。

安全な月の加算

月末日を正しく扱うために、加算後の日が元の日を超えていないかチェックする方法を採用しました。

function addMonths(date: Date, months: number): Date {
  const result = new Date(date);
  const targetMonth = result.getMonth() + months;
  const originalDay = result.getDate();

  result.setMonth(targetMonth);

  // 月末日の繰り越しが発生した場合、月末に調整
  if (result.getDate() !== originalDay) {
    // 前月の最終日に設定
    result.setDate(0);
  }

  return result;
}
// テスト
addMonths(new Date(2026, 0, 31), 1); // → 2026-02-28 ✅
addMonths(new Date(2026, 0, 31), 2); // → 2026-03-31 ✅
addMonths(new Date(2026, 0, 30), 1); // → 2026-02-28 ✅(2月30日は存在しない)
addMonths(new Date(2026, 0, 15), 1); // → 2026-02-15 ✅(通常ケース)

setDate(0) は「前月の最終日」を意味します。setMonth で月を進めた後に日が変わっていたら、繰り越しが発生したと判断して月末に調整します。

date-fns を使う場合

date-fns の addMonths は最初からこの挙動に対応しています。

import { addMonths } from "date-fns";

addMonths(new Date(2026, 0, 31), 1); // → 2026-02-28 ✅

date-fns を使っているプロジェクトなら、自前で実装する必要はありません。CancelNavi では依存を増やしたくなかったため自前で実装しましたが、規模が大きいプロジェクトでは date-fns の利用をおすすめします。

問題2 — タイムゾーンの罠

開発日記にはもう一つの問題が記録されています:

タイムゾーン問題

  • toISOString()がUTCを返すので、日本時間で日付がズレる可能性
  • ローカルタイムゾーンを考慮した日付フォーマットに変更

これは Vercel にデプロイすると顕在化する問題です。Vercel のサーバーは UTC で動作するため、日本時間の深夜(0:00〜8:59 JST)に計算すると、UTC では前日扱いになります。

// JST 2026-02-01 03:00 = UTC 2026-01-31 18:00
const now = new Date();
console.log(now.toISOString()); // "2026-01-31T18:00:00.000Z"
// ↑ UTC 基準なので「1月31日」になる

ユーザーは「2月1日」の更新日を期待しているのに、システムが「1月31日」と表示したら混乱します。

解決策 — ローカルタイムゾーンでの日付フォーマット

日付の表示にはローカルタイムゾーンを使い、保存には ISO 文字列(UTC)を使う方針にしました。

// 表示用: ローカルタイムゾーンで日付をフォーマット
function formatLocalDate(date: Date): string {
  return date.toLocaleDateString("ja-JP", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    timeZone: "Asia/Tokyo",
  });
}

// 比較用: 日付部分のみを JST で取得
function getJSTDateString(date: Date): string {
  return date.toLocaleDateString("en-CA", {
    timeZone: "Asia/Tokyo",
  }); // "2026-02-01" 形式
}

toLocaleDateStringtimeZone: "Asia/Tokyo" を指定することで、UTC の Date オブジェクトからでも JST の日付を正しく取得できます。

次回更新日の計算 — 全体の実装

月末日問題とタイムゾーン問題を踏まえた、次回更新日計算の全体像です。

interface Subscription {
  startDate: string;      // ISO 8601 形式
  billingCycle: "monthly" | "yearly";
}

function getNextRenewalDate(sub: Subscription): Date {
  const start = new Date(sub.startDate);
  const now = new Date();
  let next = new Date(start);

  // 現在日時を超えるまで更新日を進める
  while (next <= now) {
    if (sub.billingCycle === "monthly") {
      next = addMonths(next, 1);
    } else {
      next = addMonths(next, 12);
    }
  }

  return next;
}

シンプルな while ループで、基準日から次回更新日が「今日」を超えるまで加算し続けます。月末日の調整は addMonths に任せます。

エッジケースの一覧

日付計算で遭遇しやすいエッジケースをまとめました。

ケース入力期待する出力
通常1/15 + 1ヶ月2/15
月末日(2月)1/31 + 1ヶ月2/28
月末日(うるう年)1/31 + 1ヶ月(うるう年)2/29
月末日(戻り)1/31 + 2ヶ月3/31(元に戻る)
年払い2/29 + 12ヶ月(翌年は非うるう年)2/28
年またぎ12/15 + 1ヶ月翌年 1/15

日付計算はエッジケースが多い(うるう年、月末日など)

開発日記のこの記述どおり、テストケースは多めに用意することをおすすめします。特に月末日とうるう年の組み合わせは見落としがちです。

テストの書き方

日付計算のテストでは、「固定の日付」を使うことが重要です。new Date() に依存すると、テストの実行タイミングで結果が変わります。

describe("getNextRenewalDate", () => {
  it("月払い: 通常の日付", () => {
    const sub = {
      startDate: "2026-01-15T00:00:00+09:00",
      billingCycle: "monthly" as const,
    };
    // テスト実行日を固定(2026-02-10 想定)
    vi.setSystemTime(new Date("2026-02-10T12:00:00+09:00"));
    const result = getNextRenewalDate(sub);
    expect(result.getDate()).toBe(15);
    expect(result.getMonth()).toBe(1); // 2月
  });

  it("月払い: 月末日の調整", () => {
    const sub = {
      startDate: "2026-01-31T00:00:00+09:00",
      billingCycle: "monthly" as const,
    };
    vi.setSystemTime(new Date("2026-02-10T12:00:00+09:00"));
    const result = getNextRenewalDate(sub);
    expect(result.getDate()).toBe(28); // 2月は28日まで
  });
});

タイムゾーンは早めに対処しておかないと後で大変

開発日記のこの教訓は本当にそのとおりでした。テストの段階でタイムゾーンを考慮しておかないと、本番環境(UTC)で初めて問題が発覚することになります。

リマインダー機能との連携

CancelNavi では、次回更新日の自動計算をリマインダー機能と組み合わせています。更新日の数日前にリマインダーを表示することで、「解約するか継続するか」を更新日前に判断できます。

次回更新日が正確に計算されていないと、リマインダーのタイミングもズレます。日付計算の正確さが、ユーザー体験に直結する部分です。

関連記事

日付・タイムゾーンに関連する Journal 記事もどうぞ:

まとめ

サブスクリプションの次回更新日計算で遭遇した、月末日問題とタイムゾーン問題を解説しました。

持ち帰りポイント:

  1. JavaScript の setMonth は月末日を自動繰り越しする — 1月31日 + 1ヶ月 = 3月3日になる。意図と異なるなら月末調整が必要
  2. setDate(0) で「前月の最終日」が取得できる — 月末日調整のシンプルなテクニック
  3. toISOString() は UTC を返す — JST で日付がズレる。toLocaleDateStringtimeZone を指定する
  4. 日付計算のテストは固定日時で行うnew Date() に依存しない。うるう年と月末日を必ずテスト

CancelNavi ではこの更新日計算を Claude Code と一緒に実装しました。「月末日の扱いどうする?」と相談したところ、date-fns の addMonths と同じロジックを提案してもらい、テストケースも網羅的に作成できました。日付計算は「動くコード」を書くのは簡単ですが、「正しいコード」を書くにはエッジケースとの戦いになります。テストを厚くしておくことで、将来の自分を助けることになるはずです。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

CancelNavi(キャンセルナビ)

サブスク解約の最短ルート