自作アプリと自作の銀行を、実際に金が動くまで繋いだ — 決済に口座振込を足し、E2Eで確かめる

金融 決済 マイクロサービス E2E Security

はじめに

個人で、二つのシステムを別々に作っている。ひとつは複数サービスを束ねる総合プラットフォーム(映像や音楽のレンタル・販売など)。もうひとつは、口座を持つ銀行系のシステムだ。それぞれの紹介は DVDレンタルからDMM型プラットフォームへ自作の銀行系システムは実際に何が動いているか に書いた。

この二つを、初めて一本の線で繋いだ。プラットフォーム側の決済手段に、自作の銀行の「口座振込」を足す。 ユーザーがレンタルの支払いを、自分の銀行口座からの振込で済ませられるようにする。しかも設計図の上で繋げて終わり、ではない。モックを外した実走E2Eで、実際に残高が動くところまで通した。 この記事は、その連携をどう設計し、どう E2E で確かめたかの記録だ。

振込が着地する先は、この自作の銀行(毎日銀行 / EVERY DAYS BANK)の口座だ。

振込先となる自作の銀行の口座画面(データはスタブ・作品のデモ。実在の口座ではない)


全体の流れ

ユーザーが決済画面で「口座振込」を選ぶと、プラットフォームが裏で銀行に振込を依頼し、完了したら支払いを記録して商品(ここでは宅配レンタル)を確定する。

利用者:決済画面で「口座振込」を選んで支払う


プラットフォーム(=銀行から見れば「加盟店」)
   ├─▶ ① 銀行にサービス認証する(加盟店の資格で)→ 短命のトークンをもらう
   ├─▶ ② そのトークンで振込を依頼する(冪等キー付き)
   │          │
   │          ▼
   │       銀行(口座・振込)──▶ 残高が動く

   └─▶ ③ 振込完了 → 支払いを記録し、宅配を確定する

一見単純だが、ここには落としてはいけない設計判断がいくつもある。


サービス間の認証:ユーザーの資格ではなく、加盟店の資格で

まず、プラットフォームが銀行に振込を頼むとき、誰の資格で頼むのか

ユーザーのログイン資格を流用してはいけない。プラットフォームは銀行にとって、ユーザーではなく加盟店だ。そこで、加盟店としての資格情報(クライアント資格)で銀行にサービス認証し、その場限りの短命トークンを受け取る。トークンには「これは加盟店の決済用だ」という用途が刻まれ、宛先の顧客も特定済みだ。銀行の秘密はすべて銀行の中に閉じ、プラットフォーム側はトークンをもらって使うだけになる。

コードにすると、プラットフォーム側は「対応づけ → トークン調達 → 振込」を、加盟店の資格だけで行う。

// プラットフォーム側:加盟店として銀行にサービス認証 → 短命トークン → 振込
UUID   bankCustomerId = mapping.resolve(everydaysCustomerId); // 会員IDを銀行側IDへ対応づけ
String token   = bank.issueServiceToken(bankCustomerId);      // 加盟店資格・短命・用途=決済
String idemKey = idempotency.newKey();                        // 二重振込を防ぐ冪等キー
bank.transfer(token, bankCustomerId, amount, idemKey);        // Authorization: Bearer <token>

こうすると、決済のたびにユーザーの認証情報が飛び交うことがなく、権限も「決済に必要な最小限」に絞られる。


秘密が漏れても、資金は抜けない:宛先の制限

サービス間のトークンは、いつか漏れうる。漏れた前提で被害を設計する。

もし加盟店の決済用トークンが漏れて、攻撃者が任意の口座宛に振込を依頼できたら、資金の抜き取りが成立してしまう。そこで銀行側に関門を置いた。「加盟店の決済用トークンで振り込めるのは、登録済みの加盟店口座宛だけ」。それ以外の宛先は、問答無用で断る(fail-closed)。

加盟店の決済用トークンで振込を依頼


銀行:宛先は「登録済みの加盟店口座」か?
   ├─ はい ─▶ 振込を実行
   └─ いいえ ─▶ 拒否(fail-closed)
        (=加盟店の秘密が漏れても、他人の口座へは1円も動かせない)
// 銀行側:加盟店の決済用トークンで振り込める宛先は「登録済みの加盟店口座」だけ
if (token.purpose() == MERCHANT_PAYMENT
        && !merchantAllowlist.contains(destinationAccountId)) {
    throw new DestinationNotAllowedException();   // → 403(fail-closed)
}

漏洩の被害を「登録済みの加盟店口座」という一点に閉じ込める。これで、秘密が漏れても資金抽出は成立しない。

そしてもう一つ、同じ振込を二度実行しないための冪等キーを、依頼のたびに発番する。通信の再送やユーザーの二度押しで、二重に振り込まれることを防ぐ。


モックは「動く」を証明しない

ここからが本題だ。

連携の実装中、相手の銀行サービスはモック(WireMock)で置き換えてテストしていた。モックは、決められた要求に決められた応答を返す。テストは緑になり、「動いている」ように見えた。

だが、モックを外して**実際のサービス群を相手に通しで動かした(実走E2E)**途端、二つのブロッカーが出てきた。どちらも、モックが決められた応答を返していたせいで隠れていた食い違いだった。一つずつ潰し、最後に——本物の口座の残高が、実際に動くところまで確認した。

【モック(WireMock)】
   決済 ─▶ 偽の銀行が「OK」を返す ─▶ テストは緑 … だが本物の食い違いは隠れたまま

【実走E2E】
   決済 ─▶ 本物のサービス群を通す ─▶ 隠れていた食い違いが2つ露見 ─▶ 直す ─▶ 残高が動く

モックは、自分が書いたコードが「自分の想定どおり動く」ことは示す。だが、相手との境界の食い違いは、決められた応答の裏に隠してしまう。結合が本当に成立しているかは、実際に通して、最後に副作用(残高が動く)まで見て初めて分かる。


「繋がった」と言えるのは、残高が動いたとき

結合は、実走E2Eでしか証明できない。 モックのテストが緑でも、それは自分の側の話だ。相手との境界の食い違いはモックが隠す。最後は本物を通し、狙った副作用(お金なら残高の変化)が起きるところまで確かめる。

サービス間は、ユーザーの資格でなく、最小権限の加盟店資格で。 そのうえで宛先を登録済みに限定し(fail-closed)、秘密が漏れても被害を一点に閉じ込める。金銭移動は冪等キーで二重を防ぐ。

「後で動かなかったら意味がない」を先に潰す。 検証を後回しにして良いことは無い。繋いだら、その場で本物を通す。

二つの自作システムが、初めてお金の線で繋がった。設計図の上では前から繋がっていたが、実際に残高が動いて初めて、「繋がった」と言える。


関連する記事

気軽にメッセージください

仕事の依頼、案件紹介、ご感想・ご質問なんでもお待ちしております。 高い志をもった同士の皆様と繋がることを切に願っております。 これからも人生を掛けたチャレンジを続けていきます。 何卒よろしくお願い致します。