会員ポイントの有効期限と失効 — 目的別モデリング × ファーストクラスコレクション × FEFO(Bad→Good 8点)

2026-10-05 月曜(Day 144) A コーディング/アルゴリズム ★★★★★ 書籍 第14章・第9章・第13章 合成1万人×1年で実測 19テスト緑・変異8種すべて赤

概要

📦

残高ではなくロットで持つ

Bad は「残高1つ+期限1つ」。獲得のたびに全体の期限が延び、年末の残高が規約より 396万pt(95%) 多い(実測)。

⏳

期限の近い順に使う(FEFO)

獲得順(FIFO)に比べて期間限定ポイントの失効が 41.5万pt(3.9%) 少ない。同じ期限なら期間限定を先に。

↩️

取消は記録どおりに戻す

「どのロットから何pt」を保存し、取消はその逆操作。Bad は新規付与で戻すので期間限定が 180 日に化け、付与取消でマイナス残高 195 人。

🧪

比較相手は日付計算まで別に書く

1 回目の oracle は日付関数を共有していて、UTC と +30日 の誤りを見逃した。変異テストで気づき、書き直して 8 種すべて赤に。

問題

MOps 販促システムチームは、購入で付く通常ポイントと、キャンペーンで配る期間限定ポイントを points.py で管理している(1pt = 1 円。どちらも無償付与)。利用規約(本問の前提・仮定):

規約中身
通常ポイント獲得日から 180 日後の日の終わりまで。獲得ごとに期限を数える
期間限定ポイント獲得月の翌月末日の終わりまで
消費の順番期限の近いものから。期限が同じなら期間限定を先に
利用の取消元のポイントへ戻す。元の期限は変えない(期限切れなら戻らない)
付与の取消(返品)付与分を取り消す。使用済みならその分を返金額から差し引く
日付日本時間

CS と経理からの声(架空)

声中身
Q1 失効しない「半年前のポイントがまだ使えた」と SNS に書かれた
Q2 早く消えた期間限定が翌月末より前に消えた。深夜に見たら昨日の残高だった
Q3 マイナス残高返品後に残高 −80pt の会員がいる
Q4 失効額が出せない経理が求める「月ごとの失効ポイント数」の記録がない

設問

  1. Bad の問題点を7つ以上、Q1〜Q4 のどれに効くかと合わせて
  2. 規約どおりに動くモデル(Google スタイル docstring・インラインコメント・名前付き定数)。「残高」を1つの数として保存しない
  3. 規約を例で固定するテストと、別書きの参照実装(oracle)とランダムなイベント列で一致を確かめるテスト
  4. Good に誤りを1か所ずつ入れる変異テストで、テストの強さを確かめる
  5. 迷った案(とくに楽天の通常ポイント型の「獲得のたびに全体の期限を延ばす」方式)と採らなかった理由、切り替える条件

制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis 可)。1 会員ぶんのモデル。DB・同時実行・有償ポイント・部分取消は扱わない。時刻はタイムゾーン付きで届き、サーバの時計は UTC。

悪いコード (Before)

既存テストは「付与で増える」「利用で減る」の2本だけで緑。それでも 8つの問題 がある。
points.py(現行)
class PointService:
    def __init__(self):
        self.members = {}  # member_id -> {"bal": int, "exp": date}

    def _today(self, now=None):
        now = now or datetime.now(timezone.utc)
        return now.astimezone(timezone.utc).date()

    def earn(self, member_id, pt, limited=False, now=None):
        m = self.members.setdefault(member_id, {"bal": 0, "exp": None})
        today = self._today(now)
        exp = today + timedelta(days=30 if limited else 180)
        m["bal"] += pt
        if m["exp"] is None or exp > m["exp"]:
            m["exp"] = exp

    def use(self, member_id, pt, now=None):
        ...
        if m["bal"] < pt:
            return False
        m["bal"] -= pt
        return True

    def cancel_use(self, member_id, pt, now=None):
        self.earn(member_id, pt, now=now)

    def cancel_earn(self, member_id, pt, now=None):
        self.members[member_id]["bal"] -= pt

    def expire(self, member_id, now=None):
        m = self.members[member_id]
        if m["exp"] and self._today(now) >= m["exp"]:
            m["bal"] = 0

    def balance(self, member_id, now=None):
        self.expire(member_id, now)
        return self.members[member_id]["bal"]
問題点サマリー(8点)
1残高1つ+期限1つ — 獲得のたびに全ポイントの期限が延びる(第14章)
2規約上切れたポイントで払える — 利用の 22.7% が規約では残高不足
3期間限定を +30 日 — 規約は翌月末日
4UTC の日付 — JST 0〜9 時が前日扱い
5>= で当日失効 — 規約は当日まで使える
6取消を新規付与で戻す — 期間限定が 180 日に化ける
7付与取消で直接減算 — マイナス残高 195 人
8台帳なし・照会が失効を兼ねる・bool 返却 — 月別失効額を出せない(第13章)

ヒント(段階的開示)

ヒント1 — 方向性
Q1〜Q4 はどれも「ポイントを1つの数で持っている」ことから来る。残高に「いつ獲得したか」「どの種類か」「どの注文から来たか」が溶けると、期限も消費順も取消も決められない。規約の文に出てくる名詞(獲得・期限・種類・利用・取消・失効)をそのままモデルにするとどうなるか。
ヒント2 — アプローチ
  • 「残高」ではなくロット(獲得1回ぶん)を持つ。期限と種類はロットが持ち、残高は未使用分の合計から毎回出す
  • 利用は期限の近い順(FEFO)に引き、どのロットから何 pt 引いたかを保存。取消はその記録どおりに戻す
  • 変更はすべて追記だけの台帳へ。失効も1行にすれば月別失効額は集計で出る
  • 日付は zoneinfo で JST に。naive な datetime は入口で拒む
  • 残高の問い合わせで失効処理を走らせない(CQS)
  • 参照実装は Good と日付計算まで別に書く
ヒント3 — 骨格
@dataclass(slots=True)
class Lot:
    lot_id: str; kind: PointKind; earned_on: date; expires_on: date
    granted: int; remaining: int; seq: int

    def usable_on(self, day: date) -> bool:
        return day <= self.expires_on and self.remaining > 0   # 期限日「当日まで」

class PointWallet:
    def use(self, usage_id, points, at):
        lots = sorted((l for l in self._lots.values() if l.usable_on(jst_date(at))),
                      key=lambda l: (l.expires_on, PRIORITY[l.kind], l.seq))
        ...  # 先頭から引き、(lot_id, 引いた pt) を self._usages[usage_id] に保存

    def cancel_use(self, usage_id, at):
        for lot_id, amount in self._usages.pop(usage_id):
            ...  # 元のロットへ戻す。期限切れなら即失効(新しい期限は与えない)

問題点分析(8点・実測)

実測の条件: 会員 10,000 人 × 2026 年 1 年分(シード 20261005)。分布は仮定 — 月の購入 0.2〜4 回、注文 1,980〜25,000 円、付与 1%、購入の 30% で「画面の残高(本番=Bad)と注文額の半分の小さい方」を利用、毎月 40% に期間限定 300pt、返品 6%。イベント 165,178 件・利用 30,549 件。Python 3.14.7、テストは 3.13.12 + pytest 9.1.1 + hypothesis 6.168.3。

#問題効く声実測・根拠
1残高1つ+期限1つ。獲得のたびに max() で全体の期限が延びるQ1年末残高 Bad 8,122,481pt/規約 4,159,359pt。+3,963,122pt(95%) 実測・導出。規約より多い会員 5,264 人
2規約上切れたポイントで払える。有償ポイントで同じ作りなら「期限の形骸化」で 6 か月ルールの適用除外を受けられない(資金決済業協会 FAQ)Q1利用 30,549 件中 6,921 件(22.7%)が規約では残高不足。超過分 3,673,118pt 実測
3期間限定を +30 日(マジックナンバー)。1/1 獲得なら Bad 1/31・規約 2/28Q2年末に規約より少ない会員 3,112 人(#3〜#5 と一斉ゼロの合計) 実測
4日付を UTC で取るQ2JST 0〜9 時のイベント 27,187 件(16.5%)がずれうる 実測
5期限日当日に失効(>=)Q2規約テストが Bad で赤
6利用取消を「新規付与」で戻す。期間限定が通常に化けて 180 日延びるQ1デモ: 3/1 の残高 Bad 400/規約 100 実測
7付与取消で残高から直接引くQ3マイナス残高の会員 195 人。Good では 483 件・42,735pt の「返金から差し引く量」になる 実測
8台帳なし。balance() が失効を兼ねる(CQS 違反)。失敗を FalseQ4Good の台帳からは月別失効額が出る(2026-02 902,602pt … 12 月 1,298,794pt) 実測
補足: Bad は「期限の近い期間限定から先に使う」テストを緑で通る。種類を持たないので順番の誤りが表に出ないだけ。順番の効き目は FEFO と FIFO の比較(迷った案 B)で測った。

モデルと時間軸(SVG)

上: 5 つの仕事を1つの数に背負わせる Bad と、規約の名詞ごとに分けた Good Bad: members[id] bal: int exp: date(1つだけ) 照会 → 失効も走る(CQS 違反) 消費 → 順番を決められない 取消 → 戻し先が無く「新規付与」 付与取消 → 直接減算でマイナス 会計 → 失効の記録が無い Good: PointWallet(ロットを外へ出さないファーストクラスコレクション) Lot(獲得1回ぶん) kind: 通常 / 期間限定 earned_on, expires_on granted, remaining seq(同期限の並び) 残高 = 使えるロットの合計 usages 利用ID → ((lot_id, pt), …) 取消 = この記録の逆操作 付与取消の不足 = ここから数える ledger(追記だけ) earn / use / cancel_use revoke / expire Σ lot ごとの delta = remaining 月別失効額 = expire の集計 下: 実行例(demo.py)の時間軸。棒 = Good のロットが使える期間、赤い印 = Bad の「期限1つ」の動き 1月2月3月4月5月6月7月8月9月10月11月12月 o1 通常 100 1/10 〜 7/9(180日後の当日まで) 7/11 返品 → 取消0・失効100 c1 期間限定 300 1/20 〜 2/28(翌月末日)。1/25 に FEFO で先に使われ、2/10 の取消で同じロットへ戻る o2 通常 10 6/1 〜 11/28 Bad の exp 7/9 8/9 11/28 2/10 の取消を「通常 180 日の新規付与」で戻す → exp 8/9 6/1 の 10pt 獲得で全体が 11/28 まで延び、当日 >= で一斉に 0 3/1: Bad 400 / 規約 100 7/10: Bad 410 / 規約 10 11/28: Bad 0 / 規約 10 Bad は 3/1・7/10 で規約より多く(Q1)、11/28 当日には規約より少ない(Q2)。同じ 1 つの exp が両方向に間違える。

上段は責務の分け方。Bad は5つの仕事を bal と exp の2つの値で済ませようとしている。下段は実行例の時間軸で、Good のロットごとの期限(棒)と Bad の期限1つ(赤い印)がどうずれるかを示す。

Bad→Good 対比(要所)

Before
{"bal": int, "exp": date}
now.astimezone(timezone.utc).date()
exp = today + timedelta(days=30 if limited else 180)
if exp > m["exp"]: m["exp"] = exp
return False
def cancel_use(...): self.earn(member_id, pt)
self.members[member_id]["bal"] -= pt
if self._today(now) >= m["exp"]: m["bal"] = 0
def balance(...): self.expire(...)
After
Lot(kind, earned_on, expires_on, granted, remaining)
at.astimezone(ZoneInfo("Asia/Tokyo")).date()  # naive は拒否
expiry_for(kind, day)  # 通常 +180日 / 期間限定 翌月末日
ロットごとの expires_on(延ばさない)
raise InsufficientPoints(requested, available)
for lot_id, amt in self._usages.pop(usage_id): 元へ戻す
RevokeResult(revoked, shortfall)  # 不足は返金から
day <= self.expires_on  # 当日まで使える
balance() は数えるだけ / expire() は台帳に記録

模範解答(改善後コード)

黄色 = 設計意図のコメント。229 行。logs/2026-10-05-A-coding-code/points_good.py

"""points_good.py(改善後・Good): 会員ポイントをロット(獲得単位)と台帳で持つ.

規約(本問の前提・仮定):
  - 通常ポイント   : 獲得日(日本時間)から 180 日後の日の終わりまで使える。ロットごとに数える
  - 期間限定ポイント: 獲得月の翌月末日の終わりまで使える
  - 利用時は期限の近いロットから使う。期限が同じなら期間限定を先に使う
  - 利用の取消は、使ったロットへそのまま戻す。戻した時点でそのロットが期限切れなら即失効
  - 付与の取消は、そのロットの未使用分だけを取り消す。使用済みの分は「不足」として呼び出し側へ返す
    (呼び出し側は返金額から差し引く。残高をマイナスにしない)

残高は「ロットの未使用分の合計」から毎回出す(保存しない)。変更はすべて台帳に追記する。
"""

from __future__ import annotations

import calendar
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from enum import StrEnum
from zoneinfo import ZoneInfo

JST = ZoneInfo("Asia/Tokyo")  # 期限は日本時間の「日」で数える(規約の文言どおり)
REGULAR_VALID_DAYS = 180  # 通常ポイント: 獲得日 + 180 日の日の終わりまで


class PointKind(StrEnum):
    """ポイントの種類。失効規則と消費の優先順位が種類で決まる."""

    LIMITED = "limited"  # 期間限定(キャンペーン付与)
    REGULAR = "regular"  # 通常(購入付与)


# 期限が同じロットどうしの消費順。小さいほど先に使う
_CONSUME_PRIORITY: dict[PointKind, int] = {PointKind.LIMITED: 0, PointKind.REGULAR: 1}


class EntryType(StrEnum):
    EARN = "earn"
    USE = "use"
    CANCEL_USE = "cancel_use"
    REVOKE = "revoke"
    EXPIRE = "expire"


class InsufficientPoints(Exception):
    """利用時点で使えるポイントが足りない(bool の False で返さず、理由と数を持たせる)."""

    def __init__(self, requested: int, available: int) -> None:
        super().__init__(f"利用 {requested} pt に対し使える残高は {available} pt")
        self.requested = requested
        self.available = available


def jst_date(at: datetime) -> date:
    """aware な日時を日本時間の日付にする。naive は拒否する(UTC か JST か区別できないため).

    Raises:
        ValueError: タイムゾーンを持たない datetime が渡された場合。
    """
    if at.tzinfo is None or at.utcoffset() is None:
        raise ValueError(f"タイムゾーン付きの datetime が必要です: {at!r}")
    return at.astimezone(JST).date()


def expiry_for(kind: PointKind, earned_on: date) -> date:
    """種類と獲得日(JST)から有効期限日(この日の終わりまで使える)を出す."""
    if kind is PointKind.REGULAR:
        return earned_on + timedelta(days=REGULAR_VALID_DAYS)
    # 期間限定: 獲得月の翌月末日
    year, month = (earned_on.year + 1, 1) if earned_on.month == 12 else (earned_on.year, earned_on.month + 1)
    return date(year, month, calendar.monthrange(year, month)[1])


@dataclass(slots=True)
class Lot:
    """獲得 1 回ぶんのポイント。期限と種類はロットが持ち、残高はロットの合計から出す."""

    lot_id: str
    kind: PointKind
    earned_on: date
    expires_on: date
    granted: int
    remaining: int
    seq: int  # 獲得順(期限・種類が同じときの決定的な並び)
    revoked: bool = False

    def usable_on(self, day: date) -> bool:
        # 期限日「当日まで」使える。翌日から使えない
        # 取消済み(revoked)かは見ない: 取消後に戻った pt は不足分を返金から差し引き済みなので使えてよい
        return day <= self.expires_on and self.remaining > 0


@dataclass(frozen=True, slots=True)
class Entry:
    """台帳の 1 行。追記だけで、書き換えない。lot ごとの delta の合計 = lot.remaining."""

    entry_type: EntryType
    lot_id: str
    delta: int
    on: date
    ref: str  # 利用 ID・注文 ID など、どの操作から来たか


@dataclass(frozen=True, slots=True)
class RevokeResult:
    revoked: int  # ロットの未使用分から取り消せた pt
    shortfall: int  # すでに使われていて取り消せなかった pt(返金額から差し引く)


@dataclass(slots=True)
class PointWallet:
    """会員 1 人のポイント。ロットの集合を外へ出さず、操作はこのクラスのメソッドだけで行う."""

    member_id: str
    _lots: dict[str, Lot] = field(default_factory=dict)
    _usages: dict[str, tuple[tuple[str, int], ...]] = field(default_factory=dict)
    _ledger: list[Entry] = field(default_factory=list)

    # ------------------------------------------------------------ コマンド
    def earn(self, lot_id: str, kind: PointKind, points: int, at: datetime) -> Lot:
        """ポイントを付与する。lot_id は付与元(注文 ID・キャンペーン ID)から決める.

        Raises:
            ValueError: points が 1 未満、または同じ lot_id がすでにある場合(二重付与の防止)。
        """
        if type(points) is not int or points <= 0:
            raise ValueError(f"付与は 1 pt 以上の int: {points!r}")
        if lot_id in self._lots:
            raise ValueError(f"二重付与: {lot_id}")
        day = jst_date(at)
        lot = Lot(lot_id, kind, day, expiry_for(kind, day), points, points, seq=len(self._lots))
        self._lots[lot_id] = lot
        self._ledger.append(Entry(EntryType.EARN, lot_id, points, day, lot_id))
        return lot

    def use(self, usage_id: str, points: int, at: datetime) -> tuple[tuple[str, int], ...]:
        """ポイントを使う。期限の近いロットから順に引く(FEFO: First Expired, First Out).

        Returns:
            (lot_id, 引いた pt) の組。取消のために保存される。

        Raises:
            InsufficientPoints: 使える残高が足りない場合(何も引かない)。
            ValueError: points が 1 未満、または usage_id が使用済みの場合。
        """
        if type(points) is not int or points <= 0:
            raise ValueError(f"利用は 1 pt 以上の int: {points!r}")
        if usage_id in self._usages:
            raise ValueError(f"同じ利用 ID: {usage_id}")
        day = jst_date(at)
        candidates = self._usable_lots(day)
        available = sum(lot.remaining for lot in candidates)
        if available < points:
            raise InsufficientPoints(points, available)

        taken: list[tuple[str, int]] = []
        rest = points
        for lot in candidates:  # 期限の近い順に並んでいる
            take = min(lot.remaining, rest)
            lot.remaining -= take
            taken.append((lot.lot_id, take))
            self._ledger.append(Entry(EntryType.USE, lot.lot_id, -take, day, usage_id))
            rest -= take
            if rest == 0:
                break
        self._usages[usage_id] = tuple(taken)
        return self._usages[usage_id]

    def cancel_use(self, usage_id: str, at: datetime) -> int:
        """利用を取り消し、使ったロットへ戻す。期限切れのロットへ戻した分は即失効させる.

        Returns:
            戻した結果、今も使える pt。
        """
        day = jst_date(at)
        restored_usable = 0
        for lot_id, amount in self._usages.pop(usage_id):
            lot = self._lots[lot_id]
            lot.remaining += amount
            self._ledger.append(Entry(EntryType.CANCEL_USE, lot_id, amount, day, usage_id))
            if day > lot.expires_on:
                self._expire_lot(lot, day)  # 新しい期限は与えない(期限を延ばす抜け道を作らない)
            else:
                restored_usable += amount
        return restored_usable

    def revoke_earn(self, lot_id: str, at: datetime) -> RevokeResult:
        """付与を取り消す(返品・注文キャンセル)。残高はマイナスにしない."""
        day = jst_date(at)
        lot = self._lots[lot_id]
        if lot.revoked:
            raise ValueError(f"取消済み: {lot_id}")
        if day > lot.expires_on:
            self._expire_lot(lot, day)  # 期限切れの分は「取消」でなく「失効」として台帳に残す(失効額の集計を狂わせない)
        revoked = lot.remaining
        lot.remaining = 0
        lot.revoked = True
        if revoked:
            self._ledger.append(Entry(EntryType.REVOKE, lot_id, -revoked, day, lot_id))
        # 不足 = このロットから引かれて、まだ取り消されていない利用の合計
        in_use = sum(amount for taken in self._usages.values() for lid, amount in taken if lid == lot_id)
        return RevokeResult(revoked=revoked, shortfall=in_use)

    def expire(self, at: datetime) -> int:
        """期限を過ぎたロットの未使用分を失効させ、台帳に記録する。何度呼んでも同じ結果."""
        day = jst_date(at)
        return sum(self._expire_lot(lot, day) for lot in self._lots.values() if day > lot.expires_on)

    # ------------------------------------------------------------ クエリ(副作用なし)
    def balance(self, at: datetime) -> int:
        """その時点で使える残高。失効処理を走らせずに、期限で数えるだけ."""
        return sum(lot.remaining for lot in self._usable_lots(jst_date(at)))

    def ledger(self) -> tuple[Entry, ...]:
        return tuple(self._ledger)

    # ------------------------------------------------------------ 内部
    def _usable_lots(self, day: date) -> list[Lot]:
        lots = [lot for lot in self._lots.values() if lot.usable_on(day)]
        # 1 会員のロット数はsim の実測で最大 68 個。ヒープより毎回ソートの方が単純
        lots.sort(key=lambda lot: (lot.expires_on, _CONSUME_PRIORITY[lot.kind], lot.seq))
        return lots

    def _expire_lot(self, lot: Lot, day: date) -> int:
        amount = lot.remaining
        if amount:
            lot.remaining = 0
            self._ledger.append(Entry(EntryType.EXPIRE, lot.lot_id, -amount, day, lot.lot_id))
        return amount

Good と書き方を全部変えた参照実装。日付は UTC+9 時間、期限は「翌々月 1 日の前日」、消費順は毎回 min()、状態は問い合わせのたびに全イベントを再生。

"""oracle.py: 規約をいちばん素直に書いた参照実装(テストの比較相手専用。遅くてよい).

Good とは別の書き方にする: 日付・期限の計算も自前で持ち、クラスも台帳も持たず、問い合わせのたびに全イベントを最初から再生する。
消費順は「毎回 min() でいちばん先に期限が来るロットを選ぶ」で書き、Good の「並べてから順に引く」と分ける。
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone

from points_good import PointKind  # 種類の列挙だけ借りる。日付と期限の計算は借りない


def jst_date(at: datetime) -> date:
    # 日本は夏時間がないので「UTC + 9 時間」で足りる(Good は zoneinfo を使う。書き方を分ける)
    return (at.astimezone(timezone.utc) + timedelta(hours=9)).date()


def expiry_for(kind: PointKind, earned_on: date) -> date:
    if kind is PointKind.REGULAR:
        return earned_on + timedelta(days=180)
    # 翌々月 1 日の前日 = 翌月末日
    y, m = earned_on.year, earned_on.month + 2
    if m > 12:
        y, m = y + 1, m - 12
    return date(y, m, 1) - timedelta(days=1)


@dataclass(frozen=True)
class Ev:
    op: str  # earn / use / cancel / revoke
    ref: str  # earn・revoke は lot_id、use・cancel は usage_id
    at: datetime
    kind: PointKind | None = None
    points: int = 0


@dataclass
class Outcome:
    lots: dict[str, list] = field(default_factory=dict)  # lot_id -> [kind, expires_on, remaining, seq]
    accepted: dict[str, bool] = field(default_factory=dict)  # usage_id -> 受理したか
    shortfalls: dict[str, int] = field(default_factory=dict)  # lot_id -> 付与取消時の不足 pt


def run(events: list[Ev]) -> Outcome:
    out = Outcome()
    lots, usages = out.lots, {}
    for e in events:
        day = jst_date(e.at)
        if e.op == "earn":
            lots[e.ref] = [e.kind, expiry_for(e.kind, day), e.points, len(lots)]
        elif e.op == "use":
            alive = [lid for lid, v in lots.items() if v[1] >= day and v[2] > 0]
            if sum(lots[lid][2] for lid in alive) < e.points:
                out.accepted[e.ref] = False
                continue
            out.accepted[e.ref] = True
            rest, taken = e.points, []
            while rest:
                lid = min(
                    (lid for lid in alive if lots[lid][2] > 0),
                    key=lambda x: (lots[x][1], lots[x][0] is not PointKind.LIMITED, lots[x][3]),
                )
                t = min(rest, lots[lid][2])
                lots[lid][2] -= t
                rest -= t
                taken.append((lid, t))
            usages[e.ref] = taken
        elif e.op == "cancel":
            for lid, t in usages.pop(e.ref, []):
                if lots[lid][1] >= day:  # 期限切れのロットへ戻した分は消える
                    lots[lid][2] += t
        elif e.op == "revoke":
            out.shortfalls[e.ref] = sum(t for taken in usages.values() for lid, t in taken if lid == e.ref)
            lots[e.ref][2] = 0
    return out


def balance(events: list[Ev], at: datetime) -> int:
    """at 時点で使える残高."""
    day: date = jst_date(at)
    lots = run([e for e in events if e.at <= at]).lots
    return sum(v[2] for v in lots.values() if v[1] >= day)
パターンどこ書籍
目的別モデリングLot / Entry / PointWallet第14章
ファーストクラスコレクションPointWallet._lots第9章
コマンド・クエリ分離balance と expire を分ける第13章
bool/null を返さず、理由と数を持つ例外InsufficientPoints第13章・第10章
不変・追記だけの記録Entry(frozen)と台帳第3章・第4章
名前付き定数・列挙型REGULAR_VALID_DAYS・PointKind・_CONSUME_PRIORITY第10章
FEFO(期限の近い順の割り当て)_usable_lots—(物流の用語)

テスト・変異テスト・実行例

oracle との差分テスト(hypothesis、各 500 例)

@settings(max_examples=500, deadline=None)
@given(event_seqs())   # 付与・利用・取消・付与取消を最大 40 個、間隔は最大 40 日(期限をまたがせる)
def test_oracleと受理判定_不足_残高がすべて一致する(evs):
    w = PointWallet("m")
    accepted, shortfalls = apply(w, evs)
    out = oracle.run(evs)
    assert accepted == out.accepted          # どの利用を受け付けたか
    assert shortfalls == out.shortfalls      # 付与取消で返金から差し引く量
    for probe in (evs[-1].at, evs[-1].at + timedelta(days=30), evs[-1].at + timedelta(days=200)):
        assert w.balance(probe) == oracle.balance(evs, probe)


@settings(max_examples=500, deadline=None)
@given(event_seqs())
def test_台帳の合計はロットの残りに一致し残高は負にならない(evs):
    w = PointWallet("m")
    apply(w, evs)
    w.expire(evs[-1].at + timedelta(days=400))
    per_lot: dict[str, int] = {}
    for e in w.ledger():
        per_lot[e.lot_id] = per_lot.get(e.lot_id, 0) + e.delta
        assert per_lot[e.lot_id] >= 0                    # 途中で一度も負にならない
    assert all(v == 0 for v in per_lot.values())        # 400 日後には全ロットが閉じる

規約の例テスト(抜粋)

def test_後から獲得しても古いロットの期限は延びない(w):
    w.earn("o1", R, 100, jst(2026, 1, 10))
    w.earn("o2", R, 10, jst(2026, 6, 1))
    assert w.balance(jst(2026, 7, 10)) == 10  # o1 の 100 は 7/9 で終わり

def test_付与取消のあとで利用を取り消すと差し引き済みの分は使える():
    # 書いている途中で見つけた穴: 取消済みロットへ戻した分を消すと、顧客は 80 円を二重に失う
    w = PointWallet("m")
    w.earn("o1", R, 100, jst(2026, 1, 10))
    w.use("u1", 80, jst(2026, 1, 11))
    w.revoke_earn("o1", jst(2026, 1, 12))  # 80 は返金から差し引き済み
    assert w.cancel_use("u1", jst(2026, 1, 13)) == 80
    assert w.balance(jst(2026, 1, 13)) == 80
対象結果(実測)
Good19 passed(3.1〜5.2 秒)
Bad(共通の規約テスト 7 本)6 failed, 1 passed。通ったのは「期間限定から先に使う」(種類を持たないので順番の誤りが見えない)

変異テスト(Good に誤りを1か所ずつ入れる)

変異1 回目(16 本)直した後(19 本)
M1 期限日当日を使えなくする5 赤5 赤
M2 消費順を獲得順(FIFO)2 赤3 赤
M3 同期限で期間限定を後回し0 赤(生き残り)1 赤
M4 期限切れロットへ戻した分も使えるまま0 赤(生き残り)1 赤
M5 日付を UTC で取る2 赤(例テストだけ。oracle 差分は緑)3 赤
M6 付与取消の不足を 02 赤3 赤
M7 期間限定を +30 日2 赤(同上、oracle 差分は緑)3 赤
M8 付与取消の前に失効させない(未作成)1 赤
1 回目の oracle は points_good の jst_date・expiry_for を import していた。共有した関数に入れた誤り(M5・M7)は両方に同時に入り、差分テストでは差が出ない。M3 は「通常と期間限定が同じ日に切れる」組み合わせでしか出ず、差も残高ではなくキャンペーン取消時の不足 pt にしか出ない。M4 は残高を変えず、cancel_use() の戻り値と台帳の失効日だけを変える。どちらも例テストで固定した。

実行例(demo.py、実測)

1/10 購入で通常 100pt                   Bad -> None   Good -> datetime.date(2026, 7, 9)
1/20 キャンペーンで期間限定 300pt             Bad -> None   Good -> datetime.date(2026, 2, 28)
1/25 300pt 利用                      Bad -> True   Good -> (('c1', 300),)
2/10 1/25 の注文をキャンセル                Bad -> None   Good -> 300
残高照会 3/01(期間限定は 2/28 まで)           Bad -> 400    Good -> 100
6/01 購入で通常 10pt                    Bad -> None   Good -> datetime.date(2026, 11, 28)
残高照会 7/10(1/10 の 100pt は 7/9 まで)   Bad -> 410    Good -> 10
7/11 1/10 の注文を返品                   Bad -> None   Good -> RevokeResult(revoked=0, shortfall=0)
残高照会 7/11 返品後                      Bad -> 310    Good -> 10
残高照会 11/28(6/01 の 10pt の期限日)       Bad -> 0      Good -> 10
Good 台帳:
  2026-01-10 earn       o1  +100  ref=o1
  2026-01-20 earn       c1  +300  ref=c1
  2026-01-25 use        c1  -300  ref=u1
  2026-02-10 cancel_use c1  +300  ref=u1
  2026-06-01 earn       o2  +10  ref=o2
  2026-07-11 expire     o1  -100  ref=o1

迷った案と採らなかった理由

案判断と理由(数字は合成データでの実測)切り替える条件
A ローリング期限(楽天の通常ポイント型: 最後に獲得した月から1年で全体を延ばす。Bad の max() とほぼ同じ形)規約側の選択肢として残す。残高1つ+期限1つで足り、会員にも分かりやすい。Bad の本当の誤りは方式ではなく規約とコードの食い違い(年末残高で 3,963,122pt の差)法務・マーケが規約をローリングに変えたら、通常ポイントは会員ごとに1ロットに畳む(期間限定はロットのまま)
B 獲得順(FIFO)に使う不採用。期間限定の失効が FEFO 10,750,194pt → FIFO 11,165,689pt(+415,495pt、+3.9%)なし(規約が期限の近い順である限り)
C 残高をカラムに保存不採用。1会員のロット最大 68 個、use() 平均 8.6µs。保存すると「ロット合計と保存値のずれ」という誤りの入口が増える何万人分の残高を一度に出す画面ができたら、ロット更新と同じトランザクションで書く投影テーブル
D ヒープで期限順を保つ不採用。68 個ならソートで十分。取消のたびにヒープへ入れ直す処理の誤りの余地が大きい1会員のロットが数千を超える設計(日次付与など)になったら
E 付与取消でマイナス残高不採用。Q3 そのもの(195 人)返金を伴わない取消が多い事業なら、明示的な「回収予定」ロットとして持つ
F 取消で戻した分に新しい期限不採用(CS 次第で再検討)。親切だが「注文→キャンセル」の繰り返しで期限を延ばせる抜け道になるキャンセル起因の問い合わせが CS と決めた件数を超えたら、回数上限つきで導入
G 台帳だけ(イベントソーシング)不採用(oracle がこの形)。正しいが毎回全イベントを読む監査で「任意時点の残高を再現せよ」と言われたら、読み取り専用の再生経路を追加

直した後に見るもの・止め方

見るもの出し方止める・戻す判断
月別の失効 pt(経理へ)台帳の expire を月で集計前月比 ±30% 超でキャンペーン設計か失効バッチを疑う
移行時の残高差移行前夜に全会員で Bad − Good合成データでは会員の 53% が規約より多い。差をそのまま消すと炎上するので、既存残高を1ロットにして現行の期限を引き継ぐ経過措置を規約改定とセットで
付与取消の不足 ptRevokeResult.shortfall の合計(483 件・42,735pt)返金処理が差し引きに対応するまで、不足 > 0 は手動確認キューへ
JST 境界の問い合わせ「深夜に残高が違う」件数0 にならなければ naive datetime を作る呼び出し側を探す

ポイント解説

1規約の名詞をモデルにする — 「獲得ごとに 180 日」「期限の近い順」「元のポイントへ戻す」はどれもロットが主語。残高という1つの数ではこれらの文をコードに写せない。第14章の「目的ごとに分ける」は、ここでは「規約の文の主語ごとに分ける」と読める。
2取消は記録どおりに戻す — 利用時に「どのロットから何pt」を保存すれば、取消は逆操作になる。保存しないと戻し先を推測で決めることになり、期限の延長や種類の化けが起きる。
3参照実装は共有しない — 差分テストの強さは2つの実装の独立性で決まる。日付計算を共有していた1回目は UTC の誤りも +30 日の誤りも見逃した。変異テストを回さなければ気づかなかった。

実務への応用

  • 販促キャンペーンの効果測定: 期間限定ポイントの実コストは「配った pt」ではなく「使われた pt」。台帳を BigQuery に流せば、キャンペーン(lot_id)ごとの利用率・失効率がそのまま出て、D カテゴリの ROI 試算に使える
  • 会計との接続: 未使用残高は会計上の負債として見積もる対象になる。規約より 95% 多い残高を持つ Bad では根拠を出せない。会計処理は経理・監査法人の判断で、エンジニアの仕事は規約どおりの残高と月別の付与・利用・失効を台帳から出せる状態を作るところまで
  • 同じ形の問題: クーポンの有効期限、サブスクの日割り、在庫の賞味期限(FEFO はもともと物流の言葉)、ギフトカード

扱わなかった範囲(と理由)

範囲理由扱うなら
DB と同時実行1会員のモデルの正しさに絞った。9/27・10/03 で扱った会員行を SELECT … FOR UPDATE、台帳と同じトランザクション
有償ポイント資金決済法の前払式支払手段になり、区分管理・未使用残高の届出・6 か月内の期限設計が要るPointKind.PAID を足し、消費順と区分別残高を法務と決める
会計処理経理・監査法人の判断台帳を月次で BigQuery へ
部分取消9/28 の按分と組み合わせると問題が倍になるどのロットへ戻すか(期限の遠い順が顧客に有利)を規約で決める
Bad からの移行既存残高にロットの情報が無い発展問題
実 DB・実ポイント事業者使っていない。メモリ内のモデルと合成データのみ—

一次資料(参照日 2026-10-05)

  • 楽天市場ヘルプ「ポイントの有効期限について」 ichiba.faq.rakuten.net/detail/000006609 — 通常ポイントは最後に獲得した月を含めた1年間、期間限定は個別、期限の近い期間限定から使う(検索結果の要約で確認)
  • 日本資金決済業協会「前払式支払手段についてよくあるご質問」 s-kessai.jp — 無償ポイントは前払式支払手段に当たらない。期限が形骸化していれば適用除外にならない(検索結果の要約で確認)
  • 資金決済に関する法律 第4条 衆議院 法律情報
  • Python 公式ドキュメント zoneinfo・datetime(aware と naive)・calendar.monthrange

今日のまとめ

期限のある価値は「1つの残高」ではなく「獲得ごとのロット」で持ち、期限の近い順に使い、取消は保存した記録どおりに戻す。テストの比較相手は日付計算まで別に書かないと、共有した部分の誤りを見逃す。

次のステップ

  • 発展問題: Bad の本番データ(会員ごとの bal と exp だけ)から Good へ移行せよ。(a) 既存残高を1ロットにして現行の期限を引き継ぐ (b) 過去の取引履歴から規約どおりに再計算する、の2案を、(b) で残高が減る会員数(合成データなら 5,264 人)と CS の問い合わせ見込みで比べ、規約改定の文案・移行スクリプト・移行前後の残高差を検算するテストを書け
  • 参考: FEFO、ファーストクラスコレクション、イベントソーシング、property-based testing、differential testing、mutation testing(mutmut)、前払式支払手段の 6 か月ルール

自己評価(あとで記入)

自分の回答

気づき・メモ