見出し画像

Chompyのグループ注文の仕組みについて書いてみるよ

こんにちは。シンで料理人兼エンジニアをしてますオギーです。Chompyでおすすめの料理は「W omotesando The Cellar Grill」さんの「シュルキュトリーミスト(生ハムおつまみセット)」です。お好きなワインと共に頂くと至福の時が得られます🍷

今回は自分がバックエンド/クライアント両方担当したChompyの機能の一つである「グループ注文」について、簡単な仕組みを紹介しようと思います。

グループ注文とは

グループ注文は複数人で一緒に料理を注文したいときに使える便利な機能で、幹事が注文したいお店にグループを作成し参加者は各自のアプリで食べたい料理をカートに追加するだけで、まとめて注文することができます。
(特許出願中です!)

もちろん決済は各自で行われるので注文後の割り勘の計算・調整などの必要はなく楽チンです。さらに現在はキャンペーンで

・2人以上の注文で送料無料
・4人以上の注文で幹事に総額の5%分のクーポンが付与

など仲間と楽しく便利に注文することができます!

画像2

システム概要

Chompy自体のシステム構成については過去にCTO八木さんが別途記事を書いているのでそちらを参考にしてみてください。
グループ注文は大まかな流れは

1. 幹事が注文したいお店にてグループを作成する
2. 幹事がそのグループのシェアリンクを参加したい方々に共有する
3. リンクを開くことで自動でそのグループに参加した状態でお店のページが開かれる
4. 幹事/参加者は各自で注文したい料理を自由にカート追加する
5. 参加者がカート追加完了をしたら、幹事が注文を確定させる
6. Done🎉

でそれぞれシェアリンクはFirebaseDynamicLinks、決済周りはStripe、データの整合性を保証するためにトランザクションとCloudTasksによるリトライ機構を駆使しています。こちらについては後に詳細を書いていきます。

3人の参加者がいた場合のフローチャートは以下のような形です。
(実際は細かなエラーハンドリングが多く存在します)

画像1

図の通りグループに入った以降は幹事/参加者ほぼ同様のフローなため、グループ注文を意識することなく各自で注文したい料理をそれぞれのアプリから設定することができます。仕組みとしてはシンプルですが実装していく上で直面した課題・問題について、今回は以下の二つについて抜粋して紹介していきます。

1. オーソリをいつ実施するか問題
2. データの整合性をどう担保するか問題

オーソリをいつ実施するか問題

幹事がスムーズに注文確定を実施するために、各参加者はどのタイミングでオーソリ(予めクレジットカードの信用枠を確保する行為)を行うかを検討する必要があり、案としては主に以下の2つあがりました。

案1. 注文確定時に参加者それぞれでオーソリ処理・決済処理を実施する
メリット
・クレジット利用履歴に不必要な情報が登録されない
・ユーザーの感覚に沿った内容で違和感がない
デメリット
・利用できないクレジットカードが設定されていた場合、幹事がその参加者に情報の更新をお願いする必要がある
・使用できる場合でも、3Dセキュア認証が設定されていた場合に都度参加者に対応をお願いする必要がある

案2. カート追加時に都度オーソリを実施する
メリット
・確定時は、既に参加者全員の信用枠を確保してるため注文がスムーズに行える
デメリット
・クレジットカードの種類などにより不要な取引に関する情報(オーソリに関する情報)が履歴として記録、通知されてしまう

当初は違和感のない案1を検討してましたが、デメリット内容をカバーするにはプッシュ通知などで対象の参加者に対して情報を知らせる必要があり、幹事の管理コストが非常に高くなってしまう恐れがあったため、案2を採用しています。

しかし案2に対しても、デビットカードで利用した時にオーソリしたタイミング、つまりカート追加したタイミングであたかも決済されたかのような挙動になってしまう、デビットカード問題があります。キャンセル/確定時に差額分は必ず返金されるのですが、Stripeの仕様上返金にも時間がかかるなどユーザーに取っては不安を感じてしまう可能性があります。こちらの問題は現時点でもアラートを出しその旨をユーザーに伝えるなど最低限の対策しかできておらず、引き続き対策を検討しています。

データの整合性をどう担保するか問題

グループ注文は1つのOrderの中に、メンバー数分のOrderをMemberOrdersという形で保持する構造を採用しています。つまり一つのドキュメントに対して独立した複数人の参加者がカート追加/削除、クーポンの設定などを行っていきます。さらにStripeの決済処理は「非同期」で行われるためデータの整合性を担保させる必要があります。そのような処理がない場合

・Aさんがカート追加した瞬間にBさんがカート追加するとAさんのカート追加が無かったことになってる!
・設定したはずのクーポンが反映されていない。。。
・オーソリに失敗しているのに注文が確定し支払いに失敗する

など様々な大事件が起こります。こういった自体が起こらないようにするためのキーワードは「トランザクション」「リトライ」「冪等性」です。

トランザクション
一つのドキュメントに対して同タイミングでの更新が入る際に不整合を防ぐためにはトランザクションでデータを更新することが有効です。
カート追加やクーポンの設定等など各参加者が実施する処理については以下のようにトランザクションで囲むことで、片方でGetされたドキュメントが別クライアントにてUpdateされた際は再度Getし直すことができ、常に整合性を担保することが可能です。

err = s.fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		// 最新のOrderを取得する
        ordr, err = s.orderRepository.GetTx(ctx, tx, ordr.ID)
		if err != nil {
			return err
		}
     //
        // カート追加等Orderを更新する処理を書く
        //
        // Orderを更新する
        return s.orderRepository.UpdateTx(ctx, tx, ordr)
}, firestore.MaxAttempts(5)) // リトライ最大数

カート追加処理など一通りトランザクションで囲むことで整合性を担保

またfirestore.Clientを用いてドキュメントを更新する際にtx.Settx.Updateのどちらかのメソッドから更新することが可能ですが、
・tx.Set: commit時にGetしたentityが変更されていた場合エラーが出る
・tx.Update: 元のフィールド値はみてないのでエラーはでない
といった大きな違いがあります。今回のケースの場合は変更されていた場合はリトライしてほしいため、tx.Setを使用すべきのようでした。

リトライ/冪等性
Stripeによる決済等の各種処理は非同期で行われます。そのため決済処理を行っている最中にドキュメントが更新/削除され予期せぬ不整合が起こる可能性がありました。

そこで非同期処理を一通りCloud Taskによってタスク管理することでトランザクションや予期せぬユーザー行動によってエラーが発生した際も自動でリトライしてくれるため安定的に完遂させることができます。また失敗しているタスクをコンソール上から確認できるため、場合によってはデータの調整を手動で行ってからタスクをコンソールから再実行して完遂させる、といったことも行うことができ大変便利です。

リトライ前提で動作させることから冪等性を考慮する必要があります。冪等性がないとリトライによって同一内容の決済が複数回行われてしまうなどの大事件が起こる可能性があります。Stripe APIには各処理にてIdempotencyKeyが設定できるようになっているので、OrderIDなどを忘れずにキーに設定することで同一処理が複数回行われない設計にしています。

またプッシュ通知などでも冪等性を考慮したい時のために以下のような形でFirestoreにConsistencyコレクションを準備し、KeyとTTLを保持/チェックする機構を別途準備することで自前の処理に対しても冪等性を考慮するようにしています。

type expirer struct {
	ExpirationTime time.Time
}

func (c expirer) IsExpired() bool {
	if c.ExpirationTime.IsZero() {
		return false
	}
	return c.ExpirationTime.Before(dtime.Now())
}

func (r *consistencyRepository) CheckAndMarkAsDone(ctx context.Context, id string, ttl time.Duration) error {
	return r.CheckAndMarkAsDoneStrictly(ctx, id, ttl, nil)
}

func (r *consistencyRepository) CheckAndMarkAsDoneStrictly(ctx context.Context, id string, ttl time.Duration, f func() error) error {
	return r.fsClient.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
		doc := r.fsClient.Collection(collectionNameConsistency).Doc(id)
		markAsDone := func() error {
			if f != nil {
				if err := f(); err != nil {
					return err
				}
			}
			ex := &expirer{}
			if ttl > 0 {
				ex.ExpirationTime = dtime.Now().Add(ttl)
			}
			return tx.Set(doc, ex)
		}
		ds, err := tx.Get(doc)
		if err != nil {
			if status.Code(err) == codes.NotFound {
				return markAsDone()
			}
			return err
		}
		var ex expirer
		if err := ds.DataTo(&ex); err != nil {
			return err
		}
		if ex.IsExpired() {
			return markAsDone()
		}
		return consistency.ErrAlreadyDone
	}, firestore.MaxAttempts(1))
}

終わりに

今回はグループ注文初稿ということで文字が多くなってしまいましたが、次回以降は実際のコードと共に色々紹介できたらと思っています。自分自身決済が絡むシステムの構築は初めてだったので色々苦労・学びがありました。グループ注文初期の段階では実際に決済/データ不整合を結構起こしてしまったので大変ご迷惑をおかけしました 🙇‍♂️

グループ注文の他にChompyではらくとく便やオフィスランチ便など、様々な注文形態がありどれもまだまだこれからなので、ぜひチャレンジしてみたい方はこちらをご確認ください!
Chompyにて美味しい料理をたくさん食べられるよ:)


この記事が気に入ったらサポートをしてみませんか?