GASで作るStripeのトライアル期間終了通知

決済サービスのStripeではサブスクリプションにトライアル期間を設けることができます。
しかしながら、トライアル期間が終了する旨の通知は設定できても7日前という制限があります。(下記リンクで説明されています)

https://stripe.com/docs/billing/revenue-recovery/customer-emails

これを自由にカスタマイズするためにはStripe APIかWebhookを使って自前で仕組みを作る必要があります。
今回はその仕組みをStripe APIとGASを使って作る方法について紹介します。

なお、Webhookでは期限の3日前固定という制限があり、これもやはり自由度に欠けるためStripe APIを使用する方法を選択しています。

下記リンクの「customer.subscription.trial_will_end」が該当します。

https://stripe.com/docs/billing/subscriptions/webhooks

> トライアル期間終了の 3 日前に送信されます。トライアル期間が 3 日未満の場合、このイベントがトリガーされます。

# スプレッドシートの構成

まず、Googleスプレッドシートで以下のようなシートを作成します。
プログラムを修正しなくても良いようにシートで設定ができる構成にしています。

1. メール送信対象の一覧シート
2. 期間終了の何日前かなどを設定できるシート
3. メールのテンプレートを設定できるシート

## 1. メール送信対象の一覧シート

顧客ID、顧客名、メールアドレス、終了日でメール送信対象となる顧客をリストアップするシートです。
今回の設計では毎日データがリセットされ、その日の送信対象の一覧が記載されることとします。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/e52699eb-4d66-7e77-e53f-2db4cd4ef4f4.png">

## 2. 期間終了の何日前かなどを設定できるシート

プログラムを変えなくていいように設定シートを用意します。
対象を何日前かとするかを自由に変えられるようになります。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/a53b1a63-7740-1ee6-d327-4d59a236e91b.png">

## 3. メールのテンプレートを設定できるシート

メールの件名と本文のテンプレート文面を設定できるシートです。
プログラムで置き換え可能なプレイスホルダーを「${変数名}」というルールで埋め込んでいます。
こうしておくことでStripeから取得した顧客氏名などの可変な情報を文面に記載できるようになります。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/1315455e-9317-f30c-bc9b-a2b95099593f.png">

実際にこんな感じのメールが届きます。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/e210f95f-0ceb-191c-8ec4-e355a4a89e79.png">


# GASのコード

## 処理フロー

以下のフローで処理を行っています。

```mermaid
graph TB
  Start([Start])-->A([トライアル期限が通知対象の顧客リストを取得])
  A-->B([顧客リストに顧客情報を追加])
  B-->C([一覧シートに書き出しとメール送信])
  C-->End([End])
```

### トライアル期限が通知対象の顧客リストを取得

Stripe APIの「List Subscriptions」エンドポイントにアクセスしてサブスクリプションの一覧を取得します。
取得するのは全データとしていますが、トライアルをキャンセルしているデータは除外し、期間終了のN日前のデータのみに絞り込みます。

https://stripe.com/docs/api/subscriptions

:::note info
今回はListを使ってますが「Search subscriptions」で絞り込んだ方が大量のデータ量の場合には効率的ですので、サービスの状況に応じて使い分けていただくと良いかと思います。
:::

### 顧客リストに顧客情報を追加

前手順で取得した顧客リストには氏名とメールアドレスの情報は載っていないため顧客情報を取得して情報を追加します。
Stripe APIの「The customer object」エンドポイントにアクセスして顧客情報を取得し、顧客リストの1件1件に情報を追加していきます。

https://stripe.com/docs/api/customers/object

### 一覧シートに書き出しとメール送信

「1. メール送信対象の一覧シート」に顧客リストを書き出します。
書き出しながら各顧客に対してトライアル期間終了のリマインドメールを送信します。

## コード

実行ポイントとなるgetTrialEndList関数と各種クラスを作成しました。

<dl>
  <dt>getTrialEndList関数</dt>
  <dd>Stripeからトライアル期限終了N日前の一覧を取得しメール通知します</dd>
  <dt>StripeClientFactoryクラス</dt>
  <dd>StripeClientクラスのインスタンスを生成するFactoryです</dd>
  <dt>StripeClientクラス</dt>
  <dd>Stripe APIへのアクセスを行うクラスです</dd>
  <dt>SubscriptionInfoクラス</dt>
  <dd>Stripe APIから取得サブスクリプションの1件分を取り扱う情報クラスです</dd>
  <dt>TrialEndNotifierクラス</dt>
  <dd>トライアル期間終了のリマインドメールを送信するクラスです</dd>
  <dt>DateUtilクラス</dt>
  <dd>日付操作に関するユーティリティクラスです</dd>
</dl>

```javascript
// Stripe APIを叩くのに必要な情報
const STRIPE_SECRET_KEY = 'YOUR SECRET KEY'

/**
 * トライアル期限終了N日前の一覧を取得しメール通知
 */
function getTrialEndList() {
  let url, client, json

  const clientFactory = new StripeClientFactory(STRIPE_SECRET_KEY)
  const subscriptionInfos = []

  ////////////////////////////////////////////////////
  // トライアル期限が通知対象の顧客リストを取得
  ////////////////////////////////////////////////////
  const configSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('設定')
  const nDay = configSheet.getRange(2, 2).getValue()

  let nextId = false
  do {　// 全ページ分ループ
    // サブスクリプション一覧取得APIリクエスト
    url = '/subscriptions?status=trialing&limit=100'
    if (nextId !== false) {
      url += '&starting_after=' + nextId
    }
    console.log(url)
    client = clientFactory.create(url)
    json = client.get()
    // 対象のサブスクリプションを抽出
    const len = json.data.length
    for (let i=0; i<len; i++) {
      const subscription = json.data[i]
      // キャンセル以外
      if (!subscription.cancel_at_period_end) {
        const info = new SubscriptionInfo(subscription)
        // トライアル期限がN日前のもののみに絞る
        if (info.isTrialEndBeforeNDay(nDay)) {
          subscriptionInfos.push(info)
        }
      }
    }
    if (json.has_more) {
      // 次のページがある場合
      nextId = json.data[len-1].id
      Utilities.sleep(500)
    } else {
      // 最終ページの場合
      nextId = false
    }
  } while (nextId !== false)

  ////////////////////////////////////////////////////
  // 顧客情報を追加
  ////////////////////////////////////////////////////
  for (const info of subscriptionInfos) {
    Utilities.sleep(300)
    // 顧客情報取得APIリクエスト
    url = '/customers/' + info.customerId
    console.log(url)
    client = clientFactory.create(url)
    json = client.get()

    // 情報追加
    info.customerName = json.name
    info.email = json.email
  }

  ////////////////////////////////////////////////////
  // 一覧シートに書き出しとメール送信
  ////////////////////////////////////////////////////
  const listSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('一覧')
  if (listSheet.getLastRow() >= 2) {
    // シートクリア
    listSheet.getRange(2, 1, listSheet.getLastRow()-1, listSheet.getLastColumn()).clear()
  }
  for (const info of subscriptionInfos) {
    // メール送信
    const notifier = new TrialEndNotifier(info.trialEnd, info.customerName, info.email)
    notifier.sendRemindEmail()
    // シートに行追加
    listSheet.appendRow(info.toArray())
  }
}

/**
 * Stripeクライアントのファクトリークラス
 */
class StripeClientFactory {
  constructor(secretKey) {
    this.secretKey = secretKey
  }

  /**
   * StipeClientのインスタンスを生成して返却
   */
  create(appendUrl) {
    return new StripeClient(this.secretKey, appendUrl)
  }
}

/**
 * Stripeクライアントクラス
 */
class StripeClient {
  constructor(secretKey, appendUrl) {
    this.secretKey = secretKey
    this.url = this.makeUrl(appendUrl)
  }

  /**
   * APIへのリクエストURLを作成
   */
  makeUrl(appendUrl) {
    return 'https://api.stripe.com/v1' + appendUrl
  }

  /**
   * リクエストヘッダーを作成
   */
  makeHeaders() {
    return {
      'Authorization': 'Bearer ' + this.secretKey
    }
  }

  /**
   * GETリクエスト
   */
  get() {
    const params = {
      'method': 'GET',
      'headers': this.makeHeaders()
    }
    const response = UrlFetchApp.fetch(this.url, params)
    const json = JSON.parse(response.getContentText())
    return json
  }
}

/**
 * サブスクリプション情報クラス
 */
class SubscriptionInfo {
  constructor(subscription) {
    this.customerId = subscription.customer
    this.trialEnd = new Date(subscription.trial_end * 1000)　// Date型に変換
    this.customerName = ''
    this.email = ''
  }

  /**
   * トライアル期限がN日前か判定
   */
  isTrialEndBeforeNDay(beforeDayNum) {
    // 現在日付を取得
    const now = new Date()
    const ymd = DateUtil.toYmdString(now)

    // トライアル期限のN日前を取得
    const beforeNDay = DateUtil.addDay(this.trialEnd, (beforeDayNum * -1))
    const ymd2 = DateUtil.toYmdString(beforeNDay)
    
    //console.log(ymd, ymd2)
    // トライアル期限の前日が現在日付と一致するか
    return (ymd === ymd2)
  }

  /**
   * シートへの書き出し用の配列作成
   */
  toArray() {
    return [this.customerId, this.customerName, this.email, this.trialEnd]
  }
}

/**
 * トライアル期間終了通知クラス
 */
class TrialEndNotifier {
  constructor(trialEndDate, customerName, email) {
    this.trialEndDate = trialEndDate
    this.customerName = customerName
    this.email = email
  }

  /**
   * リマインドメール送信
   */
  sendRemindEmail() {
    const SERVICE_NAME = 'サービス名'

    // シートから件名と本文のテンプレートを取得
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('メール本文')
    let title = sheet.getRange(2, 2).getValue()
    let body = sheet.getRange(2, 3).getValue()

    // プレイスホルダーの置換
    title = title.replaceAll('${ServiceName}', SERVICE_NAME)
    body = body.replaceAll('${ServiceName}', SERVICE_NAME)
    body = body.replaceAll('${CustomerName}', this.customerName)
    body = body.replaceAll('${TrialEnd}', DateUtil.toYmdString(this.trialEndDate))

    // メール送信
    GmailApp.sendEmail(this.email, title, body)
  }
}

/**
 * 日付ユーティリティクラス
 */
class DateUtil {
  /**
   * DateをYYYY/MM/DD形式の文字列に変換
   */
  static toYmdString(targetDate) {
    const y = targetDate.getFullYear()
    const m = String(targetDate.getMonth() + 1).padStart(2, '0')
    const d = String(targetDate.getDate()).padStart(2, '0')
    const ymd = [y, m, d].join('/')
    return ymd
  }

  /**
   * Dateの日を加算して新しいDateオブジェクトを返却
   */
  static addDay(targetDate, day) {
    const y = targetDate.getFullYear()
    const m = targetDate.getMonth()
    const d = targetDate.getDate()
    return (new Date(y, m, d + day))
  }
}
```

# トリガーの設定

毎日実行したいのでトリガーを日次で実行されるよう設定します。
getTrialEndList関数をトリガーにセットします。

# おわりに

以上、Stripeのトライアル期間終了を通知する仕組みをご紹介しました。
他にも様々な機能がStripeにはありますので、決済サービスにStripeを起用されている方は色々と仕組みを作ってみると便利かと思います。

Stripeは最近リリースした弊社の資本政策データベースサービス「shihon」でも採用しており、実務上でもこういった仕組みが必要であると実感しているところです。

https://www.shihon.com/

この記事が皆様の一助になれば幸いです。
最後までお読みいただき、ありがとうございました。