The naïve solution
An order gets refunded. The affiliate had earned a commission on it. What do you do with that commission row?
Most affiliate platforms answer with a single SQL statement: UPDATE commissions SET status = ‘void’ WHERE order_id = $1. Done. The affiliate's pending balance drops by the commission amount, the row shows up grey on their dashboard, life moves on.
That works for a particular kind of refund: the customer changed their mind within the hold period, the order was never approved for payout, no harm done. But that's not the only kind of refund, and treating all refunds the same way creates two real problems:
- It silently rewrites approved history. If a commission was already
approved(passed the hold period, queued for the next payout), voiding it makes the affiliate's “earned this month” number jump backwards. They notice. They lose trust. - It can't handle paid commissions at all. If a commission was already paid out via Mercury or PayPal, the money has left the building. Voiding the row doesn't un-send the payment. Now you're relying on the affiliate to remember they owe you, which is a bad bet at any scale.
The decision tree
Cobalz handles refunds with a three-branch decision tree based on the commission's current status:
switch (commission.status) {
case 'pending':
// Still in the hold period. Safe to void silently.
await update(commission, { status: 'void', voided_at: now });
break;
case 'approved':
// Already approved but not yet paid. Insert a negative-amount
// sibling row that nets out at the next payout cycle. Keep
// the original approved row for auditability.
await insert({
affiliate_id: commission.affiliate_id,
order_id: commission.order_id,
parent_commission_id: commission.id,
amount: -commission.amount,
basis: commission.basis,
rate: commission.rate,
status: 'approved',
reason: 'refund_clawback',
});
break;
case 'paid':
case 'cleared':
// Money has already left the building. Don't silently claw back
// from the next payout — surface to the merchant for manual review.
await flagForReview(commission, { reason: 'refund_after_payout' });
break;
}Why a negative-amount sibling for approved
Voiding an approved commission would change the row. Inserting a negative-amount sibling preserves the original row exactly as it was when the affiliate saw it on their dashboard. The affiliate's pending balance still decreases (because the negative row sums into the same period), but the audit log is honest: “you earned $X on order N, then a refund landed and we docked $X back” — not “the row you saw never existed.”
The sibling row carries parent_commission_id pointing back to the original. So when an affiliate clicks into a commission detail page and sees-$25 staring at them, the UI can render “Clawback for order #1234 — refunded on 2026-04-12” instead of just an unexplained negative.
Why manual review for paid
The opposite end of the spectrum: a commission was paid out four weeks ago, the ACH cleared two weeks ago, and now the customer charges back. Silently deducting that amount from the affiliate's next payout is technically possible (Cobalz could net it against the next aggregation cycle) but it's rude and often surprises the affiliate.
Worse, in many programs there is no “next payout” for that affiliate — they were a one-shot promoter. There's nothing to net against. You're left chasing them for money, which is a relationship-ending move.
The right answer is to flag the situation, surface it in the merchant dashboard, and let a human decide: absorb the loss, request a refund from the affiliate, or net against future commissions. The platform makes the data visible; the merchant makes the policy call.
Partial refunds
The same tree handles partial refunds with one tweak: the negative sibling uses amount = commission.rate * refunded_subtotal instead of the full commission amount. The affiliate keeps the commission on the non-refunded portion of the order.
See it in the engine: /features/commissions