refactor(billing): system email + single-query backfill for the student cap (#735)

* refactor(billing): make student-limit email a system email; tighten backfill query

- Fetch free-plan orgs over the student limit in a single query
  (getFreePlanOrganizationsOverStudentLimit) instead of loading all orgs and
  counting each in memory; the backfill script now just fans out admin emails.
- Report a dry-run summary: how many free orgs are over the limit, how many
  with admins, total admin emails, and how many were skipped for having none.
- Reclassify the student-limit email as a system (ClassroomIO-branded) email
  rather than org-branded — it's the platform telling an admin about their
  plan. Drop the `branding` field and org masthead/theme; both callers updated.
- Reword the email to explain why it's sent (new students are being turned
  away) and that it's throttled to once a day, so it doesn't read as spam.
- Document the system-vs-org-branded email distinction in AGENTS.md.

* feat(billing): send student-limit emails on milestone crossings, once each

Stop emailing admins on every blocked attempt. Instead notify once when the
org crosses a student-count milestone:
- 50% of the limit → new studentLimitApproaching email (heads-up).
- 100% (limit reached) → studentLimitReached email.

Crossings are detected in assertStudentCapacityOrThrow on the allowed path
(blocked attempts no longer email). "Already notified" is persisted on
organization.settings.studentLimitNotified so each milestone fires at most
once, ever — surviving restarts, no per-day approximation. The backfill script
uses the same flag, making reruns idempotent (skips already-notified orgs and
marks them after sending). Email copy updated to explain the trigger and that
it's a one-time-per-milestone message.

* fix(email): use z.url() and drop the frequency footnotes on student-limit emails

Address PR review: `upgradeUrl` uses `z.url()`; remove the "once a day / once
per milestone" footnote lines from both the reached and approaching emails.

* set track={false}
This commit is contained in:
Rotimi Best
2026-07-12 20:16:12 +01:00
committed by GitHub
parent 7392be04d7
commit 8bed9f6b4e
10 changed files with 181 additions and 56 deletions
+3
View File
@@ -27,6 +27,7 @@ export * from './session';
export * from './submission-received';
export * from './submission-graded';
export * from './student-limit-reached';
export * from './student-limit-approaching';
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
@@ -46,6 +47,7 @@ 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 { studentLimitApproachingEmail } from './student-limit-approaching';
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
@@ -82,4 +84,5 @@ export type EmailSchemas = {
submissionReceived: typeof submissionReceivedEmail.template.schema;
submissionGraded: typeof submissionGradedEmail.template.schema;
studentLimitReached: typeof studentLimitReachedEmail.template.schema;
studentLimitApproaching: typeof studentLimitApproachingEmail.template.schema;
};
@@ -0,0 +1,26 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const studentLimitApproachingEmail = defineEmail({
id: 'studentLimitApproaching',
subject: "You're approaching your Free plan student limit",
schema: z.object({
orgName: z.string().min(1),
studentCount: z.number(),
studentLimit: z.number(),
upgradeUrl: z.url()
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p><strong>${fields.orgName}</strong> now has ${fields.studentCount} of the ${fields.studentLimit} students included on the Free plan — about halfway to the limit. Once you reach it, new students can't enrol, accept invites, or be added until you upgrade.</p>
<div>
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
</div>
`;
return getDefaultTemplate(content);
}
});
@@ -2,7 +2,6 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentLimitReachedEmail = defineEmail({
id: 'studentLimitReached',
@@ -11,18 +10,17 @@ export const studentLimitReachedEmail = defineEmail({
orgName: z.string().min(1),
studentCount: z.number(),
studentLimit: z.number(),
upgradeUrl: z.string().url(),
branding: ZEmailBranding
upgradeUrl: z.url()
}),
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>
<p><strong>${fields.orgName}</strong> has reached its Free plan limit of ${fields.studentLimit} students. From now on, new students can't enrol, accept invites, or be added until you upgrade.</p>
<div>
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
</div>
`;
return getDefaultTemplate(content, fields.branding);
return getDefaultTemplate(content);
}
});
+1
View File
@@ -13,6 +13,7 @@ export const EMAIL_IDS = [
'submissionGraded',
'submissionReceived',
'studentLimitReached',
'studentLimitApproaching',
'studentCourseInvite',
'studentCourseCompletion',
'studentCourseWelcome',