オンライン予約サービスは、予約を取れるだけでは売上になりません。「支払いまで済んで初めて予約が成立する」形にするには、決済の仕組みをつなぐ必要があります。この回では、Stripe(ストライプ。クレジットカード決済などを提供する海外の決済サービス)の決済ページ「Checkout」につないで、予約フォームの送信から支払い画面への移動までを作ります。
「予約サイトにカード決済を付けたい。でも、カード番号を自社のシステムで預かるのは怖いし、金額を書き換えられたりしないかも心配…」
先に結論です。カード番号は自社のサーバーに一切通さず、金額は画面からではなくサーバーが決め、支払いが確認できるまでは「仮予約」として枠を一定時間だけ押さえる。この3点を押さえれば、小さな予約サービスでも安全側の決済が作れます。
前回の第5回(会員ログイン)では、メールのリンクで入る会員ログインと予約履歴を作りました。今回はその予約に「支払い待ち」という状態を加えます。支払いが済んだことの確認と予約の確定は、次回(第7回)の Webhook(=Stripeから自社サーバーへ届く「支払いが終わりました」という通知)で行います。
この回で作るもの:予約から支払い画面までの流れ
流れは次のとおりです。
| 順番 | 起きること | 担当 |
|---|---|---|
| 1 | 利用者が予約フォームを送信する | ブラウザ |
| 2 | 入力を検査し、金額を料金表から計算する | 自社サーバー(Server Action) |
| 3 | 「支払い待ち」の仮予約を保存し、枠を35分だけ押さえる | 自社サーバー+DB |
| 4 | Stripe に決済ページを作らせ、その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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)
- 【Next.jsで作るオンライン予約サービス 第2回】空き状況カレンダーを作る(時間枠・日本時間・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第3回】予約フォームをServer Actionsで作る(Zodによる入力チェックとエラー表示)
- 【Next.jsで作るオンライン予約サービス 第4回】ダブルブッキングを防ぐ(PostgreSQLの排他制約と同時アクセスのテスト)
- 【Next.jsで作るオンライン予約サービス 第5回】会員ログインを付ける(メールのリンクで入る方式と予約履歴)
- 【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Checkoutとサーバー側で決める金額)(この記事)
- 【Next.jsで作るオンライン予約サービス 第7回】Webhookで予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)










コメント
コメント一覧 (2件)
[…] 【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Chec… […]
[…] 前回の第6回(Stripeで支払いを受ける)では、予約を「支払い待ち」の仮予約として保存し、Stripe の決済ページ(Checkout)へ利用者を移すところまで作りました。今回はその続きで、支払い結果を受け取って予約を確定します。 […]