見出し画像

Stripe + Firestore + Goによるサブスクリプション機能の構築(実践編)

こんにちは。Chompyでエンジニアをしているオギーです。前回のグループ注文に関するノートを執筆してからもう1年以上経っていました。 あっという間ですね!

今回は最近色々なサービスにて活用されているサブスクリプション機能をStripe+Firestore+Goを用いて構築したので、その工程メモノートになります。これからサブスクリプション機能を実装する方や、してみたい方々の少しでもお力になれたらと思い書きました。このノートに記載のサンプルコードは説明に必要な一部のコードのみを記載しています。全体のコードは以下のリポジトリにて公開しているため必要に応じて参考にして頂けると嬉しいです。

今回リリースしたもの

Chompy社では今年の8月に飲食店が公式アプリを開設できるサービスをリリースしており、2022年3月時点で34ブランドほどの公式アプリがリリースされています。

このオリジナルアプリの1機能としてサブスクリプション機能をリリースしました。こちらも2022年3月時点で「ITAEWON BOWLS / イテウォンボウルズ」「北海道スープカレー Suage」「GET BETTER coffee&sandwich」「京都九条ねぎチゲ鍋専門店」「GRIT TODAY」「美菜屋」「チキンカツカレー専門店けんちゃんカレー」「THE CUPS/ザ カップス」の8ブランドにて使用することができます。

北海道スープカレーSuageでは月額税込300円で注文ごとにトッピングが1品何度でも無料でつけることができるプランがあったり、GET BETTER coffee&sandwichでは月額税込3,000円で1日1杯ドリンクが無料で注文することができます。毎日美味しいコーヒーを飲むことができますね☕
ぜひ気になるお店のアプリをダウンロードしてどのようなサブスクリプションプラン、料理があるか確認してみてください。これからもっと色々なブランドにて個性的なサブスクリプションが生まれる予定なのでとっても楽しみです 😊

アプリ上のサブスクリプション機能

下準備編

Chompy社ではバックエンドをGAE+Go+Firestoreで構成しており、オンライン決済にStripeを使用しています。Stripeにはサブスクリプションに関する仕組みが用意されていたため、そちらを基に開発を進めていくことになりました。Stripeにサブスクリプションの管理を任せることで、自前でcron等を設定して月次で自動更新処理を行うといった実装・保守共に難易度が高いものを任せることができ素晴らしい世界を築き上げることができます。

題材🍜

今回のノートにて題材とするサブスクリプションプランは以下を定義します。

  • サブスクリプションタイトル: 「味噌ラーメンわくわく定額プラン」

    • 「毎日ラーメン1杯無料プラン」

      • 値段: 3,000円

      • 更新間隔: 30日ごと

    • 「トッピング毎回1品無料」

      •  値段: 350円

      • 更新間隔: 30日ごと

    • 日割り計算はどちらもなし

コレクションの定義

Firestoreのコレクションの定義について、今回は以下のような形で定義されていると仮定して進めていきます。この構造については仮のものであり今回はほとんど触れないですが、要望があればこの構造体や割引ロジックの部分についてのノートも書いていきたいと思っています。

// Plan サブスクリプションのプラン
type Plan struct {
	ID              string     `firestore:"id"`
	Title           string     `firestore:"title"`
	StripeProductID string     `firestore:"stripe_product_id"`
	StripePriceID   string     `firestore:"stripe_price_id"`
	Price           int32      `firestore:"price"`
	Benefits        []*Benefit `firestore:"benefits"`
}

// Benefit サブスクリプション適用のためのデータを定義(割引額等)。今回は触れない
type Benefit struct {
	ID    string `firestore:"id"`
	Title string `firestore:"title"`
	// DiscountValue int32 `firestore:"discount_value"`
}

// Subscription サブスクリプションは複数のプランを保持できる
type Subscription struct {
	ID    string  `firestore:"-"`
	Title string  `firestore:"title"`
	Plans []*Plan `firestore:"plans"`
}

// UserSubscription ユーザー毎のサブスクリプションプランの状態を定義
type UserSubscription struct {
	ID                    string                    `firestore:"-"`
	CustomerID            string                    `firestore:"customer_id"`
	SubscriptionID        string                    `firestore:"subscription_id"`
	PlanID                string                    `firestore:"plan_id"`
	NextPlanID            string                    `firestore:"next_plan_id"`
	Status                stripe.SubscriptionStatus `firestore:"status"`
	LatestPaymentIntentID string                    `firestore:"latest_payment_intent_id"`
	StartedAt             time.Time                 `firestore:"started_at"`

	StripeSubscriptionID     string `firestore:"stripe_subscription_id"`
	StripeSubscriptionItemID string `firestore:"stripe_subscription_item_id"`

	CurrentPeriodStart time.Time `firestore:"current_period_start"`
	CurrentPeriodEnd   time.Time `firestore:"current_period_end"`
}

タイトルや値段などサブスクリプションに関する情報はStripeAPIから取得できるため、DBと同期させずに都度StirpeAPIを叩く形でも要件によっては良いと思います。今回については以下のような理由でDBと同期をさせる形を取りました。

  1. アプリホームなどにサブスクリプションの情報を表示させるため、大量リクエストによりレート制限に引っかかってしまう可能性がある

  2. レイテンシが高くなる恐れがある

  3. 何かしらの障害などにより一時的にStripeがサブスクリプションに関するリクエストを返せなくなった時に、サブスクの登録・退会の他にホームでの表示や割引処理の適用も困難になってしまう

このような形にする場合、Stripe上のデータとDBのデータを不整合なく同期させることが非常に重要になっていきます。

Stripe サブスクリプションの概要

実装をすすめる前にStripeにて提供されているサブスクリプションについて仕組みを軽く理解をしていきます。仕組みについては公式ドキュメントがとってもよくまとまっているのでそちらを読んでしまえば大まかなことはわかります。がそれだとこのノートの意味がなくなってしまうので特に重要なポイントに触れていきます。

今回重要な概念は

  • Customer (顧客)

  • Product (製品、サブスクプラン)

  • Price (値段及び期間)

  • Subscription(顧客に紐づくサブスクの利用状態)

  • Invoice (請求)

  • PaymentIntent(支払い)

の6つであり、特にサブスクリプションにて新しく登場する三つの概念「Product」「Price」「Subscription」が重要となっていきます。Subscriptionは複数のProductを持つことができる。さらにProductは複数のPriceを持つことができ、Priceには値段及び期間が設定されている。顧客は登録したいPrice(Product)を選択しSubscriptionに登録する。

今回の題材であるラーメンのサブスクリプションに当てはめると、
「味噌ラーメンわくわく定額プラン」というSubscriptionに「毎日ラーメン一杯無料」と「トッピング毎回1品無料」の二つのProductが設定されている。このProductにはそれぞれ1つのPriceが紐づいており、「毎日ラーメン一杯無料」の方には30日3,000円のPrice、「トッピング毎回1品無料」の方には30日350円のPriceが設定されている。ユーザーは「毎日ラーメン一杯無料」に紐づく「30日3,000円」のPriceを選択してサブスクリプションに加入する。そして美味しいラーメンをたくさん食べて幸せな毎日を送る
のような形になります。

まずはこの「Product」「Price」をStripeに登録します。サンプルコードは以下のようになります。(Stripe上のダッシュボードからも作成可能です)重要な点として作成するProduct及びPriceのMetadataにDB上のIDをセットしている点です。Metadataを設定することで後に説明する自動更新処理などにて重要な役割を果たします。

// tools/create-subscription/main.go

func main() {
	ctx := context.Background()

	sub := &Subscription{
		ID:    uuid.New().String(),
		Title: "味噌ラーメンわくわく定額プラン",
		Plans: []*Plan{
			{
				ID:    uuid.New().String(),
				Title: "毎日ラーメン1杯無料プラン",
				Price: 3000,
				Benefits: []*Benefit{
					{
						ID: uuid.New().String(),
					},
				},
			},
			{
				ID:    uuid.New().String(),
				Title: "トッピング毎回1品無料",
				Price: 350,
				Benefits: []*Benefit{
					{
						ID: uuid.New().String(),
					},
				},
			},
		},
	}

	for _, plan := range sub.Plans {
		// Subscriptionの商品及び価格の詳細はこちら: https://stripe.com/docs/billing/prices-guide

		// Productの作成 https://stripe.com/docs/api/products/create
		productParams := &stripe.ProductParams{
			Name:                stripe.String(plan.Title),
			StatementDescriptor: stripe.String("Chompy"), // 明細書に記載する文字列. 5 ~ 22文字でアルファベットと数字のみなので注意. https://stripe.com/docs/statement-descriptors
		}
		productParams.AddMetadata("subscription_id", sub.ID)
		productParams.AddMetadata("plan_id", plan.ID)
		product, _ := client.Products.New(productParams)

		// Priceの作成 https://stripe.com/docs/api/prices/create
		priceParams := &stripe.PriceParams{
			Currency: stripe.String(string(stripe.CurrencyJPY)), // 通貨の設定, JPYを設定する
			Product:  stripe.String(product.ID),                 // 上記で作成したProductのIDを設定する
			Recurring: &stripe.PriceRecurringParams{ // サブスク期間の設定
				Interval:      stripe.String("day"), // 日毎
				IntervalCount: stripe.Int64(30),     // 30日
			},
			UnitAmount: stripe.Int64(3000), // 料金, 3000円
		}
		priceParams.AddMetadata("subscription_id", sub.ID)
		priceParams.AddMetadata("plan_id", plan.ID)
		price, _ := client.Prices.New(priceParams)

		plan.StripeProductID = product.ID
		plan.StripePriceID = price.ID
	}

	// DBに保存
	dr := fsClient.Collection("Subscription").Doc(sub.ID)
	if _, err := dr.Set(ctx, sub); err != nil {
		log.Fatalf("Failed to create subscription. err=%v", err)
	}
}

ここまででStripeにおけるサブスクリプションの簡単な理解と、データの準備が完了しました。次に実際のユースケースごとに実装を進めていきます。

実践編

サブスクリプションには新規加入、退会、支払い方法の変更、自動更新などの様々な処理を実装していく必要があります。サービスとして運用するためには全てのパターンを網羅する必要があるので、頑張って実装していきます。今回は既にユーザーはStripe上にCustomerが作成されていることを前提に話を進めていきます。また見出しにStripeAPIの関連するページのリンクを設定しているので必要に応じて一読すると尚良さそうです。

新規加入

ユーザーがアプリ上で加入したいサブスクリプションプランと支払い方法を選択し、新規加入する処理の実装についてです。サンプルコードは以下のようになります。

// create_user_subscription.go

func CreateUserSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	var req *CreateUserSubscriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("json.NewDecoder.Decode: %v", err)
		return
	}

	idempotencyKey := uuid.New().String()
	var intent *stripe.PaymentIntent
	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		// DBからSubscriptionを取得する
		sub, _ := GetSubscriptionTx(tx, req.SubscriptionID)
		plan := sub.Plan(req.PlanID)

		// Stripe上にてSubscriptionを作成する https://stripe.com/docs/api/subscriptions/create
		params := &stripe.SubscriptionParams{
			Customer: stripe.String(req.CustomerID),
			Items: []*stripe.SubscriptionItemsParams{
				{
					Price:    stripe.String(plan.StripePriceID), // ユーザーが選択したサブスクリプションプランのPriceIDをセットする
					Quantity: stripe.Int64(1),                   // 数量、今回は1プランを契約する
				},
			},
			CancelAtPeriodEnd: stripe.Bool(false),                                              // 自動更新有無、falseにすることで期限が切れたらStripe側で自動更新される
			ProrationBehavior: stripe.String(string(stripe.SubscriptionProrationBehaviorNone)), // 日割り計算に関するパラメータ。今回は日割りなしを想定しているのでNoneを選択する https://stripe.com/docs/billing/subscriptions/prorations
			PaymentBehavior:   stripe.String("allow_incomplete"),                               // 支払い処理に関するパラメータ。決済処理まで一気に処理をすすめる場合は allow_incompleteを選択する
		}
		params.AddMetadata("subscription_id", sub.ID)
		params.AddMetadata("plan_id", plan.ID)
		params.AddExpand("latest_invoice.payment_intent") // レスポンスとして最新のInvoiceに紐づくPaymentIntentを取得したいためAddExpandに指定しておく
		params.SetIdempotencyKey(idempotencyKey)          // 冪等キー

		s, err := client.Subscriptions.New(params)
		err = handleStripeError(err) // 冪等チェックエラーだった場合は処理を続行する
		if err != nil {
			return err
		}
		intent = s.LatestInvoice.PaymentIntent
		ub := NewUserSubscription(sub.UserSubscriptionID(req.CustomerID), req.CustomerID, sub.ID, plan.ID, s)
		ub, _ = CreateUserSubscriptionTx(tx, ub)
		return nil
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("createUserSubscriptionHandler: %v", err)
		return
	}
	res := CreateUserSubscriptionResponse{
		Status:       intent.Status,
		ClientSecret: intent.ClientSecret,
	}
	if err = json.NewEncoder(w).Encode(res); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("createUserSubscriptionHandler: %v", err)
		return
	}
}

予めDBに保存してあるサブスクリプションプランに関するデータを取得し、Stripe上にSubscriptionを作成します。作成することでパラメーターによって決済まで行われ、成功することで自動更新まで行ってもられるようになります。ここの処理では主に3つのポイントがあります。

  1. トランザクションの中でStripeAPIを叩くことで、決済エラーなどでStripe上のSubscription生成に失敗した時にDBに書き込みがコミットされない(DBとStripeで不整合が起こらない)

  2. DBに保存時にエラーが発生した時は数回(デフォルト5回)トランザクション中の処理がリトライされるため、冪等キーを設定することで複数回StripeのSubscripiton作成のエンドポイントが叩かれても重複して処理が走らないようにしている

  3. StripeのSubscription生成時に `PaymentBehavior: stripe.String("allow_incomplete")` を設定することで決済処理まで同期的に行われるようにしている

トランザクションの中でStripeAPIを叩くことで、DBとの不整合を極力発生しないようにすることができます。またリトライがされることを考慮して冪等性の考慮を行うことがとても大切です。ただしこのサンプルコードではDBの書き込みに失敗してしまった場合に不整合が起こってしまう恐れがまだ残っています。(Stripe上にSubscriptionが作成され決済が行われたが、DB上では加入したことになっていない状態)そのためDBの書き込みに失敗した場合のロールバック処理があると尚良いです。

支払いまで行うことで選択された支払い方法、例えば「クレジットカードで有効期限が切れていて決済できなかった」「3Dセキュアが設定されており追加の認証が必要」などにより決済できなかった場合も、同期的に結果をクライアントアプリに返すことができるためユーザーに対して支払い方法の変更や認証などのアクションを促すことが可能になります。

加入プラン変更 (次回更新時)

次回から別のプランに変更を行う場合、以下のような要件を満たす必要があります。

  • 現在加入中のプランは期限終了まで引き続き有効

  • 次回更新時に新しいプランの料金、期間で更新処理を行う

現在加入中のプランは終了まで有効になることから、プラン変更を予約するといった実装を行う必要があります。サンプルコードは以下のようになります。

// update_user_subscription.go

func UpdateUserSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	var req *UpdateUserSubscriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("json.NewDecoder.Decode: %v", err)
		return
	}

	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		sub, _ := GetSubscriptionTx(tx, req.SubscriptionID)
		// 新しいサブスクリプションのプランのデータを取得する
		plan := sub.Plan(req.PlanID)

		ub, _ := GetUserSubscriptionTx(tx, sub.UserSubscriptionID(req.CustomerID))

		// Subscriptionに設定されているSubscriptionItemを変更する https://stripe.com/docs/billing/subscriptions/upgrade-downgrade
		itemParams := &stripe.SubscriptionItemParams{
			Price:             stripe.String(plan.StripePriceID),
			ProrationBehavior: stripe.String(string(stripe.SubscriptionProrationBehaviorNone)),
		}
		_, _ = client.SubscriptionItems.Update(ub.StripeSubscriptionItemID, itemParams)

		// SubscriptionのMetadataを新しいプランのIDに更新する
		subParams := &stripe.SubscriptionParams{}
		subParams.AddMetadata("plan_id", plan.ID)
		_, _ = client.Subscriptions.Update(ub.StripeSubscriptionID, subParams)

		// アプリ上で変更後のプランに関する情報を表示するため、DB上に変更後のPlanIDを保持しておく
		ub.NextPlanID = plan.ID
		return UpdateUserSubscriptionTx(tx, ub)
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("updateUserSubscriptionHandler: %v", err)
		return
	}
}

まず現在のサブスクリプションプランの情報をDBから取得し、その情報を用いてStripe上のデータの更新を行います。StripeのSubscriptionは1つあたり複数のプランに加入することでき、それらはSubscriptionItemという形で保持されます。(SubscriptionとPlan(Price)の中間テーブルのような役割を持っています)
つまりこのSubscriptionItemを新しいPriceに更新することで、次回更新時にに新しい値段・期間での決済処理が行われるようになります。

注意点としてSubscriptionItemではなくSubscriptionに対して以下のようなコードで更新処理を行っても、現在のSubscripitonItemが上書きされるのではなく追加されるような挙動になります。(つまり複数のプランに加入した形になり、その数分の決済が発生してしまいます😱)

params := &stripe.SubscriptionParams{
	Items: []*stripe.SubscriptionItemsParams{
		{
			Price:    stripe.String(plan.StripePriceID),
			Quantity: stripe.Int64(1),
		},
	},
}
_, _ = client.Subscriptions.Update(ub.StripeSubscriptionID, params)

加入プラン変更 (即時)

即時にプラン変更を行う場合のサンプルコードは以下になります。

// update_user_subscription_immediately.go

func UpdateUserSubscriptionImmediatelyHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	var req *UpdateUserSubscriptionImmediatelyRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("json.NewDecoder.Decode: %v", err)
		return
	}

	idempotencyKey := uuid.New().String()
	var intent *stripe.PaymentIntent
	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		sub, _ := GetSubscriptionTx(tx, req.SubscriptionID)
		// 新しいサブスクリプションのプランのデータを取得する
		plan := sub.Plan(req.PlanID)

		ub, _ := GetUserSubscriptionTx(tx, sub.UserSubscriptionID(req.CustomerID))

		// Subscriptionに設定されているSubscriptionItemを変更する https://stripe.com/docs/billing/subscriptions/upgrade-downgrade
		itemParams := &stripe.SubscriptionItemParams{
			Price:             stripe.String(plan.StripePriceID),
			ProrationBehavior: stripe.String(string(stripe.SubscriptionProrationBehaviorNone)),
		}
		_, _ = client.SubscriptionItems.Update(ub.StripeSubscriptionItemID, itemParams)

		// Subscriptionの更新処理を実行する
		subParams := &stripe.SubscriptionParams{
			BillingCycleAnchorNow: stripe.Bool(true),
			ProrationBehavior:     stripe.String(string(stripe.SubscriptionProrationBehaviorNone)),
			CancelAtPeriodEnd:     stripe.Bool(false),
		}
		subParams.AddMetadata("plan_id", plan.ID)
		subParams.AddExpand("latest_invoice.payment_intent") // レスポンスとして最新のInvoiceに紐づくPaymentIntentを取得したいためAddExpandに指定しておく
		subParams.SetIdempotencyKey(idempotencyKey)          // 冪等キー
		s, err := client.Subscriptions.Update(ub.StripeSubscriptionID, subParams)
		err = handleStripeError(err) // 冪等チェックエラーだった場合は処理を続行する
		if err != nil {
			return err
		}
		intent = s.LatestInvoice.PaymentIntent
		// サブスクリプションプランのデータを更新する
		ub.RenewalAll(plan.ID, s)
		return UpdateUserSubscriptionTx(tx, ub)
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("updateUserSubscriptionImmediatelyHandler: %v", err)
		return
	}
	res := UpdateUserSubscriptionImmediatelyResponse{
		Status:       intent.Status,
		ClientSecret: intent.ClientSecret,
	}
	if err = json.NewEncoder(w).Encode(res); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("updateUserSubscriptionImmediatelyHandler: %v", err)
		return
	}
}

先ほどの「加入プラン変更(次回更新時)」とは異なり即時で決済処理まで行っていきます。その場合Stripe Subscriptionの更新用エンドポイントに対して `BillingCycleAnchorNow: stripe.Bool(true)` をセットした状態で叩くことで実現することができます。決済処理が走るので新規加入の時と同様に決済結果(PaymentIntent)を取得しクライアントアプリに返します。

自動更新キャンセル

今回の期間を最後にサブスクリプションプランをキャンセルしたい場合のサンプルコードは以下になります。

// cancel_user_subscription.go

func CancelUserSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	var req *CancelUserSubscriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("json.NewDecoder.Decode: %v", err)
		return
	}

	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		sub, _ := GetSubscriptionTx(tx, req.SubscriptionID)
		ub, _ := GetUserSubscriptionTx(tx, sub.UserSubscriptionID(req.CustomerID))

		// 自動更新を無効にする https://stripe.com/docs/billing/subscriptions/cancel
		params := &stripe.SubscriptionParams{
			CancelAtPeriodEnd: stripe.Bool(true),
		}
		_, _ = client.Subscriptions.Update(ub.StripeSubscriptionID, params)
		return nil
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("cancelSubscriptionHandler: %v", err)
		return
	}
}

加入プラン変更と同様にStripe Subscriptionの更新用エンドポイントに対して `CancelAtPeriodEnd: stripe.Bool(true)` をセットして叩くことで、自動更新を無効にすることができます。

自動更新キャンセルのキャンセル

1度自動更新をキャンセルし、やはり継続させたい場合は「自動更新キャンセル」で行った `CancelAtPeriodEnd: stripe.Bool(true)` を `false` に戻すだけでキャンセルすることができます

// 自動更新を有効に戻す https://stripe.com/docs/billing/subscriptions/cancel
params := &stripe.SubscriptionParams{
	CancelAtPeriodEnd: stripe.Bool(false),
}
_, _ = client.Subscriptions.Update(ub.StripeSubscriptionID, params)

支払い方法の変更

支払い方法の変更についても「自動更新キャンセル」などと同様にStripe Subscriptionの更新用エンドポイントを叩くだけで実現できます。とっても簡単ですね!

// update_user_subscription_payment.go

func UpdateUserSubscriptionPaymentHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.Background()

	var req *UpdateUserSubscriptionPaymentRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("json.NewDecoder.Decode: %v", err)
		return
	}

	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		sub, _ := GetSubscriptionTx(tx, req.SubscriptionID)
		ub, _ := GetUserSubscriptionTx(tx, sub.UserSubscriptionID(req.CustomerID))

		// 支払い方法を変更する https://stripe.com/docs/api/subscriptions/update
		params := &stripe.SubscriptionParams{
			DefaultSource: stripe.String(req.SourceID),
		}
		_, err := client.Subscriptions.Update(ub.StripeSubscriptionID, params)
		return err
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		log.Printf("updateUserSubscriptionHandler: %v", err)
		return
	}
}

自動更新

今までは同期的にAPIを叩いて変更する処理について説明していきましたが、自動更新の時は形が異なります。自動更新ではStripe側で自動で更新・決済処理が行われその結果をWebhookにて受け取りDB上のサブスクリプションプランの状態を更新します。Webhookについては以下のドキュメントが参考になります。

受信するイベントについていくつかパターンがあるのですが、今回は以下のイベントを受信する形を取ります。(イベントリスト)

  • invoice.payment_succeeded (請求書の支払い成功時)

  • invoice.payment_failed (支払い失敗時)

「Stripeサブスクリプションの概要」にて触れた通りSubscriptionを作成した後にInvoiceが作成され、最終的にPaymentIntentによって支払いが行われます。そのためPaymentIntentに関するイベントを受信して処理を行うことも可能です。ただしこちらの方法の場合、後で説明するMetadataを使ったデータ更新が困難になってしまうためinvoiceのイベントを受信する形を採用しています。サンプルコードは以下のようになります。

// webhook.go

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
	const MaxBodyBytes = int64(65536)
	r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
	p, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusServiceUnavailable)
		return
	}
	ev, err := webhook.ConstructEvent(
		p,
		r.Header.Get("Stripe-Signature"),
		os.Getenv("STRIPE_WEBHOOK_SIGNATURE"),
	)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	switch ev.Type {
	case "invoice.payment_succeeded", "invoice.payment_failed":
		var invoice stripe.Invoice
		err := json.Unmarshal(ev.Data.Raw, &invoice)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		err = renewalUserSubscription(context.Background(), invoice)
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
	}

	w.WriteHeader(http.StatusOK)
}

func renewalUserSubscription(ctx context.Context, inv stripe.Invoice) error {
	line := inv.Lines.Data[0]

        // MetadataからDB上のSubscriptionやPlanのIDを取得する
	subscriptionID := line.Metadata["subscription_id"]
	planID := line.Metadata["plan_id"]

	err := fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		sub, _ := GetSubscriptionTx(tx, subscriptionID)
		ub, _ := GetUserSubscriptionTx(tx, sub.UserSubscriptionID(inv.Customer.ID))

		// Stripe上のSubscriptionを取得する(自動更新後の最新の状態)
		stripeSub, _ := client.Subscriptions.Get(ub.StripeSubscriptionID, nil)

		// 次回更新時にプラン変更するパターン
		if ub.NextPlanID != "" {
			ub.PlanID = planID
			ub.NextPlanID = ""
		}
		ub.RenewalAll(planID, stripeSub)
		return UpdateUserSubscriptionTx(tx, ub)
	})
	if err != nil {
		return err
	}
	return nil
}

これらのイベントにはInvoiceが返ってくるため、こちらを元にデータ更新を行なっていきます。Invoice.Linesにはこの請求に関するアイテムのデータが含まれており、今回はSubscriptionの請求であることからProductが含まれています。つまりProductに適切なMetadataをセットしておくことで、更新時の処理に抽出して活用することができます。今回の例ではProductにDB上のSubscriptionIDとPlanIDをセットしているため、すぐにDB上の必要なデータを取得することができています。

PaymentIntentに関するイベントだとSubscriptionに関するデータにMetadataが含まれておらず、今回のような形を実現することができませんでした。

今回利用しているMetadataの情報はDB上のデータ構造を工夫することで代替することができるためメリットは少し薄くなっていますが、Chompy社の店舗向けアプリはTenant単位でNamespaceを分けている影響で、TenantIDが更新時に必ず必要なためこのような形を取っています。ただデメリットとしてプランの変更時などに都度Metadataも更新する必要性が出るため、要件によって検討することが大切です。

新規加入時に3Dセキュアをキャンセル、その後再度新規加入しようと時の処理

最後に発生確率はかなり低いがゼロではないパターンの実装について説明していきます。このパターンはユーザーが以下のような行動をした時に発生します。

  1. サブスクリプションプランを選択し、新規加入ボタンを押す

  2. 支払い方法に選択したクレジットカードにて3Dセキュアの設定がされており、追加の認証が必要

  3. 追加認証画面を開いたが認証はせずに閉じた(決済のキャンセルをした)

この場合Stripe上のSubscriptionのStatusは「incomplete」になり無効状態になります。さらに追加の認証時に用いた「ClientSecret」はすでにユーザーが一度認証画面を開いたことから再利用することができません。

そのためこの状態になった後に再度新規加入をしたい場合は既存のSubscriptionを更新するのではなく、新規で作成する必要があります。(もしかしたら更新することができるかもと思っていますが、実験した際はエラーが発生したためこの形にしています)サンプルコードについては「新規加入」の処理にキャンセル処理が追加されたのみなのでこちらでは割愛します。(サンプルコードはこちら)

ユーザーが追加認証(3Dセキュア)の対応を行うと `invoice.payment_succeeded` のイベントをWebhook経由で受信するため、その値を同期させることで正しい状態にできます。(処理については「自動更新」セクションを参照してください)

実装時にハマった点

ドキュメントにてレスポンスに含まれているはずのフィールドが返ってこない

StripeAPIについて一部のフィールドはリクエスト時に明示的に指定しないと、レスポンスに含まれない仕様があります。今回の例の中だと新規加入時などに `Subscription.LatestInvoice.PaymentIntent` を取得する必要があるのですが、こちらのフィールドはリクエスト時に以下のように明示的にAddExpandしないとnilが返ってきます。

params.AddExpand("latest_invoice.payment_intent")

確かに公式ドキュメントを見ると EXPANDABLE と記載があります。自分はこの仕様を知らず始めはこのデータを取得することができない(つまり同期的に決済まで行えない)と勘違いし、必死に非同期で3Dセキュアの対応等をどうユーザーに促すか悩んでしまいました😇

受け取りたいWebhookが返ってこない

「自動更新」セクションにてInvoiceに関するWebhookを受信して処理を実装しているのですが、invoice.payment_succeeded(failed)イベントが一向に受信されずしばらくはまってしまいました。StripeのWebhookイベントは受信したいものをダッシュボード等から設定する必要があります。Stripeのダッシュボードを開き「Webhook」->「設定したURL」-> 右上のボタン中にある「詳細情報の更新」を押すと受信したいイベントを設定することができます。

送信イベントに追加する必要がある

まとめ

今回はStripeを用いたサブスクリプション機能の実装に関する説明をしていきましたが、実際に本番稼働する場合はエラーハンドリング、バリデーション、ロールバック処理、決済履歴の保存、通知などが様々な処理が追加で必要になると思います。決済が絡むので丁寧に実装していくことが大切だと思っています。今回の仕様にはなかったのですが「日割り計算」「トライアル加入」など仕組みも準備されており、Stripeの仕組みを使うことでサブスクリプションの実装にて困ることはほとんどないと思います。今回の実装についても数週間でリリースすることができました。

もし間違っている点やより良い形がある場合はぜひご教授頂けると幸いです。引き続き改善を行なってより良い体験を作れるよう努力していきたいと思っています😊

エンジニアも引き続き募集しているのでご興味がある方はぜひ確認してみてください!


いいなと思ったら応援しよう!