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 againstOrder.CustomerId.SellerUserId- from theSellerOrderbeing returned against.ReturnRequestItem.SellerInventoryId/UnitPrice- snapshotted from the matchingSellerOrderLineat request time, never accepted from the client.
Ownership/security¶
- Customer: may only return their own
Order; the givenSellerOrderIdmust belong to thatOrder, and every selectedSellerOrderLineIdmust belong to thatSellerOrder. - Seller: may only see/mutate returns for their own
SellerUserId- enforced byReturnAccess.LoadOwnedBySellerAsyncinReturnFeatures.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 quantityper 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 sameSellerOrder. This telescoping pattern (running total, then subtract the previous running total) guarantees that when 100% of aSellerOrder's merchandise is eventually refunded, cumulative tax refunded equals the originalSellerOrder.TaxAmountexactly, regardless of how many partial returns came before or how each one individually rounded. - Safety clamp:
Order/SellerOrder.TaxAmountare computed at checkout without rounding to 2dp internally (pre-existing, out of this slice's scope - seeCheckoutPricing/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 originalPaymentTransaction.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:
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 onceDelivered, showing server-computed eligibility and remaining returnable quantity per line. - Seller:
/seller/returnsand/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
IReturnLogisticsProviderabstraction. - Return-shipment cancellation / re-shipment - unlike outbound
Shipment, this slice does not support cancelling an approvedReturnShipmentand creating a replacement (ReturnShipmentNumberis unique perReturnRequestId). - Customer-initiated return cancellation - once
Requested, a customer cannot withdraw their own return request in this slice (the seller canRejectit, which releases the reserved quantity). - Real seller payout debit -
SettlementAdjustment.NetSellerDebitAmountis audit data only; no automated deduction from a future payout occurs.
Related pages¶
- Seller Order Fulfilment - the
SellerOrder/Orderstate this slice never rewrites (except the terminalPaymentStatus/Statussync) - Payments & Seller Settlements - the settlement snapshot this slice never mutates, and the pre/post-settlement refund integration
- Marketplace Logistics - the outbound
Shipmentthis slice's reverseReturnShipmentdeliberately never reuses, and the provider-abstraction pattern it mirrors - Marketplace Catalog Model -
SellerInventory.ReceiveReturnedStock - Application Architecture
- Marketplace Reporting & Dashboards - reads
RefundTransaction.TotalRefundAmountas the sole source of reported refund totals - Marketplace Notifications - the seller "return requested" and customer Approved/Rejected/Received/RefundRecorded notifications this workflow triggers, and the refund-wording disclaimer they share