DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A school discount with no code, because the discount is the email domain

We used to give schools a discount code. A teacher asked for one, we minted it, and then we maintained the parts that follow from a code existing: an admin CRUD for the codes, a redemption table so per-user and per-year limits could be enforced, a validate endpoint, and a support thread every time somebody pasted a code with a smart quote in it.

The replacement is that a school is registered by its email domain. If your account's address is at a registered domain, the discount is already on the price. There is nothing to type, so there is nothing to mistype, lose, or forward to a friend.

The matching rule is longest suffix, not equality

Schools do not have one domain. A trust has a domain, the secondary under it has a subdomain, and pupil accounts often sit on a third. So the lookup is not "is this domain registered", it is "is this domain, or any parent of it, registered, and which is the most specific".

export function emailDomainCandidates(email: string | null | undefined): string[] {
  if (!email) return [];
  const at = email.lastIndexOf('@');
  if (at <= 0) return [];

  const labels = email.slice(at + 1).trim().toLowerCase().split('.');
  if (labels.length < 2 || labels.some((label) => label.length === 0)) return [];

  const candidates: string[] = [];
  for (let i = 0; i <= labels.length - 2; i++) {
    candidates.push(labels.slice(i).join('.'));
  }
  return candidates;
}
Enter fullscreen mode Exit fullscreen mode

jane@pupils.beaumont.school produces ['pupils.beaumont.school', 'beaumont.school'], in that order, and the first registered hit wins. That is what lets a sixth form under a trust's domain carry its own rate while everyone else on the trust keeps the trust's rate. It stops at two labels, so nothing ever probes a bare TLD.

The index is a plain object so it can be cached as JSON, which means membership is Object.hasOwn(index, candidate) rather than candidate in index. A lookup is one key probe per level of the email's domain, however many schools are registered.

The validation that matters is the one that refuses a mailbox provider

The domain is typed by a human into an admin form, and two kinds of input have to be refused.

A typo, by pattern: at least two lowercase labels, letters, digits and inner hyphens, an alphabetic TLD, and 253 characters at most because that is what a domain name gets. A domain nobody can receive mail at would otherwise sit in the table matching nobody, in silence, until the school asked why it was not working.

And a consumer mailbox provider, by list. Sixteen of them: gmail.com, googlemail.com, outlook.com, hotmail.com, hotmail.co.uk, live.com, live.co.uk, msn.com, yahoo.com, yahoo.co.uk, icloud.com, me.com, aol.com, proton.me, protonmail.com, gmx.com. A school contact whose address is a Gmail one is a completely normal thing to paste into that form, and registering it would discount every account on Gmail. The error says so rather than saying "invalid".

The normaliser accepts a whole address, by the way, because pasting the contact's email is what an admin will actually do:

const value = raw.trim().toLowerCase();
const at = value.lastIndexOf('@');
return (at === -1 ? value : value.slice(at + 1)).replace(/\.+$/, '');
Enter fullscreen mode Exit fullscreen mode

Every dashboard render asks the question, so it cannot be a query

The signed-in user's eligibility is needed by every price on every dashboard page. That is the shape of thing that quietly becomes a per-render query, so the active domains load once into the Next data cache as a single index keyed on domain, with a tag. An admin write calls revalidateTag(tag, { expire: 0 }), so the next request reads fresh rows; the hourly revalidate is only a backstop for an edit made outside the admin routes.

Two smaller decisions around it:

  • Checkout prices from the same cached index the dashboard displayed, so the figure a buyer saw is the figure they are charged.
  • A failed lookup returns no discount. It never returns a failed page or a failed checkout. Losing a discount is recoverable by reloading; a 500 on the pricing page is not.

What reaches the browser is trimmed to the school's name and the percentage. The row id and the matched domain stay on the server, because the page has no use for them and the matched domain is the one fact that would tell a user something about another user's account.

The purchase record is reporting, and is allowed to fail

A discounted purchase writes a row: which domain, which user, which academic year period, the list amount, the charged amount, and the Stripe checkout session id with a unique index on it. The webhook runs on every delivery, so that index is the whole retry story: onConflictDoNothing makes the second delivery a no-op.

The insert is wrapped in a try/catch that logs. By the time it runs, the buyer's entitlement has already been granted, and a thrown error would hand Stripe a 500 and buy us an endless retry loop over a reporting row. The row is for us. The purchase belongs to the customer either way.

See it

The two user-visible consequences are both on public pages, and they are the only places this feature is described in writing:

  • cogniprep.app/terms has the paragraph that states the rule we had to settle before building it: a code you apply is used instead of the school discount, not as well as it. Stacking is where a discount system turns into an arithmetic bug.
  • cogniprep.app/privacy describes the matching in the terms a buyer cares about, which is that we compare the domain of their email address against the schools that have a discount with us. Worth reading next to the code above: the privacy sentence is the honest description of a longest-suffix lookup.

The migration that shipped it created two tables and dropped two, school_discount_codes and school_discount_redemptions, both CASCADE. Deleting the code programme turned out to be most of the work, which is the next post.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Nice writeup, the longest suffix match is cleaner than I expected.

One thing I'd check: since the discount follows whatever address is on the account, eligibility should only read an email the user has actually confirmed. If signup or the change-email flow saves anything@beaumont.school before the confirmation link is clicked, someone gets the school price on an address they don't own. Same on email change: recompute from the confirmed address, not the one typed into the settings form.