Refunds
Process full or partial refunds across all payment providers with a unified API.
Quick Start
Full Refund
// Refund entire payment
const refund = await client.createRefund('stripe:pi_abc123')
console.log(refund.status) // 'PENDING' or 'SUCCEEDED'
console.log(refund.money) // { amount: 5000, currency: 'USD' }Partial Refund
// Refund ₹50 from ₹100 payment
const partialRefund = await client.createRefund('razorpay:order_xyz', {
amount: 5000, // ₹50 (in paise)
reason: 'Customer requested partial refund'
})Creating Refunds
Basic Refund
const refund = await client.createRefund(
unipayId, // Payment ID from createPayment()
{
amount?: number // Optional: defaults to full refund
reason?: string // Optional: reason for refund
refundId?: string // Optional: your reference ID
metadata?: Record<string, string> // Optional: custom data
idempotencyKey?: string // Optional: prevent duplicates
}
)Full Refund Example
import { createPaymentClient } from '@uniipay/orchestrator'
const client = createPaymentClient({ /* ... */ })
// Full refund with reason
const refund = await client.createRefund('stripe:pi_abc123', {
reason: 'Customer not satisfied with product',
refundId: 'REF-2024-001',
metadata: {
orderId: 'ORD-12345',
requestedBy: 'support@example.com'
}
})
console.log(`Refund ${refund.status}: ${refund.providerRefundId}`)Partial Refund Example
// Original payment: $100
// Refund: $30
const partialRefund = await client.createRefund('stripe:pi_abc123', {
amount: 3000, // $30 in cents
reason: 'Partial cancellation - returned 1 of 2 items'
})
// Can create multiple partial refunds up to total amount
const secondRefund = await client.createRefund('stripe:pi_abc123', {
amount: 2000, // $20 in cents
reason: 'Additional discount applied'
})
// Remaining refundable: $50Refund Statuses
| Status | Meaning | Timeline |
|---|---|---|
PENDING | Refund initiated | Immediate |
PROCESSING | Being processed by provider | Minutes to hours |
SUCCEEDED | ✅ Refund completed | 5-10 business days to customer |
FAILED | ❌ Refund failed | Check failure reason |
Checking Refund Status
// Get single refund
const refund = await client.getRefund('STRIPE', 're_abc123')
if (refund.status === 'SUCCEEDED') {
console.log('Refund completed')
console.log('Amount:', refund.money.amount / 100, refund.money.currency)
}
if (refund.status === 'FAILED') {
console.error('Refund failed:', refund.failureReason)
}Listing Refunds
Get all refunds for a payment:
const refundList = await client.listRefunds('stripe:pi_abc123')
console.log(`Total refunds: ${refundList.refunds.length}`)
console.log(`Total refunded: ${refundList.totalRefunded.amount}`)
console.log(`Payment amount: ${refundList.paymentAmount.amount}`)
refundList.refunds.forEach(refund => {
console.log(`- ${refund.providerRefundId}: ${refund.status} - ${refund.money.amount}`)
})Provider-Specific Features
Stripe
- Multiple refunds: Yes
- Partial refunds: Yes
- Instant refunds: No (5-10 business days)
- Refund limits: Up to payment amount
const refund = await client.createRefund('stripe:pi_abc123', {
amount: 5000,
reason: 'requested_by_customer', // Stripe-specific reason
metadata: {
internalNote: 'Approved by manager'
}
})Razorpay
- Multiple refunds: Yes
- Partial refunds: Yes
- Instant refunds: Yes (via metadata)
- Refund limits: Up to payment amount
// Normal refund (5-7 business days)
const normalRefund = await client.createRefund('razorpay:pay_abc', {
amount: 10000, // ₹100
reason: 'Product return'
})
// Instant refund (within minutes, via UPI/IMPS)
const instantRefund = await client.createRefund('razorpay:pay_abc', {
amount: 10000,
metadata: {
speed: 'optimum' // Razorpay instant refund
}
})Error Handling
import {
RefundCreationError,
PaymentNotRefundableError,
RefundExceedsPaymentError,
PartialRefundNotSupportedError
} from '@uniipay/orchestrator'
try {
const refund = await client.createRefund(unipayId, { amount: 5000 })
} catch (error) {
if (error instanceof PaymentNotRefundableError) {
console.error('Payment cannot be refunded - already refunded or too old')
} else if (error instanceof RefundExceedsPaymentError) {
console.error(`Refund amount exceeds remaining refundable amount`)
console.error(`Payment: ${error.paymentAmount}`)
console.error(`Already refunded: ${error.refundedAmount}`)
console.error(`Requested: ${error.requestedAmount}`)
} else if (error instanceof PartialRefundNotSupportedError) {
console.error('Provider does not support partial refunds')
} else if (error instanceof RefundCreationError) {
console.error(`Refund failed on ${error.provider}: ${error.message}`)
console.error(`Provider code: ${error.providerCode}`)
}
}Best Practices
1. Validate Before Refunding
// Get payment first to check refundability
const payment = await client.getPayment(unipayId)
if (payment.status !== 'SUCCEEDED') {
throw new Error('Payment must be successful to refund')
}
// Check if already fully refunded
const refunds = await client.listRefunds(unipayId)
if (refunds.totalRefunded.amount >= payment.money.amount) {
throw new Error('Payment already fully refunded')
}
// Calculate remaining refundable amount
const remainingAmount = payment.money.amount - refunds.totalRefunded.amount
if (requestedAmount > remainingAmount) {
throw new Error(`Can only refund up to ${remainingAmount}`)
}2. Use Idempotency
Prevent duplicate refunds:
const idempotencyKey = `refund-${orderId}-${Date.now()}`
const refund = await client.createRefund(unipayId, {
amount: 5000,
idempotencyKey
})
// If called again with same key, returns original refund3. Store Refund References
const refund = await client.createRefund(unipayId, {
amount: 5000,
reason: 'Customer request',
refundId: `REF-${orderId}-001` // Your internal reference
})
// Save to database
await db.refunds.create({
data: {
orderId,
unipayId: refund.unipayId,
providerRefundId: refund.providerRefundId,
amount: refund.money.amount,
currency: refund.money.currency,
status: refund.status,
reason: refund.reason,
createdAt: new Date()
}
})4. Notify Customers
async function processRefund(orderId: string, amount: number) {
const order = await db.orders.findUnique({ where: { id: orderId } })
const refund = await client.createRefund(order.unipayId, {
amount,
reason: 'Customer requested refund',
metadata: { orderId }
})
// Update order status
await db.orders.update({
where: { id: orderId },
data: { status: 'REFUNDED' }
})
// Send email notification
await sendEmail({
to: order.customerEmail,
subject: 'Refund Initiated',
template: 'refund-initiated',
data: {
orderId,
amount: amount / 100,
currency: order.currency,
refundId: refund.providerRefundId,
expectedDate: getRefundExpectedDate(refund.provider)
}
})
}
function getRefundExpectedDate(provider: PaymentProvider): string {
// Stripe/Razorpay: 5-10 business days
const days = provider === PaymentProvider.RAZORPAY ? 7 : 10
const date = new Date()
date.setDate(date.getDate() + days)
return date.toLocaleDateString()
}5. Handle Webhook Updates
Refund status changes are sent via webhooks:
async function handleRefundWebhook(event: WebhookEvent) {
if (event.payload.type !== 'refund') return
const { providerRefundId, status, money } = event.payload
// Update database
await db.refunds.update({
where: { providerRefundId },
data: { status }
})
if (status === 'SUCCEEDED') {
// Notify customer
await sendEmail({
to: customer.email,
subject: 'Refund Completed',
template: 'refund-completed',
data: {
amount: money.amount / 100,
currency: money.currency
}
})
} else if (status === 'FAILED') {
// Alert admin
await alertAdmin({
message: `Refund failed: ${providerRefundId}`,
severity: 'HIGH'
})
}
}Common Patterns
Auto-Refund on Cancellation
async function cancelOrder(orderId: string) {
const order = await db.orders.findUnique({ where: { id: orderId } })
if (order.status === 'PAID') {
// Auto-refund
const refund = await client.createRefund(order.unipayId, {
reason: 'Order cancelled by customer',
metadata: { orderId }
})
await db.orders.update({
where: { id: orderId },
data: {
status: 'CANCELLED',
refundId: refund.providerRefundId
}
})
}
}Partial Refund Calculator
async function calculatePartialRefund(
unipayId: string,
itemsToRefund: { itemId: string, quantity: number }[]
) {
// Get original payment
const payment = await client.getPayment(unipayId)
// Get order details
const order = await db.orders.findUnique({
where: { unipayId },
include: { items: true }
})
// Calculate refund amount
let refundAmount = 0
for (const item of itemsToRefund) {
const orderItem = order.items.find(i => i.id === item.itemId)
refundAmount += orderItem.price * item.quantity
}
// Check if exceeds payment
if (refundAmount > payment.money.amount) {
throw new Error('Refund amount exceeds payment')
}
// Create refund
return client.createRefund(unipayId, {
amount: refundAmount,
reason: 'Partial order cancellation',
metadata: {
orderId: order.id,
refundedItems: JSON.stringify(itemsToRefund)
}
})
}Batch Refunds
async function batchRefund(orderIds: string[]) {
const results = []
for (const orderId of orderIds) {
try {
const order = await db.orders.findUnique({ where: { id: orderId } })
const refund = await client.createRefund(order.unipayId, {
reason: 'Bulk refund - promotion error',
metadata: { orderId, batchId: 'BATCH-001' }
})
results.push({ orderId, status: 'SUCCESS', refundId: refund.providerRefundId })
} catch (error) {
results.push({ orderId, status: 'FAILED', error: error.message })
}
}
return results
}Refund Timelines
| Provider | Method | Timeline |
|---|---|---|
| Stripe | Card | 5-10 business days |
| Stripe | Bank transfer | 5-10 business days |
| Razorpay | UPI | 1-3 business days |
| Razorpay | Cards | 5-7 business days |
| Razorpay | Net banking | 5-7 business days |
| Razorpay | Instant* | Within minutes |
* Razorpay instant refunds require speed: 'optimum' in metadata
Testing Refunds
Test Mode
All refunds in test mode are instant:
// In test environment
const refund = await client.createRefund('stripe:pi_test_123', {
amount: 5000
})
// Status immediately: SUCCEEDED (in test mode)Production Testing
- Create small test payment ($0.50)
- Refund it
- Verify in provider dashboard
- Check webhook delivery
- Confirm customer email sent
Next Steps
- Webhooks → - Handle refund webhooks
- Error Handling → - Handle refund errors
Last updated on