DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Deleting a discount programme: 930 lines of files, two dropped tables, and a reservation lifecycle nobody needed

School discount codes existed in our codebase from migration 0035 on 10 August 2026 to migration 0049 on 18 October 2026. They were replaced by registering a school's email domain, which I wrote about separately. This post is only about the removal, because the removal was most of the work and it is the half nobody budgets for.

What came out

Four files were deleted outright:

File Lines
lib/discounts/code-manager.ts 555
app/api/admin/discount-codes/route.ts 200
app/api/admin/discount-codes/[codeId]/route.ts 136
app/api/discounts/validate/route.ts 39

Plus two tables dropped, school_discount_codes and school_discount_redemptions, both CASCADE, in the same migration that created the two replacing them. The admin screen was rebuilt in place rather than replaced, which shows up as 322 insertions against 320 deletions in one component. Three checkout routes each lost a branch. The privacy policy, the terms and the in-app FAQ each had a paragraph that described codes and had to describe something else.

Fifty-nine files, 5,687 insertions and 2,137 deletions, for a feature whose user-visible behaviour went from "type this code" to "nothing".

The reservation lifecycle is the part worth keeping in mind

The deleted redemptions table carried this constraint:

CHECK ("school_discount_redemptions"."status" IN ('reserved','completed','released'))
Enter fullscreen mode Exit fullscreen mode

Three states, because a code has a finite allocation and a checkout that has started is not a checkout that has completed. So a redemption had to be reserved when the session was created, completed when the webhook confirmed payment, and released when the buyer wandered off. That is a state machine with a timeout, a reconciliation story, and a bug class where a customer is told their school's allocation is spent because of sessions nobody ever paid for.

The replacement has one table of registered domains and one table of purchases, and no status column anywhere. A domain has no allocation to spend, so there is nothing to hold while a buyer decides. Eligibility is a property of the account, re-derived whenever anyone asks, and the purchases row is written once, after payment, from the webhook.

The lesson is not that reservations are bad. It is that the three-state lifecycle was downstream of one product decision, which was that a school gets N discounted seats identified by a shared secret. Change the identifier from a secret to a domain and the whole lifecycle evaporates. A lot of the code in a product is like this: not complexity in the problem, complexity in the shape the problem was given.

Removing a link from a chain cost 23 lines

The one thing that was pleasant was the code resolver. We have a single "Have a code?" field backed by one endpoint that tries the code against each programme in turn, first match wins, response normalised so the caller does not care which programme answered. It used to have four links in the chain. It now has three.

That removal was an import, a block, and some comment edits: the route went from 187 lines to 164. What made it cheap is a contract each programme already had to honour, documented at the top of the route:

A programme is only skipped when it reports the code is simply NOT that programme's. Any other rejection (malformed, inactive, own code, already used) is returned as-is, because it means the code WAS recognised but cannot be used.

Because "not mine" and "mine, and no" are different outcomes rather than the same null, a link can be pulled out of the chain without any other link learning about it. The only position that carries extra knowledge is the last one, which owns the terminal "that code is not recognised" message, and there is now a comment saying so.

There is also a line in the route's doc comment that only exists because of this change:

 * School discounts are not codes. They come from the buyer's email domain and
 * never pass through here.
Enter fullscreen mode Exit fullscreen mode

A comment explaining why something is absent is usually a smell. Here it is load-bearing: the next person looking for school discount handling will search this file first, because for ten weeks that was exactly where it lived.

The tail a code programme leaves and a domain does not

The real argument for doing the removal as a drop rather than a wind-down is that the two models have different tails. A code is a token that lives outside your system, in inboxes and intranet pages and a PDF somebody printed. Its lifetime is not yours to end, which is why code programmes accumulate validity windows, per-user limits and "this code has expired" copy.

A domain registration has no copies. It is a row. Deactivating it changes who qualifies on the next request, and there is nothing in the world that still half works.

See it

The removal is visible on two public pages, both of which had to stop describing codes:

  • cogniprep.app/terms lists the kinds of code we issue. It now reads "promotional codes we email you after you sign up, friend referral codes, and affiliate codes", which is the resolver's three-link chain written as a sentence, and the paragraph below it states the rule we had to settle: a code you apply is used instead of the school discount, not as well as it.
  • cogniprep.app/privacy used to have one bullet covering "school and promotional discount codes" and a record of each redemption. It now has two bullets, because the two things stopped working the same way: one compares the domain of your email address, the other still counts redemptions.

If you want the pricing these discounts apply to, it is on cogniprep.app/pricing. The figures there are now also the figures Stripe is asked for, which was the other half of the same commit.

Top comments (0)