Skip to main content

Building a Subscription System

Complete guide to implementing recurring billing subscriptions.

Setup

1. Create Subscription Products

import { createClient } from "@easylabs/node";

const easy = await createClient({
apiKey: process.env.EASY_API_KEY!,
});

async function setupSubscriptionPlans() {
// Create product
const product = await easy.createProduct({
name: "Pro Subscription",
description: "Professional tier with advanced features",
active: true,
metadata: {
features: JSON.stringify([
"Unlimited storage",
"Priority support",
"Advanced analytics",
"API access",
]),
},
});

// Create monthly price
const monthlyPrice = await easy.createPrice({
product_id: product.data.id,
unit_amount: 2999, // $29.99
currency: "USD",
recurring: {
interval: "month",
interval_count: 1,
},
active: true,
description: "Monthly billing",
});

// Create annual price (discounted)
const annualPrice = await easy.createPrice({
product_id: product.data.id,
unit_amount: 29900, // $299.00 (save $59.88)
currency: "USD",
recurring: {
interval: "year",
interval_count: 1,
},
active: true,
description: "Annual billing - Save 16%",
});

return { product, monthlyPrice, annualPrice };
}

2. Subscribe Customer

async function subscribeCustomer(
customerId: string,
paymentInstrumentId: string,
priceId: string,
trialDays?: number,
) {
const subscription = await easy.checkout({
customer_creation: false,
identity_id: customerId,
source: paymentInstrumentId,
line_items: [
{
price_id: priceId,
quantity: 1,
},
],
metadata: {
subscription_type: "recurring",
started_at: new Date().toISOString(),
trial_ends_at: trialDays
? new Date(Date.now() + trialDays * 24 * 60 * 60 * 1000).toISOString()
: undefined,
status: trialDays ? "trialing" : "active",
},
});

return subscription.data;
}

3. Recurring Billing

async function chargeSubscription(
customerId: string,
paymentInstrumentId: string,
priceId: string,
orderId: string,
) {
try {
// Get price details
const price = await easy.getPrice(priceId);

// Create transfer for billing period
const transfer = await easy.createTransfer({
amount: price.data.unit_amount,
currency: price.data.currency,
source: paymentInstrumentId,
statement_descriptor: "Subscription Charge",
tags: {
subscription: "true",
order_id: orderId,
price_id: priceId,
customer_id: customerId,
billing_period: new Date().toISOString(),
},
});

// Update order
await easy.updateOrderTags(orderId, {
last_charge: transfer.data.id,
last_charged_at: new Date().toISOString(),
next_billing_date: calculateNextBillingDate(
price.data.recurring!,
).toISOString(),
subscription_status: "active",
});

return transfer.data;
} catch (error) {
// Handle failed payment
await handleFailedPayment(orderId, error);
throw error;
}
}

function calculateNextBillingDate(recurring: any): Date {
const now = new Date();
const { interval, interval_count } = recurring;

switch (interval) {
case "day":
return new Date(now.setDate(now.getDate() + interval_count));
case "week":
return new Date(now.setDate(now.getDate() + interval_count * 7));
case "month":
return new Date(now.setMonth(now.getMonth() + interval_count));
case "year":
return new Date(now.setFullYear(now.getFullYear() + interval_count));
default:
return now;
}
}

4. Subscription Management

Cancel Subscription

async function cancelSubscription(
orderId: string,
reason: string,
immediate: boolean = false,
) {
const cancelDate = immediate ? new Date().toISOString() : undefined; // Cancel at end of billing period

const canceled = await easy.updateOrderTags(orderId, {
subscription_status: immediate ? "canceled" : "pending_cancellation",
canceled_at: immediate ? new Date().toISOString() : undefined,
cancellation_scheduled_for: !immediate ? cancelDate : undefined,
cancel_reason: reason,
});

return canceled.data;
}

Pause Subscription

async function pauseSubscription(orderId: string) {
const paused = await easy.updateOrderTags(orderId, {
subscription_status: "paused",
paused_at: new Date().toISOString(),
});

return paused.data;
}

Resume Subscription

async function resumeSubscription(orderId: string) {
const resumed = await easy.updateOrderTags(orderId, {
subscription_status: "active",
resumed_at: new Date().toISOString(),
paused_at: undefined,
});

return resumed.data;
}

Change Plan

async function changeSubscriptionPlan(
customerId: string,
currentOrderId: string,
newPriceId: string,
paymentInstrumentId: string,
immediate: boolean = true,
) {
if (immediate) {
// Cancel current subscription
await cancelSubscription(currentOrderId, "plan_change", true);

// Create new subscription
const newSubscription = await subscribeCustomer(
customerId,
paymentInstrumentId,
newPriceId,
);

return newSubscription;
} else {
// Schedule plan change for next billing date
await easy.updateOrderTags(currentOrderId, {
pending_plan_change: "true",
new_price_id: newPriceId,
plan_change_scheduled: new Date().toISOString(),
});

return { scheduled: true };
}
}

5. Express API

import express from "express";
import { createClient } from "@easylabs/node";

const app = express();
app.use(express.json());

const easy = await createClient({
apiKey: process.env.EASY_API_KEY!,
});

// Subscribe
app.post("/api/subscriptions", async (req, res) => {
try {
const { customerId, paymentInstrumentId, priceId, trialDays } = req.body;

const subscription = await subscribeCustomer(
customerId,
paymentInstrumentId,
priceId,
trialDays,
);

res.json({ success: true, subscription });
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});

// Cancel
app.delete("/api/subscriptions/:orderId", async (req, res) => {
try {
const { reason, immediate } = req.body;

const canceled = await cancelSubscription(
req.params.orderId,
reason,
immediate,
);

res.json({ success: true, subscription: canceled });
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});

// Change plan
app.post("/api/subscriptions/:orderId/change-plan", async (req, res) => {
try {
const { customerId, newPriceId, paymentInstrumentId, immediate } = req.body;

const result = await changeSubscriptionPlan(
customerId,
req.params.orderId,
newPriceId,
paymentInstrumentId,
immediate,
);

res.json({ success: true, subscription: result });
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});

// Get subscription status
app.get("/api/subscriptions/:orderId", async (req, res) => {
try {
const order = await easy.getOrder(req.params.orderId);
res.json(order);
} catch (error: any) {
res.status(404).json({ error: error.message });
}
});

app.listen(3000);

6. Billing Automation

Scheduled Billing Job

import cron from "node-cron";

// Run daily at midnight
cron.schedule("0 0 * * *", async () => {
console.log("Running subscription billing job...");

// Get all active subscriptions due for billing
const orders = await easy.getOrders({ limit: 1000 });

const dueSubscriptions = orders.data.filter((order) => {
const nextBilling = order.tags?.next_billing_date;
if (!nextBilling) return false;

return new Date(nextBilling) <= new Date();
});

// Process each subscription
for (const order of dueSubscriptions) {
try {
await chargeSubscription(
order.customer_id,
order.tags.payment_instrument_id,
order.tags.price_id,
order.id,
);
console.log(`Billed subscription: ${order.id}`);
} catch (error) {
console.error(`Failed to bill ${order.id}:`, error);
}
}
});

Handle Failed Payments

async function handleFailedPayment(orderId: string, error: any) {
const order = await easy.getOrder(orderId);
const retryCount = Number(order.data.tags?.retry_count || 0);

if (retryCount < 3) {
// Update retry count
await easy.updateOrderTags(orderId, {
subscription_status: "past_due",
retry_count: (retryCount + 1).toString(),
last_failure: new Date().toISOString(),
last_failure_reason: error.message,
});

// Schedule retry (not shown)
// await scheduleRetry(orderId, retryCount + 1);
} else {
// Cancel subscription after 3 failed attempts
await cancelSubscription(orderId, "payment_failed", true);
}
}

Best Practices

  1. Always handle trial periods
  2. Implement retry logic for failed payments
  3. Send email notifications for billing events
  4. Allow customers to update payment methods
  5. Provide clear cancellation policies
  6. Track subscription metrics and churn