DocsHub
Back-EndSchemas

SaaS Schema

Complete MongoDB schemas for a SaaS product — Plan, Subscription, and Usage tracking.

SaaS Schema

Schemas for a subscription-based SaaS product — pricing plans, a subscription linking a user to a plan, and usage tracking for metered features.


Plan Schema

// src/models/plan.model.js
import mongoose from "mongoose";

const planSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
      unique: true, // e.g. "Free", "Pro", "Enterprise"
    },
    price: {
      type: Number,
      required: true,
      min: 0,
    },
    billingCycle: {
      type: String,
      enum: ["monthly", "yearly"],
      default: "monthly",
    },
    features: [{ type: String }],
    limits: {
      projects: { type: Number, default: 3 },
      teamMembers: { type: Number, default: 1 },
      storageMB: { type: Number, default: 500 },
    },
    stripePriceId: {
      type: String, // links this plan to a Stripe Price object
    },
    isActive: {
      type: Boolean,
      default: true,
    },
  },
  { timestamps: true }
);

const Plan = mongoose.model("Plan", planSchema);

export default Plan;

Subscription Schema

// src/models/subscription.model.js
import mongoose from "mongoose";

const subscriptionSchema = new mongoose.Schema(
  {
    user: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
      unique: true, // one active subscription per user
    },
    plan: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Plan",
      required: true,
    },
    status: {
      type: String,
      enum: ["active", "trialing", "past_due", "cancelled", "expired"],
      default: "trialing",
    },
    stripeCustomerId: {
      type: String,
    },
    stripeSubscriptionId: {
      type: String,
    },
    currentPeriodStart: {
      type: Date,
      default: Date.now,
    },
    currentPeriodEnd: {
      type: Date,
      required: true,
    },
    cancelAtPeriodEnd: {
      type: Boolean,
      default: false,
    },
  },
  { timestamps: true }
);

const Subscription = mongoose.model("Subscription", subscriptionSchema);

export default Subscription;

Usage Schema

For metered features — tracking how much of a limited resource a user has consumed within their current billing period.

// src/models/usage.model.js
import mongoose from "mongoose";

const usageSchema = new mongoose.Schema(
  {
    user: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
    },
    metric: {
      type: String,
      required: true, // e.g. "projects", "apiCalls", "storageMB"
    },
    count: {
      type: Number,
      default: 0,
    },
    // which billing period this usage record belongs to
    periodStart: {
      type: Date,
      required: true,
    },
    periodEnd: {
      type: Date,
      required: true,
    },
  },
  { timestamps: true }
);

// one usage record per user, per metric, per billing period
usageSchema.index({ user: 1, metric: 1, periodStart: 1 }, { unique: true });

const Usage = mongoose.model("Usage", usageSchema);

export default Usage;

Checking If a User Has Hit Their Limit

async function checkLimit(userId, metric) {
  const subscription = await Subscription.findOne({ user: userId }).populate("plan");

  if (!subscription || subscription.status !== "active") {
    throw new Error("No active subscription");
  }

  const limit = subscription.plan.limits[metric];

  const usage = await Usage.findOne({
    user: userId,
    metric,
    periodStart: { $lte: new Date() },
    periodEnd: { $gte: new Date() },
  });

  const currentCount = usage ? usage.count : 0;

  return {
    allowed: currentCount < limit,
    currentCount,
    limit,
  };
}

Incrementing Usage

async function incrementUsage(userId, metric, periodStart, periodEnd) {
  // upsert — create the usage record if it doesn't exist yet, otherwise increment
  await Usage.findOneAndUpdate(
    { user: userId, metric, periodStart },
    {
      $inc: { count: 1 },
      $setOnInsert: { periodEnd },
    },
    { upsert: true, new: true }
  );
}

$setOnInsert only applies those fields when a new document is created by the upsert — it has no effect when updating an existing document. This is the standard pattern for "create or increment" logic.


Summary

  • Plan defines pricing tiers with a limits object — easy to check against when enforcing feature restrictions
  • Subscription links a user to a plan and tracks Stripe IDs alongside billing period dates — designed to sync with Stripe webhook events
  • Usage tracks metered consumption per user, per metric, per billing period — with a compound unique index to prevent duplicate records
  • checkLimit() and incrementUsage() together form the core pattern for enforcing plan limits in a SaaS app
  • This schema set assumes Stripe for billing — stripeCustomerId, stripeSubscriptionId, and stripePriceId are the linking fields

On this page