├── src
├── Factory
│ ├── Exception
│ │ ├── MissingDependencyException.php
│ │ └── UnsupportedFormatException.php
│ ├── ParserFactoryInterface.php
│ ├── FormatterFactoryInterface.php
│ ├── ParserFactory.php
│ └── FormatterFactory.php
├── Format.php
├── Validator
│ └── Constraints
│ │ ├── MoneyEqualToValidator.php
│ │ ├── MoneyLessThanValidator.php
│ │ ├── MoneyNotEqualToValidator.php
│ │ ├── MoneyGreaterThanValidator.php
│ │ ├── MoneyEqualTo.php
│ │ ├── MoneyLessThan.php
│ │ ├── MoneyLessThanOrEqualValidator.php
│ │ ├── MoneyGreaterThan.php
│ │ ├── MoneyNotEqualTo.php
│ │ ├── MoneyGreaterThanOrEqualValidator.php
│ │ ├── MoneyLessThanOrEqual.php
│ │ ├── MoneyGreaterThanOrEqual.php
│ │ ├── AbstractMoneyComparison.php
│ │ └── AbstractMoneyComparisonValidator.php
├── Twig
│ └── MoneyExtension.php
├── Form
│ ├── DataTransformer
│ │ └── MoneyToLocalizedStringTransformer.php
│ └── Type
│ │ └── MoneyType.php
├── Serializer
│ ├── Normalizer
│ │ └── MoneyNormalizer.php
│ └── Handler
│ │ └── MoneyHandler.php
└── BabDevMoneyBundle.php
├── config
├── serializer.php
├── jms_serializer.php
├── mapping
│ ├── Currency.orm.xml
│ ├── Currency.mongodb.xml
│ ├── Money.orm.xml
│ └── Money.mongodb.xml
├── twig.php
├── form.php
├── money.php
└── validator.php
├── LICENSE
└── composer.json
/src/Factory/Exception/MissingDependencyException.php:
--------------------------------------------------------------------------------
1 | services()
9 | ->set('money.serializer.normalizer', MoneyNormalizer::class)
10 | ->tag('serializer.normalizer')
11 | ;
12 | };
13 |
--------------------------------------------------------------------------------
/src/Format.php:
--------------------------------------------------------------------------------
1 | services()
9 | ->set('money.serializer.handler', MoneyHandler::class)
10 | ->tag('jms_serializer.subscribing_handler')
11 | ;
12 | };
13 |
--------------------------------------------------------------------------------
/config/mapping/Currency.orm.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/config/twig.php:
--------------------------------------------------------------------------------
1 | services()
9 | ->set('money.twig_extension', MoneyExtension::class)
10 | ->args([
11 | service('money.factory.formatter'),
12 | param('babdev_money.default_currency'),
13 | ])
14 | ->tag('twig.extension')
15 | ;
16 | };
17 |
--------------------------------------------------------------------------------
/config/mapping/Currency.mongodb.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/config/mapping/Money.orm.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/config/form.php:
--------------------------------------------------------------------------------
1 | services()
9 | ->set('money.form.type.money', MoneyType::class)
10 | ->args([
11 | service('money.factory.formatter'),
12 | service('money.factory.parser'),
13 | param('babdev_money.default_currency'),
14 | ])
15 | ->tag('form.type')
16 | ;
17 | };
18 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyEqualToValidator.php:
--------------------------------------------------------------------------------
1 | equals($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyEqualTo::NOT_EQUAL_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyLessThanValidator.php:
--------------------------------------------------------------------------------
1 | lessThan($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyLessThan::TOO_HIGH_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/config/mapping/Money.mongodb.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyNotEqualToValidator.php:
--------------------------------------------------------------------------------
1 | equals($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyNotEqualTo::IS_EQUAL_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyGreaterThanValidator.php:
--------------------------------------------------------------------------------
1 | greaterThan($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyGreaterThan::TOO_LOW_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyEqualTo.php:
--------------------------------------------------------------------------------
1 | 'NOT_EQUAL_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should be equal to {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyLessThan.php:
--------------------------------------------------------------------------------
1 | 'TOO_HIGH_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should be less than {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyLessThanOrEqualValidator.php:
--------------------------------------------------------------------------------
1 | lessThanOrEqual($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyLessThanOrEqual::TOO_HIGH_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyGreaterThan.php:
--------------------------------------------------------------------------------
1 | 'TOO_LOW_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should be greater than {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyNotEqualTo.php:
--------------------------------------------------------------------------------
1 | 'IS_EQUAL_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should not be equal to {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyGreaterThanOrEqualValidator.php:
--------------------------------------------------------------------------------
1 | greaterThanOrEqual($value2);
15 | }
16 |
17 | protected function getErrorCode(): string
18 | {
19 | return MoneyGreaterThanOrEqual::TOO_LOW_ERROR;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyLessThanOrEqual.php:
--------------------------------------------------------------------------------
1 | 'TOO_HIGH_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should be less than or equal to {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/MoneyGreaterThanOrEqual.php:
--------------------------------------------------------------------------------
1 | 'TOO_LOW_ERROR',
15 | ];
16 |
17 | public ?string $message = 'This value should be greater than or equal to {{ compared_value }}.';
18 | }
19 |
--------------------------------------------------------------------------------
/src/Factory/Exception/UnsupportedFormatException.php:
--------------------------------------------------------------------------------
1 | $formats
11 | *
12 | * @phpstan-param list $formats
13 | */
14 | public function __construct(
15 | private readonly array $formats,
16 | string $message = '',
17 | int $code = 0,
18 | ?\Throwable $previous = null,
19 | ) {
20 | parent::__construct($message, $code, $previous);
21 | }
22 |
23 | /**
24 | * @return list
25 | *
26 | * @phpstan-return list
27 | */
28 | public function getFormats(): array
29 | {
30 | return $this->formats;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Factory/ParserFactoryInterface.php:
--------------------------------------------------------------------------------
1 | $options
18 | *
19 | * @throws UnsupportedFormatException if an unsupported format was requested
20 | * @throws MissingDependencyException if a dependency for a parser is not available
21 | */
22 | public function createParser(string $format, ?string $locale, array $options): MoneyParser;
23 | }
24 |
--------------------------------------------------------------------------------
/src/Factory/FormatterFactoryInterface.php:
--------------------------------------------------------------------------------
1 | $options
18 | *
19 | * @throws UnsupportedFormatException if an unsupported format was requested
20 | * @throws MissingDependencyException if a dependency for a formatter is not available
21 | */
22 | public function createFormatter(string $format, ?string $locale, array $options): MoneyFormatter;
23 | }
24 |
--------------------------------------------------------------------------------
/config/money.php:
--------------------------------------------------------------------------------
1 | services()
12 | ->set('money.factory.formatter', FormatterFactory::class)
13 | ->args([
14 | param('kernel.default_locale'),
15 | ])
16 | ->alias(FormatterFactoryInterface::class, 'money.factory.formatter')
17 |
18 | ->set('money.factory.parser', ParserFactory::class)
19 | ->args([
20 | param('kernel.default_locale'),
21 | ])
22 | ->alias(ParserFactoryInterface::class, 'money.factory.parser')
23 | ;
24 | };
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Michael Babker
4 | https://www.babdev.com/
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/src/Twig/MoneyExtension.php:
--------------------------------------------------------------------------------
1 |
28 | */
29 | public function getFilters(): array
30 | {
31 | return [
32 | new TwigFilter('money', $this->formatMoney(...)),
33 | ];
34 | }
35 |
36 | /**
37 | * @return list
38 | */
39 | public function getFunctions(): array
40 | {
41 | return [
42 | new TwigFunction('money', $this->createMoney(...)),
43 | ];
44 | }
45 |
46 | /**
47 | * @phpstan-param numeric-string|int $amount
48 | * @phpstan-param non-empty-string|null $currency
49 | *
50 | * @throws \InvalidArgumentException if the amount cannot be converted to a {@see Money} instance
51 | */
52 | public function createMoney(string|int $amount, ?string $currency = null): Money
53 | {
54 | return new Money($amount, new Currency($currency ?: $this->defaultCurrency));
55 | }
56 |
57 | /**
58 | * @param array $options
59 | *
60 | * @phpstan-param Format::* $format
61 | *
62 | * @throws UnsupportedFormatException if an unsupported format was requested
63 | * @throws MissingDependencyException if a dependency for a formatter is not available
64 | * @throws FormatterException if the {@see Money} instance cannot be formatted
65 | */
66 | public function formatMoney(Money $money, string $format = Format::INTL_MONEY, ?string $locale = null, array $options = []): string
67 | {
68 | return $this->formatterFactory->createFormatter($format, $locale, $options)->format($money);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/config/validator.php:
--------------------------------------------------------------------------------
1 | services()
14 | ->set('money.validator.abstract')
15 | ->abstract()
16 | ->args([
17 | service('money.factory.formatter'),
18 | service('money.factory.parser'),
19 | param('babdev_money.default_currency'),
20 | service('property_accessor'),
21 | ])
22 |
23 | ->set('money.validator.equal_to', MoneyEqualToValidator::class)
24 | ->parent('money.validator.abstract')
25 | ->tag('validator.constraint_validator', ['alias' => MoneyEqualToValidator::class])
26 |
27 | ->set('money.validator.not_equal_to', MoneyNotEqualToValidator::class)
28 | ->parent('money.validator.abstract')
29 | ->tag('validator.constraint_validator', ['alias' => MoneyNotEqualToValidator::class])
30 |
31 | ->set('money.validator.greater_than', MoneyGreaterThanValidator::class)
32 | ->parent('money.validator.abstract')
33 | ->tag('validator.constraint_validator', ['alias' => MoneyGreaterThanValidator::class])
34 |
35 | ->set('money.validator.greater_than_or_equal', MoneyGreaterThanOrEqualValidator::class)
36 | ->parent('money.validator.abstract')
37 | ->tag('validator.constraint_validator', ['alias' => MoneyGreaterThanOrEqualValidator::class])
38 |
39 | ->set('money.validator.less_than', MoneyLessThanValidator::class)
40 | ->parent('money.validator.abstract')
41 | ->tag('validator.constraint_validator', ['alias' => MoneyLessThanValidator::class])
42 |
43 | ->set('money.validator.less_than_or_equal', MoneyLessThanOrEqualValidator::class)
44 | ->parent('money.validator.abstract')
45 | ->tag('validator.constraint_validator', ['alias' => MoneyLessThanOrEqualValidator::class])
46 | ;
47 | };
48 |
--------------------------------------------------------------------------------
/src/Form/DataTransformer/MoneyToLocalizedStringTransformer.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | final readonly class MoneyToLocalizedStringTransformer implements DataTransformerInterface
26 | {
27 | public function __construct(
28 | private FormatterFactoryInterface $formatterFactory,
29 | private ParserFactoryInterface $parserFactory,
30 | private Currency $currency,
31 | private NumberToLocalizedStringTransformer $numberTransformer,
32 | private ?string $locale = null,
33 | ) {}
34 |
35 | /**
36 | * @param Money|null $value Money object
37 | *
38 | * @return string Localized money string
39 | *
40 | * @throws TransformationFailedException if the given value is not a Money instance or if the value can not be transformed
41 | */
42 | public function transform($value): string
43 | {
44 | if (null === $value) {
45 | return '';
46 | }
47 |
48 | if (!($value instanceof Money)) {
49 | throw new TransformationFailedException(\sprintf('Expected an instance of "%s", "%s" given.', Money::class, get_debug_type($value)));
50 | }
51 |
52 | $formatter = $this->formatterFactory->createFormatter(Format::DECIMAL, $this->locale, []);
53 |
54 | return $this->numberTransformer->transform((float) $formatter->format($value));
55 | }
56 |
57 | /**
58 | * @param string $value Localized money string
59 | *
60 | * @phpstan-return Money|null
61 | *
62 | * @throws TransformationFailedException if the given value is not a string or if the value can not be transformed
63 | */
64 | public function reverseTransform($value): ?Money
65 | {
66 | $value = $this->numberTransformer->reverseTransform($value);
67 |
68 | if (null === $value) {
69 | return null;
70 | }
71 |
72 | $parser = $this->parserFactory->createParser(Format::DECIMAL, $this->locale, []);
73 |
74 | try {
75 | return $parser->parse(\sprintf('%.53f', $value), $this->currency);
76 | } catch (ParserException $e) {
77 | throw new TransformationFailedException($e->getMessage(), 0, $e);
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Serializer/Normalizer/MoneyNormalizer.php:
--------------------------------------------------------------------------------
1 | $object->getAmount(),
28 | 'currency' => $object->getCurrency()->getCode(),
29 | ];
30 | }
31 |
32 | public function supportsNormalization($data, ?string $format = null, array $context = []): bool
33 | {
34 | return $data instanceof Money;
35 | }
36 |
37 | /**
38 | * @param mixed $data Data to restore
39 | *
40 | * @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported
41 | * @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data
42 | */
43 | public function denormalize($data, string $type, ?string $format = null, array $context = []): Money
44 | {
45 | if (!\is_array($data)) {
46 | throw new InvalidArgumentException(\sprintf('Data expected to be an array, "%s" given.', get_debug_type($data)));
47 | }
48 |
49 | if (!isset($data['amount']) || !isset($data['currency'])) {
50 | throw new UnexpectedValueException('Missing required keys from data array, must provide "amount" and "currency".');
51 | }
52 |
53 | \assert((\is_int($data['amount']) || is_numeric($data['amount'])) && (\is_string($data['currency']) && '' !== $data['currency']));
54 |
55 | try {
56 | return new Money($data['amount'], new Currency($data['currency'])); // @phpstan-ignore-line argument.type
57 | } catch (\Exception $e) {
58 | throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
59 | }
60 | }
61 |
62 | public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
63 | {
64 | return Money::class === $type;
65 | }
66 |
67 | /**
68 | * @return array
69 | */
70 | public function getSupportedTypes(?string $format): array
71 | {
72 | return [
73 | Money::class => true,
74 | ];
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "babdev/money-bundle",
3 | "type": "symfony-bundle",
4 | "description": "Bundle integrating the Money for PHP library with Symfony",
5 | "keywords": ["money", "moneyphp", "currency", "symfony"],
6 | "license": "MIT",
7 | "require": {
8 | "php": "^8.2",
9 | "moneyphp/money": "^4.5",
10 | "symfony/config": "^6.4 || ^7.3 || ^8.0",
11 | "symfony/dependency-injection": "^6.4 || ^7.3 || ^8.0",
12 | "symfony/deprecation-contracts": "^2.1 || ^3.0",
13 | "symfony/http-kernel": "^6.4 || ^7.3 || ^8.0"
14 | },
15 | "require-dev": {
16 | "doctrine/doctrine-bundle": "^2.11 || ^3.0",
17 | "doctrine/mongodb-odm": "^2.7",
18 | "doctrine/mongodb-odm-bundle": "^5.0",
19 | "doctrine/orm": "^2.19 || ^3.0",
20 | "jms/serializer": "^3.28",
21 | "jms/serializer-bundle": "^5.4",
22 | "matthiasnoback/symfony-dependency-injection-test": "^6.2",
23 | "phpstan/extension-installer": "^1.4",
24 | "phpstan/phpstan": "2.1.33",
25 | "phpstan/phpstan-phpunit": "2.0.10",
26 | "phpstan/phpstan-symfony": "2.0.9",
27 | "phpunit/phpunit": "11.5.46",
28 | "symfony/form": "^6.4 || ^7.3 || ^8.0",
29 | "symfony/intl": "^6.4 || ^7.3 || ^8.0",
30 | "symfony/property-access": "^6.4 || ^7.3 || ^8.0",
31 | "symfony/serializer": "^6.4 || ^7.3 || ^8.0",
32 | "symfony/twig-bundle": "^6.4 || ^7.3 || ^8.0",
33 | "symfony/validator": "^6.4 || ^7.3 || ^8.0",
34 | "twig/twig": "^2.13 || ^3.0"
35 | },
36 | "conflict": {
37 | "doctrine/doctrine-bundle": "<2.1.1",
38 | "doctrine/mongodb-odm": "<2.2",
39 | "doctrine/mongodb-odm-bundle": "<4.3",
40 | "doctrine/orm": "<2.8",
41 | "jms/serializer": "<3.14",
42 | "symfony/form": "<6.4 || >=7.0 <7.3",
43 | "symfony/serializer": "<6.4 || >=7.0 <7.3",
44 | "symfony/validator": "<6.4 || >=7.0 <7.3",
45 | "twig/twig": "<2.13"
46 | },
47 | "suggest": {
48 | "ext/intl": "To use the intl Money\\MoneyFormatter instances",
49 | "doctrine/mongodb-odm": "To use the Money\\Money class with the Doctrine MongoDB ODM",
50 | "doctrine/orm": "To use the Money\\Money class with the Doctrine ORM",
51 | "jms/serializer-bundle": "To use the Money\\Money class with the JMS Serializer",
52 | "symfony/form": "To use the Money\\Money class with the Symfony Form component",
53 | "symfony/serializer": "To use the Money\\Money class with the Symfony Serializer",
54 | "symfony/validator": "To use the Money\\Money class with the Symfony Validator",
55 | "twig/twig": "To use the Money\\Money class with Twig"
56 | },
57 | "autoload": {
58 | "psr-4": {
59 | "BabDev\\MoneyBundle\\": "src/"
60 | }
61 | },
62 | "autoload-dev": {
63 | "psr-4": {
64 | "BabDev\\MoneyBundle\\Tests\\": "tests/"
65 | }
66 | },
67 | "config": {
68 | "allow-plugins": {
69 | "composer/package-versions-deprecated": true,
70 | "ocramius/package-versions": true,
71 | "phpstan/extension-installer": true
72 | }
73 | },
74 | "minimum-stability": "dev"
75 | }
76 |
--------------------------------------------------------------------------------
/src/BabDevMoneyBundle.php:
--------------------------------------------------------------------------------
1 | addCompilerPass(DoctrineMongoDBMappingsPass::createXmlMappingDriver([realpath(__DIR__.'/../config/mapping') => 'Money'], []));
32 | }
33 |
34 | // Register ORM mappings if DoctrineBundle and the ORM are installed
35 | if (class_exists(DoctrineBundle::class) && class_exists(EntityManager::class)) {
36 | $container->addCompilerPass(DoctrineOrmMappingsPass::createXmlMappingDriver([realpath(__DIR__.'/../config/mapping') => 'Money'], [], false, [], true));
37 | }
38 | }
39 |
40 | /**
41 | * @param DefinitionConfigurator<'array'> $definition
42 | */
43 | public function configure(DefinitionConfigurator $definition): void
44 | {
45 | $definition->rootNode()
46 | ->children()
47 | ->scalarNode('default_currency')->defaultValue('USD')->end()
48 | ->end()
49 | ;
50 | }
51 |
52 | /**
53 | * @param array{default_currency: non-empty-string} $config
54 | */
55 | public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
56 | {
57 | $container->parameters()
58 | ->set('babdev_money.default_currency', $config['default_currency']);
59 |
60 | $container->import('../config/money.php');
61 |
62 | if (ContainerBuilder::willBeAvailable('twig/twig', Environment::class, ['symfony/twig-bundle', 'babdev/money-bundle'])) {
63 | $container->import('../config/twig.php');
64 | }
65 |
66 | if (ContainerBuilder::willBeAvailable('jms/serializer', SerializerInterface::class, ['jms/serializer-bundle', 'babdev/money-bundle'])) {
67 | $container->import('../config/jms_serializer.php');
68 | }
69 |
70 | if (ContainerBuilder::willBeAvailable('symfony/form', FormInterface::class, ['babdev/money-bundle'])) {
71 | $container->import('../config/form.php');
72 | }
73 |
74 | if (ContainerBuilder::willBeAvailable('symfony/serializer', NormalizerInterface::class, ['babdev/money-bundle'])) {
75 | $container->import('../config/serializer.php');
76 | }
77 |
78 | if (ContainerBuilder::willBeAvailable('symfony/validator', ValidatorInterface::class, ['babdev/money-bundle'])) {
79 | $container->import('../config/validator.php');
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/AbstractMoneyComparison.php:
--------------------------------------------------------------------------------
1 |
45 | */
46 | public int $fractionDigits = 2;
47 |
48 | public bool $groupingUsed = true;
49 | public ?string $locale = null;
50 | public string $style = 'currency';
51 |
52 | public string|PropertyPathInterface|null $propertyPath = null;
53 |
54 | /**
55 | * @param mixed $value The value to compare or a set of options
56 | * @param string|PropertyPathInterface|null $propertyPath An optional property path to read
57 | * @param array|null $options A set of options
58 | * @param string[] $groups An array of validation groups
59 | * @param mixed $payload Domain-specific data attached to a constraint
60 | */
61 | #[HasNamedArguments]
62 | public function __construct(mixed $value = null, $propertyPath = null, ?string $message = null, ?array $options = null, ?array $groups = null, mixed $payload = null)
63 | {
64 | if (\is_array($value)) {
65 | trigger_deprecation('babdev/money-bundle', '2.1', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class);
66 |
67 | $options = array_merge($value, $options ?? []);
68 | } elseif (null !== $value) {
69 | if (\is_array($options)) {
70 | trigger_deprecation('babdev/money-bundle', '2.1', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class);
71 | } else {
72 | $options = [];
73 | }
74 |
75 | $options['value'] = $value;
76 | }
77 |
78 | parent::__construct($options, $groups, $payload);
79 |
80 | $this->message = $message ?? $this->message;
81 | $this->value = $value ?? $this->value;
82 | $this->propertyPath = $propertyPath ?? $this->propertyPath;
83 |
84 | if (null === $this->value && null === $this->propertyPath) {
85 | throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires either the "value" or "propertyPath" option to be set.', static::class));
86 | }
87 |
88 | if (null !== $this->value && null !== $this->propertyPath) {
89 | throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires only one of the "value" or "propertyPath" options to be set, not both.', static::class));
90 | }
91 |
92 | if (null !== $this->propertyPath && !class_exists(PropertyAccess::class)) {
93 | throw new LogicException(\sprintf('The "%s" constraint requires the Symfony PropertyAccess component to use the "propertyPath" option.', static::class));
94 | }
95 | }
96 |
97 | public function getDefaultOption(): ?string
98 | {
99 | return 'value';
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/Serializer/Handler/MoneyHandler.php:
--------------------------------------------------------------------------------
1 | GraphNavigatorInterface::DIRECTION_SERIALIZATION,
24 | 'format' => 'json',
25 | 'type' => Money::class,
26 | 'method' => 'serializeMoneyToJson',
27 | ],
28 | [
29 | 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
30 | 'format' => 'json',
31 | 'type' => Money::class,
32 | 'method' => 'deserializeMoneyFromJson',
33 | ],
34 | [
35 | 'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
36 | 'format' => 'xml',
37 | 'type' => Money::class,
38 | 'method' => 'serializeMoneyToXml',
39 | ],
40 | [
41 | 'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
42 | 'format' => 'xml',
43 | 'type' => Money::class,
44 | 'method' => 'deserializeMoneyFromXml',
45 | ],
46 | ];
47 | }
48 |
49 | /**
50 | * @param array{amount: int|numeric-string, currency: non-empty-string} $moneyAsArray
51 | *
52 | * @throws InvalidArgumentException if a {@see Money} instance could not be created from the serialized data
53 | */
54 | public function deserializeMoneyFromJson(DeserializationVisitorInterface $visitor, array $moneyAsArray, array $type, DeserializationContext $context): Money
55 | {
56 | try {
57 | return new Money($moneyAsArray['amount'], new Currency($moneyAsArray['currency']));
58 | } catch (\Exception $exception) {
59 | throw new InvalidArgumentException('Could not deserialize Money data.', $exception->getCode(), $exception);
60 | }
61 | }
62 |
63 | /**
64 | * @throws InvalidArgumentException if a {@see Money} instance could not be created from the serialized data
65 | */
66 | public function deserializeMoneyFromXml(XmlDeserializationVisitor $visitor, \SimpleXMLElement $moneyAsXml, array $type, DeserializationContext $context): Money
67 | {
68 | /** @phpstan-var numeric-string $amount */
69 | $amount = (string) $moneyAsXml->amount;
70 |
71 | /** @phpstan-var non-empty-string $currency */
72 | $currency = (string) $moneyAsXml->currency;
73 |
74 | try {
75 | return new Money($amount, new Currency($currency));
76 | } catch (\Exception $exception) {
77 | throw new InvalidArgumentException('Could not deserialize Money data.', $exception->getCode(), $exception);
78 | }
79 | }
80 |
81 | /**
82 | * @param array{name: string, params: array} $type
83 | *
84 | * @return array|\ArrayObject
85 | */
86 | public function serializeMoneyToJson(JsonSerializationVisitor $visitor, Money $money, array $type, SerializationContext $context)
87 | {
88 | /** @phpstan-ignore-next-line return.type */
89 | return $visitor->visitArray(
90 | [
91 | 'amount' => $money->getAmount(),
92 | 'currency' => $money->getCurrency()->getCode(),
93 | ],
94 | $type
95 | );
96 | }
97 |
98 | public function serializeMoneyToXml(XmlSerializationVisitor $visitor, Money $money, array $type, SerializationContext $context): \DOMElement
99 | {
100 | $amountNode = $visitor->getDocument()->createElement('amount');
101 | $amountNode->nodeValue = $money->getAmount();
102 |
103 | $currencyNode = $visitor->getDocument()->createElement('currency');
104 | $currencyNode->nodeValue = $money->getCurrency()->getCode();
105 |
106 | $moneyNode = $visitor->getDocument()->createElement('money');
107 | $moneyNode->appendChild($amountNode);
108 | $moneyNode->appendChild($currencyNode);
109 |
110 | return $moneyNode;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/Factory/ParserFactory.php:
--------------------------------------------------------------------------------
1 | >
20 | *
21 | * @phpstan-var array>
22 | */
23 | private const PARSER_MAP = [
24 | Format::BITCOIN => BitcoinMoneyParser::class,
25 | Format::DECIMAL => DecimalMoneyParser::class,
26 | Format::INTL_LOCALIZED_DECIMAL => IntlLocalizedDecimalParser::class,
27 | Format::INTL_MONEY => IntlMoneyParser::class,
28 | ];
29 |
30 | public function __construct(private readonly string $defaultLocale) {}
31 |
32 | /**
33 | * @param array{fraction_digits?: int<0, max>, grouping_used?: bool, style?: string} $options
34 | *
35 | * @phpstan-param Format::* $format
36 | * @phpstan-param array $options
37 | *
38 | * @throws UnsupportedFormatException if an unsupported format was requested
39 | * @throws MissingDependencyException if a dependency for a parser is not available
40 | */
41 | public function createParser(string $format, ?string $locale = null, array $options = []): MoneyParser
42 | {
43 | switch ($format) {
44 | case Format::AGGREGATE:
45 | throw new UnsupportedFormatException(array_keys(self::PARSER_MAP), \sprintf('The "%s" class is not supported by "%s".', AggregateMoneyParser::class, self::class));
46 | case Format::BITCOIN:
47 | $fractionDigits = (int) ($options['fraction_digits'] ?? 8);
48 |
49 | return new BitcoinMoneyParser($fractionDigits);
50 |
51 | case Format::DECIMAL:
52 | return new DecimalMoneyParser(new ISOCurrencies());
53 |
54 | case Format::INTL_LOCALIZED_DECIMAL:
55 | if (!class_exists(\NumberFormatter::class)) {
56 | throw new MissingDependencyException(\sprintf('The "intl_localized_decimal" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class));
57 | }
58 |
59 | $formatterLocale = $locale ?: $this->defaultLocale;
60 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2);
61 | $groupingUsed = (bool) ($options['grouping_used'] ?? true);
62 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY;
63 |
64 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY);
65 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits);
66 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0);
67 |
68 | return new IntlLocalizedDecimalParser($numberFormatter, new ISOCurrencies());
69 |
70 | case Format::INTL_MONEY:
71 | if (!class_exists(\NumberFormatter::class)) {
72 | throw new MissingDependencyException(\sprintf('The "intl_money" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class));
73 | }
74 |
75 | $formatterLocale = $locale ?: $this->defaultLocale;
76 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2);
77 | $groupingUsed = (bool) ($options['grouping_used'] ?? true);
78 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY;
79 |
80 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY);
81 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits);
82 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0);
83 |
84 | return new IntlMoneyParser($numberFormatter, new ISOCurrencies());
85 |
86 | default:
87 | throw new UnsupportedFormatException(array_keys(self::PARSER_MAP), \sprintf('Unsupported format "%s"', $format));
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Factory/FormatterFactory.php:
--------------------------------------------------------------------------------
1 | >
21 | *
22 | * @phpstan-var array>
23 | */
24 | private const FORMAT_MAP = [
25 | Format::BITCOIN => BitcoinMoneyFormatter::class,
26 | Format::DECIMAL => DecimalMoneyFormatter::class,
27 | Format::INTL_LOCALIZED_DECIMAL => IntlLocalizedDecimalFormatter::class,
28 | Format::INTL_MONEY => IntlMoneyFormatter::class,
29 | ];
30 |
31 | public function __construct(private readonly string $defaultLocale) {}
32 |
33 | /**
34 | * @param array{fraction_digits?: int<0, max>, grouping_used?: bool, style?: string} $options
35 | *
36 | * @phpstan-param Format::* $format
37 | * @phpstan-param array $options
38 | *
39 | * @throws UnsupportedFormatException if an unsupported format was requested
40 | * @throws MissingDependencyException if a dependency for a formatter is not available
41 | */
42 | public function createFormatter(string $format, ?string $locale = null, array $options = []): MoneyFormatter
43 | {
44 | switch ($format) {
45 | case Format::AGGREGATE:
46 | throw new UnsupportedFormatException(array_keys(self::FORMAT_MAP), \sprintf('The "%s" class is not supported by "%s".', AggregateMoneyFormatter::class, self::class));
47 | case Format::BITCOIN:
48 | $fractionDigits = (int) ($options['fraction_digits'] ?? 8);
49 |
50 | return new BitcoinMoneyFormatter($fractionDigits, new BitcoinCurrencies());
51 |
52 | case Format::DECIMAL:
53 | return new DecimalMoneyFormatter(new ISOCurrencies());
54 |
55 | case Format::INTL_LOCALIZED_DECIMAL:
56 | if (!class_exists(\NumberFormatter::class)) {
57 | throw new MissingDependencyException(\sprintf('The "intl_localized_decimal" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class));
58 | }
59 |
60 | $formatterLocale = $locale ?: $this->defaultLocale;
61 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2);
62 | $groupingUsed = (bool) ($options['grouping_used'] ?? true);
63 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY;
64 |
65 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY);
66 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits);
67 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0);
68 |
69 | return new IntlLocalizedDecimalFormatter($numberFormatter, new ISOCurrencies());
70 |
71 | case Format::INTL_MONEY:
72 | if (!class_exists(\NumberFormatter::class)) {
73 | throw new MissingDependencyException(\sprintf('The "intl_money" format requires the "%s" class to be available. You will need to either install the PHP "intl" extension or the "symfony/polyfill-intl-icu" package with Composer (the polyfill is only available for the "en" locale).', \NumberFormatter::class));
74 | }
75 |
76 | $formatterLocale = $locale ?: $this->defaultLocale;
77 | $fractionDigits = (int) ($options['fraction_digits'] ?? 2);
78 | $groupingUsed = (bool) ($options['grouping_used'] ?? true);
79 | $optionsStyle = $options['style'] ?? self::STYLE_CURRENCY;
80 |
81 | $numberFormatter = new \NumberFormatter($formatterLocale, self::STYLE_DECIMAL === $optionsStyle ? \NumberFormatter::DECIMAL : \NumberFormatter::CURRENCY);
82 | $numberFormatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $fractionDigits);
83 | $numberFormatter->setAttribute(\NumberFormatter::GROUPING_USED, $groupingUsed ? 1 : 0);
84 |
85 | return new IntlMoneyFormatter($numberFormatter, new ISOCurrencies());
86 |
87 | default:
88 | throw new UnsupportedFormatException(array_keys(self::FORMAT_MAP), \sprintf('Unsupported format "%s"', $format));
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/Form/Type/MoneyType.php:
--------------------------------------------------------------------------------
1 |
27 | */
28 | final class MoneyType extends AbstractType
29 | {
30 | private readonly Currency $defaultCurrency;
31 |
32 | /**
33 | * @var array>
34 | */
35 | private static array $patterns = [];
36 |
37 | /**
38 | * @phpstan-param non-empty-string $defaultCurrency
39 | */
40 | public function __construct(
41 | private readonly FormatterFactoryInterface $formatterFactory,
42 | private readonly ParserFactoryInterface $parserFactory,
43 | string $defaultCurrency,
44 | ) {
45 | $this->defaultCurrency = new Currency($defaultCurrency);
46 | }
47 |
48 | public function buildForm(FormBuilderInterface $builder, array $options): void
49 | {
50 | // Values used in HTML5 number inputs should be formatted as in "1234.5", ie. 'en' format without grouping,
51 | // according to https://www.w3.org/TR/html51/sec-forms.html#date-time-and-number-formats
52 | $builder
53 | ->addViewTransformer(new MoneyToLocalizedStringTransformer(
54 | $this->formatterFactory,
55 | $this->parserFactory,
56 | $options['currency'],
57 | new NumberToLocalizedStringTransformer(
58 | $options['scale'],
59 | $options['grouping'],
60 | $options['rounding_mode'],
61 | $options['html5'] ? 'en' : null
62 | )
63 | ))
64 | ;
65 | }
66 |
67 | public function buildView(FormView $view, FormInterface $form, array $options): void
68 | {
69 | $view->vars['money_pattern'] = self::getPattern($options['currency']); // @phpstan-ignore-line argument.type
70 |
71 | if ($options['html5']) {
72 | $view->vars['type'] = 'number';
73 | }
74 | }
75 |
76 | public function configureOptions(OptionsResolver $resolver): void
77 | {
78 | $resolver->setDefaults([
79 | 'scale' => 2,
80 | 'grouping' => false,
81 | 'rounding_mode' => \NumberFormatter::ROUND_HALFUP,
82 | 'currency' => $this->defaultCurrency,
83 | 'compound' => false,
84 | 'html5' => false,
85 | 'invalid_message' => 'Please enter a valid money amount.',
86 | ]);
87 |
88 | $resolver->setAllowedValues(
89 | 'rounding_mode',
90 | [
91 | \NumberFormatter::ROUND_FLOOR,
92 | \NumberFormatter::ROUND_DOWN,
93 | \NumberFormatter::ROUND_HALFDOWN,
94 | \NumberFormatter::ROUND_HALFEVEN,
95 | \NumberFormatter::ROUND_HALFUP,
96 | \NumberFormatter::ROUND_UP,
97 | \NumberFormatter::ROUND_CEILING,
98 | ]
99 | );
100 |
101 | $resolver->setAllowedTypes('scale', 'int');
102 | $resolver->setAllowedTypes('html5', 'bool');
103 | $resolver->setAllowedTypes('currency', Currency::class);
104 |
105 | $resolver->setNormalizer(
106 | 'grouping',
107 | static function (Options $options, $value) {
108 | if ($value && $options['html5']) {
109 | throw new LogicException('Cannot use the "grouping" option when the "html5" option is enabled.');
110 | }
111 |
112 | return $value;
113 | }
114 | );
115 | }
116 |
117 | public function getBlockPrefix(): string
118 | {
119 | return 'money';
120 | }
121 |
122 | /**
123 | * Returns the pattern for this locale in UTF-8.
124 | *
125 | * The pattern contains the placeholder "{{ widget }}" where the HTML tag should be inserted.
126 | */
127 | private static function getPattern(Currency $currency): string
128 | {
129 | $currencyCode = $currency->getCode();
130 |
131 | $locale = \Locale::getDefault();
132 |
133 | if (!isset(self::$patterns[$locale])) {
134 | self::$patterns[$locale] = [];
135 | }
136 |
137 | if (!isset(self::$patterns[$locale][$currencyCode])) {
138 | $format = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
139 | $pattern = $format->formatCurrency(123.0, $currencyCode);
140 |
141 | // the spacings between currency symbol and number are ignored, because
142 | // a single space leads to better readability in combination with input
143 | // fields
144 |
145 | // the regex also considers non-break spaces (0xC2 or 0xA0 in UTF-8)
146 |
147 | preg_match('/^([^\s\xc2\xa0]*)[\s\xc2\xa0]*123(?:[,.]0+)?[\s\xc2\xa0]*([^\s\xc2\xa0]*)$/u', $pattern, $matches);
148 |
149 | if (!empty($matches[1])) {
150 | self::$patterns[$locale][$currencyCode] = $matches[1].' {{ widget }}';
151 | } elseif (!empty($matches[2])) {
152 | self::$patterns[$locale][$currencyCode] = '{{ widget }} '.$matches[2];
153 | } else {
154 | self::$patterns[$locale][$currencyCode] = '{{ widget }}';
155 | }
156 | }
157 |
158 | return self::$patterns[$locale][$currencyCode];
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/Validator/Constraints/AbstractMoneyComparisonValidator.php:
--------------------------------------------------------------------------------
1 | propertyPath) {
47 | if (null === $object = $this->context->getObject()) {
48 | return;
49 | }
50 |
51 | try {
52 | $comparedValue = $this->getPropertyAccessor()->getValue($object, $path);
53 | } catch (NoSuchPropertyException $e) {
54 | throw new InvalidArgumentException(\sprintf('Invalid property path "%s" provided to "%s" constraint: ', $path, get_debug_type($constraint)).$e->getMessage(), 0, $e);
55 | }
56 | } else {
57 | $comparedValue = $constraint->value;
58 | }
59 |
60 | /** @var Money $firstValue */
61 | $firstValue = $this->ensureMoneyObject($constraint, $value);
62 | $secondValue = $this->ensureMoneyObject($constraint, $comparedValue);
63 |
64 | if (!$this->compareValues($firstValue, $secondValue)) {
65 | $violationBuilder = $this->context->buildViolation($constraint->message)
66 | ->setParameter('{{ value }}', $this->formatterFactory->createFormatter($constraint->formatterFormat, $constraint->locale, $this->createFactoryOptions($constraint))->format($firstValue))
67 | ->setParameter('{{ compared_value }}', null !== $secondValue ? $this->formatterFactory->createFormatter($constraint->formatterFormat, $constraint->locale, $this->createFactoryOptions($constraint))->format($secondValue) : 'N/A')
68 | ->setParameter('{{ compared_value_type }}', $this->formatTypeOf($comparedValue))
69 | ->setCode($this->getErrorCode());
70 |
71 | if (null !== $path) {
72 | $violationBuilder->setParameter('{{ compared_value_path }}', (string) $path);
73 | }
74 |
75 | $violationBuilder->addViolation();
76 | }
77 | }
78 |
79 | /**
80 | * @return array{fraction_digits: int<0, max>, grouping_used: bool, style: string}
81 | */
82 | private function createFactoryOptions(AbstractMoneyComparison $constraint): array
83 | {
84 | return [
85 | 'fraction_digits' => $constraint->fractionDigits,
86 | 'grouping_used' => $constraint->groupingUsed,
87 | 'style' => $constraint->style,
88 | ];
89 | }
90 |
91 | /**
92 | * @phpstan-param Money|float|int|numeric-string|null $value
93 | */
94 | private function ensureMoneyObject(AbstractMoneyComparison $constraint, Money|float|int|string|null $value): ?Money
95 | {
96 | if ($value instanceof Money || null === $value) {
97 | return $value;
98 | }
99 |
100 | // First try to parse (assuming formatted input) then fall back to treating as a number
101 | if (\is_string($value) && str_contains($value, '.')) {
102 | try {
103 | return $this->parserFactory->createParser($constraint->parserFormat, $constraint->locale, $this->createFactoryOptions($constraint))->parse($value, new Currency($constraint->currency ?: $this->defaultCurrency));
104 | } catch (ParserException $exception) {
105 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Money::class), 0, $exception);
106 | }
107 | }
108 |
109 | try {
110 | $number = \is_float($value) ? Number::fromFloat($value) : Number::fromNumber($value);
111 | } catch (\InvalidArgumentException $exception) {
112 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Number::class), 0, $exception);
113 | }
114 |
115 | try {
116 | return new Money((string) $number, new Currency($constraint->currency ?: $this->defaultCurrency));
117 | } catch (\InvalidArgumentException $exception) {
118 | throw new InvalidArgumentException(\sprintf('Could not convert value "%s" to a "%s" instance for comparison.', $value, Money::class), 0, $exception);
119 | }
120 | }
121 |
122 | private function getPropertyAccessor(): PropertyAccessorInterface
123 | {
124 | return $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor();
125 | }
126 |
127 | abstract protected function compareValues(Money $value1, ?Money $value2): bool;
128 |
129 | protected function getErrorCode(): ?string
130 | {
131 | return null;
132 | }
133 | }
134 |
--------------------------------------------------------------------------------