ちびキャラクターがStripeの不完全なサブスクリプションを自動で処理するシステムフローを指差しています。24時間で孤立したレコードをクリーニングし、「安全な調整」「自動失効」「手動レビュー」「二重課金リスク」に分類して対応する様子が描かれ、自動リコンサイルの設計を示しています。

Stripe でサブスクリプション課金を運用していると、incomplete ステータスのレコードが静かに溜まっていくことがあります。多くは無害ですが、放置すると二重課金の温床になり、ユーザーからの問い合わせで初めて気づく、という事態を招きます。

この記事では、incomplete が孤立して残る仕組みと、それを「機械的に安全な修復」と「人手が必要な異常」に分けて自動で処理する設計を紹介します。

こんな人におすすめ

  • Stripe の Pricing Table や Checkout でサブスクリプション課金を提供している方
  • incomplete ステータスの扱いに悩んでいる方
  • Cloudflare Workers の Cron Triggers と webhook で課金の整合性を保ちたい方
  • 二重課金を「事故が起きる前に」検知する仕組みを作りたい方

なぜ incomplete が残るのか

Stripe の Pricing Table 経由で Checkout すると、決済が確定する前に incomplete ステータスのサブスクリプションが作られます。これは Stripe の正常な挙動で、3D セキュア認証や支払い手段の確認を待っている状態です。

問題は、ユーザーが途中で離脱したり、別経路(たとえば再読み込み後に新しいセッション)で決済を完了させたりするケースです。最初に作られた incomplete はどこからも参照されないまま孤立し、自分の DB に残り続けます。

このゴミ自体は課金しないので無害ですが、レコードが残っていると「このユーザーは本当に1契約だけか」を機械的に判定しづらくなります。整合性チェックのノイズになるのです。

設計の核:2種類に分けて考える

ここで重要なのは、起こりうる状態を 機械的に安全に修復できるもの人間の判断が要るもの に分けることです。

  • 安全な修復: 長時間放置された incomplete を期限切れに倒す。これは決済が成立していないことが確実なので、自動で処理してよい。
  • 人手が必要な異常: active / trialing / past_due が同一ユーザーに複数存在する。これは真の二重課金候補なので、勝手に消さず人間に通知する。

この線引きを最初に決めておくと、コードもアラートもシンプルになります。

24時間経過した incomplete を自動で倒す

24時間以上経過した incomplete は、もう決済が成立する見込みがないと判断して incomplete_expired に倒します。閾値を定数で持ち、それより前に作られた行を抽出するだけです。

// stale incomplete の判定
const STALE_INCOMPLETE_RECONCILE_AFTER_MS = 24 * 60 * 60 * 1000

function countStaleIncompleteRows(
  rows: SubscriptionRowSnapshot[],
  staleBeforeIso: string,
): SubscriptionRowSnapshot[] {
  return rows.filter((row) => {
    if (row.status !== 'incomplete' || row.created_at == null) return false
    return row.created_at < staleBeforeIso
  })
}

判定ロジックを純粋関数に切り出しておくと、テストで状態を流し込んで検証しやすくなります。

二重の安全網:nightly cron と webhook

修復の実行経路は2つ用意します。

ひとつは nightly cron によるバッチスキャンです。Cloudflare Workers の Cron Triggers で毎晩全ユーザーを走査し、stale な incomplete をまとめて倒します。これが取りこぼしの最終防衛線になります。

もうひとつは webhook による即時リコンサイル です。Stripe からサブスクリプション更新の webhook が届いたタイミングで、そのユーザーぶんだけをその場で整合させます。snapshot を upsert したあと、即座にリコンサイルを走らせる流れです。

// webhook → 即時 reconcile
await upsertSubscriptionSnapshot(db, snapshot, timestamp)
const reconciliation = await reconcileStripeIncompleteSubscriptionsForSubject(
  db, snapshot, timestamp,
)
if (reconciliation.activeLikeCount > 1) {
  console.error('[billing] duplicate active subscription candidates detected', {
    activeLikeCount: reconciliation.activeLikeCount,
    staleIncompleteCount: reconciliation.staleIncompleteCount,
  })
}

webhook は即時性、cron は網羅性。両方あることで「リアルタイムに直しつつ、漏れたものは夜間に拾う」という二段構えになります。

真の二重課金は人手に回す

active / trialing / past_due のような「実際に課金しうる」状態が同一ユーザーに複数ある場合は、自動修復の対象外です。どちらを残すべきか、返金が要るかは状況次第で、機械的に判断できません。

そこで、これらは Discord に通知して人手で対応します。検知数を埋め込んだ embed を組み立てて投げるだけです。

// Discord通知ペイロード
function buildDiscordPayload(result: SubscriptionAnomalySweepResult) {
  return {
    username: 'Stripe Subscription Monitor',
    allowed_mentions: { parse: [] },
    embeds: [{
      title: result.anomalyCount > 0
        ? 'Stripe subscription anomalies detected'
        : 'Stripe subscription monitor',
      color: result.anomalyCount > 0 ? 0xef4444 : 0x22c55e,
      fields: [
        { name: 'scanned subjects', value: String(result.scannedSubjectCount), inline: true },
        { name: 'reconciled incomplete', value: String(result.reconciledIncompleteCount), inline: true },
        { name: 'duplicate active-like', value: String(result.anomalyCount), inline: true },
      ],
    }],
  }
}

異常がなくても緑の通知を送る

ここで見落としがちなのが、異常がゼロのときも通知を送る という点です。

anomalyCount > 0 のときだけ赤いアラートを出す設計にすると、通知が来ない日が「正常」なのか「cron が止まっている」のか区別できません。そこで異常なしのときは緑色(0x22c55e)の embed を送り、スキャン件数も併記します。

これは cron の生存確認を兼ねます。毎晩緑の通知が来ることが、バッチが動いている証拠になるわけです。沈黙を「正常」と解釈しないための工夫です。

E2E テストで汚染状態を再現する

この手のロジックで怖いのは、リファクタで修復条件が静かに壊れて、また incomplete が溜まり始めることです。

対策として、E2E テストであらかじめ汚染された DB 状態をシードします。stale な incomplete 行や、複数の active-like 行を意図的に作っておき、cron / webhook を走らせて期待どおりに「倒すべきものは倒れ、通知すべきものは通知される」ことを確認します。

正常系だけでなく、壊れた状態からの回復を再現テストにしておくことで、回帰を確実に止められます。

まとめ

Stripe の incomplete サブスクリプション問題は、起こりうる状態を整理すればシンプルに対処できます。

  • incomplete は Checkout の途中状態として正常に生まれ、離脱すると孤立して残る
  • 「機械的に安全な修復」と「人手が必要な異常」を最初に分ける
  • 24時間経過した incomplete は自動で incomplete_expired に倒す
  • nightly cron(網羅)と webhook(即時)の二重安全網を張る
  • 真の二重課金は Discord で人手に回す
  • 異常ゼロでも緑通知を送り、cron の生存を確認する
  • 汚染 DB 状態を E2E でシードして回帰を防ぐ

課金は事故が起きてから直すと信頼を失います。静かに溜まるゴミと、見逃せない異常を分けて、それぞれに合った自動化を仕込んでおくことが、安心して運用を続ける鍵になります。