Skip to content

Plan & Organization


Plan

System-seeded. One document per pricing tier. All plans get access to all stage types — differentiation is purely by usage limits and premium feature flags.

typescript
interface IPlan {
  _id: ObjectId;
  key: 'free' | 'starter' | 'pro' | 'enterprise';
  displayName: string;
  features: {
    // Usage limits — the primary monetization lever
    maxActiveJobs: number;           // -1 = unlimited
    maxCandidatesPerJob: number;     // -1 = unlimited
    maxInterviewsPerMonth: number;   // -1 = unlimited
    // All 6 stage types are available on every plan.
    // Monetization is volume-based, not feature-gated by stage type.
    // Premium add-ons:
    advancedAnalytics: boolean;
    customBranding: boolean;
    apiAccess: boolean;
    prioritySupport: boolean;
  };
  pricing: {
    monthly: number;
    annual: number;
    currency: 'USD';
  };
  isActive: boolean;
}

Indexes:

  • { key: 1 } — unique
  • { isActive: 1 }

Seed Plans

KeymaxActiveJobsmaxCandidatesPerJobmaxInterviewsPerMonthPremium features
free11030none
starter550200none
pro202001 000advancedAnalytics, customBranding
enterprise-1 (unlimited)-1-1all

Seed script: src/scripts/seedPlans.ts — run via npm run seed:plans


Organization

Replaces the organizationName string on User. All recruiter resources are scoped to an org.

typescript
interface IOrganization {
  _id: ObjectId;
  name: string;
  website?: string;
  logo?: string;

  planKey: string;                // ref → Plan.key
  planOverrides?: {               // sales-negotiated overrides beyond plan defaults
    maxActiveJobs?: number;
    maxCandidatesPerJob?: number;
    maxInterviewsPerMonth?: number;
  };

  // Usage limits + premium flags, snapshotted from Plan for fast reads.
  // Re-synced whenever the org's plan changes.
  featureSnapshot: {
    maxActiveJobs: number;
    maxCandidatesPerJob: number;
    maxInterviewsPerMonth: number;
    advancedAnalytics: boolean;
    customBranding: boolean;
    apiAccess: boolean;
  };

  members: {
    userId: ObjectId;             // ref → User
    role: 'admin' | 'recruiter' | 'viewer';
    joinedAt: Date;
  }[];

  billingEmail: string;
  stripeCustomerId?: string;

  // Phase 6: custom branding for candidate-facing pages
  brandingConfig?: {
    primaryColor: string;
    logoUrl: string;
    emailFooterText: string;
    candidatePortalDomain?: string;
  };

  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

Indexes:

  • { 'members.userId': 1 } — auth middleware: find org for logged-in user
  • { planKey: 1 }
  • { isActive: 1 }

Auto-Creation on Recruiter Registration

For Phase 0, a new Organization is auto-created for every recruiter who signs up:

typescript
Organization.create({
  name: user.name + "'s Workspace",
  planKey: 'free',
  featureSnapshot: computeFromPlan('free'),
  members: [{ userId: user._id, role: 'admin', joinedAt: new Date() }],
  billingEmail: user.email,
})

Multi-user org management (inviting team members, role changes) is added in Phase 5.

Plan Change — featureSnapshot Sync

When a plan changes (via Stripe webhook or manual update):

typescript
const plan = await Plan.findOne({ key: newPlanKey });
await Organization.findByIdAndUpdate(orgId, {
  planKey: newPlanKey,
  featureSnapshot: {
    maxActiveJobs: plan.features.maxActiveJobs,
    maxCandidatesPerJob: plan.features.maxCandidatesPerJob,
    maxInterviewsPerMonth: plan.features.maxInterviewsPerMonth,
    advancedAnalytics: plan.features.advancedAnalytics,
    customBranding: plan.features.customBranding,
    apiAccess: plan.features.apiAccess,
  },
});

Why snapshot?

featureSnapshot avoids a join to Plan on every request. The snapshot is re-synced whenever the plan changes, so it's always current.

Usage-Cap Enforcement

typescript
// POST /v1/jobs — active job limit
if (org.featureSnapshot.maxActiveJobs !== -1) {
  const count = await JobOpening.countDocuments({
    organizationId, status: { $in: ['open', 'draft'] }
  });
  if (count >= org.featureSnapshot.maxActiveJobs)
    return res.status(403).json({ message: 'Active job limit reached for your plan' });
}

// POST /v1/interviews — monthly interview limit
if (org.featureSnapshot.maxInterviewsPerMonth !== -1) {
  const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
  const count = await Interview.countDocuments({
    organizationId, createdAt: { $gte: startOfMonth }
  });
  if (count >= org.featureSnapshot.maxInterviewsPerMonth)
    return res.status(403).json({ message: 'Monthly interview limit reached for your plan' });
}

// POST /v1/interviews — first invite for a new candidate to a job
if (org.featureSnapshot.maxCandidatesPerJob !== -1) {
  const count = await CandidatePipeline.countDocuments({ jobOpeningId });
  if (count >= org.featureSnapshot.maxCandidatesPerJob)
    return res.status(403).json({ message: 'Candidate limit per job reached for your plan' });
}