# サブスク売上を取り戻すStripe実装ガイド2026：支払い失敗・ダンニング・スマートリトライで非自発的チャーンを減らす

> サブスクの「静かな売上漏れ」=非自発的チャーンを止めるStripe実装ガイド。支払い失敗の状態機械（active→past_due→unpaid/canceled）、冪等なWebhook処理、Smart Retriesと独自リトライの使い分け、ダンニングメールと顧客ポータルでのSCA再認証、past_dueを尊重する権限制御まで、TypeScriptの実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Stripe, B2B SaaS, TypeScript, アーキテクチャ設計, 決済, サブスクリプション, Next.js
- URL: https://tomodahinata.com/blog/stripe-subscription-dunning-failed-payment-recovery-churn-guide

## 要点

- サブスク売上漏れの多くはカード期限切れ等による非自発的チャーンで、運用設計で構造的に取り戻せる
- 状態機械を真実源にし、past_due は猶予（回収中）・unpaid は剥奪を厳守。アクセスは status から導出し DB に二重持ちしない
- 権限更新は customer.subscription.updated/deleted の一点に集約し、payment_failed ではフラグと通知のみに留める
- リトライは Smart Retries 一択（既定8回/2週間）。next_payment_attempt が null かで枯渇を検知する
- ダンニング＋Customer Portal でユーザー自身にカードを直させる。SCA/3DS の認証待ちでアクセスを切らない

---

サブスクの売上は、解約ボタンからだけ漏れるのではありません。**もっと静かに、毎月、カードの期限切れと一時的な決済失敗から漏れ続けます**。ユーザーは離れたつもりがないのに、カードが切れて課金が止まり、いつのまにかアクセスを失う——これが**非自発的チャーン（involuntary churn）**です。厄介なのは、これが「サービスへの不満」ではなく**ただの運用設計の欠落**で起きること。つまり、ほとんどが取り戻せる売上です。

この記事は、Stripeで**この静かな売上漏れを止める**ための実装ガイドです。挙動はすべて[Stripe公式ドキュメント](https://docs.stripe.com)で裏取りし、公式より**判断軸（いつ・どう・なぜ）を厚く**しています。私自身、金融リテラシー教育の[サブスク学習プラットフォーム](/case-studies/subscription-learning-platform)で、Stripe Webhookの冪等化・順序保証・PII墨消し・銀行振込サブスクの状態機械を Next.js 16 モノレポに実装し、METI受賞の林業DX SaaSでは Stripe Connect による B2B サブスクを担当しました。その実装で効いた「支払い失敗をどう受け止めるか」を、ここに一般化して残します。

> **この記事の射程**：Webhookの署名検証や冪等性の「基礎」は再説明しません。そこは姉妹記事[Stripe決済を本番品質で実装する完全ガイド](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions)（Idempotency-Key・raw bodyの署名検証・2層冪等モデル）に分離済みです。サブスクの構成要素・従量課金・プロレーション・顧客ポータルの全体像は[Stripe Billing 実装ガイド](/blog/stripe-billing-subscriptions-usage-based-customer-portal-guide)に、実プロダクトのモノレポ解剖は[サブスク学習プラットフォームのアーキテクチャ徹底解剖](/blog/subscription-platform-billing-idempotency-type-safety)にあります。本記事は重複を避け、**「支払い失敗からの売上回収（revenue recovery）とダンニング、非自発的チャーンの抑制」**に一点集中します。

> **基準バージョン**：Stripe Node SDK（`stripe` パッケージ）の現行系列。SDKは**リリース時点のAPIバージョンに自動ピン留め**され、TypeScript型がそのAPIバージョンと整合します（[公式: API versioning](https://docs.stripe.com/api/versioning)）。サンプルは Next.js 16（App Router）+ TypeScript strict 前提です。

---

## 0. 全体像：自発的チャーン vs 非自発的チャーン

チャーン（解約・離脱）には、性質の異なる2種類があります。ここを混同すると、打ち手を間違えます。

| 種類 | きっかけ | 主な原因 | 正しい打ち手 |
| --- | --- | --- | --- |
| **自発的チャーン** | ユーザーが「やめる」と決める | 価値不足・価格・乗り換え | プロダクト改善・解約理由の収集（Customer Portalの`cancellation_details`） |
| **非自発的チャーン** | ユーザーは続けたいのに課金が止まる | カード期限切れ・残高不足・一時的な決済失敗・SCA未完了 | **本記事のテーマ**：リトライ・ダンニング・カード自動更新・猶予期間 |

自発的チャーンはプロダクトの問題で、改善には時間がかかります。一方**非自発的チャーンは、ほとんどが「決済の再試行とユーザーへの通知」という運用で取り戻せる**——しかも実装は有限で、一度作れば永続的に効きます。費用対効果が最も高いのはこちらです。Stripeはこの領域を **[Revenue Recovery（売上回収）](https://docs.stripe.com/billing/revenue-recovery)** として体系化しています。本記事はこの公式機能を、自前のアプリ側実装と噛み合わせて使い切るための地図です。

Stripeが提供する売上回収の構成要素は、公式ドキュメント上は次の5つです。

- **Smart Retries** — 機械学習で「いつ再試行すれば成功しやすいか」を判断して自動リトライ
- **ダンニングメール（customer emails）** — 支払い失敗・カード期限切れ時に、更新リンク付きでユーザーへ自動送信
- **カード情報の自動更新（Card Account Updater）** — カード再発行時にStripeが番号を自動更新
- **売上回収アナリティクス** — 失敗率・回収率・回収額の可視化
- **コード不要の自動化（Automations）** — セグメント別のダンニングフロー

このうち**コードが要る／要らないの線引き**が設計の肝です。Stripe側（Dashboard）に寄せられるものは寄せ、アプリ側で持つべきは**「アクセス権（entitlement）の判定」だけ**——これが本記事の通底する設計方針です。

---

## 1. 支払い失敗の状態機械：active → past_due → unpaid/canceled

すべての出発点は、**サブスクの状態が支払い失敗でどう遷移するか**を正確に知ることです。アクセス権の判定はこの`status`の上に乗ります（[公式: Subscription statuses](https://docs.stripe.com/billing/subscriptions/overview)）。

```text
                  ┌──────────── 支払い成功 ───────────┐
                  ▼                                    │
  (新規) incomplete ──23時間以内に未解決──▶ incomplete_expired（活性化失敗・課金されない）
                  │
            初回成功
                  ▼
   ┌──────────▶ active ◀──────────┐
   │              │                │ 失敗中に支払い成功すれば復帰
 解約予約       請求失敗            │
   │ (期間末)      ▼                │
   │          past_due ────────────┘
   │              │  （Smart Retriesがこの間リトライ）
   │       リトライ枯渇 → Dashboard設定で分岐
   │       ┌──────┼──────────┐
   ▼       ▼      ▼          ▼
 canceled  canceled  unpaid  past_due のまま
```

ここで**最重要の区別**が `past_due` と `unpaid` です。

- **`past_due`**：直近の確定済み（finalized）請求書の支払いが失敗、または未試行。**サブスクは生きていて、Smart Retriesがこの状態の間リトライを続ける**。「まだ回収できる可能性がある」局面。
- **`unpaid`**：リトライをやり切ってもなお未払い。Stripeはもう支払いを試みない。公式は明確に **「`unpaid` ではプロダクトへのアクセスを取り消す（`past_due` の段階で試行と再試行は済んでいるため）」** と述べています。

そして**初回課金の失敗は別系統**です。新規サブスク作成時に最初の支払いが通らないと `incomplete` になり、**23時間**以内に解決しないと `incomplete_expired`（最終状態・課金は発生しない）になります。継続中の `active → past_due` とは扱いを分ける必要があります。

### 1-1. status → アプリの振る舞い対応表（これが設計の核）

非自発的チャーンを減らす設計は、結局この表に集約されます。**`status` ごとに「アクセスを許すか／猶予を与えるか／何を通知するか」を一意に決める**こと。

| `subscription.status` | アクセス | 猶予期間 | アプリの振る舞い | 通知 |
| --- | --- | --- | --- | --- |
| `trialing` | 許可 | — | 通常提供（トライアル） | トライアル終了予告（Stripeが7日前送信） |
| `active` | 許可 | — | 通常提供 | なし |
| `past_due` | **許可（猶予）** | あり（リトライ期間） | 機能は使わせつつ、アプリ内バナーで「支払い更新を」促す | ダンニングメール＋アプリ内バナー |
| `incomplete` | **拒否（未活性）** | 23時間 | 初回課金未完了。SCA待ちなら認証導線へ | 支払い確認リンク |
| `unpaid` | **取り消し** | なし | リトライ枯渇。アクセス剥奪、復帰導線（ポータル）を提示 | 最終通知＋ポータル誘導 |
| `canceled` | 取り消し | なし | 終了。再契約導線 | 解約確認 |
| `incomplete_expired` | 取り消し | — | 活性化失敗。新規契約として扱う | — |
| `paused` | （方針次第） | — | トライアル後に支払い方法なし等。請求停止中 | 支払い方法追加の促し |

設計上の急所は **`past_due` の行**です。ここで**即座にアクセスを切ると、回収できたはずの売上を自分で捨てる**ことになります（最頻出の事故。§8で詳述）。`past_due` は「猶予を与えてリトライを待つ」局面です。一方 `unpaid` まで来たら、リトライは尽きているのでアクセスを取り消すのが公式に沿った判断です。

### 1-2. collection_method：自動課金か、請求書送付か

状態遷移の前提として、**集金方法**が2種類あることを押さえます（[公式: collection_method](https://docs.stripe.com/api/subscriptions)）。

| `collection_method` | 挙動 | リトライ／ダンニング |
| --- | --- | --- |
| `charge_automatically`（既定） | 保存済みの支払い方法から自動課金 | **Smart Retries・ダンニングメールが効く**（本記事の主対象） |
| `send_invoice` | 請求書リンクをメール送付、ユーザーが手動で支払う | 自動リトライなし。`days_until_due` と未払いリマインダで督促 |

カード課金主体のSaaSは基本 `charge_automatically`。請求書ベース（B2Bの月締め等）は `send_invoice`。**売上回収の自動化が最も効くのは `charge_automatically`** です。本記事はこちらを主軸に進めます。

---

## 2. 冪等な支払い失敗Webハンドラ（リプレイ安全なTSコード）

`status` を真実源として扱うには、Stripeからの**Webhookでアプリ側のアクセス権を更新**します。ここで効くのが、姉妹記事で詳説した **2層の冪等性**（`event.id` で重複排除＋`event.created` で順序逆転を弾く）です。基礎は[Stripe決済の本番ガイド](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions)に譲り、ここでは**回収に必要なイベントだけ**を扱います。

監視すべきイベントは次の通り（[公式: Subscription webhooks](https://docs.stripe.com/billing/subscriptions/webhooks)）。

| イベント | 発火タイミング | アプリの振る舞い |
| --- | --- | --- |
| `invoice.payment_failed` | 請求の支払いが失敗 | `next_payment_attempt` を見て分岐。**リトライ予定があるなら猶予、無ければ剥奪準備** |
| `invoice.payment_action_required` | SCA/3DS等の追加認証が必要 | ユーザーを**認証導線（ポータル/確認リンク）へ誘導**。アクセスは切らない |
| `invoice.paid` | 支払い成功 | アクセスを（再）付与。回収イベントとして記録 |
| `customer.subscription.updated` | `status` 変化（`past_due`/`unpaid`化など） | **`status` に応じてentitlementを再計算**（唯一の真実源） |
| `customer.subscription.deleted` | サブスク終了 | アクセス即時取り消し |

### 2-1. 境界でStripeイベントをZodで絞る

Stripeの型は広いユニオンです。**境界で必要なフィールドだけをZodで narrow** し、以降は安全な型だけを扱うのが鉄則です（型エスケープを避ける）。

```ts
// lib/billing/recovery-events.ts
import { z } from "zod";

// past_due 判定に必要な最小フィールドだけを抜き出す
const InvoiceShape = z.object({
  id: z.string(),
  customer: z.string(),
  subscription: z.string().nullable(),
  // 次のリトライ予定（null = もうリトライしない＝枯渇のサイン）
  next_payment_attempt: z.number().int().nullable(),
  attempt_count: z.number().int(),
  status: z.enum(["draft", "open", "paid", "uncollectible", "void"]),
});

const SubscriptionShape = z.object({
  id: z.string(),
  customer: z.string(),
  status: z.enum([
    "trialing", "active", "past_due", "incomplete",
    "incomplete_expired", "unpaid", "canceled", "paused",
  ]),
  cancel_at_period_end: z.boolean(),
  current_period_end: z.number().int(),
  collection_method: z.enum(["charge_automatically", "send_invoice"]),
});

export type RecoveryInvoice = z.infer<typeof InvoiceShape>;
export type RecoverySubscription = z.infer<typeof SubscriptionShape>;

export const parseInvoice = (raw: unknown): RecoveryInvoice =>
  InvoiceShape.parse(raw);
export const parseSubscription = (raw: unknown): RecoverySubscription =>
  SubscriptionShape.parse(raw);
```

> なぜ生の `Stripe.Invoice` を直接使わないか。SDKの型は巨大で、`next_payment_attempt` のような「枯渇のサイン」を**読み落とすリスク**があります。境界で意図したフィールドだけを宣言すると、ハンドラの責務（SRP）が「このフィールド群でアクセスを決める」一点に絞れます。

### 2-2. アクセス権は status から「導出」する（保存しない）

最大の設計判断は **「entitlement（権利）をDBに二重持ちしない」** ことです。アクセスの真実源は Stripe の `subscription.status`。アプリDBはそれを**キャッシュ**として持つだけにします。これで「DBとStripeの食い違い」という不整合バグを構造的に消せます。

```ts
// lib/billing/entitlement.ts
import type { RecoverySubscription } from "./recovery-events";

export type AccessLevel = "full" | "grace" | "revoked";

// status → アクセスレベルへの「純粋関数」。§1の表をコードに落とす
export function resolveAccess(sub: RecoverySubscription): AccessLevel {
  switch (sub.status) {
    case "trialing":
    case "active":
      return "full";
    // past_due はリトライ中。猶予でアクセスは維持（=回収のチャンスを残す）
    case "past_due":
      return "grace";
    // unpaid はリトライ枯渇 → 公式に従いアクセス取り消し
    case "unpaid":
    case "canceled":
    case "incomplete":
    case "incomplete_expired":
      return "revoked";
    // paused は業務方針次第（ここでは安全側=取り消し）
    case "paused":
      return "revoked";
    default: {
      // 網羅性検査：statusに値が増えたらコンパイルエラーで気づく
      const _exhaustive: never = sub.status;
      return _exhaustive;
    }
  }
}
```

`never` による網羅性検査で、**Stripeが将来 status を増やしても、ここがコンパイルエラーになり対応漏れを防げます**。これは型安全を「将来の自分への保険」に変える実用テクニックです。

### 2-3. 冪等な失敗ハンドラ本体

```ts
// app/api/stripe/webhook/route.ts（抜粋・回収関連の分岐のみ）
import { parseInvoice, parseSubscription } from "@/lib/billing/recovery-events";
import { resolveAccess } from "@/lib/billing/entitlement";

// 注：署名検証・raw body取得・event.id重複排除・event.created順序チェックは
//     基礎記事の2層モデルに従い、ここでは「分岐の中身」だけを示す。
async function handleRecoveryEvent(event: import("stripe").Stripe.Event) {
  switch (event.type) {
    case "invoice.payment_failed": {
      const invoice = parseInvoice(event.data.object);
      if (!invoice.subscription) break;

      if (invoice.next_payment_attempt !== null) {
        // ★ まだリトライ予定がある＝past_dueで猶予を与える局面。
        //   アクセスは切らず、ユーザーに「カード更新を」促すだけ。
        await markPaymentAtRisk(invoice.subscription, {
          attemptCount: invoice.attempt_count,
          nextAttemptAt: invoice.next_payment_attempt,
        });
      } else {
        // ★ next_payment_attempt が null = リトライ枯渇のサイン。
        //   最終通知を送り、subscription.updated での unpaid 化に備える。
        await markRecoveryExhausted(invoice.subscription);
      }
      await trackEvent("payment_failed", { invoice: invoice.id });
      break;
    }

    case "invoice.payment_action_required": {
      // SCA/3DS。アクセスは切らない。認証導線へ誘導する通知だけ。
      const invoice = parseInvoice(event.data.object);
      if (invoice.subscription) {
        await notifyActionRequired(invoice.subscription);
        await trackEvent("payment_action_required", { invoice: invoice.id });
      }
      break;
    }

    case "invoice.paid": {
      // 回収成功 or 通常更新。どちらも status 基準で権限再計算するのが安全。
      const invoice = parseInvoice(event.data.object);
      await trackEvent("payment_recovered", { invoice: invoice.id });
      break;
    }

    // 真実源：status が変わったら entitlement を再計算（唯一の権限更新点）
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = parseSubscription(event.data.object);
      const access = resolveAccess(sub);
      await upsertAccessCache(sub.customer, sub.id, access, sub.status);
      break;
    }
  }
}
```

設計のポイントを3つ。

1. **権限更新の「唯一の点」は `customer.subscription.updated/deleted`**。`payment_failed` では権限を直接いじらず、**「危険フラグを立てる／通知する」だけ**にする。これで「失敗イベントとサブスク更新イベントが順不同で届く」状況でも、最終的な権限は常に `status` から導かれ、巻き戻りません（順序非依存）。
2. **`next_payment_attempt` の null/非null で猶予か枯渇かを判定**。これがアプリ側で「まだ回収を待つべきか」を知る公式の信号です。
3. **すべての失敗・認証要求・回収成功を `trackEvent` で記録**（§7の計測の土台）。握り潰さない。

> Automations を使う場合、`invoice.payment_failed` は `next_payment_attempt` をセットしなくなり、代わりに `invoice.updated` がそれを運ぶ、という公式の注意があります（[Smart Retries](https://docs.stripe.com/billing/revenue-recovery/smart-retries)）。Automations導入時は `invoice.updated` も購読してください。

---

## 3. Smart Retries か、独自リトライスケジュールか

リトライ戦略は2択です。Stripeの推奨は明確に **Smart Retries**（[公式](https://docs.stripe.com/billing/revenue-recovery/smart-retries)）。

| 観点 | **Smart Retries**（推奨） | 独自リトライスケジュール |
| --- | --- | --- |
| タイミング決定 | **機械学習**で成功率が高い時刻を選ぶ（端末の利用状況・国別の最適時刻など） | 固定ルール（前回からN日後、を手で指定） |
| 最大回数 | 既定 **8回／2週間**（1週〜2ヶ月で設定可） | **最大3回** |
| コード | 不要（Dashboard設定） | 不要（Dashboard設定） |
| セグメント別の出し分け | Automationsで可能 | 限定的 |
| 公式評価 | 「スケジュール設定のリトライより遥かに効果的」 | Smart Retriesより効果は劣る |

設定は Dashboard の **Billing → Revenue Recovery → Retries**。**特別な事情がなければ Smart Retries 一択**です。固定スケジュールが正当化されるのは、コンプライアンスや経理都合で「再試行日を厳密に固定したい」など、ごく限られた場合だけです。

そして**リトライ枯渇後の最終挙動**もここで設定します。これが §1 の状態機械の終端を決めます。

| 設定 | 枯渇後の `status` | 意味 |
| --- | --- | --- |
| Cancel subscription | `canceled` | 解約扱い（再契約が必要） |
| Mark as unpaid | `unpaid` | サブスクは残すがアクセスは剥奪。後から支払えば復帰可 |
| Leave past due | `past_due` のまま | 請求は続くが新たな自動リトライはしない |

どれを選ぶかは復帰のしやすさとのトレードオフです。**`unpaid` は「席を残したまま締め出す」**ため、ユーザーが後でカードを直せば（同じサブスクで）戻れる——B2B SaaSではこれが回収に有利なことが多いです。一方 `canceled` は完全に終了し、再契約フローを通す必要があります。

> なお、**ハードな拒否理由（`lost_card`・`stolen_card`・`authentication_required` 等）や支払い方法が無い場合、Stripeはそもそもリトライしません**（[非リトライ対象](https://docs.stripe.com/billing/revenue-recovery/smart-retries)）。「リトライしているはずなのに再試行されない」時は、まずこのハードデクラインを疑ってください。この場合の回収は、リトライではなく**ダンニング（ユーザー自身のカード更新）に賭ける**ことになります。

---

## 4. ダンニングメールと顧客ポータル：ユーザー自身にカードを直させる

リトライが「機械側の回収」なら、ダンニングは**「人間側の回収」**です。カード期限切れや残高不足は、最終的には**ユーザーが新しいカードを登録しない限り解決しません**。ここでカード番号を**自前のフォームで受け取るのは最悪手**（PCI負担・SCA対応・i18nを全部抱える）。**Stripeにホストさせる**のが唯一正しい設計です。

### 4-1. Stripeが自動で送るダンニングメール

Dashboard の **Settings → Revenue Recovery → Emails** で有効化すると、Stripeが以下を自動送信します（[公式: Customer emails](https://docs.stripe.com/billing/revenue-recovery/customer-emails)）。各メールには**カード更新ページへのリンク**が含まれます。

- **支払い失敗の通知**（理由つき。例：カード期限切れ）— 更新ページへのリンク付き
- **カード期限切れの事前通知**（登録カードの有効期限の約1ヶ月前）
- **支払い確認の通知**（3DS/SCAやBoleto等、ユーザーの確認が必要な場合）— Stripeホストの確認リンク
- **更新リマインダー**（次回請求日の前）

つまり、**「支払いが失敗したことをユーザーに伝え、カードを更新してもらう」フローは、コードを一行も書かずにStripeに任せられる**。アプリ側がやるのは、§2でフラグを立てた `past_due` のユーザーに**アプリ内でも**バナーを出して導線を二重化することだけです。

> リンクには寿命があります。サブスクが `canceled`・`incomplete_expired`・`unpaid` になる、現在の更新期間が過ぎる、等で**メール内リンクは失効**します（[公式](https://docs.stripe.com/billing/revenue-recovery/customer-emails)）。だからこそ、アプリ内には**常に有効な**Customer Portalへの導線を別途用意します。

### 4-2. 顧客ポータルへの導線（SCA/3DS再認証もここで完結）

アプリ内バナーから飛ばす先は Customer Portal です。**カード更新・未払い請求の即時支払い・3DS再認証**がすべてStripeホストのUIで完結します。アプリはセッションを作ってリダイレクトするだけ。

```ts
// app/api/billing/portal/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// past_due のユーザーが「支払い方法を更新」を押したら呼ぶ
export async function POST(req: Request) {
  const customerId = await getCustomerIdForSession(req); // 認証済みユーザーから解決
  if (!customerId) return new Response("Unauthorized", { status: 401 });

  const portal = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.APP_URL}/account/billing`,
  });
  // カード番号はアプリを一切経由しない（PCI負担をStripeに委譲）
  return Response.json({ url: portal.url });
}
```

`past_due` のユーザーに見せるアプリ内バナーの例。**機能は使わせたまま**（猶予）、目立つ導線だけ出すのが回収率を上げるコツです。

```tsx
// components/billing/payment-at-risk-banner.tsx（Server Component）
import { resolveAccess } from "@/lib/billing/entitlement";
import { getCachedSubscription } from "@/lib/billing/cache";

export async function PaymentAtRiskBanner({ customerId }: { customerId: string }) {
  const sub = await getCachedSubscription(customerId);
  if (!sub || resolveAccess(sub) !== "grace") return null; // past_due のときだけ表示

  return (
    <div role="alert" className="rounded-md border border-amber-300 bg-amber-50 p-4">
      <p className="font-medium text-amber-900">お支払いを確認できませんでした</p>
      <p className="text-sm text-amber-800">
        カードの有効期限切れ等が考えられます。サービスは引き続きご利用いただけますが、
        お早めに支払い方法をご更新ください。
      </p>
      {/* /api/billing/portal を叩いて Stripe ホストのポータルへ */}
      <form action="/api/billing/portal" method="post">
        <button className="mt-2 rounded bg-amber-600 px-3 py-1.5 text-white">
          支払い方法を更新する
        </button>
      </form>
    </div>
  );
}
```

> **SCA/3DSの落とし穴**：`invoice.payment_action_required` は「カードは有効だが、銀行が追加認証を要求している」状態です。**ここでアクセスを切ってはいけません**。やるべきは、ユーザーを認証完了に導くこと。Stripeのダンニングメール（支払い確認リンク）か、上のポータルで `requires_action` の請求を完了させれば `invoice.paid` が飛び、回収完了です。SCAは欧州を中心に「正規ユーザーの支払いが一時的に止まる」最大要因の一つで、**これを非自発的チャーンと取り違えてアクセスを切ると、優良顧客を自ら失います**。

---

## 5. 猶予期間と権限制御：past_due を尊重する

§1の表と§2の `resolveAccess` で骨格はできています。ここでは**猶予（grace）の運用**を詰めます。

猶予期間の長さは、実質 **Smart Retriesのリトライ期間（既定2週間）と一致**させるのが自然です。`past_due` の間はリトライが走っているので、その間アクセスを維持すれば「機械の回収」と「人間の回収（ダンニング）」の両方に時間を与えられます。リトライが枯渇して `unpaid` に落ちた瞬間に、`customer.subscription.updated` が飛び、`resolveAccess` が `revoked` を返してアクセスが自動で切れる——**期間の管理をアプリで持たず、Stripeの状態遷移に委ねる**のが最も壊れにくい設計です。

`cancel_at_period_end` の扱いも明確に。これは**自発的チャーンの予約**（ユーザーが期間末解約を選んだ）であって、支払い失敗とは無関係です。`cancel_at_period_end === true` でも `status` が `active` の間は**アクセスを維持**します（払った分は使える）。期間末に `status` が `canceled` に変わって初めて剥奪——ここも `status` 一本で判定できます。

```ts
// 猶予判定の一例：past_due でも「いつまで猶予するか」を可視化したい場合。
// 期間の真実は Stripe 側（リトライ設定）にあるので、UI表示のための補助に留める。
export function graceContext(sub: RecoverySubscription) {
  const isGrace = sub.status === "past_due";
  return {
    isGrace,
    // 表示用：現在の課金期間末。これを過ぎても unpaid 化は Stripe の設定次第。
    periodEnds: new Date(sub.current_period_end * 1000),
    // cancel_at_period_end は支払い失敗とは別軸（自発的解約の予約）
    willCancelAtPeriodEnd: sub.cancel_at_period_end,
  };
}
```

> **アンチパターン**：アプリ側に「猶予◯日」のタイマーを自前で持ち、`past_due` 検知から独自にカウントダウンする実装。Stripeのリトライ期間とズレて、**「Stripeはまだリトライ中なのにアプリは先にアクセスを切る」**（=回収機会の損失）か、その逆（=払ってないのに使えてしまう）が起きます。猶予の終端は **`status` の遷移に従う**のが正解です。

---

## 6. 回収の計測：recovered MRR を「事実」として持つ

「どれだけ取り戻せたか」を測れないと、改善のしようがありません。ただし**数値の捏造は厳禁**。Stripeの**売上回収アナリティクス**（Dashboard）が、失敗率・回収率・回収額を公式に集計します。これが一次情報です。

アプリ側では、§2で仕込んだ `trackEvent` を**自社の分析基盤に残す**ことで、Stripeの集計と突き合わせ可能にします。重要なのは**「失敗」と「回収」をペアで観測**すること。

```ts
// lib/billing/recovery-metrics.ts
type RecoveryEvent =
  | { kind: "payment_failed"; invoiceId: string; at: number }
  | { kind: "payment_action_required"; invoiceId: string; at: number }
  | { kind: "payment_recovered"; invoiceId: string; at: number };

// invoice 単位で「失敗→回収」が成立したかを後から照合できる形で記録する。
// 金額や率はここで「計算」せず、生イベントとして残す（捏造の余地を作らない）。
export async function recordRecoveryEvent(e: RecoveryEvent): Promise<void> {
  await analytics.append("billing_recovery", {
    kind: e.kind,
    invoice_id: e.invoiceId,
    occurred_at: new Date(e.at * 1000).toISOString(),
  });
}
```

回収率は「`payment_failed` を起点に、一定期間内に同じ `invoice` で `payment_recovered` が立った割合」として**事実から算出**できます。**推測値を載せない**——これは技術記事でも実プロダクトのレポートでも同じ規律です。MRRの絶対額やチャーン率の改善幅といった**未確認の数値を語らない**ことが、長期的な信頼になります（私の案件でも、確認できた事実だけをレポートに載せています）。

可観測性のもう一段は、**`invoice.payment_failed` の `attempt_count` の分布**を見ること。「1回目で回収できているか／何回もかかっているか」が、カードの質やダンニング文面の効きを映します。

---

## 7. よくある落とし穴（回収を殺す実装）

私が現場で見た／自分で踏みかけた、回収を台無しにするパターンです。

1. **初回失敗で即アクセス遮断**。最頻出にして最悪。`past_due` は「リトライ中＝回収のチャンス」の局面。ここで切ると、Stripeが取り戻せたはずの売上を自分で捨てます。**`past_due` は猶予、`unpaid` で剥奪**。
2. **`invoice.payment_action_required`（SCA/3DS）を無視 or 失敗扱い**。カードは有効なのに認証待ちなだけ。アクセスを切ると優良顧客を失います。**認証導線へ誘導**が正解。
3. **失敗ハンドラが非冪等**。Webhookは二重配信・順不同が正常系。`payment_failed` で直接権限をいじり、後から届いた `subscription.updated` で巻き戻る——という不整合を生みます。**権限更新は `subscription.updated/deleted` の一点に集約**し、`event.id`/`event.created` で冪等化（基礎は[姉妹記事](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions)）。
4. **ダンニングを一切しない**。リトライ（機械）だけでは、カード期限切れは直りません。**ユーザー自身のカード更新（ダンニング＋ポータル）**がないと、期限切れ起因のチャーンは丸ごと取り逃します。
5. **カード番号を自前フォームで受ける**。PCI・SCA・i18nを全部抱え込み、しかも回収率は上がりません。**Customer Portal／Stripeホストのページに委譲**。
6. **アプリ側で猶予タイマーを自作**してStripeのリトライ期間とズレる（§5）。**猶予の終端は `status` 遷移に従う**。
7. **`charge_automatically` 前提の自動化を `send_invoice` のサブスクに期待する**。後者はSmart Retriesが効きません。集金方法ごとに回収戦略を分ける。
8. **回収を計測しない**／**捏造した数値を語る**。改善できないか、信頼を失うかの二択になります。**事実だけを観測・報告**（§6）。

---

## まとめ：非自発的チャーンは「設計で塞げる売上漏れ」

サブスクの売上漏れの多くは、不満による解約ではなく、**カード期限切れと一時的な決済失敗という運用上の事故**です。そしてそれは、Stripeの公式機能とアプリ側の正しい権限設計を噛み合わせれば**構造的に塞げます**。要点を5行で。

1. **状態機械を真実源にする**。`active → past_due → unpaid/canceled` を理解し、**`past_due`=猶予（回収中）／`unpaid`=剥奪**を厳守。アクセスは `status` から**導出**し、DBに二重持ちしない。
2. **権限更新は `customer.subscription.updated/deleted` の一点に集約**。`payment_failed` ではフラグと通知のみ。`event.id`/`event.created` で冪等・順序非依存に。
3. **リトライは Smart Retries（既定8回／2週間）一択**。枯渇後の終端（`unpaid` 推奨）と猶予期間を一致させる。`next_payment_attempt` の null で枯渇を検知。
4. **ダンニング＋Customer Portal でユーザー自身にカードを直させる**。SCA/3DSは「認証待ち」であって失敗ではない——切らずに導線へ。カード番号は自前で持たない。
5. **回収を事実で計測する**。`payment_failed`↔`payment_recovered` をペアで観測し、Stripeのアナリティクスと突き合わせる。**未確認の数値は語らない**。

「一人 × 生成AI（Claude Code）で、速く・安く・安全に」決済基盤を作る——その実例が、本記事のコードの出どころである[金融リテラシー教育のサブスク学習プラットフォーム](/case-studies/subscription-learning-platform)です。Stripeでのサブスク設計・支払い失敗からの売上回収・ダンニング実装のご相談は、[お問い合わせ](/contact)からどうぞ。
