2
1

More than 1 year has passed since last update.

Stripe BillingでサブスクリプションをWebアプリに組み込む

Posted at

目的

  • 本記事は、Stripeでサブスクリプションを実装する時に必要となったノウハウを整理します
  • 決済画面で購入をしてから、退会をするまでの範囲について、どのように決済状態を識別するのか。対象のユーザをどう識別するのか。といった制御時に必要なパラメータなどについて最小構成を整理します
  • 公式ドキュメントに準拠して進めます。実際にそれに沿って進める中で「Webアプリに組み込むにあたって課題になった」ポイントや、ドキュメントから読み解けなかった課題、などにフォーカスして、分かったことを共有します
  • 記載内容について、もしよりベストプラクティスに沿った方法が別にあるようでしたら、ご助言いただけますと幸いです

ゴール

  • 購入してから解除されるまでの範囲で、apiレスポンスのどの値を見る必要があるのか。
  • checkoutを行なったユーザとwebHookで取得したイベントの実施ユーザが同じであることをどう特定するのか
  • 単純なコースのアップデートと、キャンセルをどのパラメータを見て仕分けるのか
  • といった実装時迷ったポイントを整理します
  • また、テストクロックで時間を変化してWebHookをテストする方法を整理します

環境

  • React16
  • Firebase cloudfunction
  • StirpeAPIバージョン 2022-11-15

参考

Stripe Checkout を使用した構築済みのサブスクリプションページ
https://stripe.com/docs/billing/quickstart?client=react

  • 実装内容は概ね上記に準拠しています。この通りに実施しつつ、悩んだポイントについて知見を記載します
  • 例えば、次のようなポイントです
    • checkout.sessionで購入したユーザのプレミアムフラグを有効化したい。しかし、WebHookでサブスクリプションが有効化されたときに返ってくるsubscription.createイベントと突合できない。subscriptionを特定するにはそのid sub_ で始まるキーを持っている必要があるが、checoiut.session時点では、まだsub_のキーは払い出されないため、支払いをやった人と、有効化されたsubscriptionイベントを紐づけられず、支払ったユーザのフルフィルメントができない
    • キャンセルが完了した時は、subscription.updateイベントが受信される。deleteではない。なぜか。また単純なコース変更と同じイベントで通知されてしまう。キャンセルであるとどう識別するか
    • そのsubscriptionが有効なのか、期限切れなのかは、どの項目値から識別が可能なのか

処理の流れの実装を整理しながら上記を確認します

処理の流れ

1. 購入画面を提供する

stripe.checkout.sessions.create
でsessionを取得します

const session = await stripe.checkout.sessions.create({
    billing_address_collection: 'auto',
    line_items: [
      {
        price: prices.data[0].id,
        // For metered billing, do not pass quantity
        quantity: 1,

      },
    ],
    mode: 'subscription',
    success_url: `${YOUR_DOMAIN}/?success=true&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${YOUR_DOMAIN}?canceled=true`,
  });

この時帰ってくるsessionオブジェクトの urlから、事前構築された決済画面を利用できるため、ユーザをリダイレクトします

また、この時 idを購入を行なったユーザに持たせます
cs_ で始まるコードを、アプリ側の対象ユーザに保持させます
※ Webアプリに組み込むにあたってポイントとなるのはここです。Webhookから対象決済セッションを特定するために必要なキーとなります

2. 決済の完了通知を受領する

次はWebHookで決済の完了を受信します
※ ポイントは、サブスクリプションの作成イベントではなく、決済の完了イベントを先に取得する必要があります
なぜならば、最終的に決済したユーザと、決済対象のサブスクリプションを紐づけるために、セッションIDをキーにする必要があるためです

checkout.session.completed イベントをWebHookで受信
この時点の checkout.session オブジェクトは、sessions.create時点とは異なり、次のキー値が払い出されています
customer (cus_で始まるキー)
subscription (sub_で始まるキー)

これらのキー値を、アプリ側の対象ユーザに持たせます。
この時、アプリ側の対象ユーザの特定キーになるのは、先に1.で持たせた セッションidです (cs_で始まるキー)

3. サブスクリプションをアプリ側で有効化する

サブスクリプションの有効化を実際にアプリ側で行います(フルフィルメント)

subscriptionが作成されたことを次のイベントで受信します
customer.subscription.created

この作成されたsubscriptionに対応するユーザは、2. でユーザに持たせた subscriptionのid (sub_で始まるキー)です
アプリ側の対象ユーザをこれで特定し、有料会員フラグを有効化して、サービス提供を開始します

4. 変更(サブスクリプションを終了する)

Sessionを取得した時と同じように管理画面を取得することができます
この時、1. で持たせたcs_ で始まるセッションIDを利用します。
これにより、cs_を持つユーザのサブスクリプション管理を行うことができます
(cs_ はStripe側で勝手に変わらない想定でいます。もし認識齟齬があればご指摘ください<(_ _)>)

const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);

  // This is the url to which the customer will be redirected when they are done
  // managing their billing with the portal.
  const returnUrl = YOUR_DOMAIN;

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: checkoutSession.customer,
    return_url: returnUrl,
  });

5. 退会申し込みを受信する

「4.」 の管理画面で、キャンセルを行うと、subscriptionレコードが更新され、次のイベントを WebHookで受信します
customer.subscription.updated

この時点でまだサブスクリプションはキャンセルされません。現在支払い済みの期間が完了したときに正式なキャンセルとなります
subscriptionオブジェクトの状態は、次の項目から読み解くことができます。

項目名 内容
canceled_at キャンセルが実際に執行される予定日
cancel_at_period_end TRUEの場合は、次の更新の時にキャンセルが執行されるフラグ
status 現在の状態。有効の時はActive、キャンセルが時効されるとcancelに更新される

サイト側のユーザ情報に、次回更新時に終了することを伝える場合は、
cancel_at_period_endがTrueである場合は、次回解約である(またはcanceled_atで識別も可能)ことを判定し
サイトの対象ユーザ(sub_のキーで特定)のプレミアム会員の有効フラグを更新するのが良いと思います

6. 実際に解約が執行される

cancel_at_period_end がTrueとなっており、期日を迎えたsubscriptionは、自動的にキャンセルが執行され

customer.subscription.delete がWebHookに通知されます
これが通知されたら、対象ユーザをsub_のキーで特定し、プレミアム会員フラグをクローズして、サービス提供を終了する。といった処理をします

テストクロック

テストクロックとは

  • テスト環境の時間を進め、時間で始まる、期限が切れる、等で発行されるWebHookトリガーをテストができる
  • テストクロックへは、Bilingのタブから遷移することができます
  • テストクロックの利用方法は次に記載があります
    https://stripe.com/docs/billing/testing/test-clocks
  • 利用していて、注意したポイントは以下になります

注意点

  • テストクロックはまず起点となる日付を定めたら、順行のみに進められる
  • テストロックで挙動を確認できるレコードは、既存レコードは使えない
    • テストクロック上で、クロックオブジェクトとして状態を監視するレコードを作成する
    • (APIの場合連携しているサイト側の操作で作成したレコードをそのままテストできない)
    • クロックオブジェクトで、顧客を作成し、登録するsubscriptionや決済方法等の情報を作成する
    •  それをStripeBilling画面上で、キャンセル、等subscriptionに対するイベントを実行し、WebHookの通知状況を確認したり
    •   テストクロックの時間を進行して、時間実行されるイベントをWebHookで取得して処理を確認することができる

テスト時は、先述の処理の流れのポイントとなる、次のようなパラメータをチェックしました

console.log(event.data.object.id) // サブスクレコードのid cs_
console.log(event.data.object.customer) // 購入者のid cus_
console.log(event.data.object.subscription) // 購入者のid sub
console.log("キャンセルの執行予定日")
console.log(event.data.object.canceled_at)
console.log("現在の期間が終了したらdeleteが実行されるフラグ")
console.log(event.data.object.cancel_at_period_end)
console.log("サブスクリプションは有効化、失効済みか")
console.log(event.data.object.status)

サンプルコード

上述の内容を、サンプルコードでご説明すると以下になります

webhook.js


const subscriptionCheckoutImpl = (data, context) => {
    return new Promise(async (resolve) => {
        console.log("===========================")
        console.log("subscriptionCheckoutImpl")
        console.log("===========================")
        const { type, lookup_key } = data;
        //console.log(data)
        console.log(lookup_key)
        try {

            await stripe.checkout.sessions.create({
                billing_address_collection: 'auto',
                line_items: [
                    {
// ここで商品はすでにidを渡して決めていますが、その商品データは、Stripe画面上であらかじめ作成し、lookup_keyは払い出しています。そのキーをクライアントから渡しているのがlookup_keyです
                        price: lookup_key,//prices.data[0].id,
                        // For metered billing, do not pass quantity
                        quantity: 1,

                    },
                ], //TODO ドメインはローカル
                mode: 'subscription',
                success_url: `http://localhost:3000/UserSubscribe?success=true&session_id={CHECKOUT_SESSION_ID}`,
// ※ このCHECKOUT_SESSION_IDは、自動で補完されてcheckout session idがクエリパラメータで返されます。テンプレート変数です
                cancel_url: `http://localhost:3000/UserSubscribe?canceled=true`,
            }).then(async (session) => {
// ※ アプリ上のユーザ情報を更新しています。webhookと違い、contextにuidがあありますのでそれでユーザを特定し、先述のsession.id をユーザに持たせます。このsessionIdが、後でWebHookでのユーザ特定に利用されます
                await updateUser(context.auth.uid, { sessionId: session.id, customerId: session.customer })
                resolve({ message: 'createCheckoutLinkImple success!', result: session });
            }).catch(err => {
                console.log('createCheckoutLinkImple failed.');
                resolve({ error: err });
            });
        } catch (e) {
            console.log(e)
            resolve({ error: e });
        }
    });


}

/**
 * サブスク開始セッション
 * @url https://stripe.com/docs/products-prices/pricing-models#flat-rate
 */
exports.subscriptionCheckout = functions.https.onCall(async (data, context) => {
    console.log("=====subscriptionCheckout=====")
    const result = await subscriptionCheckoutImpl(data, context);
    return { dauth: context.auth, result: result }
});
/**
 * サブスク ポータルへのリンクを返す
 */
exports.portalSession = functions.https.onCall(async (data, context) => {
    console.log("=====portalSession=====")
    //console.log(data)
    const { sessionId } = data;
    const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);

    // This is the url to which the customer will be redirected when they are done
    // managing their billing with the portal.
    const returnUrl = 'http://localhost:3000/UserSubscribe';

    const result = await stripe.billingPortal.sessions.create({
        customer: checkoutSession.customer,
        return_url: returnUrl,
    });

    return { dauth: context.auth, result: result }
})


// 以下はwebhook

exports.stripe_webhook_app5_subscription = functions.https.onRequest(async (request, response) => {
    console.group('stripe_webhook_app5_subscription')
    console.log("stripe_webhook ==========")
    const sig = request.headers['stripe-signature'];

    let event;
    try {
        // ボディのrawデータ、署名ヘッダー、署名シークレットを指定しイベントを初期化
        event = stripe.webhooks.constructEvent(request.rawBody, sig, stripeWebhookEndpointSecret);
    } catch (err) { // 不正なリクエストの場合
        return response.status(400).send(`Webhook Error: ${err.message}`);
    }
    console.log("RESULT SUBSCRIPTION")

    //console.log(event);
    //console.log(event.type);
    functions.logger.log("event", event);
    switch (event.type) {
        case 'checkout.session.completed':
            
            console.log("################")
            console.log("CHEKCOUT SESSION")

            console.log("取得イベント チェックアウトセッション作成")
            console.log(event.data.object.id) // サブスクレコードのid cs_
            console.log(event.data.object.customer) // 購入者のid cus_
            console.log(event.data.object.subscription) // 購入者のid sub
            console.log("キャンセルの執行予定日")
            console.log(event.data.object.canceled_at)
            console.log("現在の期間が終了したらdeleteが実行されるフラグ")
            console.log(event.data.object.cancel_at_period_end)
            console.log("STATUS")
            console.log(event.data.object.status)

            // ※ ここはfirestoreのアプリ側データの更新処理です。対象のユーザを更新しています。
            // idでユーザをsessionIdで検索
            // customerIdとSubscriptionIdをユーザに設定する 
            const sessionUser = await getUser("sessionId", event.data.object.id)
            await updateUser(sessionUser.uid, { subscriptionId: event.data.object.subscription, customerId: event.data.object.customer })

            break;

        // サブスク登録
        case 'customer.subscription.created':
            console.log('customer.subscription.created')
            const customerSubscriptionCreated = event.data.object;
            console.log("取得イベント COMPLETE")
            console.log(event.data.object.id) // サブスクレコードのid cs_
            console.log(event.data.object.customer) // 購入者のid cus_
            console.log(event.data.object.subscription) // 購入者のid sub
            console.log("キャンセルの執行予定日")
            console.log(event.data.object.canceled_at)
            console.log("現在の期間が終了したらdeleteが実行されるフラグ")
            console.log(event.data.object.cancel_at_period_end)
            console.log("STATUS")
            console.log(event.data.object.status)

            // ユーザをidでsubscriptionIdで検索する
            // ユーザステータスをアクティブにする
            // 0: 無料 1:キャンセル中今月末で終了 2: プレミアム課金中
            const subscUser = await getUser("subscriptionId", event.data.object.id)
            await updateUser(subscUser.uid, { premium: 1 })

            break;
        case 'customer.subscription.deleted':
            console.log("subscription.deleted===================")
            console.log("取得イベント DELETE 執行")
            console.log(event.data.object.id) // サブスクレコードのid cs_
            console.log(event.data.object.customer) // 購入者のid cus_
            console.log(event.data.object.subscription) // 購入者のid sub
            console.log("キャンセルの執行予定日")
            console.log(event.data.object.canceled_at)
            console.log("現在の期間が終了したらdeleteが実行されるフラグ")
            console.log(event.data.object.cancel_at_period_end)
            console.log("STATUS")
            console.log(event.data.object.status)

            const customerSubscriptionDeleted = event.data.object;
            //0: 無料 1:キャンセル中今月末で終了 2: プレミアム課金中
            const subscUserOffExecute = await getUser("subscriptionId", event.data.object.id)
            await updateUser(subscUserOffExecute.uid, { premium: 0 })

            break;

        // 変更(解約を含む)
        case 'customer.subscription.updated':

            // TODO 次の課題
            // コースを変えた時、どのコースに変えたかをどの項目で識別すべきか

            // キャンセル。多分月末に自動で呼ばれるので、キャンセルした直後じゃない
            // どの項目で識別するべきか
            console.log('customer.subscription.updated')
            console.log("subscription.deleted===================")
            console.log("取得イベント UPDATE 執行")
            console.log(event.data.object.id) // サブスクレコードのid cs_
            console.log(event.data.object.customer) // 購入者のid cus_
            console.log(event.data.object.subscription) // 購入者のid sub
            console.log("キャンセルの執行予定日")
            console.log(event.data.object.canceled_at)
            console.log("現在の期間が終了したらdeleteが実行されるフラグ")
            console.log(event.data.object.cancel_at_period_end)
            console.log("STATUS")
            console.log(event.data.object.status)
            // idでsubscriptionIdを検索しユーザを特定する
            // ユーザの有料課金コースフラグをOFFにする
            //0: 無料 1:キャンセル中今月末で終了 2: プレミアム課金中
            const subscUserOff = await getUser("subscriptionId", event.data.object.id)
            await updateUser(subscUserOff.uid, { premium: 1 })

            break;
        // ... handle other event types
        default:
            console.log(`Unhandled event type ${event.type}`);
    }
    console.groupEnd()
    response.send()


});
2
1
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
2
1