Subscription System
Subscription System pattern for Node.js.
A complete subscription system on @easylabs/node: pricing catalog, signup with trial, in-flight upgrade with proration preview, metered usage reporting, dunning, and cancellation at period end.
Goal
Everything you need to ship a B2B SaaS with monthly + yearly tiers, optional metered add-ons, and graceful failed-payment handling — all driven by the SDK plus webhooks.
Implementation
1. Catalog
const product = await easy.createProduct({ name: "Pro plan", active: true });
const monthly = await easy.createPrice({
product_id: product.data.id,
active: true,
recurring: true,
currency: "USD",
unit_amount: 4900,
interval: "month",
interval_count: 1,
tax_behavior: "exclusive",
trial_period_days: 14,
});
const yearly = await easy.createPrice({
product_id: product.data.id,
active: true,
recurring: true,
currency: "USD",
unit_amount: 49000, // $490 — 2 months free
interval: "year",
interval_count: 1,
tax_behavior: "exclusive",
});
const meteredApiCalls = await easy.createPrice({
product_id: product.data.id,
active: true,
recurring: true,
currency: "USD",
unit_amount: 1, // $0.01 per unit
interval: "month",
interval_count: 1,
tax_behavior: "exclusive",
pricing_model: "metered",
});2. Signup with a 14-day trial
const sub = await easy.createSubscription({
identity_id: customer.id,
items: [
{ price_id: monthly.data.id, quantity: 1 },
{ price_id: meteredApiCalls.data.id }, // qty driven by usage reports
],
instrument_id: paymentInstrumentId,
trial_period_days: 14,
metadata: { plan: "pro_monthly" },
});3. Upgrade with proration preview
const preview = await easy.getSubscriptionProrationPreview(sub.data.id, {
items: [{ price_id: yearly.data.id, quantity: 1 }],
remove_items: [
sub.data.items.find((i) => i.price_id === monthly.data.id)!.id,
],
});
// Show preview.data to the user, then commit:
await easy.updateSubscription(sub.data.id, {
items: [{ price_id: yearly.data.id, quantity: 1 }],
remove_items: [
sub.data.items.find((i) => i.price_id === monthly.data.id)!.id,
],
proration_behavior: "create_prorations",
});4. Metered usage
import { randomUUID } from "node:crypto";
async function reportApiCall(subscriptionId: string, itemId: string) {
await easy.reportSubscriptionUsage(subscriptionId, {
subscription_item_id: itemId,
quantity: 1,
action: "increment",
timestamp: new Date().toISOString(),
idempotency_key: randomUUID(),
});
}
// Period summary for an in-app dashboard:
const summary = await easy.getSubscriptionUsageSummary(sub.data.id, {
subscription_item_id: meteredItemId,
from: periodStart,
to: periodEnd,
});5. Dunning
await easy.createOrReplaceDunningConfig({
retry_mode: "smart",
smart_retry_attempts: 8,
smart_retry_window: "2_weeks",
subscription_terminal_action: "cancel",
invoice_terminal_action: "uncollectible",
payment_failed_email_enabled: true,
expiring_card_email_enabled: true,
card_expiry_warn_days: 30,
});Wire your webhook handler to react to invoice.payment_failed, subscription.paused, and subscription.deleted — see Webhooks.
6. Cancellation at period end
await easy.cancelSubscription(sub.data.id, { at_period_end: true });7. Discount on retention
const winback = await easy.createCoupon({
duration: "repeating",
duration_in_months: 3,
percent_off: 50,
name: "Winback offer",
});
await easy.applySubscriptionDiscount(sub.data.id, { coupon_id: winback.data.id });Tradeoffs
- Trial accounting. Pass
trial_period_days(ortrial_end) at create time and setinstrument_idso the card is on file for the first paid charge. Listen forsubscription.trial_will_endto nudge users 3 days out. - Proration policy.
"create_prorations"issues credits/charges immediately;"none"defers everything to the next invoice. Preview first if your users are price-sensitive. - Metered idempotency. Reuse the upstream event ID (e.g. webhook delivery ID) as
idempotency_keyso retries don't double-bill. - Cancellation timing.
at_period_end: truekeeps service active until renewal; omit it for instant termination + final invoice. - Multiple plans on one subscription. A single
SubscriptionDatamay carry manyitems(e.g. base seat + metered calls + storage). UseaddSubscriptionItem/removeSubscriptionItemfor granular changes.