👛

Firebase Stripe Extensionで人数ベースのサブスクリプションを実装する

2022/10/30に公開

先日、自分が個人開発している日程調整のNitteが、チームメンバーの人数に応じた数量ベースのサブスクリプションの支払いに対応しました。

この数量ベースのサブスクリプション、Firebase Stripe Extensionは公式には対応していないと記載があります。

でも実は、プロジェクト側に少し実装を追加すれば、数量ベースのサブスクリプションに対応させることができるんです💡
今回はその方法についてご紹介します。

🧑🏽‍💻 対象読者

  • Firebase Stripe Extensionの基本は一通り理解している方
  • Firebase Stripe Extensionで数量(人数)ベースのサブスクリプションの実装したい方

🚀 バージョン

  • stripe/firestore-stripe-payments@0.3.1

📝 仕様

例として、チームで使えるTODOアプリ「Well Done!」に人数ベースの数量課金を実装します。
「Well Done!」には、

  • 個人が無料で使えるフリープラン
  • チームで使えるチームプラン(料金は500円 ・人 / 月、2週間無料でトライアルができる)

の2つのプランがあります。

チームプランは1人のユーザーが管理者となり、チームに所属しているメンバーは全員チームプランとなります。
チーム

要求

サブスクリプションの登録

トライアル終了後、管理者はサブスクリプションに登録します。チームはフリートライアルから本登録になります。

メンバーの追加

チームにメンバーが追加されると、追加された人数分サブスクリプションの数量を増やします。追加されたメンバーはチームプランになります。

メンバーの削除

チームのメンバーが削除されると、削除された人数分サブスクリプションの数量を減らします。削除されたメンバーはフリープランに 戻ります。

終了

サブスクリプションが終了すると、チームは削除され、チームのメンバーは全員フリープランに戻ります。

データベース

データベースにはFireStoreを用います。
ドキュメントのスキーマを以下のように定義します。

UserPlans/{uid}

ユーザーのプラン

プロパティ 概要
plan ‘free’ ‘team’

個人のサブスクリプションであれば、Extensionが作るSubscriptionドキュメントをそのまま利用すればよいですが、今回はサブスクリプションを持っていない、チームに所属しているメンバーもチームプランにする必要があります。そのため、ユーザーのプランをこのUserPlansで別途管理します。

Teams/{team_id}

チーム

プロパティ 概要
name string チーム名
status freeTrial registered
subscriptionRef Ref SubscriptionのRef、メンバーの追加/削除時に使用します

Teams/{team_id}/Members/{uid}

チームメンバー

プロパティ 概要
isAdmin boolean チームの管理者かどうか

🦑 サブスクリプションの作成

まずは今回使用する数量ベースのサブスクリプションを作成します。
Stripeの商品作成ページで

  • 料金体系モデル: 数量ベースの料金体系
  • ユニットあたり: 500円
    を選択して、商品を作成してください。

1

作成した商品のPriceIdをコピーしておきましょう。
2

🐙 サブスクリプションの登録

フロント

フリートライアル終了後、サブスクリプションに登録します。
通常の実装に加えて数量ベースなので、quantityにチームの人数を渡す必要があります。
また、後続の処理で使用するためにmetadata.teamIdでチームのIDを渡しておきます。

const docData = {
  price: 'price_xxxxx', // 作成したプランのPriceId
  quantity: team.members.length, // チームの人数を指定
  metadata: {
    teamId // チームのID
  }
  ...
}
const doc = await addDoc(StripeCheckoutSession.collection(uid()!), docData)

onSnapshot(doc, async (ds) => {
  const { error, url } = ds.data() as any
  if (error) {
    //....
  }
  if (url) {
    window.location.assign(url)
  }
})

バックエンド

支払いに成功し、サブスクリプションが作成されると、Extensionによって/StripeCustomers/{uid}/subscriptions/{id} のドキュメントが作成されます。
このイベントをonCreateを使って拾い、TeamのstatussubscriptiionRefを更新します。

export const onCreateSubscription = functionsWithRegion.firestore
  .document('/StripeCustomers/{uid}/subscriptions/{id}')
  .onCreate(async (ds, context) => {
    const { uid } = context.params
    const data = ds.data()

    const teamId = data.metadata.teamId
    const team = await Team.build(teamId)
    await team.update({
	status: 'registred',
	subscriptionRef: ds.ref
    })
  })

➕ メンバーの追加

メンバーの追加時にTeams/{team_id}/Members/{uid} ドキュメントが作成されます。
このイベントをonCreateで拾い、ユーザープランの変更とサブスクリプションの更新を行います。

export const onApproveMember = functionsWithRegion.firestore
  .document('/Teams/{team_id}/Members/{uid}')
  .onCreate(async (change, context) => {
    const { team_id, uid } = context.params
    // ユーザープランの更新
    await UserPlan.doc(user.uid).update({ plan: 'team' })
     // サブスクリプションの更新
    const team = await Team.build(team_id)
    // サブスクリプションのアップデート
    await team.updateSubscriptionQuantity(
      team.members.length
    )
  })

サブスクリプションの更新は/StripeCustomers/{uid}/subscriptions から、subscriptionItemIdを取得し、更新します。

updateSubscriptionQuantity(
  quantity: number
) {
   // サブスクリプションアイテムを取得
   const subscription = await this.subscriptionRef.get()
   const subscriptionItem = subscription.data()!.items[0]
	
   // サブスクリプションを更新
   const stripe = new Stripe(customConfig.stripe.secret_api_key, {
      apiVersion: '2022-08-01'
   })
   await stripe.subscriptionItems.update(subscriptionItem.id, {
      quantity
   })
}

➖ メンバーの削除

メンバーの削除時にはTeams/{team_id}/Members/{uid} が削除されるので、onDeleteで拾い追加時と逆の処理を行います。

export const onApproveMember = functionsWithRegion.firestore
  .document('/Teams/{team_id}/Members/{uid}')
  .onDelete(async (change, context) => {
    const { team_id, uid } = context.params
    // ユーザープランの更新
    await UserPlan.doc(user.uid).update({ plan: 'free' })

     // サブスクリプションの更新
    const team = await Team.build(team_id)
    await team.updateSubscriptionQuantity(
      team.members.length
    )
  })

❌ サブスクリプションの終了

サブスクリプションの終了は、新しくcustomer.subscription.deleted のwebhookを作る方法が最もシンプルに実装できます。

このイベントは、サブスクリプション解約後、残っているサブスクリプションの期間が終了した時に発火するので、イベントが来たら解約処理を行えばよいだけです。プロジェクト側で残りの期間を管理する必要はありません。

作成するにはwebhook作成画面よりcustomer.subscription.deleted を選択し、作成します。

あとはイベントが送られてきたら解約処理を行うだけです。

import Stripe from 'stripe'

async (req: any, res: any, next: any) => {
  let event: Stripe.Event

	static stripe = new Stripe(customConfig.stripe.secret_api_key, {
    apiVersion: '2022-08-01'
  })

  event = this.stripe.webhooks.constructEvent(
    req.rawBody,
    req.headers['stripe-signature'] as string,
    customConfig.stripe.on_end_subscription_webhook_secret
  )

	// サブスクリプションを取得
  const subscriptionId: string = (event.data.object as any).id
	const subscription = await StripeSubscription.findSubscriptionById(
    subscriptionId
  )
  
  const teamId = data.metadata.teamId
  const team = await Team.build(teamId)

  await Promise.all([
   // メンバーを削除
   team.deleteAllMembers(),
   // チームを削除
   team.delete()
  ]) 
}

💡一時停止はOFFにしておくのが無難

現状Stripe Extensionでは pause_collection プロパティがSubscriptionドキュメントに同期されず、一時停止、再開を管理するのが困難です。

どうしても必要でなければ、ポータルの管理画面より一時停止と再開をOFFにしてしまうのが無難です。

4


以上、少し実装は必要ですがExtensionの恩恵を受けつつ楽に実現できます。
お試しください💪

Discussion