Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow extending Money with custom currency provider #95

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

tigitz
Copy link

@tigitz tigitz commented Oct 21, 2024

Superseeds #94

This is an attempt to solve #30 and #61

First, we need to accept that the Money and Currency objects cannot be final. In reality, they take on different shapes and forms. The current Brick\Money\Money implementation is built around ISO 4217 currencies, but users are trying to adapt it for other purposes, like cryptocurrencies or historical currencies. The reason is simple: the underlying Brick\Money manipulation engine is extremely versatile and would work just as well with those alternative currency types. This is a valid and logical use case.

Secondly, with the main methods for object creation being static, proper dependency injection to configure a global default behavior becomes impossible.

The quickest solution without a major refactor would be to introduce inheritance as an extension point. This allows users to provide custom currency providers tailored to their use cases. The library itself could leverage this to introduce new types of currencies, such as the historic ISO 4217 currencies discussed here: #93 (comment).

It uses inheritance as a point of extension to define the CurrencyProvider used and adapt the code base to rely on a Currency interface instead of the previous Currency (which was actually IsoCurrency).

The static logic for creating Money remains closely tied to IsoCurrencyProvider to prevent a more extensive breaking change.
This approach balances the need for those interested in distinguishing between Money and IsoMoney, while allowing other users to continue using Money as they have since the library's inception, without needing to address this distinction in their context.

You can now do:

class CryptoCurrency implements Currency {

    public function __construct(private string $code, private int $fractionDigits)
    {
        
    }
    public function getCode(): string
    {
        return $this->code;
    }

    public function getDefaultFractionDigits(): int
    {
        return $this->fractionDigits;
    }

    public function is(Currency $currency): bool
    {
        return $currency->getCode() === $this->code;
    }

    public function __toString()
    {
        return $this->code;
    }

    public function jsonSerialize(): mixed
    {
        return $this->code;
    }
}

class CryptoCurrencyProvider implements CurrencyProvider {
    public function getByCode(int|string $code): Currency
    {
        return match ($code) {
            'BTC' => new CryptoCurrency('BTC',  8),
            'ETH' => new CryptoCurrency('ETH', 8),
            default => throw new \InvalidArgumentException("Unknown currency code '$code'"),
        };
    }
}

class CryptoMoney extends \Brick\Money\Money {
    public static function getCurrencyProvider(): CurrencyProvider
    {
        return new CryptoCurrencyProvider();
    }
}

// 'BTC' will be resolved internally to CryptoCurrency thanks to Money#getCurrencyProvider()
$btcs = CryptoMoney::of('0.11223', 'BTC');
// By default Money#getCurrencyProvider() is using IsoCurrencyProvider so 'EUR' will be resolved internally to IsoCurrency
$euros = Money::of('20', 'EUR');

$moneyBag = new MoneyBag();
$moneyBag->add($btcs);
$moneyBag->add($euros);

echo json_encode($moneyBag->getAmounts())."\n";

$exchangeRateProvider = new class implements \Brick\Money\ExchangeRateProvider {
    public function getExchangeRate(string $sourceCurrencyCode, string $targetCurrencyCode): \Brick\Math\BigRational {
        // Mock exchange rate: 1 BTC = 30000 EUR
        return \Brick\Math\BigRational::of('30000');
    }
};

$currencyConverter = new CurrencyConverter($exchangeRateProvider);

// Convert BTC to EUR
$convertedEuros = $currencyConverter->convert($btcs, IsoCurrency::of('EUR'));

echo "Converted BTC to EUR: " . $convertedEuros->getAmount() . " EUR\n";

It has the following breaking changes:

  • Currency has been renamed to IsoCurrency to better reflect its role in representing ISO 4217 currencies
  • Currency is now an interface, with IsoCurrency serving as its concrete implementation.
  • Currency#getCurrencyCode() is now Currency#getCode() for clarity
  • UnknownCurrencyException has been renamed to UnknownIsoCurrencyException to align with its specific use for IsoCurrency types
  • ISOCurrencyProvider has been renamed to IsoCurrencyProvider to adhere to the upcoming PSR rules for capitalization of abbreviations

Previously, allowing string|int as currency necessitated objects to identify the corresponding IsoCurrency, creating a tight coupling with a specific IsoCurrencyProvider implementation and a mix of unwanted responsibility for the object. To move towards a more currency-agnostic architecture, it is now advised to resolve the currency beforehand.

  • The parameter $currency in Currency#is() has been refined to accept only Currency, removing support for string|int
  • The parameter $currency in CurrencyConverter#convert() has been refined to accept only Currency, removing support for string|int
  • The parameter $currency in CurrencyConverter#convertToRational() has been refined to accept only Currency, removing support for string|int

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant