feat(billing): enforce free-plan student cap across the app (#733)
* feat(billing): enforce free-plan student cap across the app
Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.
Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.
Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.
Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.
* feat(billing): guard remaining student-creating paths against the cap
The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:
- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)
For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.
* fix(billing): don't show cap block to existing org members on enroll page
The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.
Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.
* feat(billing): deliver org plan-limits via /account with an extensible registry
Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).
Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
(reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
sidebars, upgrade card, People pages, and invite modals read these instead
of fetching audience rows.
Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.
Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.
* refactor(billing): address PR review on the limits work
- Dedupe new student profile IDs before the cohort seat check so duplicate
batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
flag (audience config declares `upgradeResource: 'students'`; groups set it
from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
`disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
convention (operator-controlled reruns) so idempotency isn't reflagged.
* fix(billing): name the org in the enroll capacity message
Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.
* style(billing): use PremiumIcon for the Authentication upgrade indicator
Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
This commit is contained in:
@@ -36,11 +36,34 @@ import { and, eq } from 'drizzle-orm';
|
||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
||||
import {
|
||||
countActiveStudents,
|
||||
getActiveOrganizationPlan,
|
||||
getOrganizationMemberIdByOrgAndProfile,
|
||||
insertOrganizationMembersOnConflictDoNothing
|
||||
} from '@cio/db/queries/organization';
|
||||
import { getStudentLimit } from '@cio/utils/plans';
|
||||
import { env } from '../../config/env';
|
||||
import { annotateCourseContentWithProgression } from './progression';
|
||||
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
||||
import { guardCourseTypeTransition } from './public-course-guard';
|
||||
|
||||
/**
|
||||
* Whether a *new* student would be turned away from this org right now
|
||||
* (mirrors the check in `apps/api/src/services/organization/student-limit.ts`,
|
||||
* duplicated here since packages/core can't depend on the api app).
|
||||
*/
|
||||
async function isStudentLimitReached(orgId: string): Promise<boolean> {
|
||||
if (env.PUBLIC_IS_SELFHOSTED === 'true') return false;
|
||||
|
||||
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||
const limit = getStudentLimit(activePlan?.planName);
|
||||
if (!Number.isFinite(limit)) return false;
|
||||
|
||||
const currentCount = await countActiveStudents(orgId);
|
||||
return currentCount + 1 > limit;
|
||||
}
|
||||
|
||||
const DEFAULT_CONTENT_GROUPING = true;
|
||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||
|
||||
@@ -57,6 +80,17 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
|
||||
|
||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||
|
||||
if (access.roleId === ROLE.STUDENT) {
|
||||
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(organizationId, profileId);
|
||||
if (!existingMemberId && (await isStudentLimitReached(organizationId))) {
|
||||
throw new AppError(
|
||||
'This organization has reached its student limit on the Free plan',
|
||||
ErrorCodes.UPGRADE_REQUIRED,
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await insertOrganizationMembersOnConflictDoNothing(
|
||||
[
|
||||
@@ -160,9 +194,12 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
||||
|
||||
const { contentItems, org: courseOrg, ...rest } = course;
|
||||
|
||||
const studentLimitReached = courseOrg ? await isStudentLimitReached(courseOrg.id) : false;
|
||||
|
||||
const base = {
|
||||
...rest,
|
||||
content,
|
||||
studentLimitReached,
|
||||
metadata: {
|
||||
...course.metadata,
|
||||
progressionMode
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
||||
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
||||
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
||||
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
||||
|
||||
@@ -567,6 +567,58 @@ export const getOrganizationTeam = async (orgId: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts active organization members with the student role. Used to enforce per-plan student caps.
|
||||
*/
|
||||
export async function countActiveStudents(orgId: string): Promise<number> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ count: count(schema.organizationmember.id) })
|
||||
.from(schema.organizationmember)
|
||||
.where(
|
||||
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
|
||||
);
|
||||
|
||||
return Number(row?.count ?? 0);
|
||||
} catch (error) {
|
||||
console.error('countActiveStudents error:', error);
|
||||
throw new Error('Failed to count active students');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets verified admin emails for an organization. Used to notify admins about plan-limit events.
|
||||
*/
|
||||
export async function getOrganizationAdminEmails(orgId: string): Promise<Array<{ email: string; fullname: string }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
email: schema.organizationmember.email,
|
||||
profileEmail: schema.profile.email,
|
||||
fullname: schema.profile.fullname
|
||||
})
|
||||
.from(schema.organizationmember)
|
||||
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organizationmember.organizationId, orgId),
|
||||
eq(schema.organizationmember.roleId, ROLE.ADMIN),
|
||||
eq(schema.organizationmember.verified, true)
|
||||
)
|
||||
);
|
||||
|
||||
return result
|
||||
.map((member) => ({
|
||||
email: member.profileEmail || member.email || '',
|
||||
fullname: member.fullname || ''
|
||||
}))
|
||||
.filter((member) => member.email);
|
||||
} catch (error) {
|
||||
console.error('getOrganizationAdminEmails error:', error);
|
||||
throw new Error('Failed to get organization admin emails');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organization audience (all organization members with student role).
|
||||
* Includes invited members without a profile (LEFT JOIN profile).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TOrganization, TOrganizationPlan } from '@db/types';
|
||||
import type { OrgUsageLimits } from '@cio/utils/plans';
|
||||
|
||||
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
|
||||
customerId: string | null;
|
||||
@@ -8,6 +9,7 @@ export type OrganizationWithMemberAndPlans = TOrganization & {
|
||||
memberId?: number;
|
||||
roleId?: number;
|
||||
plans: OrganizationPlan[];
|
||||
limits?: OrgUsageLimits;
|
||||
};
|
||||
|
||||
export type OrganizationWithPlans = TOrganization & {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* One-time (manual, not cron) backfill: email admins of every free-plan org
|
||||
* that already exceeds the student limit, so they know new enrolments/invites
|
||||
* are now blocked. Dry-run by default; pass --execute to actually send.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm db:notify-students-over-limit # dry run (default)
|
||||
* pnpm db:notify-students-over-limit -- --execute # send emails
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
import { buildEmailBranding, sendEmail } from '@cio/email';
|
||||
import { PLAN, getStudentLimit, isOrgOnFreePlan } from '@cio/utils/plans';
|
||||
|
||||
import {
|
||||
countActiveStudents,
|
||||
getOrganizationAdminEmails,
|
||||
getOrganizations
|
||||
} from '../queries/organization/organization';
|
||||
|
||||
async function main() {
|
||||
const execute = process.argv.includes('--execute');
|
||||
const orgs = await getOrganizations();
|
||||
const limit = getStudentLimit(PLAN.BASIC);
|
||||
|
||||
let notified = 0;
|
||||
|
||||
for (const org of orgs) {
|
||||
const isFree = isOrgOnFreePlan({ plans: org.plans, isSelfHosted: false, orgId: org.id });
|
||||
if (!isFree) continue;
|
||||
|
||||
const studentCount = await countActiveStudents(org.id);
|
||||
if (studentCount <= limit) continue;
|
||||
|
||||
const admins = await getOrganizationAdminEmails(org.id);
|
||||
if (!admins.length) {
|
||||
console.log(`Skipping ${org.name} (${org.id}): ${studentCount}/${limit} but no admin emails`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${execute ? 'Notifying' : '[dry-run] Would notify'} ${org.name} (${org.id}): ${studentCount}/${limit} students → ${admins.length} admin(s)`
|
||||
);
|
||||
|
||||
if (execute) {
|
||||
const branding = buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme });
|
||||
const upgradeUrl = `https://app.classroomio.com/org/${org.siteName ?? ''}?upgrade=true`;
|
||||
|
||||
await Promise.all(
|
||||
admins.map((admin) =>
|
||||
sendEmail('studentLimitReached', {
|
||||
to: admin.email,
|
||||
fields: { orgName: org.name, studentCount, studentLimit: limit, upgradeUrl, branding }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
notified++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(execute ? `Done. Notified ${notified} org(s).` : 'Dry run complete — re-run with --execute to send.');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ export * from './quiz-assigned';
|
||||
export * from './session';
|
||||
export * from './submission-received';
|
||||
export * from './submission-graded';
|
||||
export * from './student-limit-reached';
|
||||
|
||||
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
||||
|
||||
@@ -44,6 +45,7 @@ import type { quizAssignedEmail } from './quiz-assigned';
|
||||
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
|
||||
import type { submissionReceivedEmail } from './submission-received';
|
||||
import type { submissionGradedEmail } from './submission-graded';
|
||||
import type { studentLimitReachedEmail } from './student-limit-reached';
|
||||
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
||||
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
||||
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
||||
@@ -79,4 +81,5 @@ export type EmailSchemas = {
|
||||
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
|
||||
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
||||
submissionGraded: typeof submissionGradedEmail.template.schema;
|
||||
studentLimitReached: typeof studentLimitReachedEmail.template.schema;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentLimitReachedEmail = defineEmail({
|
||||
id: 'studentLimitReached',
|
||||
subject: "You've reached your Free plan student limit",
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
studentCount: z.number(),
|
||||
studentLimit: z.number(),
|
||||
upgradeUrl: z.string().url(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p><strong>${fields.orgName}</strong> has reached the ${fields.studentLimit}-student limit on the Free plan (currently ${fields.studentCount} students). New students can't enrol or be invited until you upgrade.</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export const EMAIL_IDS = [
|
||||
'sessionUpdated',
|
||||
'submissionGraded',
|
||||
'submissionReceived',
|
||||
'studentLimitReached',
|
||||
'studentCourseInvite',
|
||||
'studentCourseCompletion',
|
||||
'studentCourseWelcome',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Root from './progress.svelte';
|
||||
import Root, { type ProgressVariant, progressVariants } from './progress.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress
|
||||
Root as Progress,
|
||||
progressVariants,
|
||||
type ProgressVariant
|
||||
};
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const progressVariants = tv({
|
||||
slots: {
|
||||
root: 'ui:relative ui:h-2 ui:w-full ui:overflow-hidden ui:rounded-md',
|
||||
indicator: 'ui:h-full ui:w-full ui:flex-1 ui:transition-all'
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
default: { root: 'ui:bg-primary/20', indicator: 'ui:bg-primary' },
|
||||
muted: { root: 'ui:bg-muted', indicator: 'ui:bg-muted-foreground/40' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type ProgressVariant = VariantProps<typeof progressVariants>['variant'];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Progress as ProgressPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '../../tools';
|
||||
@@ -5,23 +27,19 @@
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
max = 100,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> & { variant?: ProgressVariant } = $props();
|
||||
|
||||
const styles = $derived(progressVariants({ variant }));
|
||||
</script>
|
||||
|
||||
<ProgressPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="progress"
|
||||
class={cn('ui:bg-primary/20 ui:relative ui:h-2 ui:w-full ui:overflow-hidden ui:rounded-md', className)}
|
||||
{value}
|
||||
{max}
|
||||
{...restProps}
|
||||
>
|
||||
<ProgressPrimitive.Root bind:ref data-slot="progress" class={cn(styles.root(), className)} {value} {max} {...restProps}>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
class="ui:bg-primary ui:h-full ui:w-full ui:flex-1 ui:transition-all"
|
||||
class={styles.indicator()}
|
||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||
></div>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -62,3 +62,17 @@ export function getWorkspaceAllowance(planName: string | null | undefined): numb
|
||||
export function canCreateWorkspaces(planName: string | null | undefined): boolean {
|
||||
return planName === PLAN.ENTERPRISE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max students allowed per plan (org-wide, counts ROLE.STUDENT organizationmember rows).
|
||||
*/
|
||||
export const STUDENT_LIMITS: Record<string, number> = {
|
||||
[PLAN.BASIC]: 20,
|
||||
[PLAN.EARLY_ADOPTER]: 10000,
|
||||
[PLAN.ENTERPRISE]: Infinity
|
||||
};
|
||||
|
||||
export function getStudentLimit(planName: string | null | undefined): number {
|
||||
if (!planName) return STUDENT_LIMITS[PLAN.BASIC];
|
||||
return STUDENT_LIMITS[planName] ?? STUDENT_LIMITS[PLAN.BASIC];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './constants';
|
||||
export * from './limits';
|
||||
export * from './automation';
|
||||
export * from './public-api';
|
||||
export * from './org-plan';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PLAN, STUDENT_LIMITS } from './constants';
|
||||
|
||||
/**
|
||||
* Org-scoped resources that carry a per-plan limit and are surfaced to the
|
||||
* dashboard. Extend this tuple (plus PLAN_LIMITS) to add a new limited resource.
|
||||
*/
|
||||
export const PLAN_LIMIT_RESOURCES = ['students'] as const;
|
||||
export type PlanLimitResource = (typeof PLAN_LIMIT_RESOURCES)[number];
|
||||
|
||||
/** Per-resource, per-plan numeric caps (Infinity = unlimited). */
|
||||
export const PLAN_LIMITS: Record<PlanLimitResource, Record<string, number>> = {
|
||||
students: STUDENT_LIMITS
|
||||
};
|
||||
|
||||
export function getPlanLimit(resource: PlanLimitResource, planName: string | null | undefined): number {
|
||||
const table = PLAN_LIMITS[resource];
|
||||
if (!planName) return table[PLAN.BASIC];
|
||||
|
||||
return table[planName] ?? table[PLAN.BASIC];
|
||||
}
|
||||
|
||||
/** JSON-safe usage record carried in the account payload. `limit: null` = unlimited. */
|
||||
export type ResourceUsage = { used: number; limit: number | null };
|
||||
export type OrgUsageLimits = Partial<Record<PlanLimitResource, ResourceUsage>>;
|
||||
|
||||
/** Build a usage record, converting a non-JSON-safe Infinity limit to null. */
|
||||
export function toResourceUsage(used: number, planLimit: number): ResourceUsage {
|
||||
return { used, limit: Number.isFinite(planLimit) ? planLimit : null };
|
||||
}
|
||||
|
||||
export function isResourceLimitReached(usage: ResourceUsage | undefined): boolean {
|
||||
if (!usage || usage.limit === null) return false;
|
||||
|
||||
return usage.used >= usage.limit;
|
||||
}
|
||||
|
||||
export function resourceLimitRemaining(usage: ResourceUsage | undefined): number | null {
|
||||
if (!usage || usage.limit === null) return null;
|
||||
|
||||
return Math.max(0, usage.limit - usage.used);
|
||||
}
|
||||
Reference in New Issue
Block a user