DEV Community

Cover image for My referral codes are just user IDs. The hard part was the order transaction.
Michael Dixon
Michael Dixon

Posted on

My referral codes are just user IDs. The hard part was the order transaction.

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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)