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
);
}
}
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
);
}
}
{
"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
- Streaming Services
- Software Subscriptions
- Memberships
- Utilities
Track Netflix, Spotify, Disney+, HBO Max, etc.
- Set monthly or yearly billing
- Pause during off-seasons
- Monitor total entertainment spending
Manage GitHub, Adobe Creative Cloud, Microsoft 365, etc.
- Track professional tools
- Identify unused licenses
- Optimize software spending
Monitor gym, Amazon Prime, Costco, etc.
- Track yearly renewals
- Set reminders before billing
- Evaluate membership value
Track internet, phone plans, cloud storage, etc.
- Monitor recurring bills
- Compare plan costs
- Identify saving opportunities
Next Steps
Features Overview
Explore all CashGap features
API Documentation
View complete API reference