クレジットシステム
クレジットシステムは lib/credits/ です。ファイルは 2 つだけで、ledger.ts(書き込み)と index.ts(読み取り + 年額ドリップの決済)です。
2 バケットモデル
課金主体はそれぞれ 2 つのバケットを持ちます。
| バケット | カラム | 意味 |
|---|---|---|
| サブスクリプションクレジット | subscription_credits | 付与のたびにプランの月次枠へリセットされ、繰り越しません。サブスクリプション終了時にクリアされます |
| 購入クレジット | purchased_credits | クレジットパックの購入、登録ボーナス、管理者付与がここへ入ります。無期限で、消費されるか返金時に回収されるだけです |
消費はサブスクリプションバケットから先に引かれます。失効するのはこちらだからです —— 消えるものから使い、消えないものを残します。
なぜサブスクリプションクレジットは累積ではなくリセットなのか
累積すると無制限に積み上がり、「月次の供給枠」という意味が失われます。返金やダウングレードの計算も悪夢になります。リセットのセマンティクスなら、1 課金周期で使える量が確定します。
このセマンティクスは、決済フローの「有効なサブスクリプションは 1 つ」というガードの理由でもあります。並行する 2 つのサブスクリプションは互いのクレジットをリセットしてしまいます。
リセットは純増分 1 行ではなく台帳 2 行を書く
付与時には台帳に2 行書きます。
subscription_cycle_expire 使い切らなかった残高が失効(バケットが空ならこの行は書かない)
subscription_grant 満額を付与純増分の 1 行だけだと、ユーザーが 60 残していて 100 付与のプランの場合に「+40」と表示され、付与が足りないように読めてしまいます。2 行構造なら「いくら失効したか」と「いくら付与されたか」がそれぞれ明確になります。
追記専用台帳
残高テーブルを直接 UPDATE することはありません。すべての変更は lib/credits/ledger.ts の単一プリミティブ mutateCredits を通り、1 トランザクション内で次を行います。
主体で振り分け → 行ロック → 残高を upsert → バケット別の増減を計算 → スナップショット付きで台帳に 1 行追記credit_transactions の主要カラム:
| カラム | 説明 |
|---|---|
seq | 単調増加する挿入順。created_at だけでは同一トランザクション内に書かれた複数行を順序づけられない(Postgres の now() はトランザクション内で固定)ため、履歴クエリはこれで同点を解消します |
amount | 符号付きの総額。正が付与、負が消費・回収 |
subscription_delta / purchased_delta | バケット別の増減。合計が amount に一致します |
subscription_credits_after / purchased_credits_after | 変更後のスナップショット。監査時に再生が不要になります |
refunded_transaction_id | usage_refund 行にのみ設定され、取り消し対象の消費行を指します。UNIQUE —— 1 件の消費は最大 1 回しか払い戻せません |
台帳の種別
| 種別 | 発生場面 |
|---|---|
purchase | クレジットパックの購入 |
subscription_grant | サブスクリプションの定期付与(年額ドリップ、アップグレード増分を含む) |
subscription_cycle_expire | 新しい付与がバケットをリセットする際、未使用分が失効 |
usage | 機能の消費 |
usage_refund | 失敗したタスクが消費を元のバケットへ返却 |
refund_revoke | 返金後のクレジット回収 |
subscription_end_revoke | サブスクリプション終了時に残りのサブスクリプションクレジットをクリア |
admin_adjustment | 管理者による手動の増減 |
signup_bonus | 登録時のウェルカムクレジット |
不変条件
- 残高がマイナスになることはない。 回収系の操作(
refund_revoke、負のadmin_adjustment)は 0 で打ち止めになります - 何も起きない操作は台帳に書かない。 遷移関数が
nullを返す場合(すでに空のサブスクリプションバケットをクリアしようとした場合など)、残高も台帳も触りません
年額サブスクリプションの月次ドリップ
年額プランは 12 か月分を一度に付与しません。
支払い時 初月を即時付与し、残り 11 回をサブスクリプション行へ書く
以降毎月 期日が来たら定期実行が 1 回分を決済スケジュールは jsonb の中のブラックボックスではなく、subscriptions テーブルの一級のカラムです。
| カラム | 意味 |
|---|---|
monthly_credits | 1 回あたりの付与量 |
remaining_grants | 残りの付与回数 |
next_grant_at | 次回の付与時刻(UTC) |
データベースがスケジューリングキューそのものであり、next_grant_at には部分インデックスが張られ、定期実行が直接スキャンします。
付与日の計算方法
k 回目の付与 = 年額周期のアンカー + k か月(k = 12 - remaining)。月末の丸めは addMonthsClamped が行います。
丸めは必須です。素朴な setMonth は溢れます(1/31 + 1 か月 = 3/3)。さらに、日付は必ずアンカーから導出しなければならず、月ごとに連鎖させてはいけません —— 連鎖させると 31 日が恒久的に 28 日へ削られてしまいます。
31 日に購入した年額サブスクリプションは、2/28、3/31、4/30…… と常に記念日に揃って付与されます。
2 つのトリガー、1 つの入口
| トリガー | タイミング |
|---|---|
定期実行 settleDueDripGrantsBatch | /api/cron/credits が毎分実行。1 ティックあたり最大 100 件、期日の早い順 |
遅延決済 settleDueDripGrants | 残高の読み取りや消費の直前に実行。「数秒前に期日が来たが次のティックがまだ」「定期実行が停止中」をカバー |
どちらも同じプリミティブ settleDripForSubscription に集約されます。行ロックとその下での再チェックにより、並行トリガーは 1 回の付与に畳み込まれます。
数か月分が滞留している場合、リセットのセマンティクスによって1 回のリセットに畳み込まれ、月ごとの偽の履歴を捏造することはありません。
定期実行タスクは必須です
遅延決済がカバーするのは、誰かが読み書きした場合だけです。年額契約のユーザーが 1 か月ログインしなければ、遅延決済だけではその月のクレジットを受け取れません。
/api/cron/creditsの設定は定期実行タスクを参照してください。
機能をクレジットに接続する
ユーザー側の入口は actions/credits/index.ts の consumeCredits です。
import { consumeCredits } from '@/actions/credits'
const result = await consumeCredits({
amount: 10,
note: 'AI image generation', // 必須。台帳に書かれ、請求ページでユーザーに見えます
})
if (!result.success) {
// 残高不足など
return
}
const { subscriptionCredits, purchasedCredits, totalCredits, txId } = result.dataこの関数は 3 つのことを行います。セッションの検証、現在のワークスペースに基づくプールの選択(チームワークスペースなら組織の共有プール)、そして消費前に期日の来た年額ドリップの決済です。
タスク失敗時の払い戻し
txId を取得したあと、タスクが最終的に失敗した場合は refundSpentCredits でクレジットを元のバケットへ返却します。
import { refundSpentCredits } from '@/lib/credits'
await refundSpentCredits({ userId, spendTxId: txId, note: 'Generation failed' })厳守すべき 2 点:
- クライアントから呼べる server action にラップしてはいけません。 ユーザーが任意に払い戻せると、成功したタスクがすべて無料になってしまいます
- 払い戻しは、消費行に記録されたバケット別の内訳をそのまま返します。途中で月次リセットが起きていた場合、サブスクリプションバケットが一時的にプランの枠を超えることがあります —— これは意図的で、ユーザーに有利な側に倒しています。逆に購入バケットへ返すと、意図的に失敗させたタスクで失効するクレジットを恒久クレジットへ洗浄できてしまいます
払い戻しは冪等です。残高の行ロックで直列化され、refunded_transaction_id の UNIQUE 制約により 1 件の消費につき払い戻しは最大 1 回で、再実行は現在の残高をそのまま返します。
完全なサンプル
ボイラープレートにはインタラクティブな参照ページが同梱されています(開発モードのみ)。
/dashboard/credit-usage-example3 つのケース:成功する消費、タスク失敗をシミュレートした払い戻しと冪等な再実行、残高不足時のアップセル導線。コードは app/[locale]/(protected)/dashboard/credit-usage-example/ にあります。
残高の読み取り
| 関数 | 用途 |
|---|---|
getUserCredits(userId) | 個人の 2 バケット |
getOrganizationCredits(orgId) | 組織の共有プール |
getUserBillingSummary(userId) | 残高 + 現在のサブスクリプション(プラン、ステータス、周期終了日、期末解約フラグ、次回ドリップ日) |
getOrganizationBillingSummary(orgId) | 同上の組織版 |
hasEntitledSubscription(userId) | 有効なサブスクリプションを保持しているか |
すべての読み取りは、まず期日の来た年額ドリップを決済します。したがって読み取れる値は常に「受け取るべき残高」です。
対応するユーザー側の server action は actions/credits/index.ts にあります:getMyCredits、getMyBillingSummary、getMyCreditHistory。
「有効なサブスクリプション」の定義
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']督促中(past_due / unpaid)もサブスクリプション保持とみなし、権利は即座には停止しません。チェックアウトのガード、チームのシート計算、ストレージの保持ポリシーがこの定義を共有します —— 一度定義すれば全体で一貫します。
実際に付与を受けられるステータスはより狭く、active と trialing だけです。
チームの共有プール
チームプランのクレジットは organization_credit_balances に入ります。
- 課金主体は組織で、
user_idは「操作者」に降格します - バケット構造と冪等メカニズムは個人と同一で、異なるのは残高テーブルだけです
- 消費時は現在のワークスペースからプールが選ばれるため、業務コードに分岐は不要です
- クレジットパックは個人の資産であり、チームワークスペースでは購入できません
台帳は同じ credit_transactions テーブルで、organization_id の有無で主体を区別します。シート、招待フロー、組織削除ガードについてはチームと組織を参照してください。
登録ボーナスクレジット
config/credits.ts で設定します。
export const creditsConfig = {
/** 登録時に一度だけ付与。0 にすると無効 */
signupBonusCredits: 30,
} as const購入バケットに入るため、その後のサブスクリプションのリセットで消えることはありません。
冪等性は「ユーザーごとに signup_bonus の台帳行は最大 1 行」で担保され、判定と付与は同一トランザクション内で行われるため、再実行は無害です。
管理者の操作
| 入口 | 機能 |
|---|---|
/dashboard/admin/credits | 全体のクレジット台帳の監査(種別での絞り込み、メール/備考での検索)+ メール指定の一括付与 |
/dashboard/admin/users/[userId] | 単一ユーザーの残高と履歴。手動調整に対応 |
一括付与は重複排除され、1 回あたり最大 200 件のメールアドレスまで。一致しなかったアドレスはそのまま返却されます。
手動調整(adjustCredits)のセマンティクス:正の値は購入バケットへ(次回のサブスクリプションリセットで消えないように)、負の値は購入バケットから先に引き、続いてサブスクリプションバケットから引き、全体として 0 で打ち止めになります。
返金時のクレジット回収
返金は Webhook 駆動です(管理画面の返金ボタンはプロバイダー API を呼ぶだけで、ローカルの状態とクレジット回収は Webhook の到着時に書かれます)。
applyRefund は返金額に比例してクレジットを回収し、0 で打ち止めにします —— 既に使われたクレジットが残高をマイナスにすることはありません。詳細は注文とサブスクリプション管理を参照してください。