Stripeのテストモード

Page content

Stripeのテストモードは、「本番の決済を動かす前に、完全に安全な環境で課金フローを検証する」ための仕組みです。実際のお金は一切動きません。

ここでは、最短で動かす手順+実務でハマるポイントまでまとめます。


① テストモードの基本切り替え

まずはここが入口です。

  • Stripe Dashboard にログイン

  • 右上の 「テストモード」トグルをON

👉 これで「完全に別の世界」に切り替わります
(本番データとは一切混ざらない)


② APIキーをテスト用に変更(重要)

Stripeはキーが全てです。

  • テスト用キー(使う)

    • pk_test_xxx(公開キー)

    • sk_test_xxx(秘密キー)

  • 本番用キー(絶対混ぜない)

    • pk_live_xxx

    • sk_live_xxx

👉 よくあるミス
「フロントはtest、サーバーはlive」=事故確定


③ 商品・価格をテスト環境で作る

テストモード中に作る必要があります。

  • 商品(Product)作成

  • 価格(Price)設定(月額・年額など)

👉 注意
本番モードとは別なので
本番リリース時は“作り直し”になる


④ テストカードで決済する

Stripeは専用のテストカード番号を用意しています。

代表例:

  • 成功するカード
    4242 4242 4242 4242

  • 失敗するカード
    4000 0000 0000 9995

  • 3Dセキュア対応
    4000 0027 6000 3184

👉 入力情報(例)

  • 有効期限:未来の日付

  • CVC:適当(例:123)


⑤ Webhookをテストする(ここが本番差分)

決済後の処理はWebhookで動きます。

例:

  • checkout.session.completed

  • invoice.paid

ローカルでやる場合:

stripe listen --forward-to localhost:8787/webhook

👉 Stripe CLI を使うと便利


⑥ サブスクのテスト方法(重要)

サブスクは時間を待つ必要があるので工夫します。

方法①:テストクロック(おすすめ)

  • Stripe Test Clocks を使用

  • 時間を一気に進めて請求イベントを発火できる

👉 これがないと「1ヶ月待つ」ことになる


⑦ よくあるミス(実務的に重要)

❌ ミス1:本番キー混在

→ テストなのに課金される

❌ ミス2:Webhook未設定

→ 決済成功してもDB更新されない

❌ ミス3:フロントだけ動いてる

→ サーバー処理未検証

❌ ミス4:3Dセキュア未テスト

→ 本番で決済失敗


⑧ 実務での正しい流れ(おすすめ)

① テストモードON
② 商品作成
③ Checkout実装
④ Webhook実装
⑤ テストカードで決済
⑥ DB更新確認
⑦ サブスク更新(Test Clock)確認
⑧ 本番キーに切り替え


まとめ(本質)

Stripeテストモードの本質はこれです:

👉 「決済ではなく“イベント処理”を検証すること」

  • 決済成功 → DB更新

  • 失敗 → リトライ処理

  • サブスク更新 → 権限維持

ここができていれば、本番でほぼコケません。


いいですね、その構成(Workers + D1 + Stripe)なら、**かなりシンプルに“そのまま動く最小構成”**を作れます。
まずは「テストモードで1回決済 → DB反映」まで一気に通します。


全体構成(超シンプル版)

  • /create-checkout → Stripe Checkout作成

  • /webhook → 決済成功を受け取る

  • D1 → ユーザー状態を保存


① 事前準備

Stripe側

  • Stripe Dashboard でテストモードON

  • 商品+Priceを作成(price_xxx を控える)


② wrangler.toml

name = "stripe-saas"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "saas-db"
database_id = "YOUR_DB_ID"

③ D1テーブル

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT,
  is_active INTEGER
);

④ Workerコード(コピペOK)

export default {
  async fetch(req: Request, env: any) {
    const url = new URL(req.url);

    // ① Checkout作成
    if (url.pathname === "/create-checkout") {
      const stripe = await loadStripe(env.STRIPE_SECRET_KEY);

      const session = await stripe.checkout.sessions.create({
        payment_method_types: ["card"],
        mode: "subscription",
        line_items: [
          {
            price: env.STRIPE_PRICE_ID,
            quantity: 1,
          },
        ],
        success_url: "http://localhost:8787/success",
        cancel_url: "http://localhost:8787/cancel",
      });

      return Response.redirect(session.url, 303);
    }

    // ② Webhook
    if (url.pathname === "/webhook") {
      const body = await req.text();
      const sig = req.headers.get("stripe-signature");

      const stripe = await loadStripe(env.STRIPE_SECRET_KEY);

      let event;
      try {
        event = stripe.webhooks.constructEvent(
          body,
          sig,
          env.STRIPE_WEBHOOK_SECRET
        );
      } catch (err) {
        return new Response("Webhook Error", { status: 400 });
      }

      if (event.type === "checkout.session.completed") {
        const session = event.data.object;

        await env.DB.prepare(
          "INSERT INTO users (id, email, is_active) VALUES (?, ?, 1)"
        )
          .bind(session.id, session.customer_email)
          .run();
      }

      return new Response("ok");
    }

    return new Response("Hello Stripe SaaS");
  },
};

// Stripe loader(Workers対応)
async function loadStripe(secret: string) {
  const Stripe = (await import("stripe")).default;
  return new Stripe(secret, {
    apiVersion: "2023-10-16",
  });
}

⑤ 環境変数

wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put STRIPE_PRICE_ID

⑥ Webhook設定(重要)

ローカル開発:

stripe listen --forward-to localhost:8787/webhook

👉 Stripe CLI を使用

出てきた whsec_xxx を環境変数へ


⑦ テスト実行

ブラウザで:

http://localhost:8787/create-checkout

テストカード:

4242 4242 4242 4242

⑧ 成功するとこうなる

  • Stripe → 決済成功

  • Webhook発火

  • D1に保存

SELECT * FROM users;

👉 is_active = 1 ならOK


ここが“本質”

このテンプレで確認すべきは:

  • 決済できたか ❌(重要じゃない)

  • Webhook → DB更新できたか ✅(最重要)


次にやるべき拡張

ここから一気に実用になります:

① ログイン連携(Kinde/Auth)

→ user_idと紐づけ

② サブスク解約対応

  • customer.subscription.deleted

③ アクセス制御

if (!user.is_active) {
  return new Response("Payment required", { status: 402 });
}

いいテーマです。ここを曖昧にすると課金は成功してるのにユーザーが使えないという事故が起きます。
結論から言うと、設計はシンプルでOKです。


結論(推奨設計)

👉 「自分のユーザーID ↔ Stripe Customer ID を1対1で紐付ける」

users.id  ←→  stripe_customer_id

これを“唯一の正”にします。


① なぜCustomerを軸にするのか

Stripeはイベントが全部こう来ます:

  • customer.subscription.created

  • invoice.paid

  • checkout.session.completed

👉 全部 “customer” を基準に動く

つまり:

👉 Customer IDを持ってないと何も追えない


② DB設計(D1)

最小でこうです:

ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN subscription_status TEXT;

おすすめ完成形:

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT,
  stripe_customer_id TEXT UNIQUE,
  subscription_status TEXT, -- active / canceled / past_due
  current_period_end INTEGER
);

③ Checkout時に“ユーザーを埋め込む”(超重要)

ここが設計の核です。

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: PRICE_ID, quantity: 1 }],

  customer_email: user.email,

  metadata: {
    user_id: user.id, // ← これが命
  },

  success_url: "...",
  cancel_url: "...",
});

👉 ポイント
Stripeに「これは誰の決済か」を持たせる


④ Webhookで紐付ける

checkout.session.completedで確定します。

if (event.type === "checkout.session.completed") {
  const session = event.data.object;

  const userId = session.metadata.user_id;
  const customerId = session.customer;

  await DB.prepare(`
    UPDATE users
    SET stripe_customer_id = ?, subscription_status = 'active'
    WHERE id = ?
  `)
    .bind(customerId, userId)
    .run();
}

👉 これで完成:

User ←→ Customer が確定

⑤ 以降は全部Customer基準で処理

例えば:

サブスク更新

if (event.type === "invoice.paid") {
  const customerId = event.data.object.customer;

  await DB.prepare(`
    UPDATE users
    SET subscription_status = 'active'
    WHERE stripe_customer_id = ?
  `)
    .bind(customerId)
    .run();
}

解約

if (event.type === "customer.subscription.deleted") {
  const customerId = event.data.object.customer;

  await DB.prepare(`
    UPDATE users
    SET subscription_status = 'canceled'
    WHERE stripe_customer_id = ?
  `)
    .bind(customerId)
    .run();
}

⑥ よくある失敗(ここは強めに言います)

❌ metadata入れてない

→ 誰の決済かわからない → 詰み

❌ emailで紐付け

→ 将来必ずズレる(変更・重複)

❌ Customer作ってない

→ 毎回別人扱いになる


⑦ 強い設計(ワンランク上)

さらに安定させるなら:

① 事前にCustomer作る

const customer = await stripe.customers.create({
  email: user.email,
  metadata: { user_id: user.id },
});

👉 DBに保存しておく


② Checkout時に使う

customer: user.stripe_customer_id,

👉 これで完全に一貫性が保たれる


まとめ(重要な一行)

👉 「ユーザーIDをStripeに渡し、Customer IDをDBに保存する」

この往復ができていれば:

  • 決済

  • 更新

  • 解約

  • 再課金

全部コントロールできます。


いいところに来ています。
ただし最初に現実を一つだけ:フロントでのガードは意味がありません。必ずサーバー(Worker)側で遮断してください。


結論(設計の芯)

👉 「リクエストごとにDBで課金状態を検証して、ダメなら即ブロック」

Request → 認証 → DB確認 → OKなら通す / NGなら402

① 前提(必要な状態)

ユーザーはこうなっている想定:

subscription_status = 'active'
current_period_end > now

👉 これを満たさなければ 即ブロック


② middlewareの考え方(Workers)

Cloudflare Workers では「専用middleware機構」はないので、
👉 関数でラップする形が一番実務的です


③ コピペOK:未課金ブロックmiddleware

type Env = {
  DB: D1Database;
};

// 認証済みユーザー取得(仮:ヘッダーから)
async function getUserId(req: Request): Promise<string | null> {
  return req.headers.get("x-user-id"); // ← 本番はKinde等に置き換え
}

// 課金チェック
async function requirePaidUser(
  req: Request,
  env: Env
): Promise<{ ok: true; userId: string } | { ok: false; res: Response }> {
  const userId = await getUserId(req);

  if (!userId) {
    return {
      ok: false,
      res: new Response("Unauthorized", { status: 401 }),
    };
  }

  const user = await env.DB.prepare(
    `SELECT subscription_status, current_period_end
     FROM users
     WHERE id = ?`
  )
    .bind(userId)
    .first<any>();

  if (!user) {
    return {
      ok: false,
      res: new Response("User not found", { status: 404 }),
    };
  }

  const now = Math.floor(Date.now() / 1000);

  const isActive =
    user.subscription_status === "active" &&
    user.current_period_end &&
    user.current_period_end > now;

  if (!isActive) {
    return {
      ok: false,
      res: new Response("Payment Required", { status: 402 }),
    };
  }

  return { ok: true, userId };
}

④ 実際の使い方

export default {
  async fetch(req: Request, env: Env) {
    const url = new URL(req.url);

    // 保護されたAPI
    if (url.pathname === "/api/protected") {
      const check = await requirePaidUser(req, env);

      if (!check.ok) return check.res;

      // 通過したら処理
      return new Response(`Hello paid user: ${check.userId}`);
    }

    return new Response("public route");
  },
};

⑤ Webhookと連動(超重要)

middlewareはDBを見ているだけなので、
👉 Webhookで正しく更新されていることが前提

例:

// invoice.paid
subscription_status = 'active'
current_period_end = 次回請求日

// subscription.deleted
subscription_status = 'canceled'

👉 ここがズレると「課金してるのに使えない」地獄になります


⑥ よくある事故(ここは強め)

❌ statusだけ見てる

past_dueでも通る → 不正利用

❌ period見てない

→ 期限切れユーザーが使える

❌ フロントだけ制御

→ API直叩きで突破される


⑦ 実務で強い設計

さらに一段上:

キャッシュ(高速化)

// KV or Cache APIで数秒キャッシュ

👉 毎回DB叩かなくて済む


権限レベル

plan = 'free' | 'pro' | 'enterprise'

👉 APIごとに制御可能


まとめ(本質)

👉 「課金状態は“毎リクエスト検証”が正解」

  • Stripeは非同期(Webhook)

  • DBが唯一の真実

  • middlewareで強制

これで「未課金ユーザー完全遮断」が成立します。


無料トライアルは「なんとなく付ける」と必ず破綻します。
先に芯を決めます。

👉 “課金なしでも使える期間”ではなく、
👉 “期限付きで active と同等扱いにする状態”として設計する

これがブレない設計です。


結論(状態設計)

free → trialing → active → past_due → canceled

👉 トライアル中はこう扱う:

trialing = active と同じ権限(ただし期限あり)

① DB設計(これで十分強い)

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT,

  stripe_customer_id TEXT UNIQUE,

  subscription_status TEXT, -- free / trialing / active / canceled
  trial_end INTEGER,        -- UNIX秒
  current_period_end INTEGER
);

② トライアル開始パターン(2種類)

パターンA(おすすめ):Stripeに任せる

const session = await stripe.checkout.sessions.create({
  mode: "subscription",

  line_items: [{ price: PRICE_ID, quantity: 1 }],

  subscription_data: {
    trial_period_days: 7,
  },

  metadata: {
    user_id: user.id,
  },
});

👉 メリット

  • 自動で課金開始

  • Webhookも整合性あり

👉 デメリット

  • カード登録が必要

パターンB:自前トライアル(カード不要)

await DB.prepare(`
  UPDATE users
  SET subscription_status = 'trialing',
      trial_end = ?
  WHERE id = ?
`)
.bind(now + 7 * 86400, user.id)
.run();

👉 メリット

  • UXが良い(登録即使える)

👉 デメリット

  • 不正利用対策が必要

③ middleware(トライアル対応版)

ここが一番重要です👇

async function requirePaidUser(req: Request, env: Env) {
  const userId = await getUserId(req);

  if (!userId) {
    return { ok: false, res: new Response("Unauthorized", { status: 401 }) };
  }

  const user = await env.DB.prepare(`
    SELECT subscription_status, trial_end, current_period_end
    FROM users WHERE id = ?
  `)
    .bind(userId)
    .first<any>();

  if (!user) {
    return { ok: false, res: new Response("User not found", { status: 404 }) };
  }

  const now = Math.floor(Date.now() / 1000);

  const isTrial =
    user.subscription_status === "trialing" &&
    user.trial_end &&
    user.trial_end > now;

  const isActive =
    user.subscription_status === "active" &&
    user.current_period_end &&
    user.current_period_end > now;

  if (!isTrial && !isActive) {
    return {
      ok: false,
      res: new Response("Payment Required", { status: 402 }),
    };
  }

  return { ok: true, userId };
}

👉 これで
trial中も使える/期限切れたら即ブロック


④ Webhook設計(超重要)

Stripe利用時:

トライアル開始

if (event.type === "customer.subscription.created") {
  const sub = event.data.object;

  await DB.prepare(`
    UPDATE users
    SET subscription_status = 'trialing',
        trial_end = ?
    WHERE stripe_customer_id = ?
  `)
    .bind(sub.trial_end, sub.customer)
    .run();
}

課金開始

if (event.type === "invoice.paid") {
  const invoice = event.data.object;

  await DB.prepare(`
    UPDATE users
    SET subscription_status = 'active',
        current_period_end = ?
    WHERE stripe_customer_id = ?
  `)
    .bind(invoice.lines.data[0].period.end, invoice.customer)
    .run();
}

解約

if (event.type === "customer.subscription.deleted") {
  await DB.prepare(`
    UPDATE users
    SET subscription_status = 'canceled'
    WHERE stripe_customer_id = ?
  `)
    .bind(event.data.object.customer)
    .run();
}

⑤ よくある失敗(重要)

❌ trialを別ロジックにする

→ バグの温床

👉 必ず middlewareで統一判定


❌ trial期限チェックしてない

→ 永久無料になる


❌ StripeとDBのズレ

→ 課金済みなのに使えない


⑥ 実務で強い設計(ワンランク上)

① トライアル1回制限

trial_used INTEGER DEFAULT 0

② 不正防止

  • IP制限

  • クッキー

  • メールドメイン制限


③ トライアル終了前リマインド

  • trial_end - 1日 で通知

まとめ(本質)

👉 「trialもactiveも“使える状態”として同じ扱いにする」

違いはこれだけ:

trial = 無料だが期限あり
active = 有料で期限更新される