We built a buy-everything pass, deleted it 26 minutes later, and the thing that replaced it needed no new table

2026-11-10来源 dev.to TypeScriptEnglish

At 19:34 one evening last weekend we merged an All Access pass: one payment for every provider on sale plus the premium feature set, priced per region, open to every discount programme. 26 files, 2,029 insertions. A 279-line checkout route, a 240-line pricing card, 463 lines of tests, a rebuilt pricing page, a new clause in the terms.

At 20:01 it was deleted. 39 files, 828 insertions against 1,413 deletions: the route, the card, the pricing module, the tests, the purchase intent, every type, and both the terms and FAQ wording.

It never reached production. Both merges happened inside a feature branch, and that branch reached main as a single commit a day later, so no deployed state ever contained it. I checked rather than assuming, because "we shipped and reverted" and "we never shipped" are different stories and only one of them is true here: walk main's first-parent chain, ask git cat-file -e <commit>:app/api/all-access/checkout/route.ts at each step, and the file is in none of them.

The reason for the delete was commercial, not technical: a pass that bundles everything prices our most motivated customer at a discount and teaches everyone else to wait for the bundle. What the owner wanted instead was a reason to come back.

The replacement is a row in a table that already existed

Every paid purchase now mints a personal code for 20% off the next one, good for 30 days. The implementation is one new module plus a terms file, and zero migrations, because the code is a row in nurture_discount_codes, the table that already holds the codes we email people:

 * The code is a row in `nurture_discount_codes`, so it is reserved, claimed and * released by exactly the same paths as an emailed nurture code. 

Two fields make it a different kind of code. campaign is next_purchase, and the table's (user_id, campaign) unique index is the whole of "one live code per buyer". product_type is 'any', where an emailed code names the single product it discounts.

The entire minting rule is one upsert:

const [row] = await db .insert(nurtureDiscountCodesTable) .values({ id: crypto.randomUUID(), user_id: userId, campaign: NEXT_PURCHASE_CAMPAIGN, ...fresh }) .onConflictDoUpdate({ target: [nurtureDiscountCodesTable.user_id, nurtureDiscountCodesTable.campaign], set: fresh, // Only a spent or lapsed code is replaced; a working one is left alone. setWhere: or( eq(nurtureDiscountCodesTable.status, 'claimed'), lte(nurtureDiscountCodesTable.expires_at, now) ), }) .returning({ code: nurtureDiscountCodesTable.code }); 

Three behaviours out of one statement: no row yet, mint one; a claimed or expired row, replace it with a fresh code and clear the reservation and Stripe columns; a row that is live and unspent, leave it exactly as it is, because handing somebody a second code while their first one still works is a support ticket waiting to happen. setWhere on a conflict target is the feature that makes that a single round trip, and it is the one bit of ON CONFLICT I always have to look up.

mintNextPurchaseCode never throws. It runs on the webhook's post-grant path, where the sale is already delivered, so the worst outcome of a database hiccup has to be a missing code and not a failed webhook.

The ordering is the feature

/** * Gives a paying buyer their code for a discount on the next purchase. Runs after * the nurture claim above, so a purchase made with the previous code finds it * claimed and earns a fresh one. On every delivery, like the settlement: the * upsert keeps a live code, so a retry changes nothing. Never throws. */ 

Mint after the purchase's own code claim settles, and the thing chains: spend your code, the row goes to claimed, the upsert sees a spent row and replaces it. Mint before, and the buyer's live-but-about-to-be-claimed code is kept and they get nothing for the purchase they just made. Same two function calls, opposite behaviour, and no test would have told us which order was right without somebody writing down what should happen.

The cost of reusing the table was one discriminator in five places

This is the part I would want to read if somebody else wrote it, because reuse is never free.

product_type: 'any' is a new value in a column every code path compared with ===. So the match check gained a second arm:

- if (row.product_type !== params.productType) { + // A next-purchase code names no product and is good for any of them. + if (row.product_type !== params.productType && row.product_type !== ANY_PRODUCT_TYPE) { 

the eligibility query gained an or, and the label in the UI became conditional on the campaign rather than a constant string.

The sharper one was invisible. A row in nurture_discount_codes doubles as the marker for "we emailed this person recently", and every lifecycle campaign skips anyone who has one. Dropping a next-purchase code in that table would therefore have silently muted every lifecycle email for a month after each purchase. The fix is a <> in five candidate queries, and the test reads the generated SQL rather than trusting the builder:

const query = new PgDialect().sqlToQuery(mocks.execute.mock.calls[0][0] as SQL); expect(query.sql).toMatch(/nurture_discount_codes\.campaign <> \$\d+/); expect(query.params).toContain(NEXT_PURCHASE_CAMPAIGN); 

describe.each over the five campaigns, so adding a sixth campaign that forgets the exclusion fails in a file whose name says what it is about.

Two small things

The code alphabet is shared with the emailed codes and omits the characters people mistype:

/** Same unambiguous alphabet as the emailed codes (no I, O, 0, 1). */ const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; 

32 characters, drawn with crypto.getRandomValues into a Uint32Array, so byte % 32 carries no modulo bias: 32 divides 2^32 exactly. Pick an alphabet whose length is a power of two and that whole class of nitpick disappears.

And the percentage is a constant with a pointed comment:

/** Percentage off the next purchase. Quoted in copy; never type the number. */ export const NEXT_PURCHASE_DISCOUNT_PERCENT = 20; /** How long a minted code can be spent for. Terms section 5.4 states the same; change both. */ export const NEXT_PURCHASE_CODE_VALIDITY_DAYS = 30; 

"Change both" is a comment admitting there is no test pinning the constant to the legal text. There should be. It is on the list.

See it

Open the pricing page and search for "All Access": zero hits, in the rendered text and in the raw HTML. Search for "20% off" and you get the offer stated twice, once in the explainer and once in the pricing FAQ, both of them interpolating the two constants above rather than typing the numbers. Clause 5.4 of the terms names the code and the 30 days, and the policy version reads 1.13.0.

The code itself appears on the purchase confirmation and under "What you own" in the dashboard, served by a route that returns it only while it can still be spent, so there is nothing to see there without buying something.

An evening's work, 2,029 insertions deleted, and the shipped version is one upsert and a <>. That is a better trade than it sounds.

原文:https://www.a8q9.cn/en/article/we-built-a-buy-everything-pass-deleted-it-26-minutes-later-and-the-thing-that-re-9cbec9.html

相关文章