When I added referrals to my app (AI headshots; React on the front, Firebase Functions and Firestore behind), I skipped the referrals collection. No code generator either. A referral code is REF- plus the referrer's Firestore user ID.
That part took ten minutes. The order endpoint it plugs into took a lot longer to get right.
The code is the user
Redeeming looks roughly like this:
if (couponCode.startsWith("REF-")) {
const refUid = couponCode.replace("REF-", "");
if (refUid !== req.user.uid) {
const referrerDoc = await db.collection("users").doc(refUid).get();
if (referrerDoc.exists) {
price = Math.max(0, price - price * 0.2); // 20% off
referrerUid = refUid;
}
}
}
Nothing to generate, nothing to expire, no orphan rows when someone deletes their account.
Yes, if you know someone's UID you can guess their code. A referral code exists to be shared, so I don't care. I would not do this for anything meant to be secret.
Three writes, one transaction
When a referred order completes, the buyer gets their credits, the referrer gets 20 bonus credits, and an order record gets written. If any one of those lands without the others, I get a support email. So they all go inside one db.runTransaction.
The payment side: the client finishes PayPal checkout and sends me the PayPal order ID. I verify it with PayPal server-side, checking the amount matches the price I calculated (after the referral discount). Then the transaction.
The replay bug
What happens if the same PayPal order ID shows up twice? A double-click, a retry, someone replaying the request on purpose. Without protection, that's double credits for the buyer and double credits for the referrer. Nothing crashes. You'd only find out when Firestore and PayPal stop agreeing.
My first guard was a query before the transaction:
const existing = await db.collection("orders")
.where("paypalOrderID", "==", paypalOrderID).limit(1).get();
if (!existing.empty) return reject("already processed");
That catches the boring case. It doesn't catch two requests racing, because both can run the query, both see nothing, and both go on to write.
The real fix was making the PayPal order ID the order document's ID and checking it inside the transaction:
const orderRef = db.collection("orders").doc(paypalOrderID);
const orderDoc = await t.get(orderRef);
if (orderDoc.exists) throw new Error("This payment has already been processed");
Firestore won't let both of two racing transactions commit on top of the same read. The loser gets retried, re-reads, sees the order now exists, and bails. I kept the outer query anyway. It's a cheap early exit, and it skips a PayPal API call.
The coupon sitting next to it
Same endpoint, different headache. Regular coupons have usage limits. I checked the limit before the transaction, but between that check and the write, another order could take the last use. And PayPal has already captured at the discounted price by then.
So the coupon gets re-read inside the transaction (Firestore wants all reads before writes, so it sits right after the order check), and if the limit's been hit, the order is rejected. The usage increment happens in the same transaction as the order record, so a failed order never burns a use. Rejecting a paid order isn't great. Honoring a discount I can't justify is worse.
What I'd do differently
Write the "same request twice, concurrently" test first. I found the race by staring at the code, not by a test catching it, and that's luck, not process.
This all lives in the order flow of Stature AI.
Top comments (0)