Menu

注文とサブスクリプション管理

注文

データ構造の要点

orders の 1 行は、お金の動き 1 件です。主要なカラム:

カラム説明
user_id支払者。アカウント削除時に NULL となり、注文自体は残ります(財務記録はアカウントに従いません)
organization_idチーム購入時の受益組織。個人注文では NULL
plan_idconfig/pricing.ts のプラン slug
order_typeone_time / subscription_initial / subscription_renewal / subscription_upgrade
statuspending / completed / partially_refunded / refunded / failed
credits_grantedこの注文で付与されたクレジット
amount_total amount_subtotal amount_discount amount_tax amount_refundedすべて整数のセント
provider + provider_order_id複合ユニーク。これが冪等キーです
provider_payment_id返金を発行する対象の charge / capture / payment intent id

provider_order_id の実際の中身はプロバイダーごとに異なります。

プロバイダーと場面値
Stripe 買い切りcheckout session id
Stripe サブスクリプションinvoice id
Creem 買い切りorder / checkout id
Creem サブスクリプションtransaction id
PayPal 買い切りcapture id
PayPal サブスクリプションsale id

pending と返金、2 つの設計判断

pending の発生源は 1 つだけです。PayPal の eCheck のように受理済みだが未決済のキャプチャです。定期実行タスクが追跡する永続的なローカルの痕跡であり、クレジットは 0、売上にも計上されません。決済されるとその場で completed へ昇格し(クレジットはこの瞬間に付与されます)、拒否されると failed(終端、クレジット 0、監査証跡のみ)になります。

返金は行を追加せず、元の注文行を書き換えます。 amount_refunded に累積し、status を partially_refunded または refunded に変えます。これにより「1 件の支払いに 1 行」という不変条件が保たれ、冪等性もシンプルになります。

照会

ユーザー側(actions/orders/user.ts):

import { getMyOrders } from '@/actions/orders/user'
 
const result = await getMyOrders({ pageIndex: 0, pageSize: 10 })
// プラン名はリクエストのロケールに応じて config/pricing から解決されます

管理側(actions/orders/admin.ts):

import { getAdminOrders } from '@/actions/orders/admin'
 
const result = await getAdminOrders({
  pageIndex: 0,
  pageSize: 20,
  userId,        // 任意。単一ユーザーに限定(ユーザー詳細ページで使用)
  status,        // 任意
  orderType,     // 任意
  provider,      // 任意
  search,        // 任意。ユーザーのメールまたはプラン id への部分一致
})

返金

管理画面 /dashboard/admin/orders の返金ダイアログは refundOrder を呼びます。

import { refundOrder } from '@/actions/orders/admin'
 
await refundOrder({
  orderId,
  amountCents,   // 省略 = 残額の全額返金。値を渡すと部分返金
})

このアクションが行うのはプロバイダーの返金 API を呼ぶこと、ただ 1 つです。ローカルの注文ステータスとクレジット回収は、すべて返金 Webhook の到着時に書かれます。

こう設計することで、管理画面から発行した返金と、プロバイダーのダッシュボードから直接発行した返金がまったく同じコード経路を通ります。実装が二重にならず、両者の状態がずれることもありません。

事前チェック:

  • pending と failed の注文には決済済みのお金がありません(プロバイダー側でも拒否されます)
  • provider_payment_id のない注文は返金できません
  • すでに全額返金済みの注文は拒否されます
  • 部分返金の金額は返金可能な残額を超えられません

サブスクリプション

データ構造の要点

カラム説明
status正規化された統一ステータス。後述
intervalmonth / year
current_period_start / current_period_end現在の周期
cancel_at_period_end期末解約が予約されているか
canceled_at / ended_at解約日時 / 実際の終了日時
monthly_credits / remaining_grants / next_grant_at年額ドリップの計画(クレジットシステムを参照)
provider + provider_subscription_id複合ユニーク
organization_idチームサブスクリプションの組織。組織削除時は RESTRICT —— 契約履歴のある組織は物理削除できません

ステータス

データベースに保存されるのは、この正規化された集合だけです。

active | trialing | past_due | unpaid | canceled | incomplete | expired | paused

重要な 2 つの部分集合:

// 「サブスクリプションを保持している」とみなし、権利が継続する
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']
 
// クレジットの付与を受けられる
GRANTABLE_STATUSES = ['active', 'trialing']

督促中(past_due / unpaid)は権利を保ちますが、新たな付与は受けません。

canceled と expired は終端で、決して復活しません —— 再契約は新しいサブスクリプション ID と新しい行を生みます。

ステータス同期

サブスクリプションのステータスは Webhook で同期され、ポーリングは不要です。syncSubscription はステータスのみを扱い、クレジットやドリップのカウンタには触れません(それらは決済フルフィルメントの担当です)。また、順序が乱れた Webhook による終端状態の書き戻しも拒否します。

失われた Webhook は定期実行の reconcileOverdueRenewals が補償します。

セルフサービスポータル

ユーザー側の入口は actions/billing/portal.ts です。

import { createCustomerPortalSession } from '@/actions/billing/portal'
 
const result = await createCustomerPortalSession()
// result.data.url へ遷移

現在のワークスペースの有効なサブスクリプションを探し、そのサブスクリプションのプロバイダーで振り分けます。

プロバイダー遷移先
StripeBilling Portal(カード変更、請求書閲覧、解約、プラン変更)
CreemCreem Customer Portal
PayPalPayPal の自動支払いページ(PayPal にホスト型ポータルはありません)

有効なサブスクリプションがない場合は、ユーザーの Stripe 請求履歴(カード変更、請求書閲覧)へフォールバックします。

チームポータルの制限

チームサブスクリプションは購入者個人の customer に紐づきます。つまり、セッションを持つメンバーなら原理的には誰でもポータルを開き、登録カードを見たり、解約したりできてしまいます。

そのため、サーバー側で強制される制限があります。チームワークスペースでは、サブスクリプションの購入者本人だけがポータルを開けます。 この検証は UI でボタンを隠すのではなく、サーバー側で行われます。

プラン変更

Stripe のプラン変更は Billing Portal 内で行い、事前に Stripe ダッシュボードで切り替え可能な製品を設定しておく必要があります。設定方法とクレジットの計算についてはサブスクリプション変更を参照してください。

クーポンコンソール

管理画面のルート:/dashboard/admin/coupons。

Stripe が唯一のストアであり、ローカルにテーブルもマイグレーションも作りません。1 クーポン = Stripe Coupon(割引、適用範囲、課金期間)+ Stripe Promotion Code(顧客向けコード、利用回数上限、有効期限、制限条件)。

コードは作成後に変更できません。この制約が UI 全体を形づくっています。

  • 編集できるのは有効/無効のスイッチだけ
  • ほかを変えたい場合は「複製してから編集」
  • 作成時には Stripe に対してコードの空き状況をリアルタイムで確認します

作成フォーム、ステータス遷移、スキャン上限などの詳細はクーポンコンソールを参照してください。顧客側の自動適用ロジックは actions/billing/checkout.ts にあり、config/pricing.ts に設定された promotionCode を読みます(料金設定を参照)。

注意

Stripe のプロモーションコードは Stripe の製品にしか限定できないため、この機能は provider: 'stripe' のプランにのみ有効です。Creem は独自の discountCode を使い、PayPal は非対応です。

ページ一覧

ユーザー側

ルート内容
/dashboard/billing2 つのクレジットバケット、現在のプラン、購入履歴とクレジット台帳(2 つのテーブルは独立してページング)。チームワークスペースでは上部バナーがチームページへ誘導
/dashboard/teamサブスクリプション、共有クレジットプール、メンバーとシート、チームのクレジット台帳の 4 カード

/dashboard/subscription と /dashboard/credit-history は v4.0.0 で /dashboard/billing に統合されました。

管理側

ルート内容
/dashboard/admin/overview運用ダッシュボード:純収益、MRR、新規登録、クレジット消費、トレンドチャート、要対応リスト、直近の注文
/dashboard/admin/orders注文一覧(ユーザー/ステータス/種別/プロバイダーで絞り込み、メールやプランで検索)+ 返金
/dashboard/admin/credits全体のクレジット台帳の監査 + メール指定の一括付与
/dashboard/admin/couponsクーポンコンソール
/dashboard/admin/usersユーザー一覧 + 登録メールのブロックリスト
/dashboard/admin/users/[userId]単一ユーザーの 360 度ビュー:プロフィール、残高、サブスクリプション、累計統計と履歴。クレジットの手動調整に対応

Overview の集計基準

数字を読む前に、その算出方法を把握してください。

  • 金額はすべて整数のセント
  • 純収益 = 総額 − 返金。pending と failed は売上に計上しません
  • 基準通貨は料金設定で最初に有効なプランの通貨(基準外通貨の注文は「要対応」に現れます)
  • 新規登録は匿名アカウントを除外します
  • クレジット消費 = usage − usage_refund
  • MRR = active + past_due のサブスクリプションを設定上の月額レートで換算

時間ウィンドウは ?range=7d|30d|90d で制御され、UTC の暦日で区切られます。5 つのセクションはそれぞれの Suspense 境界で並列に読み込まれるため、1 つのセクションの失敗がページ全体を巻き込むことはありません。

よくある質問

金額が整数なのはなぜですか?

浮動小数点誤差を避けるため、常に整数のセントです。表示時に 100(またはその通貨の最小単位)で割ってください。

ユーザーがアカウントを削除すると注文は消えますか?

消えません。user_id が NULL になり、行は残ります。クレジット台帳も同様です —— 削除は非識別化であって消去ではありません。そうしないと売上や消費の集計が遡って縮んでしまいます。

返金後にクレジットが全額回収されなかったのはなぜですか?

回収は 0 で打ち止めになります。ユーザーが既に使ったクレジットが残高をマイナスにすることはありません。

コードから残高を直接更新できますか?

できません。残高は lib/credits/ledger.ts を通じてのみ変更されます。それ以外の方法では台帳と残高が乖離します。