くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

StripeのFirebase Extensionsを使ってみた - その3 拡張機能のソース読んでみた編 -

前回の続き。

www.memory-lovers.blog

ドキュメントを読み進めて、使い方はわかったけど、
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のチェックアウトセッションの作成
  • 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を有効にしてるときのみ実行)

実際の関数名には、拡張機能の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.tsinterfaces.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.tsProductのよう。

価格の作成/更新/削除

分岐部分はこんな感じ。

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.tsPriceが利用されているが、
billing_schemerecurringなども追加されてるので注意が必要。

削除については、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.tsSubscription

/**
 * 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のドキュメントで必要な部分を整理すれば行けそう(´ω`)

参考にしたサイト様

過去のシリーズ