basisPoints())->toBe(1550) ->and($rate->ratio())->toBe(0.155) ->and($rate->percentage())->toBe(15.5) ->and($rate->isZero())->toBeFalse(); }); it('creates a zero rate', function (): void { // GIVEN / WHEN $rate = DiscountRate::zero(); // THEN expect($rate->basisPoints())->toBe(0) ->and($rate->isZero())->toBeTrue(); }); it('creates a rate from a percentage', function (int | float $percentage, int $expectedBasisPoints): void { // GIVEN / WHEN $rate = DiscountRate::fromPercentage($percentage); // THEN expect($rate->basisPoints())->toBe($expectedBasisPoints); })->with([ 'integer percent' => [15, 1500], 'decimal percent' => [5.5, 550], 'zero' => [0, 0], 'full' => [100, 10000], ]); it('creates a rate from a ratio without float drift', function (float $ratio, int $expectedBasisPoints): void { // GIVEN / WHEN $rate = DiscountRate::fromRatio($ratio); // THEN expect($rate->basisPoints())->toBe($expectedBasisPoints); })->with([ 'ten percent' => [0.1, 1000], 'classic drift' => [0.29, 2900], // 0.29 * 10000 = 2899.9999999999995 in IEEE 754 'five point five' => [0.055, 550], ]); it('computes the rate of a fixed amount discount on a base price', function (): void { // GIVEN $basePrice = Money::EUR(1000); $discount = Money::EUR(200); // WHEN $rate = DiscountRate::fromAmounts($discount, $basePrice); // THEN expect($rate->basisPoints())->toBe(2000) ->and($rate->ratio())->toBe(0.2); }); it('rounds the rate of a fixed amount discount to the nearest basis point', function (): void { // GIVEN $basePrice = Money::EUR(300); $discount = Money::EUR(100); // 33.333...% // WHEN $rate = DiscountRate::fromAmounts($discount, $basePrice); // THEN expect($rate->basisPoints())->toBe(3333); }); it('caps a fixed amount discount greater than the base price at 100 percent', function (): void { // GIVEN $basePrice = Money::EUR(500); $discount = Money::EUR(900); // WHEN $rate = DiscountRate::fromAmounts($discount, $basePrice); // THEN expect($rate->basisPoints())->toBe(10000); }); it('returns a zero rate when the base price or the discount is not positive', function (): void { // GIVEN / WHEN $noPrice = DiscountRate::fromAmounts(Money::EUR(100), Money::EUR(0)); $noDiscount = DiscountRate::fromAmounts(Money::EUR(0), Money::EUR(1000)); // THEN expect($noPrice->isZero())->toBeTrue() ->and($noDiscount->isZero())->toBeTrue(); }); it('applies the rate to an amount', function (): void { // GIVEN $rate = DiscountRate::fromPercentage(15); $amount = Money::EUR(1000); // WHEN $discount = $rate->applyTo($amount); // THEN expect($discount->getAmount())->toBe('150'); }); it('formats the rate as a percentage', function (int $basisPoints, string $expected): void { // GIVEN $rate = DiscountRate::fromBasisPoints($basisPoints); // WHEN $formatted = (string) $rate; // THEN expect($formatted)->toBe($expected); })->with([ 'integer' => [1500, '15 %'], 'decimal' => [550, '5.5 %'], 'zero' => [0, '0 %'], 'hundred' => [10000, '100 %'], ]); it('compares two rates', function (): void { // GIVEN $a = DiscountRate::fromPercentage(10); $b = DiscountRate::fromRatio(0.1); $c = DiscountRate::fromPercentage(20); // WHEN / THEN expect($a->equals($b))->toBeTrue() ->and($a->equals($c))->toBeFalse(); }); it('throws when the rate is negative or above 100 percent', function (int $basisPoints): void { // GIVEN / WHEN $action = fn () => DiscountRate::fromBasisPoints($basisPoints); // THEN expect($action)->toThrow(\InvalidArgumentException::class); })->with([ 'negative' => [-1], 'above 100%' => [10001], ]); });