From 8bdf0d1a2bc9ecb65b70b454c0743459b2691d05 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Wed, 9 Mar 2022 12:49:00 +0530 Subject: [PATCH 01/12] refactor: improve code readability --- src/Money.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Money.php b/src/Money.php index 94dff7b..886fdcd 100644 --- a/src/Money.php +++ b/src/Money.php @@ -36,7 +36,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable public static function fromDecimal(float $decimal, Currency|string $currency = null): self { return new static( - (int) round($decimal * 10 ** currency($currency)->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. */ @@ -228,7 +228,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable return $this ->divideBy($this->currency->rate()) - ->divideBy(10 ** $mathDecimalDifference) + ->divideBy(pow(10, $mathDecimalDifference)) ->value(); } @@ -240,7 +240,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 ); } From 38c6326a4dad29c3f56504a515f7ebe97b078092 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Wed, 9 Mar 2022 12:50:04 +0530 Subject: [PATCH 02/12] feature: money instance creation from a formatted string --- README.md | 20 ++++++++++++++++++-- src/Money.php | 8 ++++++++ src/PriceFormatter.php | 15 +++++++++++++++ tests/Pest/MoneyTest.php | 12 ++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f192ed8..5d9d93b 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,22 @@ $money = Money::fromDecimal(40.25, USD::class); $money->formatted(['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']); ``` +There is also a `->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 235,56 Kč +``` + +And converting the formatted value back to the Money instance is also possible: +```php +$money = money(1000); +$formatted = $money->formatted(); // $10.00 +$fromFormatted = Money::fromFormatted($formatted); +$fromFormatted->is($money); // true +``` + +Note: `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 +430,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/Money.php b/src/Money.php index 886fdcd..9eb22ab 100644 --- a/src/Money.php +++ b/src/Money.php @@ -171,6 +171,14 @@ final class Money implements JsonSerializable, Arrayable, Wireable ])); } + /** Create a Money instance from a formatted string. */ + public static function fromFormatted(string $formatted, Currency|string $currency = null, mixed ...$overrides): self + { + $decimal = PriceFormatter::resolve($formatted, currency($currency), variadic_array($overrides)); + + return static::fromDecimal($decimal, currency($currency)); + } + /** Get the string representation of the Money instance. */ public function __toString(): string { diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index 28fcd23..de243cc 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -22,4 +22,19 @@ class PriceFormatter return $currency->prefix() . $decimal . $currency->suffix(); } + + /** Extract the decimal from the formatter 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); + + return (float) str_replace($currency->decimalSeparator(), '.', $removeNonDigits); + } } diff --git a/tests/Pest/MoneyTest.php b/tests/Pest/MoneyTest.php index a860ee6..4ab046a 100644 --- a/tests/Pest/MoneyTest.php +++ b/tests/Pest/MoneyTest.php @@ -147,6 +147,18 @@ test('money can be formatted without rounding', function () { )->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('converting money to a string returns the formatted string', function () { expect( (string) Money::fromDecimal(10.00, USD::class) From 01fd16f71bc5fdfbb8bddbb65e5777259b45d5fb Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Wed, 9 Mar 2022 07:20:34 +0000 Subject: [PATCH 03/12] Fix code style (php-cs-fixer) --- src/PriceFormatter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index de243cc..6de4961 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -33,7 +33,7 @@ class PriceFormatter $formatted = ltrim($formatted, $currency->prefix()); $formatted = rtrim($formatted, $currency->suffix()); - $removeNonDigits = preg_replace('/[^\d'.preg_quote($currency->decimalSeparator()).']/', '', $formatted); + $removeNonDigits = preg_replace('/[^\d' . preg_quote($currency->decimalSeparator()) . ']/', '', $formatted); return (float) str_replace($currency->decimalSeparator(), '.', $removeNonDigits); } From 77bec6ed02cc8bb41bbd4362e79cdd1f62053d99 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Wed, 9 Mar 2022 13:06:40 +0530 Subject: [PATCH 04/12] fix: make PHPStan happy by protecting against some rare scenarios --- src/PriceFormatter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index de243cc..2bff95b 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -35,6 +35,10 @@ class PriceFormatter $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); } } From fa337d29b476f21fe8a6c9e0fe150bf9e6c9ff01 Mon Sep 17 00:00:00 2001 From: PHP CS Fixer Date: Wed, 9 Mar 2022 07:37:04 +0000 Subject: [PATCH 05/12] Fix code style (php-cs-fixer) --- src/PriceFormatter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index 24027ab..d4e2d69 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -35,7 +35,7 @@ class PriceFormatter $removeNonDigits = preg_replace('/[^\d' . preg_quote($currency->decimalSeparator()) . ']/', '', $formatted); - if (!is_string($removeNonDigits)) { + if (! is_string($removeNonDigits)) { throw new \Exception('The formatted string could not be resolved to a valid number.'); } From 93aeb2c300719450707701a4e67a1d077851b2ba Mon Sep 17 00:00:00 2001 From: Gaurav Date: Thu, 10 Mar 2022 11:39:53 +0530 Subject: [PATCH 06/12] feat: extract currency from the formatted string --- README.md | 15 +++++++++++++-- src/Money.php | 4 ++++ src/PriceFormatter.php | 23 ++++++++++++++++++++++- tests/Pest/MoneyTest.php | 18 ++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5d9d93b..7cf6a33 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ $money = Money::new(123456, CZK::class); $money->rawFormatted(); // 1 235,56 Kč ``` -And converting the formatted value back to the Money instance is also possible: +And 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 @@ -180,7 +180,18 @@ $fromFormatted = Money::fromFormatted($formatted); $fromFormatted->is($money); // true ``` -Note: `fromFormatted()` misses the cents if the [math decimals](#math-decimals) are greater than [display decimals](#display-decimals). +Optional overrides for the [currency specification](#currency-logic) are accepted too. +```php +$money = money(1000); +$formatted = $money->formatted(); // $10.00 +$fromFormatted = Money::fromFormatted($formatted, USD::class, ['decimalSeparator' => ',', '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 diff --git a/src/Money.php b/src/Money.php index 9eb22ab..0f66604 100644 --- a/src/Money.php +++ b/src/Money.php @@ -174,6 +174,10 @@ final class Money implements JsonSerializable, Arrayable, Wireable /** Create a Money instance from a formatted string. */ 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($currency), variadic_array($overrides)); return static::fromDecimal($decimal, currency($currency)); diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index d4e2d69..709a10a 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -23,7 +23,7 @@ class PriceFormatter return $currency->prefix() . $decimal . $currency->suffix(); } - /** Extract the decimal from the formatter string as per the currency's specifications. */ + /** 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( @@ -41,4 +41,25 @@ class PriceFormatter 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 \Exception('Multiple currencies are using the same prefix and suffix. Please specify the currency of the formatted string.'); + } + + $possibleCurrency = $currency; + } + } + + return $possibleCurrency ?? throw new \Exception('None of the currencies are using the prefix and suffix that would match with the formatted string.'); + } } diff --git a/tests/Pest/MoneyTest.php b/tests/Pest/MoneyTest.php index 4ab046a..586b8b7 100644 --- a/tests/Pest/MoneyTest.php +++ b/tests/Pest/MoneyTest.php @@ -159,6 +159,24 @@ test('money can be created from a raw formatted string', function () { 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(\Exception::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(\Exception::class); + Money::fromFormatted($money->formatted()); +}); + test('converting money to a string returns the formatted string', function () { expect( (string) Money::fromDecimal(10.00, USD::class) From a8d32a0be83dc5a89b754b2a55b3359cc3584b19 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Thu, 10 Mar 2022 11:45:39 +0530 Subject: [PATCH 07/12] fix: no more currency guessing --- src/Money.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Money.php b/src/Money.php index 0f66604..9708188 100644 --- a/src/Money.php +++ b/src/Money.php @@ -178,7 +178,7 @@ final class Money implements JsonSerializable, Arrayable, Wireable ? currency($currency) : PriceFormatter::extractCurrency($formatted); - $decimal = PriceFormatter::resolve($formatted, currency($currency), variadic_array($overrides)); + $decimal = PriceFormatter::resolve($formatted, $currency, variadic_array($overrides)); return static::fromDecimal($decimal, currency($currency)); } From 96786939bd55e92a804e22793d766ccd390b4a40 Mon Sep 17 00:00:00 2001 From: Gaurav Makhecha Date: Tue, 15 Mar 2022 16:46:08 +0530 Subject: [PATCH 08/12] Apply text suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Samuel Štancl --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7cf6a33..172b054 100644 --- a/README.md +++ b/README.md @@ -166,13 +166,13 @@ $money = Money::fromDecimal(40.25, USD::class); $money->formatted(['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']); ``` -There is also a `->rawFormatted()` if you wish to use [math decimals](#math-decimals) instead of [display decimals](#display-decimals). +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 235,56 Kč +$money->rawFormatted(); // 1 234,56 Kč ``` -And converting the formatted value back to the Money instance is also possible. The package tries to extract the currency from the provided string. +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 From d6cb1ba6ae6abd118ec8094b420f9109594f420b Mon Sep 17 00:00:00 2001 From: Gaurav Date: Tue, 15 Mar 2022 17:03:57 +0530 Subject: [PATCH 09/12] update: add dedicated exception class --- src/Exceptions/CannotExtractCurrencyException.php | 15 +++++++++++++++ src/PriceFormatter.php | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 src/Exceptions/CannotExtractCurrencyException.php 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 @@ +suffix()) ) { if ($possibleCurrency) { - throw new \Exception('Multiple currencies are using the same prefix and suffix. Please specify the currency of the formatted string.'); + throw new CannotExtractCurrencyException('Multiple currencies are using the same prefix and suffix. Please specify the currency of the formatted string.'); } $possibleCurrency = $currency; } } - return $possibleCurrency ?? throw new \Exception('None of the currencies are using the prefix and suffix that would match with the formatted string.'); + return $possibleCurrency ?? throw new CannotExtractCurrencyException('None of the currencies are using the prefix and suffix that would match with the formatted string.'); } } From 2a60b7b3d83c3f9e791e458e4a2b0a0e4f0ef941 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Tue, 15 Mar 2022 17:06:44 +0530 Subject: [PATCH 10/12] test: updates --- tests/Pest/MoneyTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Pest/MoneyTest.php b/tests/Pest/MoneyTest.php index 586b8b7..b8055f1 100644 --- a/tests/Pest/MoneyTest.php +++ b/tests/Pest/MoneyTest.php @@ -1,6 +1,7 @@ remove(USD::class); - pest()->expectException(\Exception::class); + pest()->expectException(CannotExtractCurrencyException::class); Money::fromFormatted($formatted); }); @@ -173,7 +174,7 @@ test('an exception is thrown if multiple currencies are using the same prefix an currencies()->add(['code' => 'USD2', 'name' => 'USD2', 'prefix' => '$']); $money = money(1000); - pest()->expectException(\Exception::class); + pest()->expectException(CannotExtractCurrencyException::class); Money::fromFormatted($money->formatted()); }); From 34740e66a6365dd88e31b039b2af99809281f3b8 Mon Sep 17 00:00:00 2001 From: Gaurav Date: Thu, 31 Mar 2022 10:09:40 +0530 Subject: [PATCH 11/12] fix: import exception --- src/PriceFormatter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index e220485..67f04a9 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ArchTech\Money; use ArchTech\Money\Exceptions\CannotExtractCurrencyException; +use Exception; class PriceFormatter { @@ -38,7 +39,7 @@ class PriceFormatter $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.'); + throw new Exception('The formatted string could not be resolved to a valid number.'); } return (float) str_replace($currency->decimalSeparator(), '.', $removeNonDigits); From 5952ace37e0dbafc65468dfe2e87a87248ac11bc Mon Sep 17 00:00:00 2001 From: Gaurav Date: Tue, 5 Apr 2022 09:49:36 +0530 Subject: [PATCH 12/12] text: exception message and docblock updates --- README.md | 6 +++--- src/Money.php | 8 +++++++- src/PriceFormatter.php | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 172b054..d2712ad 100644 --- a/README.md +++ b/README.md @@ -180,11 +180,11 @@ $fromFormatted = Money::fromFormatted($formatted); $fromFormatted->is($money); // true ``` -Optional overrides for the [currency specification](#currency-logic) are accepted too. +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(); // $10.00 -$fromFormatted = Money::fromFormatted($formatted, USD::class, ['decimalSeparator' => ',', 'prefix' => '$ ', 'suffix' => ' USD']); +$formatted = $money->formatted(['prefix' => '$ ', 'suffix' => ' USD']); // $ 10.00 USD +$fromFormatted = Money::fromFormatted($formatted, USD::class, ['prefix' => '$ ', 'suffix' => ' USD']); $fromFormatted->is($money); // true ``` diff --git a/src/Money.php b/src/Money.php index 9708188..16f0c3c 100644 --- a/src/Money.php +++ b/src/Money.php @@ -171,7 +171,13 @@ final class Money implements JsonSerializable, Arrayable, Wireable ])); } - /** Create a Money instance from a formatted string. */ + /** + * 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) diff --git a/src/PriceFormatter.php b/src/PriceFormatter.php index 67f04a9..b01010f 100644 --- a/src/PriceFormatter.php +++ b/src/PriceFormatter.php @@ -56,13 +56,13 @@ class PriceFormatter && str_ends_with($formatted, $currency->suffix()) ) { if ($possibleCurrency) { - throw new CannotExtractCurrencyException('Multiple currencies are using the same prefix and suffix. Please specify the currency of the formatted string.'); + 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.'); + return $possibleCurrency ?? throw new CannotExtractCurrencyException("None of the currencies are using the prefix and suffix that would match with the formatted string '$formatted'."); } }