ブログ一覧へ戻る

Cycle 3 で LINE通知 + リアルタイムアラートが動いた。カメラが「見て」、AIが「判断して」、LINEが「知らせる」。技術的にはプロダクトとして成立している。

しかし、お金をいただけなければ事業ではない。プロダクトが「動く」と「売れる」の間には、決済フローという巨大な溝がある。

Sprint Cycle 4 では Stripe 決済統合を完了した。Checkout Session、Customer Portal、Webhook handler を Rust API に組み込み、フリーからエンタープライズまで4つの料金プランを構築。ダッシュボードには料金比較UI、プラン変更フロー、利用状況表示を追加した。

1. アーキテクチャ -- Dashboard から Stripe、そしてDBへ

Stripe決済統合の核心は「ユーザーの課金状態をサーバーサイドで正確に追跡する」ことにある。フロントエンドはあくまで表示と遷移のトリガーで、真の状態管理は Webhook 経由で DB に反映される。

Cycle 4 決済アーキテクチャ

Dashboard料金ページ / プラン選択
Rust APIPOST /checkout
Stripe Checkout決済ページ (hosted)
WebhookPOST /webhooks/stripe
Supabase DBplan_tier 更新

Stripe Checkout Session はホスト型の決済ページを生成する。ユーザーはミセバンAIのドメインを離れ、Stripeのセキュアな決済ページでカード情報を入力する。決済完了後、Stripe が Webhook を叩き、サーバー側で plan_tier を更新する。フロントエンドを一切信用しない設計だ。

2. 料金プラン体系

小規模店舗から大規模チェーンまで、段階的にスケールする4つのプランを設計した。

フリー
¥0/月
まずは試したい方へ
  • カメラ 1台
  • データ保持 7日
  • 基本AI分析
  • LINE通知
  • ヒートマップ
  • API アクセス
現在のプラン
スターター
¥9,800/月
個人店舗に最適
  • カメラ 4台
  • データ保持 30日
  • 高精度AI分析
  • LINE通知
  • ヒートマップ
  • API アクセス
アップグレード
エンタープライズ
¥49,800/月
チェーン展開・大規模施設
  • カメラ 無制限
  • データ保持 無制限
  • カスタムAIモデル
  • LINE通知
  • ヒートマップ + 動線分析
  • 専用SLA + サポート
お問い合わせ

フリープランで導入障壁を下げ、実際に効果を実感してからアップグレードする流れだ。スターターの¥9,800/月は従来の有人巡回コスト(月額5-10万円)の10分の1以下。プロプランのヒートマップとAPIは、データドリブンな経営判断を可能にする。

3. 実装の詳細

crates/api/src/billing.rs

Stripe クライアント -- 決済の心臓部

Stripe API との通信を一手に担うモジュール。Checkout Session 作成、Customer Portal URL 生成、サブスクリプション状態取得、Webhook 署名検証までを実装。

pub struct StripeClient {
    http: reqwest::Client,
    api_key: String,
    webhook_secret: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct PricingPlan {
    pub tier: &'static str,
    pub name: &'static str,
    pub price_monthly: i64,
    pub max_cameras: Option<i32>,
    pub retention_days: i32,
    pub features: Vec<&'static str>,
    pub stripe_price_id: &'static str,
}

pub const PLANS: &[PricingPlan] = &[
    PricingPlan {
        tier: "free", name: "フリー", price_monthly: 0,
        max_cameras: Some(1), retention_days: 7,
        features: vec!["基本AI分析"],
        stripe_price_id: "",
    },
    PricingPlan {
        tier: "starter", name: "スターター", price_monthly: 9800,
        max_cameras: Some(4), retention_days: 30,
        features: vec!["高精度AI分析", "LINE通知"],
        stripe_price_id: "price_starter_monthly",
    },
    PricingPlan {
        tier: "pro", name: "プロ", price_monthly: 29800,
        max_cameras: Some(16), retention_days: 90,
        features: vec!["最高精度AI分析", "LINE通知", "ヒートマップ", "API"],
        stripe_price_id: "price_pro_monthly",
    },
    PricingPlan {
        tier: "enterprise", name: "エンタープライズ", price_monthly: 49800,
        max_cameras: None, retention_days: -1,
        features: vec!["カスタムAIモデル", "専用SLA", "全機能"],
        stripe_price_id: "price_enterprise_monthly",
    },
];

PricingPlan をコード内に定数として定義することで、料金表示・権限チェック・Checkout 生成のすべてが単一の真実の源 (Single Source of Truth) から導かれる。

Webhook Handler

Stripe Webhook -- 決済イベントの受信と処理

Stripe からのイベントを受信し、データベースのサブスクリプション状態を更新する。署名検証により、なりすましリクエストを完全に排除。

async fn handle_stripe_webhook(
    State(state): State<AppState>,
    headers: HeaderMap,
    body: String,
) -> Result<StatusCode, ApiError> {
    // 1. 署名検証 -- なりすまし防止
    let sig = headers.get("Stripe-Signature")
        .ok_or(ApiError::Unauthorized)?;
    state.stripe.verify_webhook_signature(&body, sig)?;

    let event: StripeEvent = serde_json::from_str(&body)?;

    match event.event_type.as_str() {
        "checkout.session.completed" => {
            let session = event.data.object;
            let store_id = session.metadata.get("store_id")
                .ok_or(ApiError::BadRequest)?;
            let tier = price_id_to_tier(&session.price_id);
            // DB更新: stripe_customer_id + plan_tier
            update_store_billing(
                &state.pool, store_id,
                &session.customer, tier,
            ).await?;
        }
        "customer.subscription.updated" => {
            let sub = event.data.object;
            let tier = price_id_to_tier(&sub.items[0].price.id);
            update_store_tier_by_customer(
                &state.pool, &sub.customer, tier,
            ).await?;
        }
        "customer.subscription.deleted" => {
            let sub = event.data.object;
            update_store_tier_by_customer(
                &state.pool, &sub.customer, "free",
            ).await?;
        }
        "invoice.payment_failed" => {
            let invoice = event.data.object;
            flag_payment_failed(
                &state.pool, &invoice.customer,
            ).await?;
        }
        _ => { /* 未処理イベントは無視 */ }
    }

    Ok(StatusCode::OK)
}

price_id_to_tier() は Stripe の Price ID を内部のプラン名に逆引きする関数だ。Checkout Session の metadatastore_id を埋め込むことで、Webhook 受信時にどの店舗のサブスクリプションかを特定できる。

4. Webhook イベントフロー

Stripe Webhook は4つのイベントを処理する。それぞれが異なるビジネスロジックにマッピングされる。

Webhook イベント → アクション マッピング

checkout.session.completed
ユーザーが決済を完了
プラン有効化
stripe_customer_id 保存
plan_tier を更新
subscription.updated
プラン変更 (アップ/ダウン)
プラン切替
新Price ID → tier逆引き
機能制限を即時反映
subscription.deleted
サブスクリプション解約
フリーに降格
plan_tier = "free"
カメラ1台・7日保持に制限
invoice.payment_failed
カード決済失敗
支払い警告
payment_failed フラグ設定
LINE + ダッシュボードで通知

すべてのイベントで署名検証を最初に実行する。Stripe の Webhook Secret で HMAC-SHA256 を検証し、正当なリクエストのみを処理する。不正リクエストは 401 で即座に拒否。

5. ダッシュボード -- 課金UI

web/dashboard (billing section)

ダッシュボード -- 料金・プラン管理UI

ダッシュボードに課金セクションを追加。現在のプラン表示、4プラン比較グリッド、Checkout フロー、Customer Portal リンクを統合。

// Dashboard: Checkout フロー
async function handleUpgrade(tier) {
  const res = await fetch('/api/v1/billing/checkout', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ plan_tier: tier }),
  });
  const { checkout_url } = await res.json();
  // Stripe Checkout ページにリダイレクト
  window.location.href = checkout_url;
}

// Dashboard: Customer Portal (カード変更・解約)
async function openPortal() {
  const res = await fetch('/api/v1/billing/portal', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` },
  });
  const { portal_url } = await res.json();
  window.location.href = portal_url;
}

フロントエンドは URL を受け取ってリダイレクトするだけ。カード情報は一切ミセバンAIのサーバーを通らない。PCI DSS への準拠負担をゼロにする設計だ。

6. Before / After -- 「無料デモ」から「課金プロダクト」へ

項目Cycle 3 (Before)Cycle 4 (After)
収益化なし。完全無料Stripe サブスクリプション (4プラン)
決済フローなしCheckout Session → Webhook → DB更新
プラン管理全ユーザーが同一機能tier に応じた機能制限 (カメラ数・保持日数)
カード管理なしStripe Customer Portal (ホスト型)
サブスク状態追跡なしWebhook で DB にリアルタイム反映
DB スキーマstores テーブルに plan_tier なしstripe_customer_id, line_user_id, plan_tier カラム追加
ダッシュボード機能表示のみ料金比較 + プラン変更 + 利用状況表示

最大の変化は「プロダクトからビジネスへの転換」だ。技術的に優れていても収益がなければ持続しない。Stripe統合により、ミセバンAIはユーザーに価値を提供し、対価をいただける事業体になった。

7. 新規APIエンドポイント

MethodPath説明認証
GET/api/v1/billing/pricing全プランの料金・機能一覧を返却Public
POST/api/v1/billing/checkoutStripe Checkout Session URL を生成JWT
POST/api/v1/billing/portalStripe Customer Portal URL を生成JWT
GET/api/v1/billing/subscription現在のサブスクリプション状態を取得JWT
POST/api/v1/webhooks/stripeStripe Webhook イベント受信 (署名検証)Stripe Sig

Cycle 3 までの12エンドポイントと合わせ、合計17エンドポイントGET /billing/pricing は認証不要で、ランディングページからも呼べる。POST /webhooks/stripe は Stripe 署名ヘッダーで認証を代替する。

8. DB スキーマ変更

supabase/migrations/004_billing.sql

stores テーブルへの課金カラム追加

既存の stores テーブルに Stripe 連携用のカラムを追加するマイグレーション。

-- 004_billing.sql
ALTER TABLE stores
  ADD COLUMN stripe_customer_id TEXT UNIQUE,
  ADD COLUMN plan_tier TEXT NOT NULL DEFAULT 'free'
    CHECK (plan_tier IN ('free', 'starter', 'pro', 'enterprise')),
  ADD COLUMN line_user_id TEXT,
  ADD COLUMN payment_failed BOOLEAN NOT NULL DEFAULT false;

-- インデックス: Webhook で customer_id から店舗を逆引き
CREATE INDEX idx_stores_stripe_customer
  ON stores (stripe_customer_id)
  WHERE stripe_customer_id IS NOT NULL;

-- RLS: 自店舗の billing 情報のみ読み書き可能
CREATE POLICY "billing_own_store" ON stores
  FOR ALL USING (auth.uid() = owner_id);

stripe_customer_id に UNIQUE 制約を付けることで、1店舗 = 1 Stripe Customer の整合性を保証する。plan_tier の CHECK 制約は、不正な値の混入を DB レベルで防ぐ。

9. 技術的判断

なぜこの設計にしたか

10. 成果と数字

5
新規エンドポイント
~800
追加コード
4
料金プラン
4
Webhook イベント

作成・更新したファイル

  1. crates/api/src/billing.rs -- StripeClient、PricingPlan 定義、Checkout/Portal/Subscription (280行)
  2. crates/api/src/main.rs -- 5エンドポイント追加、StripeClient 初期化 (+95行)
  3. crates/api/Cargo.toml -- hmac, sha2 依存追加
  4. web/dashboard/billing.js -- 料金グリッド、Checkout フロー、Portal リンク (185行)
  5. web/dashboard/index.html -- 課金セクション、利用状況バー追加
  6. web/dashboard/billing.css -- 料金カード、プラン比較のスタイル (92行)
  7. supabase/migrations/004_billing.sql -- stripe_customer_id, plan_tier, line_user_id カラム (28行)
  8. web/landing/index.html -- ランディングページ料金セクションを API 連動に更新

技術がどれだけ優れていても、ユーザーがお金を払う仕組みがなければ事業は成立しない。Cycle 4 で、ミセバンAIは「動くデモ」から「売れるプロダクト」になった。

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

Cycle 1
ダッシュボード・DB・認証完了 -- UI、6テーブル、Supabase Auth
Cycle 2
Rust API x Supabase (sqlx + JWT)完了 -- 7エンドポイント、データパイプライン
Cycle 3
LINE通知Bot + リアルタイムアラート完了 -- 5エンドポイント、Realtime購読
Cycle 4
Stripe決済統合完了 -- 本記事の内容
Cycle 5
本番運用 (Production Readiness)監視・ログ基盤、カスタムドメイン、CI/CD パイプライン、パフォーマンスチューニング
Cycle 6
クローズドベータテスト実店舗での動作検証。実データによるモデルチューニング

Cycle 5 は本番運用 (Production Readiness)だ。具体的には以下を進める。

4つのサイクルで「見て」「判断して」「知らせて」「稼ぐ」仕組みが揃った。Cycle 5 ではこの仕組みを壊れない基盤の上に乗せる。

コードを書くことは始まりに過ぎない。それを24時間365日、止まらずに動かし続けること。それが本番運用だ。

AIが見守り、売上を伸ばす。次世代の店舗経営を始めませんか?

ミセバンAIは、スプリントサイクルで高速に進化中。
フリープランで今すぐ始めて、効果を実感してからアップグレード。