ポイント残高は「現在値」ではなく「履歴の積み上げ」だ
会員ポイントは通販の顧客囲い込みにおいて最も基本的な仕組みの一つだ。しかしポイントの残高計算は、見た目のシンプルさとは裏腹に、データ処理として意外に複雑だ。
なぜか。ポイントには3種類のイベントがあるからだ。
- 付与:注文に応じてポイントが増える
- 使用:購買時にポイントを使って減る
- 失効:有効期限を過ぎると消える
これらが時系列に積み重なったトランザクションログから「今この瞬間の残高」を正確に計算することが今回のテーマだ。
さらに実務では「失効処理の順序(先に付与されたポイントから失効させる先入先出)」や「使用したポイントが後から失効対象になる場合」など、複雑なルールが絡んでくる。今回はシンプルなケースから始めて、段階的に実務レベルまで引き上げていく。
使用するテーブル
-- point_transactions(ポイントトランザクションログ)
-- transaction_id : トランザクションID(主キー)
-- customer_id : 顧客ID
-- event_type : イベント種別('earn'=付与, 'use'=使用, 'expire'=失効)
-- points : ポイント数(正の整数。符号はevent_typeで管理)
-- order_id : 紐づく注文ID(NULLの場合は手動付与・失効処理)
-- occurred_at : 発生日時
-- expiry_date : このポイントの有効期限(付与イベントのみ)
-- note : メモ
| transaction_id | customer_id | event_type | points | occurred_at | expiry_date |
|---|---|---|---|---|---|
| T001 | C001 | earn | 500 | 2024-01-08 | 2025-01-31 |
| T002 | C001 | earn | 300 | 2024-02-12 | 2025-02-28 |
| T003 | C001 | use | 200 | 2024-03-05 | NULL |
| T004 | C001 | earn | 800 | 2024-04-18 | 2025-04-30 |
| T005 | C001 | expire | 500 | 2025-01-31 | NULL |
| T006 | C001 | use | 400 | 2024-06-01 | NULL |
STEP 1 ― イベント種別を符号に変換して残高を累積計算する
ポイントの増減は event_type で表現されているが、計算するには符号(プラス・マイナス)に変換する必要がある。
earn(付与):+pointsuse(使用):-pointsexpire(失効):-points
-- STEP1: 残高の累積計算
WITH signed_transactions AS (
SELECT
transaction_id,
customer_id,
event_type,
points,
occurred_at,
expiry_date,
-- 符号付きポイント(付与はプラス、使用・失効はマイナス)
CASE
WHEN event_type = 'earn' THEN points
WHEN event_type = 'use' THEN -points
WHEN event_type = 'expire' THEN -points
ELSE 0
END AS signed_points
FROM point_transactions
),
running_balance AS (
SELECT
transaction_id,
customer_id,
event_type,
points,
signed_points,
occurred_at,
expiry_date,
-- 顧客ごとに時系列順で累積和を計算
SUM(signed_points) OVER (
PARTITION BY customer_id
ORDER BY occurred_at ASC, transaction_id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_balance
FROM signed_transactions
)
SELECT *
FROM running_balance
ORDER BY customer_id, occurred_at, transaction_id;
出力イメージ(C001)
| transaction_id | event_type | points | signed_points | occurred_at | running_balance |
|---|---|---|---|---|---|
| T001 | earn | 500 | +500 | 2024-01-08 | 500 |
| T002 | earn | 300 | +300 | 2024-02-12 | 800 |
| T003 | use | 200 | -200 | 2024-03-05 | 600 |
| T004 | earn | 800 | +800 | 2024-04-18 | 1,400 |
| T006 | use | 400 | -400 | 2024-06-01 | 1,000 |
| T005 | expire | 500 | -500 | 2025-01-31 | 500 |
現在残高500ポイント。残高の推移が時系列で追える。
ORDER BYにtransaction_idを加えるのを忘れずに
同一日時に複数のイベントが発生することがある。occurred_atだけでは順序が不定になり、毎回実行するたびに残高が変わる可能性がある。transaction_id(またはcreated_atなど一意なカラム)をタイブレーカーとして必ず追加する。
STEP 2 ― 顧客ごとの現在残高を集計する
STEP1 で各イベント時点の残高が出た。現在の残高は単純に全イベントの signed_points を合計するだけでよい。
-- STEP2: 顧客ごとの現在残高
SELECT
customer_id,
SUM(
CASE
WHEN event_type = 'earn' THEN points
WHEN event_type = 'use' THEN -points
WHEN event_type = 'expire' THEN -points
ELSE 0
END
) AS current_balance,
-- 内訳も一緒に出す
SUM(CASE WHEN event_type = 'earn' THEN points ELSE 0 END) AS total_earned,
SUM(CASE WHEN event_type = 'use' THEN points ELSE 0 END) AS total_used,
SUM(CASE WHEN event_type = 'expire' THEN points ELSE 0 END) AS total_expired
FROM point_transactions
GROUP BY customer_id
ORDER BY current_balance DESC;
出力イメージ
| customer_id | current_balance | total_earned | total_used | total_expired |
|---|---|---|---|---|
| C004 | 2,400 | 3,800 | 1,200 | 200 |
| C001 | 500 | 1,600 | 600 | 500 |
| C002 | 200 | 1,200 | 800 | 200 |
| C003 | 0 | 800 | 500 | 300 |
STEP 3 ― マイナス残高の検知と対処
正しく設計されていれば残高はマイナスにならないはずだが、データ投入のミスや処理順序の誤りでマイナスになることがある。マイナス残高を検知するクエリを持っておくとデータ品質チェックに使える。
-- STEP3: マイナス残高の検知
WITH signed_transactions AS (
SELECT
transaction_id,
customer_id,
event_type,
points,
occurred_at,
CASE
WHEN event_type = 'earn' THEN points
WHEN event_type = 'use' THEN -points
WHEN event_type = 'expire' THEN -points
ELSE 0
END AS signed_points
FROM point_transactions
),
running_balance AS (
SELECT
transaction_id,
customer_id,
event_type,
occurred_at,
SUM(signed_points) OVER (
PARTITION BY customer_id
ORDER BY occurred_at ASC, transaction_id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_balance
FROM signed_transactions
)
SELECT
customer_id,
transaction_id,
event_type,
occurred_at,
running_balance
FROM running_balance
WHERE running_balance < 0 -- マイナスになった時点のレコードを検出
ORDER BY customer_id, occurred_at;
マイナス残高が検知されたら、use や expire の処理が過剰に行われていないか、または earn の処理が漏れていないかを確認する。
STEP 4 ― 失効間近のポイントを顧客別に出す(施策活用)
「来月末に失効するポイントが残っている顧客」を抽出してリマインドメールを送ることは、ポイント制度を持つ通販では定番の施策だ。
ここで重要なのは「すでに使用・失効済みのポイントを二重にカウントしない」ことだ。付与ポイントのうち、まだ消化されていない残りのポイントだけを対象にする必要がある。
シンプルなアプローチとして、「付与ポイントの合計」から「使用+失効ポイントの合計」を引いた残高が残っており、かつ失効期限が来月末以前のものを対象にする。
-- STEP4: 失効間近ポイントの抽出(来月末に失効するポイントがある顧客)
WITH current_balance AS (
SELECT
customer_id,
SUM(
CASE
WHEN event_type = 'earn' THEN points
WHEN event_type = 'use' THEN -points
WHEN event_type = 'expire' THEN -points
ELSE 0
END
) AS balance
FROM point_transactions
GROUP BY customer_id
),
expiring_points AS (
-- 来月末までに失効する予定の付与ポイントを持つ顧客
SELECT
customer_id,
SUM(points) AS expiring_amount,
MIN(expiry_date) AS earliest_expiry
FROM point_transactions
WHERE
event_type = 'earn'
AND expiry_date IS NOT NULL
-- 来月末までに失効する
AND expiry_date <= DATE_ADD('month', 1,
DATE_TRUNC('month', CURRENT_DATE)
)
-- 有効期限がまだ切れていない(今日以降が失効日)
AND expiry_date >= CURRENT_DATE
GROUP BY customer_id
)
SELECT
ep.customer_id,
cb.balance AS current_balance,
ep.expiring_amount AS points_expiring_soon,
ep.earliest_expiry AS expiry_date,
DATE_DIFF('day', CURRENT_DATE, ep.earliest_expiry) AS days_until_expiry,
'失効間近リマインド' AS segment_name
FROM expiring_points ep
JOIN current_balance cb ON ep.customer_id = cb.customer_id
-- 残高があること(すでにすべて使い切った顧客は除外)
WHERE cb.balance > 0
ORDER BY days_until_expiry ASC, ep.expiring_amount DESC;出力イメージ
| customer_id | current_balance | points_expiring_soon | expiry_date | days_until_expiry |
|---|---|---|---|---|
| C012 | 800 | 800 | 2025-01-15 | 9 |
| C001 | 500 | 500 | 2025-01-31 | 25 |
| C019 | 1,200 | 300 | 2025-01-31 | 25 |
days_until_expiry の短い順に並んでいるので、最も緊急度の高い顧客から配信できる。
STEP 5 ― 特定時点の残高を再現する(監査・問い合わせ対応)
「2024年6月1日時点でのポイント残高はいくつでしたか」という顧客からの問い合わせに答えるクエリだ。WHERE occurred_at <= 対象日時 で過去時点の状態を再現できる。
-- STEP5: 特定時点の残高を再現する
SELECT
customer_id,
SUM(
CASE
WHEN event_type = 'earn' THEN points
WHEN event_type = 'use' THEN -points
WHEN event_type = 'expire' THEN -points
ELSE 0
END
) AS balance_at_that_time
FROM point_transactions
WHERE
customer_id = 'C001'
AND occurred_at <= TIMESTAMP '2024-06-01 23:59:59' -- この時点以前のイベントだけ
GROUP BY customer_id;
出力イメージ
| customer_id | balance_at_that_time |
|---|---|
| C001 | 1,000 |
2024年6月1日時点では1,000ポイントだった(T001〜T006 のうち T005 の失効がまだ起きていないため)。累積ログさえ保持していれば、いつの時点の残高でも正確に再現できる。
応用:ポイント利用率の月次推移を追う
施策の効果を見るために「発行したポイントのうち、どれだけ使われたか」を月次で追う。
-- ポイント発行・利用の月次推移
SELECT
DATE_TRUNC('month', occurred_at) AS month,
SUM(CASE WHEN event_type = 'earn' THEN points ELSE 0 END) AS earned,
SUM(CASE WHEN event_type = 'use' THEN points ELSE 0 END) AS used,
SUM(CASE WHEN event_type = 'expire' THEN points ELSE 0 END) AS expired,
-- 利用率:使用ポイント ÷ 付与ポイント
ROUND(
SUM(CASE WHEN event_type = 'use' THEN points ELSE 0 END) * 100.0
/ NULLIF(SUM(CASE WHEN event_type = 'earn' THEN points ELSE 0 END), 0)
, 1) AS usage_rate_pct,
-- 失効率:失効ポイント ÷ 付与ポイント
ROUND(
SUM(CASE WHEN event_type = 'expire' THEN points ELSE 0 END) * 100.0
/ NULLIF(SUM(CASE WHEN event_type = 'earn' THEN points ELSE 0 END), 0)
, 1) AS expire_rate_pct
FROM point_transactions
GROUP BY DATE_TRUNC('month', occurred_at)
ORDER BY month;
出力イメージ
| month | earned | used | expired | usage_rate_pct | expire_rate_pct |
|---|---|---|---|---|---|
| 2024-01 | 1,284,000 | 892,000 | 0 | 69.5 | 0.0 |
| 2024-02 | 1,412,000 | 984,000 | 0 | 69.7 | 0.0 |
| 2024-12 | 1,841,000 | 1,284,000 | 284,000 | 69.7 | 15.4 |
| 2025-01 | 1,920,000 | 1,348,000 | 412,000 | 70.2 | 21.5 |
利用率が安定して約70%を推移している一方、失効率が1月に向けて上昇している。有効期限を年度末に設定しているためだ。失効率が高い月の前にリマインドキャンペーンを打てば失効を防ぎ、購買機会に転換できる可能性がある。
実務での運用ヒント
① 付与日ベースの先入先出(FIFO)失効計算は別途設計が必要
今回の設計では「どの付与ポイントが使われたか」を追跡していない。厳密な先入先出管理(先に付与されたポイントから先に消費させる)が必要な場合は、使用イベントごとに「どのearnイベントのポイントを消費したか」を記録する別テーブルが必要になり、設計が複雑化する。多くの通販では「残高さえ合っていれば細かい割り当ては管理しない」設計が多い。
② ポイント付与の確定タイミングに注意
「注文確定時に付与」「配送完了後に付与」「返品期間経過後に付与」など、付与タイミングのルールはシステムによって異なる。earned_at(付与確定日)と order_date(注文日)を分けて記録しておくと、後から正確な分析ができる。
③ Treasure Data での累積計算のコスト
全顧客の全履歴に対して SUM() OVER(ORDER BY ...) を計算するとコストが高くなる。実務では「月次スナップショット(月末残高)」テーブルを別途作成し、スナップショット以降の差分だけを累積計算する設計にするとパフォーマンスが大幅に改善する。
まとめ
今回のクエリの核心は2つだ。
CASE WHEN event_type = 'earn' THEN +points ... ELSE -pointsで符号付きポイントに変換するSUM(signed_points) OVER (PARTITION BY customer_id ORDER BY occurred_at ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)で時系列の累積残高を計算する
この2つのパターンを押さえれば、残高・在庫・負債など「トランザクションの積み上げで現在値が決まる」あらゆる数値に応用できる。通販では在庫数の推移追跡(入荷・出荷・廃棄のログから現在庫を計算)にも全く同じ構造が使える。