1万円の振込は、いつ本当に成立するのか。
ボタンを押した瞬間? 確認コードを入れた時? 画面の言葉とデータベースの出来事をつなぎ、お金を動かすための約束を読み解きます。
本文の説明・図・掲載コードだけで読み進められます。出典リンクは、権限のある人向け(非公開GitHub)です。GitHubで閲覧権限がある方だけ開けます。
ONE TRANSFER, FOUR MOMENTS
受付と成立は、別の出来事。 この違いが、この記事の出発点です。
この記事でわかること
- 画面、API、データベースが分担している仕事
- 振込をひとまとまりで記録する理由
- 二重クリックや通信断でも、元の依頼を確認する設計
この記事の目次
まず、1万円の行き先を見る
Aさんの口座に5万円、Bさんの口座に2万円があるとします。AさんがBさんへ1万円を振り込むと、Aさんは4万円、Bさんは3万円。二人の合計は7万円のままです。
ここでは、MiniBank内の普通預金どうしの振込を扱います。金額と残高は仕組みを説明するための架空の例です。実際に振込を実行した結果ではありません。
振込後は、Aさん 40,000円 / Bさん 30,000円。合計は 70,000円。
難しいのは、引き算と足し算そのものではありません。片方だけ成功させず、同じ依頼を二度実行せず、本人が意図した内容で動かすこと。この三つを、画面だけに任せず、奥の仕組みまでつないで守ります。
他行宛振込、現金の移動、手数料は含めません。現在の対応先はミニ銀行・本店営業部の普通預金です。MiniBankは学習・検証用のアプリであり、実在の銀行サービスではありません。
「受け付けた」は、まだ途中
Aさんが口座番号と金額を入力すると、画面は振込の依頼を送ります。ここで作るのは「この内容で振り込みたい」という記録です。依頼を受け付けたことと、お金を動かし終えたことを分けています。
- 画面:内容と、依頼の目印を送る
振込先、金額、依頼人名などに、同じ依頼を見分けるための一意のキーを添えます。
- API:入口で、形を確かめる
ログインしているか、口座番号は7桁か、金額などの形式は正しいか。受け付ける項目を限定します。
- サービス:処理先へつなぐ
設定された処理基盤へ依頼を渡し、戻ってきた結果が想定した形かを確認します。
- データベース:依頼を保存する
既定の処理経路では、request_transfer が振込依頼を作ります。この受付だけで資金は移動しません。
画面のチェックは入力を助けるため。残高、口座の持ち主、口座の状態といった重要な条件は、確定する側でも検証します。画面から来た値を、そのまま信用しない設計です。
実装をもう一段だけ読む:入口から依頼保存まで
以下は呼び出し関係を短く整理した読み取り用の図です。実行できるコードの抜粋ではありません。
TransferForm
→ POST /api/v1/transfers/request
→ requestTransfer
→ request_transfer(既定の legacy 経路)
→ 振込依頼を保存金額は画面から文字列として送られます。Idempotency-Key は、同じ依頼の再試行を区別する目印です。画面はHTTPの成功だけでなく、INSTRUCTION_ACKNOWLEDGED という「依頼を受理した」状態も確かめます。
TransferService は PAYMENT_BACKEND の設定に応じて legacy / Java を切り替えます。これは障害時に自動でもう片方へ流す仕組みではありません。この記事のSQLの説明は legacy 経路を対象にします。稼働環境の設定は今回確認していません。
TransferForm の依頼処理 ↗(非公開GitHub・要権限) / 入力スキーマ ↗(非公開GitHub・要権限) / TransferService ↗(非公開GitHub・要権限)
本人の確認で、確定へ進む
ログイン済みであることと、今回の振込内容を本人が確認したことは、同じではありません。現行の画面は、依頼を作ったあとに認証アプリの確認コード(TOTP)による確認へ進みます。
認証が済むと、画面は確定用のAPIを呼びます。データベース側は、依頼の持ち主や期限、今回の依頼に結び付いた認証を確かめてから、資金を動かす処理へ進みます。
大切なのは、ブラウザが「認証できました」と言うだけでは通れないことです。サーバー側で、依頼とセッションに対応する認証の記録を検証します。
本人確認は、確定へ進むための条件です。その後の残高チェックや記帳まで成功し、データベースの処理がまとまって確定して、初めて振込が成立します。
実装をもう一段だけ読む:認証と確定の境界
TransferMfa で今回の依頼を認証
→ onAuthorized
→ 確定用 API
→ confirmTransferOtp
→ confirm_transfer_otp
→ 認証の記録を消費
→ execute_transfer
→ completed関数名には otp が残っていますが、現行の確認処理は、依頼に結び付いたTOTP認証の記録を使います。confirm_transfer_otp は対象の依頼をロックし、持ち主、完了済みかどうか、期限を確認します。完了済みの同じ依頼なら、再度資金を動かさず同じ結果を返します。
確定中の金融処理でエラーが起きた場合、その内側の処理を取り消してから、依頼を失敗状態にし、失敗の監査記録を残します。「失敗したら何の記録も残らない」という意味ではありません。
MFA から確定への接続 ↗(非公開GitHub・要権限) / MFA API ↗(非公開GitHub・要権限) / 依頼の確定と失敗処理 ↗(非公開GitHub・要権限)
残高の裏に、二つの記録
画面には「残高4万円」と一つの数字が出ます。でも、数字を書き換えるだけでは、なぜ4万円になったのか説明できません。そこで、お金が動いた理由を帳簿に残し、その帳簿から残高を確かめるようにします。
Aさんの預金は、銀行から見ると「Aさんに返す義務のあるお金」です。会計では負債として扱います。銀行内でAさんからBさんへ1万円を振り込むと、銀行が返す相手の内訳が変わります。
| 記録するもの | 帳簿の記入 | この例での意味 |
|---|---|---|
| Aさんの預金に対応する口座 | 借方 10,000円 | Aさんへの預金債務が1万円減る |
| Bさんの預金に対応する口座 | 貸方 10,000円 | Bさんへの預金債務が1万円増える |
借方と貸方には、同じ1万円を記録します。ここで「借方なら減る、貸方なら増える」と丸暗記するのは避けましょう。今回は預金という負債の口座だから、この向きになるのです。資産など、口座の種類が違えば読み方も変わります。
二つの記録を、一つのまとまりにする
Aさんの分だけを先に保存し、その後でBさんの分を保存する途中に処理が止まると、片方だけお金が減ったように見えてしまいます。そこで、二つの帳簿の記録、振込の記録、表示用の残高の再計算を、同じデータベースのトランザクションで扱います。
必要な処理がそろったら確定し、途中で失敗したら金融処理をまとめて取り消す。このまとまりが、片方だけの振込を防ぐ基礎になります。
実装をもう一段だけ読む:ロック、元帳、残高
execute_transfer は関係する口座をUUID順にロックします。複数の処理が同時に同じ口座を変えないよう、順番をそろえて待ち合わせるためです。
口座を決まった順序でロック
→ 同じキーで完了していないか再確認
→ 元帳由来の残高・口座状態・利用条件を検証
→ ledger.post_journal_entry で記帳
→ transfers に振込を記録
→ cached_balance を再計算
→ TRANSFER_COMPLETED の監査記録表示用に保持する cached_balance は、帳簿の代わりになる正本ではありません。後続の修正では、記帳前から元帳との不一致があった場合も LG018 で拒否します。「今回の借方と貸方が合った」だけで、すでにある不一致を見逃さないためです。
post_journal_entry の戻り値はUUID単体です。この内部関数だけを直接呼ぶことは、認証と重複防止を備えた振込操作の代わりにはなりません。
ロックから記帳まで ↗(非公開GitHub・要権限) / 既存の残高不一致を拒否する修正 ↗(非公開GitHub・要権限)
うまくいかない時こそ、設計が見える
正常な振込の道順だけでは、安全は説明しきれません。よくある四つの場面から、何を区別しているかを見てみます。
入力時に十分でも、確定までに別の取引が動くかもしれません。確定処理では、ロックした状態で元帳由来の残高を確認します。
同じ依頼を示すキーと依頼IDを使います。完了済みなら同じ結果を返す仕組みで、同じ依頼を重ねて記帳しないようにします。
期限を過ぎた依頼を、そのまま確定させません。やり直す場合も、期限切れかつ未計上であることを確認する流れがあります。
「返事がない」と「失敗した」は別です。画面は結果不明として扱い、元の依頼の状態を照会します。自動で新しい振込を作りません。
たとえば記帳の直後に通信が切れると、振込は成立していても完了画面を受け取れません。状態確認で FINANCIAL_COMMITTED が返る場合は、送金済みとして案内します。ここで新しい依頼を作り直さないことが重要です。
同じものを何度受け取っても、金融上の結果を重ねない。こうした性質を冪等性(べきとうせい)と呼びます。ただし、毎回まったく新しいキーで送れば、同じ依頼とは見なせません。画面とサーバーが、元の依頼を見失わないよう連携する必要があります。
実装をもう一段だけ読む:結果不明からの戻り方
TransferForm は request_unknown を区別します。所有者の認証を伴う状態確認APIで元の依頼を調べ、確定済みならその結果を案内します。再読み込みだけでは自動再送しません。
期限切れで未計上と確認できた依頼について、ブラウザに保存した手がかりを消して入力に戻れるかは、canRestartExpiredTransfer で判断します。データベースの依頼を削除する操作ではありません。単なる「古い画面だから」という理由で、ブラウザ上の手がかりを消す設計ではありません。
v1から、何が変わった?
既存の制作記事は、v1.0.0のコードを土台にしています。振込の入口から、サービス、データベースへ進む読み方は、今も役立ちます。ただし、現在の実装と、同じ呼び出し順ではありません。
振込の骨組みを理解する
旧記事では、POST /api/v1/transfers から createTransfer、execute_transfer へ進む道順を説明しています。当時の設計を読むための記録です。
受付・認証・確定を分ける
現在は依頼を作り、今回の振込を認証してから確定します。旧APIは、認証済みの呼び出しにも OTP_REQUIRED を返す移行用の入口です。
だから、記事中の「v1」を一括で「v20」に書き換えるだけでは、正しい説明になりません。何が増えたかだけでなく、何を防ぐために経路が変わったかを読むことが、制作の過程を学ぶ意味になります。
根拠:旧APIの現在の動作 ↗(非公開GitHub・要権限) / 既存サイトが扱う版 ↗(非公開GitHub・要権限)
受付は、成立ではない。本人確認は、確定への条件。振込の記録と残高は、データベースでひとまとまりにする。そして結果が不明なら、元の依頼を確かめる。ひとつの振込は、このつながりで支えられています。
出典と、確認できた範囲
以下のリンクは非公開GitHubへの出典で、閲覧権限が必要です。本文はリンク先を開かなくても読める構成です。固定コード・設計資料を参照していますが、今回、この読み物のために銀行の取引やDB操作、本番試験は実行していません。実装や資料にある検査と、こちらで実施した検証を混同しないようにしています。
- 画面から依頼送信 ↗(非公開GitHub・要権限)
金額と一意キーを送り、受付状態を確かめる - 入力のスキーマ ↗(非公開GitHub・要権限)
APIの受け付ける形 - サービスと処理基盤 ↗(非公開GitHub・要権限)
request/confirmとlegacy/Javaの分岐 - 依頼の確定 ↗(非公開GitHub・要権限)
認証、ロック、期限、完了と失敗 - 記帳の金融処理 ↗(非公開GitHub・要権限)
ロック、残高、記帳とキャッシュ - 既存不一致の検知 ↗(非公開GitHub・要権限)
元帳とキャッシュの不一致を拒否 - 結果不明からの照会 ↗(非公開GitHub・要権限)
元の依頼と再開条件 - 旧振込APIの応答 ↗(非公開GitHub・要権限)
認証後OTP_REQUIRED
MiniBankは学習・検証用の銀行アプリです。架空の金額や人物は、仕組みを説明するための例です。
つながる読み物
ここまで読んだ記録を、ひとつ。
自分のペースで。あとから取り消すこともできます。