MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Checkoutとサーバー側で決める金額)

    オンライン予約サービスは、予約を取れるだけでは売上になりません。「支払いまで済んで初めて予約が成立する」形にするには、決済の仕組みをつなぐ必要があります。この回では、Stripe(ストライプ。クレジットカード決済などを提供する海外の決済サービス)の決済ページ「Checkout」につないで、予約フォームの送信から支払い画面への移動までを作ります。

    「予約サイトにカード決済を付けたい。でも、カード番号を自社のシステムで預かるのは怖いし、金額を書き換えられたりしないかも心配…」

    先に結論です。カード番号は自社のサーバーに一切通さず、金額は画面からではなくサーバーが決め、支払いが確認できるまでは「仮予約」として枠を一定時間だけ押さえる。この3点を押さえれば、小さな予約サービスでも安全側の決済が作れます。

    前回の第5回(会員ログイン)では、メールのリンクで入る会員ログインと予約履歴を作りました。今回はその予約に「支払い待ち」という状態を加えます。支払いが済んだことの確認と予約の確定は、次回(第7回)の Webhook(=Stripeから自社サーバーへ届く「支払いが終わりました」という通知)で行います。

    目次

    この回で作るもの:予約から支払い画面までの流れ

    流れは次のとおりです。

    順番起きること担当
    1利用者が予約フォームを送信するブラウザ
    2入力を検査し、金額を料金表から計算する自社サーバー(Server Action)
    3「支払い待ち」の仮予約を保存し、枠を35分だけ押さえる自社サーバー+DB
    4Stripe に決済ページを作らせ、そのURLへ利用者を移す自社サーバー → Stripe
    5利用者がStripeのページでカード情報を入力して支払うStripe
    6自社サイトの完了画面へ戻る(※確定はまだしない)ブラウザ
    7支払い完了の通知を受けて予約を確定する第7回で作成

    ポイントは手順5でカード番号を入力するのが Stripe のページであり、自社のサーバーには届かないことです。カード情報を自社で扱わない構成にしておくと、情報漏えいのリスクと、守るべきセキュリティ基準の負担を大きく減らせます(詳細は Stripe の公式ドキュメントを参照してください)。

    📰 出典:Stripe ドキュメント「Checkout の仕組み」

    使ったバージョンは Next.js 16・Stripe の Node.js ライブラリ 23(2026年10月に公開されたメジャーバージョン)です。実際のコードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。

    仕組みの説明:仮予約・金額・期限の3つの考え方

    仮予約:支払いが終わるまで「確定」にしない

    予約の表に status(状態)の列を足しました。

    状態意味
    pending支払い待ちの仮予約。期限まで枠を押さえる
    confirmed確定(第7回で支払い確認後に変える。第5回までの予約はこの扱い)
    cancelled取りやめ(決済ページを作れなかったときなど)
    expired支払いのないまま期限切れ

    金額:画面の値を信じず、サーバーで計算する

    ブラウザから送られてくる値は、利用者が書き換えられます。そのため金額は画面から受け取らず、第3回で作った検証処理が料金表(スペースの1時間料金)×利用時間でサーバー側に計算した値だけを使います。Stripe に渡す金額も、保存する金額も、この値です。

    円は「1円=1」でそのまま渡します(セントのような小数単位がない通貨のため。たとえば500円は 500)。また、日本円には最低決済額(50円)があります。今回の最低料金は1時間分なので問題になりませんが、料金の設定を変えるときは注意が必要です。

    📰 出典:Stripe ドキュメント「対応通貨」

    期限:決済ページ31分、仮予約35分

    Stripe の決済ページは、作成から30分〜24時間のあいだで期限を指定できます。そこで次のように決めました。

    • 決済ページの期限:31分(通信の遅れで30分を切らないよう1分の余裕)
    • 仮予約の期限:35分(決済ページより長くする)

    仮予約のほうを長くしておくのは、「決済ページはまだ有効なのに、枠だけ先に他の人へ解放される」という食い違いを防ぐためです。

    実装

    1. 予約の表に列を足し、二重予約の制約を作り直す

    lib/schema.ts に、状態・金額・仮予約の期限・Stripe の決済ページIDの列を追加しました。

    // lib/schema.ts(reservations の抜粋)
    status: text("status", { enum: ["pending", "confirmed", "cancelled", "expired"] }).notNull().default("confirmed"),
    totalYen: integer("total_yen"), // 予約時にサーバーが計算した金額(円)
    holdExpiresAt: timestamp("hold_expires_at", { withTimezone: true }), // 仮予約で枠を押さえておく期限
    stripeSessionId: text("stripe_session_id").unique(), // Stripe の決済ページ(Checkout Session)の ID

    既存の予約は確定扱いにしたいので、列の初期値は confirmed にし、仮予約は保存するときに pending を明示します。

    大事なのは、第4回で作った二重予約を防ぐ排他制約の作り直しです。取りやめや期限切れの予約まで枠を塞ぐと、同じ時間を二度と予約できなくなります。そこで「支払い待ちと確定だけを対象にする」条件(WHERE)を付けました。

    -- drizzle/0005_payment.sql(抜粋)
    ALTER TABLE "reservations" ADD CONSTRAINT "reservations_status_check"
      CHECK ("status" IN ('pending', 'confirmed', 'cancelled', 'expired'));
    ALTER TABLE "reservations" DROP CONSTRAINT "reservations_no_overlap";
    ALTER TABLE "reservations" ADD CONSTRAINT "reservations_no_overlap"
      EXCLUDE USING gist ("space_id" WITH =, tstzrange("starts_at", "ends_at") WITH &&)
      WHERE ("status" IN ('pending', 'confirmed'));

    排他制約は「現在時刻」を知りません。そのため、期限が過ぎた仮予約は状態を expired に書き換えて初めて枠が空きます。この書き換えを次の関数で行い、予約を保存する直前に、そのスペースの分だけ実行します。

    // lib/reservations.ts
    export async function expireStaleHolds(now: Date = new Date(), spaceId?: number) {
      await getDb()
        .update(reservations)
        .set({ status: "expired" })
        .where(and(eq(reservations.status, "pending"), lt(reservations.holdExpiresAt, now), /* スペース指定があれば絞る */));
    }

    空き状況の表示(lib/availability.ts)も、枠を塞ぐのは「確定」と「期限内の支払い待ち」だけにそろえています。

    2. Stripe に渡す内容を組み立てる

    lib/payment.ts の buildCheckoutParams は、通信をしない純粋な関数です。そのため、テストでそのまま中身を確かめられます。

    // lib/payment.ts(抜粋)
    return {
      mode: "payment", // 1回きりの支払い
      locale: "ja",
      customer_email: input.email,
      line_items: [{
        quantity: 1,
        price_data: {
          currency: "jpy",
          unit_amount: input.totalYen, // サーバーが計算した金額
          product_data: { name: `${input.spaceName}(${jst.format(input.startsAt)} 〜 ${jst.format(input.endsAt)})` },
        },
      }],
      client_reference_id: String(input.reservationId),
      metadata: { reservationId: String(input.reservationId) },
      expires_at: Math.floor(now.getTime() / 1000) + CHECKOUT_EXPIRES_MINUTES * 60,
      success_url: donePath,
      cancel_url: `${donePath}&canceled=1`,
    };

    client_reference_id と metadata には「どの予約の支払いか」を入れています。第7回の Webhook で予約を特定するために使います。

    決済ページを実際に作る関数は、Stripe の窓口を引数で受け取る形にしました。テストでは偽物に差し替えられます。

    export async function createCheckout(stripe: Pick<Stripe, "checkout">, input: CheckoutInput) {
      const session = await stripe.checkout.sessions.create(buildCheckoutParams(input), {
        idempotencyKey: `reservation-${input.reservationId}`, // 再送しても二重に作られない合言葉
      });
      if (!session.url) throw new Error("Stripe から決済ページの URL が返りませんでした");
      return { sessionId: session.id, url: session.url };
    }

    idempotencyKey(べき等キー)は、通信が途中で切れて同じ依頼を再送しても、結果が二重にならないようにするための合言葉です。

    秘密鍵は環境変数 STRIPE_SECRET_KEY から読み、コードには書きません(.env.example に項目だけ載せています)。鍵は使う直前に読むので、鍵が無い環境でも npm run build は通ります。

    📰 出典:Stripe API リファレンス「Checkout Session を作成する」

    3. 予約フォームの Server Action から決済ページへ移す

    app/spaces/[slug]/reserve/actions.ts の流れは、「検証 → 仮予約を保存 → 決済ページを作る → 移動」です。

    const saved = await insertReservation(space.id, result.booking, result.range, result.totalYen, user?.id);
    if (!saved.ok) { /* 同時に別の予約が入った場合のメッセージ */ }
    
    let checkoutUrl: string;
    try {
      const checkout = await createCheckout(getStripe(), { reservationId: saved.id, /* …省略… */ totalYen: result.totalYen });
      await attachCheckoutSession(saved.id, checkout.sessionId);
      checkoutUrl = checkout.url;
    } catch (error) {
      await releasePendingReservation(saved.id); // 決済ページを作れなかったら仮予約を取りやめ、枠を返す
      return { errors: { form: "決済の準備に失敗しました。しばらくしてからもう一度お試しください" }, values };
    }
    redirect(checkoutUrl);

    つまずきやすい点が2つあります。

    • redirect() は try の外に置く:Next.js の redirect は、処理を中断するために特別な例外を投げます。try の中で呼ぶと、この例外を catch が拾ってしまい、「決済の準備に失敗」と誤判定します。
    • 失敗したら必ず枠を返す:支払えない仮予約が残ると、35分間その時間が埋まったままになります。失敗時の取りやめを忘れないようにします。

    4. 完了画面は「確定」を表示しない

    Stripe のページから戻ってくる先(success_url)は、自社サイトの完了画面です。ここで注意したいのは、戻ってきたこと=支払いが完了したこと、ではない点です。URLは利用者が自分で開けますし、支払い前に別タブで開くこともできます。

    そのため完了画面は、DBの状態を見て表示を変えます。pending の間は「お支払いの確認待ち」と表示し、確定の処理は第7回の Webhook に任せます。戻る途中で「支払いをやめる」を押した場合(canceled=1)は、「まだ支払いは完了していません。◯時までは枠を押さえています」と案内します。予約履歴の画面にも、状態(支払い待ち・確定・取消・期限切れ)を表示しました。

    動作確認

    検証コマンドの結果は次のとおりです(Node.js 22・PostgreSQL 16 のローカル環境)。

    • npm run lint、npx tsc --noEmit、npm run build:成功
    • npm test:59件成功(DBつきのテストを含む。今回は決済の組み立て・仮予約の期限・枠の解放などを追加)
    • 画面操作:予約フォームを送信すると、サーバーが4,400円(2,200円×2時間)を計算して pending で保存することを確認。無効な鍵を指定した場合は、画面に「決済の準備に失敗しました」と表示され、仮予約が cancelled になって枠が戻ることを確認

    確認できていないこと:有効な Stripe のテスト用鍵がこの環境にないため、Stripe の決済ページが実際に開くところと、カード番号を入れて支払う流れは未確認です。決済ページの作成部分は、Stripe の窓口を偽物に差し替えたテストで、渡す内容と戻り値を確かめています。Docker Compose の起動も、この環境にDockerがないため未確認です。

    手元で試すときは、Stripe のダッシュボードでテストモードの秘密鍵(sk_test_ で始まるもの)を取得し、.env の STRIPE_SECRET_KEY に設定してください。テスト用のカード番号は Stripe の公式ドキュメントに載っています。本番用の鍵は、この段階では使わないでください。

    📰 出典:Stripe ドキュメント「テスト」

    つまずきやすい点・注意

    • 支払い前に枠を空ける設計に注意:仮予約の期限が決済ページより短いと、「支払ったのに枠が他の人に取られていた」事故につながります。期限の長さの関係(今回は仮予約35分>決済ページ31分)を崩さないでください。
    • 期限切れの片付けは第7回で仕上げる:今回は予約を保存する直前に、そのスペースの期限切れだけを片付けています。誰も予約しない時間帯の仮予約が pending のまま残っても、空き状況の表示では期限で除外されるため問題ありませんが、定期的な片付けは次回で追加します。
    • 完了画面で確定させない:戻り先のURLの表示だけで「確定」にしてはいけません。確定は、Stripeからの通知(署名つき)を受けた場合だけにします。
    • 決済ページの作成に失敗する場合:鍵の設定ミスやStripe側の障害でも、予約が宙に浮かないよう、取りやめ処理を必ず通します。

    発注者向けメモ:予約サービスに決済を付けるとき開発会社に確認したいこと

    決済は「付ければ終わり」ではなく、トラブル時の扱いまで決めておくと後から困りません。

    • ☐ カード番号を自社のサーバーで扱わない方式(Stripe のようなホスト型の決済ページ等)か
    • ☐ 支払い前に枠を押さえる時間(今回は35分)と、期限が切れたときの動きが決まっているか
    • ☐ 金額を画面ではなくサーバーで計算しているか(料金改定時の影響範囲も含めて)
    • ☐ 支払いが済んだ予約と、済んでいない予約が、管理画面で区別できるか
    • ☐ 決済サービスの手数料・入金のタイミング・解約や返金の扱いを、運営側(発注者)が把握しているか
    • ☐ 特定商取引法に基づく表示や、キャンセル規定の文面を誰が用意するか(法的な判断は専門家や公式窓口へ)

    開発会社への質問例です。

    • 「支払いの途中で画面を閉じられた場合、予約の枠はいつ空きますか?」
    • 「テスト用の決済で、成功・失敗・キャンセルの3パターンを一緒に確認できますか?」
    • 「決済サービスの本番の契約や審査は、こちらで進めればよいですか。それとも代行してもらえますか?」
    • 「決済の通知が届かなかった場合の復旧手順はありますか?」

    工数やリスクが増える条件としては、複数通貨・複数の支払い方法(コンビニ払いなど)、クーポンや割引、法人向けの請求書払い、定期課金などがあります。最初は「カードの1回払いのみ」で始め、実績を見て足していく進め方が堅実です。金額は書きませんが、これらは別途見積もりが必要な範囲です。

    まとめと次回予告

    この回では、予約の保存を「支払い待ちの仮予約」に変え、Stripe Checkout の決済ページへ利用者を移すところまで作りました。

    • カード番号は Stripe のページで入力させ、自社のサーバーには通さない
    • 金額は画面からではなく、サーバーが料金表から計算した値を使う
    • 仮予約は35分だけ枠を押さえ、決済ページ(31分)より長くする
    • 失敗したら仮予約を取りやめ、枠を必ず返す
    • 完了画面に戻っただけでは予約を確定させない

    次回の【第7回】は「Webhook で予約を確定する」です。Stripe から届く支払い完了の通知の署名を検証し、二重に処理しないしくみと、期限切れの仮予約の定期的な片付けを作ります。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次