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
| Key | maxActiveJobs | maxCandidatesPerJob | maxInterviewsPerMonth | Premium features |
|---|---|---|---|---|
free | 1 | 10 | 30 | none |
starter | 5 | 50 | 200 | none |
pro | 20 | 200 | 1 000 | advancedAnalytics, customBranding |
enterprise | -1 (unlimited) | -1 | -1 | all |
Seed script:
src/scripts/seedPlans.ts— run vianpm 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' });
}