makana / custom-money
货币和货币库
Requires
- php: ^8.1
- ext-json: *
- brick/math: ~0.12.0
Requires (Dev)
- ext-dom: *
- ext-pdo: *
- brick/varexporter: ~0.4.0
- php-coveralls/php-coveralls: ^2.2
- phpunit/phpunit: ^10.1
- vimeo/psalm: 5.24.0
Suggests
- ext-intl: Required to format Money objects
This package is auto-updated.
Last update: 2024-09-15 07:40:50 UTC
README
PHP的货币和货币库。
简介
处理财务数据是一件严肃的事情,应用程序中微小的舍入错误可能会导致现实生活中严重的后果。这就是为什么浮点运算不适合货币计算。
这个库基于brick/math,并处理任何规模的货币的精确计算。
安装
此库可通过Composer安装。
composer require makana/custom-money
要求
此库需要PHP 8.1或更高版本。
为了与PHP 8.0兼容,您可以使用版本0.8。对于PHP 7.4,您可以使用版本0.7。对于PHP 7.1、7.2和7.3,您可以使用版本0.5。请注意,这些PHP版本已过时且不再受支持。如果您仍然使用这些PHP版本之一,您应尽快考虑升级。
尽管不是必需的,但建议您安装GMP或BCMath扩展以加快计算速度。
项目状态和发布流程
虽然这个库仍在开发中,但它经过充分测试,应该足够稳定,可以在生产环境中使用。
当前版本号格式为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
基本操作
Money是一个不可变类:其值从不改变,因此可以安全地传递。因此,对Money的所有操作都返回一个新实例
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
您可以添加和减去Money实例
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
如果两个Money实例不是同一货币,则抛出异常
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有2位小数,而JPY为0位),并且以1个最小单位(分)为步长增加;它们内部使用所谓的DefaultContext。您可以通过提供Context实例来更改此行为。所有对Money的操作都返回具有相同上下文的另一个Money。每个上下文针对特定的用例。
现金舍入
某些货币不允许现金和非现金支付有相同的增量。例如,CHF(瑞士法郎)有2位小数,允许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()都将余数分配给列表中的第一个货币,直到总数等于原始货币。这是Martin Fowler在其著作Patterns of Enterprise Application Architecture中提出的算法。您可以在第一个示例中看到,第一个货币得到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,可以将任何类型的货币(Money、RationalMoney或MoneyBag)转换为另一种货币的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
如果你正在使用如Doctrine之类的ORM,建议将金额和货币分别存储,并在getters/setters中进行转换。
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相比如何?
请参阅此讨论。
