The Asterias referral program, open sourced
Asterias is a closed product, but one of its parts no longer is. The referral program running in production here has been extracted, rewritten and published under the MIT licence. Here is what it does, how it does it, and the three traps it exists to avoid.
- Licence
- MIT
- Built on
- Next.js 15 · Prisma 6 · Stripe 18 · TypeScript
- Unit tests
- 89
- Languages
- EN · FR · DE · IT · ES
What this module does
Most referral programs pay for a signup. A signup costs nothing to manufacture, so those programs either get farmed or get wrapped in fraud rules nobody can explain to an honest customer.
This one pays for revenue, and only while the revenue lasts. Every referral who becomes a paying customer takes a slice off the referrer’s own subscription. The moment they stop paying, that slice comes off the next invoice.
- Sarah refers Tom. Tom signs up: Sarah earns nothing.
- Tom starts a free trial: Sarah still earns nothing.
- Tom’s card is charged: Sarah gets 20% off.
- Tom’s card fails: Sarah is back to full price.
- Tom pays again: the discount comes back.
Five paying referrals and Sarah’s subscription is free. The step and the cap are yours to set in one environment file.
There is no points balance, no credit ledger and no payout queue. The discount is a function of how many referrals are paying right now, recomputed from the database and pushed to Stripe. That is what stops the screen and the invoice from saying two different things, because both read the same function.
Three traps, and what this module does about them
Three things go wrong in almost every hand-rolled referral program. This module is mostly the shape of avoiding them.
| The trap | What happens | What this module does |
|---|---|---|
| A single-use coupon | It looks like the tidy way to make "recalculated every month" true: attach a coupon, let it fall off, attach the next one. The discount then depends on something re-attaching it before every single invoice, and any window where that did not run is a full-price charge that nothing reports. | duration: forever. The discount is durable state that the module removes deliberately. A late sync costs nothing. |
| Trusting the webhook payload | Stripe does not guarantee delivery order. A past_due event can arrive after the active that replaced it. Write what the payload says and you move a discount on a third party’s subscription, based on a state that is no longer true. | Every event triggers a fresh read of the Stripe API. Concurrent handlers are ordered by their read timestamp. |
| Trusting the webhook at all | A deploy, a 500, an event Stripe stopped retrying: the referrer is on the wrong tier, and a wrong discount looks exactly like a right one. | A reconciler recomputes every tier from the database, repairs the drift, and says what it repaired. |
How the discount is computed
There is deliberately no stored "current discount". The tier is a pure function of how many referrals are paying at this instant.
discountPercent(activeReferrals) =
min(max(activeReferrals, 0), MAX_REFERRALS) × PERCENT_STEPThe same function feeds the customer’s page and the Stripe sync, so what they are shown and what their card is charged cannot disagree because two places fell out of step.
The customer feels it on their next invoice: discounts are written with proration_behavior set to none, so a tier change never generates a credit note or an immediate charge for a month already served.
| Stripe status | Counts? | Why |
|---|---|---|
| active | Yes | The referral is paying. |
| trialing | Depends on the setting | Only if REFERRAL_COUNT_TRIALING is true. Read the section on abuse before changing it. |
| past_due | No | A renewal failed. This is what "stops at the first missed payment" means. |
| unpaid | No | The invoice was never settled. |
| canceled | No | The subscription is cancelled. |
| paused | No | The subscription is paused, so nothing is charged. |
| incomplete | No | No payment ever completed. Same for an account with no subscription, or on a free plan. |
Configuration
Everything lives in the environment and is validated at boot. A configuration that cannot be honoured refuses to start, rather than failing on the day a customer earns the top tier.
| Variable | Default | What it does |
|---|---|---|
| REFERRAL_PERCENT_STEP | 20 | Percent off per paying referral. |
| REFERRAL_MAX_REFERRALS | 5 | How many referrals still earn one. |
| REFERRAL_COUNT_TRIALING | false | Whether a referral still in its free trial counts. Read the section on abuse before setting this to true. |
| REFERRAL_COUPON_PREFIX | referral_off | Prefix for the coupons this app creates. The percentage is appended to it. |
| REFERRAL_COOKIE_DAYS | 30 | How long a captured code survives before signup. |
| APP_URL | none | Public origin. Referral links are built from it. |
| STRIPE_SECRET_KEY | none | Without it, referrals are recorded and displayed, but no discount is applied. |
| STRIPE_WEBHOOK_SECRET | none | Without it, the endpoint rejects everything. Both are required for the billing half. |
Choosing the step and the cap
REFERRAL_PERCENT_STEP multiplied by REFERRAL_MAX_REFERRALS must stay at or below 100. Stripe has no coupon above 100% off, and a tier that asked for one would silently fall through to no discount at all, for the account that earned the most.
20 × 5 # a referral is worth 20%, five make it free (default)
10 × 5 # a gentler program that caps at half price
25 × 2 # two referrals, half price, nothing beyond
33 × 3 # 99% at the top; the last 1% keeps a card on fileAt 100% off, Stripe issues a zero invoice and does not charge the card. The card stops being exercised, and when the tier later drops to a paying one, that first real charge can fail on a card that expired months ago. That is why the referral page quotes the next invoice, and why 33 × 3 is in the list: it caps at 99% and keeps a real charge on the account.
Try it in a minute
The repository is a complete Next.js application: the module, a Postgres database, demo accounts and the referral page.
git clone https://github.com/MaxenceLassus/nextjs-stripe-referrals
cd nextjs-stripe-referrals
pnpm install
docker compose up -d # Postgres on :5433
cp .env.example .env.local # works as-is without Stripe
pnpm db:migrate && pnpm db:seed
pnpm devWithout a Stripe key the program records referrals, displays them, applies no discount, and says so on screen. A test key and a webhook secret switch the billing half on.
To play both sides in one browser: sign up, copy your link, open it in a private window and sign up as somebody else, subscribe that second account, then watch the first account’s page move to 20% off with its next invoice quoted. Cancel the second subscription and watch it come back off.
Installing it in your app
The whole module is one folder, src/referrals. Copy it, and three things are left to do.
The four Prisma models you add to your schema carry no foreign key to your users table: userId is an opaque string, whatever your app calls an account id. That is what makes this a folder you copy rather than a migration you merge. The trade is that nothing cascades on account deletion, hence forgetUser(userId), called from your own deletion path.
1. Attribute the account at signup
attachReferral never throws. A referral is a marketing nicety, a signup is the business: a stale, mistyped, self-referring or forged code returns a result you may log and ignore, and the account is created either way.
import { attachReferral, readReferralCookie, clearReferralCookie, accountLabel } from '@/referrals'
const user = await createYourAccount(...)
await attachReferral(user.id, await readReferralCookie(), {
label: accountLabel({ name: user.name, email: user.email }),
})
await clearReferralCookie()People share the page that convinced them, which is the pricing page far more often than it is the signup form. One line in the middleware you already have catches ?ref= on every page.
2. Catch the code everywhere, not only on /signup
The captured code survives thirty days by default, which is time enough for a visitor to leave, think about it, and come back to create an account.
// src/middleware.ts
export function middleware(request: NextRequest) {
return captureReferral(request, NextResponse.next())
}3. Mount the webhook, and tag your checkout
The webhook is where the program learns that a referral has paid. Checkout is what tells it which account the Stripe customer it just created belongs to.
// src/app/api/referrals/webhook/route.ts
export { POST } from '@/referrals/stripe/webhook'
// wherever you create a Checkout session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price, quantity: 1 }],
...referralCheckoutOptions(user.id),
})And the page
ReferralPage takes a userId and never works out who is signed in, on purpose. Authentication is yours: a component that resolved the session itself is a component that can be mounted on a route where nobody checked.
// src/app/dashboard/referrals/page.tsx
export default async function Page() {
const user = await requireUser() // your auth, not this module's
return <ReferralPage
userId={user.id}
locale={user.locale}
label={user.name}
price={{ amount: 2900, currency: 'eur' }}
/>
}Abuse, and why this design resists it
The single most important line in this module is that a trial does not count.
With trials counted, anyone opens five accounts, starts five trials, takes 100% off their own subscription and cancels all five before a card is ever touched. The program pays out for nothing.
With trials excluded, earning a discount requires real subscriptions to be really paid for. To fake five paying referrals you must genuinely pay for five subscriptions in order to make one free. The economics do the policing, which is why there is no fraud score here to tune or to explain in a support ticket.
What is left to you: rate-limit your signup route. This module cannot see your traffic, and account creation is your endpoint.
| The attack | Why it fails |
|---|---|
| Referring yourself | resolveReferrer refuses when the code’s owner is the account being created. |
| Two referrers for one account | referredUserId is unique. First one wins, forever. |
| Forging the cookie or the ?ref= value | Both are visitor-controlled and neither is trusted: the code is looked up in the database at signup. Forging one only ever gives a discount to the account named, so there is nothing to steal. |
| Claiming referrals retroactively | attachReferral refuses an account that has already been a customer. |
| Replaying a Stripe webhook | Every event id is recorded before handling, and the primary key drops the replay. |
| Reading another account’s referrals | Every query is keyed on the userId you pass. Pass an authenticated one. |
Operations
Four commands, two of which really matter.
pnpm referrals:doctor # will this deployment actually apply discounts?
pnpm referrals:reconcile # repair drift; run hourly
pnpm referrals:reconcile --dry-run # report it without writing
pnpm referrals:verify-flow # walk the whole program against a real databasereconcile only speaks when it did something. A sweep that logs on every quiet run trains everyone to ignore it, and then the noisy day gets missed too. If it is repairing twenty discounts a day, something upstream is broken and you need to know.
The diagnosis, from the server
doctor answers the question that has no answer in a browser: a missing webhook secret, a key pointing at the wrong Stripe account, a coupon with the wrong duration. From outside, every one of those looks exactly like a program where nobody has referred anybody yet.
Configuration
ok tiers: 20% x 5 = 100% maximum
ok trials do not count
Stripe
ok key works: account acct_1234 (Your Company)
ok mode: test
Coupons
ok referral_off_20: 20% off, forever
FAIL referral_off_40 has duration "once", not "forever".
A `once` coupon falls off after one invoice and the discount silently stops.What is tested, and what is not
89 unit tests, no database and no network: the tier arithmetic and its clamps, code minting and escaping, config validation, status mapping, the attribution guards, webhook signature, idempotency and routing, and the discount sync including its failure path.
Eight of those are wire tests: the real Stripe SDK, real serialised parameters, against a local recorder. They assert the bytes rather than a mock: duration=forever on the coupon, discounts[0][coupon] when applying, an empty discounts when removing, proration_behavior=none on both. These are the three things most likely to be silently wrong in a Stripe integration, and the most invisible to a mocked test.
Not covered by any of it: how Stripe actually responds, and whether a real invoice comes out lower. That needs a live test key and five minutes, and the repository spells out the steps.
Five languages
English, French, German, Italian, Spanish. English is the default and the fallback for anything unrecognised; fr-CA resolves to fr. The dictionaries are typed per language, so adding a language, or merely adding a string, fails to compile until every language has it. A test also asserts that no translation drops a placeholder.
What it runs here
This is not a weekend abstraction. It runs the referral program of Asterias, the Google review software whose site you are on. An owner who refers a fellow shopkeeper sees their subscription drop by 20% as soon as that shopkeeper really pays, and go back up if they stop.
The published version is not a copy of the code here: it is a rewrite, and it fixes two real defects found while extracting it. If you run something similar, they are worth ten minutes of your time.
Licence
MIT. Copy the folder, change it, sell what you make with it. If you ship it, a word would be welcome.
Frequently asked questions
Can the referred person get a discount too?
Not out of the box. A discount to the referred account is given before they have paid anything, which is the one thing this design deliberately avoids: it is granted on a promise rather than on evidence, and it is the only real abuse vector in the whole system. If you want it anyway, apply your own coupon at checkout; nothing here will interfere.
Does it need the webhook?
Yes, and unlike a subscription’s own provisioning this is not negotiable. A referral’s billing change has to reach an account that is not the one looking at the screen. Nobody is watching, so there is no lazy refresh to fall back on.
Does it work without Stripe?
It runs, records referrals and displays them, and says on screen that no discount is being applied. Useful for local development, and for a deployment whose Stripe setup is not finished.
What if my app is not Next.js?
The rules, codes, queries, Stripe sync, webhook handler and reconciler are plain TypeScript with no framework in them. Only capture.ts, middleware.ts and ui/ are Next-specific, about a third of the folder.
Why not one npm package?
Because the two things you most need to install are a Prisma model and an App Router page, and npm cannot deliver either without you copying them anyway. A folder is honest about that.
The code is on GitHub
MIT licensed, with a README covering the full API, the failure modes, and how to verify against a real Stripe key that an invoice really does come out lower.