Use Cases (更新: 2026/6/7)

Claude CodeでECサイトを自作する:カート・在庫・Stripe決済を段階実装

ECサイトを自分で作る手順を、商品・カート・在庫引き当て・Stripe決済・Webhook注文確定まで、コピペで動くコードと落とし穴つきで紹介します。

Claude CodeでECサイトを自作する:カート・在庫・Stripe決済を段階実装

「決済ボタンを押したのに、商品が届かない」

僕が初めて作った小さな物販サイトで、これが本当に起きました。テスト環境では完璧に動いていたんです。Stripeの支払い画面から戻ってくると「ご購入ありがとうございます」と表示され、注文が確定する。何度試しても成功する。

でも本番でやらかしました。あるお客さんが、決済を終えた直後にブラウザを閉じたんです。支払いは成立している。Stripe側にはお金が入っている。なのに僕のサイトには注文が一件も残っていなかった。「成功画面に戻ってきたら注文確定」という、いちばんやってはいけない作りにしていたからです。

ECサイトは「商品を並べて決済ボタンを置く」だけでは絶対に公開できません。商品、カート、在庫、決済、注文確定、発送、返品——これが一本の導線でつながって、初めてお金を受け取れる。今日はこの全体を、Claude Codeと一緒に段階的に作る方法を、僕がぶつけた壁ごと書きます。

この記事の要点

  • 注文確定は決済成功画面ではなくStripeのWebhookを正にする。お客さんは成功画面に戻ってこないことがある(Stripe公式も明言)。
  • 金額と在庫はサーバー側で再計算する。ブラウザから送られた価格や小計は一切信じない。
  • 在庫は「Checkout開始時に短時間引き当て→期限切れで戻す」が現実的。減らすタイミングを間違えると売り越しか在庫の塩漬けが起きる。
  • Webhookは再送される。同じ注文を二回受け取っても二重発送・二重メールにならない冪等な処理を最初から入れる。
  • Claude Codeへの依頼は「EC全部作って」ではなく、注文・在庫・決済・管理画面に分割して渡すとレビューしやすい。

ECサイトを作るとき、何をClaude Codeに任せるか

Claude Codeの本当の価値は、部品を速く書くことじゃないと僕は思っています。価値は、業務の境界を崩さずに作業へ分解できることです。

ECの仕事を分けると、結局この4つに集約されます。

  1. 注文を作る
  2. 在庫を引き当てる
  3. 支払いを確認する
  4. 発送・返品・キャンセルを記録する

この4つを混ぜたまま「ECサイト一式を作って」と一回のプロンプトで丸投げすると、決済成功画面、Webhook、在庫復元、返金処理がぜんぶ一つのファイルに溶け合って、レビューが地獄になります。僕は最初これをやって、どこが壊れているのか自分でも追えなくなりました。

だから初回の依頼では、こう制約を具体化します。

Next.js App Router + TypeScriptで小規模ECサイトの最小実装を作ってください。
商品一覧、カート、在庫引き当て、Stripe Checkout Session作成、
Webhookによる注文確定、管理画面の発送・キャンセル操作を、別々のファイルに分けてください。
金額と在庫はクライアントの値を信用せず、サーバー側の商品データから再計算してください。
Webhookは署名検証し、checkout.session.completedとasync_payment_succeededを扱ってください。
成功URLだけで注文を確定しないでください。

最後の一行が、冒頭の事故への保険です。これを書いておくだけで、Claude Codeは成功URLでの注文確定を提案してこなくなります。

決済そのものをもっと深掘りしたいならClaude CodeでStripe Checkout決済を実装する方法、データ設計から固めたいならClaude Codeでデータベースを設計する方法も合わせて読むと、この記事の前後がつながります。

全体の流れを一枚の図にする

実装に入る前に、お金とデータがどう流れるかを図にしておきます。この図がそのままClaude Codeへの作業単位になります。

flowchart LR
  A["商品一覧・商品詳細"] --> B["カート"]
  B --> C["注文作成API"]
  C --> D["在庫引き当て"]
  D --> E["Stripe Checkout Session"]
  E --> F["Webhook"]
  F --> G["注文確定・メール・発送待ち"]
  G --> H["管理画面"]
  H --> I["発送・返品・キャンセル"]

矢印の一本一本が「ここで何かを間違えると事故る場所」です。特に E → F、つまりStripeの決済画面からWebhookで戻ってくるところ。ここを成功URLでショートカットしたのが、僕の最初の失敗でした。

商品・在庫・注文のデータモデルを先に決める

UIから作りたくなりますが、先にデータの境界を決めます。「価格」「在庫」「注文ステータス」をサーバー側で握る、これだけは譲りません。

以下はデモ用のインメモリ実装です。ローカル検証ならそのまま動きます。本番ではPrismaとPostgreSQLなどの永続DBに置き換えますが、注文確定の考え方は同じです。

// src/lib/store.ts
export type Product = {
  id: string;
  slug: string;
  name: string;
  description: string;
  priceJPY: number;
  stock: number;
  active: boolean;
  image: string;
};

export type CartLine = {
  productId: string;
  quantity: number;
};

export type OrderLine = CartLine & {
  name: string;
  unitAmount: number;
};

export type OrderStatus = "pending" | "paid" | "shipped" | "canceled" | "refunded";

export type Order = {
  id: string;
  lines: OrderLine[];
  amountTotal: number;
  status: OrderStatus;
  reserved: boolean;
  stripeSessionId?: string;
  customerEmail?: string;
  createdAt: string;
  updatedAt: string;
};

const products: Product[] = [
  {
    id: "tea-001",
    slug: "roasted-green-tea",
    name: "ほうじ茶ギフトセット",
    description: "初回購入向けのギフト箱付きセット。",
    priceJPY: 3200,
    stock: 12,
    active: true,
    image: "/images/products/tea.jpg",
  },
  {
    id: "mug-001",
    slug: "ceramic-mug",
    name: "手仕事マグカップ",
    description: "少量生産の陶器マグ。返品時は割れ確認が必要です。",
    priceJPY: 4800,
    stock: 6,
    active: true,
    image: "/images/products/mug.jpg",
  },
];

// 在庫は商品定義から切り離し、注文処理で増減させる単一の真実にする
const stock = new Map<string, number>(products.map((product) => [product.id, product.stock]));
const orders = new Map<string, Order>();

export function listProducts(): Product[] {
  return products
    .filter((product) => product.active)
    .map((product) => ({ ...product, stock: stock.get(product.id) ?? 0 }));
}

export function getProduct(productIdOrSlug: string): Product | undefined {
  return listProducts().find(
    (product) => product.id === productIdOrSlug || product.slug === productIdOrSlug,
  );
}

// 同じ商品が複数行で来たらまとめ、数量の範囲もここで弾く
function normalizeLines(lines: CartLine[]): CartLine[] {
  const merged = new Map<string, number>();

  for (const line of lines) {
    if (!Number.isInteger(line.quantity) || line.quantity < 1 || line.quantity > 20) {
      throw new Error("数量は1から20の範囲で指定してください。");
    }
    merged.set(line.productId, (merged.get(line.productId) ?? 0) + line.quantity);
  }

  return Array.from(merged, ([productId, quantity]) => ({ productId, quantity }));
}

function requireOrder(orderId: string): Order {
  const order = orders.get(orderId);
  if (!order) throw new Error("注文が見つかりません。");
  return order;
}

// 価格はクライアントではなくサーバーの商品データから埋める
export function createPendingOrder(lines: CartLine[]): Order {
  const normalized = normalizeLines(lines);
  const orderLines = normalized.map((line) => {
    const product = getProduct(line.productId);
    if (!product) throw new Error(`商品が見つかりません: ${line.productId}`);

    const availableStock = stock.get(product.id) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${product.name}の在庫が不足しています。`);
    }

    return {
      productId: product.id,
      quantity: line.quantity,
      name: product.name,
      unitAmount: product.priceJPY,
    };
  });

  const now = new Date().toISOString();
  const order: Order = {
    id: crypto.randomUUID(),
    lines: orderLines,
    amountTotal: orderLines.reduce((sum, line) => sum + line.unitAmount * line.quantity, 0),
    status: "pending",
    reserved: false,
    createdAt: now,
    updatedAt: now,
  };

  orders.set(order.id, order);
  return order;
}

// 在庫引き当ては二回呼ばれても一度しか減らさない
export function reserveOrderStock(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.reserved) return order;

  for (const line of order.lines) {
    const availableStock = stock.get(line.productId) ?? 0;
    if (availableStock < line.quantity) {
      throw new Error(`${line.name}の在庫が不足しています。`);
    }
  }

  for (const line of order.lines) {
    stock.set(line.productId, (stock.get(line.productId) ?? 0) - line.quantity);
  }

  order.reserved = true;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function attachStripeSession(orderId: string, stripeSessionId: string): Order {
  const order = requireOrder(orderId);
  order.stripeSessionId = stripeSessionId;
  order.updatedAt = new Date().toISOString();
  return order;
}

// 支払い済みは何度受けても結果が変わらない(Webhook再送への保険)
export function fulfillPaidOrder(input: {
  orderId: string;
  stripeSessionId: string;
  customerEmail?: string;
}): Order {
  const order = requireOrder(input.orderId);
  if (order.status === "paid" || order.status === "shipped") return order;

  if (!order.reserved) reserveOrderStock(order.id);

  order.status = "paid";
  order.stripeSessionId = input.stripeSessionId;
  order.customerEmail = input.customerEmail;
  order.updatedAt = new Date().toISOString();
  return order;
}

export function markOrderShipped(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid") throw new Error("発送できるのは支払い済み注文だけです。");
  order.status = "shipped";
  order.updatedAt = new Date().toISOString();
  return order;
}

// 未払いで引き当て済みの在庫だけ戻す。支払い済みは触らない
export function cancelOrder(orderId: string, reason = "customer_canceled"): Order {
  const order = requireOrder(orderId);
  if (order.status === "canceled" || order.status === "refunded") return order;

  if (order.status === "pending" && order.reserved) {
    for (const line of order.lines) {
      stock.set(line.productId, (stock.get(line.productId) ?? 0) + line.quantity);
    }
    order.reserved = false;
  }

  order.status = "canceled";
  order.updatedAt = new Date().toISOString();
  console.info(`Order ${order.id} canceled: ${reason}`);
  return order;
}

export function markOrderRefunded(orderId: string): Order {
  const order = requireOrder(orderId);
  if (order.status !== "paid" && order.status !== "shipped") {
    throw new Error("返金対象は支払い済みまたは発送済み注文です。");
  }
  order.status = "refunded";
  order.updatedAt = new Date().toISOString();
  return order;
}

export function listOrders(): Order[] {
  return Array.from(orders.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

このファイル、行数は多いですが、見るべき急所は3つだけです。createPendingOrder が価格をサーバーの商品データから埋めていること。reserveOrderStockfulfillPaidOrder が二回呼ばれても壊れないこと。cancelOrder が未払い注文の在庫だけ戻すこと。Claude Codeにレビューさせるときも、この3点に絞って見させます。

商品一覧とカートを作る(送る値は2つだけ)

クライアント側のカートは、表示のためには便利です。でも金額と在庫の正しさは、ここでは一切担保しません。ユーザーには小計を見せておいて、チェックアウト時にサーバーへ送るのは productIdquantity の2つだけ。価格をクライアントから送る作りは改ざんに弱いので避けます。

// src/components/product-grid-with-cart.tsx
"use client";

import { useMemo, useState } from "react";
import type { CartLine, Product } from "@/lib/store";

type CheckoutResponse = {
  url?: string;
  error?: string;
};

export function ProductGridWithCart({ products }: { products: Product[] }) {
  const [cart, setCart] = useState<CartLine[]>([]);
  const [loading, setLoading] = useState(false);

  // 表示用の小計。サーバーはこの値を信用しない
  const subtotal = useMemo(() => {
    return cart.reduce((sum, line) => {
      const product = products.find((item) => item.id === line.productId);
      return sum + (product?.priceJPY ?? 0) * line.quantity;
    }, 0);
  }, [cart, products]);

  function addToCart(productId: string) {
    setCart((current) => {
      const existing = current.find((line) => line.productId === productId);
      if (existing) {
        return current.map((line) =>
          line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line,
        );
      }
      return [...current, { productId, quantity: 1 }];
    });
  }

  async function checkout() {
    try {
      setLoading(true);
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        // サーバーへ送るのは商品IDと数量だけ
        body: JSON.stringify({ lines: cart }),
      });
      const data = (await response.json()) as CheckoutResponse;

      if (!response.ok || !data.url) {
        throw new Error(data.error ?? "チェックアウトを開始できませんでした。");
      }

      window.location.href = data.url;
    } catch (error) {
      alert(error instanceof Error ? error.message : "チェックアウトに失敗しました。");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="grid gap-8 lg:grid-cols-[1fr_320px]">
      <div className="grid gap-6 sm:grid-cols-2">
        {products.map((product) => (
          <article key={product.id} className="rounded-lg border p-4">
            <img src={product.image} alt={product.name} className="aspect-square w-full object-cover" />
            <h2 className="mt-3 text-lg font-semibold">{product.name}</h2>
            <p className="mt-1 text-sm text-gray-600">{product.description}</p>
            <p className="mt-3 font-bold">{product.priceJPY.toLocaleString()}円</p>
            <p className="text-sm text-gray-500">在庫: {product.stock}</p>
            <button
              type="button"
              disabled={product.stock < 1}
              onClick={() => addToCart(product.id)}
              className="mt-4 w-full rounded bg-black px-4 py-2 text-white disabled:bg-gray-300"
            >
              カートに追加
            </button>
          </article>
        ))}
      </div>

      <aside className="h-fit rounded-lg border p-4">
        <h2 className="text-lg font-semibold">カート</h2>
        {cart.length === 0 ? (
          <p className="mt-3 text-sm text-gray-500">商品が入っていません。</p>
        ) : (
          <ul className="mt-3 space-y-2">
            {cart.map((line) => {
              const product = products.find((item) => item.id === line.productId);
              return (
                <li key={line.productId} className="flex justify-between text-sm">
                  <span>{product?.name}</span>
                  <span>{line.quantity}点</span>
                </li>
              );
            })}
          </ul>
        )}
        <p className="mt-4 font-bold">小計: {subtotal.toLocaleString()}円</p>
        <button
          type="button"
          disabled={cart.length === 0 || loading}
          onClick={checkout}
          className="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
        >
          {loading ? "移動中..." : "Stripe Checkoutへ進む"}
        </button>
      </aside>
    </div>
  );
}

Claude Codeにこのコンポーネントを書かせるとき、僕は「見た目」より先に「サーバーに何を送るか」を指定します。ここを曖昧にすると、AIは親切心で価格や小計まで body に詰めてくる。便利そうに見えて、その瞬間に金額改ざんの穴が開きます。

Stripe Checkout Sessionを作るAPI

Stripe Checkoutは、決済画面、3Dセキュア、配送先入力をまるごと任せられます。小規模ECなら最初の選択肢にしやすい。ただし順番が大事で、Checkout Sessionを作る前に注文を作り、在庫を引き当て、metadata に自社の注文IDを入れる。Stripeの metadata にカード情報や住所などの機微情報は入れません。

// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import {
  attachStripeSession,
  createPendingOrder,
  getProduct,
  reserveOrderStock,
} from "@/lib/store";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  try {
    const { lines } = (await request.json()) as {
      lines: { productId: string; quantity: number }[];
    };

    // 先に注文を作り、在庫を引き当ててからStripeへ
    const order = createPendingOrder(lines);
    reserveOrderStock(order.id);

    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: order.lines.map((line) => {
        const product = getProduct(line.productId);
        if (!product) throw new Error(`商品が見つかりません: ${line.productId}`);

        return {
          price_data: {
            currency: "jpy",
            product_data: {
              name: product.name,
              images: [`${process.env.NEXT_PUBLIC_APP_URL}${product.image}`],
              metadata: { productId: product.id },
            },
            // 金額はサーバーの商品データから。クライアントの小計は使わない
            unit_amount: product.priceJPY,
          },
          quantity: line.quantity,
        };
      }),
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?canceled=1`,
      shipping_address_collection: {
        allowed_countries: ["JP"],
      },
      // 自社の注文IDだけを紐づける。個人情報は入れない
      metadata: {
        orderId: order.id,
      },
    });

    attachStripeSession(order.id, session.id);
    return NextResponse.json({ url: session.url }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "チェックアウトに失敗しました。" },
      { status: 400 },
    );
  }
}

環境変数は最低限こうです。公開リポジトリへ秘密鍵をコミットしないこと。

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

unit_amountproduct.priceJPY から作っているのが肝です。日本円は最小通貨単位がそのまま「円」なので、3200円なら unit_amount は3200。ここをドル感覚で100倍したり、クライアントの小計から作ったりすると事故ります。

Webhookで注文を確定する(ここが本丸)

冒頭の失敗を二度とやらないための、いちばん大事な章です。

注文確定は成功URLではなくWebhookで行います。これは僕の好みではなく、Stripe公式のCheckout fulfillmentガイドがはっきりそう言っています。決済完了後、成功ページが読み込まれる前に通信が切れることがあり、顧客がそのページにたどり着く保証はない、と。だから成功ページは「注文状態を表示する補助」にすぎません。

もう一つ。Webhookは再送されます。Stripe公式も、同じCheckout Sessionに対してfulfillment処理が複数回、場合によっては同時に呼ばれることがあるので、一度しか実行されないようにせよ、と書いています。だから前掲の fulfillPaidOrder は、すでに paid なら何もせず返す作りにしてあるわけです。

// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { cancelOrder, fulfillPaidOrder } from "@/lib/store";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  // 署名検証には生のボディが必要。先にtext()で取る
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // 署名検証。ここを飛ばすと誰でも偽の注文確定を送れてしまう
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Invalid webhook" },
      { status: 400 },
    );
  }

  // 即時決済と非同期決済の成功、両方を拾う
  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;

    // payment_statusがpaidのときだけ確定する
    if (orderId && session.payment_status === "paid") {
      fulfillPaidOrder({
        orderId,
        stripeSessionId: session.id,
        customerEmail: session.customer_details?.email ?? undefined,
      });
    }
  }

  // 期限切れ・非同期決済の失敗では引き当てた在庫を戻す
  if (
    event.type === "checkout.session.expired" ||
    event.type === "checkout.session.async_payment_failed"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    const orderId = session.metadata?.orderId;
    if (orderId) cancelOrder(orderId, event.type);
  }

  return NextResponse.json({ received: true });
}

ローカルではStripe CLIでWebhookを転送します。これがないと開発機にイベントが届きません。

stripe listen --forward-to localhost:3000/api/stripe/webhook

このWebhookでレビューすべきは5点です。署名検証しているか、payment_status を確認しているか、非同期決済の成功と失敗を両方扱っているか、在庫を戻しているか、そして冪等性。冪等性というのは、同じ処理が何度呼ばれても結果が壊れない性質のことです。Webhook再送、管理画面の二度押し、ネットワーク再試行——ECではこの三つが必ず起きるので、避けて通れません。

管理画面・返品・キャンセルの運用

管理画面は「注文が見られる」だけでは足りません。支払い済みを発送済みにする、未払いをキャンセルする、返金済みを区別する。この操作が要ります。最初のバージョンでも、権限を持つ管理者だけがアクセスできる前提にして、操作ログを残します。

// src/app/admin/orders/page.tsx
import { cancelOrder, listOrders, markOrderShipped } from "@/lib/store";

async function shipOrder(formData: FormData) {
  "use server";
  markOrderShipped(String(formData.get("orderId")));
}

async function cancelPendingOrder(formData: FormData) {
  "use server";
  cancelOrder(String(formData.get("orderId")), "admin_canceled");
}

export default function AdminOrdersPage() {
  const orders = listOrders();

  return (
    <main className="mx-auto max-w-5xl p-6">
      <h1 className="text-2xl font-bold">注文管理</h1>
      <div className="mt-6 overflow-x-auto">
        <table className="w-full border-collapse text-sm">
          <thead>
            <tr className="border-b text-left">
              <th className="py-2">注文ID</th>
              <th className="py-2">状態</th>
              <th className="py-2">金額</th>
              <th className="py-2">メール</th>
              <th className="py-2">操作</th>
            </tr>
          </thead>
          <tbody>
            {orders.map((order) => (
              <tr key={order.id} className="border-b">
                <td className="py-3 font-mono text-xs">{order.id}</td>
                <td className="py-3">{order.status}</td>
                <td className="py-3">{order.amountTotal.toLocaleString()}円</td>
                <td className="py-3">{order.customerEmail ?? "-"}</td>
                <td className="flex gap-2 py-3">
                  <form action={shipOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    {/* 発送ボタンは支払い済みのときだけ有効 */}
                    <button
                      type="submit"
                      disabled={order.status !== "paid"}
                      className="rounded bg-black px-3 py-1 text-white disabled:bg-gray-300"
                    >
                      発送済みにする
                    </button>
                  </form>
                  <form action={cancelPendingOrder}>
                    <input type="hidden" name="orderId" value={order.id} />
                    <button
                      type="submit"
                      disabled={order.status !== "pending"}
                      className="rounded border px-3 py-1 disabled:text-gray-300"
                    >
                      キャンセル
                    </button>
                  </form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

返品とキャンセルは、コードより先に事業ルールを決めます。発送前のキャンセルは在庫を戻せる。でも発送後の返品は、商品の状態を確認してから「再販可・アウトレット・廃棄」のどれかに振り分ける。ここは自動化より先に分類を決めないと破綻します。返金は最初の段階ならStripe Dashboardで手動でも構いませんが、注文ステータスには必ず反映する。Claude Codeには「返金APIを自動で叩く」より先に「どの状態で何を許可するか」を実装させたほうが、ずっと安全です。

認証を後回しにしない

ここを甘く見ると、管理画面がインターネットに丸出しになります。実際、認証なしの /admin がクロールされて注文一覧が漏れる、というのは小規模サイトでよくある事故です。

最低限、管理画面のルートはサーバー側でセッションを確認してから描画します。ユーザー側の注文履歴も、ログイン中の本人の注文だけを返す。Claude Codeに任せるなら、認証の設計は別タスクとして切り出すのが安全です。実装の型はトークン方式かセッション方式かで変わるので、Claude CodeでJWT認証を実装する方法を下敷きにすると進めやすいはずです。ポイントは、「誰が」その操作をしてよいかを、画面ではなくサーバーで判定すること。ボタンを disabled にするのは見た目の話で、防御にはなりません。

3つのユースケースで実装の優先順位を変える

ECと一口に言っても、何を最初に作るべきかは商材で変わります。僕が相談を受けてきた中でも、この3パターンが多いです。

1つ目、D2Cブランドの初回販売。商品点数が少ないので、凝った検索機能より、商品ページ・カート・Checkout・Webhook・発送管理を優先します。キャンペーン開始でSNSから一気に人が来て同時購入が起きるので、クライアント側の在庫表示よりサーバー側の引き当てが本当に大事になります。

2つ目、デジタル教材やPDF販売。発送は要りませんが、支払い後にダウンロード権限を付けるfulfillmentが要ります。成功URLでファイルURLを出すと、支払い確認前にアクセスされる穴が開く。Webhookで支払い済みにしてから権限を付けます。

3つ目、B2Bの小ロット発注サイト。価格表、請求書払い、承認フローが入るので、最初から全自動決済にしないほうがいい場合があります。Stripe Checkoutはカード決済用に残しつつ、管理画面に「見積もり中」「請求書送付済み」「入金確認済み」を足す設計にします。

ユースケース最優先の実装注意点
D2C初回販売在庫引き当て・Checkout・発送管理SNS流入で同時購入が起きる
デジタル教材Webhook後の権限付与成功URLだけで配布しない
B2B発注管理画面・見積もり・ステータス管理決済前後に人手確認が残る

僕がやらかした落とし穴4つ

正直に並べます。全部、実際に踏みました。

成功URLで注文確定。 冒頭の事故です。決済後にブラウザを閉じられて、お金だけ入って注文が残らなかった。Webhookを正にして解決しました。

カートの価格を信用した。 ブラウザのJavaScriptは書き換えられます。ユーザーが送ってきた小計や送料をそのまま使うと、不正な金額でCheckout Sessionを作れてしまう。今は商品IDから価格を再取得し、クーポンもサーバーで検証しています。

在庫を減らすタイミングを間違えた。 Checkout開始時に減らすと、未決済で離脱した注文が在庫を塩漬けにする。逆に支払い後に初めて減らすと、同時購入で売り越す。結局「短時間の引き当て+期限切れで復元+管理画面で手動調整」の合わせ技に落ち着きました。

税と送料を後付けにした。 最初は商品代金だけで作って、あとから消費税と送料を足そうとして、Checkout Sessionの金額計算がぐちゃぐちゃになりました。税・送料は最初から line_items か Stripe の送料設定(shipping_options)に組み込む前提で設計したほうが、あとが楽です。

Claude Codeへのレビュー指示

実装が終わったら、Claude Code自身にセルフレビューさせます。「バグを探して」ではなく、ECの事故につながる観点を名指しで渡すのがコツです。

このEC実装をレビューしてください。
特に、金額改ざん、在庫の二重引き当て、Webhook再送時の二重発送、
未払い注文の発送、キャンセル時の在庫復元、管理画面の権限、
Stripe秘密鍵とWebhook secretの露出、metadataへの個人情報混入を確認してください。
問題がある箇所はファイル名、関数名、再現手順、修正案をセットで出してください。

人間側の確認も、テストカードでの成功だけでは足りません。カード認証の失敗、Checkout期限切れ、Webhook再送、発送ボタンの二度押し、返金後の表示。このあたりまで触って初めて、公開前レビューに進める土台ができます。

よくある質問

Q. 注文確定はやっぱりWebhookじゃないとダメ?成功URLでは何が起きる? A. ダメです。Stripe公式が、顧客は成功ページに戻ってこない場合があると明言しています。決済後に通信が切れたりブラウザを閉じたりすると、お金は入ったのに注文が残らない、という僕が踏んだ事故が起きます。成功ページは状態表示の補助にとどめ、確定はWebhookで。

Q. 在庫はいつ減らすのが正解? A. 「Checkout開始時に短時間引き当てて、期限切れ(checkout.session.expired)で戻す」が現実的です。支払い後に初めて減らすと同時購入で売り越し、開始時に減らしっぱなしだと未決済の離脱で在庫が塩漬けになります。引き当て+期限復元+手動調整の合わせ技で対処します。

Q. 同じWebhookが二回届いて二重発送にならない? A. なりません。設計上、二回届く前提で作ります。注文が paid ならそれ以上処理しない(前掲 fulfillPaidOrder)ようにし、これを冪等性と呼びます。Stripeも同じ処理が複数回・同時に呼ばれうると明記しているので、必須の備えです。

Q. 消費税と送料はどう入れる? A. 後付けにせず最初から設計に入れます。送料は Stripe Checkout の shipping_options、税は地域ごとの計算を line_items かStripe Taxで扱うのが素直です。僕は後から足そうとして金額計算を壊しました。

Q. 本番DBは最初から要る?インメモリのままではダメ? A. ローカル検証や一人での動作確認ならインメモリで十分です。ただしVercelのような複数インスタンス環境ではメモリが共有されず在庫が壊れるので、本番ではPrisma+PostgreSQLなどに置き換えます。関数の境界(注文・在庫・確定)は同じまま中身だけ差し替えれば移行できます。

実際に試した結果

この構成で改めて手元のデモを動かし、stripe listen でWebhookを受けながら確認しました。テストカードで checkout.session.completed が届くこと。成功ページを閉じても注文が支払い済みになること——これが冒頭の事故の答え合わせで、ちゃんと確定しました。在庫不足のときはCheckout Session自体が作られないこと。同じイベントを二回流しても発送が一度きりで止まること。期限切れで在庫が戻ること。

結局、ECで僕が学んだのは「賢く作る」より「お金とモノがズレない場所を先に押さえる」でした。金額はサーバーで再計算、確定はWebhook、在庫は引き当てと復元、Webhookは冪等に。この四つを最初に決めてしまえば、あとはClaude Codeにどんどん書かせても、こわい事故はほとんど起きません。

もし自分の商材でこの先に進めたいなら、Claude Codeで作る教材一覧に実装テンプレートをまとめています。決済まわりだけ別で詰めたい人はStripe Checkout実装の記事から入るのが近道です。

#Claude Code #EC #Stripe #Next.js #在庫管理
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。