#891 STG Test Plan
2026-03-25
Issue #891 / Stripe Webhook

月額サブスク決済失敗時に term_to が延長されない問題

STG 環境での網羅的テスト計画 ― バグ再現 → 修正検証 → 影響範囲確認

テスト環境・前提条件

EC サイト

stg.shop.mirrorfit.jp

テストアカウント

本田さんから共有済みの toC アカウント

確認手段

DB 直接参照 / MirrorFit コンソール / GKE ログ

Stripe

STG ダッシュボード(テストモード)/ Stripe CLI

テスト対象テーブル

user_subscriptions
term_to, term_from, failed_at, retry_count, payment_failed_code, last_webhook_event_id, plan_type, created_at, updated_at
user_current_payment_results
user_id, status, updated_at
users
is_withdrawal, withdrawal_date, is_device_enabled

事前準備

  1. テスト対象ユーザーの user_id と stripe_customer_id を控える
  2. user_subscriptions の現在のレコードを SELECT で記録しておく
  3. Stripe Dashboard > Webhooks で現在の endpoint 設定をスクリーンショット保存
  4. stg 用アカウントでミラーフィットアプリにログインできるか確認NOW
  5. そのアカウントが既にサブスク契約中かどうか確認する
  6. API バージョン不整合のコード修正が必要か確認する webhook: 2020-08-27(旧形式)/ コード: 2025-03-31.basil(新形式)

テスト用カード

DECLINE
4000 0000 0000 0341
カード登録は成功、課金時に失敗
SUCCESS
4242 4242 4242 4242
通常の成功カード

有効期限: 任意の未来日(例: 12/30)、CVC: 任意の3桁(例: 123)

API バージョン不整合について(既知の課題)

Stripe には 2つの API バージョンが存在する。コード側(アプリ → Stripe の API 呼び出し時)と、webhook エンドポイント側(Stripe → アプリへのイベント送信時)で、それぞれ別のバージョンが使われる。

アプリ → Stripe(API 呼び出し)
2025-03-31.basil
コード52行目で指定。retrieve 等のレスポンスは新形式。
Stripe → アプリ(webhook)
2020-08-27
webhook エンドポイント作成時に固定。ペイロードは旧形式。

同じ invoice オブジェクトでも、取得方法によってフィールド構造が異なる:

// webhook ペイロード(旧形式 2020-08-27)
invoice.subscription
// API レスポンス(新形式 2025-03-31.basil)
invoice.parent.subscription_details.subscription

handleInvoicePaymentFailed(559行)は webhook ペイロードをそのまま使うが、新形式のフィールドで読もうとしている。旧形式のペイロードが届くため subscriptionIdundefined になる。

一方 handlePaymentIntentFailed(364行)は Stripe API で invoice を retrieve するため、コード側のバージョンでレスポンスが返る。こちらは正常に取得できる。

影響 subscriptionId が取れないため、term_tocurrent_period_end + 1ヶ月(正確な値)ではなく now + 1ヶ月(フォールバック値)になる。致命的ではないが正確ではない。この問題は Phase 1〜3 とは別に検証する。
PHASE 1
バグ再現(修正前)
invoice.payment_failed 未登録の状態で決済失敗を起こし、term_to が延長されないことを確認
1-1 修正前の状態で決済失敗
手順
  1. Stripe Dashboard > Webhooks で、STG endpoint に invoice.payment_failed が登録されていないことを確認
  2. テストユーザーの支払い方法をカード 4000000000000341 に変更
  3. 決済失敗をトリガー A: toC アプリからサブスク申し込み → テストカードで決済 B: Stripe Dashboard > Subscriptions > Actions > Create upcoming invoice C: Stripe CLI で stripe trigger invoice.payment_failed
  4. Stripe Dashboard > Events で発火したイベントを確認
確認
Stripe Dashboard
  • invoice.payment_failed イベントが発火しているが webhook 未配信
  • payment_intent.payment_failed が発火している可能性あり
DB (user_subscriptions)
  • term_to が変更されていないこと
  • failed_at, retry_count が更新されていないこと
GKE ログ
  • invoice.payment_failed の処理ログが存在しないこと(webhook 未登録のため届かない)
  • payment_intent.payment_failed が受信されている → invoice retrieve 成功 → shouldHandleFailure = false → handleSubscriptionPaymentFailed が呼ばれていないこと
EXPECT invoice.payment_failed は未配信、payment_intent.payment_failed は invoice 取得成功により term_to 更新をスキップ。結果として term_to が延長されない = バグの再現状態
payment_intent.payment_failed のコード (327-406行) では、invoice を Stripe API で retrieve する。この API 呼び出しはコード側の apiVersion 2025-03-31.basil でレスポンスが返るため、invoice.parent.subscription_details.subscription から subscriptionId を取得でき、shouldHandleFailure = false になる(invoice.payment_failed 側に処理を委譲する設計)。しかし invoice.payment_failed 自体が届かないため、どちらのハンドラでも term_to は更新されない。
PHASE 2
修正適用後の検証
webhook 登録 + API バージョン整合の修正後、決済失敗時に term_to が正しく延長されることを確認
修正の適用
  1. Stripe Dashboard > Webhooks に invoice.payment_failed を追加(これのみ。コード変更なし)
2-1 初回の決済失敗(既存サブスクあり)
手順
  1. テストユーザーに有効なサブスクがあることを確認(term_to が未来日、failed_at が NULL)
  2. 支払い方法を失敗カード 4000000000000341 に設定
  3. 決済失敗をトリガー
  4. Stripe Dashboard で invoice.payment_failed の配信が 200 OK か確認
確認
GKE ログ
  • handleInvoicePaymentFailed が実行されている(invoice.payment_failed が届いている)
  • handleSubscriptionPaymentFailed が呼ばれている
DB (user_subscriptions)
  • term_to が更新されている
  • failed_at が現在日時付近に設定されている
  • retry_count = 1
  • payment_failed_code にエラーコードが入っている
  • last_webhook_event_id に evt_xxx が入っている
DB (user_current_payment_results)
  • status = 'failure'
コンソール
  • ユーザーのサブスクが有効なまま表示される(term_to が延長済みのため)
API バージョン不整合により、subscriptionId が取れず term_to = now + 1ヶ月(フォールバック値)になる可能性がある。current_period_end + 1ヶ月との差異は「別検証: API バージョン」で扱う。ここでは term_to が更新されること自体を確認する。
2-2 2回目以降のリトライ失敗
手順
  1. テスト 2-1 の失敗状態(retry_count = 1)から開始
  2. 再度決済失敗をトリガー
確認
DB (user_subscriptions)
  • retry_count が増加(前回 +1 以上、最大 4)
  • last_webhook_event_id が新しい event ID に更新
  • failed_at は最初に設定された値のまま変わらない
  • term_to が再計算されている
2-3 重複イベント検知
手順
  1. テスト 2-1 or 2-2 実施後、last_webhook_event_id を控える
  2. Stripe Dashboard > Events から同じイベントを Resend(または stripe events resend evt_xxx)
確認
DB (user_subscriptions)
  • retry_countterm_tofailed_at が一切変化しない
GKE ログ
  • 重複イベントとしてスキップされたログが出力されている
2-4 リトライ期間中に決済成功
手順
  1. テスト 2-1 or 2-2 の失敗状態から開始
  2. 支払い方法を成功カード 4242424242424242 に変更
  3. 決済成功をトリガー
  4. payment_intent.succeeded が発火することを確認
確認
DB (user_subscriptions)
  • failed_at = NULL
  • retry_count = NULL
  • payment_failed_code = NULL
  • last_webhook_event_id = NULL
DB (user_current_payment_results)
  • status = 'success'
2-5 invoice.paid による term_to 延長(既存サブスク更新)
手順
  1. テスト 2-4 で決済成功した後、または正常なサブスクがある状態
  2. 次の月次請求を成功させる(Stripe Dashboard で手動トリガー)
  3. invoice.paid イベントが発火
確認
DB (user_subscriptions)
  • term_to が invoice の period.end に基づいて更新されている
  • 新規レコードが作成されず既存レコードが更新されている(extendSubscriptionPeriod パス)
GKE ログ
  • extendSubscriptionPeriod が呼ばれているログ
2-6 サブスクレコード未存在での初回失敗
手順
  1. テストユーザーの user_subscriptions レコードを全て削除(またはレコードなしの別ユーザーを使用)
  2. 決済失敗をトリガー
確認
DB (user_subscriptions)
  • 新規レコードが INSERT されている
  • term_from = Stripe の current_period_start
  • term_to = current_period_end + 1ヶ月
  • failed_at, retry_count, payment_failed_code が設定されている
  • plan_type が Stripe の price metadata から正しく判定されている
GKE ログ
  • createSubscriptionRecordOnFirstFailure が実行されたログ
2-7 payment_intent.payment_failed と invoice.payment_failed の共存
Stripe は同一の決済失敗で両イベントを発火する可能性がある。コード上 handlePaymentIntentFailed は invoice 取得成功時に shouldHandleFailure = false となり term_to 更新をスキップする設計。
手順
  1. 決済失敗をトリガー
  2. Stripe Dashboard で両イベントの発火を確認
  3. GKE ログで両ハンドラの実行順序を確認
確認
GKE ログ
  • handlePaymentIntentFailed: shouldHandleFailure = false → handleSubscriptionPaymentFailed を呼んでいない
  • handleInvoicePaymentFailed: handleSubscriptionPaymentFailed を呼んでいる
DB (user_subscriptions)
  • term_to が1回だけ更新されている(二重更新なし)
PHASE 3
リグレッション / 影響範囲テスト
invoice.payment_failed の追加が既存の webhook 処理に悪影響を与えていないことを確認
3-1 checkout.session.completed(新規サブスク購入)
手順
  1. テストユーザーで toC アプリからサブスクを新規購入
  2. Stripe Checkout でカード 4242424242424242 を入力して完了
確認
Stripe Dashboard
  • checkout.session.completed が 200 OK で配信
DB
  • orders テーブルにレコード作成
  • user_subscriptions に新規レコード作成
コンソール
  • ユーザーのサブスク状態が正しく反映
3-2 invoice.paid(新規サブスク開始 / 退会ユーザー復帰)
手順
  1. サブスクが期限切れ、またはレコードなしのユーザーで決済成功
  2. invoice.paid イベント発火
確認
DB (user_subscriptions)
  • isNewSubscriptionStart = true のパスで新規レコード作成
  • plan_type が正しい
DB (users) ※退会ユーザーの場合
  • is_withdrawal = false
  • withdrawal_date = NULL
  • is_device_enabled = true
3-3 charge.refunded(返金処理)
手順
  1. Stripe Dashboard から過去の charge に対して返金を実行
  2. charge.refunded イベント発火
確認
Stripe Dashboard
  • charge.refunded が 200 OK で配信
GKE ログ
  • handleChargeRefunded がエラーなく処理されている
パーソナル予約を伴わない返金の場合、metadata に personal_reservation_id がないため処理スキップされる。正常動作。
3-4 checkout.session.expired(セッション期限切れ)
手順
  1. Stripe Dashboard > Send test webhook で checkout.session.expired を送信
確認
Stripe Dashboard
  • checkout.session.expired が 200 OK で配信
GKE ログ
  • handleCheckoutSessionExpired がエラーなく処理されている
3-5 payment_intent.succeeded(通常の決済成功)
手順
  1. 失敗情報がない通常状態で決済成功させる
確認
DB (user_subscriptions)
  • failed_at 等が既に NULL の場合、resetSubscriptionPaymentFailure がエラーなく通過する
DB (user_current_payment_results)
  • status = 'success'
3-6 normalizeRetryCount の境界値
手順

Stripe CLI or Dashboard の Send test webhook で invoice.payment_failed を送信し、attempt_count を以下の値に変えてテスト

期待値
attempt_countretry_count
01
11
44
54
なし (undefined)1
既存の retry_count が 3 の状態で attempt_count = 1 を送った場合は retry_count = 4(3+1)になることも確認
3-7 subscriptionId 取得のフォールバック確認
handlePaymentIntentFailed は subscriptionId を以下の順で取得する:
  1. invoice.parent.subscription_details.subscription
  2. paymentIntent.subscription
  3. paymentIntent.metadata.subscription_id
  4. paymentIntent.metadata.subscriptionId
手順
  1. 通常の決済失敗で (1) が使われることをログで確認
  2. Stripe CLI で invoice なしの payment_intent.payment_failed を送信しフォールバックが動作するかログで確認
確認
GKE ログ
  • 通常パス: invoice 取得成功 → shouldHandleFailure = false
  • フォールバックパス: invoice 取得失敗 → shouldHandleFailure = true、フォールバックで subscriptionId 取得
修正後は invoice.payment_failed がメインパスとなるため、payment_intent.payment_failed のフォールバックは保険的位置づけ。ログレベルの確認で十分。
別検証
API バージョン不整合の調査と対応
Phase 1〜3 とは独立した検証。webhook エンドポイントの API バージョンとコードの不整合を解消する。

何が起きているか

webhook エンドポイントの API バージョンが 2020-08-27 のため、Stripe から送られる webhook ペイロードは旧形式。handleInvoicePaymentFailed のコード(559行)は新形式の invoice.parent.subscription_details.subscription で subscriptionId を取得しようとするが、旧形式ペイロードではこのフィールドが存在しない(旧形式では invoice.subscription)。

結果として subscriptionId が undefined → Stripe subscription を retrieve できない → term_tocurrent_period_end + 1ヶ月(正確な値)ではなく now + 1ヶ月(フォールバック値)になる。

なお handlePaymentIntentFailed(364行)は Stripe API で invoice を retrieve するため、コード側の API バージョン 2025-03-31.basil でレスポンスが返り、こちらは正常に取得できる。問題は webhook ペイロードを直接使う handleInvoicePaymentFailed のみ。

解消方法の選択肢

方法 A: webhook API バージョンを上げる

Stripe ダッシュボードで webhook エンドポイントの API バージョンを 2025-03-31.basil に更新する。ペイロードが新形式になり、コード修正不要。

確認事項: 全イベントのペイロード形式が変わるため、既存ハンドラ(handleInvoicePaid の data.lines.data 等)が新形式でも同じ構造かを Stripe changelog で事前確認。

方法 B: コードにフォールバックを追加

559行で旧形式 invoice.subscription も参照するフォールバックを追加する。webhook 設定はそのまま。

コード変更 + デプロイが必要。webhook 設定との不整合は残るが、影響範囲は限定的。

V-1 API バージョン不整合の影響確認
Phase 2 のテスト 2-1 実施時に併せて確認する。webhook 追加のみ(コード変更・設定変更なし)の状態での挙動を記録する。
確認
GKE ログ
  • handleInvoicePaymentFailed で subscriptionId が undefined になっているか
  • handleSubscriptionPaymentFailed で Stripe subscription の retrieve がスキップされているか
DB (user_subscriptions)
  • term_to の値が now + 1ヶ月(フォールバック)か current_period_end + 1ヶ月(正確)かを、Stripe ダッシュボードの current_period_end と突き合わせて判定
EXPECT subscriptionId が取れず term_to = now + 1ヶ月 になっていれば、不整合の影響が確認できた。方法 A or B の対応を次のステップで実施する。
term_to = current_period_end + 1ヶ月 になっている場合は、想定と異なる(webhook ペイロードが予想外に新形式で届いている可能性がある)。その場合は webhook ペイロードの raw body をログで確認して実際の形式を特定する。
V-2 対応適用後の検証
方法 A(webhook API バージョン更新)または方法 B(コードフォールバック追加)を適用した後に実施する。
手順
  1. V-1 のデータをリセット(failed_at, retry_count 等を NULL に戻す、または別ユーザーを使用)
  2. 決済失敗をトリガー
確認
GKE ログ
  • handleInvoicePaymentFailed で subscriptionId が正しく取得できている
  • handleSubscriptionPaymentFailed で Stripe subscription の retrieve が成功している
DB (user_subscriptions)
  • term_to = Stripe の current_period_end + 1ヶ月(正確な値)
EXPECT subscriptionId が正しく取得され、term_to が current_period_end + 1ヶ月 になっている。
方法 A を選んだ場合: 既存ハンドラ(handleInvoicePaid, handleCheckoutSessionCompleted 等)が引き続き正常動作することを Phase 3 のテストで併せて確認する。

テスト結果記録テンプレート

テスト ID
実施日時
テストユーザー user_id
Stripe event ID
修正前/後
user_subscriptions
id
term_to
term_from
failed_at
retry_count
payment_failed_code
last_webhook_event_id
plan_type
updated_at
user_current_payment_results
status
updated_at
users
is_withdrawal
withdrawal_date
is_device_enabled
Stripe webhook 配信
GKE ログ確認
判定
備考

完了基準

0 / 0 checks