FastAPIで実践するSaaS向けプラン・請求・機能制限設計入門:無料版・有料版・契約状態を安全に扱う実務パターン
要約
- SaaSのプラン設計は、単に「月額料金を分ける」ことではありません。どの契約で、どの機能が、どの範囲まで使えるかを、アプリ全体で一貫して扱えるようにすることが本質です。
- FastAPIでは、認証・テナント・RBAC/ABACの仕組みに加えて、プラン情報・契約状態・利用制限を依存関数やポリシー関数として整理すると、設計が崩れにくくなります。
- 重要なのは、
planだけで判断しないことです。実務では、契約状態(active / trialing / past_due / canceled など)、アドオン、上限値、猶予期間、請求失敗時の扱いまで含めて設計する必要があります。 - 機能制限は、UIでボタンを隠すだけでは不十分です。最終的な判定は必ずバックエンド側で行い、監査ログやイベントログにも残せる形にしておくと運用しやすくなります。
- 本記事では、FastAPIでのプランモデル設計、機能フラグ、利用上限、請求状態の扱い、Webhook連携を前提とした状態遷移、テスト戦略までを段階的に整理します。
誰が読んで得をするか
個人開発・学習者さん
「無料プランと有料プランを分けたい」「CSV出力は有料だけにしたい」と考え始めた方に向いています。
最初は単純に if user.plan == "pro" のように書きたくなりますが、機能が増えるとすぐに散らかります。この記事では、そこから一歩整理された設計へ進むための土台をお渡しします。
小規模チームのバックエンドエンジニアさん
FastAPIでSaaSを作っていて、テナントごとにプランがあり、機能制限や利用上限が少しずつ増えてきたチームに向いています。
「どの層で判定するか」「請求失敗中は何を止めるか」「トライアル終了後はどうするか」といった運用寄りの論点を、実装へ落とし込みやすい形で整理できます。
SaaS開発チーム・スタートアップの皆さま
複数プラン、アドオン、従量課金、猶予期間、請求失敗、休眠復旧などが現実の課題になってきたチームに向いています。
課金システムそのものよりも、アプリケーションが契約状態をどう安全に反映するかという視点を中心に、後から壊れにくい設計パターンを確認できます。
アクセシビリティ評価
- 最初に全体像を示し、その後に「用語整理」「データモデル」「機能制限」「請求状態」「Webhook」「テスト」という順で進めています。途中参加でも流れを追いやすい構成です。
- 専門用語は初出で短く補足し、以降は同じ表現を使って認知負荷を下げています。
- コード例は短い責務ごとに分割し、1ブロックに多くを詰め込みすぎないようにしています。
- 目標レベルはAA相当です。
1. SaaSのプラン設計は「料金表」ではなく「権限制御」である
SaaSを作り始めると、最初に見えるのは料金ページです。
無料プラン、Proプラン、Enterpriseプランのように並べたくなりますし、マーケティング的にもそこが目立ちます。
ただ、バックエンド側の本質は、料金の表示ではありません。
実際に大切なのは、そのテナントが今どの契約状態にあり、何をどこまで使えるのかを、安全に判定することです。
たとえば、同じ「Proプラン」でも、次のような差があり得ます。
- トライアル中
- 正常課金中
- 支払い失敗で猶予期間中
- 解約予約済み
- 今月末で停止予定
- アドオンで追加容量だけ買っている
このような現実を踏まえると、単純な plan == "pro" 判定では足りません。
そのため、設計の中心には次のような情報が必要になります。
- プラン種別
- 契約状態
- 有効期限
- 利用上限
- アドオン
- 一時的な例外や特別契約
この記事では、この全体を FastAPI の依存関数やポリシーにどう落とし込むかを見ていきます。
2. 先に整理したい用語:プラン、サブスクリプション、契約状態、機能
話を混ぜないために、まず用語を整理します。
プラン
プランは、提供する機能セットの「商品名」に近い概念です。
例:
freestarterproenterprise
プランは比較的静的で、料金ページにも出てくる分類です。
サブスクリプション
サブスクリプションは、「あるテナントが今どのプランを契約しているか」という契約実体です。
プランそのものではなく、契約中の状態を持つレコードです。
契約状態
契約状態は、請求・有効性・停止の状況を示します。
例:
trialingactivepast_duecanceledpausedexpired
実務では、機能の利用可否はプラン名よりも契約状態の影響を強く受けることがあります。
機能
機能とは、ユーザーが実際に使うアプリ上の能力です。
例:
- プロジェクト作成
- CSVエクスポート
- APIアクセス
- Webhook設定
- 監査ログ閲覧
- 保存容量の上限
- メンバー数上限
この「機能」をどう表現するかが、アプリ側の設計の肝になります。
3. 最初に決めるべき設計方針:プラン名直書きを避ける
ありがちな初期実装は次のようなものです。
if tenant.plan == "pro":
# CSVエクスポート可
最初はとても分かりやすいです。
でも、少し時間が経つと、すぐに次のような問題が出ます。
starterでもCSVを解放したくなった- EnterpriseではCSVに加えて監査ログも見せたい
- 特定顧客だけ例外的にCSVを使える
- トライアル中だけ一部機能を開放したい
- 支払い失敗中はCSVだけ止めたい
すると、各所に if tenant.plan in {...} が増え、後から追いきれなくなります。
そのため、最初から次の方針をおすすめします。
- プラン名は商品ラベルとして扱う
- 実際の機能判定は「機能キー」で行う
- 利用上限は数値として別管理する
- 契約状態はプランとは独立に扱う
この設計にしておくと、後からプランを組み替えても、アプリ側の変更が少なく済みます。
4. 基本データモデル:Tenant・Subscription・PlanFeature を分ける
まずは、最低限の概念モデルを作ってみます。
4.1 テナント
テナントは顧客組織そのものです。
# app/models/tenant.py
from pydantic import BaseModel
class Tenant(BaseModel):
id: int
name: str
is_active: bool = True
4.2 サブスクリプション
契約実体は別で持ちます。
# app/models/subscription.py
from pydantic import BaseModel
from typing import Literal
from datetime import datetime
PlanCode = Literal["free", "starter", "pro", "enterprise"]
SubscriptionStatus = Literal[
"trialing",
"active",
"past_due",
"paused",
"canceled",
"expired",
]
class Subscription(BaseModel):
tenant_id: int
plan_code: PlanCode
status: SubscriptionStatus
current_period_end: datetime | None = None
cancel_at_period_end: bool = False
trial_ends_at: datetime | None = None
4.3 プラン機能
プランと機能の対応は、別テーブルまたは設定として持ちます。
# app/models/plan_feature.py
from pydantic import BaseModel
class PlanFeature(BaseModel):
plan_code: str
feature_key: str
enabled: bool = True
limit_value: int | None = None
この分け方にしておくと、pro プランに新しい機能を追加したい場合でも、プランと機能の対応だけ更新すれば済みます。
5. 機能キーを定義する:人間向け名称より機械向け名称を先に固める
機能制限を実装するなら、まず「何を制限するのか」を機械的に表現する必要があります。
たとえば、次のようなキーです。
project.createproject.export_csvaudit_log.viewapi.accesswebhook.createmember.max_countstorage.max_bytes
ここで大切なのは、真偽値の機能と上限値の機能を分けて考えることです。
真偽値の機能
- 使える / 使えない
例:
audit_log.viewproject.export_csv
上限値の機能
- 何件まで、何人まで、何GBまで
例:
member.max_count = 5storage.max_bytes = 1073741824
この区別がないと、後からとても扱いづらくなります。
6. FastAPIでの基本方針:契約情報を依存関数で解決する
FastAPIでは、認証済みユーザーやテナント情報と同じように、現在の契約情報も依存関数で取得すると整理しやすいです。
6.1 現在のテナントを取る
前回までの記事とつながる形で、現在のテナントIDを解決済みとします。
# app/deps/tenant.py
def get_current_tenant_id() -> int:
return 10
6.2 現在のサブスクリプションを取る
# app/deps/subscription.py
from fastapi import Depends, HTTPException, status
from app.deps.tenant import get_current_tenant_id
from app.models.subscription import Subscription
def get_current_subscription(
tenant_id: int = Depends(get_current_tenant_id),
) -> Subscription:
# 実際はDBやキャッシュから取得
if tenant_id == 10:
return Subscription(
tenant_id=10,
plan_code="pro",
status="active",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="subscription not found",
)
これで、各ルーターやサービス層は「現在テナントの契約情報」を一貫して参照できるようになります。
7. 機能判定を関数化する:has_feature() と get_limit()
アプリ中にプラン名を散らさないために、機能判定関数を用意します。
# app/services/plan_service.py
from app.models.subscription import Subscription
FEATURE_MATRIX = {
"free": {
"project.export_csv": False,
"audit_log.view": False,
"api.access": False,
"member.max_count": 3,
"storage.max_bytes": 100 * 1024 * 1024,
},
"pro": {
"project.export_csv": True,
"audit_log.view": True,
"api.access": True,
"member.max_count": 20,
"storage.max_bytes": 5 * 1024 * 1024 * 1024,
},
"enterprise": {
"project.export_csv": True,
"audit_log.view": True,
"api.access": True,
"member.max_count": 9999,
"storage.max_bytes": 100 * 1024 * 1024 * 1024,
},
}
# app/services/plan_service.py(続き)
def has_feature(subscription: Subscription, feature_key: str) -> bool:
plan_features = FEATURE_MATRIX.get(subscription.plan_code, {})
value = plan_features.get(feature_key, False)
return bool(value) if isinstance(value, bool) else True
def get_limit(subscription: Subscription, feature_key: str) -> int | None:
plan_features = FEATURE_MATRIX.get(subscription.plan_code, {})
value = plan_features.get(feature_key)
return value if isinstance(value, int) else None
この実装はあくまで最小例ですが、重要なのは判定ロジックを一箇所へ寄せることです。
8. 契約状態も必ず判定に含める:activeだけを使える状態としない
プラン判定でよく抜けるのが、契約状態です。
たとえば pro プランでも、状態が past_due なら「使えてよい機能」と「止めるべき機能」が分かれるかもしれません。
そこで、has_feature() だけではなく、使える状態かどうかを別で見ます。
# app/services/subscription_policy.py
from app.models.subscription import Subscription
ACTIVE_LIKE = {"trialing", "active"}
def is_subscription_usable(subscription: Subscription) -> bool:
return subscription.status in ACTIVE_LIKE
でも、実務では past_due を即停止しないことも多いです。
たとえば請求失敗から3日間は猶予し、その間は閲覧だけ可能、更新系は停止、などの運用があり得ます。
そのため、次のように「操作ごと」に状態判定を分ける設計が便利です。
# app/services/subscription_policy.py
def can_use_read_features(subscription: Subscription) -> bool:
return subscription.status in {"trialing", "active", "past_due"}
def can_use_write_features(subscription: Subscription) -> bool:
return subscription.status in {"trialing", "active"}
def can_use_export_features(subscription: Subscription) -> bool:
return subscription.status in {"active"}
このようにしておくと、請求失敗時の扱いを柔軟に変えられます。
9. 依存関数で「機能必須」を表現する
FastAPIらしく、必要な機能を依存関数にすると、ルーターで意図が読みやすくなります。
9.1 機能フラグ型の制限
# app/deps/plan_permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.subscription import get_current_subscription
from app.services.plan_service import has_feature
from app.services.subscription_policy import can_use_write_features
def require_csv_export_feature(
subscription = Depends(get_current_subscription),
):
if not can_use_write_features(subscription):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="subscription is not usable for export",
)
if not has_feature(subscription, "project.export_csv"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="csv export is not available for this plan",
)
return subscription
9.2 ルーターで使う
# app/api/v1/routers/projects.py
from fastapi import APIRouter, Depends
from app.deps.plan_permissions import require_csv_export_feature
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("/export")
def export_projects_csv(
subscription = Depends(require_csv_export_feature),
):
return {"status": "export started", "plan": subscription.plan_code}
これで、「CSVエクスポートにはプラン条件がある」という意図がルーターから読み取れます。
10. 上限値の制限:件数・容量・席数を安全に扱う
真偽値の機能だけでなく、件数や容量の制限もSaaSではとても重要です。
10.1 メンバー数上限の例
たとえば「free は3人まで」「pro は20人まで」としたい場合、追加前に上限確認を行います。
# app/services/member_policy.py
from app.models.subscription import Subscription
from app.services.plan_service import get_limit
def can_add_member(subscription: Subscription, current_member_count: int) -> bool:
limit = get_limit(subscription, "member.max_count")
if limit is None:
return True
return current_member_count < limit
10.2 サービス層で使う
# app/services/member_service.py
from fastapi import HTTPException, status
from app.models.subscription import Subscription
from app.services.member_policy import can_add_member
def invite_member(subscription: Subscription, current_member_count: int):
if not can_add_member(subscription, current_member_count):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="member limit reached",
)
return {"status": "invited"}
10.3 容量制限の例
ファイルアップロード記事とも相性が良いですが、容量制限も同じ考え方です。
- 今の使用量を集計
- 追加予定サイズを足す
storage.max_bytesを超えるなら拒否
この判定をアップロードのサービス層へ入れることで、プランとの整合が保てます。
11. アドオン設計:プランだけで表現しきれない現実に備える
現実のSaaSでは、次のような「追加購入」がよくあります。
- メンバー追加パック
- 保存容量追加
- 監査ログ延長保存
- APIアクセス枠追加
- Webhook数追加
この場合、プランだけでは表現しきれません。
そのため、subscription に加えて addons を持つ設計が役立ちます。
# app/models/addon.py
from pydantic import BaseModel
class Addon(BaseModel):
tenant_id: int
addon_key: str
quantity: int = 1
たとえば、storage.max_bytes の最終値を次のように計算します。
- プラン基本値
- アドオン加算分
- キャンペーンや例外契約による上書き
これを effective_limit() のような関数へ集約しておくと、後から変更しやすいです。
12. トライアル設計:最初から考えておくと後が楽
トライアルは売上にも影響するので、最初から整理しておく価値があります。
よくある方針
- トライアル中は
pro相当の機能を一部または全部開放 trial_ends_atを過ぎたらexpiredまたはfree相当へ移行- トライアル終了後に自動課金するか、手動アップグレード待ちかを明確にする
実務で大切なのは、トライアル中の判定を本番プラン判定と別ロジックにしないことです。
おすすめは、status="trialing" を持つ通常のサブスクリプションとして扱い、可否判定関数で吸収する方法です。
13. Webhook前提の状態遷移:課金イベントをどうアプリへ反映するか
SaaSでは、決済や請求システムからのWebhookで契約状態が変わることが多いです。
ここで重要なのは、「外部イベントを受け取ったら、アプリ内の Subscription を更新する」という責務分離です。
13.1 典型的なイベント
- 契約作成
- トライアル開始
- 更新成功
- 請求失敗
- 支払い確定
- 解約予約
- 即時解約
- プラン変更
13.2 アプリ内では「正」となる状態を持つ
外部サービスのレスポンスを毎回リアルタイム照会するのではなく、アプリ内に Subscription の現在状態を持つ方が安定します。
理由は次のとおりです。
- APIレスポンスが速くなる
- 一時的な外部障害でも判定できる
- テストしやすい
- 監査ログと状態変化を結びつけやすい
そのため、Webhook受信処理では次のような流れを作ります。
- イベントを受信
- 正当性を検証
Subscriptionを更新- 必要なら監査ログやドメインイベントを発行
14. 請求失敗時のUX設計:全部止めるか、段階的に止めるか
請求失敗は、プロダクト体験と売上の両方に影響します。
ここを曖昧にすると、現場が混乱しやすいです。
よくある段階的制御
past_dueになった直後- 閲覧は可、更新は不可
- 猶予期間が切れた
- ログインは可、主要機能は不可
- 完全停止
- 契約管理画面以外は大きく制限
この設計をFastAPI側に反映するには、先ほどのように
can_use_read_featurescan_use_write_featurescan_use_export_features
のような粒度で判定を分けるのが扱いやすいです。
15. 監査ログとの連携:課金・プラン変更は必ず残す
前回の記事の文脈ともつながりますが、プラン・請求まわりの変更は監査ログ対象として非常に重要です。
残したいイベント例:
subscription.createdsubscription.plan_changedsubscription.past_duesubscription.canceledsubscription.reactivatedaddon.addedaddon.removed
こうしたイベントは、問い合わせ対応でもインシデント調査でも役立ちます。
「なぜ昨日まで使えたCSV出力が今日使えないのか」を説明するには、契約状態の履歴が必要だからです。
16. RBAC/ABACと組み合わせる:プラン制限は認可の一部として扱う
前回のRBAC/ABAC記事との接続で大切なのは、プラン制限も認可の一部として扱うことです。
たとえばCSVエクスポート可否は、次の条件の積み重ねになります。
- RBAC
viewerは不可
- ABAC
- 自テナントであること
- 契約状態
activeであること
- プラン機能
project.export_csvが有効であること
つまり、最終的な認可は次のような合成になります。
認証済み
AND テナント所属
AND 必要ロール
AND 必要属性条件
AND 契約状態OK
AND プラン機能OK
この順番で考えると、複雑なようでいて、かなり整理しやすくなります。
17. よくある失敗パターン
17.1 tenant.plan == "pro" が全コードに散る
後でプラン変更や例外契約に対応できなくなります。
必ず関数やポリシーへ寄せます。
17.2 請求状態を見ていない
pro 契約中でも past_due や canceled の可能性があります。
plan_code と status を分けて扱う必要があります。
17.3 UIだけ制限してバックエンドを守っていない
ボタンを隠してもAPIが叩けるなら意味がありません。
最終的な判定は必ずFastAPI側で行います。
17.4 上限値を定数ベタ書きしている
「5件まで」「10件まで」が散ると、プラン変更時に事故になります。
get_limit() のような関数へまとめます。
17.5 Webhookイベントをそのまま業務判定に使う
外部イベントを受け取った瞬間の状態だけに依存すると不安定です。
アプリ内に正規化した Subscription 状態を保存する方が安全です。
18. テスト戦略:プラン制限は仕様変更で壊れやすい
プランと請求状態の組み合わせは、想像以上に壊れやすいです。
そのため、ユニットテストとAPIテストの両方で守るのがおすすめです。
18.1 最低限ほしいテスト
freeではCSVエクスポート不可proではCSVエクスポート可proでもpast_dueではエクスポート不可freeでメンバー数上限を超えると追加不可- Enterpriseでは監査ログ閲覧可
- 解約予約中でも期間内は使える
- トライアル終了後の挙動が想定どおり
18.2 ユニットテスト例
# tests/test_plan_service.py
from app.models.subscription import Subscription
from app.services.plan_service import has_feature, get_limit
def test_pro_has_csv_export():
subscription = Subscription(tenant_id=10, plan_code="pro", status="active")
assert has_feature(subscription, "project.export_csv") is True
def test_free_member_limit():
subscription = Subscription(tenant_id=10, plan_code="free", status="active")
assert get_limit(subscription, "member.max_count") == 3
18.3 契約状態テスト例
# tests/test_subscription_policy.py
from app.models.subscription import Subscription
from app.services.subscription_policy import can_use_export_features
def test_past_due_cannot_export():
subscription = Subscription(tenant_id=10, plan_code="pro", status="past_due")
assert can_use_export_features(subscription) is False
こうした純粋関数テストを厚めにしておくと、プラン改定時も安心です。
19. 読者別ロードマップ
個人開発・学習者さん
- まずは
freeとproの2段階で始める tenant.plan直書きを避けてhas_feature()を作る- 1つだけでも上限値(例: メンバー数)を
get_limit()で扱う - 主要APIに403や402相当の制御を入れる
小規模チームのエンジニアさん
- 料金表ではなく「機能一覧」を先に作る
plan_code,status,limitを分離したモデルにする- 依存関数で現在の契約情報を取得する
- ルーター直書きの判定をポリシー関数へ寄せる
- 監査ログに契約変更イベントを残す
SaaS開発チーム・スタートアップの皆さま
- プラン、アドオン、請求状態の責務を整理する
- Webhook経由でSubscription状態を同期する
- 猶予期間や停止ポリシーを明文化する
- プラン制限をRBAC/ABACと同じ認可レイヤーに統合する
- OpenAPIや管理画面、CS向け運用手順にも反映する
参考リンク
- FastAPI Documentation
- FastAPI Dependencies
- FastAPI Security
- Pydantic Documentation
- OWASP Authorization Cheat Sheet
まとめ
- SaaS向けのプラン設計は、単なる料金表ではなく、アプリ全体の認可・上限管理・状態管理の設計です。
- FastAPIでは、現在の契約情報を依存関数で解決し、機能判定や上限値判定をポリシー関数へ集約すると、設計が崩れにくくなります。
plan_codeとstatusは別で扱い、さらに必要ならアドオンや例外契約も重ねられるようにしておくと、後からの変更に強くなります。- UI側の表示制御だけでなく、バックエンド側で必ず機能制限を実施し、監査ログやテストでも守ることが重要です。
- 完璧な請求基盤を最初から作る必要はありませんが、最初から「機能キー」「上限」「契約状態」を分けておくと、将来の成長にかなり耐えやすくなります。
次の記事としては、この流れと相性が良い「FastAPIで作る社内管理画面APIの設計パターン」や「Webhook受信APIの安全な実装」が自然につながります。

