Chuyển đến nội dung chính
友田 陽大
Payments & billing
Stripe
B2B SaaS
TypeScript
アーキテクチャ設計
決済
サブスクリプション
Next.js

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

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

Published
Reading time
23 min read
Author
友田 陽大
Chia sẻ

サブスクの売上は、解約ボタンからだけ漏れるのではありません。もっと静かに、毎月、カードの期限切れと一時的な決済失敗から漏れ続けます。ユーザーは離れたつもりがないのに、カードが切れて課金が止まり、いつのまにかアクセスを失う——これが非自発的チャーン(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_dueunpaid です。

  • 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_requiredSCA/3DS等の追加認証が必要ユーザーを認証導線(ポータル/確認リンク)へ誘導。アクセスは切らない
invoice.paid支払い成功アクセスを(再)付与。回収イベントとして記録
customer.subscription.updatedstatus 変化(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つ。

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

Automations を使う場合、invoice.payment_failednext_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 subscriptioncanceled解約扱い(再契約が必要)
Mark as unpaidunpaidサブスクは残すがアクセスは剥奪。後から支払えば復帰可
Leave past duepast_due のまま請求は続くが新たな自動リトライはしない

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

なお、ハードな拒否理由(lost_cardstolen_cardauthentication_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 のユーザーにアプリ内でもバナーを出して導線を二重化することだけです。

リンクには寿命があります。サブスクが canceledincomplete_expiredunpaid になる、現在の更新期間が過ぎる、等でメール内リンクは失効します(公式)。だからこそ、アプリ内には常に有効な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 が飛び、resolveAccessrevoked を返してアクセスが自動で切れる——期間の管理をアプリで持たず、Stripeの状態遷移に委ねるのが最も壊れにくい設計です。

cancel_at_period_end の扱いも明確に。これは自発的チャーンの予約(ユーザーが期間末解約を選んだ)であって、支払い失敗とは無関係です。cancel_at_period_end === true でも statusactive の間はアクセスを維持します(払った分は使える)。期間末に statuscanceled に変わって初めて剥奪——ここも 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 を起点に、一定期間内に同じ invoicepayment_recovered が立った割合」として事実から算出できます。推測値を載せない——これは技術記事でも実プロダクトのレポートでも同じ規律です。MRRの絶対額やチャーン率の改善幅といった未確認の数値を語らないことが、長期的な信頼になります(私の案件でも、確認できた事実だけをレポートに載せています)。

可観測性のもう一段は、invoice.payment_failedattempt_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 で冪等化(基礎は姉妹記事)。
  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_failedpayment_recovered をペアで観測し、Stripeのアナリティクスと突き合わせる。未確認の数値は語らない

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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Double charges, missed webhooks, or churn from failed payments?

Payment-reliability diagnosis and billing architecture, as a technical advisor

"We get a double charge now and then." "We find out about dropped webhooks after the fact." "Failed payments keep turning into cancellations." The cause is rarely one bug — it is usually a structural gap in idempotency, ordering, retry policy, or the state machine. From reading your existing implementation to a receiver that survives redelivery, a recovery flow for failed payments, and where to draw the build-vs-buy line, we decide it together.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc