Skip to content

Marketplace Returns & Refunds

Purpose

A customer-initiated return workflow that runs after delivery: request → seller approval → reverse pickup → receipt → inspection/QC → refund, with its own accounting trail (RefundTransaction) and settlement integration (SettlementAdjustment) that never rewrites history. No real Razorpay/bank refund is ever executed, and no real reverse-courier vendor is ever called anywhere in this codebase. Builds on Seller Order Fulfilment, Payments & Seller Settlements, and Marketplace Logistics.

Return flow

Customer Return Request (item/quantity/reason, per SellerOrderLine)
  |
  v
Requested -----> Rejected (releases consumed return quantity back)
  |
  v
Approved (seller picks a warehouse, ReturnShipment booked)
  |
  v
InTransit  (synced from ReturnShipment reaching PickedUp)
  |
  v
Received   (synced from ReturnShipment reaching Received - INVENTORY STILL UNCHANGED)
  |
  v
Inspection/QC (restock + damaged quantities, one atomic transaction with the refund)
  |
  v
RefundRecorded

ReturnRequest / ReturnRequestItem

ReturnRequest (ECommerce.Domain/Entities/ReturnRequest.cs) has no public mutable setters - every field changes only through a named, domain-guarded method, mirroring Shipment's pattern. Its status enum (Requested → Rejected / Approved → InTransit → Received → RefundRecorded) is deliberately coarser than ReturnShipment's own fine-grained pickup lifecycle - there is no persisted "Inspected"-only resting state, because completing inspection and recording the refund happen as one atomic transactional operation (see below), not two separate stops.

Every authoritative id is resolved server-side, never trusted from the client:

  • CustomerId - from the authenticated caller, cross-checked against Order.CustomerId.
  • SellerUserId - from the SellerOrder being returned against.
  • ReturnRequestItem.SellerInventoryId / UnitPrice - snapshotted from the matching SellerOrderLine at request time, never accepted from the client.

Ownership/security

  • Customer: may only return their own Order; the given SellerOrderId must belong to that Order, and every selected SellerOrderLineId must belong to that SellerOrder.
  • Seller: may only see/mutate returns for their own SellerUserId - enforced by ReturnAccess.LoadOwnedBySellerAsync in ReturnFeatures.cs.
  • Shared read access: GET /api/returns/{id} allows either the filing customer or the owning seller (or Admin) - the only resource in this codebase with two legitimate non-Admin viewers.

Partial / multiple returns and the return-window

Marketplace:Returns:WindowDays fails closed (RETURN_POLICY_NOT_CONFIGURED) if missing or non-positive - the same discipline as SettlementConfig.ReadCommissionRatePercent. Eligibility is measured from SellerOrder.DeliveredAt, never order creation or shipping time.

Partial and multiple partial returns against the same SellerOrderLine are fully supported; cumulative returned quantity must never exceed purchased quantity. This is enforced by SellerOrderLine.ReturnedQuantity, a server-owned counter mutated exclusively via a single atomic conditional SQL UPDATE ... WHERE ReturnedQuantity + @qty <= Quantity (ReturnsRepository.TryReserveReturnQuantityAsync) - not by loading the entity, checking in application memory, and saving later, which two concurrent requests could both pass against the same stale read. This is what makes the "never exceeds purchased quantity" rule safe under concurrent requests, not just sequential ones, and is portable across both the MySQL production provider and the SQLite test provider (a real SELECT ... FOR UPDATE row lock is not, since SQLite has no such syntax).

A Requested (not yet approved) return already consumes its return-quantity reservation - a Rejected return is the only status that releases it back (IReturnsRepository.ReleaseReturnQuantityAsync).

Reverse logistics

ReturnShipment / ReturnShipmentTrackingEvent (ECommerce.Domain/Entities/ReturnShipment.cs) are a separate aggregate from the outbound Shipment - a return is never recorded against the original outbound shipment. Pickup and delivery are reversed from outbound: pickup is the customer's own delivery address (from Order.ShippingAddress), delivery is the seller's chosen warehouse. Tracking events are append-only, exactly like ShipmentTrackingEvent.

Reverse logistics provider abstraction

IReturnLogisticsProvider mirrors ILogisticsProvider exactly - ManualReturnLogisticsProvider (real day-one flow, no real courier called) and MockReturnLogisticsProvider (deterministic, tests only). No real reverse-courier vendor (Shiprocket/Delhivery/Blue Dart/DTDC/India Post) integration exists anywhere in this codebase.

Inventory invariant

Returns must never modify ReservedQuantity. SellerInventory.ReceiveReturnedStock(int restockQuantity, int damagedQuantity) is the only inventory-side effect a completed return may have:

AvailableQuantity += restockQuantity
DamagedQuantity   += damagedQuantity
ReservedQuantity   unchanged (not a parameter of this method at all)

Deliberately not Reserve/Release/CommitReservation (checkout/payment-pipeline only) and not UpdateStock (the seller's own absolute-correction tool).

Inventory timing

Inventory remains unchanged through Requested, Approved, every ReturnShipment pickup sub-state, and even Received - only a completed Inspection/QC changes it. This is verified by ReturnsFlowTests.Inventory_UnchangedThroughApprovePickupInTransitReceived.

Inspection/QC

For every returned line the seller records RestockQuantity + DamagedQuantity, and RestockQuantity + DamagedQuantity must equal RequestedQuantity exactly (RETURN_INSPECTION_MISMATCH otherwise). Both dispositions are refundable in this slice - disposition only decides inventory destination, never the refund amount. The seller never enters a refund amount anywhere in this UI or API - every financial figure is server-computed.

Atomic inspection/refund transaction

InspectReturnCommandHandler performs the whole operation - domain validation, inventory update, refund calculation, RefundTransaction creation, PaymentTransaction/Order payment-state sync, and (when applicable) SettlementAdjustment creation - inside one database transaction. Any failure rolls back everything, including the inventory update; inventory is never left restored without its corresponding accounting record.

RefundTransaction

A durable, immutable internal accounting/audit record - one per completed ReturnRequest (REFUND_ALREADY_EXISTS on a second attempt, enforced by a DB unique index on ReturnRequestId, mirroring SellerSettlement.SellerOrderId's own discipline). No real payment-provider refund call happens anywhere in this codebase, and no Razorpay refund id is ever invented.

Snapshot-based refund calculation

Refunds are computed only from order-time snapshots (ReturnRequestItem.UnitPrice, SellerOrder.TaxAmount/SubTotal, Order.ShippingCost) - never today's SellerProduct.Price, DiscountPrice, catalog price, or tax configuration.

  • Merchandise: exact sum of UnitPrice × returned quantity per line - never itself subject to rounding drift, since both factors are already clean values.
  • Tax: cumulative/telescoping allocation - each return computes Round(SellerOrder.TaxAmount × cumulativeMerchandiseRefunded / SellerOrder.SubTotal) and subtracts what was already refunded by prior returns for the same SellerOrder. This telescoping pattern (running total, then subtract the previous running total) guarantees that when 100% of a SellerOrder's merchandise is eventually refunded, cumulative tax refunded equals the original SellerOrder.TaxAmount exactly, regardless of how many partial returns came before or how each one individually rounded.
  • Safety clamp: Order/SellerOrder.TaxAmount are computed at checkout without rounding to 2dp internally (pre-existing, out of this slice's scope - see CheckoutPricing/Order.Create). An atypical price/quantity combination can therefore leave a sub-paisa gap between this slice's own 2dp-rounded refund components and the unrounded original PaymentTransaction.Amount. Over- refund prevention is the more fundamental invariant, so the tax portion (never merchandise or shipping, which are exact by construction) absorbs at most that sub-paisa remainder, truncated down rather than rounded, so it can never push a refund past what is actually left to refund.

All amounts use decimal, 2dp, MidpointRounding.AwayFromZero.

PaymentTransaction refund state

PaymentTransaction.Amount (the original charge) is immutable forever. ApplyRefund(decimal amount) adds one increment to RefundedAmount and derives Status:

RefundedAmount == 0            -> Verified
0 < RefundedAmount < Amount    -> PartiallyRefunded
RefundedAmount == Amount       -> Refunded

Over-refunding is structurally impossible - RefundedAmount can never exceed Amount (REFUND_EXCEEDS_PAYMENT otherwise).

Order payment state

Order.ApplyPaymentRefundStatus(bool isFullyRefunded) synchronizes PaymentTransaction.Status into Order.PaymentStatus/Order.Status. Order.Status only becomes Refunded once the full original payment has actually been accounted as refunded - a partial return never marks the whole Order Refunded. This never rewrites SellerOrder fulfilment state: a Delivered SellerOrder remains Delivered forever; returns have their own, entirely separate state machine. Order.RecalculateStatusFromSellerOrders (from Seller Order Fulfilment) now also treats Refunded as a terminal state it never overwrites.

Seller settlements

Historical settlement snapshots are never mutated after creation - especially SettlementStatus.Settled, which remains permanently immutable, exactly as documented in Payments & Seller Settlements.

Refund before settlement generation (commission avoidance, not reversal)

If a return/refund completes before a SellerSettlement is ever generated for that SellerOrder, GenerateSettlementCommandHandler computes the settlement's MerchandiseAmount/ TaxAmount from the remaining (already-reduced) financial value:

merchandiseAmount = SellerOrder.SubTotal - Σ(prior RefundTransaction.MerchandiseRefundAmount)
taxAmount         = SellerOrder.TaxAmount - Σ(prior RefundTransaction.TaxRefundAmount)

Commission is calculated only on the reduced figure - no SettlementAdjustment is ever created for a pre-settlement refund, since there is nothing to reverse yet.

Refund after settlement snapshot exists (SettlementAdjustment)

If a SellerSettlement already exists, InspectReturnCommandHandler creates a SettlementAdjustment instead - the settlement itself is never touched. Breakdown:

NetSellerDebitAmount = RefundMerchandiseAmount + RefundTaxAmount - CommissionReversalAmount

CommissionReversalAmount applies to merchandise only, and uses the same cumulative/telescoping technique as tax - but against the settlement's own snapshotted MerchandiseAmount/PlatformCommissionAmount/CommissionRatePercent, never current configuration, and tracked via prior SettlementAdjustment rows (not raw RefundTransaction rows, since some of those may be pre-settlement and already excluded from the settlement's committed base). When a settlement's committed merchandise has been completely refunded via post-settlement returns, the sum of every CommissionReversalAmount created for it equals the original SellerSettlement.PlatformCommissionAmount exactly.

No real money is ever taken back from the seller by this codebase - a SettlementAdjustment is audit/accounting data describing an outstanding future debit for manual/offline reconciliation.

Shipping refunds

Marketplace:Returns:RefundOrderShippingOnFullReturn (default false). When disabled, ShippingRefundAmount is always 0. When explicitly enabled, the parent Order.ShippingCost is refunded exactly once, and only once every SellerOrder under that Order has become fully merchandise-refunded (checked across all sibling seller-orders, not just the one being inspected). Customer-facing shipping is never allocated as a seller settlement debit - the current marketplace financial model never assigned that parent-level charge to any individual seller in the first place (see Marketplace Logistics → Shipping cost model, which is a distinct, seller-scoped concept: the seller's own logistics cost, not the customer's parent-order shipping charge).

Error codes

Code HTTP Meaning
RETURN_NOT_FOUND 404 No such return request
RETURN_FORBIDDEN 403 Not this customer's/seller's return, and caller isn't Admin
RETURN_POLICY_NOT_CONFIGURED 409 Missing/invalid Marketplace:Returns:WindowDays
RETURN_WINDOW_EXPIRED 409 Past DeliveredAt + WindowDays
RETURN_ORDER_NOT_DELIVERED 409 SellerOrder is not yet Delivered
RETURN_INVALID_QUANTITY 400 Zero/negative quantity, or a line that doesn't belong to the seller order
RETURN_QUANTITY_EXCEEDED 409 Would exceed the line's remaining returnable quantity
RETURN_INVENTORY_NOT_MAPPED 409 Line has no SellerInventoryId (legacy, pre-marketplace line)
RETURN_INVALID_TRANSITION 409 Wrong current status for the action requested
RETURN_INSPECTION_MISMATCH 400 QC quantities don't sum to the requested quantity
RETURN_SHIPMENT_NOT_FOUND 404 No ReturnShipment exists yet for this request
RETURN_SHIPMENT_CONFLICT 409 A ReturnShipment already exists (double-approve race)
REFUND_ALREADY_EXISTS 409 A refund was already recorded for this return
REFUND_EXCEEDS_PAYMENT 409 Would push RefundedAmount above Amount
REFUND_RECONCILIATION_FAILED 409 No verified PaymentTransaction exists for this order
SETTLEMENT_ADJUSTMENT_ALREADY_EXISTS 409 An adjustment was already recorded for this return
SETTLEMENT_NOTHING_PAYABLE 409 Defensive guard - a computed adjustment with nothing to record

Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.

Frontend

  • Customer: /returns (list) and /returns/:id (status timeline, reverse tracking, refund breakdown). "Request a Return" is launched from the order-detail page's per-seller-order card once Delivered, showing server-computed eligibility and remaining returnable quantity per line.
  • Seller: /seller/returns and /seller/returns/:id - context-sensitive actions (approve/reject/schedule pickup/picked-up/in-transit/receive/inspect) and the inspection/QC form (restock + damaged counts only - never a refund amount field).
  • Admin: /admin/returns, /admin/returns/:id (read-only, includes refund and settlement- adjustment visibility inline) and /admin/refunds (global refund ledger).
  • Every refund display states plainly: "Refund recorded in AkshayaBazaar. No automated Razorpay/bank refund is executed in this environment." Adjustments against settled records add: "Historical settlement remains unchanged. This adjustment represents an outstanding future seller debit."

Deferred work (explicitly out of scope for this slice)

  • Real Razorpay/bank refund - no payment-provider refund API call exists anywhere.
  • Real reverse-courier vendor integration - no Shiprocket/Delhivery/Blue Dart/DTDC/India Post API call exists anywhere; only the vendor-neutral IReturnLogisticsProvider abstraction.
  • Return-shipment cancellation / re-shipment - unlike outbound Shipment, this slice does not support cancelling an approved ReturnShipment and creating a replacement (ReturnShipmentNumber is unique per ReturnRequestId).
  • Customer-initiated return cancellation - once Requested, a customer cannot withdraw their own return request in this slice (the seller can Reject it, which releases the reserved quantity).
  • Real seller payout debit - SettlementAdjustment.NetSellerDebitAmount is audit data only; no automated deduction from a future payout occurs.