onmoon / money
基于 doctrine 类型的 moneyphp/money 的意见性扩展
Requires
- php: ^8.0|^8.1
- ext-bcmath: *
- doctrine/dbal: ^2|^3
- moneyphp/money: ^4.0.3
- thecodingmachine/safe: ^1.3|^2
Requires (Dev)
- doctrine/coding-standard: ^9.0
- phpstan/phpstan: ^1.4
- phpunit/phpunit: ^9.5.5
- roave/no-floaters: ^1.5
- squizlabs/php_codesniffer: ^3.6
- thecodingmachine/phpstan-safe-rule: ^1.1|^1.2
- vimeo/psalm: ^4.20
README
OnMoon Money 是围绕 MoneyPHP Money 的一个有意见的包装器:https://github.com/moneyphp/money
安装
安装此扩展的首选方式是通过composer。
composer require onmoon/money
功能
在原始 API 的基础上,增加了更多严格性和一些附加功能。
钱类可以扩展并用作 Doctrine Embeddables
MoneyPHP 对象是最终的,因此您不能创建自己的域值对象以添加更多语义到代码中
<?php namespace App\Application\Service; use Money\Money; class InvoiceService { public function calculateFee(Money $amount) : Money { ... } }
使用 OnMoon Money 您可以这样做
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; class InvoiceAmount extends Money { }
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; class InvoiceFee extends Money { }
<?php namespace App\Application\Service; use App\Domain\Entity\Invoice\ValueObject\InvoiceAmount; use App\Domain\Entity\Invoice\ValueObject\InvoiceFee; class InvoiceService { public function calculateFee(InvoiceAmount $amount) : InvoiceFee { ... } }
此外,MoneyPHP Money 类将货币内部存储为对象,这对于在 Doctrine 中使用 embeddables 映射值对象来说是一个问题,因为 Money 对象本身就是一个 embeddable,并且您会得到嵌套的 embeddables
<?php namespace Money; final class Money implements \JsonSerializable { /** * @var Currency */ private $currency; ... }
OnMoon Money 类将货币内部存储为字符串,并且可以使用提供的 Doctrine Types 映射为一个 embeddable
<?php namespace OnMoon\Money; abstract class BaseMoney { /** @var string */ private $amount; /** @var string */ private $currency; ... }
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <embeddable name="My\Awesome\MoneyClass"> <field name="amount" type="money" column="income" nullable="false" /> <field name="currency" type="currency" column="income_currency" nullable="false" /> </embeddable> </doctrine-mapping>
货币只从字符串创建,严格遵循货币的格式
MoneyPHP 允许从广泛的各种输入创建 Money 对象,并且需要输入金额以货币的子单位表示。没有检查货币实际有多少子单位。这要求您在代码中进行验证和检查,并且可能会出错。
<?php use Money\Money; use Money\Currency; $money = new Money(100, new Currency('EUR')); // 1 Euro $money = new Money(100.00, new Currency('EUR')); // 1 Euro $money = new Money('100', new Currency('EUR')); // 1 Euro $money = new Money('100.00', new Currency('EUR')); // 1 Euro $money = new Money('100.00', new Currency('XBT')); // 0.00000100 Bitcoins
OnMoon Money 相反,仅接受包含货币金额的字符串,该金额以人类可读的格式表示,并且严格遵循所使用的货币的格式。
<?php use OnMoon\Money\Money; use OnMoon\Money\GaapMoney; use OnMoon\Money\Bitcoin; use OnMoon\Money\Currency; $money = Money::create('100', Currency::create('BIF')); // 100 Burundi Francs $money = Money::create('100.00', Currency::create('EUR')); // 100 Euros $money = GaapMoney::create('100.000', Currency::create('IQD')); // 100 Iraqi Dinars $money = Bitcoin::create('100.00000000', Currency::create('XBT')); // 100 Bitcoins $money = Money::create(100, Currency::create('EUR')); // Error, invalid type $money = Money::create(100.00, Currency::create('EUR')); // Error, invalid type $money = Money::create('100', Currency::create('EUR')); // Error, no subunits specified $money = Money::create('100.0', Currency::create('EUR')); // Error, not all subunits specified $money = Money::create('100.000', Currency::create('EUR')); // Error, too many subunits specified
相同的 API,但是严格类型化
MoneyPHP Money
Money\Money::multiply($multiplier, $roundingMode = self::ROUND_HALF_UP)
Money\Money::allocate(array $ratios)
OnMoon Money
OnMoon\Money\Money::multiply(string $multiplier, int $roundingMode = LibMoney::ROUND_UP) : self
OnMoon\Money\Money::allocate(string ...$ratios) : array
等等。
为您的代码扩展库类并提供有意义的消息进行自定义验证
<?php namespace App\Domain\Entity\Invoice\ValueObject; use Money\Currencies; use Money\Currencies\CurrencyList; use OnMoon\Money\BaseMoney; use OnMoon\Money\Money; use OnMoon\Money\Currency; use OnMoon\Money\Exception\CannotCreateMoney; class InvoiceIncome extends Money { public static function humanReadableName() : string { return 'Invoice Income'; } protected static function amountMustBeZeroOrGreater() : bool { return true; } protected static function getAllowedCurrencies() : Currencies { return new CurrencyList(['EUR' => 2, 'USD' => 2]); } protected static function validate(BaseMoney $money) : void { if ($money->getCurrency()->getCode() === 'EUR' && $money->greaterThan(Money::create('50.00', $money->getCurrency())) ) { throw new CannotCreateMoney('Cannot exceed 50.00 for EUR currency'); } } } $invoiceIncome = InvoiceIncome::create('100.00', Currency::create('RUB')); // Error: Invalid Invoice Income with amount: 100.00 and currency: RUB. Currency not allowed. $invoiceIncome = InvoiceIncome::create('100.00', Currency::create('EUR')); // Error: Cannot exceed 50.00 for EUR currency $invoiceIncome = InvoiceIncome::create('-100.00', Currency::create('USD')); // Error: Invalid Invoice Income with amount: -100.00 and currency: USD. Amount must be zero or greater. $invoiceIncome = InvoiceIncome::create('100.00', Currency::create('USD')); // ok
使用方法
首先,您应该熟悉MoneyPHP Money 文档
货币类
OnMoon Money 提供了一个用于表示货币值的类:OnMoon\Money\Currency
。
要创建一个货币对象,您需要货币代码
<?php use OnMoon\Money\Currency; $euroCode = 'EUR'; $euro = Currency::create($euroCode);
钱类
OnMoon Money 类的 API 与 MoneyPHP Money 类相同
您可以使用自己的语义创建自己的 Money 类
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; class InvoiceAmount extends Money { }
您可以通过使用命名构造函数创建特定 Money 类的实例
<?php use OnMoon\Money\Money; use OnMoon\Money\Currency; use App\Domain\Entity\Invoice\ValueObject\InvoiceAmount; $money = Money::create('100.00', Currency::create('EUR')); // instance of OnMoon\Money\Money $money = InvoiceAmount::create('100.00', Currency::create('EUR')); // instance of App\Domain\Entity\Invoice\ValueObject\InvoiceAmount
子单位
库提供了三个基类,您可以直接使用或从中扩展
OnMoon\Money\Money
- 可以处理最多 2 个子单位的货币
OnMoon\Money\GaapMoney
- 可以处理最多 4 个子单位,并符合GAAP 标准
OnMoon\Money\BTC
- 可以处理 8 个子单位,并且仅限于比特币 (XBT) 货币
根据您使用或从中扩展的基类,某些货币可能不可用,因为它们需要比基类可以处理的更多子单位。
您应该根据应用程序将使用的货币选择基类。
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; use OnMoon\Money\GaapMoney; use OnMoon\Money\Currency; class InvoiceAmount extends Money { } class InvoiceFee extends GaapMoney { } $money = InvoiceAmount::create('100.00', Currency::create('EUR')); // ok $money = InvoiceAmount::create('100.000', Currency::create('BHD')); // error $money = InvoiceFee::create('100.00', Currency::create('EUR')); // ok $money = InvoiceFee::create('100.000', Currency::create('BHD')); // ok
如果您需要自定义的子单位金额,可以扩展任何 Money 类并实现 classSubunits
方法。
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; use OnMoon\Money\Currency; class InvoiceAmount extends Money { protected static function classSubunits() : int { return 0; } } $money = InvoiceAmount::create('100', Currency::create('DJF')); // ok $money = InvoiceAmount::create('100.00', Currency::create('EUR')); // error
请记住,您不能在 Money 类 API 中使用不同子单位的 Money 类
namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; use OnMoon\Money\Currency; class TwoSubunitMoney extends Money { protected static function subUnits() : int { return 2; } } class FourSubunitMoney extends Money { protected static function subUnits() : int { return 4; } } $twoSubunitMoney = TwoSubunitMoney::create('100.00', Currency::create('EUR')); $otherTwoSubunitMoney = TwoSubunitMoney::create('100.00', Currency::create('EUR')); $twoSubunitMoney->add($otherTwoSubunitMoney); // ok $fourSubunitMoney = FourSubunitMoney::create('100.00', Currency::create('EUR')); $twoSubunitMoney->add($fourSubunitMoney); // error
验证
除了基类提供的验证外,您还可以在扩展类中强制执行额外的约束。
通过实现以下方法之一
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; use OnMoon\Money\Currency; class PositiveAmountMoney extends Money { protected static function amountMustBeZeroOrGreater() : bool { return true; } } $money = PositiveAmountMoney::create('0.00', Currency::create('EUR')); // ok $money = PositiveAmountMoney::create('-0.01', Currency::create('EUR')); // error class GreaterThanZeroAmountMoney extends Money { protected static function amountMustBeGreaterThanZero() : bool { return true; } } $money = GreaterThanZeroAmountMoney::create('0.01', Currency::create('EUR')); // ok $money = GreaterThanZeroAmountMoney::create('0.00', Currency::create('EUR')); // error class ZeroOrLessAmountMoney extends Money { protected static function amountMustBeZeroOrLess() : bool { return true; } } $money = ZeroOrLessAmountMoney::create('0.00', Currency::create('EUR')); // ok $money = ZeroOrLessAmountMoney::create('0.01', Currency::create('EUR')); // error class NegativeAmountMoney extends Money { protected static function amountMustBeLessThanZero() : bool { return true; } } $money = NegativeAmountMoney::create('-0.01', Currency::create('EUR')); // ok $money = NegativeAmountMoney::create('0.00', Currency::create('EUR')); // error
如果您需要更复杂的验证逻辑,请实现以下方法
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\BaseMoney; use OnMoon\Money\Exception\CannotCreateMoney; use OnMoon\Money\Money; use OnMoon\Money\Currency; class ComplexValidationMoney extends Money { protected static function validate(BaseMoney $money) : void { if ($money->getCurrency()->getCode() === 'EUR' && $money->greaterThan(Money::create('50.00', $money->getCurrency())) ) { throw new CannotCreateMoney('Cannot work with Euros if amount is greater than 50.00'); } } } $money = ComplexValidationMoney::create('40.00', Currency::create('EUR')); // ok $money = ComplexValidationMoney::create('51.00', Currency::create('EUR')); // error
您还可以指定Money类及其所有扩展类的允许货币列表
<?php namespace App\Domain\Entity\Invoice\ValueObject; use Money\Currencies; use Money\Currencies\CurrencyList; use OnMoon\Money\BaseMoney; use OnMoon\Money\Exception\CannotCreateMoney; use OnMoon\Money\Money; use OnMoon\Money\Currency; class OnlyUsdMoney extends Money { protected static function getAllowedCurrencies() : Currencies { return new CurrencyList(['USD' => 2]); } } $money = OnlyUsdMoney::create('50.00', Currency::create('USD')); // ok $money = OnlyUsdMoney::create('50.00', Currency::create('EUR')); // error
库提供的默认类支持以下货币
OnMoon\Money\Money
- 所有带有0-2个子单位的ISO货币
OnMoon\Money\GaapMoney
- 所有带有0-4个子单位的ISO货币
OnMoon\Money\BTC
- 只有带有8个子单位的XBT
对Money类的所有更改金额的操作将返回基类而不是扩展类,因为结果金额可能会违反扩展类的不变性
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; use OnMoon\Money\Currency; class MyMoney extends Money { protected static function amountMustBeZeroOrGreater() : bool { return true; } } $money = MyMoney::create('100.00', Currency::create('EUR')); // instance of App\Domain\Entity\Invoice\ValueObject\MyMoney $otherMoney = MyMoney::create('200.00', Currency::create('EUR')); // instance of App\Domain\Entity\Invoice\ValueObject\MyMoney $sum = $money->subtract($otherMoney); // returns instance of OnMoon\Money\Money
错误处理
OnMoon货币类抛出的异常是从两个基异常扩展的
OnMoon\Money\Exception\MoneyLogicError
- 代表您代码中的逻辑错误,应在生产中避免,错误信息不应显示给用户。OnMoon\Money\Exception\MoneyRuntimeError
- 代表您代码中的运行时错误,可能依赖于用户输入。您可以使用它们安全地显示错误给用户。
OnMoon\Money\Exception\MoneyRuntimeError
错误消息示例
- 金额为100.00,货币为RUB的无效Money。不允许的货币。
- 金额为50.000,货币为EUR的无效Money。无效的金额格式。正确的格式是:/^-?\d+.\d{2}$/。
- 金额为-11.00,货币为USD的无效Money。金额必须大于零。
您可以通过在Money类中实现humanReadableName
方法来使这些消息更加有用
<?php namespace App\Domain\Entity\Transaction\ValueObject; use OnMoon\Money\Money; class TransactionFee extends Money { public static function humanReadableName() : string { return 'Transaction Fee'; } }
然后错误消息将如下所示
- 金额为100.00,货币为RUB的交易费用无效。不允许的货币。
- 金额为50.000,货币为EUR的交易费用无效。无效的金额格式。正确的格式是:/^-\d+.\d{2}$/。
- 金额为-11.00,货币为USD的交易费用无效。金额必须大于零。
如果您想捕获OnMoon Money抛出的所有异常,包括底层MoneyPHP Money代码的异常,请使用Money\Exception
接口。
使用库与 Symfony 和 Doctrine
库提供了四个Doctrine类型,用于将Money和Currency对象持久化到数据库
OnMoon\Money\Type\BTCMoneyType
- 应仅用于扩展OnMoon\Money\BTC
的类OnMoon\Money\Type\GaapMoneyType
- 应仅用于扩展OnMoon\Money\GaapMoney
的类OnMoon\Money\Type\MoneyType
- 应仅用于扩展OnMoon\Money\Money
的类OnMoon\Money\Type\CurrencyType
- 应仅用于扩展OnMoon\Money\Curency
的类
将Money对象映射到Type类的经验法则是,Type类的十进制精度应等于Money类的子单位。如果它们不同,您将从数据库中获取与之前保存的其他金额。
示例代码
实体
<?php namespace App\Domain\Entity\Invoice; use App\Domain\Entity\Invoice\ValueObject\InvoiceIncome; class Invoice { /** @var InvoiceIncome $income */ private $income; public function __construct(InvoiceIncome $income) { $this->income = $income; } public function income() : InvoiceIncome { return $this->income(); } }
值对象
<?php namespace App\Domain\Entity\Invoice\ValueObject; use OnMoon\Money\Money; class InvoiceIncome extends Money { }
/config/packages/doctrine.xml
<?xml version="1.0" encoding="UTF-8" ?> <container xmlns="https://symfony.ac.cn/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:doctrine="https://symfony.ac.cn/schema/dic/doctrine" xsi:schemaLocation="https://symfony.ac.cn/schema/dic/services https://symfony.ac.cn/schema/dic/services/services-1.0.xsd https://symfony.ac.cn/schema/dic/doctrine https://symfony.ac.cn/schema/dic/doctrine/doctrine-1.0.xsd"> <doctrine:config> <doctrine:dbal> <doctrine:type name="money">OnMoon\Money\Type\MoneyType</doctrine:type> <doctrine:type name="currency">OnMoon\Money\Type\CurrencyType</doctrine:type> </doctrine:dbal> </doctrine:config> </container>
实体映射
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="App\Domain\Entity\Invoice\Invoice" table="invoices"> <embedded name="income" class="App\Domain\Entity\Invoice\ValueObject\InvoiceIncome" use-column-prefix="false" /> </entity> </doctrine-mapping>
值对象映射
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <embeddable name="App\Domain\Entity\Invoice\ValueObject\InvoiceIncome"> <field name="amount" type="money" column="income" nullable="false" /> <field name="currency" type="currency" column="income_currency" nullable="false" /> </embeddable> </doctrine-mapping>