一回押したつもりの送金が、正しく一回だけ成立する仕組みを追います。
重要なコードの抜粋へ ↓このページでわかること
- 送金前の確認
- 同時実行と二重送金への対策
- トランザクションの意味
送金前に確かめること
送金元が本人の口座か、送金先が存在するか、両方が有効か、金額が正の整数か、残高が足りるかを確認します。本人確認が済んでいることも必要です。
画面でも入力を助けますが、最終判断はサーバーとデータベース側に置きます。画面を経由しないリクエストでも同じルールが適用されるためです。
一つの成功か、一つの失敗か
送金記録、仕訳、残高キャッシュ、監査ログを別々の通信で更新すると、途中失敗で食い違います。v1の execute_transfer は、これらを一つのデータベーストランザクション内で処理します。
「送金元だけ減った」という半端な状態を防ぐのが、この境界の目的です。
結果が分からない失敗への対応
リクエストを送った後に通信が切れた場合、サーバー側では送金が成功しているかもしれません。ここで新しいキーで再送すると二重送金の危険があります。同じ依頼を同じIdempotency-Keyで再試行し、履歴でも結果を確かめられるようにします。
同時実行では、画面に表示した残高だけを信じず、口座をロックしてデータベース内で再確認します。逆方向の送金が同時に起きる場合も考え、ロック順序を揃えることでデッドロックを避けます。
1万円送金の成立条件を並べる
AからBへ1万円送る場合、AとBが別口座で、どちらも有効、Aの持ち主がログイン中の顧客で、必要な本人確認が済んでいることを確かめます。金額は正の円整数で、Aに十分な残高が必要です。一つでも満たさなければ仕訳を作りません。
これらの条件を画面のチェックだけに置くと、直接APIを呼ぶ経路で抜けます。v1の送金処理ではデータベース関数内でも判定します。
二重送信と別内容の再利用は違う
同じキー、同じ内容の再送は、前回の結果を返すのが望ましい動きです。同じキーで金額や送金先が違うなら、単なる再送ではありません。衝突として拒否し、新たな取引を作りません。
この違いがないと、通信切断からの安全な再試行を許しながら、誤って別の送金まで同一視する危険があります。
ロック順序まで決める理由
AからBとBからAの送金が同時に走ると、一方がAを、もう一方がBを先にロックして待ち合う可能性があります。両処理で口座IDの順にロックすれば、待ち合いの輪を避けやすくなります。
同時実行の正しさは「残高を再確認する」だけで終わりません。どの順番で共有データを確保するかまで設計します。
OTPを使う場合の確定点
OTP経路では、申請を作った時点と送金が確定した時点を分けます。利用者がコードを入力しないまま期限切れになれば、送金は成立しません。コードが正しくても、確定時に口座が凍結されたり残高が変わったりしていれば、改めて業務条件を調べる必要があります。
「OTPに成功したから必ず送金成功」ではありません。認証の成功と金銭移動の成功を別々に画面へ伝えます。
金額と送金先の確定前確認
OTPの入力前後で、利用者が確認した送金先と金額が変わっていないことが必要です。確認コードが正しくても、別の送金内容に流用できれば追加認証の意味がありません。申請を識別するIDと内容を結び、確定時に再び業務条件を調べます。
また、送金先口座番号の入力ミスは帳簿が正しくても誤送金になります。確定前に受取人情報を読める形で示し、操作が成立した後の訂正手順も考えます。
監査の失敗も送金の失敗に含める
送金の残高と仕訳だけ確定して、監査ログの記録だけ失敗したら、後で誰が実行したか追えません。v1の送金トランザクションでは監査ログの挿入も同じ一括処理に含め、失敗すれば送金全体を取り消す設計です。
これは「必ず同時に成立してほしいもの」を決める例です。画面へ結果を返すことや通知を表示することまで同じ境界へ入れるかは、別に考える必要があります。
利用者へ返す結果を選ぶ
残高不足と、送金先が存在しない場合では、利用者が直す入力が違います。一方でデータベース内部のエラー文を公開すると、実装の情報が漏れることがあります。APIは安全なエラーコードと説明に変換し、画面は「何を確かめるか」を示します。
通信切断のように成立結果が不明な場合は、安易に失敗と断言しません。取引履歴や同じ依頼キーによる再照会・再試行につなげます。
重要なコードを読む
v1.0.0 の実コードから、この章で押さえたい箇所だけを抜粋しました。前後の定義は省略しています。
アプリから送金処理を一回呼ぶ
TypeScriptconst { data, error } = await client.rpc("execute_transfer", {
p_destination_account_number: input.destination_account_number,
p_amount: input.amount,
p_idempotency_key: idempotencyKey,
});
if (error) {
throw mapRpcError(error);
}まず理解するrpc はPostgreSQLの関数を呼びます。送金先、金額、再送を識別するキーを渡し、失敗したらAPI用のエラーへ変換します。
一歩深くここで口座残高を順に更新していません。複数の更新をアプリ側の別々の通信に分けると途中失敗で不一致が起こるため、送金の核心は execute_transfer の中に置きます。
出典:src/services/TransferService.ts
二つの口座を決まった順でロックする
SQL / PLpgSQLv_first_id := least(v_source_account.id, v_destination_account.id);
v_second_id := greatest(v_source_account.id, v_destination_account.id);
perform 1 from public.bank_accounts where id = v_first_id for update;
perform 1 from public.bank_accounts where id = v_second_id for update;まず理解する二つの口座IDを小さい順に並べ、FOR UPDATE で処理中の行をロックします。同時に届いた送金が、同じ残高を勝手に使わないためです。
一歩深くA→BとB→Aが同時に来てもロック順を統一できます。v1ではロック後に冪等性キーと帳簿から計算した残高を再確認し、待っている間に状態が変わっていないかも調べます。
出典:supabase/migrations/20260819163038_create_execute_transfer_function.sql