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

Build a Proration Billing System

Build a proration system for mid-cycle plan changes with credit calculations, upgrade/downgrade handling, seat adjustments, invoice line items, and transparent billing previews.

#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

Filip leads billing at a 25-person SaaS. When customers upgrade mid-cycle, they're charged the full new price — even if there are only 3 days left in the billing period. Customers complain it's unfair. When they downgrade, they lose the remaining value of what they already paid. Adding seats mid-cycle either double-charges or doesn't charge at all depending on which support agent handles it. They need automatic proration: fair charges on upgrades, credits on downgrades, and per-seat mid-cycle adjustments — all with clear invoice line items so customers understand what they're paying for.

Step 1: Build the Proration Engine

typescript
// src/billing/proration.ts — Mid-cycle plan changes with fair credit/charge calculation
import { pool } from "../db";
import { Redis } from "ioredis";
import Stripe from "stripe";

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

interface Subscription {
  id: string;
  customerId: string;
  planId: string;
  pricePerSeat: number;       // cents/month
  seats: number;
  billingCycle: "monthly" | "annual";
  currentPeriodStart: string;
  currentPeriodEnd: string;
  status: string;
}

interface ProrationPreview {
  creditAmount: number;        // credit from unused old plan
  chargeAmount: number;        // charge for new plan remainder
  netAmount: number;           // positive = charge, negative = credit
  lineItems: ProrationLineItem[];
  effectiveDate: string;
  daysRemaining: number;
  daysInPeriod: number;
}

interface ProrationLineItem {
  description: string;
  quantity: number;
  unitPrice: number;           // cents
  amount: number;              // cents (can be negative for credits)
  type: "credit" | "charge";
}

// Preview proration before applying
export async function previewPlanChange(
  subscriptionId: string,
  newPlanId: string,
  newSeats?: number
): Promise<ProrationPreview> {
  const { rows: [sub] } = await pool.query("SELECT * FROM subscriptions WHERE id = $1", [subscriptionId]);
  if (!sub) throw new Error("Subscription not found");

  const { rows: [newPlan] } = await pool.query("SELECT * FROM plans WHERE id = $1", [newPlanId]);
  if (!newPlan) throw new Error("Plan not found");

  const seats = newSeats || sub.seats;
  const now = new Date();
  const periodStart = new Date(sub.current_period_start);
  const periodEnd = new Date(sub.current_period_end);

  const daysInPeriod = Math.ceil((periodEnd.getTime() - periodStart.getTime()) / 86400000);
  const daysUsed = Math.ceil((now.getTime() - periodStart.getTime()) / 86400000);
  const daysRemaining = daysInPeriod - daysUsed;

  const dailyRateOld = (sub.price_per_seat * sub.seats) / daysInPeriod;
  const dailyRateNew = (newPlan.base_price * seats) / daysInPeriod;

  const lineItems: ProrationLineItem[] = [];

  // Credit for unused portion of current plan
  const creditAmount = Math.round(dailyRateOld * daysRemaining);
  if (creditAmount > 0) {
    lineItems.push({
      description: `Unused time on ${sub.plan_id} (${daysRemaining} days × ${sub.seats} seats)`,
      quantity: daysRemaining,
      unitPrice: Math.round(dailyRateOld),
      amount: -creditAmount,
      type: "credit",
    });
  }

  // Charge for remaining portion of new plan
  const chargeAmount = Math.round(dailyRateNew * daysRemaining);
  if (chargeAmount > 0) {
    lineItems.push({
      description: `Remaining time on ${newPlanId} (${daysRemaining} days × ${seats} seats)`,
      quantity: daysRemaining,
      unitPrice: Math.round(dailyRateNew),
      amount: chargeAmount,
      type: "charge",
    });
  }

  // Seat change within same plan
  if (newPlanId === sub.plan_id && newSeats && newSeats !== sub.seats) {
    const seatDiff = newSeats - sub.seats;
    const seatAdjustment = Math.round((sub.price_per_seat / daysInPeriod) * daysRemaining * Math.abs(seatDiff));

    lineItems.length = 0; // Clear plan-level items
    if (seatDiff > 0) {
      lineItems.push({
        description: `Add ${seatDiff} seat(s) for ${daysRemaining} remaining days`,
        quantity: seatDiff,
        unitPrice: Math.round((sub.price_per_seat / daysInPeriod) * daysRemaining),
        amount: seatAdjustment,
        type: "charge",
      });
    } else {
      lineItems.push({
        description: `Remove ${Math.abs(seatDiff)} seat(s), credit for ${daysRemaining} remaining days`,
        quantity: Math.abs(seatDiff),
        unitPrice: Math.round((sub.price_per_seat / daysInPeriod) * daysRemaining),
        amount: -seatAdjustment,
        type: "credit",
      });
    }

    return {
      creditAmount: seatDiff < 0 ? seatAdjustment : 0,
      chargeAmount: seatDiff > 0 ? seatAdjustment : 0,
      netAmount: seatDiff > 0 ? seatAdjustment : -seatAdjustment,
      lineItems, effectiveDate: now.toISOString(), daysRemaining, daysInPeriod,
    };
  }

  const netAmount = chargeAmount - creditAmount;

  return { creditAmount, chargeAmount, netAmount, lineItems, effectiveDate: now.toISOString(), daysRemaining, daysInPeriod };
}

// Apply plan change with proration
export async function applyPlanChange(
  subscriptionId: string,
  newPlanId: string,
  newSeats?: number
): Promise<{ success: boolean; invoiceId?: string; preview: ProrationPreview }> {
  const preview = await previewPlanChange(subscriptionId, newPlanId, newSeats);
  const { rows: [sub] } = await pool.query("SELECT * FROM subscriptions WHERE id = $1", [subscriptionId]);

  const seats = newSeats || sub.seats;

  // Create invoice for proration
  if (preview.netAmount !== 0) {
    const invoiceId = `inv-${Date.now().toString(36)}`;

    await pool.query(
      `INSERT INTO invoices (id, customer_id, subscription_id, amount, status, line_items, type, created_at)
       VALUES ($1, $2, $3, $4, $5, $6, 'proration', NOW())`,
      [invoiceId, sub.customer_id, subscriptionId, Math.abs(preview.netAmount),
       preview.netAmount > 0 ? "pending" : "credit",
       JSON.stringify(preview.lineItems)]
    );

    if (preview.netAmount > 0) {
      // Charge the customer
      try {
        await stripe.paymentIntents.create({
          amount: preview.netAmount,
          currency: "usd",
          customer: sub.stripe_customer_id,
          description: `Proration: ${sub.plan_id}${newPlanId}`,
          metadata: { invoiceId, subscriptionId },
          confirm: true,
          automatic_payment_methods: { enabled: true, allow_redirects: "never" },
        });
        await pool.query("UPDATE invoices SET status = 'paid' WHERE id = $1", [invoiceId]);
      } catch (err: any) {
        await pool.query("UPDATE invoices SET status = 'failed' WHERE id = $1", [invoiceId]);
        return { success: false, preview };
      }
    } else {
      // Apply credit to customer balance
      await pool.query(
        "UPDATE customers SET credit_balance = credit_balance + $2 WHERE id = $1",
        [sub.customer_id, Math.abs(preview.netAmount)]
      );
    }
  }

  // Update subscription
  const { rows: [newPlan] } = await pool.query("SELECT * FROM plans WHERE id = $1", [newPlanId]);
  await pool.query(
    `UPDATE subscriptions SET plan_id = $2, price_per_seat = $3, seats = $4, updated_at = NOW() WHERE id = $1`,
    [subscriptionId, newPlanId, newPlan.base_price, seats]
  );

  // Log the change
  await pool.query(
    `INSERT INTO subscription_changes (subscription_id, old_plan, new_plan, old_seats, new_seats, proration_amount, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
    [subscriptionId, sub.plan_id, newPlanId, sub.seats, seats, preview.netAmount]
  );

  return { success: true, preview };
}

Results

  • Fair billing on upgrades — customer upgrading from $49 to $99 with 10 days left pays $16.33 prorated charge, not $99; zero complaints about "double charging"
  • Credits on downgrades — unused time on the expensive plan applied as credit; customer sees the math on their invoice; support tickets about billing drop 70%
  • Mid-cycle seat changes — adding 5 seats on day 20 of 30 charges for 10 days × 5 seats; removing seats gives credit; HR doesn't have to wait until next billing cycle
  • Preview before commit — customer sees exact charges/credits before confirming; no surprises; "what will I pay?" answered in milliseconds
  • Transparent invoices — each line item shows: what changed, how many days, unit price, total; customers trust the billing because they can verify the math