DEV Community

Hajime
Hajime

Posted on

「無料キャンペーン中」の価格を、恒久価格と同じ場所に置かない

料金表を実装するとき、いま無料であることと本来いくらであることを同じフィールドに入れると、あとで必ず壊れます。

キャンペーンが終わったときに、何を元の値に戻せばいいのか分からなくなるからです。上書きしてしまったなら、戻す先がありません。

音楽人材のマッチングサービスの料金表が、この構造をきれいに持っていたので、設計として整理します(2026年9月9日に確認した内容)。

Music Chronicle

音楽の仕事の料金体系を確認するなら Music Chronicle

元の記載

サイトにはこう書かれていました。

料金

  • スカウト送信 — 無料(通常 チケット1枚)
  • 単発依頼の掲載 — 無料(通常 チケット1枚)
  • 継続募集の掲載 — 無料(通常 チケット3枚)
  • ポートフォリオの掲載・動画公開 — 無料(掲載は今後も無料)
  • 仕事への応募 — 無料
  • メッセージのやり取り — 無料

注目すべきは、上3つと下3つで「無料」の意味が違うことです。

上3つは期間限定の無料で、括弧の中に通常価格が残っています。下3つは恒久的に無料です。ポートフォリオの掲載には「今後も無料」と明記されていました。

同じ「無料」という表示が、2種類ある。これを1つのフィールドで表すことはできません。

型で分ける

価格を、恒久的な定義と、いま適用されている条件に分けます。

type Feature = {
  id: string;
  name: string;
  /** 恒久的な価格。キャンペーンでも書き換えない */
  standard: { kind: 'free' } | { kind: 'tickets'; count: number };
  /** 現在適用中の上書き。無ければ standard がそのまま適用される */
  override?: { kind: 'free'; campaign: string; announced_end: string | null };
};
Enter fullscreen mode Exit fullscreen mode

これで、2種類の無料が区別できます。

[
  { "id": "portfolio", "name": "ポートフォリオの掲載・動画公開",
    "standard": { "kind": "free" } },

  { "id": "scout", "name": "スカウト送信",
    "standard": { "kind": "tickets", "count": 1 },
    "override": { "kind": "free", "campaign": "beta", "announced_end": null } },

  { "id": "job_continuous", "name": "継続募集の掲載",
    "standard": { "kind": "tickets", "count": 3 },
    "override": { "kind": "free", "campaign": "beta", "announced_end": null } }
]
Enter fullscreen mode Exit fullscreen mode

standard は絶対に書き換えません。キャンペーンは override を足すだけ、終了は override を外すだけ。これで元に戻せます。

announced_end を null にできるようにする

サイトには、有料化の開始時期は事前に告知されると書かれていました。つまり、現時点で終了日は決まっていません。

ここで announced_end を適当な日付で埋めると、決まっていないことを決まっているように表示することになります。

null を許容し、表示側で分岐させます。

const endLabel = (o) =>
  o.announced_end ? `${fmt(o.announced_end)}まで` : '終了時期は事前告知';
Enter fullscreen mode Exit fullscreen mode

「いつまでか未定」と正直に出すほうが、後から日付がずれて信用を失うより安全です。

表示は「いまの価格」と「通常価格」を並べる

override があるときの表示は、片方だけにしないほうがいい。

スカウト送信        無料   (通常 チケット1枚)
継続募集の掲載      無料   (通常 チケット3枚)
ポートフォリオ掲載  無料   (今後も無料)
Enter fullscreen mode Exit fullscreen mode

通常価格を隠すと、終了時に利用者が驚きます。逆に恒久無料のものには「今後も無料」と書く。同じ「無料」でも、後で変わるかどうかが一目で分かります。

これは実装の都合ではなく、元データが2種類を区別して持っているからこそ書ける表示です。

チケットは残高ではなく、期限付きのロットで持つ

もう一つ、設計が効くところがあります。チケットの条件です。

  • 1枚3,000円(税込)。クレジットカード(Stripe)で1〜10枚をまとめて購入できる
  • 期限は購入日から180日。過ぎた未使用分は失効する
  • 未使用チケットの払い戻しは原則行わない
  • 審査で不承認になった掲載については、消費分が自動で返却される

期限が購入から180日なので、残高を1つの整数で持つことはできません。

// ダメな例
user.tickets = 7;   // ← どれがいつ失効するのか分からない
Enter fullscreen mode Exit fullscreen mode

購入した単位(ロット)で持ちます。

{
  "lots": [
    { "id": "L1", "purchased_at": "2026-04-01", "expires_at": "2026-09-28", "count": 3, "used": 3 },
    { "id": "L2", "purchased_at": "2026-08-15", "expires_at": "2027-02-11", "count": 5, "used": 1 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

消費は期限が近いロットから。これを間違えると、まだ使えたはずのチケットが失効します。

function consume(lots, n, now) {
  const live = lots
    .filter((l) => l.expires_at > now && l.count > l.used)
    .sort((a, b) => a.expires_at.localeCompare(b.expires_at));   // 期限が近い順
  // …live の先頭から n 枚を引き当てる
}
Enter fullscreen mode Exit fullscreen mode

返却は「取り消し」であって「付与」ではない

審査で承認されなかった場合の自動返却が、ここで効いてきます。

返却を新しいロットの付与として実装すると、期限が延びます。4月に買ったチケットを使い、9月に不承認になって返却されたとき、新規ロットにすると期限が2027年3月まで延びてしまう。

正しくは、消費した記録を取り消す処理です。

function refund(lots, consumption) {
  // consumption は「どのロットから何枚引いたか」の記録
  for (const { lot_id, n } of consumption.entries) {
    const lot = lots.find((l) => l.id === lot_id);
    lot.used -= n;        // 元のロットに戻す。期限は元のまま
  }
}
Enter fullscreen mode Exit fullscreen mode

つまり、消費のときに「どのロットから引いたか」を記録しておく必要があります。残高を整数で減らす実装では、返却を正しく処理できません。

なお、期限切れのロットに戻すことになる場合もありえます。そのときどうするかは仕様の問題なので、勝手に延長せず、仕様として明記されている扱いに従うことになります。

購入の上限も制約として持つ

1〜10枚という購入単位の上限も、UI の都合ではなくデータ側の制約として持ちます。

{ "purchase": { "min": 1, "max": 10, "unit_price_incl_tax": 3000 } }
Enter fullscreen mode Exit fullscreen mode

税込価格であることを、フィールド名に入れておくこと。税区分が曖昧な price は、いずれ必ず事故ります。

課金しない機能も、同じテーブルに置く

最後に、料金表の外にある機能について。

このサービスでは、音楽シーンの公式データを扱う面(音楽ランキング / 動画ランキング / 公式ニュース / プレス・IR)があり、会員登録・閲覧はすべて無料でした。アーティストのお気に入り登録とマイダッシュボードが使えるとのことです。

これらは「キャンペーンで無料」ではなく「そもそも課金対象ではない」ものです。先ほどの型で言えば standard: { kind: 'free' } で、override を持ちません。

課金対象でないものを料金表から除外すると、あとで「これは有料ですか」という問い合わせが発生します。同じテーブルに入れて、恒久無料として明示するほうが親切でした。

まとめ

  • standardoverride を分ける。キャンペーンで恒久価格を書き換えない
  • 終了日が未定なら null決まっていないことを日付で埋めない
  • 表示では「いまの価格」と「通常価格」を並べる
  • チケットは整数の残高ではなく、期限付きのロットで持つ
  • 消費は期限が近い順。どのロットから引いたかを記録する
  • 返却は消費の取り消し。新規付与にすると期限が延びる
  • 税区分をフィールド名に入れる
  • 恒久無料の機能も料金表に載せ、「今後も無料」と明示する

料金や条件は変わることがあります。実装に取り込むなら、取得日を添えて持ってください。

本ページはプロモーションが含まれています

Top comments (0)