whitecube / php-prices
一个简单的PHP库,用于复杂货币价格管理
Requires
- php: ^8.0
- brick/money: 0.8.*
Requires (Dev)
- pestphp/pest: ^1.0
This package is auto-updated.
Last update: 2024-09-16 08:00:30 UTC
README
💸 版本 3.x
这个新版本旨在避免在除法和乘法操作中的舍入错误。
我们几乎将所有的
Brick\Money\Money
类型提示替换为Brick\Money\AbstractMoney
,以便可以使用Brick\Money\RationalMoney
实例作为价格对象的基础,从而允许无舍入的除法和乘法。有关更多信息,请参阅此文档中关于舍入错误的新章节。
我们还添加了尽可能多的类型定义。这引入了一些 破坏性更改。
使用底层 brick/money
库,这个简单的价格对象允许处理复杂的组合货币值,包括排除、包含、增值税(及其他潜在税)和折扣金额。这使得计算最终可显示的价格更加安全、容易,无需担心其构建。
安装
composer require whitecube/php-prices
入门
每个 Price
对象都有一个基本 Brick\Money\Money
或 Brick\Money\RationalMoney
实例,该实例被认为是商品未变、每单位且排他的金额。所有组合操作,如添加增值税或应用折扣,都是在此基础值之上添加的。
use Whitecube\Price\Price; $steak = Price::EUR(1850) // Steak costs €18.50/kg ->setUnits(1.476) // Customer had 1.476kg, excl. total is €27.31 ->setVat(6) // There is 6% VAT, incl. total is €28.95 ->addTax(50) // There also is a €0.50/kg tax (before VAT), incl. total is €29.73 ->addDiscount(-100); // We granted a €1.00/kg discount (before VAT), incl. total is €28.16
通常,最佳做法是以 最小货币单位(小数位)(如“分”)表示金额。
有几个方便的方法可以获得 Price
实例
从构造函数
您可以通过直接使用所需的 Brick\Money\Money
实例来实例化 Price 来设置此基本值
use Brick\Money\Money; use Whitecube\Price\Price; $base = new Money::ofMinor(500, 'USD'); // $5.00 $single = new Price($base); // 1 x $5.00 $multiple = new Price($base, 4); // 4 x $5.00
从 Brick/Money-like 方法
为了方便,也可以使用简写的 Money 工厂方法
use Whitecube\Price\Price; $major = Price::of(5, 'EUR'); // 1 x €5.00 $minor = Price::ofMinor(500, 'USD'); // 1 x $5.00
使用这些静态调用,您不能直接使用构造函数定义数量或单位。
有关所有可用的 Brick/Money 构造函数的更多信息,请参阅 它们的文档。
从货币代码方法
您还可以使用 3 位货币 ISO 代码直接使用预期的货币和数量来创建实例
use Whitecube\Price\Price; $single = Price::EUR(500); // 1 x €5.00 $multiple = Price::USD(500, 4); // 4 x $5.00
使用这些静态调用,所有货币值都视为小数位(例如,分)。
有关所有可用的 ISO 4217 货币的列表,请参阅 Brick/Money 的 iso-currencies 定义。
从解析字符串值
此外,价格还可以从“原始货币值”字符串中解析。此方法可能很有用,但应始终谨慎使用,因为它可能在某些边缘情况中产生意外的结果,尤其是在“猜测”货币时。
use Whitecube\Price\Price; $guessCurrency = Price::parse('5,5$'); // 1 x $5.50 $betterGuess = Price::parse('JMD 5.50'); // 1 x $5.50 $forceCurrency = Price::parse('10', 'EUR'); // 1 x €10.00 $multiple = Price::parse('6.008 EUR', null, 4); // 4 x €6.01 $force = Price::parse('-5 EUR', 'USD', 4); // 4 x $-5.00
解析格式化字符串是一个棘手的话题。有关解析字符串值的更多信息,请参阅下文。
访问货币对象(获取器)
一旦设置,就可以使用 base()
方法访问 基本金额。
注意 如果您在实例化价格对象时提供了一个
Brick\Money\Money
实例,您将得到一个Brick\Money\Money
实例。同样,使用Brick\Money\RationalMoney
实例将返回一个RationalMoney
对象。
$perUnit = $price->base(); // Brick\Money\Money $allUnits = $price->base(false); // Brick\Money\Money
获取 货币 实例同样简单
$currency = $price->currency(); // Brick\Money\Currency
带有所有修饰符(除增值税外)的 总排除金额
$perUnit = $price->exclusive(true); // Brick\Money\Money $allUnits = $price->exclusive(); // Brick\Money\Money
总含税金额(包含所有调整项和增值税)
$perUnit = $price->inclusive(true); // Brick\Money\Money $allUnits = $price->inclusive(); // Brick\Money\Money
增值税变量
$vat = $price->vat(); // Whitecube\Price\Vat $percentage = $price->vat()->percentage(); // float $perUnit = $price->vat()->money(true); // Brick\Money\Money $allUnits = $price->vat()->money(); // Brick\Money\Money
比较金额
可以使用 compareTo
方法检查价格对象的 总含税金额 是否大于、小于或等于另一个值
$price = Price::USD(500, 2); // 2 x $5.00 $price->compareTo(999); // 1 $price->compareTo(1000); // 0 $price->compareTo(1001); // -1 $price->compareTo(Money::of(10, 'USD')); // 0 $price->compareTo(Price::USD(250, 4)); // 0
为了方便,还有一个 equals()
方法
$price->equals(999); // false $price->equals(1000); // true $price->equals(Money::of(10, 'USD')); // true $price->equals(Price::USD(250, 4)); // true
如果您不想比较最终修改后的值,可以使用 compareBaseTo
方法
$price = Price::USD(500, 2); // 2 x $5.00 $price->compareBaseTo(499); // 1 $price->compareBaseTo(500); // 0 $price->compareBaseTo(501); // -1 $price->compareBaseTo(Money::of(5, 'USD')); // 0 $price->compareBaseTo(Price::USD(500, 4)); // 0
修改基础价格
价格对象会将所有 Brick\Money\Money
API 方法调用转发到其基础值。
警告:与 Money 对象相反,价格对象不是不可变的。因此,加法、减法等操作将直接修改价格的基础值,而不是返回一个新的实例。
use Whitecube\Price\Price; $price = Price::ofMinor(500, 'USD')->setUnits(2); // 2 x $5.00 $price->minus('2.00') // 2 x $3.00 ->plus('1.50') // 2 x $4.50 ->dividedBy(2) // 2 x $2.25 ->multipliedBy(-3) // 2 x $-6.75 ->abs(); // 2 x $6.75
请参阅 brick/money
的文档 了解完整的功能列表。
💡 值得知道:尽可能使用调整项来更改价格,因为其基础值应该是恒定的。有关调整项的更多信息,请参阅下文的 “添加调整项”部分。
在执行除法和乘法时正确处理四舍五入
从 Brick\Money\Money
实例创建价格对象时,执行除法和乘法可能会出现舍入误差。
问题的一个例子:我们有一个1000个小单位的基础价格,需要除以12,然后乘以11。
1000 / 12 * 11 = 916,6666666666...
使用常规的 Brick\Money\Money
类强制我们在进行除法时指定舍入模式,这意味着在进行乘法之前就有一个舍入的结果,这会在结果中引入错误
use \Brick\Money\Money; use \Whitecube\Price\Price; use \Brick\Math\RoundingMode; $base = Money::ofMinor(1000, 'EUR'); $price = new Price($base); // A rounding mode is mandatory in order to do the division, // which causes rounding errors down the line $price->dividedBy(12, RoundingMode::HALF_UP)->multipliedBy(11); $price->getMinorAmount(); // 913 minor units ❌
解决方案是使用一个基于 Brick\Money\RationalMoney
实例构建价格实例,该实例将金额表示为分数,因此不需要舍入。
use \Brick\Money\Money; use \Whitecube\Price\Price; use \Brick\Math\RoundingMode; use \Brick\Money\Context\CustomContext; $base = Money::ofMinor(1000, 'EUR')->toRational(); $price = new Price($base); // With RationalMoney, rounding is not necessary at this stage $price->dividedBy(12)->multipliedBy(11); // But rounding can occur at the very end $price->to(new CustomContext(2), RoundingMode::HALF_UP)->getMinorAmount(); // 917 minor units ✅
有关更多信息,请参阅 brick/money关于此问题的文档。
设置单位(数量)
此包的默认行为是将其基础价格视为“每单位”价格。如果没有指定单位,则默认为 1
。由于“单位”可以是任何从不可分割的产品数量到测量的东西,因此它们总是转换为浮点数。
您可以在实例化时设置单位数量(或如果您愿意,则称为“数量”)
use Whitecube\Price\Price; use Brick\Money\Money; $price = new Price(Money::ofMinor(500, 'EUR'), 2); // 2 units of €5.00 each $same = Price::EUR(500, 2); // same result $again = Price::parse('5.00', 'EUR', 2); // same result
...或稍后使用 setUnits()
方法进行修改
$price->setUnits(1.75); // 1.75 x €5.00
您可以使用 units()
方法返回单位计数(始终为 float
)
$quantity = $price->units(); // 1.75
设置增值税
可以通过提供其相对值(例如,21%)来添加增值税
use Whitecube\Price\Price; $price = Price::USD(200); // 1 x $2.00 $price->setVat(21); // VAT is now 21.0%, or $0.42 per unit $price->setVat(null); // VAT is unset
一旦设置,价格对象将能够提供各种与增值税相关的信息
use Whitecube\Price\Price; $price = Price::EUR(500, 3)->setVat(10); // 3 x €5.00 $percentage = $price->vat()->percentage(); // 10.0 $perUnit = $price->vat()->money(true); // €0.50 $allUnits = $price->vat()->money(); // €1.50
设置调整项
调整项是企业需要在账单上显示价格之前应用的所有自定义操作。这些操作范围从折扣到税,包括自定义规则和优惠券。这是此包存在的主要原因。
折扣
use Whitecube\Price\Price; use Brick\Money\Money; $price = Price::USD(800, 5) // 5 x $8.00 ->addDiscount(-100) // 5 x $7.00 ->addDiscount(Money::of(-5, 'USD')); // 5 x $6.50
税收(除了增值税之外)
use Whitecube\Price\Price; use Brick\Money\Money; $price = Price::EUR(125, 10) // 10 x €1.25 ->addTax(100) // 10 x €2.25 ->addTax(Money::of(0.5, 'EUR')); // 10 x €2.75
自定义调整项类型
有时调整项不能归入“折扣”或“税收”类别,在这种情况下,您可以添加自己的调整项类型
use Whitecube\Price\Price; use Brick\Money\Money; $price = Price::USD(2000) // 1 x $20.00 ->addModifier('coupon', -500) // 1 x $15.00 ->addModifier('extra', Money::of(2, 'USD')); // 1 x $17.00
💡 值得知道:调整项类型(
tax
、discount
或您自己的)对于过滤、分组和显示子总计或价格构造细节很有用。更多信息请参阅下文的 “显示修改详情”部分。
复杂的调整项
大多数情况下,定义修饰符比简单的“+”或“-”运算要复杂。根据复杂程度,有一些选项可以让你根据需要配置修饰符。
闭包修饰符
您可以使用闭包而不是为修饰符提供货币价值,闭包将获取一个Whitecube\Price\Modifier
实例。然后可以使用此对象对价格值执行一些操作。可用的操作有
所有这些方法都具有与它们的Brick\Money\Money
等效方法的相同签名。我们不使用相同的方法名称是为了暗示对象的可变性。
use Whitecube\Price\Price; use Whitecube\Price\Modifier; $price = Price::USD(1250) ->addDiscount(function(Modifier $discount) { $discount->subtract(100)->multiply(0.95); }) ->addTax(function(Modifier $tax) { $tax->add(250); }) ->addModifier('lucky', function(Money $modifier) { $modifier->divide(2); });
此外,使用闭包修饰符还可以添加其他有用的配置,例如
修饰符类
为了获得更大的灵活性和可读性,也可以将这些功能提取到它们自己的类中
use Whitecube\Price\Price; $price = Price::EUR(600, 5) ->addDiscount(Discounts\FirstOrder::class) ->addTax(Taxes\Gambling::class) ->addModifier('custom', SomeCustomModifier::class);
这些类必须实现Whitecube\Price\PriceAmendable
接口,该接口看起来大致如下
use Brick\Money\AbstractMoney; use Brick\Math\RoundingMode; use Whitecube\Price\Modifier; use Whitecube\Price\PriceAmendable; class SomeRandomModifier implements PriceAmendable { /** * The current modifier "type" * * @return string */ protected string $type; /** * Return the modifier type (tax, discount, other, ...) * * @return string */ public function type(): string { return $this->type; } /** * Define the modifier type (tax, discount, other, ...) * * @param null|string $type * @return $this */ public function setType(?string $type = null): static { $this->type = $type; return $this; } /** * Return the modifier's identification key * * @return null|string */ public function key(): ?string { return 'very-random-tax'; } /** * Get the modifier attributes that should be saved in the * price modification history. * * @return null|array */ public function attributes(): ?array { return [ 'subtitle' => 'Just because we don\'t like you today', 'color' => 'red', ]; } /** * Whether the modifier should be applied before the * VAT value has been computed. * * @return bool */ public function appliesAfterVat(): bool { return false; } /** * Apply the modifier on the given Money instance * * @param \Brick\Money\AbstractMoney $build * @param float $units * @param bool $perUnit * @param null|\Brick\Money\AbstractMoney $exclusive * @param null|\Whitecube\Price\Vat $vat * @return null|\Brick\Money\AbstractMoney */ public function apply(AbstractMoney $build, float $units, bool $perUnit, AbstractMoney $exclusive = null, Vat $vat = null): ?AbstractMoney { if(date('j') > 1) { // Do not apply if it's not the first day of the month return null; } // Otherwise add $2.00 per unit $supplement = Money::of(2, 'EUR'); return $build->plus($perUnit ? $supplement : $supplement->multipliedBy($units, RoundingMode::HALF_UP)); } }
如有需要,还可以从价格配置中传递参数到这些自定义类
use Brick\Money\Money; use Whitecube\Price\Price; $price = Price::EUR(600, 5) ->addModifier('lucky-or-not', BetweenModifier::class, Money::ofMinor(-100, 'EUR'), Money::ofMinor(100, 'EUR'));
use Brick\Money\Money; use Whitecube\Price\PriceAmendable; class BetweenModifier implements PriceAmendable { protected $minimum; protected $maximum; public function __construct(Money $minimum, Money $maximum) { $this->minimum = $minimum; $this->maximum = $maximum; } // ... }
增值税之前还是之后?
根据修饰符的性质,增值税可以在其干预最终价格之前或之后应用。所有修饰符都可以配置为在以下这些阶段之一执行。
默认情况下,修饰符是在增值税应用之前添加的,这意味着它们很可能也会修改增值税值。为了避免这种情况,可以在计算出的增值税上添加一个修饰符。从法律角度来说,这实际上相当罕见,但为什么不可以呢?
use Whitecube\Price\Price; $price = Price::USD(800, 5)->addTax(function($tax) { $tax->add(200)->setPostVat(); });
在自定义类中,这由appliesAfterVat
方法处理。
⚠️ 警告:在增值税之后应用修饰符将改变修饰符的执行顺序。价格将首先应用应该在增值税之前执行的修饰符(按出现顺序),然后应用增值税本身,最后是剩余的修饰符(也按出现顺序)。
包含价格将包含所有修饰符(增值税前后),但默认情况下,仅包含“增值税之前”的修饰符。如果您认为“增值税之后”的修饰符是包含价格的一部分,您始终可以通过在exclusive()
方法的第二个参数中提供$includeAfterVat = true
来将它们计算在内。
use Whitecube\Price\Price; $price = Price::USD(800, 5)->setVat(10)->addTax(function($tax) { $tax->add(200)->setPostVat(); }); $price->exclusive(); // $40.00 $price->exclusive(false, true); // $50.00 $price->inclusive(); // $54.00
显示修改详情
在调试或构建复杂用户界面时,通常需要检索完整的价格修改历史记录。这可以通过在所有修饰符都已添加到价格实例后使用modifications()
方法来完成
$history = $price->modifications(); // Array containing chronological modifier results
每个历史项都包含其应用于总价格的数量。如果您想查询这些修改的“每单位”价值
$perUnitHistory = $price->modifications(true);
根据修饰符类型过滤此历史记录
use Whitecube\Price\Modifier; $history = $price->modifications(false, Modifier::TYPE_DISCOUNT); // Only returning discount results
通常您不需要从modifications()
方法获取所有数据,只需要修改总数。这些可以通过使用discounts()
、taxes()
和通用的modifiers()
方法来返回
$totalDiscounts = $price->discounts(); $totalDiscountsPerUnit = $price->discounts(true); $totalTaxes = $price->taxes(); $totalTaxesPerUnit = $price->taxes(true); $totalAllModifiers = $price->modifiers(); $totalAllModifiersPerUnit = $price->modifiers(true); $totalCustomTypeModifiers = $price->modifiers(false, 'custom'); $totalCustomTypeModifiersPerUnit = $price->modifiers(true, 'custom');
输出
默认情况下,所有处理过的货币值都被包裹在一个Brick\Money\Money
对象中。这应该是唯一操作这些值的方法,以避免十进制近似误差。
以字符串形式显示价格
显示价格有许多不同的格式化方法,您的应用程序肯定有自己的需求,需要得到尊重。当然,可以处理返回的 Brick\Money\Money
对象的价格格式化,但我们还包含了一个方便的价格格式化器。请注意,它的默认行为基于 PHP 的 NumberFormatter
(默认使用当前区域设置,有关更多信息请参阅 setlocale
)。
use Whitecube\Price\Price; setlocale(LC_ALL, 'en_US'); $price = Price::USD(65550, 8)->setVat(21); echo Price::format($price); // $6,345.24 echo Price::format($price->exclusive()); // $5,244.00 echo Price::format($price->vat()); // $1,101.24
对于其他语言的格式化,请提供所需的区域设置名称作为第二个参数
use Whitecube\Price\Price; setlocale(LC_ALL, 'en_US'); $price = Price::USD(65550, 8)->setVat(21); echo Price::format($price, 'de_DE'); // 6.345,24 € echo Price::format($price->exclusive(), 'fr_BE'); // 5 244,00 € echo Price::format($price->vat(), 'en_GB'); // €1,101.24
对于更高级的定制用例,请使用 Price::formatUsing()
方法来提供自定义格式化函数
use Whitecube\Price\Price; Price::formatUsing(fn($price, $locale = null) => $price->exclusive()->getMinorAmount()->toInt()); $price = Price::EUR(600, 8)->setVat(21); echo Price::format($price); // 4800
Price::formatUsing()
方法接受一个闭包函数、一个格式化器类名或一个格式化器实例。后两种选项都应扩展 \Whitecube\Price\Formatting\CustomFormatter
use Whitecube\Price\Price; Price::formatUsing(fn($price, $locale = null) => /* Convert $price to a string for $locale */); // or Price::formatUsing(\App\Formatters\MyPriceFormatter::class); // or Price::formatUsing(new \App\Formatters\MyPriceFormatter($some, $dependencies));
为了获得更多灵活性,可以定义多个命名格式化器,并使用它们自己的动态静态方法来调用它们
use Whitecube\Price\Price; setlocale(LC_ALL, 'en_US'); Price::formatUsing(fn($price, $locale = null) => $price->exclusive()->getMinorAmount()->toInt()) ->name('rawExclusiveCents'); Price::formatUsing(\App\Formatters\MyInvertedPriceFormatter::class) ->name('inverted'); $price = Price::EUR(600, 8)->setVat(21); echo Price::formatRawExclusiveCents($price); // 4800 echo Price::formatInverted($price); // -€58.08 // When using named formatters the default formatter stays untouched echo Price::format($price); // €58.08
请注意,可以将额外的参数传递给您的自定义格式化器
use Whitecube\Price\Price; setlocale(LC_ALL, 'en_US'); Price::formatUsing(function($price, $max, $locale = null) { return ($price->compareTo($max) > 0) ? Price::format($max, $locale) : Price::format($price, $locale); })->name('max'); $price = Price::EUR(100000, 2)->setVat(21); echo Price::formatMax($price, Money::ofMinor(180000, 'EUR'), 'fr_BE'); // 1 800,00 €
JSON
价格可以序列化为 JSON,并使用 Price::json($value)
方法重新生成,这在从数据库或外部 API 存储和检索价格时非常有用
use Whitecube\Price\Price; $json = json_encode(Price::USD(999, 4)->setVat(6)); $price = Price::json($json); // 4 x $9.99 with 6% VAT each
💡 有趣的事实:您还可以使用
Price::json()
来从关联数组创建 Price 对象,只要它包含base
、currency
、units
和vat
键。
解析值
有几个可用的方法可以将货币字符串值转换为 Price 对象。通用的 parseCurrency
方法将尝试从给定的字符串猜测货币类型
use Whitecube\Price\Price; $fromIsoCode = Price::parse('USD 5.50'); // 1 x $5.50 $fromSymbol = Price::parse('10€'); // 1 x €10.00
为此,字符串应始终包含对所使用货币的指示(有效的 ISO 代码或符号)。当使用符号时,请注意,其中一些符号(例如 $
)在多种货币中使用,导致结果模糊。
当您确信所涉及的 ISO 货币时,应直接将其作为 parse()
方法的第二个参数传递
use Whitecube\Price\Price; $priceEUR = Price::parse('5,5 $', 'EUR'); // 1 x €5.50 $priceUSD = Price::parse('0.103', 'USD'); // 1 x $0.10
当使用专用货币解析器时,所有单位/符号和非数字字符都将被忽略。
🔥 赞助
如果您在生产应用程序中依赖于此包,请考虑 赞助我们!这是帮助我们继续做我们热爱的事情的最好方式:制作优秀的开源软件。
贡献
请随意提出更改建议,请求新功能或自己修复错误。我们确信还有很多可以改进的地方,我们非常乐意合并有用的拉取请求。
谢谢!
用 ❤️ 为开源制作
在 Whitecube,我们使用大量开源软件作为我们日常工作的一部分。因此,当我们有机会回馈时,我们会非常兴奋!
我们希望您会喜欢我们从我们这里的小贡献,如果您在项目中发现它有用,我们非常愿意 听到您的反馈。关注我们的 Twitter 获取更多更新!