Skip to content
Revenue-critical code cover
writing

The refund path is where I read a codebase's soul

Checkout code is easy. Refunds, proration, and failed webhooks are where I can tell if a payments codebase was built by someone senior.

Hasan MisbahHasan Misbah9 min read
payments
refunds
laravel
code-review
webhooks
TL;DR

The refund path, not checkout, reveals whether a payments codebase was written by someone senior. The five tells: refunds as append-only events, integer line-item math, database-enforced idempotency, gateway-confirmed state, and entitlement that revokes on the confirmed refund.

When I audit a payments codebase, I skip the checkout flow and go straight to the refund path. Checkout is the demo. Every tutorial gets money in. The senior work shows up where money has to go back out: partial refunds, proration on a mid-cycle change, and the webhook that confirms none of it. Those branches are unglamorous, rarely demoed, and almost never covered by tests, which is exactly why they tell the truth about who wrote the code.

I have shipped this path more than once: on a WooCommerce marketplace, inside a WordPress subscription and licensing plugin, and around a Laravel mail service that had to bill for what it actually sent. The same tells appear every time. Here is how I read them.

Tell one: is a refund an event, or a mutation?

Open the refund handler and look at what it writes. If it does something like this, I already know the maturity level:

// The junior version
$order->status = 'refunded';
$order->save();

That flips a boolean and throws away the truth. A refund is not a state, it is an event with an amount, a reason, a gateway reference, and a time. The senior version appends to a ledger and never overwrites history:

$refund = Refund::create([
    'order_id'        => $order->id,
    'amount_cents'    => $amountCents,
    'currency'        => $order->currency,
    'gateway_ref'     => null,          // filled when the gateway confirms
    'idempotency_key' => $idempotencyKey,
    'reason'          => $reason,
    'status'          => 'pending',
    'created_at'      => now(),
]);

Once refunds are rows, the hard questions answer themselves. Total refunded is sum(amount_cents) scoped to the order, not a flag. A second partial refund is just another row. Reconciliation against the gateway becomes a join instead of a guess. The moment I see the order row being mutated in place, I know reporting is going to lie to this business by month three.

Tell two: partial refunds, and the off-by-a-cent that becomes a support ticket

The partial refund is where I find the rounding bugs. Say a customer bought three seats at a bundle price and wants one seat back. Junior code divides the order total by three. On a price that does not divide cleanly, you now refund a cent too much or too little, and across enough orders your gateway settlement stops matching your ledger.

The fix is to never derive a refund by dividing a total. Refund against recorded line items, in integer cents, and let the last unit absorb the remainder:

public function refundableCents(OrderItem $item, int $qty): int
{
    // unit_price_cents is stored per item at purchase time, in integer cents
    $alreadyRefunded = $item->refunds()->sum('quantity');
    $remaining = $item->quantity - $alreadyRefunded;

    if ($qty > $remaining) {
        throw new RefundExceedsRemainingException($item->id, $remaining);
    }

    return $item->unit_price_cents * $qty;
}

Notice the guard. If the requested quantity exceeds what is left to refund, it throws instead of quietly returning a negative or over-refunding. Code that lets you refund more than was paid is code that has never met a determined customer or a scripted retry.

The rule I hold is blunt: a refund path that can ever pay back more than was collected is a defect, not an edge case. I would rather throw loudly on an impossible quantity than let a fraction of a cent drift between my ledger and the gateway’s settlement, because that drift stays invisible until reconciliation and is miserable to unwind after the fact.

Tell three: idempotency, because the network will double-fire

The single clearest senior signal is an idempotency key on the refund. Refunds get triggered by humans clicking twice, by admin dashboards timing out and retrying, and by webhook redelivery. These are the same duplicate-delivery and retry problems that bite every payment integration at 2am. If the same logical refund can run twice, you will eventually pay a customer back twice, and clawing that money back is a worse conversation than the original complaint.

I make the key part of the request, not something generated inside the handler, and I enforce it at the database:

CREATE UNIQUE INDEX ux_refunds_idem
    ON refunds (order_id, idempotency_key);
try {
    $refund = $this->createRefund($order, $amountCents, $idempotencyKey);
} catch (UniqueConstraintViolationException) {
    // Already processed this exact refund. Return the existing one, do not create a second.
    return Refund::where('order_id', $order->id)
        ->where('idempotency_key', $idempotencyKey)
        ->firstOrFail();
}

The database is the referee. Application-level “check then insert” loses to a race under real concurrency, and refunds are exactly the place races bite, because retries cluster in time. If the codebase relies on a SELECT before the INSERT with no unique constraint behind it, I write that down as a defect, not a style preference.

Tell four: the local state is optimistic, the gateway is the truth

Here is the branch most codebases get wrong. You call the gateway, it returns 200, you mark the refund complete. But a refund at Stripe or PayPal or Paddle is not final on the API response, it is final when the gateway’s webhook says so. The API can accept a refund that later fails at the card network. If your local state jumped straight to completed, your ledger now disagrees with reality and nobody notices until settlement.

So the refund has two stages. The API call moves it to processing. The webhook moves it to completed or failed:

public function handleRefundWebhook(array $payload): void
{
    $refund = Refund::where('gateway_ref', $payload['refund_id'])->first();
    if (! $refund) {
        // Out-of-order delivery: the webhook beat our own record. Park it, do not drop it.
        return $this->parkForReplay($payload);
    }

    // Webhooks arrive out of order and more than once. Only move forward.
    if ($refund->status === 'completed') {
        return;
    }

    $refund->update([
        'status'       => $payload['status'] === 'succeeded' ? 'completed' : 'failed',
        'confirmed_at' => now(),
    ]);

    if ($refund->status === 'completed') {
        $this->revokeEntitlement($refund);
    }
}

Two details in there are the whole game. The early return when the webhook arrives before the local record exists (out-of-order delivery is normal, not exotic), and the guard against moving a completed refund again on redelivery. A codebase that treats webhooks as reliable, ordered, and once-only is a codebase that has not run in production long enough.

Tell five: does the refund touch entitlement?

This is where payments meets the rest of the product, and where I connect the refund path to the licensing and subscription work I do most. A refund that moves money but leaves the customer with a working license key, an active subscription, or a downloadable they already refunded is a leak. Revocation belongs on the confirmed refund, not the API call, because you do not want to lock someone out of a refund that later fails at the network and never actually settles.

So revokeEntitlement runs off the webhook, and it is careful about partial refunds. Refunding one of three seats should drop the seat count, not kill the whole license. If the refund handler cannot tell the difference between “gave back everything” and “gave back one unit,” entitlement will either over-punish paying customers or leak access to refunded ones. Both cost money, just on different lines of the spreadsheet.

Why this path, specifically

I do not read the refund path because refunds are common. On a healthy product they are not. I read it because it is the one flow where the code has to be correct while money moves the wrong direction, under retries, with an async source of truth, and with a customer who is already annoyed. Nobody polishes that branch to look good in a demo. That is precisely why it is honest.

The takeaway

The refund path is the one flow that has to be correct while money moves the wrong way, under retries, against an async source of truth, for a customer who is already unhappy. Nobody polishes it for a demo, so it reports the real maturity of the code: event over mutation, integer line items over division, a database-enforced idempotency key, gateway-confirmed state, and entitlement that revokes on the confirmed refund. Read those five tells and you know what a codebase actually is, not what it wants to be.

Show me your checkout and I learn what your product wants to be. Show me your refund path and I learn what it actually is.

FAQ

Why read the refund path instead of the checkout flow?

Checkout is the demo path every tutorial covers, so it says little about seniority. Refunds, proration, and webhook confirmation are rarely demoed and rarely tested, which is exactly why they reveal who wrote the code.

Should a refund be stored as a status change or a new record?

A new record. A refund is an event with an amount, a reason, a gateway reference, and a time, so appending a row keeps history intact, makes total refunded a sum, and turns reconciliation into a join instead of a guess.

How do I stop partial refunds from drifting by a cent?

Never derive a refund by dividing the order total. Refund against recorded line items in integer cents, let the last unit absorb any remainder, and guard so a request cannot exceed what is left to refund.

Where should the idempotency key be enforced?

At the database, with a unique index on the order and idempotency key. An application-level check-then-insert loses to the races that cluster around retries, and refunds are exactly where those races bite.

When is a refund actually final?

When the gateway’s webhook confirms it, not when the API returns 200. Move the refund to processing on the API call and to completed or failed on the webhook, and only revoke entitlement once it is confirmed.

Should refunding one of three seats revoke the whole license?

No. A partial refund should drop the affected quantity, not kill the entire entitlement, or you either over-punish paying customers or leak access to refunded ones.

let's talk

Building something where this kind of work matters?

I'm in GMT+6 and work async-first, so your timezone is never a reason not to reach out.

Get in touch
Copyright © Hasan Misbah. All rights reserved.
Hasan Misbah