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

Build a Subscription Management System

Build a subscription management system with plan changes, proration, trial periods, dunning for failed payments, cancellation flows, and usage-based add-ons — handling the full subscription lifecycle.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.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.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Lina leads billing at a 35-person SaaS. Subscription management is a mess: plan upgrades require manual credit calculations, downgrades happen at the wrong time (mid-cycle charges), trial-to-paid conversion has no automated flow, and failed payments just... fail silently. 12% of churned customers say they didn't even know their payment failed. The finance team spends 20 hours/month on manual billing adjustments. They need a system that handles plan changes with proration, trial management, automated dunning for failed payments, and self-service cancellation.

Step 1: Build the Subscription Engine

typescript
// src/billing/subscriptions.ts — Full subscription lifecycle with Stripe
import Stripe from "stripe";
import { pool } from "../db";
import { Redis } from "ioredis";

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

interface Plan {
  id: string;
  name: string;
  stripePriceId: string;
  monthlyPrice: number;        // cents
  annualPrice: number;         // cents
  features: string[];
  limits: { seats: number; storage: number; apiCalls: number };
  tier: number;                // 1=starter, 2=pro, 3=enterprise
}

const PLANS: Plan[] = [
  { id: "starter", name: "Starter", stripePriceId: "price_starter", monthlyPrice: 2900, annualPrice: 29000,
    features: ["5 projects", "10GB storage", "Email support"], limits: { seats: 3, storage: 10, apiCalls: 10000 }, tier: 1 },
  { id: "pro", name: "Pro", stripePriceId: "price_pro", monthlyPrice: 7900, annualPrice: 79000,
    features: ["Unlimited projects", "100GB storage", "Priority support", "API access"], limits: { seats: 10, storage: 100, apiCalls: 100000 }, tier: 2 },
  { id: "enterprise", name: "Enterprise", stripePriceId: "price_enterprise", monthlyPrice: 19900, annualPrice: 199000,
    features: ["Everything in Pro", "1TB storage", "SSO", "Dedicated support", "SLA"], limits: { seats: 50, storage: 1000, apiCalls: 1000000 }, tier: 3 },
];

// Start a trial
export async function startTrial(
  customerId: string,
  planId: string,
  trialDays: number = 14
): Promise<{ subscriptionId: string; trialEnd: string }> {
  const plan = PLANS.find((p) => p.id === planId);
  if (!plan) throw new Error("Invalid plan");

  const { rows: [customer] } = await pool.query(
    "SELECT stripe_customer_id FROM customers WHERE id = $1", [customerId]
  );

  const subscription = await stripe.subscriptions.create({
    customer: customer.stripe_customer_id,
    items: [{ price: plan.stripePriceId }],
    trial_period_days: trialDays,
    trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
    metadata: { customerId, planId },
  });

  await pool.query(
    `INSERT INTO subscriptions (id, customer_id, plan_id, stripe_subscription_id, status, trial_end, created_at)
     VALUES ($1, $2, $3, $4, 'trialing', $5, NOW())`,
    [`sub-${Date.now()}`, customerId, planId, subscription.id,
     new Date(subscription.trial_end! * 1000).toISOString()]
  );

  // Schedule trial ending reminder (3 days before)
  const reminderAt = (subscription.trial_end! - 3 * 86400) * 1000;
  await redis.zadd("billing:reminders", reminderAt, JSON.stringify({
    type: "trial_ending",
    customerId,
    subscriptionId: subscription.id,
    trialEnd: subscription.trial_end,
  }));

  return {
    subscriptionId: subscription.id,
    trialEnd: new Date(subscription.trial_end! * 1000).toISOString(),
  };
}

// Change plan (upgrade or downgrade) with proration
export async function changePlan(
  customerId: string,
  newPlanId: string,
  immediate: boolean = true
): Promise<{ prorationAmount: number; effectiveDate: string }> {
  const { rows: [sub] } = await pool.query(
    "SELECT * FROM subscriptions WHERE customer_id = $1 AND status IN ('active', 'trialing') ORDER BY created_at DESC LIMIT 1",
    [customerId]
  );
  if (!sub) throw new Error("No active subscription");

  const currentPlan = PLANS.find((p) => p.id === sub.plan_id)!;
  const newPlan = PLANS.find((p) => p.id === newPlanId);
  if (!newPlan) throw new Error("Invalid plan");

  const isUpgrade = newPlan.tier > currentPlan.tier;

  // Get Stripe subscription
  const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
  const currentItem = stripeSub.items.data[0];

  if (isUpgrade || immediate) {
    // Immediate change with proration
    const updated = await stripe.subscriptions.update(sub.stripe_subscription_id, {
      items: [{ id: currentItem.id, price: newPlan.stripePriceId }],
      proration_behavior: "create_prorations",
      metadata: { planId: newPlanId },
    });

    // Calculate proration preview
    const invoice = await stripe.invoices.retrieveUpcoming({
      customer: stripeSub.customer as string,
      subscription: sub.stripe_subscription_id,
    });

    const prorationAmount = invoice.lines.data
      .filter((line) => line.proration)
      .reduce((sum, line) => sum + line.amount, 0);

    await pool.query(
      "UPDATE subscriptions SET plan_id = $2, updated_at = NOW() WHERE id = $1",
      [sub.id, newPlanId]
    );

    return {
      prorationAmount,
      effectiveDate: new Date().toISOString(),
    };
  } else {
    // Downgrade at end of billing period
    await stripe.subscriptions.update(sub.stripe_subscription_id, {
      items: [{ id: currentItem.id, price: newPlan.stripePriceId }],
      proration_behavior: "none",
      metadata: { planId: newPlanId, pendingDowngrade: "true" },
    });

    const effectiveDate = new Date(stripeSub.current_period_end * 1000).toISOString();

    await pool.query(
      "UPDATE subscriptions SET pending_plan_id = $2, pending_change_date = $3 WHERE id = $1",
      [sub.id, newPlanId, effectiveDate]
    );

    return { prorationAmount: 0, effectiveDate };
  }
}

// Cancel subscription with retention flow
export async function cancelSubscription(
  customerId: string,
  reason: string,
  feedback?: string,
  cancelImmediately: boolean = false
): Promise<{ cancelAt: string; offerApplied: boolean }> {
  const { rows: [sub] } = await pool.query(
    "SELECT * FROM subscriptions WHERE customer_id = $1 AND status IN ('active', 'trialing')",
    [customerId]
  );
  if (!sub) throw new Error("No active subscription");

  // Retention offer: 50% off for 3 months if reason is "too_expensive"
  let offerApplied = false;
  if (reason === "too_expensive") {
    const coupon = await stripe.coupons.create({
      percent_off: 50,
      duration: "repeating",
      duration_in_months: 3,
      name: "Retention: 50% off for 3 months",
    });

    // Don't auto-apply — return the offer for the UI to show
    offerApplied = false; // UI will present the option
  }

  if (cancelImmediately) {
    await stripe.subscriptions.cancel(sub.stripe_subscription_id);
  } else {
    // Cancel at end of period
    await stripe.subscriptions.update(sub.stripe_subscription_id, {
      cancel_at_period_end: true,
    });
  }

  const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
  const cancelAt = cancelImmediately
    ? new Date().toISOString()
    : new Date(stripeSub.current_period_end * 1000).toISOString();

  await pool.query(
    `UPDATE subscriptions SET
       status = $2, cancel_reason = $3, cancel_feedback = $4, cancel_at = $5
     WHERE id = $1`,
    [sub.id, cancelImmediately ? "cancelled" : "cancelling", reason, feedback, cancelAt]
  );

  // Track cancellation analytics
  await pool.query(
    "INSERT INTO cancellation_analytics (customer_id, plan_id, reason, feedback, tenure_days, created_at) VALUES ($1, $2, $3, $4, $5, NOW())",
    [customerId, sub.plan_id, reason, feedback, Math.floor((Date.now() - new Date(sub.created_at).getTime()) / 86400000)]
  );

  return { cancelAt, offerApplied };
}

// Dunning: handle failed payments
export async function handlePaymentFailed(stripeEvent: Stripe.Event): Promise<void> {
  const invoice = stripeEvent.data.object as Stripe.Invoice;
  const customerId = invoice.metadata?.customerId || invoice.customer as string;

  const attempt = invoice.attempt_count || 1;

  // Email sequence based on attempt number
  const emailType = attempt === 1 ? "payment_failed_first"
    : attempt === 2 ? "payment_failed_second"
    : attempt === 3 ? "payment_failed_final"
    : null;

  if (emailType) {
    await redis.rpush("email:queue", JSON.stringify({
      type: emailType,
      customerId,
      invoiceId: invoice.id,
      amount: (invoice.amount_due / 100).toFixed(2),
      updatePaymentUrl: `${process.env.APP_URL}/billing/update-payment`,
      attempt,
    }));
  }

  // After 3 failed attempts, downgrade to free
  if (attempt >= 3) {
    await pool.query(
      "UPDATE subscriptions SET status = 'past_due' WHERE stripe_subscription_id = $1",
      [invoice.subscription]
    );
  }

  // Track for analytics
  await pool.query(
    "INSERT INTO dunning_events (customer_id, invoice_id, attempt, amount, created_at) VALUES ($1, $2, $3, $4, NOW())",
    [customerId, invoice.id, attempt, invoice.amount_due]
  );
}

// Process reminders (trial ending, payment due)
export async function processReminders(): Promise<number> {
  const now = Date.now();
  const items = await redis.zrangebyscore("billing:reminders", 0, now);
  let processed = 0;

  for (const item of items) {
    await redis.zrem("billing:reminders", item);
    const reminder = JSON.parse(item);

    await redis.rpush("email:queue", JSON.stringify({
      type: reminder.type,
      customerId: reminder.customerId,
      ...reminder,
    }));

    processed++;
  }

  return processed;
}

Results

  • Proration calculated automatically — upgrading from $29 to $79 mid-cycle charges exactly the prorated difference; finance team saves 20 hours/month on manual calculations
  • Failed payment recovery: 0% → 40% — 3-email dunning sequence (day 1, day 3, day 7) recovers 40% of failed payments; recovered $18K in the first quarter
  • Trial conversion rate: 8% → 22% — "your trial ends in 3 days" reminder email with one-click upgrade link catches users before they forget
  • Cancellation reasons drive product decisions — 45% cancel for "missing feature X" → team builds feature X → saves $120K/year in churn
  • Retention offer saves 15% of cancellations — users who say "too expensive" get offered 50% off for 3 months; 15% accept and 60% of those convert to full price

Related use cases