Asterias
Get started

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 trapWhat happensWhat this module does
A single-use couponIt 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 payloadStripe 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 allA 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_STEP

The 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 statusCounts?Why
activeYesThe referral is paying.
trialingDepends on the settingOnly if REFERRAL_COUNT_TRIALING is true. Read the section on abuse before changing it.
past_dueNoA renewal failed. This is what "stops at the first missed payment" means.
unpaidNoThe invoice was never settled.
canceledNoThe subscription is cancelled.
pausedNoThe subscription is paused, so nothing is charged.
incompleteNoNo 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.

VariableDefaultWhat it does
REFERRAL_PERCENT_STEP20Percent off per paying referral.
REFERRAL_MAX_REFERRALS5How many referrals still earn one.
REFERRAL_COUNT_TRIALINGfalseWhether a referral still in its free trial counts. Read the section on abuse before setting this to true.
REFERRAL_COUPON_PREFIXreferral_offPrefix for the coupons this app creates. The percentage is appended to it.
REFERRAL_COOKIE_DAYS30How long a captured code survives before signup.
APP_URLnonePublic origin. Referral links are built from it.
STRIPE_SECRET_KEYnoneWithout it, referrals are recorded and displayed, but no discount is applied.
STRIPE_WEBHOOK_SECRETnoneWithout 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 file

At 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 dev

Without 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 attackWhy it fails
Referring yourselfresolveReferrer refuses when the code’s owner is the account being created.
Two referrers for one accountreferredUserId is unique. First one wins, forever.
Forging the cookie or the ?ref= valueBoth 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 retroactivelyattachReferral refuses an account that has already been a customer.
Replaying a Stripe webhookEvery event id is recorded before handling, and the primary key drops the replay.
Reading another account’s referralsEvery 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 database

reconcile 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.