Skip to main content
Temporal TypeScript SDK

Build a recurring billing subscription system with TypeScript

~2 hoursTypeScriptBeginner

Introduction

Managing subscription-based services requires precision and fault tolerance at every step. You need to reliably handle processes like user sign-ups, trial periods, billing cycles, and cancellations. This often involves making durable calls to external services such as databases, email servers, and payment gateways.

In this tutorial you build the backend processes of a phone subscription management application using TypeScript. You handle the entire subscription lifecycle, from welcoming new users to managing billing and cancellations through command-line programs. While using command-line scripts simplifies the demonstration, in a real-world scenario you'd likely build a web interface or API.

You'll find the code for this tutorial on GitHub in the subscription-workflow-project-template-typescript repository.

Prerequisites

Create your project

Create a new project directory called subscription-workflow-with-temporal:

mkdir subscription-workflow-with-temporal
cd subscription-workflow-with-temporal

Install @tsconfig/node20, Nodemon, and ts-node:

npm install --save-dev @tsconfig/node20
npm install --save-dev nodemon
npm install --save-dev ts-node

Configure TypeScript:

touch tsconfig.json
tsconfig.json
{
"extends": "@tsconfig/node20/tsconfig.json",
"version": "4.9.5",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "./src",
"outDir": "./lib"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

Install Temporal and its dependencies:

npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity

Your dependencies in package.json:

"dependencies": {
"@temporalio/activity": "^1.10.0",
"@temporalio/client": "^1.10.0",
"@temporalio/worker": "^1.10.0",
"@temporalio/workflow": "^1.10.0",
}

Create a src folder and files:

mkdir -p src
touch src/worker.ts src/workflows.ts src/activities.ts
mkdir src/scripts
touch src/scripts/cancel-subscription.ts src/scripts/execute-workflow.ts src/scripts/query-billinginfo.ts src/scripts/update-chargeamt.ts

In package.json, create scripts:

"scripts": {
"start": "ts-node src/worker.ts",
"start.watch": "nodemon src/worker.ts",
"workflow": "ts-node src/scripts/execute-workflow.ts",
"querybillinginfo": "ts-node src/scripts/query-billinginfo.ts",
"cancelsubscription": "ts-node src/scripts/cancel-subscription.ts",
"updatechargeamount": "ts-node src/scripts/update-chargeamt.ts"
}

Define your customer

Define the customer information needed when signing up. Use a single object for parameters and return types - this allows you to change fields without breaking Workflow compatibility. Create src/shared.ts:

touch src/shared.ts

The Customer object will have: firstName, lastName, email, id, and a subscription object with trialPeriod, billingPeriod, maxBillingPeriods, and initialBillingPeriodCharge.

Your shared.ts will also include a Task Queue name to route tasks to the appropriate Worker.

src/shared.ts
export const TASK_QUEUE_NAME = "subscriptions-task-queue";

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

Define external interactions

Define the functions that handle interactions with external systems. These are called Activities. If an Activity fails, Temporal can automatically retry it.

For this tutorial, define Activities for tasks like charging customers and sending emails, stubbed out with basic log statements. Add the following code to src/activities.ts:

src/activities.ts
import { log } from "@temporalio/activity";

import { Customer } from "./shared";

export async function sendWelcomeEmail(customer: Customer) {
log.info(`Sending welcome email to ${customer.email}`);
}

Add a few more email/charging functions:

src/activities.ts
// ...
export async function sendCancellationEmailDuringTrialPeriod(
customer: Customer
) {
log.info(`Sending trial cancellation email to ${customer.email}`);
}
export async function chargeCustomerForBillingPeriod(
customer: Customer,
chargeAmount: number
) {
log.info(
`Charging ${customer.email} amount ${chargeAmount} for their billing period`
);
}
export async function sendSubscriptionFinishedEmail(
customer: Customer
) {
log.info(`Sending subscription completed email to ${customer.email}`);
}
export async function sendSubscriptionOverEmail(customer: Customer) {
log.info(`Sending subscription over email to ${customer.email}`);
}

Define your application logic

Build a Temporal Workflow to use those functions for the business logic. A Workflow Definition is essentially a function that can store state and orchestrates Activities.

Workflow requirements:

  1. User Signup and Free Trial: send a welcome email and start a free trial for the duration defined by trialPeriod.
  2. Cancellation During Trial: if the user cancels during the trial period, send a trial cancellation email and complete the Workflow.
  3. Billing Process: if the trial expires without cancellation, start the billing process, charging up to maxBillingPeriods times. If the customer cancels during a billing period, send a subscription cancellation email.
  4. Dynamic Updates: cancel a subscription, look up the amount charged, or change billing amount.

Start by adding imports and Activities:

src/workflows.ts
import {
proxyActivities,
log,
workflowInfo,
sleep,
} from "@temporalio/workflow";
import type * as activities from "./activities";
import { Customer } from "./shared";

const {
sendWelcomeEmail,
sendSubscriptionFinishedEmail,
chargeCustomerForBillingPeriod,
sendCancellationEmailDuringTrialPeriod,
sendSubscriptionOverEmail,
} = proxyActivities<typeof activities>({
startToCloseTimeout: "5 seconds",
});

Using proxyActivities, you can create a proxy object that allows users to call the Activity from within the Workflow as if it's a local function. startToCloseTimeout indicates the maximum time for an Activity to execute, including retries.

Add the subscriptionWorkflow definition:

src/workflows.ts
export async function subscriptionWorkflow(
customer: Customer
): Promise<string> {
let subscriptionCancelled = false;
let totalCharged = 0;
let billingPeriodNumber = 1;
let billingPeriodChargeAmount =
customer.subscription.initialBillingPeriodCharge;

// Send welcome email to customer
await sendWelcomeEmail(customer);

// Check if the subscription was cancelled during the trial period
if (subscriptionCancelled) {
await sendCancellationEmailDuringTrialPeriod(customer);
return `Subscription finished for: ${customer.id}`;
} else {
// Trial period is over, start billing until we reach the max billing periods or subscription is cancelled
while (true) {
if (billingPeriodNumber > customer.subscription.maxBillingPeriods) break;

if (subscriptionCancelled) {
await sendSubscriptionFinishedEmail(customer);
return `Subscription finished for: ${customer.id}, Total Charged: ${totalCharged}`;
}

log.info(`Charging ${customer.id} amount ${billingPeriodChargeAmount}`);

await chargeCustomerForBillingPeriod(customer, billingPeriodChargeAmount);
totalCharged += billingPeriodChargeAmount;
billingPeriodNumber++;

// Wait for the next billing period or until the subscription is cancelled
await sleep(customer.subscription.billingPeriod);
}

// If the subscription period is over and not cancelled, notify the customer to buy a new subscription
await sendSubscriptionOverEmail(customer);
return `Completed ${
workflowInfo().workflowId
}, Total Charged: ${totalCharged}`;
}
}

The sleep function is a durable Timer provided by Temporal. The Temporal Server persists the sleep details in its database. This ensures that the Workflow can resume accurately after the specified duration, even if the Server or Worker experiences downtime.

Production Consideration: Managing Long-Running Workflows

In production code, Temporal recommends using the continue-as-new feature to manage long-running Workflows and prevent excessively large Event Histories. You can learn more in Temporal's free course: Temporal 102.

Run the subscription Workflow

Ensure you have a local Temporal Service running. Open a separate terminal window and start the service with temporal server start-dev. Your Temporal Server should run on http://localhost:8233.

Define your Worker program in worker.ts:

src/worker.ts
import { TASK_QUEUE_NAME } from "./shared";
import { NativeConnection, Worker } from "@temporalio/worker";
import * as activities from "./activities";

async function run() {
const connection = await NativeConnection.connect({
address: "localhost:7233",
});

// Step 1: Register Workflows and Activities with the Worker and connect to
// the Temporal server.
const worker = await Worker.create({
connection,
workflowsPath: require.resolve("./workflows"),
activities,
taskQueue: TASK_QUEUE_NAME,
});

// Step 2: Start accepting tasks on the `subscriptions-task-queue` queue
await worker.run();
}

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

Run the Worker:

npm run start

To restart the Worker on Workflow changes, use nodemon:

npm run start.watch

Create your Client to start your subscriptionWorkflow in execute-workflow.ts:

src/scripts/execute-workflow.ts
import { Connection, Client } from "@temporalio/client";
import { subscriptionWorkflow } from "../workflows";
import { Customer, TASK_QUEUE_NAME } from "../shared";

Add a customer object:

src/scripts/execute-workflow.ts
const cust = {
firstName: "First Name",
lastName: "Last Name",
email: "email-1@customer.com",
subscription: {
trialPeriod: 3, // 3 seconds
billingPeriod: 3, // 3 seconds
maxBillingPeriods: 3,
initialBillingPeriodCharge: 120,
},
id: "Id-1"
};

Then add the run function:

src/scripts/execute-workflow.ts
async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});

const execution = await client.workflow.start(subscriptionWorkflow, {
args: [cust],
taskQueue: TASK_QUEUE_NAME,
workflowId: "SubscriptionsWorkflow" + cust.id,
});
const result = await execution.result();
return result;
}

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

Run your Client:

npm run workflow

The Worker, polling the same Task Queue, picks up the task and executes it. The Workflow Execution completes successfully with output: Completed SubscriptionsWorkflowId-1, Total Charged: 360. This log of events is known as an Event History.

Replace the cust object with a custArray:

const custArray: Customer[] = [1, 2, 3, 4, 5].map((i) => ({
firstName: "First Name" + i,
lastName: "Last Name" + i,
email: "email-" + i + "@customer.com",
subscription: {
trialPeriod: 3000 + i * 1000, // 3 seconds
billingPeriod: 3000 + i, // 3 seconds
maxBillingPeriods: 3,
initialBillingPeriodCharge: 120 + i * 10,
},
id: "Id-" + i,
}));

Modify your Client code to loop through the array:

async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});

const custArray: Customer[] = [1, 2, 3, 4, 5].map((i) => ({
firstName: "First Name" + i,
lastName: "Last Name" + i,
email: "email-" + i + "@customer.com",
subscription: {
trialPeriod: 3 + i * 1000, // 3 seconds
billingPeriod: 3 + i, // 3 seconds
maxBillingPeriods: 3,
initialBillingPeriodCharge: 120 + i * 10,
},
id: "Id-" + i,
}));
const resultArr = await Promise.all(
custArray.map(async (cust) => {
try {
const execution = await client.workflow.start(subscriptionWorkflow, {
args: [cust],
taskQueue: TASK_QUEUE_NAME,
workflowId: "SubscriptionsWorkflow" + cust.id,
workflowRunTimeout: "10 mins",
});
const result = await execution.result();
return result;
} catch (err) {
console.error("Unable to execute workflow for customer:", cust.id, err);
return `Workflow failed for: ${cust.id}`;
}
})
);
resultArr.forEach((result) => {
console.log("Workflow result", result);
});
}

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

Run your Workflow:

npm run workflow

You'll see five more instances of the Subscription Workflow. The Worker outputs Activity logs: Sending welcome email to email-1@customer.com, etc.

Retrieve subscription details

A Query is a synchronous operation to get the state of a Workflow Execution without impacting its execution.

Open workflows.ts, add defineQuery to your imports from @temporalio/workflow, and add Query definitions above the Workflow Definition:

export const customerIdNameQuery = defineQuery<string>("customerIdName");
export const billingPeriodNumberQuery = defineQuery<number>(
"billingPeriodNumber"
);
export const totalChargedAmountQuery =
defineQuery<number>("totalChargedAmount");

Use the setHandler function to handle Queries:

  setHandler(customerIdNameQuery, () => customer.id);
setHandler(billingPeriodNumberQuery, () => billingPeriodNumber);
setHandler(totalChargedAmountQuery, () => totalCharged);

These Query handlers return subscription details. Queries can also be used after the Workflow completes.

Cancel your subscription and update billing charge amount

A Signal is an asynchronous message sent to a running Workflow Execution, allowing you to change its state and control its flow.

Add defineSignal to your imports, and add the Signal definitions:

export const cancelSubscription = defineSignal("cancelSubscription");
export const updateBillingChargeAmount = defineSignal<[number]>(
"updateBillingChargeAmount"
);

This defines:

  1. cancelSubscription: a Signal with no input parameters.
  2. updateBillingChargeAmount: a Signal that takes a number parameter.

Implement the Signal handlers:

setHandler(cancelSubscription, () => {
subscriptionCancelled = true;
});
setHandler(updateBillingChargeAmount, (newAmount: number) => {
billingPeriodChargeAmount = newAmount;
log.info(
`Updating BillingPeriodChargeAmount to ${billingPeriodChargeAmount}`
);
});

Wait for user input to continue or cancel your subscription

The Temporal SDK provides the condition method to determine the execution path until a specified condition is satisfied or a timeout is reached. Add condition to your imports.

Modify your Workflow Execution code to wait for the subscription to be canceled or for a trial period timeout. Replace the existing trial/billing block with:

src/workflows.ts
// ...
// Used to wait for the subscription to be cancelled or for a trial period timeout to elapse
if (
await condition(
() => subscriptionCancelled,
customer.subscription.trialPeriod
)
) {
await sendCancellationEmailDuringTrialPeriod(customer);
return `Subscription finished for: ${customer.id}`;
} else {
// Trial period is over, start billing until we reach the max billing periods for the subscription or subscription has been cancelled
while (true) {
if (billingPeriodNumber > customer.subscription.maxBillingPeriods) break;

if (subscriptionCancelled) {
await sendSubscriptionFinishedEmail(customer);
return `Subscription finished for: ${customer.id}, Total Charged: ${totalCharged}`;
}

log.info(`Charging ${customer.id} amount ${billingPeriodChargeAmount}`);

await chargeCustomerForBillingPeriod(customer, billingPeriodChargeAmount);
totalCharged += billingPeriodChargeAmount;
billingPeriodNumber++;

// Wait for the next billing period or until the subscription is cancelled
await sleep(customer.subscription.billingPeriod);
}

// If the subscription period is over and not cancelled, notify the customer to buy a new subscription
await sendSubscriptionOverEmail(customer);
return `Completed ${
workflowInfo().workflowId
}, Total Charged: ${totalCharged}`;
}

The condition method pauses the Workflow until either the subscriptionCancelled flag is true or the trial period expires.

Cancel an ongoing subscription

Send your cancelSubscription Signal from the Temporal Client. In cancel-subscription.ts, first define your customer object:

src/scripts/cancel-subscription.ts
const customer: Customer = {
firstName: "Grant",
lastName: "Fleming",
email: "email-1@customer.com",
subscription: {
trialPeriod: 2000,
billingPeriod: 2000,
maxBillingPeriods: 12,
initialBillingPeriodCharge: 100,
},
id: "ABC123",
};

Then bring in some boilerplate Client code:

import { Connection, Client } from "@temporalio/client";
import { subscriptionWorkflow } from "../workflows";
import { TASK_QUEUE_NAME, Customer } from "../shared";

async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});
const subscriptionWorkflowExecution = await client.workflow.start(
subscriptionWorkflow,
{
args: [customer],
taskQueue: TASK_QUEUE_NAME,
workflowId: `subscription-${customer.id}`,
}
);
console.log(await subscriptionWorkflowExecution.result());
}

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

Add the cancelSubscription Signal import:

import { subscriptionWorkflow, cancelSubscription } from "../workflows";

Obtain the handle of the running Workflow Execution using getHandle:

// ...
const subscriptionWorkflowExecution = await client.workflow.start(
subscriptionWorkflow,
{
args: [customer],
taskQueue: TASK_QUEUE_NAME,
workflowId: `subscription-${customer.id}`,
}
);
const handle = await client.workflow.getHandle(`subscription-${customer.id}`);

Use the handle to send the Signal:

const handle = await client.workflow.getHandle(`subscription-${customer.id}`);
await handle.signal(cancelSubscription);

Save your code, ensure your Worker is running, and send:

npm run cancelsubscription

You should see: Completed subscription-ABC123, Total Charged: 1200.

Update the charge amount

Send another Signal to update the charge amount in update-chargeamt.ts:

src/scripts/update-chargeamt.ts
import { Connection, Client } from "@temporalio/client";
import { subscriptionWorkflow } from "../workflows";
import { TASK_QUEUE_NAME, Customer } from "../shared";

async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});
const subscriptionWorkflowExecution = await client.workflow.start(
subscriptionWorkflow,
{
args: [customer],
taskQueue: TASK_QUEUE_NAME,
workflowId: `subscription-${customer.id}`,
}
);
console.log(await subscriptionWorkflowExecution.result());
}

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

const customer: Customer = {
firstName: "Grant",
lastName: "Fleming",
email: "email-1@customer.com",
subscription: {
trialPeriod: 2000,
billingPeriod: 2000,
maxBillingPeriods: 12,
initialBillingPeriodCharge: 100,
},
id: "ABC123",
};

Add the Signal import:

import { subscriptionWorkflow, updateBillingChargeAmount } from "../workflows";

Get the handle and send the Signal:

async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});
const subscriptionWorkflowExecution = await client.workflow.start(
subscriptionWorkflow,
{
args: [customer],
taskQueue: TASK_QUEUE_NAME,
workflowId: `subscription-${customer.id}`,
}
);
const handle = await client.workflow.getHandle(`subscription-${customer.id}`);

// Signal workflow and update charge amount to 300 for next billing period
try {
await handle.signal(updateBillingChargeAmount, 300);
console.log(
`subscription-${customer.id} updating BillingPeriodChargeAmount to 300`
);
} catch (err) {
console.error("Cant signal workflow", err);
}
}

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

Send the Signal:

npm run updatechargeamount

Output: updating BillingPeriodChargeAmount to 300.

Retrieve billing period and total charged details

Send a Query through the Client to retrieve information from a running or completed Workflow. In query-billinginfo.ts, reuse the customer object:

src/scripts/query-billinginfo.ts
const customer: Customer = {
firstName: "Grant",
lastName: "Fleming",
email: "email-1@customer.com",
subscription: {
trialPeriod: 2000,
billingPeriod: 2000,
maxBillingPeriods: 12,
initialBillingPeriodCharge: 100,
},
id: "ABC123",
};

Then bring in the boilerplate Client code:

import { Connection, Client } from "@temporalio/client";
import { subscriptionWorkflow } from "../workflows";
import { TASK_QUEUE_NAME, Customer } from "../shared";

async function run() {
const connection = await Connection.connect({ address: "localhost:7233" });
const client = new Client({
connection,
});
const subscriptionWorkflowExecution = await client.workflow.start(
subscriptionWorkflow,
{
args: [customer],
taskQueue: TASK_QUEUE_NAME,
workflowId: `subscription-${customer.id}`,
}
);
}

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

Iterate through five billing periods and use the WorkflowHandle.query method:

// ...
// Wait for some time before querying to allow the workflow to progress
for (let i = 1; i <= 5; i++) {
// Loop for 5 billing periods
await new Promise((resolve) => setTimeout(resolve, 2500)); // Adjust the wait time to match billing period plus buffer
try {
const billingPeriodNumber =
await subscriptionWorkflowExecution.query<number>(
"billingPeriodNumber"
);
const totalChargedAmount =
await subscriptionWorkflowExecution.query<number>("totalChargedAmount");

console.log("Workflow Id", subscriptionWorkflowExecution.workflowId);
console.log("Billing Period", billingPeriodNumber);
console.log("Total Charged Amount", totalChargedAmount);
} catch (err) {
console.error(
`Error querying workflow with ID ${subscriptionWorkflowExecution.workflowId}:`,
err
);
}
}

Send the Queries:

npm run querybillinginfo

You should see the Workflow Id, the billing period, and the total charged amount in the command-line output.

Conclusion

By using Temporal, you built a fault-tolerant subscription Workflow that manages complex state transitions and interactions with external services. Temporal's durable execution and automatic state persistence ensured that your Workflow could reliably handle user sign-ups, trial periods, billing cycles, and cancellations, even in the face of failures or interruptions.

As a next step, try using Express.js to build an API for your application. The code you used in the command-line scripts can be adapted for your API endpoints, enabling more seamless and user-friendly interactions with your Temporal Workflow Executions.

Get notified when we launch new educational content

New courses, tutorials, and learning resources - straight to your inbox.

Subscribe
Feedback