「解約ボタンを押したらサブスクが止まる」
これだけのことに、7つの問題が隠れていた。解約機能は「簡単に見えて複雑」という代表的な機能だ。
1:Stripe Customer Portalを使わないと決めた理由
Stripeには「Customer Portal」という機能がある。Stripeが提供する管理UIで、ユーザーがサブスクの解約ができる。設定すれば数分で動く。
使わなかった理由は2つある。Customer Portalに遷移するとStripeのドメインになる。1cto.jpのUIで一貫したブランド体験を保ちたかった。そしてCustomer Portalのカスタマイズには制限がある。解約後の引き留めメッセージや次回更新日の表示を自由に設計できない。
自前でAPIを叩くことで、UI/UXを完全にコントロールできる。その代わり、7つの落とし穴を自分で解決することになった。
2:cancel_at_period_endとstatusは別物
解約予約を実装するとき、最初の実装はstatusを「canceled」に変えていた。しかし正確には違う。
cancel_at_period_end: trueに設定すると、Stripeのサブスクのstatusはまだactiveのままだ。支払い期間が終わって初めてcanceledになる。
def get_subscription_display(user_id: int) -> dict:
sub = db.query(Subscription).filter(...).first()
if not sub:
return {'status': 'none'}
if sub.status == 'active' and sub.cancel_at_period_end:
return {
'status': 'canceling',
'expires_at': sub.current_period_end
}
elif sub.status == 'active':
return {
'status': 'active',
'next_billing_at': sub.current_period_end
}
else:
return {'status': 'canceled'}
statusだけで判断できない。DBにcancel_at_period_endカラムを追加して、Webhookで更新する必要があった。
3:解約APIの認証チェック
解約APIを実装するとき、「ログイン済みユーザーなら解約できる」という実装をした。これには問題がある。
ユーザーAが/api/subscriptions/{sub_id}/cancelを叩くとき、sub_idがユーザーBのサブスクIDでも解約できてしまう。
# 修正後
sub = db.query(Subscription).filter(
Subscription.id == sub_id,
Subscription.user_id == current_user.id # 自分のサブスクか確認
).first()
if not sub:
raise HTTPException(status_code=404)
ユーザーIDの確認は1行だが、ないと重大な問題になる。
4:メールアドレスをまたいだ顧客検索
複数のStripe顧客が作られる問題の対策として、解約APIでは「ユーザーのメールアドレスで全顧客を検索」する実装にした。
def _get_active_subscription(email: str):
customers = stripe.Customer.list(email=email)
for customer in customers.data:
subs = stripe.Subscription.list(
customer=customer.id,
status='active',
limit=1
)
if subs.data:
return subs.data[0]
return None
DBのcustomer_idが古くなっていても、メールアドレスで検索すれば実際の有効なサブスクを見つけられる。
5:current_period_endのタイムスタンプ変換
StripeのAPIが返すcurrent_period_endはUnixタイムスタンプ(秒)だ。そのまま返すと使いにくい。
from datetime import datetime, timezone, timedelta
JST = timezone(timedelta(hours=9))
def format_period_end(unix_ts: int) -> str:
dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc).astimezone(JST)
return dt.strftime('%Y年%m月%d日')
日本語の日付で返す。「2026年6月30日」という表示の方が、ユーザーには意味が伝わる。
6:解約確認モーダルの2ステップ
解約ボタン1回で即解約は危険だ。誤タップのリスクがある。
2ステップの確認を実装した。「解約する」ボタンを押すとモーダルが開く。「現在のサービス期間(〇月〇日)まで利用できます」というメッセージと、「やっぱり続ける」「解約を確定する」の2ボタン。「解約を確定する」を押すとAPIを叩く。
解約確定後のUIは即座に「解約予約中(〇月〇日まで利用可)」に切り替わる。サーバーのレスポンスを待ってから更新するので、成功後だけ表示が変わる。
7:subscription.updatedWebhookのハンドラ
解約予約が完了すると、Stripeはcustomer.subscription.updatedというWebhookを送ってくる。このときDBを更新する。
elif event_type == 'customer.subscription.updated':
sub_data = event['data']['object']
stripe_sub_id = sub_data['id']
sub = db.query(Subscription).filter(
Subscription.stripe_subscription_id == stripe_sub_id
).first()
if sub:
sub.status = sub_data['status']
sub.cancel_at_period_end = sub_data['cancel_at_period_end']
sub.current_period_end = sub_data['current_period_end']
db.commit()
サブスクが実際にキャンセルされるとcustomer.subscription.deletedイベントが届く。このタイミングでstatusをcanceledに更新する。
マイページの解約フローは「表示・認証・状態管理・Webhook連携」の全てが絡み合う。どれか一つ間違えると、「解約したのにまだ課金された」「解約したのに読めなくなった」という問題につながる。
7つを一つずつ潰していくことで、正確な解約フローが完成した。
*次回は「パスワードリセット機能を30分で作った話」*
*シンプルシステム株式会社 代表 伊藤勝彦*