
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` に倒します。閾値を定数で持ち、それより前に作られた行を抽出するだけです。
```typescript
// 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 したあと、即座にリコンサイルを走らせる流れです。
```typescript
// 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 を組み立てて投げるだけです。
```typescript
// 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 でシードして回帰を防ぐ
課金は事故が起きてから直すと信頼を失います。静かに溜まるゴミと、見逃せない異常を分けて、それぞれに合った自動化を仕込んでおくことが、安心して運用を続ける鍵になります。

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