I added a referral program to my SaaS. The rule sounds simple: when someone you referred becomes a paying annual customer, you get an account credit. Not when they sign up, not when they start a trial, but when money has actually moved.
Writing that logic took an afternoon. Proving it worked took much longer, and most of that time went into one uncomfortable fact: the only event that matters, "the referred customer paid", happens at the end of a trial, on an annual plan, and you only get one real shot at it per customer. The next natural billing event for that subscription is a year away.
This post is about how I tested that path without charging a real card, without creating fake accounts on any platform, and without touching my production database. The tools were Stripe Test Clocks and a disposable Postgres branch. Along the way the test surfaced two things I would not have found by reading the code: a $0 trial invoice counts as paid, and on recent Stripe API versions the invoice's subscription field is simply null.
The stack is Next.js on Vercel, Drizzle, and Neon Postgres, but the testing approach doesn't depend on any of that.
The bug that shaped the design: "active" is not "paid"
My first version of the reward check looked at the subscription status. If it was active and on the annual price, the referrer got their credit.
A security review caught the problem before it shipped. When a trial ends, Stripe moves the subscription out of trialing and creates the first real invoice. The customer.subscription.updated event with status: "active" can arrive before the payment on that invoice has succeeded. If the card is declined, the subscription later goes to past_due, but by then my code had already handed out a credit for a payment that never happened.
So the rule changed from "the subscription is active" to "the subscription is active and its latest invoice is paid and the amount paid is greater than zero". The trigger also moved: the main entry point is now the invoice.paid webhook, with customer.subscription.updated kept only as a fallback.
Here is the eligibility check as it exists in the code today (comments translated from my original notes):
export async function rewardReferralIfEligible(stripeSubscriptionId: string): Promise<void> {
const [referral] = await db
.select()
.from(referrals)
.where(eq(referrals.stripeSubscriptionId, stripeSubscriptionId))
.limit(1);
if (!referral || referral.status !== "pending") return;
// Primary idempotency key: if a transaction ID is already recorded, the credit was granted.
// Confirm "rewarded" without calling Stripe.
if (referral.stripeBalanceTransactionId) {
await db
.update(referrals)
.set({ status: "rewarded", rewardedAt: referral.rewardedAt ?? new Date() })
.where(and(eq(referrals.id, referral.id), eq(referrals.status, "pending")));
return;
}
const stripe = getStripe();
// Re-check eligibility against Stripe's actual state. Don't trust the caller's pre-filtering.
const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId, {
expand: ["latest_invoice"],
});
if (sub.status !== "active") return;
const priceId = sub.items.data[0]?.price.id ?? null;
if (!priceId || priceId !== PLAN_PRICE_IDS.pro_annual()) return;
const latestInvoice =
typeof sub.latest_invoice === "object" && sub.latest_invoice !== null
? (sub.latest_invoice as Stripe.Invoice)
: null;
if (!latestInvoice || latestInvoice.status !== "paid" || (latestInvoice.amount_paid ?? 0) <= 0) {
return;
}
// Compare-and-swap: only one process can move pending -> rewarded.
const updated = await db
.update(referrals)
.set({ status: "rewarded", rewardedAt: new Date() })
.where(and(eq(referrals.id, referral.id), eq(referrals.status, "pending")))
.returning();
if (updated.length === 0) return;
// ... resolve the referrer's Stripe customer, check for an existing credit, grant it
}
Every condition in that function is a claim about how Stripe behaves at the end of a trial. I had read the docs, and a reviewer had read the code. Neither of those is evidence. I needed to watch a trial end.
Why the obvious test plans didn't work
Before I found a setup I liked, I talked myself out of three others. Each failure taught me something about what the test actually needed.
Plan 1: subscribe for real in production, then cancel during the trial. I had used this trick before to test checkout, because cancelling inside the trial costs nothing. But it tests nothing here. The reward only fires on invoice.paid after a successful charge. Cancel during the trial and the code under test never runs. A free test of the wrong path is not a cheap test of the right one.
Plan 2: subscribe for real, but give the test customer a balance credit large enough to cover almost the whole invoice. This looked clever until I reread my own guard: amount_paid > 0. If the credit covers the full amount, Stripe records the invoice as paid with nothing actually charged, and my code correctly refuses to reward it. To get through the guard I would have had to leave a small remainder on the card. That's workable, but it's a real charge on a real card in a real account, and it still runs against production data.
Plan 3, the one that actually killed the real-money approach: the referred customer has to be a different person. My self-referral guard rejects a referral when the referrer and the referred user share a user ID or an email address. That is the point of the guard. To test the happy path end to end in production, I needed a second account that signs in through the same OAuth provider my app uses. I run exactly one account on that platform on purpose. Creating a second account to test my own app is precisely the kind of thing that gets accounts suspended, and I wasn't going to trade that risk for a test.
Once all three were off the table, the shape of the right test was clear. I didn't need a real person or a real card. I needed Stripe to believe that a trial had ended and a payment had succeeded, and I needed my database to contain the rows that would exist if a real referral had happened. Both of those can be faked safely, as long as the fakes are fenced off from everything real.
The setup: Stripe test mode, a Test Clock, and a throwaway database branch
The test environment had three pieces:
-
Stripe test mode. Customers, subscriptions, and invoices created with a
sk_test_key. Test card tokens likepm_card_visamake payments succeed without any real card. - A Stripe Test Clock. A test-mode customer can be attached to a clock that starts frozen at a chosen time. You then advance the clock, and Stripe runs every billing event that would have happened in between: the trial ends, the first invoice is created, payment is attempted.
- A Neon branch of the production database. Branching gives a copy of the schema (and data) that can be written to freely and then deleted. The production database is never connected.
I wrote one script with a subcommand for each step, so every step could be run, inspected, and repeated on its own: setup, reward, advance, assert, reset-pending, cleanup. Each subcommand prints a single JSON line with IDs, statuses, and counts, and never prints connection strings or keys.
Guards first
A test script that creates customers and writes rows is one environment variable away from doing that in production. So before any subcommand does anything, it runs the same guard function:
// Never write to a DATABASE_URL whose hostname doesn't start with this
// (prevents accidental writes to production or other branches)
const S8_BRANCH_ENDPOINT = "ep-xxxxxxxx"; // the test branch's endpoint, redacted here
async function assertGuards(): Promise<{ stripe: Stripe; priceId: string }> {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey || !secretKey.startsWith("sk_test_")) {
printAndExit({ ok: false, reason: "STRIPE_SECRET_KEY_NOT_TEST_MODE" }, 1);
}
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
printAndExit({ ok: false, reason: "DATABASE_URL_NOT_SET" }, 1);
}
let hostname: string;
try {
hostname = new URL(connectionString).hostname;
} catch {
printAndExit({ ok: false, reason: "DATABASE_URL_UNPARSEABLE" }, 1);
}
if (!hostname.startsWith(S8_BRANCH_ENDPOINT)) {
printAndExit({ ok: false, reason: "DATABASE_URL_NOT_S8_BRANCH" }, 1);
}
const priceId = process.env.STRIPE_PRICE_PRO_ANNUAL;
if (!priceId) {
printAndExit({ ok: false, reason: "STRIPE_PRICE_PRO_ANNUAL_NOT_SET" }, 1);
}
const stripe = getStripe();
const price = await stripe.prices.retrieve(priceId);
if (price.livemode === true) {
printAndExit({ ok: false, reason: "PRICE_IS_LIVEMODE" }, 1);
}
return { stripe, priceId };
}
There are three independent checks: the key prefix, the database hostname, and the livemode flag on the price Stripe actually returns. The database check is an allowlist of one specific branch, not a denylist of production. A denylist would quietly allow any other database I happened to point it at.
The connection string for the branch went into a separate, git-ignored env file that the script loads on top of the normal one. I never needed to see its value, and the script never printed it.
Creating the world
setup builds everything a real referral would have produced:
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: "s8-referral-e2e",
});
const customerB = await stripe.customers.create({
email: "s8-referred@example.test",
name: "S8 Referred",
test_clock: clock.id,
payment_method: "pm_card_visa",
invoice_settings: { default_payment_method: "pm_card_visa" },
metadata: { s8: "1" },
});
const sub = await stripe.subscriptions.create({
customer: customerB.id,
items: [{ price: priceId }],
trial_period_days: 14,
discounts: [{ coupon: REFERRAL_COUPON_ID }],
expand: ["latest_invoice"],
});
On the database side, inside one transaction, it inserts two users (referrer A and referred B), a trialing subscription row for B, and a pending referral row linking them. The .example.test email addresses are reserved for testing and can't belong to anyone.
One detail was deliberate: user A gets no subscription row at all. On the free plan in my app, a user has no Stripe customer and no subscription row until they first check out. The reward code has to create a Stripe customer for the referrer on demand before it can credit them. That lazy path is exactly the kind of branch that never runs in development, so the test environment was built to force it.
Test 1: the negative test, during the trial
Before advancing the clock, I ran the reward function against the subscription while it was still in its trial. The expected outcome was: nothing happens, and the referral stays pending.
It did stay pending. The interesting part was why. It would be easy to assume that during a trial the latest invoice is missing, or unpaid. It was neither. The subscription already had a latest invoice, its status was paid, and its amount_paid was 0.
When a subscription starts with a trial, Stripe creates a zero-amount invoice for the trial period and marks it paid. If my guard had been latestInvoice.status === "paid" on its own, which reads like a perfectly reasonable definition of "the customer paid", the reward would have fired the moment the trial started, with no money involved at all.
The amount_paid > 0 half of the condition was the only thing standing between a correct reward and a free one. It looks like a minor extra check when you read the code. On the most ordinary path in the system, the trial, it is the check doing all the work.
Test 2: advancing the clock past the end of the trial
advance moves the Test Clock to a point a few hours after the trial ends, then waits for Stripe to finish processing:
await stripe.testHelpers.testClocks.advance(state.testClockId, {
frozen_time: state.trialEnd + 3 * 3600,
});
const startedAt = Date.now();
let ready = false;
while (Date.now() - startedAt < ADVANCE_TIMEOUT_MS) {
const clock = await stripe.testHelpers.testClocks.retrieve(state.testClockId);
if (clock.status === "ready") {
ready = true;
break;
}
await sleep(ADVANCE_POLL_MS);
}
Advancing a clock is asynchronous. The API call returns right away, and the clock reports a non-ready status while Stripe works through the simulated time. Reading the subscription before the clock is ready gives you a state that is halfway through the transition. That's another way to fool yourself, so the script polls until ready and fails loudly on timeout.
After the advance, the subscription was active, the latest invoice was paid, the amount paid was exactly the discounted annual price, and the billing_reason was subscription_cycle. That's the state my production webhook would see.
The surprise: invoice.subscription was null
The webhook handler for invoice.paid has to find out which subscription the invoice belongs to. Older Stripe API versions put that on invoice.subscription. Newer versions, starting with the release Stripe calls Basil, moved it to invoice.parent.subscription_details.subscription. My SDK version and my webhook endpoint's API version were not the same, so I had written the extractor to check both:
// Invoice -> subscription ID. Basil and later: invoice.parent.subscription_details.subscription.
// Older versions: invoice.subscription. Check both, because the SDK's pinned version and the
// webhook endpoint's version differ.
function extractSubscriptionId(invoice: Stripe.Invoice): string | null {
const inv = invoice as unknown as {
subscription?: string | { id: string } | null;
parent?: {
subscription_details?: { subscription?: string | { id: string } | null } | null;
} | null;
};
const raw = inv.parent?.subscription_details?.subscription ?? inv.subscription ?? null;
if (!raw) return null;
return typeof raw === "string" ? raw : raw.id;
}
When I wrote that fallback, I wasn't sure it was needed. It might just have been defensive noise. So the advance step logged both paths separately on the real invoice. The new path had the subscription ID. The old path was null.
Without the new path, handleInvoicePaid would have returned early on every invoice. No error, no log line, just a referral sitting in pending forever. Because this is an annual plan, "forever" is not an exaggeration: there is no second chance until the renewal a year later. That's the kind of bug a Test Clock is good at finding, because it hands you a real Stripe object of the current shape rather than the shape you remember.
Test 3: granting the reward, and proving it happened exactly once
With the subscription in its paid state, I ran the reward path through my manual sweep script, first as a dry run and then for real. Then assert checked the outcome from three independent angles:
- the
referralsrow for this subscription wasrewarded, and there was exactly one row; - the referrer now had a subscription row and a Stripe customer, created lazily by the reward code;
- the referrer's Stripe balance held exactly the expected credit, and exactly one balance transaction contained the marker string
(referral <id>).
"Exactly one" was the whole point. A reward system that double-pays is worse than one that pays late, because a late payment can be fixed with a sweep and a double payment means asking someone to give money back.
Re-running everything
First, I simply ran the sweep again, and then called the reward function directly. Nothing changed. The referral was no longer pending, so the function returned immediately.
Second, I tested the failure mode that worried me more. The reward code calls Stripe with an idempotency key, so a retried request inside Stripe's retention window returns the original result instead of creating a second credit. But Stripe's docs say idempotency keys can be removed after they are at least 24 hours old. If the Stripe call succeeded but my process lost the response, the code rolls the referral back to pending. If the retry then happens a day later, the idempotency key no longer protects anything.
To simulate that, reset-pending forces the row back into the "we don't know what happened" state:
const updated = await db
.update(referrals)
.set({ status: "pending", rewardedAt: null, stripeBalanceTransactionId: null })
.where(eq(referrals.id, state.referralId))
.returning({ id: referrals.id });
Then I ran the sweep again. This time the reward code reached its second line of defense. It listed the referrer's balance transactions on Stripe, found the existing credit by its marker, recorded that transaction's ID, and confirmed rewarded without creating a new credit. The balance transaction count stayed at one.
That second line of defense came out of a review, and up to this point it had only ever been read, never executed. Now it had run against a real Stripe API response.
Cleanup is part of the test
cleanup deletes the Test Clock, then deletes the test referral, subscription, and user rows from the branch and prints how many rows of each it removed. After that I deleted the database branch itself and the temporary env file that held its connection string.
I treat cleanup as a test step rather than an afterthought. A leftover database branch with a real-looking schema, and an env file pointing at it, is something future-me could connect to by mistake months later. A test that leaves debris behind has side effects, and side effects are exactly what I was trying to keep away from production in the first place.
What this test did not prove
This setup proved the reward logic: the eligibility conditions, the lazy customer creation, the compare-and-swap, the double-payment defenses, and the invoice shape on the current API version. It did not prove:
- that the production webhook endpoint actually receives
invoice.paid. My script called the reward function directly instead of going through the webhook, so it can't see the endpoint's configuration at all. This one wasn't hypothetical: when the switch toinvoice.paidwas made, a review added "confirm the endpoint is subscribed toinvoice.paid" as a separate pre-launch check. When I looked at the production endpoint, it wasn't subscribed, and I had to add the event by hand; - that the live Checkout page applies the referral discount correctly (I checked that separately, by creating a real Checkout Session with the same parameters and looking at the page, without completing it);
- that a real second person can go through the whole flow in the UI.
I wrote those gaps down next to the test results, in the same document. A test report that lists only what passed invites the next reader, usually me, to assume it covered everything.
What I'd generalize from this
Any condition based on "paid" needs a test that runs while a trial is still in progress. Zero-amount invoices are real invoices with a real paid status. If your logic means "money moved", say so in the code with an amount check, and make sure a test exercises the trial state, not just the after-trial state.
Time-dependent billing logic deserves a clock you control. If the event you care about happens once per customer, at the end of a trial or a renewal, don't wait for it and don't fake it by calling your handler with a hand-written payload. A Test Clock gives you real Stripe objects in their real current shape, which is exactly what exposed the invoice.subscription: null problem.
Fence your test harness with allowlists, not hope. Key prefix, a specific database hostname, and the livemode flag of an object fetched from the API. Three cheap checks, each one independent of the others, all of them run before the first write.
Test your second line of defense by deliberately breaking the first. Idempotency keys expire, responses get lost, rows get rolled back. If your defense against that is "look for an existing record before writing", the only way to know it works is to put the system into the ambiguous state on purpose and run it.
Build the code path you can't reach in development. My lazy customer creation only runs for a referrer who has never checked out. In normal development I always have a subscription, so it would never have run. The test data was shaped specifically to force that branch.
Write down what the test didn't cover, and give each gap its own check. The most important production misconfiguration in this feature, a webhook endpoint not subscribed to the event that triggers the reward, sat entirely outside what my test could see. A green test run would never have revealed it. It was caught only because it had its own line on the pre-launch checklist, separate from the test.
Most of the harness is guards and output formatting. The Test Clock calls themselves are a small fraction of it. That ratio seems about right to me: moving time is easy, and making sure you only moved time where you meant to is the part that takes the effort.













