🛠️

TemporalでSubscriptionのWorkflowを組む(課金処理編)

2023/02/10に公開

前回からの続きとなります。
https://zenn.dev/kenfdev/articles/237e902d409d05

本記事ではいよいよ3番の「課金処理」を実装しましょう。

  1. ✅ユーザーがサインアップしたらウェルカムメッセージ送信し、無料トライアル期間を TrialPeriod の間有効化する
  2. TrialPeriod が終わったら、課金処理を開始する
    • ✅無料トライアル期間内にユーザーがキャンセルしたらキャンセルメールを送信する
  3. 課金処理
    1. MaxBillingPeriods を超えていない限り
    2. BillingPeriodChargeAmound顧客に請求する
    3. 2が終わったら BillingPeriod 待機する
    4. 待機中に顧客がキャンセルをしたら、サブスクリプションキャンセルメールを送信する
    5. サブスクリプションが完了( MaxBillingPeriods を超えた)したら、サブスクリプション終了メールを送信する
  4. サブスクリプション購読中の任意のタイミングで顧客に関する以下の情報が取得可能とする
    • 請求金額
    • 現在のサブスクリプション期間(手動での調整用 e.g. 返金など)

データモデルの作成

課金処理を実装する前に顧客と対応するCustomer型を作っておきます。

export type Customer = {
  firstName: string;
  lastName: string;
  email: string;
  subscription: {
    trialPeriod: number;
    billingPeriod: number;
    maxBillingPeriods: number;
    initialBillingPeriodCharge: number;
  };
  id: string;
};

Workflowとactivityのリファクタ

Workflowではこの Customer を使うようにします。あわせてactivitiesもいままで email を受け取るようにしてましたが、 Customer を受けるように修正しておきます。

export const cancelSubscription = wf.defineSignal('cancelSignal');
 
-export async function subscriptionWorkflow(email: string, trialPeriod: string | number): Promise<void> {
+export async function subscriptionWorkflow(customer: Customer): Promise<void> {
   let isCanceled = false;
   wf.setHandler(cancelSubscription, () => void (isCanceled = true));
 
-  await acts.sendWelcomeEmail(email);
+  await acts.sendWelcomeEmail(customer);
   if (await wf.condition(() => isCanceled, customer.subscription.trialPeriod)) {
-    await acts.sendCancellationEmailDuringTrialPeriod(email);
+    await acts.sendCancellationEmailDuringTrialPeriod(customer);
   } else {
-    await acts.sendSubscriptionOverEmail(email);
+    await acts.sendSubscriptionOverEmail(customer);
   }
 }

Clientの改修

Workflowが受け取る情報が email から Customer に変わったので、呼び出し側のClientも改修します。ついでに複数のWorkflowをいっきに実行するようにしてみましょう。5人の顧客がサインアップしたことにします。

コード詳細
import { Connection, Client } from '@temporalio/client';
import { subscriptionWorkflow } from '../workflows';
import { nanoid } from 'nanoid';
import { Customer } from '../types';

async function run() {
  const connection = await Connection.connect({ address: 'temporal:7233' });

  const client = new Client({ connection });

  const customers = [1, 2, 3, 4, 5].map(
    (i) =>
      ({
        firstName: 'First Name' + i,
        lastName: 'Last Name' + i,
        email: `email-${i}@example.com`,
        subscription: {
          trialPeriod: 3000 + i * 10000, // 3+ seconds,
          billingPeriod: 3000 + i * 10000, // 3+ seconds,
          maxBillingPeriods: 3,
          initialBillingPeriodCharge: 120 + i * 10,
        },
        id: `id-${i}`,
      } as Customer)
  );

  const results = await Promise.all(
    customers.map((customer) =>
      client.workflow
        .start(subscriptionWorkflow, {
          args: [customer],
          taskQueue: 'subscription-tutorial',
          // in practice, use a meaningful business ID, like customerId or transactionId
          workflowId: 'workflow-' + nanoid(),
        })
        .catch((err) => console.error('Unable to execute workflow', err))
    )
  );

  results.forEach((result) => {
    console.log('Workflow result', result);
  });
}

run().catch((err) => {
  console.error(err);
  process.exit(1);
});

テスト

リファクタが終わったところで、ワークフローを実行してみましょう。

npm run start.watch

npm run workflow

# Workerのログ
Sending welcome email to email-4@example.com
Sending welcome email to email-2@example.com
Sending welcome email to email-5@example.com
Sending welcome email to email-3@example.com
Sending welcome email to email-1@example.com
# しばらくすると↓
Sending subscription over email to email-1@example.com
Sending subscription over email to email-2@example.com
Sending subscription over email to email-3@example.com
Sending subscription over email to email-4@example.com
Sending subscription over email to email-5@example.com

ちゃんと動いてそうですね。

課金処理

実装

課金処理の実装には特に新しい概念は登場しません。今までの知識の応用になります。

以下2つのActivityを追加します。(例によって中身はただの console.log

  • 顧客に請求する(chargeCustomerForBillingPeriod)
  • サブスクリプションキャンセルメールを送信する(sendCancellationEmailDuringActiveSubscription)

そして、Workflow側に「課金処理」を実装します。ここは普通に要件に則ってプログラムを組む感じになります。内容が気になる方はは「コードの詳細」を見てください。

要件

  1. 課金処理
    1. MaxBillingPeriods を超えていない限り
    2. BillingPeriodChargeAmound顧客に請求する
    3. 2が終わったら BillingPeriod 待機する
    4. 待機中に顧客がキャンセルをしたら、サブスクリプションキャンセルメールを送信する
    5. サブスクリプションが完了( MaxBillingPeriods を超えた)したら、サブスクリプション終了メールを送信する
コードの詳細
import * as wf from '@temporalio/workflow';
import type * as activities from './activities';
import { Customer } from './types';

const acts = wf.proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export const cancelSubscription = wf.defineSignal('cancelSignal');

export async function subscriptionWorkflow(customer: Customer, trialPeriod: string | number): Promise<void> {
  let isCanceled = false;
  wf.setHandler(cancelSubscription, () => void (isCanceled = true));

  await acts.sendWelcomeEmail(customer);
  if (await wf.condition(() => isCanceled, trialPeriod)) {
    await acts.sendCancellationEmailDuringTrialPeriod(customer);
  } else {
    await billingCycle(customer);
  }
}

async function billingCycle(customer: Customer) {
  let isCanceled = false;
  wf.setHandler(cancelSubscription, () => void (isCanceled = true)); // reuse signals
  await acts.chargeCustomerForBillingPeriod(customer);
  for (let num = 0; num < customer.subscription.maxBillingPeriods; num++) {
    // Wait 1 billing period to charge customer or if they cancel subscription
    // whichever comes first
    if (await wf.condition(() => isCanceled, customer.subscription.billingPeriod)) {
      // If customer cancelled their subscription send notification email
      await acts.sendCancellationEmailDuringActiveSubscription(customer);
      break;
    }

    await acts.chargeCustomerForBillingPeriod(customer);
  }

  // if we get here the subscription period is over
  if (!isCanceled) {
    await acts.sendSubscriptionOverEmail(customer);
  }
}

テスト

実装が終わったのでテストしてみます。

npm run start.watch

npm run workflow

まずは顧客5人全員にウェルカムメッセージが送信されます。

Sending welcome email to email-1@example.com
Sending welcome email to email-2@example.com
Sending welcome email to email-4@example.com
Sending welcome email to email-3@example.com
Sending welcome email to email-5@example.com

しばらくすると最速で無料トライアルが終わる一人目の顧客への課金が開始されます。

Charging email-1@example.com amount 130 for their billing period

ここで、まだ無料トライアル期間中の4人目の顧客のトライアル期間をキャンセルしましょう(workflowIdは実行時の内容から取得してくる)。

$ npm run workflow:cancel -- workflow-q9qI6Ny3rfefhiicGwxx7

> temporal-hello-world@0.1.0 workflow:cancel
> ts-node src/scripts/cancelSubscription.ts workflow-q9qI6Ny3rfefhiicGwxx7

Workerのログにトライアルがキャンセルされた旨がログされます。

Sending trial cancellation email to email-4@example.com

しばらく放置していると、それぞれのサブスクリプションの期間に基づいて課金が実行されていくのが確認できます。

Charging email-2@example.com amount 140 for their billing period
Charging email-1@example.com amount 130 for their billing period
Charging email-3@example.com amount 150 for their billing period
Charging email-1@example.com amount 130 for their billing period
Charging email-2@example.com amount 140 for their billing period

では、このあたりで3人目と5人目の顧客のサブスクリプションをキャンセルします。

Sending active subscriber cancellation email to email-3@example.com
Charging email-1@example.com amount 130 for their billing period
Sending subscription over email to email-1@example.com
Charging email-5@example.com amount 170 for their billing period
Charging email-2@example.com amount 140 for their billing period
Sending active subscriber cancellation email to email-5@example.com
Charging email-2@example.com amount 140 for their billing period
Sending subscription over email to email-2@example.com

3人目がキャンセルになり、その間もアクティブな顧客への課金は実行されています。そして、一人目の顧客の課金期間が完了しているのも確認できます。そして最終的に3人がキャンセルをして、二人が最後まで課金されることが確認できます。

おわりに

ということで本記事で要件3の「課金処理」が実装できました。Workflowを馴染みのある言語で実装していけるのはかなり良いですね。ちゃんと単体テストも書いていくことができるのでTemporalのDevXの良さを実感しています。最後はQueryについて見ていこうと思います。

現在の実装状況

  1. ✅ユーザーがサインアップしたらウェルカムメッセージ送信し、無料トライアル期間を TrialPeriod の間有効化する
  2. TrialPeriod が終わったら、課金処理を開始する
    - ✅無料トライアル期間内にユーザーがキャンセルしたらキャンセルメールを送信する
  3. ✅課金処理
    1. ✅MaxBillingPeriods を超えていない限り
    2. ✅BillingPeriodChargeAmound顧客に請求する
    3. ✅2が終わったら BillingPeriod 待機する
    4. ✅待機中に顧客がキャンセルをしたら、サブスクリプションキャンセルメールを送信する
    5. ✅サブスクリプションが完了( MaxBillingPeriods を超えた)したら、サブスクリプション終了メールを送信する
  4. サブスクリプション購読中の任意のタイミングで顧客に関する以下の情報が取得可能とする
    - 請求金額
    - 現在のサブスクリプション期間(手動での調整用 e.g. 返金など)

このデモを試したい人は以下のリポジトリでVSCodeのdevcontainerを用意しているので、うまくいけばそのまま動作確認できます。
https://github.com/kenfdev/temporal-tutorial/tree/main/subscription-tutorial

Discussion