「表示価格は A が安いけど、B はポイント還元が多い。結局どっちが得なのか」を計算で出したくなります。素朴に書くと、次の一行です。
const effectivePrice = displayPrice - totalPoints; // これだと数字を盛れる
ですが、全部のポイントを引くと困ります。要エントリーのキャンペーンや自分の SPU 倍率まで含めた「条件を全部満たせば届く価格」になってしまい、誰にとっても確実な最安ではなくなります。確実にもらえる分と「たぶんもらえる」分を同じ土俵に混ぜたのが原因です。
楽天・Yahoo! の点数計算でこれに詰まったので、ポイントを確実性で 3 層に分ける形に整理しました。コードは短く、考え方が要点です。
ポイントを確実性で 3 層に分ける
| 層 | 中身 | ランキングに使う |
|---|---|---|
| ① 確定 | API が返す実付与ポイント | 使う |
| ② 条件付き | 公知キャンペーン(5 と 0 のつく日 など) | 使わない |
| ③ 仮定 | 自分の SPU 倍率・買い回り | 使わない |
並び順(ランキング)は確定層だけで決め、②③は「最大でここまで下がる」という幅として見せます。こうすると最安順は誰が見ても同じになり、上振れは各自の条件で変わる、という形に切り分けられます。自己申告のポイントを盛った人ほど上位が動く、という挙動を避けられます。
最小実装
ポイントは層ごとに floor(切り捨て)してから合算します。まとめて足してから floor すると、実際の付与額と数円ずれます。
type Tier = "confirmed" | "conditional" | "assumed";
function computeEffectivePrice(
base: number,
layers: { rate: number; cap?: number; tier: Tier }[],
) {
let confirmed = 0,
conditional = 0,
assumed = 0;
for (const l of layers) {
let pts = Math.floor(base * l.rate); // 層ごとに切り捨て
if (l.cap != null) pts = Math.min(pts, l.cap); // 上限は floor 後に適用
if (l.tier === "confirmed") confirmed += pts;
else if (l.tier === "conditional") conditional += pts;
else assumed += pts;
}
const rankKey = base - confirmed; // 並び順は確定層だけで引く
const total = confirmed + conditional + assumed;
const effectivePrice = Math.max(0, base - total); // 表示は全層・負ガード
return { rankKey, effectivePrice };
}
ポイントは、並び順を決める rankKey には確定層だけを引き、画面に出す effectivePrice は全層を引いた値にします。同じ価格でも、ランキングと表示で引く層を変えるのが要点です。高還元で総ポイントが価格を超えると負になるので、Math.max(0, …) でガードします。
ハマりどころ
-
floor の順序: 上限(cap)も、floor したあとのポイント円に対して
Math.minを取ります。先に cap してから floor すると 1 円ずれます。実際の付与もキャンペーンごとに切り捨てて付くことが多いので、層別 floor が実額に近くなります。 - 倍率は「増分」で持つ: キャンペーンの「+4 倍」は、税込価格への追加付与分(増分)として保持します。倍率をそのまま価格に掛けると、標準でもらえるポイントを二重に数えてしまいます。等倍は増分 0 です。
まとめ
実質価格は「表示価格 − ポイント」の一行に見えて、信用できる数字にするには確実性で層を分ける必要があります。確定層だけでランキングし、条件付き・仮定はレンジで見せる。端数は層ごとに floor してから合算する。これだけで「一番安いはずが条件付きだった」を防げます。
送料を実質価格に入れない判断(API で取れないので推測しない)、要エントリーやキャップの開示、価格履歴と通知への組み込みは Aulvem 本家にまとめました → ポイント還元込みの「実質価格」をどう計算したか — 3層に分けた設計メモ