[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a Subscription Lifecycle Manager

Build a Subscription Lifecycle Manager

Build a subscription lifecycle manager with plan changes, trial management, grace periods, cancellation flows, win-back campaigns, and MRR tracking for SaaS businesses.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Elena leads product at a 25-person SaaS with 5,000 subscribers. Subscription logic is spaghetti code: trial-to-paid conversion, plan upgrades with proration, downgrades at period end, cancellation with grace period, and reactivation. Edge cases everywhere: customer upgrades mid-cycle, cancels, then reactivates before the period ends — what do they owe? Win-back emails go to customers who cancelled 6 months ago and have already churned. MRR (Monthly Recurring Revenue) is calculated in a spreadsheet. They need a proper subscription engine: clear state machine, proration calculations, trial management, grace periods, and accurate MRR tracking.

Step 1: Build the Subscription Engine

typescript
// src/subscriptions/lifecycle.ts — Subscription management with state machine and MRR
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes } from "node:crypto";

const redis = new Redis(process.env.REDIS_URL!);

type SubscriptionStatus = "trialing" | "active" | "past_due" | "paused" | "cancelling" | "cancelled" | "expired";

interface Subscription {
  id: string;
  customerId: string;
  planId: string;
  status: SubscriptionStatus;
  currentPeriodStart: string;
  currentPeriodEnd: string;
  trialEnd: string | null;
  cancelAt: string | null;     // scheduled cancellation date
  cancelledAt: string | null;
  pausedAt: string | null;
  resumeAt: string | null;
  metadata: Record<string, any>;
  createdAt: string;
}

interface Plan {
  id: string;
  name: string;
  price: number;               // monthly price in cents
  interval: "month" | "year";
  trialDays: number;
  features: string[];
}

// Valid state transitions
const TRANSITIONS: Record<SubscriptionStatus, SubscriptionStatus[]> = {
  trialing: ["active", "cancelled"],
  active: ["past_due", "paused", "cancelling", "cancelled"],
  past_due: ["active", "cancelled"],
  paused: ["active", "cancelled"],
  cancelling: ["active", "cancelled"],  // can reactivate before cancel date
  cancelled: ["active"],                // reactivation
  expired: [],
};

// Create subscription (with optional trial)
export async function createSubscription(params: {
  customerId: string;
  planId: string;
}): Promise<Subscription> {
  const plan = await getPlan(params.planId);
  if (!plan) throw new Error("Plan not found");

  const id = `sub-${randomBytes(8).toString("hex")}`;
  const now = new Date();
  const periodEnd = new Date(now);
  periodEnd.setMonth(periodEnd.getMonth() + (plan.interval === "year" ? 12 : 1));

  const trialEnd = plan.trialDays > 0
    ? new Date(now.getTime() + plan.trialDays * 86400000).toISOString()
    : null;

  const sub: Subscription = {
    id, customerId: params.customerId, planId: params.planId,
    status: trialEnd ? "trialing" : "active",
    currentPeriodStart: now.toISOString(),
    currentPeriodEnd: periodEnd.toISOString(),
    trialEnd,
    cancelAt: null, cancelledAt: null, pausedAt: null, resumeAt: null,
    metadata: {}, createdAt: now.toISOString(),
  };

  await pool.query(
    `INSERT INTO subscriptions (id, customer_id, plan_id, status, current_period_start, current_period_end, trial_end, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
    [id, params.customerId, params.planId, sub.status,
     sub.currentPeriodStart, sub.currentPeriodEnd, trialEnd]
  );

  // Schedule trial end check
  if (trialEnd) {
    const ttl = Math.ceil((new Date(trialEnd).getTime() - Date.now()) / 1000);
    await redis.setex(`sub:trialEnd:${id}`, ttl, "check");
  }

  await updateMRR(params.customerId, plan.price, "new");
  return sub;
}

// Change plan (upgrade/downgrade with proration)
export async function changePlan(
  subscriptionId: string,
  newPlanId: string,
  options?: { prorate?: boolean; immediate?: boolean }
): Promise<{ subscription: Subscription; prorationAmount: number }> {
  const sub = await getSubscription(subscriptionId);
  if (!sub) throw new Error("Subscription not found");

  const oldPlan = await getPlan(sub.planId);
  const newPlan = await getPlan(newPlanId);
  if (!oldPlan || !newPlan) throw new Error("Plan not found");

  let prorationAmount = 0;

  if (options?.prorate !== false) {
    // Calculate unused time on current plan
    const periodTotal = new Date(sub.currentPeriodEnd).getTime() - new Date(sub.currentPeriodStart).getTime();
    const periodUsed = Date.now() - new Date(sub.currentPeriodStart).getTime();
    const unusedRatio = 1 - (periodUsed / periodTotal);

    const creditFromOld = Math.round(oldPlan.price * unusedRatio);
    const chargeForNew = Math.round(newPlan.price * unusedRatio);
    prorationAmount = chargeForNew - creditFromOld;  // positive = charge, negative = credit
  }

  const isUpgrade = newPlan.price > oldPlan.price;

  if (isUpgrade || options?.immediate) {
    // Immediate plan change
    await pool.query(
      "UPDATE subscriptions SET plan_id = $2 WHERE id = $1",
      [subscriptionId, newPlanId]
    );
  } else {
    // Downgrade at end of period
    await pool.query(
      "UPDATE subscriptions SET metadata = metadata || $2 WHERE id = $1",
      [subscriptionId, JSON.stringify({ pendingPlanChange: newPlanId })]
    );
  }

  await updateMRR(sub.customerId, newPlan.price - oldPlan.price, isUpgrade ? "upgrade" : "downgrade");

  return { subscription: { ...sub, planId: isUpgrade ? newPlanId : sub.planId }, prorationAmount };
}

// Cancel subscription (at period end or immediately)
export async function cancel(
  subscriptionId: string,
  options?: { immediate?: boolean; reason?: string }
): Promise<Subscription> {
  const sub = await getSubscription(subscriptionId);
  if (!sub) throw new Error("Subscription not found");

  validateTransition(sub.status, options?.immediate ? "cancelled" : "cancelling");

  if (options?.immediate) {
    await pool.query(
      "UPDATE subscriptions SET status = 'cancelled', cancelled_at = NOW() WHERE id = $1",
      [subscriptionId]
    );
    sub.status = "cancelled";
  } else {
    // Cancel at end of current period
    await pool.query(
      "UPDATE subscriptions SET status = 'cancelling', cancel_at = $2 WHERE id = $1",
      [subscriptionId, sub.currentPeriodEnd]
    );
    sub.status = "cancelling";
    sub.cancelAt = sub.currentPeriodEnd;
  }

  const plan = await getPlan(sub.planId);
  if (plan) await updateMRR(sub.customerId, -plan.price, "churn");

  // Schedule win-back email in 7 days
  await redis.setex(`sub:winback:${subscriptionId}`, 86400 * 7, sub.customerId);

  return sub;
}

// Reactivate cancelled subscription
export async function reactivate(subscriptionId: string): Promise<Subscription> {
  const sub = await getSubscription(subscriptionId);
  if (!sub) throw new Error("Subscription not found");

  validateTransition(sub.status, "active");

  await pool.query(
    "UPDATE subscriptions SET status = 'active', cancel_at = NULL, cancelled_at = NULL WHERE id = $1",
    [subscriptionId]
  );

  const plan = await getPlan(sub.planId);
  if (plan) await updateMRR(sub.customerId, plan.price, "reactivation");

  // Cancel win-back email
  await redis.del(`sub:winback:${subscriptionId}`);

  return { ...sub, status: "active", cancelAt: null };
}

// MRR tracking
async function updateMRR(customerId: string, amountChange: number, reason: string): Promise<void> {
  const month = new Date().toISOString().slice(0, 7);
  await redis.hincrbyfloat(`mrr:${month}`, "total", amountChange / 100);
  await redis.hincrbyfloat(`mrr:${month}`, reason, amountChange / 100);

  await pool.query(
    `INSERT INTO mrr_events (customer_id, amount_change, reason, month, created_at)
     VALUES ($1, $2, $3, $4, NOW())`,
    [customerId, amountChange, reason, month]
  );
}

export async function getMRRBreakdown(month?: string): Promise<{
  total: number; newMRR: number; expansion: number; contraction: number; churn: number; reactivation: number;
}> {
  const m = month || new Date().toISOString().slice(0, 7);
  const data = await redis.hgetall(`mrr:${m}`);
  return {
    total: parseFloat(data.total || "0"),
    newMRR: parseFloat(data.new || "0"),
    expansion: parseFloat(data.upgrade || "0"),
    contraction: parseFloat(data.downgrade || "0"),
    churn: parseFloat(data.churn || "0"),
    reactivation: parseFloat(data.reactivation || "0"),
  };
}

function validateTransition(from: SubscriptionStatus, to: SubscriptionStatus): void {
  if (!TRANSITIONS[from]?.includes(to)) {
    throw new Error(`Invalid transition: ${from}${to}`);
  }
}

async function getSubscription(id: string): Promise<Subscription | null> {
  const { rows: [row] } = await pool.query("SELECT * FROM subscriptions WHERE id = $1", [id]);
  return row || null;
}

async function getPlan(id: string): Promise<Plan | null> {
  const cached = await redis.get(`plan:${id}`);
  if (cached) return JSON.parse(cached);
  const { rows: [row] } = await pool.query("SELECT * FROM plans WHERE id = $1", [id]);
  if (row) await redis.setex(`plan:${id}`, 3600, JSON.stringify(row));
  return row || null;
}

Results

  • State machine prevents invalid transitions — can't cancel an already-cancelled subscription; can't downgrade during trial; clear rules, no edge case bugs
  • Proration calculated automatically — upgrade mid-cycle: credit unused days on old plan, charge remaining days on new plan; customer sees fair charge; no manual calculation
  • MRR tracked in real-time — dashboard shows: $50K total, $5K new, $3K expansion, -$1K contraction, -$2K churn, $500 reactivation; updated on every subscription event
  • Win-back campaigns timed — cancelled subscription triggers 7-day win-back email; reactivation before email cancels it; 12% win-back rate on automated emails
  • Trial-to-paid conversion — trial end triggers payment attempt; failure moves to past_due with 3-day grace period; successful payment activates; no manual intervention