ブログ一覧へ戻る

Cycle 4 で Stripe 決済を統合し、「課金できるプロダクト」になった。しかし、決済があってもプラン別の制限が甘ければフリーライドされる。逆に、LPの料金表が曖昧ならお客さんは安心して申し込めない

Sprint Cycle 5 では、バックエンドに plan_guard.rs というプラン別制限モジュールを新設し、LPの料金セクションを全面刷新した。1時間のスプリントに3つの並列エージェントを投入。「売れる仕組み」の土台を固めるサイクルだ。

1. アーキテクチャ -- API リクエストとプランガード

全てのAPIリクエストは JWT 認証を通過した後、plan_guard レイヤーでプラン別の制限チェックを受ける。制限に引っかかった場合は 403 Forbidden を返し、フレーム送信やカメラ登録を拒否する。

Cycle 5 プランガード フロー

API Requestカメラ追加 / フレーム送信
JWT Authユーザー認証
plan_guardプラン制限チェック
OK → Handler通常処理続行
NG → 403プランアップグレード促進

ポイントはガードを純粋関数として切り出したこと。ビジネスロジック (ハンドラ) とプラン制限 (ガード) を分離し、テスト容易性を確保している。

2. plan_guard.rs -- プラン別制限の全貌

crates/api/src/plan_guard.rs

Tier Enforcement モジュール

プランごとのカメラ台数、データ保持期間、機能フラグを一元管理するモジュール。3つの純粋関数と2つの非同期チェック関数で構成される。

カメラ上限

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,
    }
}
機能FreeStarterProEnterprise
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() の追加

crates/api/src/db.rs

カメラ台数カウント関数

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 の bigintint にキャストし、Rust 側で u32 に変換する。

4. main.rs -- 統合とエンドポイント

crates/api/src/main.rs

プランガード統合 + 使用量エンドポイント

/// 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料金セクションの刷新

web/landing/index.html (#pricing)

4プラン料金表の全面刷新

LP のコンバージョン率を上げるために、料金セクションを全面的にリデザイン。信頼性シグナルの追加と CTA の明確化が柱。

Free

¥0/月
  • カメラ1台
  • 7日間データ保持
  • 人数カウント

Starter

¥9,800/月
  • カメラ4台
  • 30日間データ保持
  • 属性分析・動線分析
  • アラート通知

Enterprise

¥49,800/月
  • カメラ無制限
  • 10年データ保持
  • カスタムAIモデル
  • 専任サポート

トラストシグナル -- 申し込みの心理的障壁を下げる

日本のSaaS市場では「いきなり課金」への抵抗が強い。LP上に3つのトラストシグナルを目立つ位置に配置した。

CTA の変更

項目Before (Cycle 4)After (Cycle 5)
CTAテキストお問い合わせ無料で始める (ダッシュボード直リンク)
リンク先メールフォーム/dashboard/ (セルフサーブ)
トライアル表記なし14日間無料トライアル明記
トラストシグナルなしカード不要 / 5分 / いつでも解約
プラン比較3プラン表4プラン + 機能マトリクス

「お問い合わせ」から「無料で始める」への変更は小さく見えるが、セルフサーブ率に直結する。営業担当を介さずに登録 → カメラ接続 → 価値体感 → 有料プランという自走導線が完成した。

6. 技術的判断

なぜこの設計にしたか

7. 成果と数字

~850
追加コード
3
ユニットテスト
8
機能フラグ
~1時間
実装時間

作成・更新したファイル

  1. crates/api/src/plan_guard.rs -- プラン別制限モジュール (新規、約180行)
  2. crates/api/src/db.rs -- count_cameras() 追加 (+25行)
  3. crates/api/src/main.rs -- mod plan_guard 宣言、GET /api/v1/stores/me/usage 追加 (+85行)
  4. web/landing/index.html -- 料金セクション全面刷新 (約560行 変更)

「決済ができる」と「売れる仕組みがある」は違う。プランガードで制限を明確にし、LPで価値を伝え、セルフサーブで申し込める。この3点が揃って初めて「売れる」プロダクトになる。

8. 次のサイクルで何をするか

Cycle 1
ダッシュボード・DB・認証完了 -- UI、6テーブル、Supabase Auth
Cycle 2
Rust API × Supabase (sqlx + JWT)完了 -- 7エンドポイント、データパイプライン
Cycle 3
LINE通知Bot + リアルタイムアラート完了 -- 5エンドポイント、LINE Messaging API
Cycle 4
Stripe決済統合完了 -- Checkout Session、Customer Portal、Webhook
Cycle 5
プランガード + LP料金刷新完了 -- 本記事の内容
Cycle 6
法務・オンボーディング・クローズドベータ準備プライバシーポリシー、利用規約、オンボーディングウィザード
Cycle 7
クローズドベータテスト実店舗での動作検証。実データによるチューニング

Cycle 6 では法務コンプライアンス (プライバシーポリシー、利用規約、特商法表記) とオンボーディングウィザードを構築する。日本のSaaSとして公開するには、法的ページは必須。そして、初回ログインからカメラ接続までの体験を滑らかにするオンボーディングが、フリーユーザーの定着率を決める。

「守り」の plan_guard と「攻め」の LP刷新。プロダクトは技術だけでなく、ビジネスの仕組みが揃って初めて市場に出せる。

プラン選びの迷いをゼロに。14日間の無料トライアルで体感してください。

ミセバンAIは、スプリントサイクルで高速に進化中。
既存の防犯カメラがAI店長に変わる、小規模店舗のための次世代AI分析。