whitecube/php-prices

一个简单的PHP库,用于复杂货币价格管理

资助包维护!
whitecube

v4.0.0-beta 2024-09-16 07:59 UTC

README

run-tests

💸 版本 3.x

这个新版本旨在避免在除法和乘法操作中的舍入错误。

我们几乎将所有的 Brick\Money\Money 类型提示替换为 Brick\Money\AbstractMoney,以便可以使用 Brick\Money\RationalMoney 实例作为价格对象的基础,从而允许无舍入的除法和乘法。有关更多信息,请参阅此文档中关于舍入错误的新章节
我们还添加了尽可能多的类型定义。这引入了一些 破坏性更改

使用底层 brick/money 库,这个简单的价格对象允许处理复杂的组合货币值,包括排除、包含、增值税(及其他潜在税)和折扣金额。这使得计算最终可显示的价格更加安全、容易,无需担心其构建。

安装

composer require whitecube/php-prices

入门

每个 Price 对象都有一个基本 Brick\Money\MoneyBrick\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

💡 值得知道:调整项类型(taxdiscount 或您自己的)对于过滤、分组和显示子总计或价格构造细节很有用。更多信息请参阅下文的 “显示修改详情”部分

复杂的调整项

大多数情况下,定义修饰符比简单的“+”或“-”运算要复杂。根据复杂程度,有一些选项可以让你根据需要配置修饰符。

闭包修饰符

您可以使用闭包而不是为修饰符提供货币价值,闭包将获取一个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 对象,只要它包含 basecurrencyunitsvat 键。

解析值

有几个可用的方法可以将货币字符串值转换为 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 获取更多更新!