サブスクの売上は、解約ボタンからだけ漏れるのではありません。もっと静かに、毎月、カードの期限切れと一時的な決済失敗から漏れ続けます。ユーザーは離れたつもりがないのに、カードが切れて課金が止まり、いつのまにかアクセスを失う——これが非自発的チャーン(involuntary churn)です。厄介なのは、これが「サービスへの不満」ではなくただの運用設計の欠落で起きること。つまり、ほとんどが取り戻せる売上です。
この記事は、Stripeでこの静かな売上漏れを止めるための実装ガイドです。挙動はすべてStripe公式ドキュメントで裏取りし、公式より判断軸(いつ・どう・なぜ)を厚くしています。私自身、金融リテラシー教育のサブスク学習プラットフォームで、Stripe Webhookの冪等化・順序保証・PII墨消し・銀行振込サブスクの状態機械を Next.js 16 モノレポに実装し、METI受賞の林業DX SaaSでは Stripe Connect による B2B サブスクを担当しました。その実装で効いた「支払い失敗をどう受け止めるか」を、ここに一般化して残します。
この記事の射程:Webhookの署名検証や冪等性の「基礎」は再説明しません。そこは姉妹記事Stripe決済を本番品質で実装する完全ガイド(Idempotency-Key・raw bodyの署名検証・2層冪等モデル)に分離済みです。サブスクの構成要素・従量課金・プロレーション・顧客ポータルの全体像はStripe Billing 実装ガイドに、実プロダクトのモノレポ解剖はサブスク学習プラットフォームのアーキテクチャ徹底解剖にあります。本記事は重複を避け、**「支払い失敗からの売上回収(revenue recovery)とダンニング、非自発的チャーンの抑制」**に一点集中します。
基準バージョン:Stripe Node SDK(
stripeパッケージ)の現行系列。SDKはリリース時点のAPIバージョンに自動ピン留めされ、TypeScript型がそのAPIバージョンと整合します(公式: API versioning)。サンプルは Next.js 16(App Router)+ TypeScript strict 前提です。
0. 全体像:自発的チャーン vs 非自発的チャーン
チャーン(解約・離脱)には、性質の異なる2種類があります。ここを混同すると、打ち手を間違えます。
| 種類 | きっかけ | 主な原因 | 正しい打ち手 |
|---|---|---|---|
| 自発的チャーン | ユーザーが「やめる」と決める | 価値不足・価格・乗り換え | プロダクト改善・解約理由の収集(Customer Portalのcancellation_details) |
| 非自発的チャーン | ユーザーは続けたいのに課金が止まる | カード期限切れ・残高不足・一時的な決済失敗・SCA未完了 | 本記事のテーマ:リトライ・ダンニング・カード自動更新・猶予期間 |
自発的チャーンはプロダクトの問題で、改善には時間がかかります。一方非自発的チャーンは、ほとんどが「決済の再試行とユーザーへの通知」という運用で取り戻せる——しかも実装は有限で、一度作れば永続的に効きます。費用対効果が最も高いのはこちらです。Stripeはこの領域を 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)。
┌──────────── 支払い成功 ───────────┐
▼ │
(新規) 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)。
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決済の本番ガイドに譲り、ここでは回収に必要なイベントだけを扱います。
監視すべきイベントは次の通り(公式: Subscription 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 し、以降は安全な型だけを扱うのが鉄則です(型エスケープを避ける)。
// 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の食い違い」という不整合バグを構造的に消せます。
// 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. 冪等な失敗ハンドラ本体
// 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つ。
- 権限更新の「唯一の点」は
customer.subscription.updated/deleted。payment_failedでは権限を直接いじらず、「危険フラグを立てる/通知する」だけにする。これで「失敗イベントとサブスク更新イベントが順不同で届く」状況でも、最終的な権限は常にstatusから導かれ、巻き戻りません(順序非依存)。 next_payment_attemptの null/非null で猶予か枯渇かを判定。これがアプリ側で「まだ回収を待つべきか」を知る公式の信号です。- すべての失敗・認証要求・回収成功を
trackEventで記録(§7の計測の土台)。握り潰さない。
Automations を使う場合、
invoice.payment_failedはnext_payment_attemptをセットしなくなり、代わりにinvoice.updatedがそれを運ぶ、という公式の注意があります(Smart Retries)。Automations導入時はinvoice.updatedも購読してください。
3. Smart Retries か、独自リトライスケジュールか
リトライ戦略は2択です。Stripeの推奨は明確に 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はそもそもリトライしません(非リトライ対象)。「リトライしているはずなのに再試行されない」時は、まずこのハードデクラインを疑ってください。この場合の回収は、リトライではなくダンニング(ユーザー自身のカード更新)に賭けることになります。
4. ダンニングメールと顧客ポータル:ユーザー自身にカードを直させる
リトライが「機械側の回収」なら、ダンニングは**「人間側の回収」です。カード期限切れや残高不足は、最終的にはユーザーが新しいカードを登録しない限り解決しません**。ここでカード番号を自前のフォームで受け取るのは最悪手(PCI負担・SCA対応・i18nを全部抱える)。Stripeにホストさせるのが唯一正しい設計です。
4-1. Stripeが自動で送るダンニングメール
Dashboard の Settings → Revenue Recovery → Emails で有効化すると、Stripeが以下を自動送信します(公式: Customer emails)。各メールにはカード更新ページへのリンクが含まれます。
- 支払い失敗の通知(理由つき。例:カード期限切れ)— 更新ページへのリンク付き
- カード期限切れの事前通知(登録カードの有効期限の約1ヶ月前)
- 支払い確認の通知(3DS/SCAやBoleto等、ユーザーの確認が必要な場合)— Stripeホストの確認リンク
- 更新リマインダー(次回請求日の前)
つまり、「支払いが失敗したことをユーザーに伝え、カードを更新してもらう」フローは、コードを一行も書かずにStripeに任せられる。アプリ側がやるのは、§2でフラグを立てた past_due のユーザーにアプリ内でもバナーを出して導線を二重化することだけです。
リンクには寿命があります。サブスクが
canceled・incomplete_expired・unpaidになる、現在の更新期間が過ぎる、等でメール内リンクは失効します(公式)。だからこそ、アプリ内には常に有効なCustomer Portalへの導線を別途用意します。
4-2. 顧客ポータルへの導線(SCA/3DS再認証もここで完結)
アプリ内バナーから飛ばす先は Customer Portal です。カード更新・未払い請求の即時支払い・3DS再認証がすべてStripeホストのUIで完結します。アプリはセッションを作ってリダイレクトするだけ。
// 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 のユーザーに見せるアプリ内バナーの例。機能は使わせたまま(猶予)、目立つ導線だけ出すのが回収率を上げるコツです。
// 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 一本で判定できます。
// 猶予判定の一例: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の集計と突き合わせ可能にします。重要なのは**「失敗」と「回収」をペアで観測**すること。
// 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. よくある落とし穴(回収を殺す実装)
私が現場で見た/自分で踏みかけた、回収を台無しにするパターンです。
- 初回失敗で即アクセス遮断。最頻出にして最悪。
past_dueは「リトライ中=回収のチャンス」の局面。ここで切ると、Stripeが取り戻せたはずの売上を自分で捨てます。past_dueは猶予、unpaidで剥奪。 invoice.payment_action_required(SCA/3DS)を無視 or 失敗扱い。カードは有効なのに認証待ちなだけ。アクセスを切ると優良顧客を失います。認証導線へ誘導が正解。- 失敗ハンドラが非冪等。Webhookは二重配信・順不同が正常系。
payment_failedで直接権限をいじり、後から届いたsubscription.updatedで巻き戻る——という不整合を生みます。権限更新はsubscription.updated/deletedの一点に集約し、event.id/event.createdで冪等化(基礎は姉妹記事)。 - ダンニングを一切しない。リトライ(機械)だけでは、カード期限切れは直りません。**ユーザー自身のカード更新(ダンニング+ポータル)**がないと、期限切れ起因のチャーンは丸ごと取り逃します。
- カード番号を自前フォームで受ける。PCI・SCA・i18nを全部抱え込み、しかも回収率は上がりません。Customer Portal/Stripeホストのページに委譲。
- アプリ側で猶予タイマーを自作してStripeのリトライ期間とズレる(§5)。猶予の終端は
status遷移に従う。 charge_automatically前提の自動化をsend_invoiceのサブスクに期待する。後者はSmart Retriesが効きません。集金方法ごとに回収戦略を分ける。- 回収を計測しない/捏造した数値を語る。改善できないか、信頼を失うかの二択になります。事実だけを観測・報告(§6)。
まとめ:非自発的チャーンは「設計で塞げる売上漏れ」
サブスクの売上漏れの多くは、不満による解約ではなく、カード期限切れと一時的な決済失敗という運用上の事故です。そしてそれは、Stripeの公式機能とアプリ側の正しい権限設計を噛み合わせれば構造的に塞げます。要点を5行で。
- 状態機械を真実源にする。
active → past_due → unpaid/canceledを理解し、past_due=猶予(回収中)/unpaid=剥奪を厳守。アクセスはstatusから導出し、DBに二重持ちしない。 - 権限更新は
customer.subscription.updated/deletedの一点に集約。payment_failedではフラグと通知のみ。event.id/event.createdで冪等・順序非依存に。 - リトライは Smart Retries(既定8回/2週間)一択。枯渇後の終端(
unpaid推奨)と猶予期間を一致させる。next_payment_attemptの null で枯渇を検知。 - ダンニング+Customer Portal でユーザー自身にカードを直させる。SCA/3DSは「認証待ち」であって失敗ではない——切らずに導線へ。カード番号は自前で持たない。
- 回収を事実で計測する。
payment_failed↔payment_recoveredをペアで観測し、Stripeのアナリティクスと突き合わせる。未確認の数値は語らない。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」決済基盤を作る——その実例が、本記事のコードの出どころである金融リテラシー教育のサブスク学習プラットフォームです。Stripeでのサブスク設計・支払い失敗からの売上回収・ダンニング実装のご相談は、お問い合わせからどうぞ。