azaharizaman / nexus-currency
Framework-agnostic ISO 4217-compliant currency management and exchange rate engine for the Nexus ERP system
Requires
- php: ^8.3
- azaharizaman/nexus-common: dev-main
- psr/log: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- azaharizaman/nexus-accounting: dev-main
This package is auto-updated.
Last update: 2026-05-05 02:43:58 UTC
README
Framework-agnostic ISO 4217-compliant currency management and exchange rate engine for the Nexus ERP system.
Overview
The Nexus\Currency package provides authoritative currency metadata and exchange rate management following ISO 4217 standards. It complements the existing Nexus\Finance\ValueObjects\Money implementation by providing validation, formatting, and exchange rate coordination without replacing core financial value objects.
Key Features
- ISO 4217 Compliance: Full support for international currency standards
- Decimal Precision Rules: Correct handling of 0-decimal (JPY), 2-decimal (USD), and 3-decimal (BHD) currencies
- Exchange Rate Management: Pluggable provider architecture for external rate APIs
- Intelligent Caching: Configurable rate storage to minimize API calls
- Framework-Agnostic: Pure PHP with no Laravel dependencies
- BCMath Compatible: Designed to work with high-precision monetary calculations
- Stateless Design: All services are stateless and horizontally scalable
Architecture
This package follows the "Logic in Packages, Implementation in Applications" pattern:
┌─────────────────────────────────────────────────────────────┐
│ Nexus\Currency │
│ (ISO 4217 Authority & Exchange Rate Coordination) │
└────────────────────┬────────────────────────────────────────┘
│ provides metadata
↓
┌─────────────────────────────────────────────────────────────┐
│ Nexus\Finance │
│ (Money & ExchangeRate Value Objects) │
└────────────────────┬────────────────────────────────────────┘
│ used by
↓
┌─────────────────────────────────────────────────────────────┐
│ Nexus\Accounting, Nexus\Payroll, Nexus\Procurement, etc. │
└─────────────────────────────────────────────────────────────┘
Integration with Nexus\Finance
The Nexus\Currency package augments (not replaces) existing financial components:
Nexus\Finance\ValueObjects\Moneyremains the core monetary value objectNexus\Finance\ValueObjects\ExchangeRateremains the core rate value objectNexus\Currencyprovides:- Currency metadata (symbols, names, decimal rules)
- Currency code validation
- Exchange rate provider coordination
- Formatting utilities
What This Package Provides
CurrencyManager: High-level currency operations (validation, formatting, metadata)ExchangeRateService: Exchange rate lookup with caching and conversion- Value Objects:
Currency(metadata),CurrencyPair(pair representation) - Contracts: Repository, Provider, and Storage interfaces
- Exceptions: Domain-specific errors with static factories
What the Application Must Implement
The consuming application (Nexus\Atomy) must provide:
- Currency Repository: Database-backed implementation with ISO 4217 seed data
- Exchange Rate Provider: Integration with external APIs (ECB, Fixer.io, etc.) using
Nexus\Connector - Rate Storage: Redis/Database caching implementation
- Service Bindings: IoC container bindings in service provider
Installation
composer require azaharizaman/nexus-currency:"*@dev"
Requirements
- PHP 8.3 or higher
- BCMath extension (for precision calculations)
- PSR-3 Logger implementation (optional)
Core Components
1. Currency Value Object
Immutable representation of ISO 4217 currency metadata:
use Nexus\Currency\ValueObjects\Currency; // Currency with full ISO 4217 data $usd = new Currency( code: 'USD', name: 'US Dollar', symbol: '$', decimalPlaces: 2, numericCode: '840' ); // Access metadata echo $usd->getCode(); // "USD" echo $usd->getSymbol(); // "$" echo $usd->getDecimalPlaces(); // 2 // Format amounts echo $usd->formatAmount('1234.56'); // "$ 1,234.56" echo $usd->formatAmount('1234.56', false, true); // "1,234.56 USD" // Check decimal type $jpy = new Currency('JPY', 'Japanese Yen', '¥', 0, '392'); $jpy->isZeroDecimal(); // true
2. CurrencyPair Value Object
Represents a currency exchange pair:
use Nexus\Currency\ValueObjects\CurrencyPair; // Create pair $pair = new CurrencyPair('USD', 'EUR'); // Or from string notation $pair = CurrencyPair::fromString('USD/EUR'); // Access components echo $pair->getFromCode(); // "USD" echo $pair->getToCode(); // "EUR" echo $pair->toString(); // "USD/EUR" // Get inverse $inverse = $pair->inverse(); // EUR/USD
3. CurrencyManager Service
High-level currency management:
use Nexus\Currency\Services\CurrencyManager; $manager = app(CurrencyManager::class); // Get currency metadata $usd = $manager->getCurrency('USD'); // Validate currency code $manager->validateCode('USD'); // void (success) $manager->validateCode('XXX'); // throws CurrencyNotFoundException // Check existence $manager->exists('EUR'); // true $manager->exists('ZZZ'); // false // Get decimal precision $precision = $manager->getDecimalPrecision('JPY'); // 0 // Format amounts $formatted = $manager->formatAmount('1234.5678', 'USD'); // "$ 1,234.57" (rounded to 2 decimals) // Get all currencies $all = $manager->getAllCurrencies(); // Search currencies $results = $manager->searchCurrencies('dollar');
4. ExchangeRateService
Exchange rate lookup and conversion:
use Nexus\Currency\Services\ExchangeRateService; use Nexus\Currency\ValueObjects\CurrencyPair; use Nexus\Finance\ValueObjects\Money; $service = app(ExchangeRateService::class); // Get current exchange rate $pair = new CurrencyPair('USD', 'EUR'); $rate = $service->getRate($pair); // Returns Nexus\Finance\ValueObjects\ExchangeRate // Get historical rate $date = new DateTimeImmutable('2024-01-15'); $historicalRate = $service->getRate($pair, $date); // Convert money $usd = Money::of(100, 'USD'); $eur = $service->convert($usd, 'EUR'); // Money in EUR // Get multiple rates (batch operation) $pairs = [ new CurrencyPair('USD', 'EUR'), new CurrencyPair('USD', 'GBP'), new CurrencyPair('USD', 'JPY'), ]; $rates = $service->getRates($pairs); // Refresh specific rates (bypass cache) $service->refreshRates($pairs); // Clear all cached rates $service->clearCache(); // Check provider capabilities $service->supportsHistoricalRates(); // true/false $service->getProviderName(); // "ECB" or "Fixer.io" $service->isProviderAvailable(); // true/false
Application Integration
Step 1: Implement Currency Repository
Create a database-backed repository in apps/Atomy:
namespace App\Repositories; use Nexus\Currency\Contracts\CurrencyRepositoryInterface; use Nexus\Currency\ValueObjects\Currency; use Illuminate\Support\Facades\DB; class DbCurrencyRepository implements CurrencyRepositoryInterface { public function findByCode(string $code): ?Currency { $row = DB::table('currencies') ->where('code', $code) ->where('is_active', true) ->first(); if (!$row) { return null; } return new Currency( code: $row->code, name: $row->name, symbol: $row->symbol, decimalPlaces: $row->decimal_places, numericCode: $row->numeric_code ); } public function getAll(): array { $rows = DB::table('currencies') ->where('is_active', true) ->get(); $currencies = []; foreach ($rows as $row) { $currencies[$row->code] = new Currency( code: $row->code, name: $row->name, symbol: $row->symbol, decimalPlaces: $row->decimal_places, numericCode: $row->numeric_code ); } return $currencies; } public function exists(string $code): bool { return DB::table('currencies') ->where('code', $code) ->where('is_active', true) ->exists(); } // Implement other methods... }
Step 2: Create Database Migration
// database/migrations/xxxx_create_currencies_table.php Schema::create('currencies', function (Blueprint $table) { $table->string('code', 3)->primary(); $table->string('name', 100); $table->string('symbol', 10); $table->tinyInteger('decimal_places')->default(2); $table->string('numeric_code', 3); $table->boolean('is_active')->default(true); $table->timestamps(); });
Step 3: Seed ISO 4217 Data
// database/seeders/CurrencySeeder.php DB::table('currencies')->insert([ ['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$', 'decimal_places' => 2, 'numeric_code' => '840'], ['code' => 'EUR', 'name' => 'Euro', 'symbol' => '€', 'decimal_places' => 2, 'numeric_code' => '978'], ['code' => 'GBP', 'name' => 'British Pound', 'symbol' => '£', 'decimal_places' => 2, 'numeric_code' => '826'], ['code' => 'JPY', 'name' => 'Japanese Yen', 'symbol' => '¥', 'decimal_places' => 0, 'numeric_code' => '392'], ['code' => 'MYR', 'name' => 'Malaysian Ringgit', 'symbol' => 'RM', 'decimal_places' => 2, 'numeric_code' => '458'], ['code' => 'SGD', 'name' => 'Singapore Dollar', 'symbol' => 'S$', 'decimal_places' => 2, 'numeric_code' => '702'], ['code' => 'CNY', 'name' => 'Chinese Yuan', 'symbol' => '¥', 'decimal_places' => 2, 'numeric_code' => '156'], ['code' => 'BHD', 'name' => 'Bahraini Dinar', 'symbol' => 'BD', 'decimal_places' => 3, 'numeric_code' => '048'], // Add more currencies as needed... ]);
Step 4: Implement Exchange Rate Provider
Using Nexus\Connector for resilient API integration:
namespace App\Services\ExchangeRates; use Nexus\Currency\Contracts\ExchangeRateProviderInterface; use Nexus\Currency\ValueObjects\CurrencyPair; use Nexus\Finance\ValueObjects\ExchangeRate; use Nexus\Connector\Services\ConnectorManager; use DateTimeImmutable; class EcbExchangeRateProvider implements ExchangeRateProviderInterface { public function __construct( private readonly ConnectorManager $connector ) {} public function getRate(CurrencyPair $pair, ?DateTimeImmutable $asOf = null): ExchangeRate { // Use Nexus\Connector with circuit breaker and retry logic $response = $this->connector->execute( connectionId: 'ecb-api', method: 'GET', endpoint: $asOf ? "/history/{$asOf->format('Y-m-d')}" : '/latest', params: [ 'base' => $pair->getFromCode(), 'symbols' => $pair->getToCode(), ] ); $data = json_decode($response->getBody(), true); if (!isset($data['rates'][$pair->getToCode()])) { throw ExchangeRateNotFoundException::forPair($pair, $asOf); } return ExchangeRate::create( fromCurrency: $pair->getFromCode(), toCurrency: $pair->getToCode(), rate: $data['rates'][$pair->getToCode()], effectiveDate: $asOf ?? new DateTimeImmutable() ); } public function supportsHistoricalRates(): bool { return true; } public function getProviderName(): string { return 'European Central Bank'; } public function isAvailable(): bool { return $this->connector->isHealthy('ecb-api'); } // Implement getRates()... }
Step 5: Implement Rate Storage (Redis)
namespace App\Services\ExchangeRates; use Nexus\Currency\Contracts\RateStorageInterface; use Nexus\Currency\ValueObjects\CurrencyPair; use Nexus\Finance\ValueObjects\ExchangeRate; use Illuminate\Support\Facades\Redis; use DateTimeImmutable; class RedisRateStorage implements RateStorageInterface { private const PREFIX = 'exchange_rate:'; public function get(CurrencyPair $pair, ?DateTimeImmutable $asOf = null): ?ExchangeRate { $key = $this->buildKey($pair, $asOf); $data = Redis::get($key); if (!$data) { return null; } $decoded = json_decode($data, true); return ExchangeRate::create( fromCurrency: $decoded['from'], toCurrency: $decoded['to'], rate: $decoded['rate'], effectiveDate: new DateTimeImmutable($decoded['date']) ); } public function put(CurrencyPair $pair, ExchangeRate $rate, int $ttl = 3600): bool { $key = $this->buildKey($pair, $rate->getEffectiveDate()); $data = json_encode([ 'from' => $rate->getFromCurrency(), 'to' => $rate->getToCurrency(), 'rate' => $rate->getRate(), 'date' => $rate->getEffectiveDate()->format('Y-m-d H:i:s'), ]); return Redis::setex($key, $ttl, $data); } public function forget(CurrencyPair $pair, ?DateTimeImmutable $asOf = null): bool { $key = $this->buildKey($pair, $asOf); return Redis::del($key) > 0; } public function flush(): bool { $keys = Redis::keys(self::PREFIX . '*'); return count($keys) > 0 ? Redis::del($keys) > 0 : true; } public function has(CurrencyPair $pair, ?DateTimeImmutable $asOf = null): bool { $key = $this->buildKey($pair, $asOf); return Redis::exists($key) > 0; } private function buildKey(CurrencyPair $pair, ?DateTimeImmutable $asOf): string { $dateStr = $asOf ? $asOf->format('Y-m-d') : 'current'; return self::PREFIX . $pair->toString() . ':' . $dateStr; } }
Step 6: Bind Implementations in Service Provider
namespace App\Providers; use Illuminate\Support\ServiceProvider; use Nexus\Currency\Contracts\CurrencyRepositoryInterface; use Nexus\Currency\Contracts\ExchangeRateProviderInterface; use Nexus\Currency\Contracts\RateStorageInterface; use Nexus\Currency\Contracts\CurrencyManagerInterface; use Nexus\Currency\Services\CurrencyManager; use Nexus\Currency\Services\ExchangeRateService; use App\Repositories\DbCurrencyRepository; use App\Services\ExchangeRates\EcbExchangeRateProvider; use App\Services\ExchangeRates\RedisRateStorage; class CurrencyServiceProvider extends ServiceProvider { public function register(): void { // Bind repository $this->app->singleton( CurrencyRepositoryInterface::class, DbCurrencyRepository::class ); // Bind exchange rate provider $this->app->singleton( ExchangeRateProviderInterface::class, EcbExchangeRateProvider::class ); // Bind rate storage $this->app->singleton( RateStorageInterface::class, RedisRateStorage::class ); // Bind currency manager $this->app->singleton( CurrencyManagerInterface::class, CurrencyManager::class ); // Bind exchange rate service $this->app->singleton(ExchangeRateService::class); } }
Integration with Nexus\Finance
Enhancing Money Validation
Update Nexus\Finance\ValueObjects\Money to use CurrencyManager:
// In Nexus\Finance\ValueObjects\Money use Nexus\Currency\Contracts\CurrencyManagerInterface; private function validateCurrency(string $currency): void { // Get CurrencyManager from container (or inject if refactoring to service) $currencyManager = app(CurrencyManagerInterface::class); // Delegate validation to Currency package try { $currencyManager->validateCode($currency); } catch (\Nexus\Currency\Exceptions\InvalidCurrencyCodeException $e) { throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } catch (\Nexus\Currency\Exceptions\CurrencyNotFoundException $e) { throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } }
Currency-Aware Formatting
Add a formatting method to Money that uses currency metadata:
// In Nexus\Finance\ValueObjects\Money public function formatWithCurrency(CurrencyManagerInterface $currencyManager): string { return $currencyManager->formatAmount( amount: $this->amount, currencyCode: $this->currency, includeSymbol: true, includeCode: false ); }
Usage:
$money = Money::of(1234.5678, 'USD'); $currencyManager = app(CurrencyManagerInterface::class); echo $money->formatWithCurrency($currencyManager); // "$ 1,234.57" $jpy = Money::of(1234.5678, 'JPY'); echo $jpy->formatWithCurrency($currencyManager); // "¥ 1,235" (zero decimals)
Decimal Precision Strategy
The 4-Decimal Internal Standard
Nexus\Finance\ValueObjects\Money uses 4 decimal places for all internal BCMath calculations:
// In Money VO private const PRECISION = 4;
Why 4 decimals internally?
- Prevents rounding errors in complex calculations (tax, interest, multi-step conversions)
- Allows accurate intermediate results for financial operations
- Industry standard for accounting systems
Currency-Specific Display Precision
Nexus\Currency provides the correct display precision per ISO 4217:
$usd = $currencyManager->getCurrency('USD'); $usd->getDecimalPlaces(); // 2 $jpy = $currencyManager->getCurrency('JPY'); $jpy->getDecimalPlaces(); // 0 $bhd = $currencyManager->getCurrency('BHD'); $bhd->getDecimalPlaces(); // 3 (Bahraini Dinar)
Best Practice: Calculate at 4, Display per Currency
// Internal calculation (4 decimals) $subtotal = Money::of(100.00, 'USD'); $tax = $subtotal->multiply(0.06); // 6.0000 $total = $subtotal->add($tax); // 106.0000 // Display with currency-specific precision (2 decimals for USD) $formatted = $total->formatWithCurrency($currencyManager); // Result: "$ 106.00" // For JPY (0 decimals) $jpyTotal = Money::of(1234.5678, 'JPY'); $formatted = $jpyTotal->formatWithCurrency($currencyManager); // Result: "¥ 1,235" (rounded, no decimals)
Error Handling
All exceptions provide static factory methods for contextual error messages:
use Nexus\Currency\Exceptions\CurrencyNotFoundException; use Nexus\Currency\Exceptions\InvalidCurrencyCodeException; use Nexus\Currency\Exceptions\ExchangeRateNotFoundException; use Nexus\Currency\Exceptions\ExchangeRateProviderException; try { $currency = $manager->getCurrency('XXX'); } catch (CurrencyNotFoundException $e) { // "Currency with code 'XXX' not found. Ensure it exists in the currency repository." } try { $manager->validateCode('12'); } catch (InvalidCurrencyCodeException $e) { // "Currency code must be 3 characters, got 2: '12'" } try { $rate = $service->getRate(new CurrencyPair('USD', 'FAKE')); } catch (ExchangeRateNotFoundException $e) { // "Exchange rate not found for currency pair USD/FAKE..." } try { $rate = $service->getRate(new CurrencyPair('USD', 'EUR')); } catch (ExchangeRateProviderException $e) { // "Exchange rate provider 'ECB' API failed. Please try again later." }
Testing
Unit Testing with In-Memory Repository
For package unit tests, create a simple in-memory implementation:
use Nexus\Currency\Contracts\CurrencyRepositoryInterface; use Nexus\Currency\ValueObjects\Currency; class InMemoryCurrencyRepository implements CurrencyRepositoryInterface { private array $currencies = []; public function __construct() { // Seed with common currencies for testing $this->currencies = [ 'USD' => new Currency('USD', 'US Dollar', '$', 2, '840'), 'EUR' => new Currency('EUR', 'Euro', '€', 2, '978'), 'JPY' => new Currency('JPY', 'Japanese Yen', '¥', 0, '392'), 'MYR' => new Currency('MYR', 'Malaysian Ringgit', 'RM', 2, '458'), ]; } public function findByCode(string $code): ?Currency { return $this->currencies[$code] ?? null; } public function getAll(): array { return $this->currencies; } public function exists(string $code): bool { return isset($this->currencies[$code]); } // Implement other methods... }
Documentation
📚 Complete Documentation
- Getting Started Guide - Quick start, installation, and basic configuration
- API Reference - Complete interface and method documentation
- Integration Guide - Laravel, Symfony, and custom framework integration
- Code Examples - Practical usage examples
- Basic Usage - Currency validation, formatting, and retrieval
- Advanced Usage - Exchange rates, conversion, and caching
- Requirements - Detailed functional and architectural requirements
- Implementation Summary - Development progress and metrics
- Test Suite Summary - Testing strategy and coverage
- Valuation Matrix - Package valuation and ROI metrics
🔗 Quick Links
| Documentation | Description |
|---|---|
| Prerequisites | System requirements and dependencies |
| Core Concepts | ISO 4217, non-breaking augmentation, caching |
| Configuration Steps | Complete setup guide |
| Laravel Integration | Laravel migrations, repositories, service providers |
| Symfony Integration | Symfony entities, services, configuration |
| Testing Examples | Unit and integration test examples |
| Troubleshooting | Common issues and solutions |
License
MIT License - see LICENSE file for details.
Credits
Developed by the Nexus Development Team for the Nexus ERP System.
Related Packages
- Nexus\Finance - Core financial value objects (Money, ExchangeRate)
- Nexus\Accounting - Multi-currency financial statements
- Nexus\Connector - Resilient API integration with circuit breaker
- Nexus\Tenant - Multi-tenancy with per-tenant base currency