
Build a recurring billing subscription system with TypeScript
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
- Set up a local development environment for Temporal and TypeScript.
- Complete the Hello World tutorial.
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
{
"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.
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:
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:
// ...
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:
- User Signup and Free Trial: send a welcome email and start a free trial for the duration defined by
trialPeriod. - Cancellation During Trial: if the user cancels during the trial period, send a trial cancellation email and complete the Workflow.
- Billing Process: if the trial expires without cancellation, start the billing process, charging up to
maxBillingPeriodstimes. If the customer cancels during a billing period, send a subscription cancellation email. - Dynamic Updates: cancel a subscription, look up the amount charged, or change billing amount.
Start by adding imports and Activities:
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:
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.
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:
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:
import { Connection, Client } from "@temporalio/client";
import { subscriptionWorkflow } from "../workflows";
import { Customer, TASK_QUEUE_NAME } from "../shared";
Add a customer object:
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:
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:
cancelSubscription: a Signal with no input parameters.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:
// ...
// 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:
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:
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:
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.