🎃

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 つ:

  1. price card は コードではなく DB に置く。コードに INPUT_TOKEN_RATE = 1.5 みたいに書くと、Anthropic の改訂のたびに 5 プロダクトのコードを直す必要がある。JSONB + 時系列管理が運用上はるかに楽。

  2. Idempotency と Outbox は最初から仕込む。「あとで足す」は無理。Idempotency が無い状態で 1 度でも Celery のリトライが起きると顧客被害が出る。Outbox が無い状態で Stripe が落ちると整合性が崩れる。両方とも初日に入れた方が良い。

  3. 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