Skip to main content

Partial Refunds

Partial refunds allow you to return a portion of a charge amount to your customers. This is useful for scenarios like partial returns, price adjustments, shipping refunds, or any situation where a full refund isn't necessary.

Overviewโ€‹

Partial refunds provide flexibility in how you handle returns and adjustments:

  • Flexible Amounts: Refund any amount up to the original charge amount
  • Multiple Refunds: Create multiple partial refunds until fully refunded
  • Remaining Balance: Track how much can still be refunded
  • Same Process: Use the same API as full refunds, just specify an amount
  • Audit Trail: All partial refunds are tracked on the charge

Key Featuresโ€‹

  • Precise Amount Control: Specify exact refund amounts
  • Currency Aware: Automatically uses the charge's currency
  • Balance Tracking: See remaining refundable amount
  • Metadata Support: Tag each refund with context
  • No Limits on Count: Create as many partial refunds as needed
  • Automatic Validation: System prevents over-refunding

When to Use Partial Refundsโ€‹

Common scenarios for partial refunds:

  • Partial Product Returns: Customer returns some items from an order
  • Price Adjustments: Correcting billing errors or applying discounts
  • Shipping Refunds: Refunding delivery fees only
  • Damaged Items: Partial compensation for damaged goods
  • Service Credits: Offering partial refunds for service issues
  • Promotional Adjustments: Applying coupons post-purchase
  • Quantity Corrections: Refunding for incorrect quantities
  • Bundle Adjustments: Refunding specific items from a bundle

Creating Partial Refunds via APIโ€‹

Basic Partial Refundโ€‹

Specify the amount parameter to create a partial refund:

const omise = require('omise')({
secretKey: 'skey_test_123456789',
});

// Create a partial refund
async function createPartialRefund(chargeId, amount) {
try {
const refund = await omise.charges.createRefund(chargeId, {
amount: amount, // Amount in smallest currency unit
metadata: {
reason: 'Partial product return',
items_returned: '2 out of 5 items'
}
});

console.log('Partial refund created:', refund.id);
console.log('Amount refunded:', refund.amount);
console.log('Remaining refundable:', calculateRemaining(refund));

return refund;
} catch (error) {
console.error('Partial refund failed:', error.message);
throw error;
}
}

// Calculate remaining refundable amount
function calculateRemaining(refund) {
// Get the charge to see total refunded
return omise.charges.retrieve(refund.charge).then(charge => {
const totalRefunded = charge.refunded_amount || 0;
const remaining = charge.amount - totalRefunded;
return remaining;
});
}

// Refund shipping cost only
async function refundShippingCost(chargeId, shippingAmount) {
const refund = await omise.charges.createRefund(chargeId, {
amount: shippingAmount,
metadata: {
reason: 'shipping_refund',
type: 'delivery_fee'
}
});

console.log(`Refunded shipping: ${shippingAmount / 100} THB`);
return refund;
}

// Example usage
createPartialRefund('chrg_test_123456789', 50000); // Refund 500 THB
refundShippingCost('chrg_test_123456789', 10000); // Refund 100 THB shipping

API Responseโ€‹

{
"object": "refund",
"id": "rfnd_test_5xyz789abc",
"location": "/charges/chrg_test_123456789/refunds/rfnd_test_5xyz789abc",
"amount": 50000,
"currency": "thb",
"charge": "chrg_test_123456789",
"transaction": "trxn_test_5xyz789abc",
"created": "2024-01-15T10:30:00Z",
"status": "pending",
"metadata": {
"reason": "Partial product return",
"items_returned": "2 out of 5 items"
}
}

Multiple Partial Refundsโ€‹

You can create multiple partial refunds for a single charge:

async function handleMultiplePartialRefunds(chargeId) {
// Original charge: 100,000 (1,000 THB)

// First partial refund - shipping
const refund1 = await omise.charges.createRefund(chargeId, {
amount: 10000, // 100 THB
metadata: { reason: 'shipping_refund' }
});

// Second partial refund - one item
const refund2 = await omise.charges.createRefund(chargeId, {
amount: 30000, // 300 THB
metadata: { reason: 'item_return', item: 'Product A' }
});

// Third partial refund - another item
const refund3 = await omise.charges.createRefund(chargeId, {
amount: 25000, // 250 THB
metadata: { reason: 'item_return', item: 'Product B' }
});

// Check remaining: 100,000 - 10,000 - 30,000 - 25,000 = 35,000 (350 THB)
const charge = await omise.charges.retrieve(chargeId);
console.log('Total refunded:', charge.refunded_amount);
console.log('Remaining:', charge.amount - charge.refunded_amount);

return [refund1, refund2, refund3];
}

Common Use Casesโ€‹

1. Line Item Refundsโ€‹

Refund specific items from an order:

class OrderRefundManager:
def __init__(self, charge_id):
self.charge_id = charge_id
self.charge = omise.Charge.retrieve(charge_id)

def refund_items(self, item_ids):
"""Refund specific items by their IDs"""
order = self.get_order_details()

# Calculate refund amount
refund_amount = 0
refunded_items = []

for item_id in item_ids:
item = next((i for i in order['items'] if i['id'] == item_id), None)
if item:
refund_amount += item['price'] * item['quantity']
refunded_items.append(item)

# Create refund
refund = self.charge.refund(
amount=refund_amount,
metadata={
'reason': 'line_item_refund',
'refunded_items': str(refunded_items),
'item_count': len(refunded_items)
}
)

# Update order
self.update_order_items(item_ids, 'refunded')

return refund

def refund_by_percentage(self, percentage):
"""Refund a percentage of the total charge"""
if not 0 < percentage <= 100:
raise ValueError("Percentage must be between 0 and 100")

refund_amount = int(self.charge.amount * percentage / 100)

refund = self.charge.refund(
amount=refund_amount,
metadata={
'reason': 'percentage_refund',
'percentage': percentage
}
)

return refund

def get_remaining_refundable(self):
"""Get remaining refundable amount"""
self.charge = omise.Charge.retrieve(self.charge_id)
return self.charge.amount - self.charge.refunded_amount

# Example usage
manager = OrderRefundManager('chrg_test_123456789')
manager.refund_items(['item_1', 'item_3'])
manager.refund_by_percentage(10) # 10% discount
remaining = manager.get_remaining_refundable()

2. Progressive Refundsโ€‹

Handle refunds in stages:

class ProgressiveRefund
def initialize(charge_id)
@charge_id = charge_id
@charge = Omise::Charge.retrieve(charge_id)
@refund_schedule = []
end

def schedule_refund(amount, date, reason)
@refund_schedule << {
amount: amount,
date: date,
reason: reason,
status: 'scheduled'
}
end

def process_due_refunds
due_refunds = @refund_schedule.select { |r|
r[:status] == 'scheduled' && r[:date] <= Date.today
}

due_refunds.each do |scheduled|
begin
refund = @charge.refund(
amount: scheduled[:amount],
metadata: {
reason: scheduled[:reason],
scheduled_date: scheduled[:date].to_s,
processing_date: Date.today.to_s
}
)

scheduled[:status] = 'processed'
scheduled[:refund_id] = refund.id

puts "Processed refund: #{refund.id} for #{scheduled[:amount]}"
rescue Omise::Error => e
scheduled[:status] = 'failed'
scheduled[:error] = e.message
puts "Failed to process refund: #{e.message}"
end
end

@refund_schedule
end

def get_schedule_summary
{
total_scheduled: @refund_schedule.sum { |r| r[:amount] },
processed: @refund_schedule.count { |r| r[:status] == 'processed' },
pending: @refund_schedule.count { |r| r[:status] == 'scheduled' },
failed: @refund_schedule.count { |r| r[:status] == 'failed' }
}
end
end

# Example: Refund in 3 installments
progressive = ProgressiveRefund.new('chrg_test_123456789')
progressive.schedule_refund(33333, Date.today, 'First installment')
progressive.schedule_refund(33333, Date.today + 30, 'Second installment')
progressive.schedule_refund(33334, Date.today + 60, 'Third installment')

progressive.process_due_refunds

3. Price Match Refundsโ€‹

Handle price match guarantees:

class PriceMatchRefund {
private $chargeId;
private $originalPrice;

public function __construct($chargeId) {
$this->chargeId = $chargeId;
$charge = OmiseCharge::retrieve($chargeId);
$this->originalPrice = $charge['amount'];
}

public function applyPriceMatch($competitorPrice, $competitorName) {
// Calculate difference
$difference = $this->originalPrice - $competitorPrice;

if ($difference <= 0) {
return [
'success' => false,
'message' => 'Competitor price is not lower'
];
}

// Apply price match policy (e.g., 110% of difference)
$refundAmount = intval($difference * 1.10);

// Ensure we don't refund more than charged
$charge = OmiseCharge::retrieve($this->chargeId);
$remaining = $charge['amount'] - $charge['refunded_amount'];
$refundAmount = min($refundAmount, $remaining);

// Create refund
$refund = $charge->refund([
'amount' => $refundAmount,
'metadata' => [
'reason' => 'price_match',
'competitor' => $competitorName,
'competitor_price' => $competitorPrice,
'original_price' => $this->originalPrice,
'difference' => $difference,
'policy_rate' => '110%'
]
]);

return [
'success' => true,
'refund_id' => $refund['id'],
'amount' => $refundAmount,
'savings' => $difference,
'bonus' => $refundAmount - $difference
];
}

public function applyDiscountCode($discountAmount, $code) {
$charge = OmiseCharge::retrieve($this->chargeId);
$remaining = $charge['amount'] - $charge['refunded_amount'];

if ($discountAmount > $remaining) {
throw new Exception('Discount amount exceeds remaining refundable amount');
}

$refund = $charge->refund([
'amount' => $discountAmount,
'metadata' => [
'reason' => 'discount_code',
'code' => $code,
'applied_at' => date('c')
]
]);

return $refund;
}
}

// Example usage
$priceMatch = new PriceMatchRefund('chrg_test_123456789');
$result = $priceMatch->applyPriceMatch(95000, 'Competitor Store');

if ($result['success']) {
echo "Price match applied! Refunding {$result['amount']} satangs\n";
echo "You save {$result['savings']} + bonus {$result['bonus']}\n";
}

4. Subscription Prorationโ€‹

Prorate refunds for subscription cancellations:

class SubscriptionRefund {
constructor(chargeId, subscriptionStart, subscriptionEnd) {
this.chargeId = chargeId;
this.subscriptionStart = new Date(subscriptionStart);
this.subscriptionEnd = new Date(subscriptionEnd);
}

async calculateProratedRefund(cancellationDate) {
const charge = await omise.charges.retrieve(this.chargeId);

// Calculate days
const totalDays = this.daysBetween(this.subscriptionStart, this.subscriptionEnd);
const usedDays = this.daysBetween(this.subscriptionStart, cancellationDate);
const remainingDays = totalDays - usedDays;

// Calculate refund amount
const dailyRate = charge.amount / totalDays;
const refundAmount = Math.floor(dailyRate * remainingDays);

return {
totalAmount: charge.amount,
totalDays: totalDays,
usedDays: usedDays,
remainingDays: remainingDays,
refundAmount: refundAmount,
effectiveRate: dailyRate
};
}

async processProratedRefund(cancellationDate, reason) {
const calculation = await this.calculateProratedRefund(cancellationDate);

const refund = await omise.charges.createRefund(this.chargeId, {
amount: calculation.refundAmount,
metadata: {
reason: 'subscription_cancellation_prorated',
cancellation_date: cancellationDate.toISOString(),
subscription_start: this.subscriptionStart.toISOString(),
subscription_end: this.subscriptionEnd.toISOString(),
total_days: calculation.totalDays,
used_days: calculation.usedDays,
remaining_days: calculation.remainingDays,
cancellation_reason: reason
}
});

return {
refund: refund,
calculation: calculation
};
}

daysBetween(date1, date2) {
const oneDay = 24 * 60 * 60 * 1000;
return Math.round(Math.abs((date2 - date1) / oneDay));
}
}

// Example usage
const subsRefund = new SubscriptionRefund(
'chrg_test_123456789',
'2024-01-01',
'2024-01-31'
);

const result = await subsRefund.processProratedRefund(
new Date('2024-01-15'),
'Customer requested cancellation'
);

console.log(`Refunding ${result.calculation.refundAmount} for ${result.calculation.remainingDays} unused days`);

Tracking Refund Balanceโ€‹

Monitor how much has been refunded and what remains:

class RefundTracker:
def __init__(self, charge_id):
self.charge_id = charge_id
self.refresh()

def refresh(self):
"""Refresh charge data"""
self.charge = omise.Charge.retrieve(self.charge_id)

def get_refund_summary(self):
"""Get comprehensive refund summary"""
self.refresh()

refunds = self.charge.refunds.data if hasattr(self.charge, 'refunds') else []

return {
'charge_id': self.charge_id,
'original_amount': self.charge.amount,
'refunded_amount': self.charge.refunded_amount,
'remaining_amount': self.charge.amount - self.charge.refunded_amount,
'refund_count': len(refunds),
'fully_refunded': self.charge.refunded,
'currency': self.charge.currency,
'refunds': [
{
'id': r.id,
'amount': r.amount,
'status': r.status,
'created': r.created,
'reason': r.metadata.get('reason', 'N/A') if r.metadata else 'N/A'
}
for r in refunds
]
}

def can_refund(self, amount):
"""Check if amount can be refunded"""
self.refresh()
remaining = self.charge.amount - self.charge.refunded_amount
return amount <= remaining

def get_refund_percentage(self):
"""Get percentage of charge that has been refunded"""
self.refresh()
if self.charge.amount == 0:
return 0
return (self.charge.refunded_amount / self.charge.amount) * 100

def format_summary(self):
"""Format summary for display"""
summary = self.get_refund_summary()

output = f"""
Refund Summary for {summary['charge_id']}
{'=' * 50}
Original Amount: {summary['original_amount'] / 100:.2f} {summary['currency'].upper()}
Refunded Amount: {summary['refunded_amount'] / 100:.2f} {summary['currency'].upper()}
Remaining Amount: {summary['remaining_amount'] / 100:.2f} {summary['currency'].upper()}
Number of Refunds: {summary['refund_count']}
Fully Refunded: {'Yes' if summary['fully_refunded'] else 'No'}
Refund Percentage: {self.get_refund_percentage():.1f}%

Refund History:
"""
for refund in summary['refunds']:
output += f" - {refund['id']}: {refund['amount'] / 100:.2f} {summary['currency'].upper()} ({refund['status']})\n"
output += f" Reason: {refund['reason']}\n"

return output

# Example usage
tracker = RefundTracker('chrg_test_123456789')
print(tracker.format_summary())

if tracker.can_refund(50000):
print("Can refund 500 THB")
else:
print("Insufficient remaining balance")

Best Practicesโ€‹

1. Validate Refund Amountโ€‹

Always validate before creating a refund:

async function safePartialRefund(chargeId, amount, reason) {
// Retrieve charge
const charge = await omise.charges.retrieve(chargeId);

// Validate amount
if (amount <= 0) {
throw new Error('Refund amount must be positive');
}

const remaining = charge.amount - charge.refunded_amount;
if (amount > remaining) {
throw new Error(`Amount exceeds remaining refundable balance. Remaining: ${remaining}`);
}

// Validate currency (ensure amount is in smallest unit)
if (amount % 1 !== 0) {
throw new Error('Amount must be an integer (smallest currency unit)');
}

// Create refund
const refund = await omise.charges.createRefund(chargeId, {
amount: amount,
metadata: {
reason: reason,
validated_at: new Date().toISOString()
}
});

return refund;
}

2. Maintain Detailed Recordsโ€‹

class RefundAuditLog
def self.log_refund(charge_id, refund, context = {})
log_entry = {
timestamp: Time.now.iso8601,
charge_id: charge_id,
refund_id: refund.id,
amount: refund.amount,
currency: refund.currency,
status: refund.status,
reason: refund.metadata['reason'],
user_id: context[:user_id],
ip_address: context[:ip_address],
user_agent: context[:user_agent],
notes: context[:notes]
}

# Save to database or logging system
save_audit_log(log_entry)

# Also log to file for compliance
File.open('refund_audit.log', 'a') do |f|
f.puts JSON.generate(log_entry)
end
end

def self.save_audit_log(entry)
# Save to your database
# AuditLog.create!(entry)
end
end

3. Handle Edge Casesโ€‹

function createRefundWithValidation($chargeId, $amount, $metadata = []) {
try {
$charge = OmiseCharge::retrieve($chargeId);

// Check if charge is refundable
if (!$charge['paid']) {
throw new Exception('Charge has not been paid yet');
}

if ($charge['refunded']) {
throw new Exception('Charge has already been fully refunded');
}

// Check remaining balance
$remaining = $charge['amount'] - $charge['refunded_amount'];
if ($amount > $remaining) {
throw new Exception("Amount exceeds remaining balance of {$remaining}");
}

// Check if charge is too old (e.g., more than 180 days)
$chargeAge = time() - $charge['created'];
$maxAge = 180 * 24 * 60 * 60; // 180 days
if ($chargeAge > $maxAge) {
// Log warning but allow refund
error_log("Warning: Refunding charge older than 180 days: {$chargeId}");
}

// Create refund
$refund = $charge->refund([
'amount' => $amount,
'metadata' => $metadata
]);

return [
'success' => true,
'refund' => $refund
];

} catch (Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}

4. Customer Communicationโ€‹

def refund_with_notification(charge_id, amount, reason, customer_email):
"""Create refund and notify customer"""

# Create refund
refund = omise.Charge.retrieve(charge_id).refund(
amount=amount,
metadata={'reason': reason}
)

# Send email notification
send_refund_email(
to=customer_email,
subject='Partial Refund Processed',
template='partial_refund',
data={
'refund_amount': format_currency(amount, refund.currency),
'reason': reason,
'refund_id': refund.id,
'estimated_days': '5-10 business days'
}
)

# Send SMS for large amounts
if amount >= 100000: # 1000 THB or more
send_sms_notification(
customer_phone,
f"Refund of {format_currency(amount, refund.currency)} has been processed. Check your email for details."
)

return refund

FAQโ€‹

What's the minimum refund amount?โ€‹

The minimum refund amount depends on the currency. For Thai Baht (THB), the minimum is 1 satang (0.01 THB). Always specify amounts in the smallest currency unit (satangs for THB, cents for USD, etc.).

Can I refund more than the original charge amount?โ€‹

No, the total of all refunds (full and partial) cannot exceed the original charge amount. The API will return an error if you attempt to over-refund.

How many partial refunds can I create for one charge?โ€‹

There's no limit to the number of partial refunds you can create, as long as the total doesn't exceed the original charge amount. You can create as many partial refunds as needed until the charge is fully refunded.

What happens if I try to refund more than the remaining balance?โ€‹

The API will return an error with code invalid_request indicating that the refund amount exceeds the available balance. Always check the remaining refundable amount before creating a refund.

Can I cancel or modify a partial refund after it's created?โ€‹

No, refunds cannot be canceled or modified once created. If you refund by mistake, you would need to charge the customer again (with their permission). Double-check amounts before creating refunds.

Do partial refunds affect transaction fees?โ€‹

No, transaction fees are not refunded for partial refunds (or full refunds). You still pay the original transaction fee even if you refund part or all of the charge.

How do I track which items were refunded?โ€‹

Use the metadata field to record detailed information about what was refunded. Include item IDs, SKUs, quantities, or any other relevant information to maintain a clear audit trail.

Can I automate partial refunds based on rules?โ€‹

Yes, you can build automation around partial refunds. For example, automatically refund shipping if delivery is late, or refund a percentage for quality issues. Just ensure you have proper validation and error handling.

Next Stepsโ€‹