0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Claude Codeで会社を回す #13】売上が減ったんじゃない、カードが止まってただけだった

0
Last updated at Posted at 2026-08-14

別件で connect-stripe.ts を開いて、手が止まった。

決済失敗のハンドラに、通知が無い。

自分が取りっぱぐれるほうには、ちゃんと書いてあるのに。

二階建ての SaaS には、webhook も二階建てある

toco という E ラーニングの SaaS を作っている。構造が二階建てだ。

私がスクール運営者から利用料をもらう。運営者は自分の生徒から受講料をもらう。後者は Stripe Connect を使っていて、お金は運営者の Stripe アカウントに直接入る。私の口座は通らない。

だから Stripe の webhook も 2 本ある。

platform-stripe.ts が私宛の課金を受ける。connect-stripe.ts が運営者宛の課金を受ける。両方に invoice.payment_failed のハンドラがある。決済が落ちたときに飛んでくるやつだ。

片方にだけ通知があった

私宛のほう。platform-stripe.ts:183。

if (event.type === 'invoice.payment_failed') {
  const invoice = event.data.object as Stripe.Invoice
  const customerId = invoice.customer as string

  const { data: prevTenant } = await supabase
    .from('tenants')
    .select('id, subscription_status')
    .eq('stripe_customer_id', customerId)
    .maybeSingle()

  const { error } = await supabase
    .from('tenants')
    .update({ subscription_status: 'past_due' })
    .eq('stripe_customer_id', customerId)

  if (error) {
    logger.error('テナント支払い失敗ステータス更新失敗', { error, customerId })
    throw new Error('payment_failed_update_failed')
  }

  if (prevTenant?.id) {
    await notifyTenantChurn(prevTenant.id, 'past_due', prevTenant.subscription_status)
  }
}

最後の行。notifyTenantChurn を呼んでいる。督促のメールが飛ぶ。

運営者宛のほう。connect-stripe.ts:144。

if (event.type === 'invoice.payment_failed') {
  const invoice = event.data.object as Stripe.Invoice
  // REFACTOR-A002: stripe-node v20+ で invoice.subscription は
  // parent.subscription_details.subscription に移動した。
  // 型から外れているため unknown 経由でアクセスし、未設定時は処理スキップ。
  const rawSub =
    (invoice as unknown as { subscription?: string | { id: string } }).subscription ??
    invoice.parent?.subscription_details?.subscription
  const subscriptionId = typeof rawSub === 'string' ? rawSub : rawSub?.id
  if (subscriptionId) {
    try {
      await subRepo.updateByStripeSubscriptionId(subscriptionId, {
        status: 'past_due',
      })
    } catch (error: unknown) {
      logger.error('生徒サブスクリプション支払い失敗ステータス更新失敗', {
        error: toErrorMessage(error),
        subscriptionId,
      })
      throw error
    }
  }
}

手抜きではない。型のズレにコメントで注釈を入れているし、try/catch もある。失敗すればログにも出る。

でも、そのログが出るのは私のところだ。

運営者には何も届かない。生徒にはもっと届かない。列が past_due に変わって、それで終わりだ。

つまり私は、自分が取りっぱぐれるときには督促を出して、運営者が取りっぱぐれるときにはデータベースの列だけ書き換えていた。

悪意はない。順番の問題だ。自分の痛いところから実装して、そこで満足して、運営者側を後回しにしたまま忘れた。

ただ、後回しにした側にも同じだけ金がかかっている。

past_due という 8 文字

これはステータス文字列だが、意味は「この人はまだ辞めていない」だ。

カードの有効期限が切れた。限度額に当たった。紛失して再発行したまま登録を直していない。カード会社の不正検知に引っかかった。

どれも「もう使いたくない」とは違う。

しかも本人はたいてい気づいていない。Stripe からの通知は届かないことがあるし、届いても英語のことがある。ログインすればまだ使えるサービスなら、なおさら気づかない。

そのまま何週間か経つ。リトライも尽きる。サブスクリプションが終わる。

管理画面の数字の上では、これは「解約」として集計される。解約率のグラフが少し上がる。プロダクトの何が悪かったのかを考え始める。

売上が減ったんじゃない。カードが止まっていただけだ。

Stripe が勝手にリトライしてくれるのでは

してくれる。ただしリトライが救えるのは、一時的な理由で落ちた決済だけだ。

有効期限が切れたカードは、何回試しても通らない。番号が変わったカードも同じだ。カードそのものを直せるのは本人しかいない。

その本人が、止まっていることを知らされていない。

これは toco だけの話じゃないと思う

Stripe を使っている個人開発の SaaS を思い浮かべてほしい。invoice.payment_failed を受けて、レコードのステータスを更新して、それで終わっているものは相当あるはずだ。

Stripe 自体はリトライしてくれる。失敗すればダッシュボードにも出る。だから「一応ハンドリングはしてある」状態で止まりやすい。

私がそうだった。

作ることにした。ただし 1 行も書いていない

止まったカードを直せるのは本人だけだ。本人に届く形で連絡して、更新画面まで連れていって、直ったら請求を通し直す。それをやるツールを作ることにした。churndown という名前にした。

ただ、これを書いている時点でコードは存在しない。リポジトリと、要求仕様と、この記事があるだけだ。

個人開発を 6 つ走らせて、3 つを 1 日で畳んだことがある。畳めたのは、畳む条件を先に決めていたからだった。決めていなかったものは、たぶん今も惰性で残っている。

だから今回も先に決めた。

ランディングページを出して、この連載から人を流す。30 日で LP に 50 人が来て、そのうち 2 件が先行案内に登録したら作る。届かなかったら作らない。

この連載はそこまで含めて実況にする。最終回は「作った」でも「やめた」でもよくて、判定した数字をそのまま出す。

作ってから「誰も使わなかった」と書くより、作る前に「誰も欲しがらなかった」と書けたほうが安い。

自分の invoice.payment_failed を開いてみてほしい

中に通知の処理が入っていなければ、同じ場所に立っている。

止まったカードは、誰にも知らされないまま解約になる。


churndown — この記事に書いた「連絡が無い」を埋めるために作ろうとしているものです。冒頭に書いたとおり、まだコードはありません。

30 日後に、作るか作らないかを登録数で決めます。どちらに転んでも結果はこの連載に書きます。

この記事について

一人会社で SaaS を作りながら、経営の判断を Claude Code に手伝わせている記録の続きです。今回から、決済リカバリーのツールを作る(か、やめる)ところまでを書いていきます。

次: 【#17】「回収できたら 10%」の原価を、先に計算した

arecore.net の中の人です。受託開発・SES・自社プロダクト開発をやっています。ご相談・フィードバックは arecore.net からどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?