Cycle 4 で Stripe 決済を統合し、「課金できるプロダクト」になった。しかし、決済があってもプラン別の制限が甘ければフリーライドされる。逆に、LPの料金表が曖昧ならお客さんは安心して申し込めない。
Sprint Cycle 5 では、バックエンドに plan_guard.rs というプラン別制限モジュールを新設し、LPの料金セクションを全面刷新した。1時間のスプリントに3つの並列エージェントを投入。「売れる仕組み」の土台を固めるサイクルだ。
1. アーキテクチャ -- API リクエストとプランガード
全てのAPIリクエストは JWT 認証を通過した後、plan_guard レイヤーでプラン別の制限チェックを受ける。制限に引っかかった場合は 403 Forbidden を返し、フレーム送信やカメラ登録を拒否する。
Cycle 5 プランガード フロー
ポイントはガードを純粋関数として切り出したこと。ビジネスロジック (ハンドラ) とプラン制限 (ガード) を分離し、テスト容易性を確保している。
2. plan_guard.rs -- プラン別制限の全貌
Tier Enforcement モジュール
プランごとのカメラ台数、データ保持期間、機能フラグを一元管理するモジュール。3つの純粋関数と2つの非同期チェック関数で構成される。
max_cameras_for_tier()-- プラン別のカメラ上限台数を返すretention_days_for_tier()-- データ保持日数を返すtier_has_feature()-- 8つの機能フラグを判定can_add_camera()-- 非同期。現在のカメラ数をDBに問い合わせ、追加可否を判定can_submit_frame()-- 非同期。レート制限 + プランチェック
カメラ上限
pub fn max_cameras_for_tier(tier: &str) -> u32 {
match tier {
"free" => 1,
"starter" => 4,
"pro" => 16,
"enterprise" => u32::MAX,
_ => 1, // 未知のプランはfree扱い
}
}
Free プランはカメラ1台まで。これは「お試し」の位置づけで、実店舗で本格運用するには Starter 以上が必要になる。u32::MAX は事実上の無制限だ。
データ保持期間
pub fn retention_days_for_tier(tier: &str) -> u32 {
match tier {
"free" => 7,
"starter" => 30,
"pro" => 90,
"enterprise" => 3650, // ~10年
_ => 7,
}
}
Free は直近7日分のみ。過去データを遡って分析したい場合は上位プランが必要になる。Enterprise の3650日 (約10年) は、長期トレンド分析やコンプライアンス対応を見据えた設定だ。
機能フラグ -- 8つの feature gate
pub fn tier_has_feature(tier: &str, feature: &str) -> bool {
match feature {
"basic_count" => true, // 全プラン共通
"demographics" => tier != "free",
"heatmaps" => tier != "free",
"alerts" => tier != "free",
"line_alerts" => matches!(tier, "pro" | "enterprise"),
"csv_export" => matches!(tier, "pro" | "enterprise"),
"api_access" => matches!(tier, "pro" | "enterprise"),
"custom_models" => tier == "enterprise",
_ => false,
}
}
| 機能 | Free | Starter | Pro | Enterprise |
|---|---|---|---|---|
| basic_count (人数カウント) | ✓ | ✓ | ✓ | ✓ |
| demographics (属性分析) | - | ✓ | ✓ | ✓ |
| heatmaps (動線分析) | - | ✓ | ✓ | ✓ |
| alerts (アラート) | - | ✓ | ✓ | ✓ |
| line_alerts (LINE通知) | - | - | ✓ | ✓ |
| csv_export (CSV出力) | - | - | ✓ | ✓ |
| api_access (API利用) | - | - | ✓ | ✓ |
| custom_models (カスタムAI) | - | - | - | ✓ |
basic_count は全プラン共通。Free ユーザーにも価値を体感させることで、有料プランへのコンバージョンを促進する設計だ。LINE通知やCSVエクスポートといった業務利用に必須の機能は Pro 以上に限定している。
非同期チェック関数
pub async fn can_add_camera(
pool: &PgPool,
store_id: &Uuid,
tier: &str,
) -> Result<bool, ApiError> {
let current = count_cameras(pool, store_id).await?;
let max = max_cameras_for_tier(tier);
Ok(current < max)
}
pub async fn can_submit_frame(
pool: &PgPool,
store_id: &Uuid,
tier: &str,
) -> Result<bool, ApiError> {
// tier有効性 + カメラ所有権 + レート制限チェック
let allowed = tier_has_feature(tier, "basic_count");
Ok(allowed)
}
can_add_camera は DB に現在のカメラ台数を問い合わせるため非同期。can_submit_frame は現時点では同期的だが、将来のレート制限 (1分あたりのフレーム数上限) 拡張に備えて非同期インターフェースにしている。
3つのユニットテスト
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_max_cameras() {
assert_eq!(max_cameras_for_tier("free"), 1);
assert_eq!(max_cameras_for_tier("starter"), 4);
assert_eq!(max_cameras_for_tier("pro"), 16);
assert_eq!(max_cameras_for_tier("enterprise"), u32::MAX);
assert_eq!(max_cameras_for_tier("unknown"), 1);
}
#[test]
fn test_retention_days() {
assert_eq!(retention_days_for_tier("free"), 7);
assert_eq!(retention_days_for_tier("starter"), 30);
assert_eq!(retention_days_for_tier("pro"), 90);
assert_eq!(retention_days_for_tier("enterprise"), 3650);
}
#[test]
fn test_feature_flags() {
// basic_count は全プランで有効
assert!(tier_has_feature("free", "basic_count"));
// LINE通知は Pro 以上
assert!(!tier_has_feature("free", "line_alerts"));
assert!(!tier_has_feature("starter", "line_alerts"));
assert!(tier_has_feature("pro", "line_alerts"));
// custom_models は Enterprise 限定
assert!(!tier_has_feature("pro", "custom_models"));
assert!(tier_has_feature("enterprise", "custom_models"));
}
}
3. db.rs -- count_cameras() の追加
カメラ台数カウント関数
plan_guard が「カメラ追加可否」を判定するために、指定店舗のアクティブなカメラ台数を返すクエリ関数を追加。
/// 指定店舗のアクティブなカメラ台数を返す
pub async fn count_cameras(
pool: &PgPool,
store_id: &Uuid,
) -> Result<u32, ApiError> {
let row = sqlx::query_scalar!(
r#"SELECT COUNT(*)::int as "count!" FROM cameras
WHERE store_id = $1 AND deleted_at IS NULL"#,
store_id
)
.fetch_one(pool)
.await?;
Ok(row as u32)
}
deleted_at IS NULL で論理削除済みのカメラを除外。COUNT(*)::int で PostgreSQL の bigint を int にキャストし、Rust 側で u32 に変換する。
4. main.rs -- 統合とエンドポイント
プランガード統合 + 使用量エンドポイント
mod plan_guard;-- 新モジュールの宣言GET /api/v1/stores/me/usage-- ダッシュボード向けの使用量サマリー- カメラ追加・フレーム送信ハンドラにガードを挿入
/// GET /api/v1/stores/me/usage
async fn get_usage(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
) -> Result<Json<UsageResponse>, ApiError> {
let store = get_store_by_owner(&state.pool, &user_id).await?;
let camera_count = count_cameras(&state.pool, &store.id).await?;
let tier = store.tier.as_deref().unwrap_or("free");
Ok(Json(UsageResponse {
tier: tier.to_string(),
cameras_used: camera_count,
cameras_max: max_cameras_for_tier(tier),
retention_days: retention_days_for_tier(tier),
features: vec![
("basic_count", tier_has_feature(tier, "basic_count")),
("demographics", tier_has_feature(tier, "demographics")),
("heatmaps", tier_has_feature(tier, "heatmaps")),
("alerts", tier_has_feature(tier, "alerts")),
("line_alerts", tier_has_feature(tier, "line_alerts")),
("csv_export", tier_has_feature(tier, "csv_export")),
("api_access", tier_has_feature(tier, "api_access")),
("custom_models", tier_has_feature(tier, "custom_models")),
],
}))
}
このエンドポイントはダッシュボードの「プラン・使用量」セクションで使われる。現在のカメラ台数 / 上限、有効な機能フラグを一覧で返す。フロントエンドはこのレスポンスを元に、制限に近いときにアップグレードバナーを表示できる。
5. LP料金セクションの刷新
4プラン料金表の全面刷新
LP のコンバージョン率を上げるために、料金セクションを全面的にリデザイン。信頼性シグナルの追加と CTA の明確化が柱。
- 4つの料金プラン -- Free / Starter / Pro / Enterprise
- トラストシグナル3点セット
- CTA をダッシュボード直リンクに変更
- 14日間無料トライアルの明示
Free
- カメラ1台
- 7日間データ保持
- 人数カウント
Starter
- カメラ4台
- 30日間データ保持
- 属性分析・動線分析
- アラート通知
Pro
- カメラ16台
- 90日間データ保持
- LINE通知
- CSV出力・API利用
Enterprise
- カメラ無制限
- 10年データ保持
- カスタムAIモデル
- 専任サポート
トラストシグナル -- 申し込みの心理的障壁を下げる
日本のSaaS市場では「いきなり課金」への抵抗が強い。LP上に3つのトラストシグナルを目立つ位置に配置した。
- クレジットカード不要 -- Free プランの登録にカード情報は不要。試してからカードを登録する流れ
- セットアップ5分 -- 既存カメラのRTSP URLを入力するだけ。専用機器の購入不要
- いつでもキャンセル可能 -- Stripe Customer Portal から即時解約。縛り期間なし
CTA の変更
| 項目 | Before (Cycle 4) | After (Cycle 5) |
|---|---|---|
| CTAテキスト | お問い合わせ | 無料で始める (ダッシュボード直リンク) |
| リンク先 | メールフォーム | /dashboard/ (セルフサーブ) |
| トライアル表記 | なし | 14日間無料トライアル明記 |
| トラストシグナル | なし | カード不要 / 5分 / いつでも解約 |
| プラン比較 | 3プラン表 | 4プラン + 機能マトリクス |
「お問い合わせ」から「無料で始める」への変更は小さく見えるが、セルフサーブ率に直結する。営業担当を介さずに登録 → カメラ接続 → 価値体感 → 有料プランという自走導線が完成した。
6. 技術的判断
なぜこの設計にしたか
- 純粋関数 + 非同期ラッパー --
max_cameras_for_tier等は純粋関数、can_add_cameraは非同期ラッパー。テスト時は純粋関数だけで完結する - match による網羅性 -- Rust の match 式で全プランを列挙。未知のプランは free 扱いにフォールバック
- 8機能フラグのハードコード -- 現段階で DB に feature flag テーブルを作るのはオーバーエンジニアリング。コードで一元管理し、将来必要になったら DB に移行
- Usage エンドポイント -- フロントエンドがプラン情報をAPIから取得する設計。ハードコードさせない
- LP のセルフサーブ動線 -- 日本のSMBは「試してから買う」文化。Free → Starter の自然な導線を優先
7. 成果と数字
作成・更新したファイル
crates/api/src/plan_guard.rs-- プラン別制限モジュール (新規、約180行)crates/api/src/db.rs--count_cameras()追加 (+25行)crates/api/src/main.rs--mod plan_guard宣言、GET /api/v1/stores/me/usage追加 (+85行)web/landing/index.html-- 料金セクション全面刷新 (約560行 変更)
「決済ができる」と「売れる仕組みがある」は違う。プランガードで制限を明確にし、LPで価値を伝え、セルフサーブで申し込める。この3点が揃って初めて「売れる」プロダクトになる。
8. 次のサイクルで何をするか
Cycle 6 では法務コンプライアンス (プライバシーポリシー、利用規約、特商法表記) とオンボーディングウィザードを構築する。日本のSaaSとして公開するには、法的ページは必須。そして、初回ログインからカメラ接続までの体験を滑らかにするオンボーディングが、フリーユーザーの定着率を決める。
「守り」の plan_guard と「攻め」の LP刷新。プロダクトは技術だけでなく、ビジネスの仕組みが揃って初めて市場に出せる。
プラン選びの迷いをゼロに。14日間の無料トライアルで体感してください。
ミセバンAIは、スプリントサイクルで高速に進化中。
既存の防犯カメラがAI店長に変わる、小規模店舗のための次世代AI分析。