brick/货币

货币和货币库

维护者

详细信息

github.com/brick/money

源代码

问题

资助包维护!
BenMorel

0.9.0 2023-11-26 16:51 UTC

README

PHP的货币和货币库。

Build Status Coverage Status Latest Stable Version Total Downloads License

简介

处理财务数据是一个严肃的问题,应用程序中微小的舍入错误可能会导致现实生活中严重的后果。这就是为什么浮点数运算不适合货币计算。

该库基于brick/math,能够对任何大小的货币进行精确计算。

安装

您可以通过Composer安装此库。

composer require brick/money

要求

此库需要PHP 8.1或更高版本。

为了与PHP 8.0兼容,您可以使用版本0.8。对于PHP 7.4,您可以使用版本0.7。对于PHP 7.1、7.2和7.3,您可以使用版本0.5。请注意,这些PHP版本已达到EOL状态,不再受支持。如果您仍在使用这些PHP版本之一,您应尽快考虑升级。

虽然不是必需的,但建议您安装GMPBCMath扩展,以提高计算速度。

项目状态和发布过程

虽然这个库仍在开发中,但它经过了良好的测试,应该足够稳定,可以在生产环境中使用。

当前版本以0.x.y编号。当引入非破坏性更改(添加新方法、优化现有代码等)时,y会增加。

当引入破坏性更改时,始终开始一个新的0.x版本周期。

因此,将您的项目锁定到给定的发布周期,如0.9.*是安全的。

如果您需要升级到较新的发布周期,请查看发布历史,以获取每个进一步的0.x.0版本引入的更改列表。

创建货币

从常规货币值

要创建一个货币,调用of()工厂方法

use Brick\Money\Money;

$money = Money::of(50, 'USD'); // USD 50.00
$money = Money::of('19.9', 'USD'); // USD 19.90

如果给定的金额不适合货币的默认小数位数(例如,USD为2),您可以传递一个RoundingMode

$money = Money::of('123.456', 'USD'); // RoundingNecessaryException
$money = Money::of('123.456', 'USD', roundingMode: RoundingMode::UP); // USD 123.46

请注意,舍入模式仅用于一次,用于在of()中提供的值;它不会存储在Money对象中,任何后续操作在需要时仍需要传递一个RoundingMode

从小单位(分)

或者,您可以使用ofMinor()方法从“小单位”(分)创建货币

use Brick\Money\Money;

$money = Money::ofMinor(1234, 'USD'); // USD 12.34

基本操作

货币是一个不可变类:其值永远不会改变,因此可以安全地传递。因此,所有对货币的操作都返回一个新的实例

use Brick\Money\Money;

$money = Money::of(50, 'USD');

echo $money->plus('4.99'); // USD 54.99
echo $money->minus(1); // USD 49.00
echo $money->multipliedBy('1.999'); // USD 99.95
echo $money->dividedBy(4); // USD 12.50

您可以添加和减去货币实例

use Brick\Money\Money;

$cost = Money::of(25, 'USD');
$shipping = Money::of('4.99', 'USD');
$discount = Money::of('2.50', 'USD');

echo $cost->plus($shipping)->minus($discount); // USD 27.49

如果两个货币实例不是同一货币,将抛出异常

use Brick\Money\Money;

$a = Money::of(1, 'USD');
$b = Money::of(1, 'EUR');

$a->plus($b); // MoneyMismatchException

如果结果需要四舍五入,则必须将舍入模式作为第二个参数传递,否则会抛出异常

use Brick\Money\Money;
use Brick\Math\RoundingMode;

$money = Money::of(50, 'USD');

$money->plus('0.999'); // RoundingNecessaryException
$money->plus('0.999', RoundingMode::DOWN); // USD 50.99

$money->minus('0.999'); // RoundingNecessaryException
$money->minus('0.999', RoundingMode::UP); // USD 49.01

$money->multipliedBy('1.2345'); // RoundingNecessaryException
$money->multipliedBy('1.2345', RoundingMode::DOWN); // USD 61.72

$money->dividedBy(3); // RoundingNecessaryException
$money->dividedBy(3, RoundingMode::UP); // USD 16.67

货币上下文

默认情况下,货币具有官方的货币比例,由ISO 4217标准定义(例如,EUR和USD有两位小数,而JPY为零位),并且以1个最小单位(分)的步长增加;它们内部使用所谓的DefaultContext。您可以通过提供Context实例来更改此行为。所有Money操作都会返回另一个具有相同上下文的Money。每个上下文针对特定的用例

现金舍入

一些货币不允许现金和现金支付有相同的增量。例如,CHF(瑞士法郎)有两位小数,允许增量为0.01 CHF,但瑞士没有小于5分(或0.05 CHF)的硬币。

您可以使用CashContext处理此类货币

use Brick\Money\Money;
use Brick\Money\Context\CashContext;
use Brick\Math\RoundingMode;

$money = Money::of(10, 'CHF', new CashContext(step: 5)); // CHF 10.00
$money->dividedBy(3, RoundingMode::DOWN); // CHF 3.30
$money->dividedBy(3, RoundingMode::UP); // CHF 3.35

自定义比例

您可以通过提供CustomContext来使用自定义比例的货币

use Brick\Money\Money;
use Brick\Money\Context\CustomContext;
use Brick\Math\RoundingMode;

$money = Money::of(10, 'USD', new CustomContext(scale: 4)); // USD 10.0000
$money->dividedBy(7, RoundingMode::UP); // USD 1.4286

自动比例

如果您需要调整其比例以适应操作结果的货币,则AutoContext是您所需要的

use Brick\Money\Money;
use Brick\Money\Context\AutoContext;

$money = Money::of('1.10', 'USD', new AutoContext()); // USD 1.1
$money->multipliedBy('2.5'); // USD 2.75
$money->dividedBy(8); // USD 0.1375

请注意,不建议使用AutoContext来表示中间计算结果:特别是,它不能表示所有除法的结果,因为其中一些可能导致无限循环小数,这将抛出异常。对于这些用例,您需要使用RationalMoney。请参阅下一节!

高级计算

您可能偶尔需要在Money上执行多个操作,并且仅在最后一步应用舍入模式;如果您在每个单独的操作上应用舍入模式,您可能会得到不同的结果。这正是RationalMoney发挥作用的地方。该类内部将金额存储为有理数(分数)。您可以从Money创建RationalMoney,反之亦然

use Brick\Money\Money;
use Brick\Math\RoundingMode;

$money = Money::of('9.5', 'EUR') // EUR 9.50
  ->toRational() // EUR 950/100
  ->dividedBy(3) // EUR 950/300
  ->plus('17.795') // EUR 6288500/300000
  ->multipliedBy('1.196') // EUR 7521046000/300000000
  ->to($money->getContext(), RoundingMode::DOWN) // EUR 25.07

如您所见,中间结果表示为分数,并且从不执行舍入。最终的to()方法将其转换为Money,如果需要,则应用上下文和舍入模式。大多数时候,您希望结果与原始Money的上下文相同,这正是上面示例所做的事情。但您真的可以应用任何上下文

...
  ->to(new CustomContext(scale: 8), RoundingMode::UP); // EUR 25.07015334

注意:如您在上面的示例中所示,分数中的数字可能会迅速变得非常大。这通常不是问题——没有关于涉及计算中数字数量的硬限制——但如果有必要,您可以在任何时候简化分数,而不影响实际货币价值

...
  ->multipliedBy('1.196') // EUR 7521046000/300000000
  ->simplified() // EUR 3760523/150000

货币分配

您可以将货币轻松分成几个部分

use Brick\Money\Money;

$money = Money::of(100, 'USD');
[$a, $b, $c] = $money->split(3); // USD 33.34, USD 33.33, USD 33.33

您还可以根据一系列比例分配货币。假设您想将987.65 CHF的利润分配给3个股东,他们的股份分别为48%41%11%

use Brick\Money\Money;

$profit = Money::of('987.65', 'CHF');
[$a, $b, $c] = $profit->allocate(48, 41, 11); // CHF 474.08, CHF 404.93, CHF 108.64

它与现金舍入配合得很好

use Brick\Money\Money;
use Brick\Money\Context\CashContext;

$profit = Money::of('987.65', 'CHF', new CashContext(step: 5));
[$a, $b, $c] = $profit->allocate(48, 41, 11); // CHF 474.10, CHF 404.95, CHF 108.60

请注意,比例可以是任何(非负)整数值,不需要相加等于100

当分配产生余数时,split()allocate()将余数分配到列表中的第一个货币上,直到总数等于原始货币。这是马丁·福勒在他的书中提出的算法:企业应用架构模式。您可以在第一个示例中看到,第一笔货币得到33.34美元,而其他货币得到33.33美元。

钱袋(混合货币)

有时您可能需要将不同货币的货币相加。MoneyBag在这种情况下很有用

use Brick\Money\Money;
use Brick\Money\MoneyBag;

$eur = Money::of('12.34', 'EUR');
$jpy = Money::of(123, 'JPY');

$moneyBag = new MoneyBag();
$moneyBag->add($eur);
$moneyBag->add($jpy);

您可以将任何类型的货币添加到MoneyBag中:一个Money、一个RationalMoney,甚至另一个MoneyBag

请注意,与其他类不同,MoneyBag 是可变的:当你调用 add()subtract() 时,其值会发生变化。

你可以用 MoneyBag 做什么?嗯,你可以使用 CurrencyConverter 将其转换为任意货币的 Money。继续阅读!

货币转换

此库附带一个 CurrencyConverter,可以将任何类型的货币(MoneyRationalMoneyMoneyBag)转换为另一种货币的 Money。

use Brick\Money\CurrencyConverter;

$exchangeRateProvider = ...;
$converter = new CurrencyConverter($exchangeRateProvider); // optionally provide a Context here

$money = Money::of('50', 'USD');
$converter->convert($money, 'EUR', roundingMode: RoundingMode::DOWN);

转换器执行可能的精确计算,在最终步骤之前,内部将结果表示为有理数。

要使用货币转换器,你需要一个 ExchangeRateProvider。提供了几种实现,其中包括

ConfigurableProvider

此提供程序从空状态开始,允许你手动添加汇率。

use Brick\Money\ExchangeRateProvider\ConfigurableProvider;

$provider = new ConfigurableProvider();
$provider->setExchangeRate('EUR', 'USD', '1.0987');
$provider->setExchangeRate('USD', 'EUR', '0.9123');

PDOProvider

此提供程序从数据库表读取汇率。

use Brick\Money\ExchangeRateProvider\PDOProvider;
use Brick\Money\ExchangeRateProvider\PDOProviderConfiguration;

$pdo = new \PDO(...);

$configuration = new PDOProviderConfiguration(
    tableName: 'exchange_rates',
    exchangeRateColumnName: 'exchange_rate',
    sourceCurrencyColumnName: 'source_currency_code',
    targetCurrencyColumnName: 'target_currency_code',
);

$provider = new PDOProvider($pdo, $configuration);

PDOProvider 还支持固定源或目标货币,以及动态 WHERE 条件。有关更多信息,请参阅 PDOProviderConfiguration 类。

BaseCurrencyProvider

此提供程序基于另一个汇率提供程序,对于所有可用汇率都相对于单一货币的相当常见的情况,这是一种提供程序。例如,欧洲中央银行提供的 汇率 都是相对于 EUR。你可以直接使用它们将 EUR 转换为 USD,但不能将 USD 转换为 EUR,更不用说 USD 转换为 GBP。

此提供程序将组合汇率以获取预期的结果。

use Brick\Money\ExchangeRateProvider\ConfigurableProvider;
use Brick\Money\ExchangeRateProvider\BaseCurrencyProvider;

$provider = new ConfigurableProvider();
$provider->setExchangeRate('EUR', 'USD', '1.1');
$provider->setExchangeRate('EUR', 'GBP', '0.9');

$provider = new BaseCurrencyProvider($provider, 'EUR');
$provider->getExchangeRate('EUR', 'USD'); // 1.1
$provider->getExchangeRate('USD', 'EUR'); // 10/11
$provider->getExchangeRate('GBP', 'USD'); // 11/9

请注意,汇率提供程序可以返回有理数!

编写自己的提供程序

编写自己的提供程序很简单:ExchangeRateProvider 接口只有一个方法,即 getExchangeRate(),该方法接受货币代码并返回一个数字。

自定义货币

Money 默认支持 ISO 4217 货币。你也可以通过创建一个 Currency 实例来使用自定义货币。让我们创建一个比特币货币

use Brick\Money\Currency;
use Brick\Money\Money;

$bitcoin = new Currency(
    'XBT',     // currency code
    0,         // numeric currency code, useful when storing monies in a database; set to 0 if unused
    'Bitcoin', // currency name
    8          // default scale
);

你现在可以使用这个货币而不是货币代码

$money = Money::of('0.123', $bitcoin); // XBT 0.12300000

格式化

格式化需要 intl 扩展

Money 对象可以按照给定的区域设置进行格式化。

$money = Money::of(5000, 'USD');
echo $money->formatTo('en_US'); // $5,000.00
echo $money->formatTo('fr_FR'); // 5 000,00 $US

或者,你可以使用自己的 NumberFormatter 实例对 Money 对象进行格式化,这为你提供了定制的空间。

$formatter = new \NumberFormatter('en_US', \NumberFormatter::CURRENCY);
$formatter->setSymbol(\NumberFormatter::CURRENCY_SYMBOL, 'US$');
$formatter->setSymbol(\NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, '·');
$formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 2);

$money = Money::of(5000, 'USD');
echo $money->formatWith($formatter); // US$5·000.00

重要提示:因为格式化是使用 NumberFormatter 执行的,所以在过程中金额会被转换为浮点数;所以在格式化非常大的货币值时,可能会出现差异。

将货币存储在数据库中

持久化金额

  • 作为整数:在许多应用程序中,货币仅使用默认缩放(例如,对于 USD,有 2 位小数,对于 JPY,为 0)。在这种情况下,最佳实践是将次要单位(分)存储为整数字段。

    $integerAmount = $money->getMinorAmount()->toInt();

    稍后检索它:

    Money::ofMinor($integerAmount, $currencyCode);

    此方法适用于所有货币,无需担心缩放。你只需担心整数溢出(这将引发异常),但这种情况不太可能发生,除非你处理巨额资金。

  • 作为小数:对于其他大多数情况,建议将金额字符串存储为小数类型。

    $decimalAmount = (string) $money->getAmount();

    稍后检索它:

    Money::of($decimalAmount, $currencyCode);

持久化货币

  • 作为字符串:如果你只处理 ISO 货币,或者具有 3 位字母货币代码的自定义货币,你可以将货币存储为 CHAR(3)。否则,你很可能需要一个 VARCHAR。如果你的应用程序使用固定货币列表,你也可以使用 ENUM

    $currencyCode = $money->getCurrency()->getCurrencyCode();

    在检索货币时:你可以在 Money::of()Money::ofMinor() 中直接使用 ISO 货币代码。对于自定义货币,你需要先将它们转换为 Currency 实例。

  • 作为整数:如果您只处理ISO货币或具有数字代码的自定义货币,您可以存储货币代码为整数。

    $numericCode = $money->getCurrency()->getNumericCode();

    当检索货币时,您可以直接在 Money::of()Money::ofMinor() 中使用ISO货币的数字代码。对于自定义货币,您需要首先将它们转换为 Currency 实例。

  • 硬编码:如果您的应用程序仅处理一种货币,您完全可以硬编码货币代码而无需在数据库中存储。

使用ORM

如果您使用的是ORM,例如Doctrine,建议您将金额和货币分别存储,并在getter/setter中执行转换。

class Entity
{
    private int $price;
    private string $currencyCode;

    public function getPrice() : Money
    {
        return Money::ofMinor($this->price, $this->currencyCode);
    }

    public function setPrice(Money $price) : void
    {
        $this->price = $price->getMinorAmount()->toInt();
        $this->currencyCode = $price->getCurrency()->getCurrencyCode();
    }
}

常见问题解答

这个项目和 moneyphp/money 有何比较?

请参阅 这个讨论