Stripe Webhook でトランザクションを自動化 🌐💼

Stripe のウェブフックを実装し、取引の各ステップを自動化する方法を探求します。このチャプターでは、ウェブフックの設定から、取引イベントのリアルタイムモニタリング、そして自動応答の仕組みまでを解説。Stripe とサーバーレスアプリケーションの連携を最大限に活かす技術を提供します。

## Stripe CLI のインストール

Stripe CLI は Stripe の開発を効率的に行うためのコマンドラインツールです。以下のコマンドでインストールできます。

Stripe CLI のインストール

https://stripe.com/docs/stripe-cli#install

Mac の場合

```bash
brew install stripe/stripe-cli/stripe
```

## Stripe Webhook のテスト

`skeet s` でローカルサーバーを起動し、Stripe Webhook のテストを行います。

```bash
skeet s
```

別ウィンドウで Stripe CLI を使って、Stripe Webhook のテストを行います。
新しく追加した `stripeRouter` のエンドポイントを指定します。

```bash
stripe listen --forward-to http://127.0.0.1:5001/<your-project-id>/<your-region>/stripeRouter
```

そしてさらに別ウィンドウで、Stripe CLI でトリガーを送信します。

```bash
stripe trigger checkout.session.completed
```

![](https://storage.googleapis.com/zenn-user-upload/90d269ef68bc-20231116.png)

すると、ローカルサーバーにリクエストが送信され、
`payment` アクションが実行されました。

無事に `stripeRouter` のエンドポイントにリクエストが送信されました 🎉

## Firestore との連携、StripeUser モデルの作成

それでは、Stripe と Firestore を連携させるために、
StripeUser モデルを作成します。

`functions/skeet/src/models/StripeUser.ts`

```ts
import { Timestamp, FieldValue } from '@skeet-framework/firestore'

// CollectionId: StripeUser
// DocumentId: StripeUserId
// Path: StripeUser
export const StripeUserCN = 'StripeUser'
export type StripeUser = {
  id?: string
  customerId: string | null
  username: string
  email: string
  isActivated: boolean
  expirationDate: Timestamp | FieldValue | null
  createdAt?: Timestamp | FieldValue
  updatedAt?: Timestamp | FieldValue
}
```

## トランザクション発生時に StripeUser を作成する

サブスクリプションの購入が完了したら、Webhook を受信して、
StripeUser を作成します。

`functions/skeet/src/lib/stripe/webhook/payment.ts`

```ts
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import { add } from '@skeet-framework/firestore'
import Stripe from 'stripe'

export const subscription = async (
  db: FirebaseFirestore.Firestore,
  eventObject: Stripe.Checkout.Session
) => {
  console.log('subscription event handler')
  const stripeUserParams: StripeUser = {
    customerId: String(eventObject.customer),
    username: eventObject.customer_details?.name || '',
    email: eventObject.customer_details?.email || '',
    isActivated: true,
    expirationDate: null,
  }
  await add<StripeUser>(db, StripeUserCN, stripeUserParams)
  return true
}
```

それでは `subscription` イベントを起こすために、
テスト用のペイメントリンクから支払いを行います。

テスト用のクレジットカードは

`4242 4242 4242 4242`

期限とCVCは任意のものでテスト用として動作します。

![](https://storage.googleapis.com/zenn-user-upload/b8e78776c4ca-20231116.png)

そして Firebase エミュレーターの Firestore を確認します。

`http://localhost:4000/`

![](https://storage.googleapis.com/zenn-user-upload/d9c08a66e40e-20231116.png)


無事に StripeUser が作成されました 🎉


## メンバー限定のボタンに変更する

前の章で作成した、`bonusAction.ts` を会員限定のコンテンツへ変更します。

`functions/skeet/src/lib/discord/actions/bonusAction.ts`

```ts
import { DiscordUser, DiscordUserCN } from '@/models/discordUserModels'
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import {
  DiscordRouterParams,
  deferResponse,
  updateResponse,
} from '@skeet-framework/discord-utils'
import { get, update } from '@skeet-framework/firestore'
import { Response } from 'firebase-functions/v1'

export const bonusAction = async (
  res: Response,
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  body: DiscordRouterParams
) => {
  console.log('bonusAction')
  await deferResponse(discordToken, body.id, body.token)

  const memberId = body.member.user.id
  const user = await get<DiscordUser>(db, DiscordUserCN, memberId)
  if (!user) {
    await updateResponse(discordToken, body.application_id, body.token, {
      content: `⚠️ Failed to get user.Please try again in a few seconds.`,
      flags: 64,
    })
    return false
  }

  const bonus = Math.floor(Math.random() * 100)
  const newExp = user.exp + bonus
  let lv = user.lv
  const expToNextLevel = 100 * lv

  if (expToNextLevel >= 300) {
    const stripeUser = await get<StripeUser>(db, StripeUserCN, memberId)
    if (!stripeUser || !stripeUser.isActivated) {
      await updateResponse(discordToken, body.application_id, body.token, {
        content: `⚠️ この先はメンバーシップが必要です。`,
        flags: 64,
      })
      return false
    }
  }

  if (newExp >= expToNextLevel) {
    lv++
  }

  await update<DiscordUser>(db, DiscordUserCN, body.member.user.id, {
    exp: newExp,
    lv,
  })

  let description = `あなたは${bonus}の EXP を獲得しました！`
  if (lv > user.lv) {
    description += `\nレベルが${lv}に上がりました！`
  }
  // Discord Embed Message
  const emdbed = {
    description,
    color: 0x0099ff,
  }

  await updateResponse(discordToken, body.application_id, body.token, {
    embeds: [emdbed],
    flags: 64,
  })
  return true
}
```

これで、さきほどのボタンを EXP が 300 を超えるまでクリックしてみます。

![](https://storage.googleapis.com/zenn-user-upload/bbf9aa5bef47-20231116.png)

メンバー限定のコンテンツに変更されました 🎉