Menu

決済システム概要

注目すべき点

本章は v4.0.0 以降の決済システムについて説明します。v4 は書き直しであり、料金はデータベースからコードへ移され、3 プロバイダーのフルフィルメントロジックは 1 つの層に集約されました。3.x からアップグレードする場合は、まず バージョン 4.x チェンジログ をお読みください。

NEXTY.DEV の決済システムは Stripe・Creem・PayPal の 3 チャネルに同時対応し、サブスクリプション(月額/年額)と買い切り購入(クレジットパック)をカバーします。クレジット台帳、チーム共有プール、返金、照合、不正検知への対応を内蔵しています。

全体像を 1 本の流れで

config/pricing.ts                    料金の唯一の情報源(1 プラン = 1 オブジェクト)
        │ planId
        ▼
actions/billing/checkout.ts          統一チェックアウト入口。plan.provider で振り分け
        │
        ▼
プロバイダーのホスト型決済画面           Stripe Checkout / Creem Checkout / PayPal
        │ ユーザーが支払う
        ▼
app/api/{stripe,creem,paypal}/webhook    ルートは署名検証と振り分けのみ。業務ロジックを持たない
        │
        ▼
lib/billing/{stripe,creem,paypal}.ts     アダプター:Webhook ペイロードをコアの入力へ変換
        │
        ▼
lib/billing/fulfillment.ts           プロバイダー非依存のフルフィルメントコア。1 トランザクションで完結
        │
        ├──▶ orders            注文行(冪等キー = provider + provider_order_id)
        ├──▶ subscriptions     サブスクリプション行(年額のドリップ計画を含む)
        └──▶ lib/credits/      クレジット台帳(残高 + 追記専用ログ)
 
app/api/cron/credits                 定期実行:失われた Webhook の補償、年額ドリップの決済

5 つの設計原則

この 5 つを理解すれば、以降のドキュメントは自明になります。

1. 料金はデータではなくコード。 pricing_plans テーブルも管理画面の CRUD も存在しません。config/pricing.ts が唯一の情報源であり、価格変更はコードレビューとデプロイを経ます。詳細は料金設定を参照してください。

2. 金額は常に整数のセント。 データベースの金額カラムはすべて integer で、単位はプロバイダーネイティブのセントです。numeric も浮動小数点も使いません。

3. 冪等性は防御的ではなく構造的。 orders には (provider, provider_order_id) の一意インデックスがあり、書き込みは onConflictDoNothing、返金は行を追加せず累計額を更新します。Webhook の再送は構造上無害で、業務コードのあちこちに重複判定を書く必要がありません。

4. 残高は台帳経由でしか変わらない。 残高テーブルを直接 UPDATE することはありません。すべての変更は lib/credits/ledger.ts を通り、同一トランザクション内で credit_transactions にスナップショット付きの 1 行を追記します。

5. プロバイダー SDK の型はアダプターの外に出ない。 フルフィルメントコアが知っているのは lib/billing/types.ts の統一入力だけで、各プロバイダーのネイティブペイロードはそれぞれのアダプター内で変換されます。プロバイダーの追加はアダプターの追加であり、コアには手を入れません。

データベーステーブル

テーブル役割ポイント
ordersお金の動き 1 件につき 1 行冪等キーは (provider, provider_order_id)。返金は行を追加せず元の行を書き換える。アカウント削除時は user_id が NULL になり、注文自体は残る
subscriptionsサブスクリプションの状態正規化された単一のステータス列(プロバイダーのステータスはアダプターが正規化)。年額のドリップ計画は一級の状態として保持:monthly_credits / remaining_grants / next_grant_at
credit_balances個人のクレジット残高2 つのバケット:subscription_credits(付与のたびにリセット)と purchased_credits(無期限)
organization_credit_balancesチームの共有プール個人テーブルと同形で、主キーが organization_id
credit_transactionsクレジット台帳追記専用。各行が符号付きの amount、バケット別の増減、変更後のスナップショット、単調増加の seq を持つ

注文種別 order_type:

値意味
one_timeクレジットパックの購入
subscription_initialサブスクリプションの初回請求
subscription_renewal継続請求
subscription_upgrade期間途中のプラン変更による日割り請求

注文ステータス order_status:pending(PayPal eCheck などの保留中キャプチャのみが到達。クレジットは 0 で、定期実行が前進させる)、completed、partially_refunded、refunded、failed。

コードマップ

config/
  pricing.ts            プラン定義 + 参照ヘルパー(唯一の情報源)
  credits.ts            決済以外のクレジット規則(登録ボーナス)
 
actions/
  billing/checkout.ts   統一チェックアウト入口
  billing/portal.ts     サブスクリプションのセルフサービス入口(プロバイダー別に振り分け)
  credits/              クレジットの読み取りと消費(ユーザー側)、管理側の調整と一括付与
  orders/               注文照会(ユーザー/管理)、返金の入口
  coupons/admin.ts      クーポン管理(Stripe が唯一のストア)
 
lib/billing/
  types.ts              型の契約
  fulfillment.ts        プロバイダー非依存のフルフィルメントコア
  stripe.ts creem.ts paypal.ts    3 つのアダプター
  cancel.ts             統一されたプロバイダー側キャンセル
  duplicate.ts          重複サブスクリプションの決着
  reconcile.ts          定期照合(失われた Webhook の補償)
  notify.ts             Webhook 起点の通知(Redis で重複排除)
  customer.ts           stripeCustomerId の唯一の読み書き経路
 
lib/credits/
  ledger.ts             書き込み層:残高変更の唯一の通路
  index.ts              読み取り層 + 年額ドリップの決済
 
app/api/
  stripe/webhook  creem/webhook  paypal/webhook    3 つの Webhook ルート
  paypal/create-order  paypal/capture-order        PayPal の買い切り購入
  cron/credits                                     定期実行の入口
 
components/pricing/     PricingSection / PricingCard / CheckoutButton

課金主体:個人とチーム

同じコードが 2 種類の課金主体を扱います。

  • 個人:organization_id が NULL。クレジットは credit_balances に入ります
  • チーム:organization_id に値が入り、クレジットは organization_credit_balances の共有プールに入ります。user_id は「支払者/操作者」に降格します

両者はバケット構造と冪等メカニズムを共有し、異なるのは残高テーブルだけです。いくつかの約束事:

  • チームプランは audience: 'team' で示し、seats がメンバー数の上限を決めます。サブスクリプションがない場合の上限は 1(オーナーのみ)です
  • クレジットパックは個人の資産であり、チームワークスペースでは購入できません(チェックアウトが TEAM_WORKSPACE_CREDIT_PACK を返し、クライアントが個人ワークスペースへの切り替えを促します)
  • 「有効なサブスクリプションを持つか」の判定は個人とチームで互いに独立しており、一方が他方の枠を占めることはありません

3 プロバイダーの機能差

機能StripeCreemPayPal
サブスクリプション✅ Checkout Session✅ Checkout✅ Billing Plan の事前作成が必要
買い切り購入✅ Checkout Session✅ Checkout✅ price から動的に注文を作成
ホスト型セルフサービスポータル✅ Billing Portal✅ Customer Portal❌ PayPal の自動支払いページへ遷移
プロモーションコードの自動適用✅✅ discountCode❌
クーポン管理画面✅❌❌
返金 API✅✅✅
保留中の決済(eCheck)——✅ pending 注文として記録し、定期実行が前進させる
不正検知シグナル✅ Radar✅ dispute✅ reversal

各プランが自身の provider を宣言し、3 つすべてを同時に有効化できます。

次に読む