AI 開発 SaaS の課金を中央集約した — Billing Control Plane を分離して、5 プロダクトでクレジット共有する設
はじめに
AI 系 SaaS を 5 つ並行で立ち上げると、プロダクトごとに billing を実装するのが急速に苦しくなる 局面が来ます。
筆者の Corevice では Codens という AI 開発スイートの中で、Red(自動修復)/ Blue(QA)/ Green(PRD)/ Yellow(エンジニア活動集計)/ Purple(オーケストレーション)の 5 プロダクトを並列で運用しています。最初は各プロダクトに同じ Stripe + クレジット台帳のコードを散らしていたのですが、
- 価格改訂のたびに 5 箇所のコードを直す必要がある
- 顧客が「Red と Blue でクレジット共有したい」と言ったら全プロダクト横断で動く仕組みがない
- Anthropic API のモデル別課金体系の変更を追従するのが困難
…とすぐに行き詰まり、Billing Control Plane (BCP) という別サービスに集約しました。本記事はその設計の中身を共有します。
なぜ「コントロールプレーン」と呼ぶか
AWS の VPC や Kubernetes の API Server のように、「データを読み書きする普通のサービス(data plane)」とは別レイヤーに、「他のすべてのサービスがクレジット消費の判定を仰ぐ場所」を独立して立てます。これが BCP。
┌─────────────────────────────────────────────────────┐
│ Red / Blue / Green / Yellow / Purple │
│ (data planes — 各プロダクトの本来のロジック) │
└────────────────┬────────────────────────────────────┘
│ HTTP /api/v1/credits/consume
▼
┌─────────────────────────────────────────────────────┐
│ Billing Control Plane (BCP) │
│ - org_credit balance │
│ - rate_card (Anthropic API tier に追従) │
│ - idempotency_keys (重複請求防止) │
│ - outbox_events (downstream propagation) │
└────────────────┬────────────────────────────────────┘
│ Stripe API / Postgres SELECT FOR UPDATE
▼
Stripe ←→ Aurora (organization_credits)
各プロダクトは Anthropic API を呼ぶ前に BCP に「この API call は consume 可能か?」と HTTP で問い合わせます。BCP が承認したら balance を引いて、API call を返す。
中核 1: RateCard JSONB
BCP の rate_card は Postgres の JSONB で持ちます。コードに料金を埋め込まない。
# domain/entities/rate_card.py
@dataclass
class RateCard:
effective_from: datetime
effective_to: datetime | None
rates: dict # JSONB
# ...
def get_input_token_rate(self) -> Decimal:
return Decimal(str(self.rates.get("input_token_rate", 0)))
def get_output_token_rate(self) -> Decimal:
return Decimal(str(self.rates.get("output_token_rate", 0)))
def get_model_multiplier(self, model_name: str | None) -> Decimal:
if not model_name:
return Decimal("1")
by_model = self.rates.get("by_model") or {}
mult = by_model.get(model_name)
return Decimal(str(mult)) if mult else Decimal("1")
実際の DB には次のような JSON が 1 行入っています:
{
"input_token_rate": "1.5",
"output_token_rate": "7.5",
"by_model": {
"claude-haiku-4-5": "0.2",
"claude-sonnet-4-6": "1.0",
"claude-opus-4-7": "5.0"
},
"minimum_charge": "0"
}
なぜ JSONB なのか
最初は素直に rate_card_models テーブルを切ろうかと思いました。が、
- Anthropic はモデルを 数ヶ月ごとに追加・置き換える(Sonnet 4 → 4.5 → 4.6 みたいに)
- そのたびに ALTER TABLE するのは現実的ではない
- 過去の rate card は monotonically immutable(履歴管理) で持ちたい
…と考えると、JSONB に丸投げして effective_from/effective_to で時系列管理するのが圧倒的に楽でした。rate_card テーブルは行を追加するだけで価格改定でき、過去の取引は当時の rate_card を参照すれば再計算可能。
中核 2: 価格計算 = _price()
各プロダクトから consume_credit API が叩かれた瞬間、BCP は次の関数で credit 消費量を決めます。
# use_cases/billing/consume_credit_use_case.py 抜粋
async def _price(self, req: ConsumeCreditRequest) -> Decimal:
"""Lookup current rate card and compute required credits.
Per-model multiplier scales both input and output rates so revenue
tracks Anthropic's price tier (e.g. opus-4-7 @ 5.0x = ~5x sonnet API
cost, restoring the configured markup).
"""
card = await self.rate_card_repo.get_current()
if card is None:
raise NotFound("no active rate card")
multiplier = card.get_model_multiplier(req.model_name)
in_cost = (
Decimal(req.input_tokens) * card.get_input_token_rate() * multiplier
).quantize(Decimal("0.01"))
out_cost = (
Decimal(req.output_tokens) * card.get_output_token_rate() * multiplier
).quantize(Decimal("0.01"))
total = in_cost + out_cost
minimum = Decimal(str(card.rates.get("minimum_charge", 0)))
if total < minimum:
total = minimum
return total
Decimal を最初から最後まで通す のが地味に重要です。途中で float に落とすと、丸め誤差で「請求額がずれる」「1 円ずれて顧客が問い合わせ」みたいな恐怖が起きる。quantize(Decimal("0.01")) で円単位に丸めるのも明示的に。
中核 3: Idempotency
各プロダクトは Anthropic API 呼び出しの前後で BCP に consume を投げます。ところが、Celery のリトライや一時的なネットワーク障害で 同じリクエストが 2 回届く ことがある。これを 2 回引き落とすと顧客に被害が出ます。
# use_cases/billing/consume_credit_use_case.py 冒頭
payload_hash = compute_payload_hash({
"service_id": req.service_id,
"operation_type": req.operation_type,
"input_tokens": req.input_tokens,
"output_tokens": req.output_tokens,
"model_name": req.model_name,
})
outcome, idem_key = await self.idempotency.acquire(
auth_org_id=req.auth_org_id,
service_id=req.service_id,
idempotency_key=req.idempotency_key,
payload_hash=payload_hash,
)
if outcome == IdempotencyOutcome.REPLAY:
# 過去の結果を返す ── 課金しない
cached = idem_key.response_payload
return ConsumeCreditResult(
transaction_id=UUID(cached["transaction_id"]),
credits_consumed=Decimal(str(cached["credits_consumed"])),
is_replay=True,
...
)
クライアントが渡す idempotency_key (UUID) と、payload の hash を組み合わせて一致したら 以前の結果を返す。balance はもう引いてあるので二重消費しない。
payload_hash を入れているのは「同じ idempotency_key で異なる内容を投げてきた」場合の検知用。これは攻撃の可能性があるので明示的に弾きます。
中核 4: Outbox Pattern
クレジット消費が成功したら、Stripe の Subscription の usage_record に同期する必要があります。が、BCP の DB トランザクション内で Stripe API を呼ぶのは危険:
- Stripe が遅い → DB ロック保持時間が伸びる → 他のリクエストが詰まる
- Stripe API が一時的に落ちる → クレジット消費は成功したのに Stripe 反映に失敗 → ロールバック?
- ロールバックすると、すでに Anthropic に投げた API 呼び出しは取り戻せない
これを避けるため、Outbox Pattern を採用:
┌─ Atomic Transaction in Postgres ──────────┐
│ 1. UPDATE org_credit SET balance -= N │
│ 2. INSERT credit_transactions (...) │
│ 3. INSERT outbox_events (event_type=...) │ ← これを足す
└───────────────────────────────────────────┘
│
▼
(別の Celery worker が outbox を読んで Stripe へ propagation)
outbox_events テーブルに「Stripe に何を送るべきか」を 1 行書くだけ。実際の Stripe 呼び出しは別 worker が拾って実行する。Stripe が落ちてても Outbox は溜まり続けるので、復旧後に消化できる。
5 プロダクトでクレジット共有する仕組み
各プロダクトは BCP の auth_org_id を共有します。たとえば顧客の Acme Corp が:
- Red Codens で Sentry エラー自動修正 → 30K credits 消費
- Blue Codens で E2E テスト生成 → 50K credits 消費
- Green Codens で PRD 自動生成 → 12K credits 消費
これらが 同一の organization_credits.balance から引かれる。月初に 1M credits 購入 → 月末に残り 908K、みたいな。
各プロダクトの billing client(billing_plane_client.py)は次のような薄い HTTP クライアントです:
# infrastructure/external_apis/billing_plane_client.py 抜粋(各プロダクト共通)
class BillingPlaneClient:
async def consume(
self,
org_id: UUID,
service_id: str, # "red" / "blue" / ...
operation_type: str, # "fix_generation" / "test_generation" / ...
input_tokens: int,
output_tokens: int,
model_name: str, # "claude-sonnet-4-6"
idempotency_key: str,
) -> dict:
return await self._http.post("/api/v1/credits/consume", json={...})
各プロダクトは「自分が何 token 使ったか」だけ報告し、credit 換算は BCP に任せる。これで:
- 価格改定は BCP の rate_card 1 箇所
- モデル乗算規則変更も同様
- Stripe 連携もBCP のみ
- 各プロダクトは課金ロジックを 1 行も持たない
学び
実装してみての学び 3 つ:
-
price card は コードではなく DB に置く。コードに
INPUT_TOKEN_RATE = 1.5みたいに書くと、Anthropic の改訂のたびに 5 プロダクトのコードを直す必要がある。JSONB + 時系列管理が運用上はるかに楽。 -
Idempotency と Outbox は最初から仕込む。「あとで足す」は無理。Idempotency が無い状態で 1 度でも Celery のリトライが起きると顧客被害が出る。Outbox が無い状態で Stripe が落ちると整合性が崩れる。両方とも初日に入れた方が良い。
-
Decimal を最後まで通す。
floatで計算すると、ある日突然 1 円ずれて顧客から「請求書の合計が合わない」と問い合わせが来る。Python ならDecimal、Postgres ならnumeric。早期に統一する。
おわりに
この BCP 構成を本番で動かしているのが、私たち Corevice の Codens スイートです。Anthropic API への markup 内訳と rate card は LP で公開していて、token 単位で計算できます: https://www.codens.ai/
本日 (2026-04-29) 23:00 JST、Hacker News に Show HN で出します。「token 単位で透明な価格表記の AI 開発スイート」が英語圏 dev コミュニティでどう評価されるか、結果を見届けます。
Discussion