目次12

個人で価格ウォッチャーを作って公開した。表示価格からポイント還元を引いた実質価格で複数モールを比べ、ウォッチした消耗品が安くなったら通知する、というものだ。作る前は「表示価格 − もらえるポイント」を出すだけ、と軽く見ていた。実際に手を動かすと、その一行がいちばん詰まった。

理由は3つあった。全部のポイントを引くと数字を盛れてしまう。確実にもらえるポイントと「たぶんもらえる」ポイントを同列に足すと、一番安いはずの店が実は条件付きだった、という事故が起きる。還元率の単位はモールでバラバラだし、端数の扱いを間違えると実額と数円ずれる。

この記事は、その実質価格をどう計算したか、設計で決めたポイントを共有するものです。結論から言うと、ポイントを確実性で3層に分け、層ごとに端数処理してから合算し、ランキングには確実な層だけを使う、という形に落ち着いた。同じように「ポイント込みでどっちが得か」を計算したい人の手がかりになればと思う。

まず「実質価格」を定義する

実質価格は、表示価格から「その買い物でもらえるポイント」を引いた金額だ。1,000 円の商品に 100 ポイント付くなら実質 900 円、と考える。表示価格そのものより、財布から最終的に減る額に近い。

ただしポイントは現金と完全には同じでない。使途が限られ、期限もある。今回は「次の買い物で 1 ポイント = 1 円として使える」という前提を置いて円換算した。この前提を最初に固定しないと、店をまたいだ比較がそもそも成立しない。定義に前提を含めておくところからが設計だった。

素朴な実装はどこで壊れたか

最初に書いたのはこれだ。

const effectivePrice = displayPrice - totalPoints; // これだと盛れる

短いが、3 つの穴があった。

  • もらえるか確実でないポイントまで引いてしまう。要エントリーのキャンペーンや、自分の SPU 倍率を当て込んだ分まで引くと、表示上いちばん安く見える店が「条件を全部満たして初めて届く価格」になる。
  • 還元率の単位がモールで違う。楽天の倍率、Yahoo! のキャンペーン加算、期間限定ポイント。これを一括で掛けると桁がずれる。
  • 端数。ポイントは多くの場合 floor(切り捨て)で付く。複数の還元をまとめて足してから floor すると、実際の付与額と数円ずれる。

数円のズレでも、1 位と 2 位が入れ替われば「どこで買うのが得か」の答えが変わる。盛らないことと、ズレないこと。実質価格はこの 2 つを同時に満たさないと、ただの参考値に落ちる。ここで一度作り直すことにした。

設計の軸: ポイントを確実性で3層に分ける

作り直しで据えた軸が、ポイントを「どれだけ確実にもらえるか」で3層に分けることだった。

中身確実性ランキングに使う
① 確定API が返す実付与ポイント確実使う
② 条件付き公知のキャンペーン(5 と 0 のつく日など)要エントリー等で変わる使わない
③ 仮定ユーザー自身の SPU 倍率・買い回り自己申告使わない

確定層だけで順位を決め、②③は「最大でここまで下がる」という幅として見せる。並び順は誰にとっても同じ(確実な情報だけで決まる)で、上振れは各自の条件で変わる、という住み分けにした。

flowchart TD
  Price[税込表示価格]
  L1[① 確定: API実値ポイント]
  L2[② 条件付き: 公知キャンペーン]
  L3[③ 仮定: ユーザー設定]
  Rank[ランキング rankKey = 価格 − ①]
  Eff[表示する実質価格 = 価格 − ①②③]

  Price --> L1 --> Rank
  Price --> L2 --> Eff
  Price --> L3 --> Eff
  L1 --> Eff

計算結果には、含まれた最も弱い層を確実性ティアとして持たせた。型で言うとこれだけだ。

type CertaintyTier = 'confirmed' | 'conditional' | 'assumed';

③(仮定)を含む価格は通知を発火させない、というルールもここに紐づく。自己申告のポイントを根拠に「買い時です」とは断定できない。発火するのは確定だけ、もしくは要エントリーを開示した条件付きまで、と決めた。

端数は層ごとに floor してから合算する

ポイントの floor は、層をまたいで最後に一度だけ、ではいけない。各層で floor してから足す。

let pts = Math.floor(base * rate); // 層ごとに切り捨て
if (cap != null && pts > cap) {
  pts = Math.min(pts, cap); // 上限は floor 後のポイント円に適用
}

実際の付与も「キャンペーンごとに切り捨てて付く」ことが多く、合算してから floor すると実額と 1〜数円ずれる。上限(cap)の当て方も同じで、floor したあとのポイント円に対して Math.min を取る。先に cap してから floor すると、やはり 1 円ずれる。地味な順序だが、ここが表示の信頼に効いた。

倍率の持ち方にも癖がある。キャンペーンの「+4 倍」は、税込価格への追加付与分(増分)として持つ。等倍(1 倍)は増分 0 だ。倍率をそのまま価格に掛けると、標準でもらえるポイントを二重に数えてしまう。だから「増分だけ」を率として保持した。

ランキングのキーと表示価格を分ける

並び順を決めるキーと、画面に出す実質価格は、引く層を変えた。

const rankKey = base - confirmedPoints;                 // ① だけ
const total = confirmedPoints + conditionalPoints + assumedPoints;
const effectivePrice = Math.max(0, base - total);       // 負ガード

rankKey は確定層だけを引いた値。誰が見ても同じ順位になる。effectivePrice は全層を引いた値で、各自の条件込みで「最大どこまで下がるか」を表す。高還元の商品で総ポイントが価格を超えると負になるので、Math.max(0, …) でガードした。

順位と表示額をあえてずらしたのは、読み手によって変わる数字(条件付き・仮定)を順位に混ぜたくなかったからだ。混ぜると、自分の SPU 設定を盛った人ほど上位が動く、という気持ち悪い挙動になる。

送料はあえて実質価格に入れない

送料額は、どのモールの検索 API からも取れない。だから実質価格には足さないと決めた。推測で足すと、取れないものを埋めるための誤差を自分で持ち込むことになる。

代わりに、送料込み / 送料別 / 不明のフラグを持たせ、送料込みのものだけを同じ土俵で比較する。

// 送料込みのものだけを主比較群に入れる
function isComparableForRank(listing: Listing): boolean {
  return listing.postageFlag === 0; // 0=送料込, 1=送料別, null=不明
}

送料別と送料込みを混ぜて最安を出すと、送料で逆転するのに気づけない。送料別・不明のものは主比較群から外し、注記として開示する。入れられない情報を「入れない」と決めて、その判断を表に出すほうが、半端に推測するより誠実だと思う。

振り返り

実質価格は「表示価格 − ポイント」と一行で書けるのに、信用できる数字にしようとすると、確実性の層分け・層ごとの floor・ランキングキーの分離・送料の除外、と地味な判断が積み重なった。派手な処理はどこにもない。ただ、ここを雑にやると「一番安いはずが条件付きだった」で読み手の信頼を失う。時間を使ったのは、結局この計算の足場を固めるところだった。

この3層計算は、ウォッチした消耗品について毎日自動で回し、確定層ベースで本当に安くなったときだけ通知する、という形で動いている。公開したのは ヤスゴロ という価格ウォッチャーで、ログイン不要・無料で使える。

よくある質問

ポイントを全部引いた金額で比較してはだめ?

全部引くと数字を盛れてしまいます。要エントリーのキャンペーンや自分の SPU 倍率まで引いた価格は「条件を全部満たせば届く価格」で、誰にとっても確実な最安ではありません。確実にもらえる API 実値ポイントだけを引いた値でランキングし、条件付き・仮定のポイントは別枠のレンジとして見せています。

期間限定ポイントや要エントリーのキャンペーンはどう扱う?

実質価格の本体には乗せず、条件付きの層(②層)として分けて計算し、要エントリーや上限到達のフラグを付けて開示します。金額として断定するのは API が実値を返す確定ポイントだけ。期間限定や要エントリーは「最大でここまで下がる」という幅で見せ、通知の発火条件からも外しました。

送料を実質価格に入れないと最安が逆転しない?

逆転し得ます。ただし送料額はどのモールの検索 API からも取れないので、推測で足すとかえって誤差を持ち込みます。今回は送料を実質価格に算入せず、送料込み/送料別/不明のフラグを持たせ、送料込みのものだけを同じ土俵で比較しました。送料別のものは主比較群から外し、注記で開示する形です。

なぜランキングと表示する実質価格で使う層を変えるの?

並び順は誰が見ても同じであってほしいからです。ランキングのキーには確定層だけを使い、表示する実質価格は確定+条件付き+仮定を引いた値にしています。こうすると「一番安い順」は確実な情報だけで決まり、各自の条件で変わる上振れは表示価格側のレンジで伝わります。