diff --git a/README.md b/README.md index f192ed8..d2712ad 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ $money->decimals(); // 100.0 ### Formatting money -You can format money using the `->formatted()` method: +You can format money using the `->formatted()` method. It takes [display decimals](#display-decimals) into consideration. ```php $money = Money::fromDecimal(40.25, USD::class); @@ -166,6 +166,33 @@ $money = Money::fromDecimal(40.25, USD::class); $money->formatted(['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']); ``` +There's also `->rawFormatted()` if you wish to use [math decimals](#math-decimals) instead of [display decimals](#display-decimals). +```php +$money = Money::new(123456, CZK::class); +$money->rawFormatted(); // 1 234,56 Kč +``` + +Converting the formatted value back to the `Money` instance is also possible. The package tries to extract the currency from the provided string: +```php +$money = money(1000); +$formatted = $money->formatted(); // $10.00 +$fromFormatted = Money::fromFormatted($formatted); +$fromFormatted->is($money); // true +``` + +If you had passed overrides while [formatting the money instance](#formatting-money), the same can passed to this method. +```php +$money = money(1000); +$formatted = $money->formatted(['prefix' => '$ ', 'suffix' => ' USD']); // $ 10.00 USD +$fromFormatted = Money::fromFormatted($formatted, USD::class, ['prefix' => '$ ', 'suffix' => ' USD']); +$fromFormatted->is($money); // true +``` + +Notes: +1) If currency is not specified and none of the currencies match the prefix and suffix, an exception will be thrown. +2) If currency is not specified and multiple currencies use the same prefix and suffix, an exception will be thrown. +3) `fromFormatted()` misses the cents if the [math decimals](#math-decimals) are greater than [display decimals](#display-decimals). + ### Rounding money Some currencies, such as the Czech Crown (CZK), generally display final prices in full crowns, but use cents for the intermediate math operations. For example: @@ -414,7 +441,7 @@ For the Czech Crown (CZK), the display decimals will be `0`, but the math decima For the inverse of what was just explained above, you can use the `rawFormatted()` method. This returns the formatted value, **but uses the math decimals for the display decimals**. Meaning, the value in the example above will be displayed including cents: ```php -money(123456, new CZK)->rawFormatted(); // 1 235,56 Kč +money(123456, new CZK)->rawFormatted(); // 1 234,56 Kč ``` This is mostly useful for currencies like the Czech Crown which generally don't use cents, but **can** use them in specific cases. diff --git a/src/Exceptions/CannotExtractCurrencyException.php b/src/Exceptions/CannotExtractCurrencyException.php new file mode 100644 index 0000000..74a92fe --- /dev/null +++ b/src/Exceptions/CannotExtractCurrencyException.php @@ -0,0 +1,15 @@ +mathDecimals()), + (int) round($decimal * pow(10, currency($currency)->mathDecimals())), currency($currency) ); } @@ -154,7 +154,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable /** Get the decimal representation of the value. */ public function decimal(): float { - return $this->value / 10 ** $this->currency->mathDecimals(); + return $this->value / pow(10, $this->currency->mathDecimals()); } /** Format the value. */ @@ -171,6 +171,24 @@ final class Money implements JsonSerializable, Arrayable, Wireable ])); } + /** + * Create a Money instance from a formatted string. + * + * @param string $formatted The string formatted using the `formatted()` or `rawFormatted()` method. + * @param Currency|string|null $currency The currency to use when passing the overrides. If not provided, the currency of the formatted string is used. + * @param array ...$overrides The overrides used when formatting the money instance. + */ + public static function fromFormatted(string $formatted, Currency|string $currency = null, mixed ...$overrides): self + { + $currency = isset($currency) + ? currency($currency) + : PriceFormatter::extractCurrency($formatted); + + $decimal = PriceFormatter::resolve($formatted, $currency, variadic_array($overrides)); + + return static::fromDecimal($decimal, currency($currency)); + } + /** Get the string representation of the Money instance. */ public function __toString(): string { @@ -228,7 +246,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable return $this ->divideBy($this->currency->rate()) - ->divideBy(10 ** $mathDecimalDifference) + ->divideBy(pow(10, $mathDecimalDifference)) ->value(); } @@ -240,7 +258,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable $mathDecimalDifference = $newCurrency->mathDecimals() - currencies()->getDefault()->mathDecimals(); return new static( - (int) round($this->valueInDefaultCurrency() * $newCurrency->rate() * 10 ** $mathDecimalDifference, 0), + (int) round($this->valueInDefaultCurrency() * $newCurrency->rate() * pow(10, $mathDecimalDifference), 0), $currency ); } diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index 28fcd23..b01010f 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace ArchTech\Money; +use ArchTech\Money\Exceptions\CannotExtractCurrencyException; +use Exception; + class PriceFormatter { /** Format a decimal per the currency's specifications. */ @@ -22,4 +25,44 @@ class PriceFormatter return $currency->prefix() . $decimal . $currency->suffix(); } + + /** Extract the decimal from the formatted string as per the currency's specifications. */ + public static function resolve(string $formatted, Currency $currency, array $overrides = []): float + { + $currency = Currency::fromArray( + array_merge(currency($currency)->toArray(), $overrides) + ); + + $formatted = ltrim($formatted, $currency->prefix()); + $formatted = rtrim($formatted, $currency->suffix()); + + $removeNonDigits = preg_replace('/[^\d' . preg_quote($currency->decimalSeparator()) . ']/', '', $formatted); + + if (! is_string($removeNonDigits)) { + throw new Exception('The formatted string could not be resolved to a valid number.'); + } + + return (float) str_replace($currency->decimalSeparator(), '.', $removeNonDigits); + } + + /** Tries to extract the currency from the formatted string. */ + public static function extractCurrency(string $formatted): Currency + { + $possibleCurrency = null; + + foreach (currencies()->all() as $currency) { + if ( + str_starts_with($formatted, $currency->prefix()) + && str_ends_with($formatted, $currency->suffix()) + ) { + if ($possibleCurrency) { + throw new CannotExtractCurrencyException("Multiple currencies are using the same prefix and suffix as '$formatted'. Please specify the currency of the formatted string."); + } + + $possibleCurrency = $currency; + } + } + + return $possibleCurrency ?? throw new CannotExtractCurrencyException("None of the currencies are using the prefix and suffix that would match with the formatted string '$formatted'."); + } } diff --git a/tests/Pest/MoneyTest.php b/tests/Pest/MoneyTest.php index a860ee6..b8055f1 100644 --- a/tests/Pest/MoneyTest.php +++ b/tests/Pest/MoneyTest.php @@ -1,6 +1,7 @@ toBe('10,34 Kč'); }); +test('money can be created from a formatted string', function () { + $money = Money::fromFormatted('$10.40'); + expect($money->value())->toBe(1040); +}); + +test('money can be created from a raw formatted string', function () { + currencies()->add([CZK::class]); + + $money = Money::fromFormatted('1 234,56 Kč', CZK::class); + expect($money->value())->toBe(123456); +}); + +test('an exception is thrown if none of the currencies match the prefix and suffix', function () { + $money = money(1000); + $formatted = $money->formatted(); + + currencies()->remove(USD::class); + + pest()->expectException(CannotExtractCurrencyException::class); + Money::fromFormatted($formatted); +}); + +test('an exception is thrown if multiple currencies are using the same prefix and suffix', function () { + currencies()->add(['code' => 'USD2', 'name' => 'USD2', 'prefix' => '$']); + $money = money(1000); + + pest()->expectException(CannotExtractCurrencyException::class); + Money::fromFormatted($money->formatted()); +}); + test('converting money to a string returns the formatted string', function () { expect( (string) Money::fromDecimal(10.00, USD::class)