CogniPrep sells one-time purchases: unlock a provider's practice tests, unlock scoring and feedback, buy interview sessions, unlock the assessment centre exercise library. Over time four unrelated discount programmes grew around those products:
- school discounts, negotiated with an institution and capped per academic year
- affiliate codes, handed out by a creator, who earns a commission on the sale
- friend referrals, where an existing user's code gives a new buyer a cut
- nurture codes, personal and single use, minted by a lifecycle email
Each one started life as its own input with its own explanatory link, which is how a purchase screen ends up with three links nobody clicks. They all collapsed into a single field that says "Have a code?" and accepts anything.
That is a nice UI simplification and a much more interesting server problem, because now one endpoint has to work out which programme a code belongs to without being told.
Two failures that look identical and must not be treated the same
Trying programmes in turn only works if you can distinguish two kinds of rejection:
- the code is not this programme's code, so try the next programme
- the code is this programme's code, and it cannot be used, so stop and say why
If you flatten those into one boolean, an expired school code falls through the whole chain and comes out the other end as "that code is not recognised", which is a lie that generates a support email. So the outcome of trying one programme is a three state union:
export type CodeProgrammeResult<TPreview> =
| { status: 'matched'; preview: TPreview }
| { status: 'rejected'; response: NextResponse }
| { status: 'skipped' };
export async function tryCodeProgramme<TPreview>(
preview: () => Promise<TPreview>,
options: {
isNotFound: (error: unknown) => boolean;
errorResponse: (error: unknown) => NextResponse | null;
}
): Promise<CodeProgrammeResult<TPreview>> {
try {
return { status: 'matched', preview: await preview() };
} catch (error) {
if (options.isNotFound(error)) return { status: 'skipped' };
const rejection = options.errorResponse(error);
if (rejection) return { status: 'rejected', response: rejection };
throw error;
}
}
Every programme already exposed the same three pieces independently: a Zod schema, a preview* function that prices a code without charging anything, and an *ErrorResponse mapper that turns a known rejection into a 400 with a machine readable code and returns null for anything it does not recognise. That last null is what makes the helper safe: an unknown error is rethrown rather than being reported to the buyer as a bad code.
The order of the chain is not arbitrary
The resolve route tries four programmes, and the order is decided by what each one needs to know.
Nurture codes go first. They are the only programme keyed to a specific user, so they cannot collide with the shared codes below. They are also the only programme that discounts the cheapest product, so if the buyer is purchasing that one and no nurture code matched, there is nothing left to try and the route answers immediately rather than running three more database lookups that structurally cannot match.
Referrals go second, because they are the other programme that needs the buyer's identity, this time to reject a user trying to redeem their own code.
Affiliate codes go third. They are shared and stateless to validate.
School discounts go last, and their call site is the one that looks odd:
const school = await tryCodeProgramme(
() => previewDiscount({ code, userId: user!.id, productType, productId, bundle }),
{ isNotFound: () => false, errorResponse: discountErrorResponse }
);
isNotFound: () => false says: nothing falls through you. The last programme in the chain owns the terminal answer, so its own "no such code" rejection is what the buyer sees when the code really is unknown. There is no separate final return apiError('unknown code') for the happy path of failure, which means there is exactly one place that phrase is produced.
The skipped branch for that last call is therefore unreachable, and it is still written out, with a comment saying so, because handling the union exhaustively is cheaper than a cast that stops being true the day a fifth programme is appended.
The client only has to learn one word
The response is normalised, so the component does not care which programme matched. It gets a kind, a before price, an after price, a percentage, a minimum charge flag and a short label such as Supporting Ada Lovelace or Northfield College discount. The only thing kind is used for is picking a field name at checkout:
export function codeToCheckoutFields(applied: AppliedCode | null): {
schoolCode?: string;
affiliateCode?: string;
referralCode?: string;
nurtureCode?: string;
} {
if (!applied) return {};
switch (applied.kind) {
case 'school': return { schoolCode: applied.code };
case 'affiliate': return { affiliateCode: applied.code };
case 'referral': return { referralCode: applied.code };
case 'nurture': return { nurtureCode: applied.code };
}
}
Codes are mutually exclusive by construction: the function returns at most one field, so there is no "apply both" state for the checkout route to have an opinion about.
"That code is bad" and "I could not check" are also different answers
The same field auto applies a referral code stored earlier in the visit, and that path needs its own version of the same distinction:
return { ok: false, terminal: res.status === 400, message: /* ... */ };
A 400 is the resolve chain saying the code is genuinely unusable, so the stored code is forgotten. A rate limit, a network failure or a 500 is not evidence about the code at all, so the stored code survives and gets another chance on the next page. Treating those the same means a visitor who was rate limited quietly loses their friend's referral.
One more small thing in that component: the field id comes from React's useId rather than a constant, because two of them can be mounted at once (the unlock card and the locked game dialog both render one). A hard coded id would give both the same label target and the same aria-describedby, so the error message announced would be the other field's.
Nothing shown here is load bearing
The whole endpoint is read only. It reserves nothing, accrues no commission and charges nobody. Checkout re-validates the code and re-prices from our own product constants, never from the client, so a tampered response can change what the buyer is told and not what they are charged. It is rate limited as a write anyway, because it is a lookup by guessable string.
See it
- The public list prices the codes are applied against: cogniprep.app/pricing. Note that the prices you see are in your own currency and VAT inclusive, which is a separate story.
- The field itself sits behind sign in. Sign up free at cogniprep.app, open the upgrade card, open your browser's Network tab and type any nonsense into "Have a code?". You will see exactly one
POST /api/codes/resolve, a 400, and the terminal message produced by the last programme in the chain. Apply a real code and the same single request comes back withkindtelling you which of the four it turned out to be.
Top comments (0)