
特に扱いが難しいのが、外部決済ページからSPAへ戻ってきた直後の判定である。戻り先の画面に表示された情報だけで決済完了を判断すると、ブラウザの戻る操作や再読み込み、タブの終了、Webhookの到着順によって、画面とデータベースの内容が食い違う。
チケットと料金
テーブルマウンテン
I
節約する
公式チケット窓口とオンライン購入
- 大人 240 €
- 子ども 120 €
- 割引 70 €
240 €
公式サイトII
優先入場
優先入場チケット · 所要時間2時間
事前予約ができる優先入場チケットで待ち時間を節約し、2時間でケーブルカーに乗車できます。
2354.12 ZAR
オンラインで予約III
ガイドツアー
ガイドツアー · 所要時間2.5時間
チケット購入の列に並ぶことなくケーブルカーに乗車でき、2.5時間のガイドツアーで歴史や生態系について学べます。
952.86 ZAR
オンラインで予約テーブルマウンテンのように、天候による運休や、指定日から数えた有効期間が関係するチケットでは、決済済みかどうかだけでなく、いつ利用できるのか、運休時にどの状態へ移すのかまで一貫して管理しなければならない。
この記事で扱うのは、個別サービスの実績を紹介するものではなく、テーブルマウンテン チケットの予約をSPAへ組み込む際に検討できる実装例である。決済の完了をSPAの一時的な状態から切り離し、Laravel側の決済レコード、Webhook、決済照会を中心に状態遷移を設計する。個人開発でも、どの情報を信頼し、どこで再確認するかを先に決めておけば、機能を小さく始めながら破綻しにくい構成にできる。
テーブルマウンテン観光の料金体系とオンライン予約のメリット
テーブルマウンテン・エアリアル・ケーブルウェイのチケットには、公式サイトでのオンライン予約、当日の窓口購入、優先搭乗枠など、複数の購入経路がある。販売条件や料金は時期、曜日、チケット種別によって変わるため、予約画面に固定値を埋め込むより、取得した販売情報をもとに表示する構成のほうが扱いやすい。
| 購入経路 | 実装上の特徴 | 予約サービスで考慮する点 |
|---|---|---|
| 公式サイトのオンライン予約 | 事前に日付や券種を選び、決済まで完了させる | 空き状況を取得した時点と、決済を確定する時点に差がある |
| 当日の窓口購入 | 現地で空き状況を確認して購入する | オンライン予約の注文状態と混同しない |
| 優先搭乗チケット | 通常の待機列とは異なる利用条件が付く | 通常券とは券種、価格、利用条件を別属性として持つ |
オンライン予約の利点は、訪問前に券種と利用日を選び、現地での購入手続きを減らせることにある。旅行当日の行動計画も立てやすく、窓口の混雑状況に左右されにくい。
ただし、オンラインで決済した時点で、チケットの利用まで完了するわけではない。少なくとも次のイベントは分けて考えたほうがよい。
- 予約内容を作成する
- 決済を開始する
- 決済が成功する
- チケットを発券する
- 指定日に利用する
- 運休時に振り替える
- 有効期間を過ぎる
- 必要に応じて返金や取消を行う
この流れを一つの購入完了フラグで表現すると、途中状態を扱えなくなる。決済だけ成功して発券処理が遅れている場合や、発券済みだが指定日がまだ来ていない場合、指定日に運休して振替待ちになった場合を、同じ値では表現できないからだ。
テーブルマウンテンのチケットには、指定日から原則7日間の有効期間が設定される。悪天候などでケーブルウェイが運休した場合に、別日程への振替が認められる運用を考慮したものだ。ここで注意したいのは、購入日から7日間ではなく、指定した利用日を基準に有効期間を考える点である。
今日購入して数日後の利用日を指定する場合、購入確定日と利用可能期間の起点は一致しない。予約テーブルに購入日時だけを保存していると、有効期限の計算や運休後の振替で無理が出る。決済日時、指定利用日、有効期間の終了日を分けて保持するのが自然だ。
チケットの購入と利用は同じイベントではない。決済が完了した時点で、利用期間と運休時の扱いまで追跡できる状態にしておく。
また、運行情報の主体も明確に分けておきたい。天候や運休に関する確定情報を出すのは、決済プロバイダではなくケーブルウェイ公式である。決済プロバイダは支払いの成否を返すサービスであり、施設の運行判断を持っているわけではない。
予約サービスでは、決済情報と運行情報を同じ外部連携として扱わない。決済は決済プロバイダのWebhookや照会APIを通じて確認し、運行可否はケーブルウェイ公式が案内する情報を基準に反映する。どちらも外部データではあるが、意味も更新条件も異なる。
SPAにおける外部決済リダイレクトと状態管理の技術的課題
SPAに外部決済を組み込む場合、少なくとも三つの状態が並行して存在する。
- SPAフロントエンドの状態:選択中の商品、入力内容、画面上の進行状況
- 決済プロバイダの状態:決済セッション、与信、売上確定、失敗、取消、期限切れ
- 自社バックエンドの状態:注文、決済レコード、発券可否、チケットの利用状態
これらは同時には更新されない。ユーザーが決済ページで入力を終えても、ブラウザが自社サイトへ戻る前にWebhookが届くとは限らない。反対に、Webhookによるサーバー側の更新が先に完了していても、ユーザーがブラウザを閉じていればSPAはその結果を表示できない。
ここで、リダイレクト先のクエリパラメータだけを見て決済完了と判断する設計は危険になる。リダイレクトは決済プロバイダからブラウザへ返される案内であり、自社サーバーが検証した最終結果ではない。URLの値は改変でき、ブラウザの履歴や戻る操作の影響も受ける。
SPAでは、画面表示のための一時的な状態と、サーバー側で確定した状態を分離する。決済直後は画面上で「確認中」として扱い、バックエンドへ注文の最新状態を問い合わせる。決済の確定後にだけ、チケットの発券や利用可能表示へ進める。
想定例として整理する状態不整合
実測ログや特定サービスの障害記録がない状態で、個別の利用者事例や遅延時間を断定することはできない。ただし、設計上は次のようなケースを想定しておく必要がある。
1. 決済プロバイダ側では売上が確定しているが、Webhookを自社バックエンドが受け取る前に、利用者がブラウザの戻る操作を行う。SPAが保持していた状態だけを初期化すると、支払い済みの注文が未決済に見える可能性がある。
2. 利用者が決済途中でブラウザを閉じる。後からWebhookが届いても、SPAのメモリ上にある注文情報は失われているため、再訪時に注文との紐付けを復元できない可能性がある。
3. Webhookの配送に失敗する、あるいは自社側の受信処理でエラーが起きる。決済プロバイダ側の状態と自社データベースの状態が食い違い、再確認の仕組みがなければ注文が保留のまま残る。
4. 決済成功の通知より前に、処理中や未確定を示す古いイベントが届く。イベントの到着順だけで状態を更新すると、確定済みの注文が以前の状態へ戻る可能性がある。
5. 予約作成時と決済確定時で販売条件が変わる。券種、価格、利用日などを照合しなければ、別の注文に対する決済を誤って紐付ける危険がある。
これらは特定アプリで実際に発生した事例としてではなく、外部決済とSPAを組み合わせる際の想定障害として扱うべきものだ。重要なのは、発生頻度を推測して優先順位を決めることではない。どのケースでも、最終的にサーバー側で決済プロバイダの状態を再確認できる経路を残しておくことである。
リダイレクトは画面を戻すための仕組みであり、決済を確定する仕組みではない。完了判定は自社サーバーが検証した状態を基準にする。
決済状態を表す値も、単純な真偽値では足りない。少なくとも、決済開始前、決済処理中、確認待ち、成功、失敗、期限切れ、取消、返金済みなどを区別する必要がある。さらにチケット側では、発券待ち、利用可能、利用済み、期限切れ、運休による振替待ち、振替済みといった別の状態が存在する。
決済とチケットを一つの status で表そうとすると、決済は成功しているがチケットはまだ利用前、という状態を表現できない。orders、payments、tickets を分け、必要な関係をIDで結ぶほうが、後からの照会や返金処理にも対応しやすい。
Laravelバックエンドによる決済ステータスの検証とWebhook処理
Laravel側では、Webhookを単なる通知受け取り用のコントローラとして実装しない。署名検証、イベントの重複排除、状態遷移、トランザクション、ログ記録を、それぞれの責務として分けておく。
構成の一例は次のようになる。
| 層 | 主な責務 | 設計上の注意 |
|---|---|---|
| ルーティング | Webhook専用のPOSTエンドポイントを公開する | HTTPSを必須にし、通常の画面用ルートと分離する |
| ミドルウェア | 署名や送信時刻を検証する | JSONへ変換した後ではなく、受信した生の本文を検証する |
| コントローラ | 受信イベントを受け付け、処理を委譲する | 重い処理をその場で実行しない |
| サービス層 | プロバイダ固有のイベントを内部形式へ変換する | 決済プロバイダの差分をここに閉じ込める |
| データベース処理 | 決済と注文の状態を更新する | 二重更新を防ぎ、トランザクションで整合性を保つ |
| キュー | メールや発券通知などの副作用を処理する | Webhookの応答を遅らせない |
署名検証は生のリクエスト本文で行う
決済プロバイダが署名を付けてWebhookを送る場合、検証対象は受信したリクエストの生の本文にする。先にJSONとして解析し、キーの並び替えやエスケープ処理を経たデータを使うと、送信元が計算した署名と一致しなくなることがある。
Laravelでは、リクエスト本文を取得して署名検証用のサービスへ渡す。検証ロジックをコントローラに直接書くより、専用のミドルウェアやサービスに分離したほうがテストしやすい。
署名アルゴリズム、ヘッダー名、タイムスタンプの扱いは決済プロバイダごとに異なる。複数のプロバイダに対応する場合は、共通のインターフェースを用意しながら、プロバイダ固有の検証処理を混ぜないことが大切だ。
IPアドレスの許可リストを追加の防御策として使える場合もあるが、それだけに依存するのは避けたい。送信元のネットワーク構成が変わる可能性があり、IPが正しいことと本文が改ざんされていないことは別の問題だからだ。署名検証を基本にし、決済プロバイダが提供する方式に従って検証する。
送信時刻を検証できる仕様なら、古いリクエストをそのまま受け付けない仕組みも加える。これは過去のWebhookを再送して状態を動かすリプレイ攻撃への対策になる。ただし、時刻の許容幅を機械的に狭くしすぎると、正当な遅延まで失敗扱いになる。検証に失敗したリクエストは破棄するだけでなく、監視用のログに残し、再処理の判断ができるようにしておく。
Webhookの重複配信を前提にする
Webhookは一度だけ届くとは限らない。受信側の応答が遅れた場合や一時的な通信エラーがあった場合、同じイベントが再送されることがある。したがって、同じ通知を受け取っても二重に発券しない設計が必要だ。
決済プロバイダが送る event_id や transaction_id を受信イベントテーブルに保存し、ユニーク制約を設定する。すでに処理済みのイベントであれば、同じ副作用を実行せずに受信処理を終える。決済成功イベントを二回受け取ったからといって、チケットを二枚発行してはいけない。
ただし、重複排除のキーを一つに決める際には、プロバイダの仕様を確認する必要がある。イベントごとに異なるIDが発行されるなら、イベントIDだけでは同一取引に対する重複処理を防げない場合がある。取引ID、注文ID、イベント種別を組み合わせる設計が必要になることもある。
また、イベントを記録したことと、内部処理が完了したことは別に管理したほうがよい。受信済みだが処理失敗、検証済みだがキュー投入待ち、といった状態を残せれば、再処理の判断がしやすくなる。
状態遷移に逆行を許さない
Webhook処理では、通知の到着順が常に理想的とは限らない。成功通知の後に、以前の処理中イベントが届く可能性もある。そのため、現在の状態から許可される遷移を定義しておく。
たとえば、次のような遷移を考えられる。
createdからpendingへ進むpendingからpaidまたはfailedへ進むpaidからrefundedへ進むpendingに戻す操作は、通常のWebhook処理では許可しないpaidの注文を、古いイベントによってpendingに戻さない
状態を直接代入するのではなく、現在の状態と受信イベントの組み合わせを確認してから更新する。許可されない遷移はエラーとして記録し、必要であれば管理者が調査できるようにする。
決済レコードと注文レコードを更新する場合は、同じデータベーストランザクション内で処理する。決済だけ成功になり、注文が保留のまま残る状態を避けるためだ。
一方、発券やメール送信のような外部副作用は、トランザクションの中で直接実行しない。データベースの更新が確定した後にキューへ渡し、失敗した場合は再試行できるようにする。トランザクション内で外部サービスを呼び出すと、応答遅延やロールバックとの関係が複雑になる。
Webhookの応答と内部処理を分ける
Webhookの受信エンドポイントでは、署名と最低限の形式を確認したら、可能な範囲で早く応答する。メール送信、チケット情報の生成、外部APIへの連携、分析イベントの送信などを同期処理に含めると、プロバイダ側のタイムアウトや再送を招きやすい。
Laravelのキューを使えば、受信したイベントを処理ジョブへ渡せる。ここでもジョブの重複実行を前提にし、同じイベントを処理しても結果が壊れないようにする。キューが失敗した場合に再試行できることは便利だが、無制限に再試行すると障害を見えにくくする。失敗回数や保留時間を監視し、最終的には管理者が確認できる場所へ送る。
Webhook専用ルートを通常の画面用ルートと分けることも忘れやすい。Laravelの web ミドルウェアグループを通すと、CSRF検証によって外部からのPOSTが拒否される場合がある。Webhookの仕様に合わせて api グループを使うか、必要なミドルウェアだけを個別に指定する。
CSRFを無効化するだけで終わらせてはいけない。外部から受け付ける入口である以上、署名検証、入力形式の検証、イベントの重複排除、監査ログを別の防御線として組み合わせる必要がある。
悪天候時の有効期間7日間ルールを考慮した予約データ設計
チケット予約では、決済状態と利用状態に加えて、指定日と有効期間を保存する。最低限のデータとしては、次のような項目が考えられる。
issued_at:決済と発券が確定した日時valid_from:利用可能期間の開始日valid_until:利用可能期間の終了日status:予約や発券の状態weather_status:運行情報との関係を示す状態reschedule_count:振替処理の回数original_visit_date:最初に指定した利用日current_visit_date:振替後を含む現在の利用日provider_transaction_id:決済プロバイダ側の取引ID
issued_at と valid_from を一つにまとめないことが重要だ。購入確定の日時は決済処理の監査に使い、利用期間の開始日はチケットの有効性判定に使う。用途が異なる値を一つの日時で表現すると、後から運休振替や問い合わせに対応しにくくなる。
決済状態とチケット状態を分離する
決済レコードには、決済処理の状態を持たせる。たとえば、作成済み、処理中、成功、失敗、返金済み、照会待ちなどだ。一方、チケットレコードには、発券待ち、利用可能、利用済み、期限切れ、運休による振替待ち、無効化済みといった状態を持たせる。
この分離によって、決済は成功しているが発券処理がまだ完了していない状態を表現できる。外部連携に障害があった場合も、決済を取り消さずに発券処理だけ再実行できる。
状態を一つの文字列に集約すると、次のような判断が難しくなる。
- 決済は成功したが、利用開始日がまだ来ていない
- 指定日に運休し、振替可能な状態になっている
- 振替後の新しい利用日が設定されている
- チケットは期限切れだが、返金処理は別途進行中である
- 決済プロバイダの照会結果を待っている
- 発券処理だけが失敗し、決済自体は確定している
予約、決済、チケットを別テーブルにしても、処理が分散しすぎると整合性を失う。注文IDや取引IDを共通のキーとして持たせ、管理画面では三つの状態を同時に確認できるようにする。利用者向けの表示は簡潔にしつつ、運用側では状態の内訳を追えることが望ましい。
運休情報はケーブルウェイ公式を基準にする
悪天候による運休を判定する際、気象予報と施設の運行判断を混同しない。予報上は天候が悪く見えても運行する場合があり、反対に予報が改善していても、施設側の判断で運休が続く場合がある。
予約サービスが反映すべきなのは、ケーブルウェイ公式が案内する運行情報である。決済プロバイダの管理画面や決済APIから運休を判断するものではない。
運行情報を自動連携できるかどうかは、ケーブルウェイ公式が提供する手段と利用条件に左右される。公式に公開されたAPIがあるなら、その仕様に従う。APIがない場合は、公式サイトなどで案内される情報を確認する運用を設ける。
許可のないクローリングや、公式SNSの投稿だけを根拠に自動で予約状態を変更する設計は、情報の欠落や更新タイミングを含めて慎重に検討しなければならない。外部ページの表示が一時的に取得できなかったことと、運休が決まったことは同じではない。
自動化できない部分を無理に自動化しないのも、個人開発では現実的な判断になる。運休が発表された際に管理画面から対象日を指定し、該当予約を振替待ちへ変更するだけでも、責務は明確になる。後から自動化する場合も、まずは手動処理で必要な状態と運用を確認しておくと、誤更新の範囲を抑えられる。
運休処理では、チケットをいきなり期限切れにしない。運休の原因、公式情報を確認した日時、対象となった利用日、振替の受付状況を記録する。利用者が問い合わせたときに、どの判断に基づいて状態が変わったのかを説明できるからだ。
7日間の計算は日付と時刻を分けて考える
指定日から原則7日間というルールを実装する際、日時の単純な加算だけで済ませない。施設が日単位で有効期間を扱うのか、終了日の営業時間まで利用できるのか、タイムゾーンをどこに置くのかを確認する必要がある。
データベースでは基準となる日付を保存し、表示時や利用判定時に施設のタイムゾーンを基準に計算する。購入者のブラウザが別の地域にある場合、ブラウザのローカル時刻をそのまま有効期限の判定に使うと、日付境界で表示が食い違う可能性がある。
「指定日から7日間」という表現も、実装時には解釈を固定しなければならない。指定日を1日目として数えるのか、指定日の翌日から数えるのかで終了日は変わる。仕様上の数え方をデータモデルとテストケースに落とし込み、画面表示、利用判定、振替処理で同じルールを使う必要がある。
振替の場合は、元の利用日を上書きしない。original_visit_date を残したうえで、現在の利用日を別に持たせる。振替前後の履歴を別テーブルに記録しておけば、同じチケットが何度も振り替えられていないか、誰がいつ処理したかを確認できる。
履歴には、変更前の利用日、変更後の利用日、変更理由、処理者、処理日時、参照した運行情報を残す。現在の状態だけを保存すると、後から見たときに、利用者の操作による変更なのか、運休対応による変更なのかが分からなくなる。
フロントエンドと決済プロバイダ間の非同期通信におけるエラーハンドリング
SPAのエラーハンドリングで避けたいのは、すべての失敗を一つのエラー画面に押し込むことだ。決済前の入力エラー、決済プロバイダ側の拒否、Webhookの確認待ち、運休による利用日変更では、利用者が取るべき行動が違う。
リダイレクト後は必ずサーバーから状態を取得する
決済プロバイダから戻った画面では、URLに含まれる注文識別子などを使って、自社バックエンドへ最新状態を問い合わせる。SPAが保持していた注文状態をそのまま表示してはいけない。
流れとしては、次のように整理できる。
1. SPAが予約内容をバックエンドへ送信し、注文を作成する。
2. バックエンドが注文IDと決済セッションを発行する。
3. SPAは決済ページへ移動する。
4. 決済完了後、SPAは戻り先で注文IDを使ってバックエンドへ照会する。
5. バックエンドは自社データベースの状態を返す。
6. 未確定の場合は確認中の画面を表示し、一定の条件で再照会する。
7. Webhookなどで確定した後、チケット情報を表示する。
決済プロバイダが返すパラメータは、注文を探すための手掛かりにはできるが、成功判定そのものには使わない。照会対象の注文がログイン中の利用者のものか、署名やトークンが正しいか、すでに別のチケットを発行していないかもバックエンドで確認する。
URLに決済プロバイダの取引情報を過剰に載せないことも大切だ。ブラウザ履歴、アクセスログ、リファラ、画面共有などを通じて情報が露出する可能性がある。戻り先には自社側で発行した識別子を使い、詳細情報は認証済みのAPIから取得するほうが安全である。
注文IDをそのまま連番で公開するより、推測されにくい公開用識別子を別に用意する方法もある。ただし、識別子を推測しにくくするだけで認可が不要になるわけではない。注文の所有者確認は、必ずバックエンド側で行う。
Webhookの遅延は異常ではなく、確認中として扱う
Webhookが戻り画面の表示より後に届くことは、設計上起こり得る。そこで、戻った直後に未決済と表示して注文を破棄すると、正当な決済を失敗扱いにしてしまう。
この間は、SPAに確認中の状態を表示する。単に読み込み中の表示を置くのではなく、画面上でできることと、まだできないことを分ける。
- 注文情報は表示する
- チケットの利用用コードは、確定まで表示しない
- 決済ボタンを再度押せないようにする
- ブラウザを閉じても後から確認できるよう、注文をアカウントに紐付ける
- 一定時間が経過しても確定しない場合は、再照会や問い合わせの導線を出す
再照会は、最初から短い間隔で無制限に繰り返すのではなく、回数や時間に上限を設ける。ブラウザのタブを開いたままにする方式だけでは、利用者が離脱した時点で確認が止まるからだ。
再照会の間隔を段階的に伸ばす方法もある。戻り直後は比較的短い間隔で状態を確認し、その後は間隔を広げる。重要なのは、画面上の再照会が止まっても、サーバー側で注文を追跡できる状態を維持することである。
Webhookが届かない場合の回復経路を用意する
Webhookの不着を完全に防ぐことはできない。送信側、ネットワーク、受信側のどこかで問題が起きる可能性があるため、受信処理だけに依存せず、決済プロバイダへ状態を照会する回復経路を用意する。
利用者向けには、決済完了ページやマイページから注文の再確認を実行できるようにする。管理者向けには、注文IDや取引IDを指定して決済プロバイダへ照会し、自社データベースとの差分を確認できる画面を用意する。
ただし、照会結果をそのまま無条件で成功に更新するのは危険だ。次の項目を確認してから状態を反映する。
- 照会した取引IDが自社注文のものと一致しているか
- 金額、通貨、商品、利用日が注文内容と一致しているか
- 決済が本当に売上確定の状態か
- すでに返金や取消が行われていないか
- その注文に対して発券処理が済んでいないか
- 同じ取引を別の注文へ紐付けていないか
自動照会を導入する場合も、最終的には同じ検証をサービス層で通す。Webhook経由と管理画面経由で別々の状態更新処理を作ると、片方だけ検証が不足する事故につながる。
ブラウザを閉じた利用者を前提にする
決済途中でブラウザを閉じる利用者は必ずいる。これは必ずしも決済失敗を意味しない。決済画面へ移動した後に閉じたのか、カード入力中に閉じたのか、売上確定後に閉じたのかは、ブラウザ側からは判断できない。
そのため、決済開始前に自社側で注文と保留中の決済レコードを作成しておく。次回訪問時には、ログイン中の利用者に紐付く保留注文を表示し、バックエンドから現在の決済状態を確認する。
ローカルストレージに取引IDだけを保存する方法は補助的には使えるが、それを唯一の復元手段にしない。ローカルストレージは端末やブラウザをまたぐと失われる。別の端末からログインした場合にも注文を確認できるようにするなら、サーバー側の注文紐付けが必要だ。
匿名利用を許可する場合は、短期トークンやメールによる照会など、個人情報を露出させない復元方法を別途設計する。注文番号だけで詳細情報を返す方式は、番号が漏れた場合の影響を考えなければならない。
保留注文をいつまで保持するかも決めておく。決済プロバイダ側でセッションが失効した後も、注文を無期限に残すと、マイページに古い注文が積み上がる。注文の保持期限と、再決済を許可する条件を分けて管理すると、履歴と現在の購入導線を整理しやすい。
失敗、取消、期限切れを同じ扱いにしない
カードの拒否、利用者によるキャンセル、決済セッションの期限切れ、通信エラーは、すべて画面上では「失敗」と表示できる。しかしバックエンドでは、原因を区別して保存したほうがよい。
失敗理由を細かく持つことで、再試行の可否を判断できる。決済の入力エラーなら同じ注文から再試行できるかもしれないが、在庫や販売枠が変わっている場合は、料金と空き状況を再取得して注文を作り直す必要がある。
外部照会が必要な不明状態を、利用者操作だけで新しい決済へ進めるのも避けたい。最初の決済が確定しているか分からない状態で新しい決済を作ると、二重決済の確認が難しくなる。
SPAの画面には、状態に応じた次の行動を示す。
- 確認中:画面を閉じても注文は失われないことを伝え、再確認の手段を出す
- 決済失敗:原因を断定せず、再試行または予約内容の確認へ誘導する
- 利用者キャンセル:注文を取り消せる状態として扱い、必要なら再予約へ進める
- 照会不能:同じ決済を何度も開始させず、注文番号を残して問い合わせへつなぐ
- 決済成功・発券待ち:決済済みであることと、チケット表示の準備中であることを分けて示す
- 運休・振替待ち:決済済みであることを維持し、利用日の変更に必要な手続きを示す
「もう一度購入する」ボタンをどの状態でも表示すると、二重決済の原因になる。再試行前に既存の注文と決済の状態を確認し、まだ有効な決済処理が残っているなら、新しい決済を作らず照会へ誘導する。
状態不整合を防ぐためのテストと運用
外部決済のテストは、カード決済が成功する正常系だけでは不十分だ。SPA、Laravel、決済プロバイダの三者で更新タイミングがずれる状況を意図的に作り、最終的にどの状態へ収束するかを確認する。
最低限、次のシナリオはテスト対象にしたい。
1. 決済ページから戻った直後にWebhookを受信する。
2. Webhookを受信する前に戻り画面を表示する。
3. 決済後、SPAを再読み込みする。
4. 決済途中でタブを閉じ、後から再訪する。
5. 同じWebhookを複数回受信する。
6. 成功イベントの後に古い処理中イベントを受信する。
7. 署名が不正なWebhookを受信する。
8. Webhook処理中にデータベース更新が失敗する。
9. 決済は成功しているが、発券ジョブが失敗する。
10. 指定日に運休となり、チケットを振替待ちへ変更する。
11. 振替後に元の利用日へ戻る操作を行う。
12. 有効期間を過ぎたチケットを表示する。
13. 決済成功後に利用者が別端末からマイページを開く。
14. 返金済みの取引に対して、古い成功Webhookが再送される。
15. 同じ注文に対して再決済ボタンを連続して押す。
テストでは、単にHTTPレスポンスが成功したかを見るだけでなく、注文、決済、チケットの三つのレコードがどう変化したかを確認する。特に、同じイベントを二度処理しても発券数が増えないこと、失敗したジョブを再実行しても状態が壊れないことは、決済連携の基本的な確認になる。
運休処理のテストでは、決済状態を不用意に失敗へ戻していないかも確認する。運休は支払い失敗とは別の問題であり、チケットの利用可能日や振替状態を変更する処理だからだ。
ログには、個人情報や決済情報をそのまま残さない。注文ID、内部イベントID、プロバイダ側の取引ID、イベント種別、処理結果、失敗理由を追跡できる形にし、カード情報などの機微なデータは記録しない。
Webhookの受信時刻と処理完了時刻を分けて記録すれば、遅延が送信側にあるのか、自社のキューやデータベースにあるのかを調査しやすい。署名検証に失敗した場合も、本文全体をログへ保存するのではなく、追跡に必要な識別子と失敗理由だけを残す。
運用では、次の状態を監視できるようにする。
- 保留注文の件数
- Webhookの署名検証エラー
- 重複イベントの受信数
- 発券ジョブの失敗数
- 決済照会によって復旧した注文数
- 長時間確認中の注文
- 振替待ちのチケット
- 返金や取消と注文状態の不一致
具体的な発生率を事前に置くのではなく、まず観測できる状態を作る。そのうえで自分のサービスの傾向を確認し、再試行や管理者対応の優先順位を調整する。個人開発では、最初から高度な監視基盤を用意するより、注文IDを軸に処理の経路を追えることのほうが実用的な場合も多い。
実装例で引き直す責務の境界
この種の設計例で大きな意味を持つのは、処理を増やすことそのものではない。SPAの表示、決済プロバイダの結果、Laravelの確定状態、ケーブルウェイ公式の運行情報を、それぞれ別の責務として扱うことである。
整理すると、次のようになる。
- SPAは利用者の操作と画面表示を担当する
- Laravelは注文と決済状態の信頼できる記録を担当する
- Webhookは決済プロバイダから届くイベントとして検証する
- 決済照会はWebhook不着時の回復経路として使う
- チケットモデルは決済モデルから分離する
- 7日間の有効期間は指定利用日を基準に計算する
- 運休情報はケーブルウェイ公式の案内を基準にする
- 発券や通知などの副作用はキューで処理する
- すべての状態更新は、注文との紐付けと許可された遷移を確認してから行う
SPAのインメモリ状態を捨てる必要はない。画面を滑らかに動かすための一時状態として使い、サーバー側の確定状態と役割を分ければよい。利用者が決済ページへ移動した後にブラウザを閉じても、注文がサーバーに残っていれば、再訪時に状態を復元できる。
テーブルマウンテン チケットの予約フローでは、購入日と利用日、決済完了とチケット利用、決済結果と運休判断がそれぞれ別の軸で動く。ここを一つの成功フラグでまとめようとすると、正常系では動いても、戻る操作やWebhookの再送、悪天候による振替で破綻する。
外部決済リダイレクトは、利用者を別ページへ移動させて戻すだけの機能に見える。しかし実際には、非同期通知、再試行、重複イベント、ブラウザ離脱、データの監査まで含めた状態管理の問題である。
個人開発のサービスであっても、最初からすべてを自動化する必要はない。決済の確定元、予約データの基準日、運行情報の出どころ、復旧の手段を明確にしておけば、機能を小さく始めても後から拡張できる。
最終的に守るべきなのは、画面上で一度「完了」と表示することではない。利用者が後から戻ってきても、管理者が注文を照会しても、決済とチケットの状態を同じ履歴から説明できることだ。
LaravelとSPAを分けて実装する価値は、見た目の構成にあるのではなく、責務と状態の境界を明確に保てる点にある。テーブルマウンテン チケットの予約API連携でも、その境界を先に設計しておけば、外部決済のリダイレクトは単なる画面遷移ではなく、検証可能な状態遷移として扱える。
関連記事: フロントエンド・API連携についてよくある質問 、 ツリートップス・アドベンチャー・キャンベラ チケットのオンライン購入手順と現地での注意点.
現地の基本情報
通貨: ZAR
通行区分: 左側通行
よくある質問
SPAで決済完了を判定する際、リダイレクト先のURLパラメータを信頼してはいけないのですか?
決済プロバイダからのWebhookが届く前にユーザーが画面を閉じてしまった場合、どう対処すべきですか?
悪天候による運休時のチケット振替はどのように管理すべきですか?
Webhookの重複配信によってチケットが二重に発券されるのを防ぐには?
決済とチケットの状態を一つのステータスで管理してはいけない理由は?
Photo: Danie van der Merwe from Cape Town, South Africa / CC BY 2.0 — Wikimedia Commons