"I paid 102.30, and you returned 99.85? That’s not a refund, that’s a penalty."
Alex had ordered sneakers from our cross-border platform — ¥16,500, which came out to €102.30 at the time of payment. A few days later the size didn’t fit, so he requested a refund. Everything went smoothly from an operations standpoint … until the money hit his card.
We sent back ¥16,500, but the yen-to-euro rate had moved since his purchase. He got €99.85 — €2.45 less than he paid. The root cause wasn’t a calculation error. It was our naive exchange rate handling: we were using whatever rate was current at the time of the refund, not the rate at the time of the original payment.
The Real Problem: A Drifting Exchange Rate
Our platform is a Laravel monolith that powers a marketplace connecting European buyers with Japanese sneaker sellers. Order amounts are captured in the seller’s local currency — yen. But we charge customers in their preferred currency (euros, dollars, etc.), and all the financial reporting is in yet another base currency. That meant every money‑moving operation crossed a rate boundary.
For a purchase, we would fetch the live rate from an external provider, convert the total, and charge the customer. The order record only stored the final converted amount and the currency, not the rate itself. When a refund was triggered, the system simply fetched the current rate again and used it to calculate the refund amount in the customer’s currency. If the yen had weakened against the euro between purchase and refund, the customer lost money. If it had strengthened, we lost money. Either way, someone was unhappy.
The fix was obvious in hindsight: we needed to pin the exchange rate to the order at the moment of creation, and then reuse that exact same rate for any subsequent financial event — refunds, partial refunds, chargebacks, everything.
Building a Laravel Exchange Rate Service That Actually Works
We introduced an OrderExchangeRate model and a dedicated service class to handle rate capture and retrieval. The service’s job is simple:
- When an order is placed, fetch the live rate and store it together with the order.
- When a refund is initiated, retrieve that stored rate — avoid hitting the API again.
- Fall back gracefully if no rate is found (which shouldn’t happen, but paranoia pays).
Here’s the core of the service:
namespace App\Services;
use App\Models\OrderExchangeRate;
use App\Services\ExchangeRateProvider;
use Illuminate\Support\Facades\Log;
class ExchangeRateService
{
public function captureRateForOrder(int $orderId, string $from, string $to): void
{
try {
$rate = app(ExchangeRateProvider::class)->getRate($from, $to);
} catch (\Throwable $e) {
Log::error('Failed to fetch rate for order', [
'order_id' => $orderId,
'exception' => $e->getMessage(),
]);
throw $e;
}
OrderExchangeRate::create([
'order_id' => $orderId,
'from_currency' => $from,
'to_currency' => $to,
'rate' => $rate,
'captured_at' => now(),
]);
}
}
The `ExchangeRateProvider` is just a thin wrapper around the actual rate API (we used Fixer, later switched to the ECB feed for cost reasons). The important part is that the rate is captured *exactly once* and stored immutably. No drifting.
Then, when a refund is initiated, we look up that stored rate:
php
public function getRateForOrder(int $orderId): float
{
$rate = OrderExchangeRate::where('order_id', $orderId)
->latest('captured_at')
->value('rate');
if (!$rate) {
Log::warning('No stored rate found for order, falling back', [
'order_id' => $orderId,
]);
// Emergency fallback – fetch live, but not for refund calculation
// This path should be extremely rare and trigger an alert
return app(ExchangeRateProvider::class)->getRate('JPY', 'EUR');
}
return $rate;
}
Notice the fallback – it’s deliberately kept as a last resort. In production we haven’t hit it because the capture happens in the same database transaction as the order creation. But having it there, with a loud log, saved us one night when a deployment script accidentally dropped a handful of rate records. Knowing exactly which orders were affected let us manually recalculate the refunds before any customer noticed.
## A Note on Caching
A common temptation is to cache rates in Redis to avoid the API call on every order. We did that too – until we realized that a 5‑minute cache window could still mean two orders placed seconds apart would use slightly different rates. That’s fine for display purposes, but for money‑locking, every order needs its own point‑in‑time snapshot. So we kept the rate capture per‑order, and used a short‑lived cache only for the order‑creation page’s currency conversion preview. The actual payment captured the rate afresh and stored it.
In a project I reviewed later — Taocarts, a cross‑border e‑commerce SaaS — they handle this similarly. Each order carries its own exchange rate, and refunds consistently reference that original rate. The lesson is the same everywhere: exchange rates are not a global variable.
## The Impact: From Angry Tickets to Boring Books
After rolling out the exchange rate service, the number of refund‑related disputes dropped to practically zero. Before, we averaged 3–5 complaints per month about refund amounts being off. In the eight months since, we’ve had one — and that one turned out to be a double‑charge caused by a payment gateway callback retry, not a rate issue.
The other win was on the accounting side. With every order pinned to a known rate, our finance team stopped spending hours reconciling currency‑conversion discrepancies at month‑end. The "books don’t match" meetings became 10‑minute check‑ins rather than multi‑day investigations.
## Lessons Learned (the Hard Way)
* **Don’t use a live rate for money that already moved.** The moment a payment is captured, the exchange rate becomes part of the transaction’s DNA. Storing it in the order record is cheap; customer trust isn’t.
* **Store the rate, not just the converted amount.** If you only store the final amount, you can’t reverse‑engineer the correct rate for partial refunds, and you lose the ability to provide transparency to the customer.
* **Fallbacks are dangerous, but necessary.** Design for the impossible. A missing rate record is an edge case that shouldn’t exist, but when it does, a clear log entry and an alert are worth more than an automatic “fix.”
* **The exchange rate service doesn’t need to be complex.** A single model, two methods, and a database column. The complexity isn’t in the code — it’s in realising you need it in the first place.
Looking back, the €2.45 that sparked all this work was the best unexpected investment. Alex eventually got his full refund — manually, while we were fixing the system — and stayed a customer. That’s the part that stuck with me: a €2.45 rounding error nearly lost us a customer, and fixing it was cheaper than any ad campaign.
These days our refund disputes are close to zero — one incident in eight months, and that was a gateway retry. One model, two methods, and a database column.
The key takeaway: capture the rate once and trust that snapshot. Have you faced similar refund issues?
DESCRIPTION: Storing exchange rates at order creation prevents multi-currency refund disputes.
Top comments (0)