DEV Community

Iqbal120708
Iqbal120708

Posted on

Behind RefundRequest: Designing Item-Level Refunds for a Single-Vendor E-commerce App

Introduction

When I built the refund feature for this single-vendor e-commerce project, I ran into a problem that looked simple on the surface but turned out to have a fairly deep root cause: if an order contained more than one product, and a customer only wanted to refund one of them, the system I'd originally designed would end up refunding every other product in that same order too.

The reason was straightforward — refund status originally lived only at the Order level, not per product. So the moment an order's status changed to "refunded," every item inside it got dragged along, even though the customer had only asked to refund one item.

This article walks through how I got to the final design: why RefundRequest became a separate model pointing to OrderItem (rather than just a status field on Order), why refunds are sent manually to the customer's bank account/e-wallet instead of automatically through the Midtrans Refund API, how I handled a race condition between a webhook and an admin action that can realistically happen almost simultaneously, and why the actual refund execution step (complete()) has to pass 4 guards before it runs.

Some approaches I tried and then rejected myself are included here too — I think that's actually the most honest way to show how the thinking happened, not just the end result.

Why RefundRequest Is a FK to OrderItem, Not a Status on Order

My first attempt at fixing this problem was the solution that looked "correct" on paper: move all refund/cancel status from Order to OrderItem. If the status lived at the item level, the "refunding 1 product cascades into refunding all of them" problem would just disappear.

But once I actually started, this solution meant a large-scale refactor — methods in the Midtrans service, the Order model, and the serializer would all need to change to follow the status move to OrderItem. Meanwhile, Order.Status.CANCELED and Order.PaymentStatus.REFUNDED weren't actually being used anywhere yet at that point. I decided not to move the entire status set — instead, I dropped those two fields and created a new model, RefundRequest, to handle the status on its own. On OrderItem, I added two properties, is_refunded and has_active_refund, to read refund state without needing an extra status field on Order.

The same pattern showed up again when I considered Order.reduced_stock. I initially planned to move this field to OrderItem too, along with a rewrite of reduce_product_stock/restore_product_stock and their tests. I dropped it for the same reason: the migration was too large, and the RefundRequest.status I'd just built was already enough of a guard. reduced_stock stayed a one-time boolean on Order, never toggled back by a refund or cancel. I accepted the trade-off consciously: once a partial refund happens, this field no longer means "the order's entire stock is still fully held" — it's only valid as a guard in three specific places (reduce_stock(), reverse_stock(), release_reservation()), and shouldn't be read as an accurate stock indicator anywhere else.

The final design for the refund model itself: RefundRequest with a ForeignKey to OrderItem — not a OneToOneField. The reason for a plain FK: it preserves history if a customer requests a refund, gets rejected, then requests again for the same item.

(Snippet simplified to focus on what's discussed here — other fields like amount, status, and the destination account are covered in the next section.)

class RefundRequest(BaseModel):
    class Reason(models.TextChoices):
        CUSTOMER_CANCEL = "customer_cancel", "Cancelled by Customer"
        OUT_OF_STOCK = "out_of_stock", "Out of Stock"
        RETURN = "return", "Post-Shipment Return"
        OTHER = "other", "Other"

    order_item = models.ForeignKey(
        OrderItem,
        on_delete=models.PROTECT,
        related_name="refund_requests",
    )

    reason = models.CharField(
        max_length=30,
        choices=Reason.choices,
        help_text=(
            "Auto-set to CUSTOMER_CANCEL/RETURN when the customer submits it. "
            "Admin can edit manually (e.g. to OUT_OF_STOCK) before status is COMPLETED."
        ),
    )

    @property
    def order(self):
        return self.order_item.order
Enter fullscreen mode Exit fullscreen mode

The "only 1 active request per item" rule isn't enforced through a database constraint — a constraint would need to account for status, which can change (REQUESTED/APPROVED are active, REJECTED/COMPLETED are not). I put it at the serializer level instead:

def validate_order_item(self, order_item):
    request = self.context["request"]
    if order_item.order.user_id != request.user.id:
        raise serializers.ValidationError("This item doesn't belong to your order.")
    if order_item.has_active_refund:
        raise serializers.ValidationError("There's already an active refund request for this item.")
    return order_item
Enter fullscreen mode Exit fullscreen mode

There's one small detail worth pointing out from the model above. I initially added order as a direct ForeignKey for faster queries, then removed it — order is already reachable through order_item.order, and Django doesn't guarantee those two fields would stay consistent with each other. I replaced it with @property order, so there's only one source of truth.

The more important decision here: I merged order cancellation and refund into the same model, distinguished by the reason field above (CUSTOMER_CANCEL vs RETURN vs others) rather than a separate model or flow. I initially assumed cancellation needed its own flow (its own canceled_at, and so on). But on reflection, cancellation is really just a special case of refund — the amount is always the full price, and it happens before the item ships. This is the part I think matters most here: recognizing that two things that look different are actually the same domain, before building two separate systems for the same thing.

Why Refunds Are Sent Manually, Not Automatically Through the Midtrans API

The most basic reason refunds in this system aren't automated through the Midtrans Refund API: not every payment method this project uses supports it. bank_transfer, cstore, and echannel have no automatic refund path in Midtrans at all — if I forced an API refund integration, some payment methods would still need to be handled manually anyway, meaning two different paths for the same problem. I chose one manual path for every method instead, for consistency.

As a consequence, I needed somewhere to store the refund destination. My first plan was just bank_name/account_number — a quiet assumption that every customer has a bank account. That assumption was wrong; a lot of customers only use e-wallets. I revised it to be generic:

class DestinationType(models.TextChoices):
    BANK = "bank", "Bank Transfer"
    EWALLET = "ewallet", "E-Wallet"

class Provider(models.TextChoices):
    BCA = "bca", "BCA"
    MANDIRI = "mandiri", "Mandiri"
    BNI = "bni", "BNI"
    BRI = "bri", "BRI"
    PERMATA = "permata", "Permata"
    CIMB = "cimb", "CIMB Niaga"
    GOPAY = "gopay", "GoPay"
    SHOPEEPAY = "shopeepay", "ShopeePay"
    DANA = "dana", "DANA"
    OVO = "ovo", "OVO"

BANK_PROVIDERS = {"bca", "mandiri", "bni", "bri", "permata", "cimb"}
EWALLET_PROVIDERS = {"gopay", "shopeepay", "dana", "ovo"}
Enter fullscreen mode Exit fullscreen mode

destination_provider uses choices instead of free text — so the customer picks from a list rather than typing it manually, which is prone to typos. Rendering the dropdown/search is the frontend's job; the backend just exposes the valid options.

One validation I put explicitly in the serializer: destination_provider has to be consistent with destination_type — bank providers can only pair with BANK, e-wallet providers can only pair with EWALLET. Without this check, inconsistent data (say, type=BANK with provider=GOPAY) could slip into the database and only get caught when an admin tries to actually send the transfer.

account_holder_name is stored separately too — not for automated validation, but so an admin can match the recorded name against the name shown in their transfer app before actually sending money, to prevent transferring to the wrong person.

One thing I decided deliberately: refunds in this system always go to a manual bank account/e-wallet, never automatically back to the original payment method (unlike large marketplaces that can refund straight to their own internal balance/wallet). Two reasons: this system has no internal balance/wallet, and the Midtrans Refund API — which does support automatic refunds for gopay/shopeepay/qris — was deliberately left unintegrated for now. Same principle as the previous section: don't add complexity for a case that isn't clearly needed at this project's scale.

Race Condition: How the Webhook Path and the Admin Path Stay in Sync

There's a real scenario I had to handle: a Midtrans reversal webhook (a payment that was PAID flipping to FAILED) and an admin completing a refund can happen almost at the same time on the same order — a realistic 1-5 minute window. If both paths read and write stock data without knowing about each other, reserved_stock/stock could get adjusted twice for the same item.

These two paths don't share one single function outright — they stay in sync through row-level locks on the database rows they both touch.

The webhook path (reverse_stock() case) uses the get_unrefunded_items() helper to exclude items that already have a COMPLETED RefundRequest:

def get_unrefunded_items(order):
    refunded_item_ids = RefundRequest.objects.filter(
        order_item__order=order,
        status=RefundRequest.Status.COMPLETED,
    ).values_list("order_item_id", flat=True)
    return order.items.select_for_update().exclude(id__in=refunded_item_ids)
Enter fullscreen mode Exit fullscreen mode

reverse_stock() — called when a previously successful payment gets reversed by Midtrans (paid → failed), and only runs if reduced_stock=True:

def reverse_stock(self):
    if not self.order.reduced_stock:
        return

    if self.order.status in [Order.Status.SHIPPED, Order.Status.DELIVERED]:
        logger_error.critical(
            "Payment reversal but order already shipped/delivered - "
            "NOT auto-restocking, needs manual review",
            extra={
                "event_type": "transaction",
                "order_id": self.order.order_id,
                "order_status": self.order.status,
            },
        )
        return

    try:
        items_to_restore = get_unrefunded_items(self.order)
        restore_product_stock(items_to_restore)
        self.order.reduced_stock = False
        self.order.save(update_fields=["reduced_stock"])
        logger.warning(
            "Stock restored due to payment reversal (paid -> failed)",
            extra={"event_type": "transaction", "order_id": self.order.order_id},
        )
    except Exception:
        logger_error.exception(
            "Failed to restore stock after reversal",
            extra={"event_type": "transaction", "order_id": self.order.order_id},
        )
        raise
Enter fullscreen mode Exit fullscreen mode

The admin path, complete(), doesn't call get_unrefunded_items() at all. It operates on a single item, locking OrderItem, Order, and Product directly:

def complete(self):
    refund_request = self.refund_request

    with transaction.atomic():
        order_item = OrderItem.objects.select_for_update().get(
            pk=refund_request.order_item_id
        )
        order = Order.objects.select_for_update().get(pk=order_item.order_id)

        if refund_request.status != RefundRequest.Status.APPROVED:
            raise ValidationError(
                "Only requests with status APPROVED can be completed."
            )

        valid_statuses = [
            Order.Status.PENDING, Order.Status.PROCESSING,
            Order.Status.SHIPPED, Order.Status.DELIVERED,
        ]
        if order.status not in valid_statuses:
            raise ValidationError(
                f"Order status is {order.status}, refund cancelled."
            )

        if order.payment_status == Order.PaymentStatus.FAILED:
            raise ValidationError(
                "Payment failed, the item was never shipped -- "
                "there's nothing to refund."
            )

        if order.payment_status == Order.PaymentStatus.PAID and not order.reduced_stock:
            logger_error.critical(
                "Refund blocked - order is PAID but reduced_stock is False, needs manual review",
                extra={
                    "event_type": "refund",
                    "order_id": order.order_id,
                    "refund_request_id": refund_request.id,
                    "payment_status": order.payment_status,
                    "reduced_stock": order.reduced_stock,
                },
            )
            send_refund_anomaly_email.delay(order.id, refund_request.id)
            raise ValidationError(
                "Order is in an anomalous state (paid but stock not yet processed) -- "
                "needs admin review before the refund can proceed."
            )

        refund_request.status = RefundRequest.Status.COMPLETED
        refund_request.completed_at = timezone.now()
        refund_request.save(update_fields=["status", "completed_at"])

        product = Product.objects.select_for_update().get(pk=order_item.product_id)
        if order.reduced_stock:
            product.stock += order_item.qty
        else:
            product.reserved_stock -= order_item.qty
        product.save()

    send_refund_status_email.delay(refund_request.id)
Enter fullscreen mode Exit fullscreen mode

Locking OrderItem in complete() looks odd at first — no OrderItem field is ever written inside this function. But its purpose isn't to protect OrderItem's own data — it's a synchronization point with get_unrefunded_items(), which also locks the same OrderItem rows.

Concretely, here's the sequence this prevents. Order X has 2 items, A and B, with reduced_stock=True. An admin just approved a refund for item A. Almost simultaneously, Midtrans sends a reversal webhook for the same order X.

  1. complete() locks the OrderItem A row first (select_for_update()) and starts processing.
  2. reverse_stock() runs almost at the same time, calling get_unrefunded_items(). Notice the order inside this helper: the RefundRequest.objects.filter(...) line has no lock, and Django evaluates that query lazily — it's only actually executed against the database when .exclude() on the next line gets evaluated. That .exclude() line is the one that needs select_for_update() on OrderItem, and since item A is already locked by complete(), the entire query — including the RefundRequest query that only runs later — gets held up too.
  3. complete() finishes all its guards, saves refund_request.status = COMPLETED, adds to product.stock for item A, then commits — releasing the lock on item A.
  4. get_unrefunded_items() can now proceed, acquires the lock, and its RefundRequest query finally executes — reading the now-updated data: RefundRequest for item A is COMPLETED, so get_unrefunded_items() automatically excludes it from the result.
  5. restore_product_stock only runs for item B. Item A doesn't get its stock added back a second time.

If complete() didn't also lock OrderItem, step 2 wouldn't wait — get_unrefunded_items() could read stale data (status still APPROVED, not yet COMPLETED) before complete() finishes, and would still process a restore for item A even though it's being refunded in another transaction. The lock isn't about protecting a field that changes — it's about forcing the second transaction to delay reading its data until the first, overlapping transaction is done.

release_reservation() uses the same get_unrefunded_items(), so it gets the same lock protection against complete() — but it handles a different status transition (pending → failed for abandoned checkouts, not a reversal of a successful payment). The function itself doesn't re-check whether it's actually safe to run — it relies on its caller (the view receiving the webhook) to confirm this is genuinely a new transition, not a reversal or a duplicate webhook, before calling it. I won't go further into this here since it doesn't add anything to the race condition argument above.

4 Guards Before a Refund Actually Executes

The complete() code already appeared in full in the previous section to explain the locking — this section focuses on the 4 guards inside it, why each one exists, and why they're ordered the way they are.

Guard 1 — status must be APPROVED

if refund_request.status != RefundRequest.Status.APPROVED:
    raise ValidationError(
        "Only requests with status APPROVED can be completed."
    )
Enter fullscreen mode Exit fullscreen mode

This prevents double execution. approve()/reject() only change status and a timestamp — they never touch stock at all. complete() is the only place that actually executes the stock restore. Without this guard, calling complete() twice for the same request (say, from a retry or a double-click in the admin) could add stock back twice for the same refund.

Guard 2 — order status must be one of the valid statuses

valid_statuses = [
    Order.Status.PENDING, Order.Status.PROCESSING,
    Order.Status.SHIPPED, Order.Status.DELIVERED,
]
if order.status not in valid_statuses:
    raise ValidationError(
        f"Order status is {order.status}, refund cancelled."
    )
Enter fullscreen mode Exit fullscreen mode

This is a general fence — an order whose status falls outside this list (for example, already CANCELLED through another path) shouldn't have its refund processed further.

Guard 3 — payment must not be FAILED

if order.payment_status == Order.PaymentStatus.FAILED:
    raise ValidationError(
        "Payment failed, the item was never shipped -- "
        "there's nothing to refund."
    )
Enter fullscreen mode Exit fullscreen mode

If the payment failed, there's no money to give back — the item was never shipped in the first place. This guard prevents a refund from being processed for a transaction that logically never happened.

Guard 4 — the anomalous combination: PAID but stock never processed

if order.payment_status == Order.PaymentStatus.PAID and not order.reduced_stock:
    logger_error.critical(
        "Refund blocked - order is PAID but reduced_stock is False, needs manual review",
        extra={
            "event_type": "refund",
            "order_id": order.order_id,
            "refund_request_id": refund_request.id,
            "payment_status": order.payment_status,
            "reduced_stock": order.reduced_stock,
        },
    )
    send_refund_anomaly_email.delay(order.id, refund_request.id)
    raise ValidationError(
        "Order is in an anomalous state (paid but stock not yet processed) -- "
        "needs admin review before the refund can proceed."
    )
Enter fullscreen mode Exit fullscreen mode

This guard is different from the previous three — it's not checking "is this allowed," it's checking for a state combination that shouldn't be possible if everything upstream worked correctly: the order was paid, but its stock was never reduced. If this happens, some other step in the system got skipped.

The response also differs from the first three guards. Those three just raise ValidationError — rejected, done, the admin sees the error message and knows why. This guard adds two more things: logger_error.critical (not a warning or info level) and send_refund_anomaly_email.delay(...), which emails the store. The reasoning: an anomalous state like this isn't enough to just sit in a log that no one may ever open. The admin needs to know actively, not just have it recorded passively, because this signals a bug or some other failed process somewhere unexpected — not just "the user entered something wrong," like the previous three guards.

The order of these four guards isn't arbitrary either — from the cheapest to check (a single status field) to the most expensive in meaning (a system anomaly that needs an active notification). The cheapest guard runs first so common cases (a retry, an order that isn't eligible yet) fail fast without triggering the critical log and email meant only for genuinely abnormal cases.

Closing Thoughts

Looking back, almost every major decision in this feature follows the same pattern: start with a solution that looks more "complete" on paper — a full status migration to the item level, separate destination fields per bank, an automatic refund API integration — then walk it back once it becomes clear the cost of that change doesn't match the actual problem at hand. RefundRequest as a new model, reduced_stock staying on Order, manual refunds to a bank account/e-wallet — all of these are smaller versions of the original plan, not the original plan itself.

The principle I kept coming back to: only add complexity when there's actual evidence it's needed. Partial-quantity refunds, a Midtrans Refund API integration, a separate Django app for refunds — I rejected all of these, not because they could never be useful, but because there was no evidence they were needed at this single-vendor scale yet. If that need shows up later, having RefundRequest as its own model makes that change easier to make without tearing apart Order.

The full code is on GitHub. If you have questions, or think part of this design could've been approached differently, I'd be glad to discuss it in the comments.

Top comments (0)