Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/Commands/Refresh.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

final class Refresh extends Command
{
Expand Down Expand Up @@ -117,17 +118,34 @@ protected function resolveBases(): array
$args = (array) $this->argument('bases');

if ($args !== []) {
return array_values(array_map('strtoupper', $args));
return $this->normalizeBases($args);
}

$configured = config('rates.bases');

if (is_array($configured) && $configured !== []) {
return array_values(array_filter($configured, 'is_string'));
return $this->normalizeBases($configured);
}

$default = config('rates.base');

return is_string($default) ? [$default] : [];
return is_string($default) ? $this->normalizeBases([$default]) : [];
}

/**
* Normalize base strings for cache-key consistency.
*
* @param array<int, mixed> $bases
* @return array<int, string>
*/
protected function normalizeBases(array $bases): array
{
return array_values(array_filter(
array_map(
fn (mixed $base): ?string => is_string($base) ? (string) Str::of($base)->trim()->upper() : null,
$bases,
),
fn (?string $base): bool => $base !== null && $base !== '',
));
}
}
22 changes: 20 additions & 2 deletions src/Commands/Status.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

final class Status extends Command
{
Expand Down Expand Up @@ -84,11 +85,28 @@ protected function resolveBases(): array
$args = (array) $this->argument('bases');

if ($args !== []) {
return array_values(array_map('strtoupper', $args));
return $this->normalizeBases($args);
}

$configured = config('rates.bases');

return is_array($configured) ? array_values(array_filter($configured, 'is_string')) : [];
return is_array($configured) ? $this->normalizeBases($configured) : [];
}

/**
* Normalize base strings for cache-key consistency.
*
* @param array<int, mixed> $bases
* @return array<int, string>
*/
protected function normalizeBases(array $bases): array
{
return array_values(array_filter(
array_map(
fn (mixed $base): ?string => is_string($base) ? (string) Str::of($base)->trim()->upper() : null,
$bases,
),
fn (?string $base): bool => $base !== null && $base !== '',
));
}
}
72 changes: 71 additions & 1 deletion src/Concerns/InteractsWithCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,27 @@ public function flush(Currency|string|null $base = null): void
$store = $this->cacheStore();

if ($base !== null) {
$store->forget($this->cacheKey($this->resolveBase($base)));
$key = $this->cacheKey($this->resolveBase($base));

$store->forget($key);
$this->forgetCacheKey($store, $key);

return;
}

$keys = $store->get($this->cacheIndexKey(), []);

if (is_array($keys)) {
foreach ($keys as $key) {
if (is_string($key)) {
$store->forget($key);
}
}
}

$store->forget($this->cacheIndexKey());

// Clear legacy entries created before cache keys were indexed.
foreach (Currency::cases() as $currency) {
$store->forget($this->cacheKey($currency->value));
}
Expand All @@ -69,6 +85,8 @@ protected function cacheRate(CacheRepository $store, string $key, Rate $rate): v
$this->applyFreshUntil($rate);

$store->put($key, $rate, $this->staleTtl());

$this->rememberCacheKey($store, $key);
}

/**
Expand Down Expand Up @@ -171,6 +189,58 @@ protected function cacheKey(string $base): string
return "{$prefix}:{$base}";
}

/**
* Track cache keys so flush() can clear custom string bases too.
*/
protected function rememberCacheKey(CacheRepository $store, string $key): void
{
$indexKey = $this->cacheIndexKey();
$keys = $store->get($indexKey, []);

if (! is_array($keys)) {
$keys = [];
}

if (! in_array($key, $keys, true)) {
$keys[] = $key;
}

$store->forever($indexKey, array_values($keys));
}

/**
* Remove a key from the tracked cache-key index.
*/
protected function forgetCacheKey(CacheRepository $store, string $key): void
{
$indexKey = $this->cacheIndexKey();
$keys = $store->get($indexKey, []);

if (! is_array($keys)) {
return;
}

$keys = array_values(array_filter($keys, fn (mixed $cachedKey): bool => $cachedKey !== $key));

if ($keys === []) {
$store->forget($indexKey);

return;
}

$store->forever($indexKey, $keys);
}

/**
* Build the key used to index all cached rate keys.
*/
protected function cacheIndexKey(): string
{
$prefix = config('rates.cache.prefix', 'rates');

return "{$prefix}:__keys";
}

/**
* Get the stale TTL in seconds (total time before eviction from cache).
*/
Expand Down
15 changes: 12 additions & 3 deletions src/Concerns/ResolvesDrivers.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use AtoBeach\Rates\Rate;
use Closure;
use Illuminate\Support\Str;
use InvalidArgumentException;

/**
* Driver-resolution machinery, modelled on Illuminate\Support\Manager.
Expand Down Expand Up @@ -113,11 +114,19 @@ protected function resolveBase(Currency|string|null $base): string
return $base->value;
}

if ($base !== null) {
return $base;
$base ??= config('rates.base', 'USD');

if (! is_string($base)) {
throw new InvalidArgumentException('Base currency must be a string or Currency enum.');
}

$base = (string) Str::of($base)->trim()->upper();

if (! preg_match('/^[A-Z]{3}$/', $base)) {
throw new InvalidArgumentException("Invalid base currency [{$base}]. Expected a three-letter ISO 4217 code.");
}

return config('rates.base', 'USD');
return $base;
}

/**
Expand Down
40 changes: 39 additions & 1 deletion src/Conversion.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function __construct(
private Currency|string|null $from = null,
private ?Rate $rate = null,
) {
$this->amount = (string) $amount;
$this->amount = self::normalizeNumber($amount);
}

/**
Expand All @@ -41,6 +41,8 @@ public function __construct(
*/
public static function bcround(string $value, int $precision): string
{
$value = self::normalizeNumber($value);

if ($precision < 0) {
$precision = 0;
}
Expand Down Expand Up @@ -132,6 +134,42 @@ public function toMany(array $currencies): array
return $result;
}

/**
* Normalize input to the plain decimal format expected by bcmath.
*/
private static function normalizeNumber(float|int|string $value): string
{
if (is_float($value)) {
if (! is_finite($value)) {
throw ConversionException::invalidAmount((string) $value);
}

$value = (string) Str::of(sprintf('%.14F', $value))->rtrim('0')->rtrim('.');
}

$value = (string) Str::of((string) $value)->trim();

if ($value === '') {
throw ConversionException::invalidAmount($value);
}

if (str_starts_with($value, '+')) {
$value = mb_substr($value, 1);
}

if (str_starts_with($value, '.')) {
$value = '0'.$value;
} elseif (str_starts_with($value, '-.')) {
$value = '-0'.mb_substr($value, 1);
}

if (! preg_match('/^-?(?:\d+|\d+\.\d+)$/', $value)) {
throw ConversionException::invalidAmount($value);
}

return $value === '-0' ? '0' : $value;
}

/**
* Resolve the Rate instance needed for conversion.
*
Expand Down
8 changes: 7 additions & 1 deletion src/Drivers/ApilayerCurrencyData.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ class ApilayerCurrencyData extends HttpDriver
*/
public function url(string $base): string
{
return "https://api.apilayer.com/currency_data/live?source={$base}";
return 'https://api.apilayer.com/currency_data/live?'.http_build_query([
'source' => $base,
], '', '&', PHP_QUERY_RFC3986);
}

/**
Expand All @@ -45,6 +47,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate
return $rate;
}

if (! $this->matchesRequestedBase($rate, $data->source, 'source')) {
return $rate;
}

$rate->source = 'Apilayer Currency Data';
$rate->timestamp = $data->timestamp;

Expand Down
9 changes: 8 additions & 1 deletion src/Drivers/CurrencyLayer.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ public function url(string $base): string
{
$key = config('rates.currencylayer.key');

return "https://api.currencylayer.com/live?access_key={$key}&source={$base}";
return 'https://api.currencylayer.com/live?'.http_build_query([
'access_key' => (string) $key,
'source' => $base,
], '', '&', PHP_QUERY_RFC3986);
}

/**
Expand All @@ -36,6 +39,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate
return $rate;
}

if (! $this->matchesRequestedBase($rate, $data->source, 'source')) {
return $rate;
}

$rate->source = 'CurrencyLayer';
$rate->timestamp = $data->timestamp;

Expand Down
28 changes: 28 additions & 0 deletions src/Drivers/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use AtoBeach\Rates\Contracts\Driver as DriverContract;
use AtoBeach\Rates\Rate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Fluent;
use Illuminate\Support\Str;

abstract class Driver implements DriverContract
{
Expand Down Expand Up @@ -116,4 +118,30 @@ protected function unpackPairQuotes(array $quotes, string $source): array

return $rates;
}

/**
* Ensure the upstream response is quoted against the requested base.
*/
protected function matchesRequestedBase(Rate $rate, mixed $actual, string $field = 'base'): bool
{
if (! is_string($actual)) {
return true;
}

$actual = (string) Str::of($actual)->trim()->upper();

if ($actual === '') {
return true;
}

if ($actual === $rate->base) {
return true;
}

$provider = static::PROVIDER ?? static::class;

Log::warning("[{$provider}] Response {$field} ({$actual}) differs from requested base ({$rate->base}); ignoring response.");

return false;
}
}
24 changes: 19 additions & 5 deletions src/Drivers/EuropeanCentralBank.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,28 @@ public function update(Command $command): void
(string) Str::of($this->getDatabasePath())->basename()
);

$response = Http::withOptions(['sink' => $path])->get(
$this->getDatabaseUrl()
);
$temporaryPath = tempnam($root, 'rates-ecb-');

throw_if(
$response->failed(),
new RuntimeException('Failed to download European Central Bank reference file. Response: '.$response->body())
$temporaryPath === false,
new RuntimeException('Failed to create a temporary European Central Bank reference file.')
);

$response = Http::withOptions(['sink' => $temporaryPath])->get(
$this->getDatabaseUrl()
);

if ($response->failed()) {
@unlink($temporaryPath);

throw new RuntimeException('Failed to download European Central Bank reference file. Response: '.$response->body());
}

if (! @rename($temporaryPath, $path)) {
@unlink($temporaryPath);

throw new RuntimeException('Failed to replace European Central Bank reference file.');
}
}

/**
Expand Down
Loading
Loading