microCMSでサブスクリプションのプラン情報を管理しつつ、Stripe Checkoutで契約・決済画面を提供する方法


この記事は、[microCMSアドベントカレンダー](https://qiita.com/advent-calendar/2022/microcms)22日目の記事です。

## microCMSで、サブスクリプションのプラン情報を管理する

microCMSを利用してサイトやサービスの情報を管理している場合、サブスクリプションのプランや提供内容等もまとめて管理することができます。

例えば次のスクリーンショットでは、プラン名や料金に加えて、どのような機能を提供するかについても登録しています。

![スクリーンショット 2022-12-22 17.04.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/6785071a-e2bc-398e-9976-fbefd1ec76b1.png)

このデータは次のようなJSONで取得できます。

```json
{
  "id": "wxn06g97s",
  "createdAt": "2022-12-22T08:04:32.738Z",
  "updatedAt": "2022-12-22T08:04:32.738Z",
  "publishedAt": "2022-12-22T08:04:32.738Z",
  "revisedAt": "2022-12-22T08:04:32.738Z",
  "name": "For Startup",
  "features": {
    "fieldId": "product-feature",
    "domain": true,
    "custom_field": false
  },
  "prices": {
    "fieldId": "plan-price",
    "monthly": 1000,
    "annual": 10000
  }
}
```

## microCMSのデータをStripeを連携させる２つの方法

microCMS上で管理しているデータを元にStripeでサブスクリプションを作成する方法は２つあります。

### 1: StripeでのCheckoutやSubscription作成時に、アドホックに料金データを作成する

手早く組み込みができるのは、Stripe上にデータを持たせずに連携させる方法です。

この場合、サイト上の表示などはmicroCMSにあるデータのみを利用します。

そしてユーザーがサブスクリプションの申し込みや商品を注文する際に、Stripe上にその場限りの料金データを登録します。

```js
import Stripe from 'stripe';
import { createClient } from 'microcms-js-sdk'; 
const client = createClient({
  serviceDomain: "YOUR_DOMAIN",
  apiKey: "YOUR_API_KEY",
});
const stripe = new Stripe('sk_test_xxx');

const createCheckoutSession = (productId: string, interval: 'monthly' | 'annual') => {
  const microCMSProductData = await client.get({
    endpoint: "product",
    contentId: productId,
  });
  const product = await stripe.product.create({
    name: microCMSProductData.name
  });
  await stripe.checkout.sessions.create({
    success_url: 'https://example.com/success',
    cancel_url: 'https://example.com/cancel',
    customer_creation: 'if_required',
    line_items: [{
      price_data: {
        currency: 'jpy',
        product: product.id,
        unit_amount: microCMSProductData[interval],
        recurring: {
          interval,
        }
      },
      quantity: 1
    }],
    mode: 'subscription',
  })
}
```

https://qiita.com/hideokamoto/items/6bd31422e2ebb27d4eaa

#### 都度作成のメリット

この方法は、この後紹介するWebhook連携よりも管理するアプリ・APIの量が少なくなります。

そのため、個人開発などで手早くはじめて、ある程度慣れてきたら後者の方法に切り替えるなど、一時的に開発速度を上げる使い方ができます。

#### 都度作成のデメリット
都度商品・料金データが作成されるため、Stripe側での管理はほぼ諦める形になります。

また、「どのプランがよく契約されているか」などの分析データを、ダッシュボードで確認することができなくなります。

### 2: microCMS Webhookで、Stripeに最低限必要な情報を連携させる [推奨]

microCMS / Stripe両方の力を活かせる方法は、このWebhookを利用する方法です。

どちらのサービスもWebhookの仕組みを用意していますが、今回はmicroCMS側を利用します。

[API設定]からWebhook APIを登録しましょう。

![スクリーンショット 2022-12-22 17.21.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2366300/272651bd-dfc1-26b5-f4ac-9051108c5220.png)

#### Webhookを利用した連携の例

microCMSのWebhookデータを利用して、Stripeに商品や料金情報を登録しましょう。

新規にコンテンツを作成した場合、`req.body.type`が`new`になりますので、Stripe側にリソースを作成する処理を追加しましょう。

```typescript
app.post("/webhook", async (req, res) => {
  const { api, contents, type } = req.body;
  switch (type) {
    case 'new': {
      const { name, prices, id } = contents.new.publishValue
      const product = await stripe.products.create({
        id,
        name,
      });
      await stripe.prices.create({
        product: product.id,
        unit_amount: prices.monthly,
        recurring: {
          interval: 'month'
        },
        currency: 'jpy',
        lookup_key: `${id}_month`,
      });
      await stripe.prices.create({
        product: product.id,
        unit_amount: prices.annual,
        recurring: {
          interval: 'year'
        },
        currency: 'jpy',
        lookup_key: `${id}_annual`,
      });
      break;
    }
    case 'edit':
    default:
      break;
  }
  res.status(201).end();
});
```

また、料金の変更やmicroCMS上での非公開操作などを反映させるために、`type='edit'`も実装しましょう。

```diff_typescript

    }
    case 'edit': {
+      const { name, prices, id } = contents.new.publishValue;
+      const { status } = contents.new;
+      if (status[0] === 'PUBLISH') {
+        // 公開状態の場合は、変更操作
+        const product = await stripe.products.retrieve(id);
+        await stripe.products.update(id, {
+          name
+        });
+        const productPrices = await stripe.prices.list({
+          lookup_keys: [`${id}_month`,`${id}_annual` ]
+        });
+        await Promise.all(productPrices.data.map(async price => {
+          const interval = price.recurring.interval === 'month' ? 'monthly': 'annual';
+          const newPrice = prices[interval]
+          // 価格の変更がない場合はなにもしない
+          if (price.unit_amount === newPrice) return;
+          // 価格の変更は、新規作成 -> 削除で対応
+          await stripe.prices.create({
+            product: product.id,
+            unit_amount: newPrice.annual,
+            recurring: price.recurring,
+            currency: price.currency,
+            transfer_lookup_key: true,
+            lookup_key: `${id}_${interval}`,
+          });
+          await stripe.prices.update(price.id, {
+            active: false
+          })
+        }));
+      } else if (status[0] === 'CLOSED') {
+        // 非公開状態になるため、Stripe側も非アクティブに
+        await stripe.products.update(id, {
+          active: false
+        });
+      }
+      break;
    }
    default:
      break;
```

#### microCMSのデータから、Stripeの情報を引き出す

上のサンプルでは、商品(Product)のIDをmicroCMSのIDと揃えています。

そのため、microCMS側のIDが取得できれば、それを元にデータを取り出すことができます。

```js
const product = await stripe.product.retrieve(id);
```

料金データについても、ProductのIDを利用して取得できます。

```js
const productPrices = await stripe.prices.list({
  lookup_keys: [`${id}_month`,`${id}_annual` ]
});
```

https://qiita.com/hideokamoto/items/fed4a7caba36585dccee

#### Webhook連携のメリット

microCMS上のデータのうち、サブスクリプションの契約などに必要な情報をStripeと同期させています。

同じ商品・料金データが重複することがなく、Stripeダッシュボード上でのデータ分析や検索などがやりやすくなります。

#### Webhook連携のデメリット

Webhook APIのメンテナンスコストが発生することも１つですが、「Stripe側で商品・料金データを操作できる」点にも注意が必要です。

料金データが誤って削除されていたりすると、microCMSやアプリケーションコードを変更していなくても不具合が発生します。

対策としては、Stripe側のWebhookも利用して、双方向に変更を同期できるようにするなどが挙がります。

ただしこの場合、「microCMSで情報を変更 -> Webhook経由でStripeの情報を更新 -> Webhook経由でmicroCMSの情報を更新 -> ...」と無限ループが発生する恐れがある点にはご注意ください。

ループを回避するため、「料金の金額が変わっている場合は処理しない」のように、更新を必要としないケースの判定を入れましょう。

```diff_typescript
          const interval = price.recurring.interval === 'month' ? 'monthly': 'annual';
          const newPrice = prices[interval]
+          if (price.unit_amount === newPrice) return;
          await stripe.prices.create({
```