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

Add support for decimals and significant digits #39

Merged
merged 7 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ MONEY_DEFAULT_CURRENCY=SEK
php artisan vendor:publish --provider="Pelmered\FilamentMoneyField\FilamentMoneyFieldServiceProvider" --tag="config"
```

## Additional Configuration
## Global Configuration

### If you want to use the formatting mask on the `MoneyInput` component

Expand All @@ -90,6 +90,33 @@ Possible options: `after`, `before`, `none`.
MONEY_UNIT_PLACEMENT=after // Defaults to before
```

### Decimals and significant digits

The number of decimals and significant digits can be set in the config file. Defaults to 2.

```env
//with input 123456
MONEY_DECIMAL_DIGITS=0 // Gives 0 decimals, e.g. $1,235
MONEY_DECIMAL_DIGITS=2 // Gives 2 decimals, e.g. $1,234.56
```

For significant digits, use negative values. For example -2 will give you 2 significant digits.

```env
//with input 12345678
MONEY_DECIMAL_DIGITS=-2 // Gives 2 significant digits, e.g. $120,000
MONEY_DECIMAL_DIGITS=-4 // Gives 4 significant digits, e.g. $123,400
```

This can also be set on a per-field basis.

```php
MoneyInput::make('price')->decimalDigits(0);
MoneyEntry::make('price')->decimalDigits(2);
MoneyColumn::make('price')->decimalDigits(-2);
```


## Usage

### InfoList
Expand Down Expand Up @@ -131,7 +158,9 @@ MoneyInput::make('price')
->currency('SEK')
->locale('sv_SE')
->minValue(0) // Do not allow negative values.
->maxValue(10000); // Add min and max value (in minor units, i.e. cents) to the input field. In this case no values over 100
->maxValue(10000) // Add min and max value (in minor units, i.e. cents) to the input field. In this case no values over 100
->step(100) // Step value for the input field. In this case only multiples of 100 are allowed.
->decimals(0)
```

### Table column
Expand Down
2 changes: 1 addition & 1 deletion config/filament-money-field.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
| The currency code to use if not set on the field.
|
*/
'fraction_digits' => env('MONEY_FRACTION_DIGITS', 2),
'decimal_digits' => env('MONEY_DECIMAL_DIGITS', 2),

/*
|---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions src/Forms/Components/MoneyInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ protected function setUp(): void
return $state;
}

return MoneyFormatter::formatAsDecimal((int) $state, $currency, $locale);
return MoneyFormatter::formatAsDecimal((int) $state, $currency, $locale, $this->decimals);
});

$this->dehydrateStateUsing(function (MoneyInput $component, $state): ?string {
$currency = $component->getCurrency();
$state = MoneyFormatter::parseDecimal($state, $currency, $component->getLocale());
$state = MoneyFormatter::parseDecimal($state, $currency, $component->getLocale(), $this->decimals);

if (! is_numeric($state)) {
return null;
Expand Down
10 changes: 10 additions & 0 deletions src/HasMoneyAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ trait HasMoneyAttributes

protected string $locale;

protected ?int $decimals = null;

protected ?string $monetarySeparator = null;

public function getCurrency(): Currency
Expand Down Expand Up @@ -54,5 +56,13 @@ public function locale(string|Closure|null $locale = null): static
return $this;
}

public function decimals(int|Closure $decimals): static
{
$this->decimals = $this->evaluate($decimals);

return $this;
}

// This should typically be provided by the Filament\Support\Concerns\EvaluatesClosures trait in Filament
abstract protected function evaluate(string|Closure|null $value): mixed;
}
50 changes: 38 additions & 12 deletions src/MoneyFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,45 @@ public static function format(
null|int|string $value,
Currency $currency,
string $locale,
int $outputStyle = NumberFormatter::CURRENCY
int $outputStyle = NumberFormatter::CURRENCY,
?int $decimals = null,
): string {
if ($value === '' || ! is_numeric($value)) {
return '';
}

$numberFormatter = self::getNumberFormatter($locale, $outputStyle);
$numberFormatter = self::getNumberFormatter($locale, $outputStyle, $decimals);
$moneyFormatter = new IntlMoneyFormatter($numberFormatter, new ISOCurrencies());

$money = new Money((int) $value, $currency);

return $moneyFormatter->format($money); // outputs $1.000,00
return $moneyFormatter->format($money); // Outputs something like "$1.234,56"
}

public static function formatAsDecimal(null|int|string $value, Currency $currency, string $locale): string
{
return static::format($value, $currency, $locale, NumberFormatter::DECIMAL); // outputs 1.000,00
public static function formatAsDecimal(
null|int|string $value,
Currency $currency,
string $locale,
?int $decimals = null,
): string {
return static::format($value, $currency, $locale, NumberFormatter::DECIMAL, $decimals);
}

public static function parseDecimal(?string $moneyString, Currency $currency, string $locale): string
{
public static function parseDecimal(
?string $moneyString,
Currency $currency,
string $locale,
?int $decimals = null
): string {
if (is_null($moneyString) || $moneyString === '') {
return '';
}

$numberFormatter = self::getNumberFormatter($locale, NumberFormatter::DECIMAL);
$numberFormatter = self::getNumberFormatter($locale, NumberFormatter::DECIMAL, $decimals);
$moneyParser = new IntlLocalizedDecimalParser($numberFormatter, new ISOCurrencies());

// Needed to fix some parsing issues with small numbers such as
// Remove grouping separator from the money string
// This is needed to fix some parsing issues with small numbers such as
// "2,00" with "," left as thousands separator in the wrong place
// See: https://github.com/pelmered/filament-money-field/issues/20
$formattingRules = self::getFormattingRules($locale);
Expand Down Expand Up @@ -74,12 +84,19 @@ public static function getFormattingRules(string $locale): MoneyFormattingRules
);
}

private static function getNumberFormatter(string $locale, int $style): NumberFormatter
private static function getNumberFormatter(string $locale, int $style, ?int $decimals = null): NumberFormatter
{
$config = config('filament-money-field');

$numberFormatter = new NumberFormatter($locale, $style);
$numberFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $config['fraction_digits']);

$decimals = self::getDecimals($decimals);

if ($decimals < 0) {
$numberFormatter->setAttribute(NumberFormatter::MAX_SIGNIFICANT_DIGITS, abs($decimals));
} else {
$numberFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $decimals);
}

if ($config['intl_currency_symbol']) {
$intlCurrencySymbol = $numberFormatter->getSymbol(NumberFormatter::INTL_CURRENCY_SYMBOL);
Expand All @@ -95,4 +112,13 @@ private static function getNumberFormatter(string $locale, int $style): NumberFo

return $numberFormatter;
}

private static function getDecimals(?int $decimals = null): int
{
if (! is_null($decimals)) {
return $decimals;
}

return (int) config('filament-money-field.decimal_digits', 2);
}
}
27 changes: 27 additions & 0 deletions tests/FormInputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,31 @@ public function testResolveLabelClosures(): void
$field = $component->getComponent('data.price');
$this->assertEquals('Custom Label in Closure', $field->getLabel());
}

public function testSetDecimalsOnField(): void
{
$field = (new MoneyInput('price'))->decimals(1);
$component = ComponentContainer::make(FormTestComponent::make())
->statePath('data')
->components([
$field,
])->fill([$field->getName() => 2345345]);
$this->assertEquals('2345345', $component->getState()['price']);

$field = (new MoneyInput('price'))->decimals(3);
$component = ComponentContainer::make(FormTestComponent::make())
->statePath('data')
->components([
$field,
])->fill([$field->getName() => 2345345]);
$this->assertEquals('2345345', $component->getState()['price']);

$field = (new MoneyInput('price'))->decimals(-2);
$component = ComponentContainer::make(FormTestComponent::make())
->statePath('data')
->components([
$field,
])->fill([$field->getName() => 2345345]);
$this->assertEquals('2345345', $component->getState()['price']);
}
}
130 changes: 127 additions & 3 deletions tests/MoneyFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ public function testMoneyParserDecimal(): void
);
}

public function testInternationalCurrencySymbol()
public function testInternationalCurrencySymbol(): void
{
config(['filament-money-field.intl_currency_symbol' => true]);

Expand All @@ -249,13 +249,137 @@ public function testInternationalCurrencySymbol()
);
}

public function testInternationalCurrencySymbolSuffix()
public function testInternationalCurrencySymbolSuffix(): void
{
config(['filament-money-field.intl_currency_symbol' => true]);

self::assertSame(
self::replaceNonBreakingSpaces('1 000,00 SEK'),
MoneyFormatter::format(100000, new Currency('EUR'), 'sv_SE')
MoneyFormatter::format(100000, new Currency('SEK'), 'sv_SE')
);
}

public function testGlobalDecimals(): void
{
config(['filament-money-field.decimal_digits' => null]);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000'),
MoneyFormatter::format(100020, new Currency('USD'), 'en_US')
);

config(['filament-money-field.decimal_digits' => 0]);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000'),
MoneyFormatter::format(100020, new Currency('USD'), 'en_US')
);

config(['filament-money-field.decimal_digits' => 2]);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000.11'),
MoneyFormatter::format(100011, new Currency('USD'), 'en_US')
);

config(['filament-money-field.decimal_digits' => 4]);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000.7700'),
MoneyFormatter::format(100077, new Currency('USD'), 'en_US')
);

config(['filament-money-field.decimal_digits' => -2]);
self::assertSame(
self::replaceNonBreakingSpaces('$120,000'),
MoneyFormatter::format(12345678, new Currency('USD'), 'en_US')
);

config(['filament-money-field.decimal_digits' => -4]);
self::assertSame(
self::replaceNonBreakingSpaces('$1,235,000'),
MoneyFormatter::format(123456789, new Currency('USD'), 'en_US')
);
}

public function testGlobalDecimalsSek(): void
{
config(['filament-money-field.decimal_digits' => 0]);
self::assertSame(
self::replaceNonBreakingSpaces('1 000 kr'),
MoneyFormatter::format(100020, new Currency('SEK'), 'sv_SE')
);

config(['filament-money-field.decimal_digits' => 2]);
self::assertSame(
self::replaceNonBreakingSpaces('1 000,11 kr'),
MoneyFormatter::format(100011, new Currency('SEK'), 'sv_SE')
);

config(['filament-money-field.decimal_digits' => 4]);
self::assertSame(
self::replaceNonBreakingSpaces('1 000,7700 kr'),
MoneyFormatter::format(100077, new Currency('SEK'), 'sv_SE')
);

config(['filament-money-field.decimal_digits' => -2]);
self::assertSame(
self::replaceNonBreakingSpaces('120 000 kr'),
MoneyFormatter::format(12345678, new Currency('SEK'), 'sv_SE')
);

config(['filament-money-field.decimal_digits' => -4]);
self::assertSame(
self::replaceNonBreakingSpaces('1 235 000 kr'),
MoneyFormatter::format(123456789, new Currency('SEK'), 'sv_SE')
);
}

public function testDecimalsAsParameter(): void
{
self::assertSame(
self::replaceNonBreakingSpaces('$1,234.56'),
MoneyFormatter::format(123456, new Currency('USD'), 'en_US')
);
self::assertSame(
self::replaceNonBreakingSpaces('$1,235'),
MoneyFormatter::format(123456, new Currency('USD'), 'en_US', decimals: 0)
);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000.12'),
MoneyFormatter::format(100012, new Currency('USD'), 'en_US', decimals: 2)
);
self::assertSame(
self::replaceNonBreakingSpaces('$1,000.5500'),
MoneyFormatter::format(100055, new Currency('USD'), 'en_US', decimals: 4)
);
self::assertSame(
self::replaceNonBreakingSpaces('$1,200'),
MoneyFormatter::format(123456, new Currency('USD'), 'en_US', decimals: -2)
);
self::assertSame(
self::replaceNonBreakingSpaces('$123,500'),
MoneyFormatter::format(12345678, new Currency('USD'), 'en_US', decimals: -4)
);
}

public function testDecimalsAsParameterSek(): void
{
self::assertSame(
self::replaceNonBreakingSpaces('1 001 kr'),
MoneyFormatter::format(100060, new Currency('SEK'), 'sv_SE', decimals: 0)
);
self::assertSame(
self::replaceNonBreakingSpaces('1 000,12 kr'),
MoneyFormatter::format(100012, new Currency('SEK'), 'sv_SE', decimals: 2)
);
self::assertSame(
self::replaceNonBreakingSpaces('1 000,5500 kr'),
MoneyFormatter::format(100055, new Currency('SEK'), 'sv_SE', decimals: 4)
);
self::assertSame(
self::replaceNonBreakingSpaces('1 200 kr'),
MoneyFormatter::format(123456, new Currency('SEK'), 'sv_SE', decimals: -2)
);
self::assertSame(
self::replaceNonBreakingSpaces('123 500 kr'),
MoneyFormatter::format(12345678, new Currency('SEK'), 'sv_SE', decimals: -4)
);
}
}