Skip to main content

Subscriptions

The subscription tracking feature helps users monitor recurring payments, avoid surprise charges, and manage their subscription costs effectively.

Subscription Model

Database Schema

lib/db/models.ts
export interface SubscriptionDocument extends Document {
  userId: string;
  name: string;
  amount: number;
  frequency: "monthly" | "yearly";
  nextBillingDate: Date;
  category?: string;
  active: boolean;
  note?: string;
  createdAt: Date;
  updatedAt: Date;
}

const subscriptionSchema = new Schema<SubscriptionDocument>({
  userId: { type: String, required: true, index: true },
  name: { type: String, required: true, trim: true },
  amount: { type: Number, required: true, min: 0 },
  frequency: {
    type: String,
    required: true,
    enum: ["monthly", "yearly"],
    default: "monthly",
  },
  nextBillingDate: { type: Date, required: true },
  category: { type: String, trim: true },
  active: { type: Boolean, default: true },
  note: { type: String, trim: true },
}, { timestamps: true });

// Compound indexes for efficient queries
subscriptionSchema.index({ userId: 1, active: 1 });
subscriptionSchema.index({ userId: 1, nextBillingDate: 1 });

export const SubscriptionModel: Model<SubscriptionDocument> =
  mongoose.models.Subscription ||
  mongoose.model<SubscriptionDocument>("Subscription", subscriptionSchema);

Key Features

Active/Paused Status

Toggle subscriptions between active and paused without deleting data. Perfect for seasonal services.

Billing Reminders

Automatic detection of subscriptions renewing in the next 7 days with visual alerts.

Cost Calculations

Automatic monthly and yearly cost totals, normalizing yearly subscriptions to monthly equivalents.

Next Billing Date

Track when each subscription will charge next to avoid surprise payments.

API Endpoints

Get All Subscriptions

app/api/subscriptions/route.ts
import { NextRequest } from "next/server";
import { connectDB } from "@/lib/db/connection";
import { SubscriptionModel } from "@/lib/db/models";
import {
  authenticateRequest,
  getPaginationParams,
  paginatedResponse,
} from "@/lib/api/utils";

export async function GET(request: NextRequest) {
  try {
    const { auth, error: authError } = await authenticateRequest(request);
    if (authError) return authError;

    await connectDB();

    const pagination = getPaginationParams(request);
    const url = new URL(request.url);

    // Build query filters
    const query: Record<string, unknown> = { userId: auth.userId };

    // Optional active filter
    const active = url.searchParams.get("active");
    if (active !== null) {
      query.active = active === "true";
    }

    // Optional category filter
    const category = url.searchParams.get("category");
    if (category) {
      query.category = category;
    }

    // Get total count and items
    const [total, subscriptions] = await Promise.all([
      SubscriptionModel.countDocuments(query),
      SubscriptionModel.find(query)
        .sort({ nextBillingDate: 1 })
        .skip(pagination.skip)
        .limit(pagination.limit)
        .lean(),
    ]);

    // Transform to client format
    const items = subscriptions.map((sub) => ({
      id: sub._id.toString(),
      name: sub.name,
      amount: sub.amount,
      frequency: sub.frequency,
      nextBillingDate: sub.nextBillingDate.toISOString(),
      category: sub.category,
      active: sub.active,
      note: sub.note,
      createdAt: sub.createdAt.toISOString(),
      updatedAt: sub.updatedAt.toISOString(),
    }));

    return paginatedResponse(items, total, pagination);
  } catch (error) {
    console.error("Subscription list error:", error);
    return errorResponse(
      "INTERNAL_ERROR",
      "Failed to fetch subscriptions",
      500
    );
  }
}
Query Parameters:
  • page - Page number (default: 1)
  • limit - Items per page (default: 50)
  • active - Filter by active status (“true” or “false”)
  • category - Filter by category

Create Subscription

app/api/subscriptions/route.ts
import { createSubscriptionSchema } from "@/lib/validations";
import { parseBody, successResponse } from "@/lib/api/utils";

export async function POST(request: NextRequest) {
  try {
    const { auth, error: authError } = await authenticateRequest(request);
    if (authError) return authError;

    const { data, error: parseError } = await parseBody(
      request,
      createSubscriptionSchema
    );
    if (parseError) return parseError;

    await connectDB();

    const subscription = await SubscriptionModel.create({
      userId: auth.userId,
      name: data.name,
      amount: data.amount,
      frequency: data.frequency,
      nextBillingDate: new Date(data.nextBillingDate),
      category: data.category,
      active: data.active ?? true,
      note: data.note,
    });

    return successResponse(
      {
        id: subscription._id.toString(),
        name: subscription.name,
        amount: subscription.amount,
        frequency: subscription.frequency,
        nextBillingDate: subscription.nextBillingDate.toISOString(),
        category: subscription.category,
        active: subscription.active,
        note: subscription.note,
        createdAt: subscription.createdAt.toISOString(),
        updatedAt: subscription.updatedAt.toISOString(),
      },
      201
    );
  } catch (error) {
    console.error("Subscription create error:", error);
    return errorResponse(
      "INTERNAL_ERROR",
      "Failed to create subscription",
      500
    );
  }
}
Request Body:
{
  "name": "Netflix Premium",
  "amount": 15.99,
  "frequency": "monthly",
  "nextBillingDate": "2024-04-01",
  "category": "Entertainment",
  "active": true,
  "note": "Family plan"
}

Update Subscription

Includes special handling for toggling active status:
app/api/subscriptions/[id]/route.ts
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const { auth, error: authError } = await authenticateRequest(request);
    if (authError) return authError;

    const { data, error: parseError } = await parseBody(
      request,
      updateSubscriptionSchema
    );
    if (parseError) return parseError;

    await connectDB();

    const subscription = await SubscriptionModel.findOneAndUpdate(
      { _id: params.id, userId: auth.userId },
      {
        ...data,
        ...(data.nextBillingDate && {
          nextBillingDate: new Date(data.nextBillingDate),
        }),
      },
      { new: true }
    );

    if (!subscription) {
      return errorResponse("NOT_FOUND", "Subscription not found", 404);
    }

    return successResponse({
      id: subscription._id.toString(),
      name: subscription.name,
      amount: subscription.amount,
      frequency: subscription.frequency,
      nextBillingDate: subscription.nextBillingDate.toISOString(),
      category: subscription.category,
      active: subscription.active,
      note: subscription.note,
      createdAt: subscription.createdAt.toISOString(),
      updatedAt: subscription.updatedAt.toISOString(),
    });
  } catch (error) {
    console.error("Subscription update error:", error);
    return errorResponse(
      "INTERNAL_ERROR",
      "Failed to update subscription",
      500
    );
  }
}

Validation

lib/validations.ts
import { z } from "zod";

export const createSubscriptionSchema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  amount: z.number().positive("Amount must be positive"),
  frequency: z.enum(["monthly", "yearly"]),
  nextBillingDate: z.string().refine((val) => !isNaN(Date.parse(val)), {
    message: "Invalid date format",
  }),
  category: z.string().max(50).optional(),
  active: z.boolean().optional().default(true),
  note: z.string().max(500).optional(),
});

export const updateSubscriptionSchema = createSubscriptionSchema.partial();

export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
export type UpdateSubscriptionInput = z.infer<typeof updateSubscriptionSchema>;

Subscription Page UI

app/subscriptions/page.tsx
export default function SubscriptionsPage() {
  const { data, isLoading } = useDashboardData();
  const subscriptions = data?.subscriptions ?? [];

  // Calculate totals
  const activeSubscriptions = subscriptions.filter(s => s.active);
  
  // Normalize yearly to monthly for comparison
  const monthlyTotal = activeSubscriptions.reduce((sum, s) => {
    return sum + (s.frequency === "yearly" ? s.amount / 12 : s.amount);
  }, 0);
  const yearlyTotal = monthlyTotal * 12;

  // Find upcoming renewals (next 7 days)
  const today = new Date();
  const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000);
  const upcomingRenewals = activeSubscriptions.filter(s => {
    const billingDate = new Date(s.nextBillingDate);
    return billingDate >= today && billingDate <= nextWeek;
  });

  return (
    <DashboardWrapper className="space-y-8">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Subscriptions</h1>
          <p className="text-muted-foreground text-lg">
            Manage your recurring payments
          </p>
        </div>
        <Button asChild className="rounded-2xl">
          <Link href="/subscriptions/new">
            <Plus className="mr-2 h-4 w-4" />
            Add Subscription
          </Link>
        </Button>
      </div>

      {/* Stats Cards */}
      <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
        <StatCard
          label="Monthly Cost"
          value={monthlyTotal}
          suffix="/mo"
          icon={CreditCard}
          color="text-orange-500"
        />
        <StatCard
          label="Yearly Cost"
          value={yearlyTotal}
          suffix="/yr"
          icon={Calendar}
          color="text-purple-500"
        />
        <StatCard
          label="Active"
          value={activeSubscriptions.length}
          suffix={` subscription${activeSubscriptions.length !== 1 ? 's' : ''}`}
          icon={CheckCircle2}
          color="text-blue-500"
        />
      </div>

      {/* Upcoming Renewals Alert */}
      {upcomingRenewals.length > 0 && (
        <UpcomingRenewalsAlert renewals={upcomingRenewals} />
      )}

      {/* Subscription List */}
      <SubscriptionList subscriptions={subscriptions} />
    </DashboardWrapper>
  );
}

Cost Calculation Logic

Yearly subscriptions are automatically converted to monthly equivalents (divided by 12) for accurate monthly cost calculations.
// Monthly total calculation
const monthlyTotal = activeSubscriptions.reduce((sum, subscription) => {
  // If yearly, divide by 12 to get monthly equivalent
  const monthlyAmount = subscription.frequency === "yearly" 
    ? subscription.amount / 12 
    : subscription.amount;
  return sum + monthlyAmount;
}, 0);

// Yearly total is monthly * 12
const yearlyTotal = monthlyTotal * 12;

Upcoming Renewals Alert

function UpcomingRenewalsAlert({ renewals }: { renewals: Subscription[] }) {
  return (
    <div className="flex items-center gap-4 p-5 rounded-3xl border border-amber-200 bg-amber-50 dark:bg-amber-950/20">
      <div className="h-12 w-12 rounded-2xl bg-amber-500/10 flex items-center justify-center">
        <AlertCircle className="h-6 w-6 text-amber-500" />
      </div>
      <div>
        <p className="font-semibold text-amber-800 dark:text-amber-200">
          Upcoming Renewals
        </p>
        <p className="text-sm text-amber-700 dark:text-amber-300">
          {renewals.length} subscription{renewals.length !== 1 ? 's' : ''} renewing in the next 7 days
        </p>
      </div>
    </div>
  );
}

Subscription Card Component

function SubscriptionCard({ subscription }: { subscription: Subscription }) {
  const { deleteSubscription, toggleSubscriptionActive } = useFinanceStore();
  const { addToast } = useToast();
  const queryClient = useQueryClient();

  const nextBilling = new Date(subscription.nextBillingDate);
  const formattedDate = nextBilling.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  });

  const handleToggle = async () => {
    try {
      await toggleSubscriptionActive(subscription.id);
      await queryClient.invalidateQueries({ queryKey: queryKeys.dashboard });
      addToast({
        type: "success",
        title: subscription.active ? "Paused" : "Resumed",
        message: subscription.active
          ? `"${subscription.name}" has been paused`
          : `"${subscription.name}" is now active`,
      });
    } catch (error) {
      addToast({
        type: "error",
        title: "Failed to update",
        message: "Please try again.",
      });
    }
  };

  return (
    <div className="flex items-center justify-between p-5 rounded-3xl border bg-card">
      <div className="flex items-center gap-4">
        <div className="h-12 w-12 rounded-2xl bg-orange-500/10 flex items-center justify-center">
          <CreditCard className="h-6 w-6 text-orange-500" />
        </div>
        <div>
          <div className="flex items-center gap-2">
            <h3 className="font-semibold">{subscription.name}</h3>
            {!subscription.active && (
              <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
                Paused
              </span>
            )}
          </div>
          <p className="text-sm text-muted-foreground">
            {subscription.frequency === "monthly" ? "Monthly" : "Yearly"} • Next: {formattedDate}
          </p>
        </div>
      </div>
      <div className="flex items-center gap-3">
        <div className="text-right">
          <p className="text-xl font-bold text-orange-600">
            {formatCurrency(subscription.amount)}
            <span className="text-sm font-normal text-muted-foreground">
              /{subscription.frequency === "monthly" ? "mo" : "yr"}
            </span>
          </p>
        </div>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <button className="p-2.5 hover:bg-muted rounded-2xl">
              <MoreVertical className="h-4 w-4" />
            </button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={handleToggle}>
              {subscription.active ? (
                <><ToggleRight className="h-4 w-4 mr-2" />Pause</>
              ) : (
                <><ToggleLeft className="h-4 w-4 mr-2" />Resume</>
              )}
            </DropdownMenuItem>
            <DropdownMenuItem asChild>
              <Link href={`/subscriptions/${subscription.id}/edit`}>
                <Edit className="h-4 w-4 mr-2" />
                Edit
              </Link>
            </DropdownMenuItem>
            <DropdownMenuItem
              onClick={() => handleDelete(subscription.id)}
              className="text-destructive"
            >
              <Trash2 className="h-4 w-4 mr-2" />
              Delete
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
    </div>
  );
}

State Management

Subscription actions in Zustand store:
stores/finance-store.ts
interface FinanceStore {
  deleteSubscription: (id: string) => Promise<void>;
  toggleSubscriptionActive: (id: string) => Promise<void>;
}

export const useFinanceStore = create<FinanceStore>((set) => ({
  deleteSubscription: async (id: string) => {
    const response = await fetch(`/api/subscriptions/${id}`, {
      method: "DELETE",
    });
    if (!response.ok) throw new Error("Failed to delete subscription");
  },
  
  toggleSubscriptionActive: async (id: string) => {
    // Fetch current subscription
    const getResponse = await fetch(`/api/subscriptions/${id}`);
    if (!getResponse.ok) throw new Error("Failed to fetch subscription");
    const subscription = await getResponse.json();
    
    // Toggle active status
    const response = await fetch(`/api/subscriptions/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ active: !subscription.active }),
    });
    if (!response.ok) throw new Error("Failed to toggle subscription");
  },
}));

Common Use Cases

Track Netflix, Spotify, Disney+, HBO Max, etc.
  • Set monthly or yearly billing
  • Pause during off-seasons
  • Monitor total entertainment spending

Next Steps

Features Overview

Explore all CashGap features

API Documentation

View complete API reference

Build docs developers (and LLMs) love