料金設定 config/pricing.ts
注目すべき点
v4.0.0 以降、料金はデータベースに保存されなくなり、管理画面の料金管理ページも廃止されました。
config/pricing.tsが料金の唯一の情報源です。
なぜ料金をコードに置くのか
- 価格変更は低頻度だが影響の大きい操作です。管理画面で 2 クリックして即反映されるより、コードレビューを 1 回通す価値があります
- お金の情報源は常に決済プロバイダー側にあります。このファイルの仕事はただ 1 つ、プランの slug を「プロバイダー側の価格オブジェクト + 付与クレジット + カードのコピー」に対応づけることです
- カードのコピー(名称、説明、機能リスト)はプランオブジェクトに直接インライン化されています。1 プランが 1 オブジェクトで、i18n ファイルに散らばりません
プランの書き方
サブスクリプションプラン
{
id: 'pro-monthly', // 安定した slug。注文とクレジット台帳が永続的に参照する
kind: 'subscription',
interval: 'month', // 'month' | 'year'
monthlyCredits: 2000, // 毎月付与するクレジット(サブスクリプションバケットはこの値にリセット)
provider: 'stripe', // 決済を処理するプロバイダー
stripePriceId: { // プロバイダー側の参照。環境ごとに 1 つ
test: 'price_xxx_test',
live: 'price_xxx_live',
},
price: 29.9, // 表示価格(主要通貨単位。セントではない)
currency: 'USD',
popular: true, // 料金ページでこのカードを強調
active: true,
copy: {
en: { name: 'Pro', description: '...', features: ['2,000 credits every month', '...'] },
zh: { name: '专业版', description: '...', features: ['每月 2,000 积分', '...'] },
ja: { name: 'プロ', description: '...', features: ['毎月 2,000 クレジット', '...'] },
},
}クレジットパック(買い切り購入)
{
id: 'pack-standard',
kind: 'credit_pack',
credits: 5000, // 一度だけ購入バケットへ付与。無期限
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 49,
currency: 'USD',
active: true,
copy: { en: { ... }, zh: { ... }, ja: { ... } },
}チームプラン
{
id: 'team-monthly',
kind: 'subscription',
interval: 'month',
audience: 'team', // チームプランの印:課金主体は組織
seats: 5, // シート上限。招待と参加の時点で強制
monthlyCredits: 10000, // 組織の共有プールへ付与
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 149.5,
currency: 'USD',
active: true,
copy: {
ja: {
name: 'チーム',
description: 'チームでの共同制作に。共有プールと 5 シート。',
features: [
// オブジェクト形式 + highlight は行全体を強調。チーム限定の特典に使う
{ text: '毎月 **10,000 クレジット** をチームで共有', highlight: true },
{ text: '**5 シート** 込み — チーム全員を招待', highlight: true },
'全メンバーがプロの全機能を利用可能', // 素の文字列は控えめに描画される
],
},
// en / zh も同じ形
},
}フィールドリファレンス
共通フィールド
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | ✅ | 安定した slug。注文、サブスクリプション、クレジット台帳が永続的に参照します |
provider | 'stripe' | 'creem' | 'paypal' | ✅ | このプランのチェックアウトを処理するプロバイダー |
stripePriceId / creemProductId / paypalPlanId | { test, live } | プロバイダー次第 | プロバイダー側の参照。次節を参照 |
price | number | ✅ | 表示価格。主要通貨単位(9.9 = $9.90)。実際の請求通貨はプロバイダー側に従います |
currency | string | ✅ | 表示用の ISO 通貨コード |
copy | { en, zh, ja, ... } | ✅ | ロケール別のカードコピー。en は必須でフォールバックを兼ねます |
originalPrice | number | 取り消し線付きの元価格。プロモーション表示用 | |
promotionCode | string | このプランのチェックアウトで自動適用するコード。サイト全体のキャンペーンより優先 | |
popular | boolean | 料金ページでこのカードを強調 | |
active | boolean | false = 料金ページから非表示。過去の解決は引き続き機能します |
サブスクリプション専用
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
kind | 'subscription' | ✅ | |
interval | 'month' | 'year' | ✅ | |
monthlyCredits | number | ✅ | 月次クレジット枠。サブスクリプションバケットは付与のたびにこの値へリセットされ、繰り越しません。年額プランはこの量を毎月配分します |
audience | 'personal' | 'team' | 省略時は個人。'team' は組織を課金主体にします | |
seats | number | チームプラン | シート上限 |
trialDays | number | トライアル日数 |
クレジットパック専用
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
kind | 'credit_pack' | ✅ | |
credits | number | ✅ | 購入バケットへ一度だけ付与されるクレジット。無期限 |
プロバイダー側の参照の書き方
各プランは provider で決済担当を宣言し、対応するフィールドだけを埋めます。
| provider | 埋めるフィールド | 入れる値 |
|---|---|---|
stripe | stripePriceId | Stripe Price ID |
creem | creemProductId | Creem Product ID |
paypal | paypalPlanId | PayPal Billing Plan ID。サブスクリプションのみ。PayPal のクレジットパックは price から動的に注文を作成するため plan id は不要です |
どのフィールドも { test, live } の 2 値を持ちます。どちらを使うかは環境で決まります。
- Stripe は
STRIPE_SECRET_KEYのプレフィックス(sk_test/sk_live) - PayPal は
NEXT_PUBLIC_PAYPAL_ENVIRONMENT(sandbox/live)
そのため、同じ設定ファイルがテスト環境でも本番環境でもそのまま動きます。
スクリプトで Stripe の価格を一括作成
Stripe ダッシュボードで 1 つずつ製品を作りたくない場合:
# ドライラン。作成される内容を表示するだけ
pnpm stripe:bootstrap
# 適用:Stripe に製品と価格を作成し、config/pricing.ts に書き戻す
pnpm stripe:bootstrap --writeこのスクリプトは冪等です。製品は metadata.planId で、価格は金額 + 通貨 + 周期で既存のものを再利用し、すでに反映済みのエントリはスキップします。書き込む列は STRIPE_SECRET_KEY のプレフィックスで決まります。
多言語コピー
copy は { ロケール: PlanCopy } のオブジェクトで、en は必須かつ未知のロケールのフォールバックです。
interface PlanCopy {
name: string
description: string
features: PlanFeature[]
}
type PlanFeature =
| string // 素の行。控えめに描画
| { text: string; highlight?: boolean } // highlight: true で行全体を強調機能行は **太字** の断片に対応しています。highlight: true は慣例としてチーム限定の特典(シート、共有プール、メンバー別統計)に限定し、共通のベースライン機能の中で目立つようにします。
コピーの取得には getPlanCopy(plan, locale) を使い、未知のロケールは自動的に en へフォールバックします。
プロモーションコード
2 つの階層があり、プラン単位がサイト全体より優先されます。
// サイト全体のキャンペーン
export const pricingCampaign: PricingCampaign = {
promotionCode: 'LAUNCH20',
}
// 個別のプラン
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }コードが設定されていると、チェックアウト時に自動適用されます(Stripe と Creem が対応。PayPal は非対応)。設定がない場合は、決済画面にプロバイダー標準の手入力欄が表示されます。
コード自体は Stripe 側で作成・管理し、ボイラープレートはクーポンコンソールを同梱しています。テストモードと本番モードで、同じ顧客向けコードをそれぞれ作成する必要がある点に注意してください。
注目すべき点
設定したコードが現在のモードに存在しない、または無効化されている場合でも、チェックアウトは失敗せず手入力欄へフォールバックします。古いキャンペーン設定が売上を止めることはありません。
プラン廃止の鉄則
planId は再利用せず、販売実績のあるプランのエントリを物理削除してはいけません。
既存サブスクリプションの更新・アップグレード請求はすべて、このファイルを通じてプランを解決します(metadata.planId とプロバイダー側の価格 ID、どちらの経路も最終的にここへ到達します)。エントリを削除すると、次のことが起こります。
- Webhook のフルフィルメントが請求のたびに例外を投げ、5xx を返す
- Stripe が最大 3 日間、指数バックオフでリトライし続ける
- リトライのたびに管理者へ付与失敗の警告メールが届く
提供終了は必ず active: false で行ってください。影響するのは料金ページの表示だけで、過去の解決は引き続き機能します。
参照ヘルパー
config/pricing.ts は派生配列と参照関数も公開しています。ランタイムのコードはこれらだけを読みます。
| エクスポート | 用途 |
|---|---|
pricingPlans | 廃止済みを含む全プラン |
activePricingPlans | active !== false のプラン |
subscriptionPlans / creditPackPlans | 種別での絞り込み |
personalSubscriptionPlans / teamSubscriptionPlans | 課金主体での絞り込み |
getPlanById(planId) | slug からプランを取得 |
findPlanByProviderPriceId(provider, id) | 逆引き:プロバイダー側 ID → プラン(Webhook が使用。test/live 両方の値に一致) |
getProviderPriceRef(plan) | そのプラン自身のプロバイダーの参照 |
getPlanCredits(plan) | サブスクリプションは月次枠、パックは総量 |
getPlanSeats(planId) | チームのシート上限。非チームプランや失効した slug は 1 |
getPlanCopy(plan, locale) / getPlanName(planId, locale) | ローカライズされたコピー / 名称 |
getAutoPromotionCode(plan) | このプランで自動適用すべきプロモーションコード |
isSubscriptionPlan / isTeamPlan / isStripePlan | 型ガード |
フロントエンドでの描画
料金ページのコンポーネントは components/pricing/ にあります。
PricingSection.tsx—— サーバーコンポーネント。id="pricing"アンカー付き。3 つのタブ(月額 / 年額 / クレジットパック)を持ち、その周期にチームプランが存在する場合は個人プランのグリッドの下にチーム用サブセクションを追加しますPricingCard.tsx—— 単一カード。PayPal のクレジットパックでは CTA をPayPalCheckoutButtonに、それ以外はCheckoutButtonに振り分けますCheckoutButton.tsx——createCheckoutSessionを呼んでプロバイダーの決済画面へ遷移し、各種ガードコードを適切な遷移先(ログインページ、請求ページ、チームページ、あるいはトースト)へマッピングします
セクションレベルの UI テキスト(見出し、タブ名など)は Pricing i18n 名前空間から、プランのコピーはこのファイルから来ます。
料金を変更したあと
- 開発サーバーを再起動します(設定はモジュールレベルの定数のため、ホットリロードが効かない場合があります)
- 料金ページ、チェックアウト、Webhook がすべて新しい設定を読みます。ほかに同期すべきものはありません
- 本番公開前に、すべての価格 ID の
live列が埋まっていることを確認してください