Order Refunds
The refund system handles processing refunds for orders, including full and partial refunds, payment gateway integration, and refund status tracking.
Refund Overview
Refund States
Refund Status
enum RefundStatus {
PENDING = "pending", // Refund created, processing
COMPLETED = "completed", // Refund processed successfully
FAILED = "failed", // Refund failed
}
Refund Data Structure
Refund Entity
interface Refund {
id: string;
orderId: string;
amount: number; // Refund amount in rupees
reason: string; // Refund reason
status: RefundStatus;
providerRefundId?: string; // Payment gateway refund ID
processedAt?: Date; // When refund was processed
createdAt: Date;
updatedAt: Date;
}
Creating Refunds
Refund Creation
async create(
orderId: string,
amount: number,
reason: string,
): Promise<Refund> {
// Validate refund amount
if (amount <= 0) {
throw new BadRequestException("Refund amount must be greater than 0");
}
if (amount < MIN_REFUND_AMOUNT_INR) {
throw new BadRequestException(
`Refund amount must be at least ${MIN_REFUND_AMOUNT_INR} INR`
);
}
// Get order
const order = await this.getOrder(orderId);
// Calculate refundable amount
const refundableAmount = this.calculateRefundableAmount(order, amount);
// Validate amount doesn't exceed refundable
if (amount > refundableAmount) {
throw new BadRequestException(
`Refund amount exceeds refundable amount: ${refundableAmount}`
);
}
// Create refund record
const [refund] = await db
.insert(refunds)
.values({
orderId,
amount,
reason,
status: RefundStatus.PENDING,
})
.returning();
// Process refund via payment gateway
await this.processRefund(refund);
return refund;
}
Refundable Amount Calculation
Refundable Amount Logic
function calculateRefundableAmount(
order: Order,
requestedAmount: number,
): number {
// Get existing refunds
const existingRefunds = await this.getRefundsByOrderId(order.id);
const totalRefunded = existingRefunds.reduce(
(sum, refund) => sum + refund.amount,
0,
);
// Payment fees are typically NOT refunded by payment gateways
// Full refund: refund entire order total (includes fee)
// Partial refund: exclude payment fee from refundable amount
const paymentFeeInRupees = (order.paymentFee || 0) / 100;
const isFullRefund = requestedAmount >= order.total - totalRefunded;
const refundableAmount = isFullRefund
? order.total // Full refund includes fee
: order.total - paymentFeeInRupees; // Partial refund excludes fee
const maxRefundable = refundableAmount * MAX_REFUND_AMOUNT_MULTIPLIER;
const remainingRefundable = maxRefundable - totalRefunded;
return remainingRefundable;
}
Refund Constraints
- Minimum Amount: ₹1 INR minimum refund
- Maximum Amount: Cannot exceed order total (with multiplier)
- Payment Fee: Excluded from partial refunds, included in full refunds
Refund Processing
Payment Gateway Integration
async processRefund(refund: Refund): Promise<void> {
// Get order and payment details
const order = await this.getOrder(refund.orderId);
const payment = await this.getPaymentByOrderId(order.id);
if (!payment || payment.status !== "captured") {
throw new BadRequestException(
"Cannot refund order without captured payment"
);
}
try {
// Process refund via Razorpay
const razorpayRefund = await this.razorpay.payments.refund(
payment.providerPaymentId,
{
amount: Math.round(refund.amount * 100), // Convert to paise
notes: {
refund_id: refund.id,
order_id: order.id,
reason: refund.reason,
},
},
);
// Update refund status
await db
.update(refunds)
.set({
status: RefundStatus.COMPLETED,
providerRefundId: razorpayRefund.id,
processedAt: new Date(),
})
.where(eq(refunds.id, refund.id));
// Update order status if full refund
const totalRefunded = await this.getTotalRefunded(order.id);
if (totalRefunded >= order.total) {
await this.updateOrderStatus(order.id, OrderStatus.REFUNDED);
}
// Add timeline event
await this.timelineService.addEvent(order.id, {
type: TimelineEventType.REFUNDED,
message: `Refund of ₹${refund.amount} processed`,
metadata: {
refundId: refund.id,
amount: refund.amount,
reason: refund.reason,
},
});
} catch (error) {
// Update refund status to failed
await db
.update(refunds)
.set({
status: RefundStatus.FAILED,
})
.where(eq(refunds.id, refund.id));
throw error;
}
}
Full vs Partial Refunds
Full Refund
// Full refund: refund entire order total
const fullRefundAmount = order.total;
// Includes payment fee
// Order status updated to REFUNDED
// All inventory restored (if applicable)
Partial Refund
// Partial refund: refund specific amount
const partialRefundAmount = 500; // Less than order total
// Payment fee excluded from refundable amount
// Order status remains unchanged (unless fully refunded)
// Inventory handling depends on business rules
Refund Reasons
Common Refund Reasons
enum RefundReason {
CUSTOMER_REQUEST = "Customer requested refund",
PRODUCT_DEFECT = "Product defect",
WRONG_ITEM = "Wrong item received",
SIZE_MISMATCH = "Size mismatch",
DELIVERY_DELAY = "Delivery delay",
DUPLICATE_ORDER = "Duplicate order",
OTHER = "Other",
}
Refund Reason Validation
if (!reason || reason.trim().length === 0) {
throw new BadRequestException("Refund reason is required");
}
Refund Status Updates
Status Update Flow
Multiple Refunds
Handling Multiple Refunds
// Order can have multiple refunds
const refunds = await this.getRefundsByOrderId(orderId);
// Calculate total refunded
const totalRefunded = refunds.reduce(
(sum, refund) => sum + refund.amount,
0,
);
// Check if fully refunded
if (totalRefunded >= order.total) {
await this.updateOrderStatus(orderId, OrderStatus.REFUNDED);
}
Refund Limits
// Maximum refundable amount with multiplier
const maxRefundable = refundableAmount * MAX_REFUND_AMOUNT_MULTIPLIER;
// Check remaining refundable
const remainingRefundable = maxRefundable - totalRefunded;
if (amount > remainingRefundable) {
throw new BadRequestException(
`Refund amount exceeds remaining refundable: ${remainingRefundable}`
);
}
Inventory Handling
Refund Inventory Rules
async handleRefundInventory(orderId: string, refund: Refund): Promise<void> {
const order = await this.getOrder(orderId);
// Business rule: Restore inventory for full refunds
if (refund.amount >= order.total) {
// Restore inventory for all items
for (const item of order.items) {
await this.inventoryStore.incrementInventory(
item.productVariantId,
item.quantity,
);
}
}
// Partial refunds: Inventory handling depends on business rules
// Typically, inventory is not restored for partial refunds
}
Refund Timeline
Timeline Events
// Refund created
await this.timelineService.addEvent(orderId, {
type: TimelineEventType.REFUNDED,
message: `Refund of ₹${amount} requested`,
metadata: {
refundId: refund.id,
amount: refund.amount,
reason: refund.reason,
},
});
// Refund processed
await this.timelineService.addEvent(orderId, {
type: TimelineEventType.REFUNDED,
message: `Refund of ₹${amount} processed successfully`,
metadata: {
refundId: refund.id,
providerRefundId: refund.providerRefundId,
},
});
API Endpoints
Create Refund
POST /admin/orders/{orderId}/refund
Content-Type: application/json
{
"amount": 500,
"reason": "Product defect"
}
Response:
{
"id": "refund-123",
"orderId": "order-456",
"amount": 500,
"reason": "Product defect",
"status": "pending",
"createdAt": "2024-12-18T10:00:00Z"
}
Get Refunds for Order
GET /admin/orders/{orderId}/refunds
Response:
{
"refunds": [
{
"id": "refund-123",
"amount": 500,
"reason": "Product defect",
"status": "completed",
"processedAt": "2024-12-18T10:05:00Z"
}
],
"totalRefunded": 500,
"remainingRefundable": 500
}
Error Handling
Refund Validation Errors
// Amount validation
if (amount <= 0) {
throw new BadRequestException("Refund amount must be greater than 0");
}
// Minimum amount
if (amount < MIN_REFUND_AMOUNT_INR) {
throw new BadRequestException(
`Refund amount must be at least ${MIN_REFUND_AMOUNT_INR} INR`
);
}
// Exceeds refundable
if (amount > remainingRefundable) {
throw new BadRequestException(
`Refund amount exceeds refundable amount: ${remainingRefundable}`
);
}
Payment Gateway Errors
try {
await this.processRefund(refund);
} catch (error) {
// Update refund status to failed
await this.updateRefundStatus(refund.id, RefundStatus.FAILED);
// Log error
this.logger.error("Refund processing failed", error);
// Notify admin
await this.notificationsService.notifyAdmin({
type: "REFUND_FAILED",
refundId: refund.id,
error: error.message,
});
}
Refund Reconciliation
Reconciliation Process
async reconcileRefunds(): Promise<void> {
// Find pending refunds
const pendingRefunds = await this.getPendingRefunds();
for (const refund of pendingRefunds) {
// Check payment gateway status
const gatewayStatus = await this.checkGatewayRefundStatus(refund);
if (gatewayStatus === "completed" && refund.status !== "completed") {
// Update refund status
await this.updateRefundStatus(refund.id, RefundStatus.COMPLETED);
}
}
}
Best Practices
- Amount Validation: Always validate refund amounts
- Reason Required: Require refund reasons for tracking
- Status Tracking: Track refund status accurately
- Error Handling: Handle payment gateway errors gracefully
- Timeline Events: Record all refund events in timeline
Edge Cases
Refund After Order Cancellation
- Refunds can be created for cancelled orders
- Payment must be captured
- Inventory already restored during cancellation
Refund for Partial Order
- Handle refunds for specific items
- Calculate refund amount for items only
- Update order status appropriately
Refund Timeout
- Handle refund processing timeouts
- Retry failed refunds
- Manual intervention for stuck refunds