前回の続き。
ドキュメントを読み進めて、使い方はわかったけど、
Stripe側でどんな処理しているのかもやもや。
とりあえず、拡張機能インストール時に追加された
関数の中身を見てWebhookまわりとかを理解していく。
拡張機能のソースコードは、こちら。
・stripe-firebase-extensions/firestore-stripe-subscriptions
定期支払いについては、おおまかな流れやライフサイクル/イベントが、
以下のドキュメントにまとまっているので、まずはそれを読むと理解しやすい。
・定期支払いの仕組み
Stripクライアントを利用した流れは、こちら。
・Checkout とカスタマーポータルを使用した定期支払い
追加される6つの関数
インストールすると追加される関数はこの6つ。
- createCustomer:
- AuthのonCreateトリガー
- Authユーザを作成したとき、Stripeの顧客オブジェクトも作成
- (設定で
Sync new users to Stripe customers and Cloud Firestore
を有効にしてるときのみ実行)
- createCheckoutSession:
- FirestoreのonCreateトリガー (
path: /customers/{uid}/checkout_sessions/{id}
) - checkout_sessionsドキュメントからStripeのチェックアウトセッションの作成
- FirestoreのonCreateトリガー (
- createPortalLink:
- Callable Function
- カスタマーポータルのリンクを生成
- handleWebhookEvents:
- HTTPリクエストで呼べるFunction
- StripeのWebhookイベントをハンドリング
- onUserDeleted:
- AuthのonDeleteトリガー
- Authユーザを削除したとき、Stripeの顧客オブジェクトを削除
- (設定で
Automatically delete Stripe customer objects
を有効にしてるときのみ実行)
- onCustomerDataDeleted:
- FirestoreのonDeleteトリガー (
path: /customers/{uid}
) - Firestoreの顧客ドキュメントを削除したとき、Stripeの顧客オブジェクトも削除
- (設定で
Automatically delete Stripe customer objects
を有効にしてるときのみ実行)
- FirestoreのonDeleteトリガー (
実際の関数名には、拡張機能のprefixがつくので
ext-firestore-stripe-subscriptions-createPortalLink
みたいな感じになる。
handleWebhookEventsだけ、すごいボリューム(´ω`)
ソースコードの構成
ファイルとしては4つで、こんな感じ。
firestore-stripe-subscriptions/functions/src/ ├── index.ts ... メインの処理。関数の内容がここ ├── interfaces.ts ... 型定義。Stripeの型もimportしてる ├── config.ts ... Configで設定した値 └── logs.ts ... ログのユーティリティ関数たち
だいたいはindex.ts
とinterfaces.ts
を読めばよさそう。
関数の中でやっていること
handleWebhookEventsは重いので、それ以外を上から順に(´ω`)
StripeクライアントとFirebase Adminの初期化はindex.tsの冒頭でしてる。
コードを見やすくするために、ログを削除したり、型を追加してる。
createCustomer
ざっくりこんな感じ。
stripe.customers.create()
を使って顧客オブジェクトを作成して- 返ってきた結果の一部をFirestore(
/customers/{uid}
)に保存
// index.ts exports.createCustomer = functions.auth.user().onCreate( async (user): Promise<void> => { if (!config.syncUsersOnCreate) return; const { email, uid } = user; await createCustomerRecord({ email, uid }); } ); /** * Create a customer object in Stripe when a user is created. */ const createCustomerRecord = async ({ email, uid }: { email?: string; uid: string; }) => { try { const customerData: CustomerData = { metadata: { firebaseUID: uid } }; if (email) customerData.email = email; const customer: Stripe.Response<Stripe.Customer> = await stripe.customers.create(customerData); // Add a mapping record in Cloud Firestore. const customerRecord = { email: customer.email, stripeId: customer.id, stripeLink: `https://dashboard.stripe.com${ customer.livemode ? '' : '/test'}/customers/${customer.id}`, }; await admin .firestore() .collection(config.customersCollectionPath) .doc(uid) .set(customerRecord, { merge: true }); return customerRecord; } catch (error) { return null; } };
{ merge: true })
で保存されてるので、拡張機能のConfigで既存のパスを設定しても上書きはされなさそう。
型定義としてはこんな感じっぽい。
// /customers/{uid}/ export interface CustomerDoc { email: string; stripeId: string; stripeLink: string; }
createCheckoutSession
ざっくりこんな感じ。
stripe.checkout.sessions.create()
でチェックアウトセッションを作成- 返ってきた
sessionId
をドキュメントに保存 - エラーだったら、
error.message
にエラーメッセージを保存 /customers/{uid}
にドキュメントがなければ、ここで作成し、stripeIdを取得
ほぼstripe.checkout.sessions.create()
に渡すパラメタの構築処理で、
保存したStripe側から返ってきた結果は、sessionId
だけ更新する感じ。
/** * Create a CheckoutSession for the customer so they can sign up for the subscription. */ exports.createCheckoutSession = functions.firestore .document(`/${config.customersCollectionPath}/{uid}/checkout_sessions/{id}`) .onCreate(async (snap, context) => { const { mode = 'subscription', price, success_url, cancel_url, quantity = 1, payment_method_types = ['card'], metadata = {}, tax_rates = [], allow_promotion_codes = false, trial_from_plan = true, line_items, billing_address_collection = 'required', collect_shipping_address = false, locale = 'auto', promotion_code, client_reference_id, } = snap.data(); try { // Get stripe customer id let customerRecord = (await snap.ref.parent.parent.get()).data(); if (!customerRecord?.stripeId) { const { email } = await admin.auth().getUser(context.params.uid); customerRecord = await createCustomerRecord({ uid: context.params.uid, email }); } const customer = customerRecord.stripeId; // Get shipping countries const shippingCountries = collect_shipping_address ? ( await admin .firestore() .collection(config.productsCollectionPath) .doc('shipping_countries') .get() ).data()?.['allowed_countries'] ?? [] : []; const sessionCreateParams: Stripe.Checkout.SessionCreateParams = { billing_address_collection, shipping_address_collection: { allowed_countries: shippingCountries }, payment_method_types, customer, line_items: line_items ? line_items : [ { price, quantity } ], mode, success_url, cancel_url, locale, }; if (mode === 'subscription') { sessionCreateParams.subscription_data = { trial_from_plan, metadata, default_tax_rates: tax_rates, }; } if (promotion_code) { sessionCreateParams.discounts = [{ promotion_code }]; } else { sessionCreateParams.allow_promotion_codes = allow_promotion_codes; } if (client_reference_id) sessionCreateParams.client_reference_id = client_reference_id; const session: Stripe.Response<Stripe.Checkout.Session> = await stripe.checkout.sessions.create( sessionCreateParams, { idempotencyKey: context.params.id } ); await snap.ref.set( { sessionId: session.id, created: admin.firestore.Timestamp.now(), }, { merge: true } ); return; } catch (error) { await snap.ref.set( { error: { message: error.message } }, { merge: true } ); } });
型定義は、こんな感じっぽい。success_urlとcancel_urlは必須。
該当のフィールドが存在しない場合は、デフォルト値がStripe側に渡される。
// /customers/{uid}/checkout_sessions/{id} export interface CheckoutSessionDoc { mode?: 'subscription' | "payment" | string; // default: 'subscription' price?: string; success_url: string; cancel_url: string; quantity?: number; // default: 1 payment_method_types?: Array<Stripe.Chekout.SessionCreateParams.PaymentMethodType>; // default: ['card'] metadata?: Stripe.Checkout.SessionCreateParams.SubscriptionData; // default: {} tax_rates?: Array<string>; // default: [] allow_promotion_codes?: boolean; // default: false trial_from_plan?: boolean; // default: true line_items?: Stripe.Checkout.SessionCreateParams.LineItem[]; billing_address_collection?: Stripe.Chekout.SessionCreateParams.BillingAddressCollection; // default: 'required', collect_shipping_address?: boolean; // default: false, locale?: Stripe.Chekout.SessionCreateParams.Locale; // default: 'auto', promotion_code?: string; client_reference_id?: string; }
createPortalLink
ざっくりこんな感じ。
context.auth.uid
に該当する/customers/{uid}
を取得stripe.billingPortal.sessions.create()
でカスタマポータルのURLを作成
イメージ通りな感じ(´ω`)
Firebase Authで認証済み+自分のみのURLしか取得できないので安心(´ω`)
/** * Create a billing portal link */ exports.createPortalLink = functions.https.onCall(async (data, context) => { // Checking that the user is authenticated. if (!context.auth) { // Throwing an HttpsError so that the client gets the error details. throw new functions.https.HttpsError( 'failed-precondition', 'The function must be called while authenticated!' ); } const uid = context.auth.uid; try { if (!uid) throw new Error('Not authenticated!'); const return_url = data.returnUrl; // Get stripe customer id const customer = ( await admin .firestore() .collection(config.customersCollectionPath) .doc(uid) .get() ).data().stripeId; const session = await stripe.billingPortal.sessions.create({ customer, return_url, }); return session; } catch (error) { throw new functions.https.HttpsError('internal', error.message); } });
onUserDeleted
ざっくりこんな感じ。
- 削除されたuidに該当する
/customers/{uid}
を取得 stripe.customers.del()
で、Stripe側の顧客オブジェクトを削除- Stripeの顧客オブジェクトを削除すると、すべての有効なサブスクがすぐにキャンセルされる
/customers/{uid}/subscriptions
配下のドキュメントをstatus: 'canceled'
に更新- 有効なサブスク(statusがtrialingかactive)のドキュメントのみ
/* * The `onUserDeleted` deletes their customer object in Stripe which immediately cancels all their subscriptions. */ export const onUserDeleted = functions.auth.user().onDelete(async (user) => { if (!config.autoDeleteUsers) return; // Get the Stripe customer id. const customer = ( await admin .firestore() .collection(config.customersCollectionPath) .doc(user.uid) .get() ).data(); // If you use the `delete-user-data` extension it could be the case that the customer record is already deleted. // In that case, the `onCustomerDataDeleted` function below takes care of deleting the Stripe customer object. if (customer) { await deleteStripeCustomer({ uid: user.uid, stripeId: customer.stripeId }); } }); const deleteStripeCustomer = async ({ uid, stripeId }: { uid: string; stripeId: string; }) => { try { // Delete their customer object. // Deleting the customer object will immediately cancel all their active subscriptions. await stripe.customers.del(stripeId); // Mark all their subscriptions as cancelled in Firestore. const update = { status: 'canceled', ended_at: admin.firestore.Timestamp.now(), }; // Set all subscription records to canceled. const subscriptionsSnap = await admin .firestore() .collection(config.customersCollectionPath) .doc(uid) .collection('subscriptions') .where('status', 'in', ['trialing', 'active']) .get(); subscriptionsSnap.forEach((doc) => { doc.ref.set(update, { merge: true }); }); } catch (error) { logs.customerDeletionError(error, uid); } };
onCustomerDataDeleted
ざっくりいうと、onUserDeleted
と同じ。
ただ、Firestore側で削除すると、Authのユーザは削除されない。
(onUserDeleted
の途中部分のみのイメージ)
/* * The `onCustomerDataDeleted` deletes their customer object in Stripe which immediately cancels all their subscriptions. */ export const onCustomerDataDeleted = functions.firestore .document(`/${config.customersCollectionPath}/{uid}`) .onDelete(async (snap, context) => { if (!config.autoDeleteUsers) return; const { stripeId } = snap.data(); await deleteStripeCustomer({ uid: context.params.uid, stripeId }); }); const deleteStripeCustomer = async ({ uid, stripeId }: { uid: string; stripeId: string; }) => { // 略。onUserDeletedのと同じ。 };
handleWebhookEvents
いよいよ本題。22のWebhookイベントを処理している関数。。
メインの部分の流れはこんな感じ。
stripe.webhooks.constructEvent()
でWebhook の署名を確認- 対応するイベントかどうかのチェック(relevantEventsのイベントのみ)
- 各イベントに対する処理
- レスポンスの返却
/** * A webhook handler function for the relevant Stripe events. */ export const handleWebhookEvents = functions.handler.https.onRequest( async (req: functions.https.Request, resp) => { const relevantEvents = new Set([ 'product.created', 'product.updated', 'product.deleted', 'price.created', 'price.updated', 'price.deleted', 'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'tax_rate.created', 'tax_rate.updated', 'invoice.paid', 'invoice.payment_succeeded', 'invoice.payment_failed', 'invoice.upcoming', 'invoice.marked_uncollectible', 'invoice.payment_action_required', 'payment_intent.processing', 'payment_intent.succeeded', 'payment_intent.canceled', 'payment_intent.payment_failed', ]); let event: Stripe.Event; // Instead of getting the `Stripe.Event` // object directly from `req.body`, // use the Stripe webhooks API to make sure // this webhook call came from a trusted source try { event = stripe.webhooks.constructEvent( req.rawBody, req.headers['stripe-signature'], config.stripeWebhookSecret ); } catch (error) { resp.status(401).send('Webhook Error: Invalid Secret'); return; } if (relevantEvents.has(event.type)) { try { switch (event.type) { case 'product.created': case 'product.updated': // ... } } catch (error) { resp.json({ error: 'Webhook handler failed. View function logs in Firebase.' }); return; } } // Return a response to Stripe to acknowledge receipt of the event. resp.json({ received: true }); } );
各イベントに対する処理が多いので、それぞれ見ていく。
商品の作成/更新/削除
分岐部分はこんな感じ。
switch (event.type) { case 'product.created': case 'product.updated': await createProductRecord(event.data.object as Stripe.Product); break; case 'product.deleted': await deleteProductOrPrice(event.data.object as Stripe.Product); break; }
作成/更新については、createProductRecord
の部分で、
受け取ったデータの一部をドキュメントに保存してる。
あと、商品のメタデータにプレフィックス(stripe_metadata_
)をつけている。
// index.ts /** * Create a Product record in Firestore based on a Stripe Product object. */ const createProductRecord = async (product: Stripe.Product): Promise<void> => { const { firebaseRole, ...rawMetadata } = product.metadata; const productData: Product = { active: product.active, name: product.name, description: product.description, role: firebaseRole ?? null, images: product.images, metadata: product.metadata, ...prefixMetadata(rawMetadata), }; await admin .firestore() .collection(config.productsCollectionPath) .doc(product.id) .set(productData, { merge: true }); logs.firestoreDocCreated(config.productsCollectionPath, product.id); }; /** * Prefix Stripe metadata keys with `stripe_metadata_` to be spread onto Product and Price docs in Cloud Firestore. */ const prefixMetadata = (metadata: object) => Object.keys(metadata).reduce((prefixedMetadata, key) => { prefixedMetadata[`stripe_metadata_${key}`] = metadata[key]; return prefixedMetadata; }, {});
削除については、deleteProductOrPrice
でやっていて、
/products/{id}
のドキュメントを削除している。
const deleteProductOrPrice = async (pr: Stripe.Product | Stripe.Price) => { if (pr.object === 'product') { await admin .firestore() .collection(config.productsCollectionPath) .doc(pr.id) .delete(); } if (pr.object === 'price') { await admin .firestore() .collection(config.productsCollectionPath) .doc((pr as Stripe.Price).product as string) .collection('prices') .doc(pr.id) .delete(); } };
/products/{id}
に保存されるドキュメントの型は、
interfaces.ts
のProduct
のよう。
価格の作成/更新/削除
分岐部分はこんな感じ。
switch (event.type) { case 'price.created': case 'price.updated': await insertPriceRecord(event.data.object as Stripe.Price); break; case 'price.deleted': await deleteProductOrPrice(event.data.object as Stripe.Price); break; }
作成/更新については、insertPriceRecord
の部分で、
これも受け取ったデータを詰め替えて、Firestoreに保存している。
/** * Create a price (billing price plan) and insert it into a subcollection in Products. */ const insertPriceRecord = async (price: Stripe.Price): Promise<void> => { if (price.billing_scheme === 'tiered') // Tiers aren't included by default, we need to retireve and expand. price = await stripe.prices.retrieve(price.id, { expand: ['tiers'] }); const priceData: Price = { active: price.active, billing_scheme: price.billing_scheme, tiers_mode: price.tiers_mode, tiers: price.tiers ?? null, currency: price.currency, description: price.nickname, type: price.type, unit_amount: price.unit_amount, recurring: price.recurring, interval: price.recurring?.interval ?? null, interval_count: price.recurring?.interval_count ?? null, trial_period_days: price.recurring?.trial_period_days ?? null, transform_quantity: price.transform_quantity, metadata: price.metadata, ...prefixMetadata(price.metadata), }; const dbRef = admin .firestore() .collection(config.productsCollectionPath) .doc(price.product as string) .collection('prices'); await dbRef.doc(price.id).set(priceData, { merge: true }); logs.firestoreDocCreated('prices', price.id); };
処理の冒頭で、if (price.billing_scheme === 'tiered')
のチェックをしてるが、
Stripeでは購入した数量に応じて金額を変更する段階制料金も用意されているよう。
・段階制料金
interfaces.ts
のPrice
が利用されているが、
billing_scheme
やrecurring
なども追加されてるので注意が必要。
削除については、deleteProductOrPrice
でやっていて、
商品の削除と同じく、該当ドキュメントの削除のみ。
税率の作成/更新
分岐部分はこんな感じ。削除はない。
switch (event.type) { case 'tax_rate.created': case 'tax_rate.updated': await insertTaxRateRecord(event.data.object as Stripe.TaxRate); break; }
実際の処理はinsertTaxRateRecord
の部分で、
これも受け取ったデータを詰め替えて、Firestoreに保存している。
/** * Insert tax rates into the products collection in Cloud Firestore. */ const insertTaxRateRecord = async (taxRate: Stripe.TaxRate): Promise<void> => { const taxRateData: TaxRate = { ...taxRate, ...prefixMetadata(taxRate.metadata), }; delete taxRateData.metadata; await admin.firestore() .collection(config.productsCollectionPath) .doc('tax_rates') .collection('tax_rates') .doc(taxRate.id) .set(taxRateData); };
型については、Stripe.TaxRate
をほぼそのまま使っている。
export interface TaxRate extends Stripe.TaxRate { // Any additional properties [propName: string]: any; }
サブスクの作成/更新/削除
分岐部分はこんな感じ。削除はない。
switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.deleted': const subscription = event.data.object as Stripe.Subscription; await manageSubscriptionStatusChange( subscription.id, subscription.customer as string, event.type === 'customer.subscription.created' ); break; }
実際の処理はmanageSubscriptionStatusChange
の部分。
処理が長くて、分岐も多くて、かなりきつい(´ω`)
/customers/{uid}
からstripeIdが一致するドキュメントを取得- ドキュメントからuidを取得
stripe.subscriptions.retrieve()
を使って、サブスクの情報を取得- サブスクの情報の価格情報をFirestoreから取得して展開
- Stripeクライアントで取得した情報を詰め替えて、Firebaseに保存
- メタデータにfirebaseRoleがあれば、Authのカスタムクレームを設定
- デフォルトの支払い方法が設定されていたら、
stripe.customers.update()
で顧客情報を更新
長い。。(´ω`)
ざっくりだと、こんな感じ。 - Stripe上のデータをFirestorに同期 - メタデータをみて、Authのカスタムクレームを設定
/customers/{uid}/subscriptions/{id}
の型は、
interfaces.ts
のSubscription
/** * Manage subscription status changes. */ const manageSubscriptionStatusChange = async ( subscriptionId: string, customerId: string, createAction: boolean ): Promise<void> => { // Get customer's UID from Firestore const customersSnap = await admin.firestore() .collection(config.customersCollectionPath) .where('stripeId', '==', customerId) .get(); if (customersSnap.size !== 1) { throw new Error('User not found!'); } const uid = customersSnap.docs[0].id; // Retrieve latest subscription status and write it to the Firestore const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['default_payment_method', 'items.data.price.product'], }); const price: Stripe.Price = subscription.items.data[0].price; const prices = []; for (const item of subscription.items.data) { prices.push( admin.firestore() .collection(config.productsCollectionPath) .doc((item.price.product as Stripe.Product).id) .collection('prices') .doc(item.price.id) ); } const product: Stripe.Product = price.product as Stripe.Product; const role = product.metadata.firebaseRole ?? null; // Get reference to subscription doc in Cloud Firestore. const subsDbRef = customersSnap.docs[0].ref.collection('subscriptions').doc(subscription.id); // Update with new Subscription status const subscriptionData: Subscription = { metadata: subscription.metadata, role, status: subscription.status, stripeLink: `https://dashboard.stripe.com${subscription.livemode ? '' : '/test'}/subscriptions/${subscription.id}`, product: admin.firestore().collection(config.productsCollectionPath).doc(product.id), price: admin.firestore().collection(config.productsCollectionPath).doc(product.id).collection('prices').doc(price.id), prices, quantity: subscription.items.data[0].quantity ?? null, items: subscription.items.data, cancel_at_period_end: subscription.cancel_at_period_end, cancel_at: subscription.cancel_at ? admin.firestore.Timestamp.fromMillis(subscription.cancel_at * 1000) : null, canceled_at: subscription.canceled_at ? admin.firestore.Timestamp.fromMillis(subscription.canceled_at * 1000) : null, current_period_start: admin.firestore.Timestamp.fromMillis(subscription.current_period_start * 1000), current_period_end: admin.firestore.Timestamp.fromMillis(subscription.current_period_end * 1000), created: admin.firestore.Timestamp.fromMillis(subscription.created * 1000), ended_at: subscription.ended_at ? admin.firestore.Timestamp.fromMillis(subscription.ended_at * 1000) : null, trial_start: subscription.trial_start ? admin.firestore.Timestamp.fromMillis(subscription.trial_start * 1000) : null, trial_end: subscription.trial_end ? admin.firestore.Timestamp.fromMillis(subscription.trial_end * 1000) : null, }; await subsDbRef.set(subscriptionData); logs.firestoreDocCreated('subscriptions', subscription.id); // Update their custom claims if (role) { try { // Get existing claims for the user const { customClaims } = await admin.auth().getUser(uid); // Set new role in custom claims as long as the subs status allows if (['trialing', 'active'].includes(subscription.status)) { await admin.auth().setCustomUserClaims(uid, { ...customClaims, stripeRole: role }); } else { logs.userCustomClaimSet(uid, 'stripeRole', 'null'); await admin.auth().setCustomUserClaims(uid, { ...customClaims, stripeRole: null }); } } catch (error) { // User has been deleted, simply return. return; } } // NOTE: This is a costly operation and should happen at the very end. // Copy the billing deatils to the customer object. if (createAction && subscription.default_payment_method) { await copyBillingDetailsToCustomer(subscription.default_payment_method as Stripe.PaymentMethod); } return; };
チェックアウトセッションの完了
分岐部分はこんな感じ。完了だけ。
サブスクの場合は、上と同じmanageSubscriptionStatusChange
、
一括払いは、別の処理になっている。
switch (event.type) { case 'checkout.session.completed': const checkoutSession = event.data .object as Stripe.Checkout.Session; if (checkoutSession.mode === 'subscription') { const subscriptionId = checkoutSession.subscription as string; await manageSubscriptionStatusChange( subscriptionId, checkoutSession.customer as string, true ); } else { const paymentIntentId = checkoutSession.payment_intent as string; const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); await insertPaymentRecord(paymentIntent); } break; }
insertPaymentRecord
の処理はこんな感じ。
stripe.paymentIntents.retrieve()
で取得した情報を- Firestoreの
/customers/{uid}/payments/{id}
に Stripe.PaymentIntent
のまま保存
/** * Add PaymentIntent objects to Cloud Firestore for one-time payments. */ const insertPaymentRecord = async (payment: Stripe.PaymentIntent) => { // Get customer's UID from Firestore const customersSnap = await admin.firestore() .collection(config.customersCollectionPath) .where('stripeId', '==', payment.customer) .get(); if (customersSnap.size !== 1) { throw new Error('User not found!'); } // Write to invoice to a subcollection on the subscription doc. await customersSnap.docs[0].ref.collection('payments').doc(payment.id).set(payment); logs.firestoreDocCreated('payments', payment.id); };
請求内容(インボイス)の同期
ここではじめて、インボイスなるものが登場。
Stripeのドキュメントだとこのあたり。
・Invoicing
・定期支払いのインボイス
分岐部分はこんな感じ。
switch (event.type) { case 'invoice.paid': case 'invoice.payment_succeeded': case 'invoice.payment_failed': case 'invoice.upcoming': case 'invoice.marked_uncollectible': case 'invoice.payment_action_required': const invoice = event.data.object as Stripe.Invoice; await insertInvoiceRecord(invoice); break; }
実際の処理はinsertInvoiceRecord
の部分で、
Firestoreの/customers/{uid}/subscriptions/{id}/invoices/{id}
に、
Stripe.Invoice
を保存している。
/** * Add invoice objects to Cloud Firestore. */ const insertInvoiceRecord = async (invoice: Stripe.Invoice) => { // Get customer's UID from Firestore const customersSnap = await admin.firestore() .collection(config.customersCollectionPath) .where('stripeId', '==', invoice.customer) .get(); if (customersSnap.size !== 1) { throw new Error('User not found!'); } // Write to invoice to a subcollection on the subscription doc. await customersSnap.docs[0].ref .collection('subscriptions') .doc(invoice.subscription as string) .collection('invoices') .doc(invoice.id) .set(invoice); };
一括場合の同期
サブスクじゃない、1回限りの支払いのときの処理。
分岐部分はこんな感じ。
switch (event.type) { case 'payment_intent.processing': case 'payment_intent.succeeded': case 'payment_intent.canceled': case 'payment_intent.payment_failed': const paymentIntent = event.data.object as Stripe.PaymentIntent; await insertPaymentRecord(paymentIntent); break; }
実際の処理はinsertPaymentRecord
の部分で、
checkout.session.completed
イベントのときと同じ。
まとめ
拡張機能の中身を見てみると、
Stripeの情報をFirestoreに同期してくれてるよう。
定期支払いについては、おおまかな流れやライフサイクル/イベントが、
以下のドキュメントにまとまっているので、まずはそれを読むと理解しやすい。
・定期支払いの仕組み
実際読んでみると、拡張機能のヘルプになかったinvoicesなどもあったりしたので、
ちゃんと目を通しておいてよかった。(´ω`)
更新したFirestoreの構成はこんな感じ。
- customers/{uid} ... 顧客 - checkout_sessions/{id} ... 顧客のチェックアウトセッション - subscriptions/{id} ... 顧客のサブスク - invoices/{id} ... 顧客のサブスクの請求情報 - payments/{id} ... 顧客の一回限り - invoices/{id} ... 顧客の一回限りの請求情報 - products/{id} ... 商品 - prices/{id} ... 価格 - products/tax_rates - tax_rates/{id} ... 税率
また、各ドキュメントの型定義はこんな感じ。
// /customers/{uid}/ export type CustomerDoc = { email: string; stripeId: string; stripeLink: string; } // /customers/{uid}/checkout_sessions/{id} export type CheckoutSessionDoc = { mode?: 'subscription' | "payment" | string; // default: 'subscription' price?: string; success_url: string; cancel_url: string; quantity?: number; // default: 1 payment_method_types?: Array<Stripe.Chekout.SessionCreateParams.PaymentMethodType>; // default: ['card'] metadata?: Stripe.Checkout.SessionCreateParams.SubscriptionData; // default: {} tax_rates?: Array<string>; // default: [] allow_promotion_codes?: boolean; // default: false trial_from_plan?: boolean; // default: true line_items?: Stripe.Checkout.SessionCreateParams.LineItem[]; billing_address_collection?: Stripe.Chekout.SessionCreateParams.BillingAddressCollection; // default: 'required', collect_shipping_address?: boolean; // default: false, locale?: Stripe.Chekout.SessionCreateParams.Locale; // default: 'auto', promotion_code?: string; client_reference_id?: string; } // /customers/{uid}/subscriptions/{id} export type SubscriptionDoc = // interfaces.tsのSubscription // /customers/{uid}/payments/{id} export type PaymentDoc = Stripe.PaymentIntent; // /customers/{uid}/subscriptions/{id}/invoices/{id} // /customers/{uid}/payments/{id}/invoices/{id} export type InvoiceDoc = Stripe.Invoice; // /products/{id} export type ProductDoc = // interfaces.tsのProduct // /products/{id}/prices/{id} export type PriceDoc = // interfaces.tsのPrice // /products/tax_rates/tax_rates/{id} export type TaxRateDoc = // interfaces.tsのTaxRate ≒ Stripe.TaxRate
だいたい流れがわかってきた気がする。。(´ω`)
つぎは、Stripeのドキュメントで必要な部分を整理すれば行けそう(´ω`)
参考にしたサイト様
- Webhook でイベント通知を受信する
- stripe/stripe-node: Node.js library for the Stripe API.
- Stripe API Reference - Webhook Endpoints
- 定期支払いの仕組み
- Invoicing
Stripe試してみたシリーズ
- 第一回: サンプルを試す
- 第二回: 拡張機能のドキュメントを読む
- 第三回: 拡張機能のソースを読む
- 第四回: Nuxtアプリで試してみる
- 第五回: 本番運用する前に