StripeとDiscordのロール管理を統合 🤝🎮

Stripe の支払いと Discord のロール管理をシームレスに統合する方法を紹介します。このチャプターでは、支払い完了時の Discord ロールの自動割り当てや、定期購読に基づくロールの更新など、経済的な価値に基づくコミュニティ管理の技術を展開。サーバーレスアプリケーションでの効果的なユーザー経験を構築します。

この章では会員制コミュニティの構築に必要な最低限の機能を実装します。

- ボタンアクションから支払いリンクを作成
- Stripe の支払い管理
- Discord のロール管理

ここで `membership` という名前の Discord ロールを作成します。

## ボタンアクションから支払いリンクを作成

Discord のボタンアクションから、新しいプライベートチャンネルを作成し、
支払いリンクをスレッドへ投稿します。

`memberMessage.ts` 関数を作成し、ボタンメッセージを作成します。

`functions/skeet/src/lib/discord/messages/memberMessage.ts`

```ts
import {
  ButtonStyleTypes,
  MessageComponentTypes,
} from '@skeet-framework/discord-utils'

export const memberMessage = () => {
  const body = {
    content: 'ボタンを押してね！',
    components: [
      {
        type: MessageComponentTypes.ACTION_ROW,
        components: [
          {
            type: MessageComponentTypes.BUTTON,
            style: ButtonStyleTypes.PRIMARY,
            label: '📝 メンバーになる',
            custom_id: 'member-button',
          },
        ],
      },
    ],
  }
  return body
}
```

## Stripe のカスタムパラメーターの設定

`paymentLinkMessage.ts` 関数を作成し、支払いリンクを作成します。

`functions/skeet/src/lib/discord/messages/paymentLinkMessage.ts`

```ts
import { ButtonStyleTypes, MessageComponentTypes } from 'discord-interactions'

export const paymentLinkMessage = (discordUserId: string) => {
  const paymentLink = `https://buy.stripe.com/test_cN2bJj1XBbG3cN2fZ0?client_reference_id=${discordUserId}&locale=ja`
  const body = {
    content: `メンバーになるにはこちらのリンクから支払いをしてください！`,
    components: [
      {
        type: MessageComponentTypes.ACTION_ROW,
        components: [
          {
            type: MessageComponentTypes.BUTTON,
            style: ButtonStyleTypes.LINK,
            label: 'お支払いリンク',
            url: paymentLink,
          },
        ],
      },
    ],
  }
  return body
}
```

ここでは前の章で作成した　ペイメントリンクを使用します。
そして、URL パラメーターに `client_reference_id` を追加することで、
Discord のユーザー ID を Stripe の顧客 ID 情報と照合できるようにします。

`locale` を `ja` に設定することで、日本語の支払い画面を表示します。

## メンバーボタンアクションの登録

次に、`memberAction.ts` 関数を作成し、ボタンアクションを定義します。
このアクションでは `StripeUser` が作成されていなければ作成し、
プライベートチャンネルに新たにボタンメッセージを送信します。

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

```ts
import {
  DiscordRouterParams,
  messageChannel,
} from '@skeet-framework/discord-utils'
import { Response } from 'firebase-functions/v1'
import { add, get } from '@skeet-framework/firestore'
import {
  createPrivateChannel,
  deferResponse,
  updateResponse,
} from '@skeet-framework/discord-utils'
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import { paymentLinkMessage } from '../messages/paymentLinkMessage'

export const memberAction = async (
  res: Response,
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  body: DiscordRouterParams
) => {
  await deferResponse(discordToken, body.id, body.token)
  const memberId = body.member.user.id
  const username = body.member.user.username
  const stripeUser = await get<StripeUser>(db, StripeUserCN, memberId)
  if (stripeUser && stripeUser.isActivated) {
    const embed = {
      description: `⚠️ すでに会員です。`,
      // yellow
      color: 0xffee00,
    }
    await updateResponse(discordToken, body.application_id, body.token, {
      embeds: [embed],
      flags: 64,
    })
    return
  }
  const channelName = `payment-${username}`

  const channel = await createPrivateChannel(
    discordToken,
    body.guild_id,
    channelName,
    memberId
  )

  if (channel) {
    const stripeUserParams: StripeUser = {
      customerId: null,
      username,
      email: '',
      isActivated: false,
      expirationDate: null,
    }
    console.log({ stripeUserParams })
    await add<StripeUser>(db, StripeUserCN, stripeUserParams, memberId)

    await messageChannel(discordToken, channel.id, paymentLinkMessage(memberId))
    const channelNumber = channel?.id || ''
    console.log({ channelNumber })

    const embed = {
      description: `✅ 新しいチャンネルが作成されました - <#${channelNumber}>`,
      color: 0x00ff00,
    }
    const content = {
      embeds: [embed],
      flags: 64,
    }
    await updateResponse(discordToken, body.application_id, body.token, content)
    return
  } else {
    await updateResponse(discordToken, body.application_id, body.token, {
      content: `⚠️ Failed to create a channel.Please try again in a few seconds.`,
      flags: 64,
    })
    return
  }
}
```

## DiscordRouter の更新

そして `discordRouter` エンドポイントに `memberAction` を登録します。

`functions/skeet/src/routings/http/discordRouter.ts`

```ts
...
} else {
  // Button action
  const { custom_id } = req.body.data as ActionData
  if (custom_id.match(/^register-button$/)) {
    await registerAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
  if (custom_id.match(/^view-button$/)) {
    await viewAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
  if (custom_id.match(/^bonus-button$/)) {
    await bonusAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
  if (custom_id.match(/^member-button$/)) {
    await memberAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
}
...
```

アプリをデプロイして、変更を反映させます。

```bash
skeet deploy --function skeet:discordRouter
```

テスト用の `root.ts` を以下のように変更します。

```ts
import { onRequest } from 'firebase-functions/v2/https'
import { publicHttpOption } from '@/routings/options'
import { TypedRequestBody } from '@/types/http'
import { RootParams } from '@/types/http/rootParams'
import { messageChannel } from '@skeet-framework/discord-utils'
import { defineSecret } from 'firebase-functions/params'
import { memberMessage } from '@/lib/discord/messages/memberMessage'

const DISCORD_TOKEN = defineSecret('DISCORD_TOKEN')
export const root = onRequest(
  { ...publicHttpOption, secrets: [DISCORD_TOKEN] },
  async (req: TypedRequestBody<RootParams>, res) => {
    try {
      // Define your logic here
      const message = memberMessage()
      const channelId = 'your-channel-id'
      await messageChannel(DISCORD_TOKEN.value(), channelId, message)
      res.json({ status: 'success' })
    } catch (error) {
      res.status(500).json({ status: 'error', message: String(error) })
    }
  },
)
```

アプリを起動して、ボタンメッセージを送信します。

```bash
skeet s
```

テストようの`root` エンドポイントにアクセスします。

`http://127.0.0.1:5001/your-project-id/asia-northeast1/root`

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

そしてボタンを押すと

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

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

無事にチャンネルが作成され、支払いリンクが投稿されました。

## Stripe Webhook 受信時に membership ロールの付与

支払いが完了したときに、ユーザーに `membership` ロールを付与します。
subscription 関数を更新します。

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

```ts
import { DISCORD_GUILD_ID, MEMBERSHIP_ROLE_ID } from '@/lib/config'
import { StripeUser, StripeUserCN } from '@/models/stripeUserModels'
import { addRoleToUser } from '@skeet-framework/discord-utils'
import { get, update } from '@skeet-framework/firestore'
import { addMonths, utcNow } from '@skeet-framework/utils'
import Stripe from 'stripe'

export const subscription = async (
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  eventObject: Stripe.Checkout.Session
) => {
  console.log('subscription event handler')
  const discordUserId = eventObject.client_reference_id as string

  // Get StripeUser by discordUserId
  const stripeUser = await get<StripeUser>(db, StripeUserCN, discordUserId)
  if (!stripeUser) {
    console.log('stripeUser not found')
    return false
  }

  // Add Discord Role
  await addRoleToUser(
    discordToken,
    DISCORD_GUILD_ID,
    discordUserId,
    MEMBERSHIP_ROLE_ID
  )

  // Update StripeUser
  const today = utcNow()
  const oneMonthLater = addMonths(today, 1)
  await update<StripeUser>(db, StripeUserCN, discordUserId, {
    isActivated: true,
    customerId: String(eventObject.customer),
    expirationDate: oneMonthLater,
  })
  return true
}
```

## StripeRouter に DiscordToken を追加

`functions/skeet/src/routings/http/stripeRouter.ts`

```ts
// Discord Token を追加
const DISCORD_TOKEN = defineSecret('DISCORD_TOKEN')

export const stripeRouter = onRequest(
  {
    ...publicHttpOption,
    secrets: [
      STRIPE_SECRET_KEY,
      STRIPE_WEBHOOK_SECRET_KEY,
      STRIPE_WEBHOOK_SECRET_KEY_TEST,
      STRIPE_SECRET_KEY_TEST,
      DISCORD_TOKEN,
    ],
  },
...
```

そして subscription 関数の引数を更新します。

```ts
...
try {
  //let event = req.body as Stripe.Event
  const event = await getVerifiedStripeEvent(req, stripe, secret, payload)
  switch (event.type) {
    case 'checkout.session.completed': {
      const eventObject = event.data.object
      if (eventObject.mode === 'subscription') {
        await subscription(db, DISCORD_TOKEN.value(), eventObject)
      }
      if (eventObject.mode === 'payment') {
        await payment(db, eventObject)
      }
      break
    }
    default:
      break
  }
...
```

アプリをデプロイして、変更を反映させます。

```bash
skeet deploy --function skeet:stripeRouter,skeet:discordRouter
```

## Firebase エミュレーターで 統合テスト

`skeet s` でアプリを起動します。

```bash
skeet s
```

別ウィンドウで、Stripe CLI を起動します。

```bash
stripe listen --forward-to localhost:5001/skeet/us-central1/stripeRouter
```

テストユーザーを Firebase エミュレーターに登録します。
テスト用に自分の Discord ユーザー ID を登録します。

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

それではメンバーボタンから新規チャンネルを作成し、
テスト用の支払いを完了させてみます。

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

無事にロールが付与されました 🎉