onmoon/money

基于 doctrine 类型的 moneyphp/money 的意见性扩展

1.1.0 2022-02-11 12:01 UTC

This package is auto-updated.

Last update: 2024-09-11 17:58:41 UTC


README

Latest Version Build License Email

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>