別件で 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 からどうぞ。