Skip to content

Commit b6260b0

Browse files
committed
feature #7478 Add support for Money objects in MoneyField (javiereguiluz)
This PR was squashed before being merged into the 5.x branch. Discussion ---------- Add support for Money objects in MoneyField Fix #6222. Commits ------- 2a28b5c Add support for Money objects in MoneyField
2 parents 9bd3676 + 2a28b5c commit b6260b0

8 files changed

Lines changed: 238 additions & 1 deletion

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"require-dev": {
4747
"dama/doctrine-test-bundle": "^8.2",
4848
"doctrine/doctrine-fixtures-bundle": "^3.4|3.5.x-dev|^4.0",
49+
"moneyphp/money": "^4.8",
4950
"phpstan/extension-installer": "^1.4",
5051
"phpstan/phpstan": "^2.0",
5152
"phpstan/phpstan-phpunit": "^2.0",

config/services.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
use EasyCorp\Bundle\EasyAdminBundle\Form\Extension\EaCrudFormTypeExtension;
7575
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType;
7676
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudFormType;
77+
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\EaMoneyType;
7778
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType;
7879
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FiltersFormType;
7980
use EasyCorp\Bundle\EasyAdminBundle\Intl\IntlFormatter;
@@ -311,6 +312,9 @@
311312
->arg(1, service('filesystem'))
312313
->tag('form.type')
313314

315+
->set(EaMoneyType::class)
316+
->tag('form.type')
317+
314318
->set(ChoiceFilterConfigurator::class)
315319

316320
->set(CommonFilterConfigurator::class)

doc/fields/MoneyField.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@ If you do not store money amounts in cents, set this option to ``false``:
8787
However, if you've defined your own custom divisor with the
8888
``setFormTypeOption('divisor', ...)`` method, the custom divisor will be used.
8989

90+
useMoneyObject
91+
~~~~~~~~~~~~~~
92+
93+
If your entity stores money amounts as ``Money\Money`` objects from the
94+
`Money PHP`_ library, EasyAdmin can handle them directly. First, install the
95+
library:
96+
97+
.. code-block:: terminal
98+
99+
$ composer require moneyphp/money
100+
101+
When the property value is already a ``Money`` object (e.g. on ``edit`` and
102+
``detail`` pages), EasyAdmin detects it automatically and no extra configuration
103+
is needed. On ``new`` pages, where the value is ``null``, you must enable this
104+
option explicitly::
105+
106+
yield MoneyField::new('price')->useMoneyObject();
107+
108+
The currency is read from the ``Money`` object by default, but you can override
109+
it with ``setCurrency()`` or ``setCurrencyPropertyPath()``::
110+
111+
yield MoneyField::new('price')->useMoneyObject()->setCurrency('EUR');
112+
113+
.. note::
114+
115+
``Money`` objects always store amounts in the smallest currency unit (e.g.
116+
cents), so ``setStoredAsCents()`` has no effect when using this option.
117+
90118
.. _`MoneyType`: https://symfony.com/doc/current/reference/forms/types/money.html
91119
.. _`ISO 4217 standard`: https://en.wikipedia.org/wiki/ISO_4217
92120
.. _`Symfony PropertyAccess`: https://symfony.com/doc/current/components/property_access.html

src/Field/Configurator/MoneyConfigurator.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
99
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
1010
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
11+
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\EaMoneyType;
12+
use Money\Money;
1113
use Symfony\Component\Intl\Currencies;
1214
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1315

@@ -28,6 +30,64 @@ public function supports(FieldDto $field, EntityDto $entityDto): bool
2830
}
2931

3032
public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
33+
{
34+
if ($this->shouldUseMoneyObject($field)) {
35+
$this->configureForMoneyObject($field, $entityDto);
36+
} else {
37+
$this->configureForScalarValue($field, $entityDto);
38+
}
39+
}
40+
41+
private function shouldUseMoneyObject(FieldDto $field): bool
42+
{
43+
if (null !== $configOption = $field->getCustomOption(MoneyField::OPTION_USE_MONEY_OBJECT)) {
44+
return $configOption;
45+
}
46+
47+
return $field->getValue() instanceof Money;
48+
}
49+
50+
private function configureForMoneyObject(FieldDto $field, EntityDto $entityDto): void
51+
{
52+
$value = $field->getValue();
53+
54+
// determine currency: explicit setCurrency() takes priority, then Money object's currency
55+
$currencyCode = $field->getCustomOption(MoneyField::OPTION_CURRENCY);
56+
if (null === $currencyCode && null !== $currencyPropertyPath = $field->getCustomOption(MoneyField::OPTION_CURRENCY_PROPERTY_PATH)) {
57+
$entityInstance = $entityDto->getInstance();
58+
if (null !== $entityInstance && $this->propertyAccessor->isReadable($entityInstance, $currencyPropertyPath)) {
59+
$currencyCode = $this->propertyAccessor->getValue($entityInstance, $currencyPropertyPath);
60+
}
61+
}
62+
if (null === $currencyCode && $value instanceof Money) {
63+
$currencyCode = $value->getCurrency()->getCode();
64+
}
65+
66+
if (null !== $currencyCode && !Currencies::exists($currencyCode)) {
67+
throw new \InvalidArgumentException(sprintf('The "%s" value used as the currency of the "%s" money field is not a valid ICU currency code.', $currencyCode, $field->getProperty()));
68+
}
69+
70+
$field->setFormType(EaMoneyType::class);
71+
$field->setFormTypeOption('currency', $currencyCode);
72+
$field->setFormTypeOption('ea_money_object', true);
73+
74+
$numDecimals = $field->getCustomOption(MoneyField::OPTION_NUM_DECIMALS);
75+
$field->setFormTypeOption('scale', $numDecimals);
76+
77+
// Money objects always store amounts in smallest units, so divisor is always 100
78+
$field->setFormTypeOptionIfNotSet('divisor', self::DEFAULT_DIVISOR);
79+
80+
if (null === $value) {
81+
return;
82+
}
83+
84+
$divisor = $field->getFormTypeOption('divisor');
85+
$amount = (int) $value->getAmount() / $divisor;
86+
$formattedValue = $this->intlFormatter->formatCurrency($amount, $currencyCode, ['fraction_digit' => $numDecimals]);
87+
$field->setFormattedValue($formattedValue);
88+
}
89+
90+
private function configureForScalarValue(FieldDto $field, EntityDto $entityDto): void
3191
{
3292
$currencyCode = $this->getCurrency($field, $entityDto);
3393
if (null !== $currencyCode && !Currencies::exists($currencyCode)) {

src/Field/MoneyField.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class MoneyField implements FieldInterface
1919
public const OPTION_CURRENCY_PROPERTY_PATH = 'currencyPropertyPath';
2020
public const OPTION_NUM_DECIMALS = 'numDecimals';
2121
public const OPTION_STORED_AS_CENTS = 'storedAsCents';
22+
public const OPTION_USE_MONEY_OBJECT = 'useMoneyObject';
2223

2324
public static function new(string $propertyName, TranslatableInterface|string|bool|null $label = null): self
2425
{
@@ -33,7 +34,8 @@ public static function new(string $propertyName, TranslatableInterface|string|bo
3334
->setCustomOption(self::OPTION_CURRENCY, null)
3435
->setCustomOption(self::OPTION_CURRENCY_PROPERTY_PATH, null)
3536
->setCustomOption(self::OPTION_NUM_DECIMALS, 2)
36-
->setCustomOption(self::OPTION_STORED_AS_CENTS, true);
37+
->setCustomOption(self::OPTION_STORED_AS_CENTS, true)
38+
->setCustomOption(self::OPTION_USE_MONEY_OBJECT, null);
3739
}
3840

3941
public function setCurrency(string $currencyCode): self
@@ -71,4 +73,19 @@ public function setStoredAsCents(bool $asCents = true): self
7173

7274
return $this;
7375
}
76+
77+
/**
78+
* Enables support for Money\Money objects from the moneyphp/money library.
79+
* This is needed for "new" pages where the value is null and auto-detection can't work.
80+
*/
81+
public function useMoneyObject(bool $use = true): self
82+
{
83+
if ($use && !class_exists(\Money\Money::class)) {
84+
throw new \LogicException('You must install the "moneyphp/money" package to use MoneyField with Money objects.');
85+
}
86+
87+
$this->setCustomOption(self::OPTION_USE_MONEY_OBJECT, $use);
88+
89+
return $this;
90+
}
7491
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer;
4+
5+
use Money\Currency;
6+
use Money\Money;
7+
use Symfony\Component\Form\DataTransformerInterface;
8+
9+
/**
10+
* Transforms between Money\Money objects and numeric (int) values.
11+
*
12+
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
13+
*/
14+
class MoneyToNumericTransformer implements DataTransformerInterface
15+
{
16+
public function __construct(private readonly string $currencyCode)
17+
{
18+
}
19+
20+
public function transform(mixed $value): ?int
21+
{
22+
if (null === $value) {
23+
return null;
24+
}
25+
26+
if (!$value instanceof Money) {
27+
throw new \InvalidArgumentException(sprintf('Expected an instance of "%s", got "%s".', Money::class, get_debug_type($value)));
28+
}
29+
30+
return (int) $value->getAmount();
31+
}
32+
33+
public function reverseTransform(mixed $value): ?Money
34+
{
35+
if (null === $value) {
36+
return null;
37+
}
38+
39+
return new Money((string) $value, new Currency($this->currencyCode));
40+
}
41+
}

src/Form/Type/EaMoneyType.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Form\Type;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Form\DataTransformer\MoneyToNumericTransformer;
6+
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
7+
use Symfony\Component\Form\FormBuilderInterface;
8+
use Symfony\Component\OptionsResolver\OptionsResolver;
9+
10+
/**
11+
* Extends MoneyType to support Money\Money objects via a data transformer.
12+
*
13+
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
14+
*/
15+
class EaMoneyType extends MoneyType
16+
{
17+
public function buildForm(FormBuilderInterface $builder, array $options): void
18+
{
19+
parent::buildForm($builder, $options);
20+
21+
if (true === $options['ea_money_object']) {
22+
$builder->addModelTransformer(new MoneyToNumericTransformer($options['currency']));
23+
}
24+
}
25+
26+
public function configureOptions(OptionsResolver $resolver): void
27+
{
28+
parent::configureOptions($resolver);
29+
30+
$resolver->setDefault('ea_money_object', false);
31+
$resolver->setAllowedTypes('ea_money_object', 'bool');
32+
}
33+
34+
public function getBlockPrefix(): string
35+
{
36+
return 'money';
37+
}
38+
}

tests/Unit/Field/MoneyFieldTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
66
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\MoneyConfigurator;
77
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
8+
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\EaMoneyType;
89
use EasyCorp\Bundle\EasyAdminBundle\Intl\IntlFormatter;
10+
use Money\Currency;
11+
use Money\Money;
912

1013
class MoneyFieldTest extends AbstractFieldTest
1114
{
@@ -123,4 +126,49 @@ public function testFieldWithCustomDivisor(): void
123126
self::assertSame('€0.07', $fieldDto->getFormattedValue());
124127
self::assertSame(10000, $fieldDto->getFormTypeOption('divisor'));
125128
}
129+
130+
public function testFieldWithMoneyObject(): void
131+
{
132+
$money = new Money('500', new Currency('EUR'));
133+
$field = MoneyField::new('foo')->setValue($money);
134+
$fieldDto = $this->configure($field);
135+
136+
self::assertSame('€5.00', $fieldDto->getFormattedValue());
137+
self::assertSame(EaMoneyType::class, $fieldDto->getFormType());
138+
self::assertSame('EUR', $fieldDto->getFormTypeOption('currency'));
139+
self::assertTrue($fieldDto->getFormTypeOption('ea_money_object'));
140+
self::assertSame(100, $fieldDto->getFormTypeOption('divisor'));
141+
}
142+
143+
public function testFieldWithMoneyObjectAndExplicitCurrency(): void
144+
{
145+
$money = new Money('500', new Currency('EUR'));
146+
$field = MoneyField::new('foo')->setValue($money)->setCurrency('USD');
147+
$fieldDto = $this->configure($field);
148+
149+
self::assertSame('USD', $fieldDto->getFormTypeOption('currency'));
150+
self::assertSame(EaMoneyType::class, $fieldDto->getFormType());
151+
}
152+
153+
public function testFieldWithMoneyObjectNull(): void
154+
{
155+
$field = MoneyField::new('foo')->setValue(null)->useMoneyObject()->setCurrency('EUR');
156+
$fieldDto = $this->configure($field);
157+
158+
self::assertSame(EaMoneyType::class, $fieldDto->getFormType());
159+
self::assertSame('EUR', $fieldDto->getFormTypeOption('currency'));
160+
self::assertTrue($fieldDto->getFormTypeOption('ea_money_object'));
161+
}
162+
163+
public function testFieldWithMoneyObjectAndCustomDivisor(): void
164+
{
165+
$money = new Money('7500', new Currency('EUR'));
166+
$field = MoneyField::new('foo')->setValue($money);
167+
$field->setFormTypeOption('divisor', 1000);
168+
$fieldDto = $this->configure($field);
169+
170+
self::assertSame('€7.50', $fieldDto->getFormattedValue());
171+
self::assertSame(1000, $fieldDto->getFormTypeOption('divisor'));
172+
self::assertSame(EaMoneyType::class, $fieldDto->getFormType());
173+
}
126174
}

0 commit comments

Comments
 (0)