whisper-money/app/Services/ExchangeRateService.php

143 lines
4.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\ExchangeRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class ExchangeRateService
{
/** @var array<string, array<string, float>> */
private array $ratesCache = [];
/** @var array<string, true> */
private array $databaseMissCache = [];
public function __construct(private CurrencyConversionService $currencyApi) {}
/**
* Convert an amount in cents from one currency to another on a given date.
*
* Uses DB-cached exchange rates, falling back to the external API on cache miss.
*
* @param string $source Source currency code (e.g., "EUR")
* @param string $target Target currency code (e.g., "USD")
* @param int $amountInCents Amount in the source currency's smallest unit
* @param string $date Date string (YYYY-MM-DD)
*/
public function convert(string $source, string $target, int $amountInCents, string $date): int
{
$source = strtolower($source);
$target = strtolower($target);
if ($source === $target || $amountInCents === 0) {
return $amountInCents;
}
$rates = $this->getRates($target, $date);
if (! isset($rates[$source]) || $rates[$source] == 0) {
Log::warning('Exchange rate not found, returning unconverted amount', [
'source' => $source,
'target' => $target,
'date' => $date,
]);
return $amountInCents;
}
return (int) round($amountInCents / $rates[$source]);
}
/**
* Get all exchange rates for a base currency on a given date.
*
* Checks the DB cache first, then fetches from the external API
* and stores the result for future lookups.
*
* @return array<string, float>
*/
public function getRates(string $baseCurrency, string $date): array
{
$baseCurrency = strtolower($baseCurrency);
$date = $this->normalizeDate($date);
$cacheKey = $this->cacheKey($baseCurrency, $date);
if (array_key_exists($cacheKey, $this->ratesCache)) {
return $this->ratesCache[$cacheKey];
}
if (! isset($this->databaseMissCache[$cacheKey])) {
$cached = ExchangeRate::query()
->where('base_currency', $baseCurrency)
->where('date', $date)
->first();
if ($cached) {
return $this->ratesCache[$cacheKey] = $cached->rates;
}
}
$rates = $this->currencyApi->getRatesForCurrency($baseCurrency, $date);
if (! empty($rates)) {
ExchangeRate::query()->upsert([
[
'base_currency' => $baseCurrency,
'date' => $date,
'rates' => json_encode($rates, JSON_THROW_ON_ERROR),
],
], uniqueBy: ['base_currency', 'date'], update: ['rates']);
}
return $this->ratesCache[$cacheKey] = $rates;
}
/**
* Preload cached exchange rates for many dates in one query.
*
* @param iterable<int, string> $dates
*/
public function preloadRates(string $baseCurrency, iterable $dates): void
{
$baseCurrency = strtolower($baseCurrency);
$dates = collect($dates)
->map(fn (string $date): string => $this->normalizeDate($date))
->unique()
->reject(fn (string $date): bool => array_key_exists($this->cacheKey($baseCurrency, $date), $this->ratesCache))
->values();
if ($dates->isEmpty()) {
return;
}
ExchangeRate::query()
->where('base_currency', $baseCurrency)
->whereIn('date', $dates->all())
->get(['base_currency', 'date', 'rates'])
->each(function (ExchangeRate $exchangeRate): void {
$date = $exchangeRate->date->toDateString();
$this->ratesCache[$this->cacheKey($exchangeRate->base_currency, $date)] = $exchangeRate->rates;
});
$dates
->reject(fn (string $date): bool => array_key_exists($this->cacheKey($baseCurrency, $date), $this->ratesCache))
->each(function (string $date) use ($baseCurrency): void {
$this->databaseMissCache[$this->cacheKey($baseCurrency, $date)] = true;
});
}
private function normalizeDate(string $date): string
{
$today = Carbon::today()->toDateString();
return $date > $today ? $today : $date;
}
private function cacheKey(string $baseCurrency, string $date): string
{
return strtolower($baseCurrency).'|'.$date;
}
}