Skip to content

Commit

Permalink
Merge pull request #16 from gauravmak/from_string_implementation
Browse files Browse the repository at this point in the history
Money instance creation from a formatted string
  • Loading branch information
stancl authored Apr 5, 2022
2 parents db2d96e + 5952ace commit 545610e
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 6 deletions.
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/Exceptions/CannotExtractCurrencyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace ArchTech\Money\Exceptions;

use Exception;

class CannotExtractCurrencyException extends Exception
{
public function __construct(string $message)
{
parent::__construct($message);
}
}
26 changes: 22 additions & 4 deletions src/Money.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected function newFromDecimal(float $decimal): self
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)
);
}
Expand Down Expand Up @@ -154,7 +154,7 @@ public function currency(): Currency
/** 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. */
Expand All @@ -171,6 +171,24 @@ public function rawFormatted(mixed ...$overrides): 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)
? 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
{
Expand Down Expand Up @@ -228,7 +246,7 @@ public function valueInDefaultCurrency(): int

return $this
->divideBy($this->currency->rate())
->divideBy(10 ** $mathDecimalDifference)
->divideBy(pow(10, $mathDecimalDifference))
->value();
}

Expand All @@ -240,7 +258,7 @@ public function convertTo(Currency|string $currency): self
$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
);
}
Expand Down
43 changes: 43 additions & 0 deletions src/PriceFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace ArchTech\Money;

use ArchTech\Money\Exceptions\CannotExtractCurrencyException;
use Exception;

class PriceFormatter
{
/** Format a decimal per the currency's specifications. */
Expand All @@ -22,4 +25,44 @@ public static function format(float $decimal, Currency $currency, array $overrid

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'.");
}
}
31 changes: 31 additions & 0 deletions tests/Pest/MoneyTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use ArchTech\Money\Currencies\USD;
use ArchTech\Money\Exceptions\CannotExtractCurrencyException;
use ArchTech\Money\Money;
use ArchTech\Money\Tests\Currencies\CZK;
use ArchTech\Money\Tests\Currencies\EUR;
Expand Down Expand Up @@ -147,6 +148,36 @@
)->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)
Expand Down

0 comments on commit 545610e

Please sign in to comment.