概要
残高ではなくロットで持つ
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 失効額が出せない | 経理が求める「月ごとの失効ポイント数」の記録がない |
設問
- Bad の問題点を7つ以上、Q1〜Q4 のどれに効くかと合わせて
- 規約どおりに動くモデル(Google スタイル docstring・インラインコメント・名前付き定数)。「残高」を1つの数として保存しない
- 規約を例で固定するテストと、別書きの参照実装(oracle)とランダムなイベント列で一致を確かめるテスト
- Good に誤りを1か所ずつ入れる変異テストで、テストの強さを確かめる
- 迷った案(とくに楽天の通常ポイント型の「獲得のたびに全体の期限を延ばす」方式)と採らなかった理由、切り替える条件
制約: Python 3.12+、標準ライブラリのみ(テストは pytest・hypothesis 可)。1 会員ぶんのモデル。DB・同時実行・有償ポイント・部分取消は扱わない。時刻はタイムゾーン付きで届き、サーバの時計は UTC。
悪いコード (Before)
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"]
>= で当日失効 — 規約は当日まで使えるヒント(段階的開示)
ヒント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/28 | Q2 | 年末に規約より少ない会員 3,112 人(#3〜#5 と一斉ゼロの合計) 実測 |
| 4 | 日付を UTC で取る | Q2 | JST 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 違反)。失敗を False | Q4 | Good の台帳からは月別失効額が出る(2026-02 902,602pt … 12 月 1,298,794pt) 実測 |
モデルと時間軸(SVG)
上段は責務の分け方。Bad は5つの仕事を bal と exp の2つの値で済ませようとしている。下段は実行例の時間軸で、Good のロットごとの期限(棒)と Bad の期限1つ(赤い印)がどうずれるかを示す。
Bad→Good 対比(要所)
{"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(...)
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
| 対象 | 結果(実測) |
|---|---|
| Good | 19 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 付与取消の不足を 0 | 2 赤 | 3 赤 |
| M7 期間限定を +30 日 | 2 赤(同上、oracle 差分は緑) | 3 赤 |
| M8 付与取消の前に失効させない | (未作成) | 1 赤 |
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ロットにして現行の期限を引き継ぐ経過措置を規約改定とセットで |
| 付与取消の不足 pt | RevokeResult.shortfall の合計(483 件・42,735pt) | 返金処理が差し引きに対応するまで、不足 > 0 は手動確認キューへ |
| JST 境界の問い合わせ | 「深夜に残高が違う」件数 | 0 にならなければ naive datetime を作る呼び出し側を探す |
ポイント解説
実務への応用
- 販促キャンペーンの効果測定: 期間限定ポイントの実コストは「配った 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
今日のまとめ
次のステップ
- 発展問題: Bad の本番データ(会員ごとの
balとexpだけ)から Good へ移行せよ。(a) 既存残高を1ロットにして現行の期限を引き継ぐ (b) 過去の取引履歴から規約どおりに再計算する、の2案を、(b) で残高が減る会員数(合成データなら 5,264 人)と CS の問い合わせ見込みで比べ、規約改定の文案・移行スクリプト・移行前後の残高差を検算するテストを書け - 参考: FEFO、ファーストクラスコレクション、イベントソーシング、property-based testing、differential testing、mutation testing(mutmut)、前払式支払手段の 6 か月ルール