DEV Community

rutvik patel
rutvik patel

Posted on

5 Edge Cases That Break a Simple Referral Program

A referral program looks like one line of logic: if someone signs up through a link, pay the person who shared it.

Then real money gets involved. We recently built one for our SaaS, with commissions paid on every customer payment for 12 months. Most of the work went into these five cases.

1. Two links, one customer: who gets the credit?

A visitor clicks one promoter's link on Monday and another promoter's link on Thursday. You have to choose, and the rule has to be simple enough to explain to both of them.

We chose first touch. The first valid code is kept, and later ones are ignored:

if ($code === null || $this->current($request) !== null) {
    return; // invalid code, or this visitor already has one
}
Enter fullscreen mode Exit fullscreen mode

The code goes in the session and a first-party cookie for 30 days. The cookie holds only the code. It is cleared once the account is created, and from then on the link is stored on the account itself.

We also ignore the link for signed-in users, so an existing customer can't be claimed by someone who sends them a link later.

2. People will refer themselves

If a referral pays commission and also gives a discount, someone will try signing up through their own link with a second email address.

So we compare the mobile number and the real inbox. Gmail ignores dots and everything after a +, so these are all the same person:

private static function inbox(?string $email): string
{
    $email = strtolower(trim((string) $email));
    [$local, $domain] = explode('@', $email, 2);
    $local = explode('+', $local, 2)[0];

    if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) {
        $local = str_replace('.', '', $local);
        $domain = 'gmail.com';
    }

    return $local . '@' . $domain;
}
Enter fullscreen mode Exit fullscreen mode

This won't stop someone with a different phone and a different inbox. For that, we flag unusual patterns for a person to review. Nothing is blocked automatically, because a false accusation is expensive too.

3. One payment can arrive more than once

A payment can be confirmed by the browser, by a webhook and by a reconciliation job. If each one creates a commission, you pay three times.

Checking first isn't enough, because two requests can pass the check together. So the database has the final say:

Schema::create('promoter_commissions', function (Blueprint $table) {
    // ...
    $table->foreignId('payment_id')->unique();
});
Enter fullscreen mode Exit fullscreen mode

When two requests race, the second insert fails. We catch that and return the row that already exists.

4. Customers get refunds after you've counted the commission

If you pay commission immediately and the customer gets a refund the next week, you've lost that money.

Our refund window is 30 days, so a commission waits the same 30 days:

pending (30 days) -> approved -> paid
        \                \
         -----------------> reversed
Enter fullscreen mode Exit fullscreen mode

If a payment stops being successful, an unpaid commission is reversed. A commission that was already paid stays on the record and is settled by hand.

5. Your revenue isn't the amount the customer paid

Our Indian prices include 18% GST, and that tax isn't our income. If we paid commission on the full amount, we'd pay it on money we pass to the government.

Say a customer pays ₹1,180:

Item Amount
Customer pays ₹1,180
GST at 18% ₹180
Base for commission ₹1,000
Commission at 20% ₹200

We save the tax split on each payment when it's created. That way, a later change to the tax rate can't change an old commission.

What we left out

We send payouts by hand over UPI or bank transfer, and record the transaction reference. Automating this can wait until the volume justifies it, and a person looking at each payout is also one more fraud check.

If you've built something like this, which edge case surprised you most? I'd like to hear about it in the comments.


Disclosure: this is the system behind the RSFlowHub promoter program, which I work on. It pays 20% of customer payments for 12 months. The code above is simplified from ours. I used an AI assistant to draft this post, and checked the details against our code.

Top comments (0)