全国オンライン対応受付 平日 9:00-18:00
お問い合わせ

開発記録 / 1cto.jp制作記 / Vol.23 1cto.jp制作記 Vol.2

記事 04

マイページの解約フローを完成させるまでに詰まった7つのポイント

これだけのことに、7つの問題が隠れていた。解約機能は「簡単に見えて複雑」という代表的な機能だ。

2026-06-07 公開

「解約ボタンを押したらサブスクが止まる」

これだけのことに、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分で作った話」*

*シンプルシステム株式会社 代表 伊藤勝彦*