brick / money

A money and currency library for PHP
MIT License
1.61k stars 96 forks source link

No support for adding CurrencyProviders #61

Open devsi opened 2 years ago

devsi commented 2 years ago

Hi,

Although you do support custom currencies through new Currency, the project does not support the expectation that the Money class may not rely on the ISOCurrencyProvider, and could instead rely on a CurrencyProviderInterface.

I propose making ISOCurrencyProvider an implementation of CurrencyProviderInterface, and setting it to default to maintain the existing functionality, and then enable users to set their own Concrete CurrencyProvider to allow for additional options.

This would cover the case where cryptocurrencies (popularity rising as they are!) can be built using the interface and be validated correctly, instead of having to manually override the string currency with a new Currency() every time. This is for sure necessary as a developer when you want to dynamically load a list of available crypto's. I wouldn't expect the CryptoCurrencyProvider to be a part of this project, but at least the Interface to support additional non standard currencies.

I would be more than happy to pick this work up and submit a PR. I am writing it anyway for my local project but without the reliance on the Interface :)

Thanks.

BenMorel commented 2 years ago

Hi, I don't have a problem with introducing a CurrencyProviderInterface, however I'm reluctant to inject it globally so that it can be used in Money::of() as a default value. I'm against using any kind of global state here.

IMO, if you're using something else than ISO currencies, you should create your Monies from a factory that gets the provider injected, not from Money::of().

devsi commented 2 years ago

Absolutely I agree. I wouldn't want a secondary provider to come in through Money::of either That makes no sense to me. However coupling the Provider of Currency to the Money object I think would be beneficial, which means there should be an easy way to override the default. As Money::of is the general way of doing this, and there's no state to the Money object (agree there too). I think you're right in that a Factory would be the way to go, but would need a way to create a Money object that uses a different provider without maintaining state of that provider.

Any suggestions as to how such a factory might work? If Money::of doesn't take the provider, but its the way to generate a new object, how does one switch the Provider out?

For my project, i have done the following, any alternative to this?:

function factoryFunc($amount, string $currency, $context, $roundingMode) {
    // getCurrencyFromCode fetches `new Currency` from a Collection of a few different CurrencyProviders
    $currency = $this->getCurrencyFromCode($currency);

    return Money::of($amount, $currency, $context, $roundingMode);
}
puzzledmonkey commented 1 year ago

Hi! Nice library, thanks.

However, version 0.8.0 completely removes HRK from the ISO currencies. While this is correct, we have legacy data in Croatian Kuna, and we use Laravel Nova which interfaces directly to Money::of or Money::ofMinor and does not allow us to hook into this and provide a custom new Currency(...) easily.

Is it possible to implement an extension to ISOCurrencyProvider such as addCurrency(new Currency(...)); which adds the new currency to $currencyData for the singleton? This would allow us (or anyone else) to add currencies at boot time and not have to hook deeper into the system.

Thanks for considering!

BenMorel commented 1 year ago

@puzzledmonkey I'm generally against adding any kind of global state configuration, as it may have side effects if several parts of your application use brick/money.

What exactly prevents Laravel Nova from using a factory to create Money instances?

puzzledmonkey commented 1 year ago

Well I can't modify their code, so for now I've just made a small field class that extends their Currency field and overrides the method that uses Money::of.

I understand you're against any kind of global state configuration, but isn't that kind of what the ISOCurrentProvider singleton's currencyData provides? Of course it's up to you, but I just figured it would help people in our case where a legacy currency has been removed but needs supporting for the forseeable future too.

puzzledmonkey commented 1 year ago

Note: for now I just overrode the Nova Currency field method and add the custom currency in as necessary. However I'm not sure this is the best method either because I'm creating a new Currency object for each call to the field. A global setting would only need to instantiate it once.

hrvoj3e commented 2 weeks ago

So we should use it like this if HRK is needed?

if ('HRK' === $currency) {
    // https://github.com/brick/money/blob/eb7fe4cee92325589dada779000758c0e56b5508/data/iso-currencies.php#L63C5-L63C38
    $currency = new Brick\Money\Currency('HRK', 191, 'Kuna', 2);
} else {
  // leave as is
}

Brick\Money\Money::of($number, $currency);
puzzledmonkey commented 2 weeks ago

So we should use it like this if HRK is needed?

if ('HRK' === $currency) {
    // https://github.com/brick/money/blob/eb7fe4cee92325589dada779000758c0e56b5508/data/iso-currencies.php#L63C5-L63C38
    $currency = new Brick\Money\Currency('HRK', 191, 'Kuna', 2);
} else {
  // leave as is
}

Brick\Money\Money::of($number, $currency);

yes that's pretty much exactly the code i used to get this working

BenMorel commented 1 week ago

So we should use it like this if HRK is needed?

@hrvoj3e Pretty much, yes. I would advise to create a service that gets injected throughout your application, and centralize the currency logic there. It could look like this:

class CurrencyProvider
{
    public function getCurrency(string|int $currencyCode): Currency
    {
        if ('HRK' === $currencyCode) {
            return new Currency('HRK', 191, 'Kuna', 2);
        }

        return Currency::of($currencyCode);
    }
}

and pass the resulting Currency to Money::of().

The library could provide a CurrencyProviderInterface, as suggested by @devsi, but there aren't many ways in which it could be used by brick/money itself without using some kind of global configuration.

The only options I can think of:

  1. add yet another parameter to Money factory methods, which would accept an optional CurrencyProvider:
    class Money
    {
        public function of(
            BigNumber|int|float|string $amount,
            Currency|string|int $currency,
            ?Context $context = null,
            RoundingMode $roundingMode = RoundingMode::UNNECESSARY,
            ?CurrencyProviderInterface $currencyProvider = null,
        ) {
            ...
        }
    );

    Now you must remember to always pass the $currencyProvider argument:

    Money::of(50, 'HRK', currencyProvider: $myCurrencyProvider);
  2. or, in addition to the CurrencyProvider interface, provide a concrete MoneyFactory class, that takes a CurrencyProviderInterface implementation, and has methods to build Money objects that you can use in place of static Money factory methods:

    class MoneyFactory
    {
        public function __construct(
            private CurrencyProviderInterface $currencyProvider,
        ) {
        }
    
        public function of(
            BigNumber|int|float|string $amount,
            Currency|string|int $currency,
            ?Context $context = null,
            RoundingMode $roundingMode = RoundingMode::UNNECESSARY,
        ) {
            if (is_string($currency) || is_int($currency)) {
                $currency = $this->currencyProvider->getCurrency($currency);
            }
    
            return Money::of($amount, $currency, $context, $roundingMode);
        }
    
        // ...additional factory methods
    }

    Now you must always use the MoneyFactory instead of Money::of() and others:

    $moneyFactory->of(50, 'HRK');

If anything, I prefer option 1. At least we don't provide two ways of building Money objects.

In either case, you'll need support from your DI container to always have a CurrencyProvider or a MoneyFactory instance at hand (or use a singleton if you're into this).

Other ideas welcome!

puzzledmonkey commented 1 week ago

Other ideas welcome!

what about my idea above?

"Is it possible to implement an extension to ISOCurrencyProvider such as addCurrency(new Currency(...)); which adds the new currency to $currencyData for the singleton? This would allow us (or anyone else) to add currencies at boot time and not have to hook deeper into the system."

BenMorel commented 1 week ago

@puzzledmonkey Unfortunately, this falls under global configuration, which I try to avoid at all costs. ISOCurrencyProvider is fine though, as it's immutable.

BenMorel commented 1 week ago

By the way, I forgot about #30 which started the same conversation back in 2020.