DEV3 Technologies home

Affiliate commission job

A queued job that turns a paid order into one commission row with a tiered rate and a hold period, safe against duplicate dispatches.

LaravelQueue jobsaffiliatecommissionqueueidempotent

Code

<?php

namespace App\Jobs;

use App\Models\Commission;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CreateCommission implements ShouldBeUnique, ShouldQueue
{
    use Queueable;

    /** Monthly sales threshold => rate in basis points, highest first. */
    private const TIERS = [50 => 2000, 20 => 1500, 0 => 1000];

    private const HOLD_DAYS = 30;

    public function __construct(public int $orderId) {}

    public function uniqueId(): string
    {
        return (string) $this->orderId;
    }

    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);

        if (! $order->affiliate_id || $order->status !== 'paid') {
            return;
        }

        $sales = Commission::where('affiliate_id', $order->affiliate_id)
            ->where('created_at', '>=', now()->startOfMonth())
            ->count();

        $bps = collect(self::TIERS)->first(fn ($rate, $threshold) => $sales >= $threshold);

        // The unique index on order_id is the real guard: a replay returns the existing row.
        Commission::firstOrCreate(
            ['order_id' => $order->id],
            [
                'affiliate_id' => $order->affiliate_id,
                'amount_cents' => intdiv($order->total_cents * $bps, 10000),
                'rate_bps' => $bps,
                'status' => 'pending',
                'available_at' => now()->addDays(self::HOLD_DAYS),
            ],
        );
    }
}

How it works

What it solves

When a referred customer pays, the affiliate must earn exactly one commission, calculated from the right rate, and not payable until the refund window has closed. Payment webhooks fire twice and queues retry, so the job has to be safe to run any number of times.

How it works

The job implements ShouldBeUnique with the order id as the key, which stops two copies running at once. It is the unique index on commissions.order_id that gives the real guarantee: firstOrCreate either inserts the row or returns the existing one, so a replay after a crash cannot pay twice.

The rate depends on how many sales the affiliate has already made this calendar month. A small ordered array maps thresholds to percentages, and the first tier whose threshold is not above the count wins. Amounts are integer cents and the commission uses intdiv on basis points, so there are no floating point surprises.

New commissions start as pending with an available_at date set by the hold period. A separate scheduled command later flips matured rows to approved, unless the order was refunded in the meantime.

Adapting it

Move the tiers into config or a database table, add a flat bonus, or skip orders under a minimum value. Dispatch the job from the payment success handler with CreateCommission::dispatch($order->id).

Pitfalls

  • Compute the tier count inside the job so retries use consistent data.
  • Skip orders with no referrer before doing any work.

How to use it

  1. Create commissions (order_id unique, affiliate_id, amount_cents, rate_bps, status, available_at).
  2. Add referred_by to your orders or users table.
  3. Copy the job and dispatch it after payment: CreateCommission::dispatch($order->id).
  4. Schedule a daily command that approves rows where available_at has passed and the order is not refunded.
  5. Run php artisan queue:work.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets