障害・修正記録
「払ったのにログインできない」を
生む決済バグを見つけて直した。
自分たちのteai.ioでLLM実験の予算を使い切り、実際にクレジットをチャージしようとして気づいた。Proプランの決済ボタンは、静的なStripe決済リンクに直接つながっているだけで、「誰が払ったか」をteai.io側に伝える仕組みが一切なかった。決済時のメールアドレスと登録メールが1文字でも違えば、支払いは成立するのにクレジットは誰のアカウントにも増えない。今日、この欠陥を発見してその場で直した記録。
結論から3行: ①Proプランの決済リンクはuser_idを一切運ばない静的URLだった。②webhook側は最終手段としてメール完全一致で本人を推測し、失敗すると無言で孤児アカウントを作る設計だった。③認証必須のcheckout API経由に切り替え、実user_idを直接紐付けることで解決した。同日中に実機で確認済み。
気づいたきっかけ
今日はteai/maxに未解決の数学予想を解かせる実験をしていて、実際にteai.ioの自分のアカウントのクレジットを使い切った。「じゃあ自分でチャージしよう」とダッシュボードの「チャージ」リンクをたどったところ、Pro/SMB/Businessという月額プランしかなく、しかもProボタンはhttps://buy.stripe.com/...という固定URLに飛ぶだけだった。「これ、本当に自分のアカウントに反映されるのか?」と疑問に思い、コードを読んだ。
見つかった欠陥
Stripeの「Payment Link」は、URLを配るだけで使える決済リンクだ。手軽な反面、誰がそのURLを踏んだかをアプリ側に伝える仕組みを自分で用意しない限り、決済完了時にteai.io側は「誰が払ったか」を一切知らない。webhook(checkout.session.completed)を受け取った時の本人特定は、次の順で試みられていた:
1. client_reference_id (Stripeチェックアウト作成時に付与できるID) → 常に空
2. metadata.session_key → 常に空
3. 決済時のメールアドレスで、teai.io登録メールを完全一致検索
4. 1〜3すべて失敗 → 新規の匿名アカウントをその場で作成し、そこにプラン・クレジットを付与
実害のシナリオ 会社のクレジットカードで法人メールを使って決済した、Stripeが自動補完したメールが少し違った、大文字小文字が違った——理由は何であれ3が外れると、4に落ちて誰にも紐付かない孤児アカウントが生まれる。支払った本人からは「クレジットカードは引き落とされたのに、teai.ioにログインしても何も増えていない」という不具合にしか見えない。
直した内容
- 認証必須化 — 決済APIをログイン必須にし、未認証は401を返すようにした。以前は誰でも(未ログインでも)呼べてしまう設計だった。
- 実user_idを直接紐付け — ログイン中に解決済みの本物のuser_idを、そのまま
client_reference_idとしてStripeチェックアウトに渡すようにした。webhook側はもう「推測」する必要がなく、一意に確定する。
- 静的リンクを廃止 — Proボタンを固定URLから、認証ヘッダ付きでチェックアウトAPIを呼ぶJavaScriptに変更。未ログインなら先に登録・ログインへ誘導し、完了直後に自動でStripeへ進む。
- 保険としてのアラート強化 — それでも孤児アカウントが作られるケースが万一あれば、以前は目立たないログだったものを、決済者のメールアドレス付きの明示的なアラートとして記録するようにした。
検証
$ curl -X POST https://api.teai.io/api/v1/billing/checkout -d '{"plan":"pro"}'
→ 401 Unauthorized (未認証は拒否されることを確認)
$ curl -X POST https://api.teai.io/api/v1/billing/checkout \
-H "Authorization: Bearer $TOKEN" -d '{"plan":"pro"}'
→ {"checkout_url":"https://checkout.stripe.com/c/pay/cs_live_...","plan":"pro"}
(実際に本番のStripe live checkoutセッションが発行されることを確認)
$ curl -s https://teai.io/pricing | grep -c "buy.stripe.com"
→ 0 (静的リンクが完全に消えたことを確認)
バックエンド側のユニットテスト(webhookの本人解決ロジック)21件も全てgreenであることを確認済み。
教訓 Stripeの「Payment Link」は導入が一番簡単な決済方法だが、その手軽さと引き換えに「誰が払ったか」の紐付けを自分で設計しないといけない。SaaSでこの手のリンクを使う時は、必ず「決済メールとアプリ内アカウントが一致しなかったらどうなるか」を最初に問うべきだった。