Refund Limitations & Restrictions
Understanding refund limitations and restrictions is crucial for managing customer expectations and ensuring smooth refund operations. This guide covers all refund rules, constraints, and best practices.
Overviewโ
Refunds in Omise are subject to various limitations based on:
- Charge Status: Only successful charges can be refunded
- Time Constraints: Some payment methods have time limits
- Amount Restrictions: Cannot exceed original charge amount
- Balance Requirements: Must have sufficient account balance
- Payment Method: Different methods have different rules
- Currency: Refunds must use original currency
- Network Rules: Card network and bank policies apply
General Limitationsโ
Charge Status Requirementsโ
| Charge Status | Can Refund? | Notes |
|---|---|---|
successful | Yes | Standard refund scenario |
pending | No | Wait for charge to complete |
failed | No | No money was captured |
expired | No | Charge was never completed |
reversed | No | Already reversed |
async function checkRefundEligibility(chargeId) {
const charge = await omise.charges.retrieve(chargeId);
if (charge.status !== 'successful') {
return {
eligible: false,
reason: `Charge status is ${charge.status}, must be successful`
};
}
if (charge.refunded) {
return {
eligible: false,
reason: 'Charge has already been fully refunded'
};
}
const remaining = charge.amount - charge.refunded_amount;
if (remaining === 0) {
return {
eligible: false,
reason: 'No remaining balance to refund'
};
}
return {
eligible: true,
remaining: remaining,
currency: charge.currency
};
}
Amount Limitationsโ
Maximum Refund Amount
- Cannot exceed original charge amount
- Total of all refunds cannot exceed charge amount
- Must account for existing refunds
Minimum Refund Amount
- Depends on currency
- THB: 20 satangs (0.20 THB) minimum
- USD: 1 cent minimum
- JPY: 1 yen minimum
def validate_refund_amount(charge_id, refund_amount):
"""Validate refund amount against limitations"""
charge = omise.Charge.retrieve(charge_id)
# Check minimum amount (currency specific)
minimums = {
'thb': 20, # 0.20 THB
'usd': 1, # 0.01 USD
'jpy': 1, # 1 JPY
'sgd': 1, # 0.01 SGD
'eur': 1 # 0.01 EUR
}
min_amount = minimums.get(charge.currency.lower(), 1)
if refund_amount < min_amount:
raise ValueError(f"Refund amount below minimum of {min_amount} {charge.currency}")
# Check maximum amount
remaining = charge.amount - charge.refunded_amount
if refund_amount > remaining:
raise ValueError(
f"Refund amount {refund_amount} exceeds remaining balance {remaining}"
)
# Check if charge amount is zero
if charge.amount == 0:
raise ValueError("Cannot refund a zero-amount charge")
return True
Time Limitationsโ
General Timeline
- No strict time limit for most refunds
- Older refunds may have higher decline rates
- Recommended: Refund within 180 days
Payment Method Specific
- Credit cards: No time limit
- Debit cards: No time limit
- Internet banking: 90 days typically
- Mobile banking: 90 days typically
- E-wallets: Varies by provider
def check_refund_timing(charge_id)
charge = Omise::Charge.retrieve(charge_id)
charge_age_days = (Time.now - Time.at(charge.created)) / 86400
warnings = []
# Check age-based warnings
if charge_age_days > 180
warnings << "Charge is more than 180 days old - refund may have higher decline rate"
end
if charge_age_days > 365
warnings << "Charge is more than 1 year old - consider alternative compensation"
end
# Payment method specific checks
case charge.source.type
when 'internet_banking'
if charge_age_days > 90
warnings << "Internet banking refunds are best within 90 days"
end
when 'mobile_banking'
if charge_age_days > 90
warnings << "Mobile banking refunds are best within 90 days"
end
end
{
charge_age_days: charge_age_days.round(1),
warnings: warnings,
recommended: charge_age_days <= 180
}
end
Balance Requirementsโ
Insufficient Balanceโ
Refunds require sufficient balance in your Omise account:
async function checkRefundBalance(chargeId, refundAmount) {
try {
// Get account balance
const balance = await omise.balance.retrieve();
// Get charge details
const charge = await omise.charges.retrieve(chargeId);
// Check if we have enough balance
if (balance.available < refundAmount) {
return {
canRefund: false,
reason: 'insufficient_balance',
available: balance.available,
required: refundAmount,
shortfall: refundAmount - balance.available,
nextSettlement: await getNextSettlementDate()
};
}
return {
canRefund: true,
available: balance.available,
afterRefund: balance.available - refundAmount
};
} catch (error) {
return {
canRefund: false,
reason: 'error',
error: error.message
};
}
}
// Handle insufficient balance scenario
async function refundWithBalanceCheck(chargeId, amount) {
const balanceCheck = await checkRefundBalance(chargeId, amount);
if (!balanceCheck.canRefund) {
if (balanceCheck.reason === 'insufficient_balance') {
console.log(`Insufficient balance. Need ${balanceCheck.shortfall} more.`);
console.log(`Next settlement: ${balanceCheck.nextSettlement}`);
// Queue for later or notify finance team
await queueRefundForLater(chargeId, amount);
return {
success: false,
queued: true,
message: 'Refund queued pending balance'
};
}
}
// Proceed with refund
const refund = await omise.charges.createRefund(chargeId, { amount });
return { success: true, refund };
}
Balance Monitoringโ
class RefundBalanceManager:
def __init__(self):
self.pending_refunds = []
def queue_refund(self, charge_id, amount, reason):
"""Queue refund when balance is insufficient"""
self.pending_refunds.append({
'charge_id': charge_id,
'amount': amount,
'reason': reason,
'queued_at': time.time(),
'status': 'pending'
})
def process_pending_refunds(self):
"""Process queued refunds when balance is available"""
balance = omise.Balance.retrieve()
for refund_request in self.pending_refunds[:]:
if refund_request['status'] != 'pending':
continue
if balance.available >= refund_request['amount']:
try:
charge = omise.Charge.retrieve(refund_request['charge_id'])
refund = charge.refund(amount=refund_request['amount'])
refund_request['status'] = 'processed'
refund_request['refund_id'] = refund.id
refund_request['processed_at'] = time.time()
# Update available balance
balance.available -= refund_request['amount']
print(f"Processed queued refund: {refund.id}")
except Exception as e:
refund_request['status'] = 'failed'
refund_request['error'] = str(e)
print(f"Failed to process queued refund: {str(e)}")
def get_pending_total(self):
"""Get total amount of pending refunds"""
return sum(
r['amount'] for r in self.pending_refunds
if r['status'] == 'pending'
)
Payment Method Restrictionsโ
Credit & Debit Cardsโ
General Rules
- Must refund to original card
- Cannot refund to a different card
- Card expiration doesn't prevent refunds
- Closed accounts may cause issues
function validateCardRefund($charge) {
$restrictions = [
'can_refund_to_different_card' => false,
'requires_active_card' => false,
'time_limit_days' => null, // No time limit
'min_amount' => 1, // 1 cent/satang
'max_refund_count' => null // Unlimited
];
// Check if card is expired
if (isset($charge['card'])) {
$currentYear = intval(date('Y'));
$currentMonth = intval(date('m'));
$cardYear = intval($charge['card']['expiration_year']);
$cardMonth = intval($charge['card']['expiration_month']);
if ($cardYear < $currentYear ||
($cardYear == $currentYear && $cardMonth < $currentMonth)) {
$restrictions['warnings'][] = 'Card has expired - refund may still work';
}
}
return $restrictions;
}
Internet Bankingโ
Restrictions
- 90-day recommended window
- Bank-specific rules may apply
- Some banks don't support refunds
- May require customer re-authentication
def validate_internet_banking_refund(charge)
restrictions = {
recommended_time_limit: 90, # days
requires_active_account: true,
bank_specific_rules: true
}
# Banks with known limitations
limited_banks = {
'bay' => { max_days: 90, notes: 'Strict 90-day limit' },
'bbl' => { max_days: 180, notes: 'Longer window available' }
}
if charge.source.type == 'internet_banking'
bank_code = charge.source.bank_code
if limited_banks.key?(bank_code)
restrictions[:bank_limits] = limited_banks[bank_code]
end
end
restrictions
end
Mobile Banking & E-Walletsโ
Common Restrictions
- Provider-specific limits
- Account must be active
- May require customer verification
- Different timing windows
const PAYMENT_METHOD_LIMITS = {
'promptpay': {
timeLimit: 180,
canRefund: true,
requiresActiveAccount: true,
notes: 'PromptPay refunds require active registration'
},
'truemoney': {
timeLimit: 90,
canRefund: true,
requiresActiveAccount: true,
notes: 'TrueMoney wallet must be active'
},
'alipay': {
timeLimit: 180,
canRefund: true,
requiresActiveAccount: true,
notes: 'Alipay account must be accessible'
},
'paynow': {
timeLimit: 90,
canRefund: true,
requiresActiveAccount: true,
notes: 'PayNow registration must be active'
}
};
function getPaymentMethodLimits(paymentMethod) {
return PAYMENT_METHOD_LIMITS[paymentMethod] || {
timeLimit: 180,
canRefund: true,
requiresActiveAccount: false,
notes: 'Standard refund rules apply'
};
}
Currency Restrictionsโ
Same Currency Requirementโ
Refunds must use the original charge currency:
def validate_refund_currency(charge_id, refund_amount, refund_currency=None):
"""Validate refund currency matches charge currency"""
charge = omise.Charge.retrieve(charge_id)
# Currency is automatically inherited, but validate if specified
if refund_currency and refund_currency.lower() != charge.currency.lower():
raise ValueError(
f"Refund currency {refund_currency} must match charge currency {charge.currency}"
)
# Currency-specific validations
currency_rules = {
'thb': {
'decimal_places': 2,
'min_amount': 20, # 0.20 THB
'unit_name': 'satang'
},
'usd': {
'decimal_places': 2,
'min_amount': 1, # 0.01 USD
'unit_name': 'cent'
},
'jpy': {
'decimal_places': 0,
'min_amount': 1, # 1 JPY (no decimals)
'unit_name': 'yen'
}
}
rules = currency_rules.get(charge.currency.lower(), {
'decimal_places': 2,
'min_amount': 1,
'unit_name': 'unit'
})
return {
'currency': charge.currency,
'amount_in_smallest_unit': refund_amount,
'rules': rules,
'valid': True
}
Multi-Currency Considerationsโ
class MultiCurrencyRefundHandler
CURRENCY_CONFIGS = {
'thb' => { multiplier: 100, decimals: 2, symbol: 'เธฟ' },
'usd' => { multiplier: 100, decimals: 2, symbol: '$' },
'sgd' => { multiplier: 100, decimals: 2, symbol: 'S$' },
'jpy' => { multiplier: 1, decimals: 0, symbol: 'ยฅ' },
'eur' => { multiplier: 100, decimals: 2, symbol: 'โฌ' }
}
def self.format_amount(amount, currency)
config = CURRENCY_CONFIGS[currency.downcase]
return "#{amount} #{currency.upcase}" unless config
if config[:decimals] > 0
formatted = (amount.to_f / config[:multiplier]).round(config[:decimals])
"#{config[:symbol]}#{formatted}"
else
"#{config[:symbol]}#{amount}"
end
end
def self.validate_amount_format(amount, currency)
config = CURRENCY_CONFIGS[currency.downcase]
return false unless config
# Check if amount is integer
return false unless amount.is_a?(Integer)
# Check if amount is positive
return false unless amount > 0
# For zero-decimal currencies, amount should be the actual amount
# For two-decimal currencies, amount should be in smallest unit
true
end
end
Regional Restrictionsโ
Geographic Limitationsโ
const REGIONAL_REFUND_RULES = {
'TH': {
country: 'Thailand',
supportedMethods: ['card', 'internet_banking', 'mobile_banking', 'promptpay', 'truemoney'],
restrictions: {
'internet_banking': 'Some banks have 90-day limits',
'promptpay': 'Requires active registration'
}
},
'SG': {
country: 'Singapore',
supportedMethods: ['card', 'paynow', 'grabpay'],
restrictions: {
'paynow': '90-day recommended window'
}
},
'JP': {
country: 'Japan',
supportedMethods: ['card', 'konbini'],
restrictions: {
'konbini': 'Refunds not supported - use alternative compensation'
}
},
'MY': {
country: 'Malaysia',
supportedMethods: ['card', 'fpx', 'boost', 'grabpay'],
restrictions: {
'fpx': 'Bank-specific rules apply'
}
}
};
function checkRegionalRestrictions(charge) {
const country = charge.source.country || 'TH';
const rules = REGIONAL_REFUND_RULES[country];
if (!rules) {
return {
supported: true,
warnings: ['Unknown region - standard rules apply']
};
}
const paymentMethod = charge.source.type;
if (!rules.supportedMethods.includes(paymentMethod)) {
return {
supported: false,
reason: `${paymentMethod} refunds not supported in ${rules.country}`
};
}
return {
supported: true,
restrictions: rules.restrictions[paymentMethod] || 'Standard rules apply',
region: rules.country
};
}
Technical Limitationsโ
API Rate Limitsโ
class RefundRateLimiter:
def __init__(self, max_refunds_per_minute=60):
self.max_refunds = max_refunds_per_minute
self.refund_timestamps = []
def can_refund(self):
"""Check if we can create a refund now"""
now = time.time()
one_minute_ago = now - 60
# Remove old timestamps
self.refund_timestamps = [
ts for ts in self.refund_timestamps
if ts > one_minute_ago
]
return len(self.refund_timestamps) < self.max_refunds
def record_refund(self):
"""Record a refund attempt"""
self.refund_timestamps.append(time.time())
def wait_time(self):
"""Get wait time in seconds before next refund"""
if self.can_refund():
return 0
oldest = min(self.refund_timestamps)
wait = 60 - (time.time() - oldest)
return max(0, wait)
# Usage
rate_limiter = RefundRateLimiter()
def create_refund_with_rate_limit(charge_id, amount):
if not rate_limiter.can_refund():
wait = rate_limiter.wait_time()
print(f"Rate limit reached. Wait {wait:.1f} seconds.")
time.sleep(wait)
rate_limiter.record_refund()
return omise.Charge.retrieve(charge_id).refund(amount=amount)
Concurrency Restrictionsโ
class RefundLockManager {
constructor() {
this.locks = new Map();
}
async acquireLock(chargeId, timeout = 30000) {
const startTime = Date.now();
while (this.locks.has(chargeId)) {
if (Date.now() - startTime > timeout) {
throw new Error('Could not acquire refund lock: timeout');
}
await this.sleep(100);
}
this.locks.set(chargeId, Date.now());
}
releaseLock(chargeId) {
this.locks.delete(chargeId);
}
async withLock(chargeId, fn) {
try {
await this.acquireLock(chargeId);
return await fn();
} finally {
this.releaseLock(chargeId);
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage
const lockManager = new RefundLockManager();
async function safeRefund(chargeId, amount) {
return await lockManager.withLock(chargeId, async () => {
// Check current state
const charge = await omise.charges.retrieve(chargeId);
const remaining = charge.amount - charge.refunded_amount;
if (amount > remaining) {
throw new Error('Insufficient remaining balance');
}
// Create refund
return await omise.charges.createRefund(chargeId, { amount });
});
}
Best Practices for Working with Limitationsโ
1. Pre-Refund Validationโ
class RefundValidator {
public static function validateRefund($chargeId, $amount, $metadata = []) {
$errors = [];
$warnings = [];
try {
$charge = OmiseCharge::retrieve($chargeId);
// Status check
if ($charge['status'] !== 'successful') {
$errors[] = "Charge status is {$charge['status']}, must be successful";
}
// Amount check
$remaining = $charge['amount'] - $charge['refunded_amount'];
if ($amount > $remaining) {
$errors[] = "Amount {$amount} exceeds remaining balance {$remaining}";
}
// Time check
$ageInDays = (time() - $charge['created']) / 86400;
if ($ageInDays > 180) {
$warnings[] = "Charge is {$ageInDays} days old - may have higher decline rate";
}
// Payment method check
$methodLimits = self::getPaymentMethodLimits($charge['source']['type']);
if (isset($methodLimits['time_limit']) &&
$ageInDays > $methodLimits['time_limit']) {
$warnings[] = "Exceeds recommended {$methodLimits['time_limit']}-day window for {$charge['source']['type']}";
}
// Balance check (would need actual balance API call)
// This is a placeholder
if (!self::checkBalance($amount)) {
$errors[] = "Insufficient account balance";
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
'charge' => $charge,
'remaining' => $remaining
];
} catch (Exception $e) {
return [
'valid' => false,
'errors' => [$e->getMessage()],
'warnings' => []
];
}
}
private static function getPaymentMethodLimits($type) {
$limits = [
'card' => ['time_limit' => null],
'internet_banking' => ['time_limit' => 90],
'mobile_banking' => ['time_limit' => 90],
'promptpay' => ['time_limit' => 180]
];
return $limits[$type] ?? ['time_limit' => 180];
}
private static function checkBalance($amount) {
// Implement actual balance check
return true;
}
}
// Usage
$validation = RefundValidator::validateRefund('chrg_test_123', 50000);
if ($validation['valid']) {
if (!empty($validation['warnings'])) {
foreach ($validation['warnings'] as $warning) {
error_log("Refund warning: {$warning}");
}
}
// Proceed with refund
createRefund($chargeId, $amount);
} else {
foreach ($validation['errors'] as $error) {
echo "Cannot refund: {$error}\n";
}
}
2. Graceful Degradationโ
class RefundWithFallback
def self.attempt_refund(charge_id, amount, options = {})
max_retries = options[:max_retries] || 3
retry_count = 0
begin
# Validate first
validation = validate_refund(charge_id, amount)
unless validation[:valid]
return {
success: false,
reason: 'validation_failed',
errors: validation[:errors]
}
end
# Attempt refund
charge = Omise::Charge.retrieve(charge_id)
refund = charge.refund(amount: amount, metadata: options[:metadata])
{
success: true,
refund: refund,
warnings: validation[:warnings]
}
rescue Omise::InvalidRequestError => e
if e.message.include?('insufficient') && retry_count < max_retries
# Queue for later
queue_refund(charge_id, amount, options)
{
success: false,
reason: 'insufficient_balance',
queued: true,
message: 'Refund queued for when balance is available'
}
else
{
success: false,
reason: 'invalid_request',
error: e.message
}
end
rescue Omise::Error => e
retry_count += 1
if retry_count < max_retries
sleep(2 ** retry_count) # Exponential backoff
retry
else
{
success: false,
reason: 'api_error',
error: e.message,
retries: retry_count
}
end
end
end
def self.validate_refund(charge_id, amount)
# Implement validation logic
{ valid: true, errors: [], warnings: [] }
end
def self.queue_refund(charge_id, amount, options)
# Implement queuing logic
end
end
3. User-Friendly Error Messagesโ
def get_user_friendly_error(error_code, context={}):
"""Convert technical errors to user-friendly messages"""
messages = {
'charge_already_refunded': {
'user': 'This payment has already been refunded.',
'action': 'Check your refund history for details.'
},
'insufficient_fund': {
'user': 'Unable to process refund at this time due to account balance.',
'action': f"Your refund will be processed automatically within {context.get('wait_days', 2)} business days."
},
'invalid_charge': {
'user': 'This payment cannot be found or is invalid.',
'action': 'Please verify the payment ID and try again.'
},
'charge_not_paid': {
'user': 'This payment has not been completed yet.',
'action': 'Refunds can only be created for successful payments.'
},
'amount_exceeds_refundable': {
'user': f"The refund amount exceeds the available balance of {context.get('remaining', 'unknown')}.",
'action': 'Please enter a lower amount or contact support.'
},
'payment_method_not_supported': {
'user': f"Refunds are not available for {context.get('method', 'this payment method')}.",
'action': 'Please contact our support team for alternative options.'
}
}
return messages.get(error_code, {
'user': 'An error occurred while processing your refund.',
'action': 'Please try again or contact support if the problem persists.'
})
# Usage
try:
refund = omise.Charge.retrieve(charge_id).refund(amount=amount)
except omise.errors.InvalidRequestError as e:
error_code = extract_error_code(str(e))
message = get_user_friendly_error(error_code, {
'remaining': remaining_amount,
'method': payment_method
})
print(f"User Message: {message['user']}")
print(f"Suggested Action: {message['action']}")
Workarounds for Common Limitationsโ
Alternative Compensation Methodsโ
When refunds aren't possible, consider alternatives:
class AlternativeCompensation {
static async handleUnrefundableCharge(chargeId, amount, reason) {
const charge = await omise.charges.retrieve(chargeId);
// Determine why refund isn't possible
const issue = this.diagnoseIssue(charge);
switch (issue.type) {
case 'payment_method_limitation':
// Offer store credit or bank transfer
return await this.offerStoreCredit(charge.customer, amount, reason);
case 'expired_timeline':
// Manual bank transfer
return await this.initiateBankTransfer(charge, amount, reason);
case 'closed_account':
// Contact customer for new payment details
return await this.requestNewPaymentDetails(charge, amount, reason);
default:
// Escalate to support
return await this.escalateToSupport(charge, amount, reason, issue);
}
}
static async offerStoreCredit(customerId, amount, reason) {
// Create store credit
const credit = await createStoreCredit({
customer_id: customerId,
amount: amount,
reason: reason,
expires_at: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
});
// Notify customer
await notifyCustomer(customerId, {
type: 'store_credit_issued',
amount: amount,
code: credit.code
});
return {
method: 'store_credit',
credit_code: credit.code,
amount: amount
};
}
static diagnoseIssue(charge) {
// Implement diagnosis logic
return { type: 'payment_method_limitation' };
}
}
FAQโ
Why can't I refund a pending charge?โ
Pending charges haven't been fully processed yet. You must wait for the charge to reach successful status before creating a refund. If you need to cancel a pending charge, use the charge cancellation endpoint instead.
What happens if I try to refund more than the charge amount?โ
The API will return an error indicating that the refund amount exceeds the refundable balance. You cannot refund more than the original charge amount, including all previous refunds.
Can I refund a charge if the customer's card has expired?โ
Yes, in most cases. Card networks typically allow refunds to expired cards, and the funds will still reach the customer's account. However, if the account is closed, the refund may fail.
What if the customer's bank account is closed?โ
If refunding to a closed account, the refund will typically fail or be rejected by the bank. You'll need to arrange alternative compensation, such as a manual bank transfer to a new account or store credit.
Are there different rules for test vs live mode?โ
Test mode has the same validation rules as live mode, but refunds are simulated and don't involve real money. Use test mode to verify your refund logic before going live.
Can I bypass refund limitations?โ
No, refund limitations are enforced by payment networks, banks, and Omise systems. These rules exist for security and compliance reasons. For edge cases, contact Omise support for alternative solutions.
Why do old charges have higher refund decline rates?โ
Banks and payment networks may flag very old refunds as suspicious or unusual. While there's typically no hard time limit, refunding within 180 days is recommended for best results.
What happens if my account balance is insufficient?โ
The refund will fail with an insufficient_fund error. You can either wait for pending settlements to clear or queue the refund for automatic processing when balance becomes available.
Related Resourcesโ
- Creating Refunds - How to create full refunds
- Partial Refunds - Creating partial refunds
- Balance Management - Monitor your account balance
- Error Handling - Handle refund errors
- API Reference - Complete refund API documentation
Next Stepsโ
- Review balance management to ensure sufficient funds
- Implement error handling for refund failures
- Set up webhooks to track refund status
- Learn about reconciliation for accounting
- Explore transaction history for refund tracking