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
- Always handle trial periods
- Implement retry logic for failed payments
- Send email notifications for billing events
- Allow customers to update payment methods
- Provide clear cancellation policies
- Track subscription metrics and churn