オンライン予約サービスで「予約したのに確認メールが届かない」「キャンセルしたのに返金がいつ戻るのか分からない」という問い合わせは、運営の負担になりやすい代表例です。この回では、予約が確定したら確認メールを送り、利用者がキャンセルしたときはキャンセル規定にもとづいて金額を決め、Stripe(ストライプ。クレジットカード決済などを提供する海外の決済サービス)で返金するところまでを作ります。
「キャンセルされたら、返金は手作業になるの? 二重に返金してしまったり、返金に失敗したりしないか心配…」
先に結論です。返金は「予約の状態を先に更新し、そのあと決済サービスに返金を頼む」順番で行い、失敗したときは記録を残して人が対応できるようにしておくのが堅実です。さらに、返金額は画面ではなくサーバーが規定から計算し、確認メールは「送った印を先に付けてから送る」ことで二重送信を防ぎます。
前回の第7回(Webhookで予約を確定する)では、Stripe からの通知で仮予約を確定させ、支払い済みの事実を記録しました。今回はその続きで、確定した予約の「その後」を作ります。
この回で作るもの:確認メールとキャンセル・返金の流れ
作る機能は3つです。使ったバージョンは Next.js 16・Stripe の Node.js ライブラリ 23 で、コードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。
| 機能 | 内容 | 主なファイル |
|---|---|---|
| 予約確認メール | 予約が確定した直後に1通だけ送る。送れなかった分は定期実行で送り直す | lib/mailer.ts、lib/notify.ts |
| キャンセル規定 | 利用開始までの残り時間で返金の割合を決める | lib/cancellation.ts |
| キャンセルと返金 | 会員本人の確定済み予約だけを取り消し、Stripe に返金を頼む | lib/cancel.ts、app/reservations/actions.ts |
あわせて、データベースに5つの列を足しました(drizzle/0007_cancel_refund.sql)。返金の宛先になる支払いのID、確認メールを送った日時、キャンセルした日時、返金額、返金の状況です。
📰 出典:Stripe ドキュメント「Create a refund」
Stripe の返金は、支払い(PaymentIntent)または請求(Charge)を指定して作ります。一部だけを返金することもでき、すでに全額返金した支払いを再び返金しようとするとエラーになります(執筆時点:2026年10月)。
キャンセル規定をコードで表す
キャンセル規定は、「利用開始の7日前までは全額、3日前までは半額、それ以降は返金なし」というサンプルの決まりにしました。割合は事業者が決めることなので、数字は例です。大切なのは、規定を1か所のコードに集め、画面の表示・メール・返金のすべてが同じ関数を使うことです。画面とサーバーで計算が食い違うと、「画面では全額返金と出ていたのに半額だった」というトラブルになります。
// lib/cancellation.ts(抜粋)
export const CANCELLATION_TIERS = [
{ hoursBefore: 7 * 24, percent: 100 }, // 利用開始の7日前まで: 全額
{ hoursBefore: 3 * 24, percent: 50 }, // 3日前まで: 半額
] as const;
export type CancellationQuote = { allowed: boolean; refundYen: number; percent: number };
// 返金額を決める。通信も DB も使わない純粋な関数なので、そのままテストできる。
// 利用開始後のキャンセルは受け付けない。端数は切り捨て(事業者に不利にならない側)
export function quoteCancellation(startsAt: Date, totalYen: number, now: Date = new Date()): CancellationQuote {
const hoursLeft = (startsAt.getTime() - now.getTime()) / 3_600_000;
if (hoursLeft <= 0) return { allowed: false, refundYen: 0, percent: 0 };
const tier = CANCELLATION_TIERS.find((t) => hoursLeft >= t.hoursBefore);
const percent = tier?.percent ?? 0;
return { allowed: true, refundYen: Math.floor((totalYen * percent) / 100), percent };
}
通信もデータベースも使わない関数なので、そのままテストできます。端数は切り捨てにして、境界(ちょうど7日前、ちょうど3日前、利用開始の直前と直後)をテストで確かめています。
返金は「先に記録、あとで返金」の順で行う
キャンセルの処理で一番気をつけるのは順番です。
- 予約を「取消」にして、返金額と「返金を依頼中」を記録する(データベース)
- Stripe に返金を頼む(外部サービス)
- 成功したら「返金済み」、失敗したら「失敗」に更新する
先に返金してからデータベースを更新すると、返金は済んだのに予約が確定のまま残り、同じ枠を使う人と二重になる恐れがあります。逆に、データベースの処理を開いたまま外部の通信を待つと、通信が遅いときに他の処理を巻き込みます。そこで、取消の記録は1つの更新文で済ませ、返金の通信はその外で行います。
// lib/cancel.ts(抜粋)
// 「確定のまま」の行だけを取消にする。条件に status を入れているので、2回目以降は 0 行になって失敗する
const updated = await db
.update(reservations)
.set({ status: "cancelled", cancelledAt: now, refundYen: quote.refundYen, refundStatus: quote.refundYen > 0 ? "requested" : "none" })
.where(and(eq(reservations.id, r.id), eq(reservations.userId, input.userId), eq(reservations.status, "confirmed")))
.returning({ id: reservations.id });
if (updated.length === 0) return { ok: false, reason: "not_cancellable" };
if (quote.refundYen === 0)
更新の条件に「いま確定している行」を入れているのがポイントです。ボタンを2回押された場合や、2つのタブから同時に送られた場合でも、取消に変えられるのは最初の1回だけで、返金も1回しか頼まれません(テストで同時に2回呼んで確かめています)。
返金の通信と、失敗したときの扱いは次のとおりです。
// lib/cancel.ts(抜粋)
try {
if (!r.paymentIntentId) throw new Error("支払い情報(PaymentIntent)が記録されていません");
await refund({ reservationId: r.id, paymentIntentId: r.paymentIntentId, amountYen: quote.refundYen });
await db.update(reservations).set({ refundStatus: "succeeded" }).where(eq(reservations.id, r.id));
return { ok: true, reservationId: r.id, refundYen: quote.refundYen, refundStatus: "succeeded" };
} catch (error) {
await db.update(reservations).set({ refundStatus: "failed" }).where(eq(reservations.id, r.id));
console.error(`要確認: 予約 ${r.id} の返金に失敗しました`, error instanceof Error ? error.message : error);
return { ok: true, reservationId: r.id, refundYen: quote.refundYen, refundStatus: "failed" };
}
返金に失敗しても、取消は元に戻しません。利用者には「キャンセルは受け付けた。返金は運営が確認して連絡する」と伝え、運営側は返金の状況が「失敗」の予約を調べて対応します。Stripe への依頼そのものには、予約ごとの合言葉(Idempotency-Key)を付けています。通信が途中で切れて再送しても、同じ返金が二重に作られないようにするためです。
// lib/payment.ts(抜粋)
export async function refundPayment(stripe: Pick<Stripe, "refunds">, input: { reservationId: number; paymentIntentId: string; amountYen: number }) {
const refund = await stripe.refunds.create(
{ payment_intent: input.paymentIntentId, amount: input.amountYen, metadata: { reservationId: String(input.reservationId) } },
{ idempotencyKey: `refund-reservation-${input.reservationId}` },
);
return { refundId: refund.id, status: refund.status };
}
📰 出典:Stripe ドキュメント「Idempotent requests」
返金の宛先になる支払いのIDは、第7回の Webhook で支払いを確認するときに予約へ記録するよう足しました。会員が他人の予約IDを指定しても、検索の条件に「ログイン中の本人のID」が入っているので、「見つからない」と同じ扱いになります。
確認メールを1回だけ送る
メールは、決済サービスの通知に比べて「送れなかった」が起きやすい処理です。送信サービスの一時的な不調、宛先の誤り、送信元の認証設定の不備などが原因です。そこで次の方針にしました。
- 予約が確定した直後(Webhook の処理の後)に1通送る
- 送信に失敗しても、Webhook は成功として返す(失敗で 500 を返すと、Stripe の再送は「処理済み」と判断されて送り直せないため)
- 送れなかった予約は「未送信」のまま残し、定期実行(第7回で作った入口)で送り直す
// app/api/stripe/webhook/route.ts(抜粋)
if (outcome === "confirmed" || outcome === "revived") {
// 確定したときだけ確認メールを送る。メールの失敗で 500 を返すと、Stripe の再送では「処理済み」になって送り直せないので、
// ここでは例外を外に出さない(未送信の予約は定期実行で送り直す)
const id = Number((event.data.object as { client_reference_id?: string | null }).client_reference_id);
if (Number.isInteger(id)) await sendConfirmationMail(id, appOrigin());
}
return Response.json({ received: true, outcome });
二重送信を防ぐ工夫が、「送った印を先に付けてから送る」ことです。
// lib/notify.ts(抜粋)
export async function sendConfirmationMail(id: number, origin: string, send: Send = sendMail, now: Date = new Date()): Promise<"sent" | "skipped" | "failed"> {
const db = getDb();
const claimed = await db
.update(reservations)
.set({ confirmationSentAt: now })
.where(and(eq(reservations.id, id), eq(reservations.status, "confirmed"), isNull(reservations.confirmationSentAt), isNotNull(reservations.contactEmail)))
.returning({ id: reservations.id });
if (claimed.length === 0) return "skipped";
try {
const r = await loadMailReservation(id);
if (!r) throw new Error("宛先が無い予約です");
await send(confirmationMail(r, origin));
return "sent";
} catch (error) {
await db.update(reservations).set({ confirmationSentAt: null }).where(eq(reservations.id, id));
console.error(`予約 ${id} の確認メールを送れませんでした`, error instanceof Error ? error.message : error);
return "failed";
}
}
通知が重なって届いたり、定期実行と Webhook が同時に動いたりしても、印を付けられるのは1つの処理だけです。送信に失敗したら印を外して、あとで再挑戦できるようにします。
送信そのものは、ライブラリを足さずに送信サービス(Resend)の HTTP API を呼ぶだけの小さな関数にしました。
// lib/mailer.ts(抜粋)
export async function sendMail(mail: Mail, fetcher: Fetcher = fetch, env: NodeJS.ProcessEnv = process.env): Promise<void> {
const key = env.RESEND_API_KEY;
const from = env.MAIL_FROM;
if (!key || !from) {
// 開発中だけは、送らずにサーバーのログへ出す。本番でログに出すと誰でも見られかねないので、エラーにする
if (env.NODE_ENV === "production") throw new MailNotConfiguredError();
console.log(`[開発用] メール(宛先 ${mail.to}/件名 ${mail.subject})\n${mail.text}`);
return;
}
const res = await fetcher("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ from, to: [mail.to], subject: mail.subject, text: mail.text }),
});
if (!res.ok) throw new MailSendError(res.status);
}
📰 出典:Resend ドキュメント「Send Email」
エラーのメッセージには宛先も本文も入れていません。ログに個人情報が残らないようにするためです。開発中はメールを送らずにサーバーのログへ出し、本番で設定が無いときは黙って成功にせずエラーにします。
動作確認の方法と、確認できていないこと
- 型チェック・ESLint・ビルドは成功しました。テストは98件が成功しています(キャンセル規定、メール送信、返金の呼び出し、キャンセルの同時実行、確認メールの二重送信防止など。データベースにつなぐ分はローカルのPostgreSQL 16で実行)。
- ローカルの本番起動で、確定済みの予約に「キャンセルする(返金 ¥4,400/100%)」ボタンが表示され、押すと予約が取消になることを確認しました。このときは Stripe のキーを設定していないため、返金は「失敗」として記録され、画面に「運営側で確認する」旨が出ることも確認しています。
- 未確認:Stripe の実際の返金(テストモードを含む)、Resend での実際のメール送信、送信元ドメインの認証、メールの到達率。この環境ではキーとドメインが無いため、公式ドキュメントの仕様どおりに書いたものです。
- このビルドは、環境の都合で
next build --webpackを使いました(標準の Turbopack のネイティブ部分がこの環境で読み込めないため)。
つまずきやすい点・省略したこと
- 返金の反映時期:返金を依頼してから、利用者のカード明細に反映されるまでの期間は、カード会社によって異なります。メールには「反映の時期はカード会社によって異なります」と書くにとどめ、日数は断定しません。
- 一部返金と手数料:Stripe の決済手数料が返金時にどう扱われるかは、契約やプランによります。経理に関わるので、事前に確認が必要です。
- キャンセル後の再予約:取り消した枠は、第6回で作った排他制約の条件(支払い待ちと確定だけが対象)により、すぐ他の人が予約できます。テストで確かめています。
- 省略したこと:管理者による代理キャンセル、返金の失敗を知らせる通知(監視)、領収書・請求書のメール、メールの再送上限と配信停止の扱いは作っていません。「返金が失敗」の予約を探す関数(
listRefundsToCheck)だけ用意しています。
発注者向けメモ:キャンセルと返金を開発会社に頼むときの確認点
キャンセルと返金は、お金と信用に直結するのに、見積もりの段階では見落とされやすい部分です。次の点を確認すると安心です。
- ☐ キャンセル規定(いつまでに何%返金か)を、事業者側で決めて、予約画面と利用規約に書いてあるか
- ☐ 返金額を、画面ではなくサーバー側で計算しているか
- ☐ ボタンの二度押しや同時操作で、二重に返金されない作りか
- ☐ 返金が失敗したとき、誰が・どの画面で気づき、どう対応するかが決まっているか
- ☐ 確認メールが届かなかったとき、送り直す仕組みがあるか。送信元ドメインの認証(SPF・DKIM)を誰が設定するか
- ☐ メールの送信サービスの費用と、アカウントの契約者(発注者名義か)が決まっているか
📰 出典:消費者庁「特定商取引法ガイド」
インターネットで物やサービスを販売する場合は、特定商取引法の通信販売のルールが関わることがあります。キャンセルや返品の条件の表示が必要かどうかは、事業の内容によって変わりますので、最終的な判断は消費者庁のガイドや専門家に確認してください。
開発会社への質問例です。
- 「返金に失敗した予約は、どの画面で確認できますか? 運営の手間はどのくらいですか?」
- 「キャンセル規定を変えたいときは、設定の変更だけで済みますか。それともコードの修正が必要ですか?」
- 「確認メールの送信が止まったとき、どのくらいで気づけますか?」
工数やリスクが増える条件は、キャンセル規定の種類が多い(スペースや日時で変わる)、一部返金や追加料金の精算、複数の決済方法(コンビニ払いなど返金方法が異なるもの)の併用、領収書の自動発行、メール文面の多言語対応などです。最初は「カード払い・規定は1種類」から始め、必要になってから広げるのが堅実です。
まとめと次回予告
この回では、予約が確定した後の運用に関わる部分を作りました。
- 返金額は、1つの関数にまとめたキャンセル規定からサーバーが計算する
- 予約を取消にする記録を先に行い、そのあと返金を頼む。失敗は記録して人が対応する
- 返金の依頼には予約ごとの合言葉を付け、二重返金を防ぐ
- 確認メールは「送った印を先に付けてから送る」ことで二重送信を防ぎ、送れなかった分は定期実行で送り直す
次回の【第9回】は「管理画面を作る」です。スペースや料金の登録、予約の一覧、返金に失敗した予約の確認など、運営側が使う画面と、その権限の分け方を作ります。前の回は第7回 Webhookで予約を確定するです。
この連載の記事一覧
この記事は連載「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で予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)
- 【Next.jsで作るオンライン予約サービス 第8回】予約確認メールとキャンセル・返金(キャンセル規定・Stripeの返金・メール送信)(この記事)









