Skip to content

Commit

Permalink
HP-1950 Refactored progressive prices (#69)
Browse files Browse the repository at this point in the history
* HP-1950 Refactored progressive prices

* Added ProgressivePriceCalculationTrace, refined tests

Renamed ProgressivePriceThresholds to ProgressivePriceThresholdList

* Make MultipliedMoney properties R/O
  • Loading branch information
SilverFire authored Jul 9, 2024
1 parent 54e3cc0 commit a9003e6
Show file tree
Hide file tree
Showing 15 changed files with 909 additions and 319 deletions.
124 changes: 124 additions & 0 deletions src/Money/MultipliedMoney.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace hiqdev\php\billing\Money;

use Laminas\Code\Reflection\Exception\InvalidArgumentException;
use Money\Currencies\ISOCurrencies;
use Money\Currency;
use Money\Money;
use Money\MoneyParser;
use Money\Parser\DecimalMoneyParser;

/**
* Class MultipliedMoney a wrapper around the Money class and provides
* a way to work with sub-cent prices.
*
* For example, if you have a price of $0.001, it will be represented as
* 1 USD with a multiplier of 1000. After you do some calculations with this
* price, you can convert it back to the decimal representation by dividing
* the amount by the multiplier.
*
* By default, the MultipliedMoney uses the DecimalMoneyParser to parse the
* amount. You can set your own parser by calling the setDecimalMoneyParser.
*
* @author Dmytro Naumenko <[email protected]>
*/
final class MultipliedMoney
{
private function __construct(
private readonly Money $money,
private readonly int $multiplier = 1
) {
}

/**
* @param numeric-string $amount
* @param string $currencyCode
*/
public static function create(string $amount, string $currencyCode): MultipliedMoney
{
if (!is_numeric($amount)) {
throw new InvalidArgumentException('Amount of the MultipliedMoney must be numeric');
}

$currency = new Currency($currencyCode);
$parser = self::getMoneyParser();

if (!self::isFloat($amount) || self::isWhole($amount)) {
return new self($parser->parse($amount, $currency), 1);
}

$multiplier = self::calculateMultiplierToInteger($amount);
return new self(
$parser->parse((string)($amount * $multiplier), $currency),
$multiplier
);
}

public function money(): Money
{
return $this->money;
}

public function multiplier(): int
{
return $this->multiplier;
}

public function getCurrency(): Currency
{
return $this->money->getCurrency();
}

public function getAmount(): string
{
return $this->money->getAmount();
}

/**
* @param numeric-string $amount
*/
private static function calculateMultiplierToInteger(string $amount): int
{
if (self::isWhole($amount)) {
return 1;
}

[$integer, $fraction] = explode('.', $amount, 2);
return (int)('1' . implode(array_fill(0, strlen($fraction), 0)));
}

/**
* @param numeric-string $number
*/
private static function isWhole(string $number): bool
{
/** @noinspection PhpWrongStringConcatenationInspection */
return is_int($number + 0);
}

/**
* @param numeric-string $number
*/
private static function isFloat(string $number): bool
{
return str_contains($number, '.');
}

private static MoneyParser $moneyParser;
public static function setDecimalMoneyParser(MoneyParser $moneyParser): void
{
self::$moneyParser = $moneyParser;
}
private static function getMoneyParser(): MoneyParser
{
if (!isset(self::$moneyParser)) {
self::setDecimalMoneyParser(new DecimalMoneyParser(new ISOCurrencies()));
}

return self::$moneyParser;
}

}
52 changes: 0 additions & 52 deletions src/price/MoneyBuilder.php

This file was deleted.

4 changes: 3 additions & 1 deletion src/price/PriceFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ public function createSinglePrice(PriceCreationDto $dto)

public function createProgressivePrice(PriceCreationDto $dto): ProgressivePrice
{
return new ProgressivePrice($dto->id, $dto->type, $dto->target, $dto->prepaid, $dto->thresholds, $dto->plan);
$thresholds = ProgressivePriceThresholdList::fromScalarsArray($dto->thresholds);

return new ProgressivePrice($dto->id, $dto->type, $dto->target, $dto->prepaid, $dto->price, $thresholds, $dto->plan);
}
}
85 changes: 55 additions & 30 deletions src/price/ProgressivePrice.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class ProgressivePrice extends AbstractPrice
{
protected ProgressivePriceThresholds $thresholds;
protected ProgressivePriceThresholdList $thresholds;

protected Money $price;

Expand All @@ -28,27 +28,18 @@ public function __construct(
TargetInterface $target,
QuantityInterface $prepaid,
Money $price,
/* @psalm-var array{
* array{
* 'price': string,
* 'currency': string,
* 'quantity': string,
* 'unit': string
* }
* } $thresholds
*/
array $thresholds,
ProgressivePriceThresholdList $thresholds,
?PlanInterface $plan = null
) {
parent::__construct($id, $type, $target, $plan);
$this->thresholds = new ProgressivePriceThresholds($thresholds);
$this->thresholds = $thresholds;
$this->price = $price;
$this->prepaid = $prepaid;
}

public function getThresholds(): array
public function getThresholds(): ProgressivePriceThresholdList
{
return $this->thresholds->__toArray();
return $this->thresholds;
}

public function getPrepaid(): QuantityInterface
Expand All @@ -66,10 +57,10 @@ public function getPrice(): Money
*/
public function calculateUsage(QuantityInterface $quantity): ?QuantityInterface
{
$usage = $quantity->convert($this->prepaid->getUnit());
$usage = $quantity->subtract($this->prepaid);

if ($usage->isPositive()) {
return $usage;
return $quantity;
}

return Quantity::create($this->prepaid->getUnit()->getName(), 0);
Expand All @@ -83,23 +74,57 @@ public function calculatePrice(QuantityInterface $quantity): ?Money
return $this->price;
}

/**
* @var ProgressivePriceCalculationTrace[]
*/
private array $calculationTraces = [];

/**
* @return ProgressivePriceCalculationTrace[]
* @internal A debug method to see intermediate calculations
* after the latest call to calculateSum()
*/
public function getCalculationTraces(): array
{
return $this->calculationTraces;
}

public function calculateSum(QuantityInterface $quantity): ?Money
{
$result = new Money(0, $this->price->getCurrency());
$usage = $this->calculateUsage($quantity);
$thresholds = $this->thresholds->get();
foreach ($thresholds as $key => $threshold) {
if ($threshold->quantity()->compare($usage) < 0) {
$boundary = $usage->subtract($threshold->quantity());
$result = $result->add(new Money(
(int) $boundary->multiply($threshold->price()->getAmount())->getQuantity(),
$threshold->price()->getCurrency()
)
);
$usage = $usage->subtract($boundary);
$this->calculationTraces = [];

$result = $this->price->multiply(0);
$remainingUsage = $this->calculateUsage($quantity);
if ($remainingUsage->getQuantity() === 0) {
return $result;
}

$totalBilledUsage = $this->prepaid;
$thresholds = $this->thresholds->withAdded(
ProgressivePriceThreshold::createFromObjects($this->price, $this->prepaid)
)->get();

foreach ($thresholds as $threshold) {
$quantity = $threshold->quantity();
if ($quantity->compare($remainingUsage) >= 0) {
$quantity = $remainingUsage;
}
$billedUsage = $remainingUsage->subtract($quantity)->convert($threshold->unit());
$price = $threshold->price();

$chargedAmount = $price->money()
->multiply((string)$billedUsage->getQuantity())
->divide((string)($price->multiplier()));

$this->calculationTraces[] = new ProgressivePriceCalculationTrace(
$threshold, $billedUsage, $chargedAmount
);

$result = $result->add($chargedAmount);
$remainingUsage = $remainingUsage->subtract($billedUsage);
$totalBilledUsage = $totalBilledUsage->add($billedUsage);
}
$result = (new DecimalMoneyParser(new ISOCurrencies()))->parse($result->getAmount(), $result->getCurrency());
return $result->divide($this->thresholds->getPriceRate());

return $result;
}
}
44 changes: 44 additions & 0 deletions src/price/ProgressivePriceCalculationTrace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace hiqdev\php\billing\price;

use hiqdev\php\units\Quantity;
use Money\Money;
use Stringable;

/**
* Class ProgressivePriceCalculationTrace represents a single step in the calculation of a progressive price.
*
* @author Dmytro Naumenko <[email protected]>
*/
final class ProgressivePriceCalculationTrace implements Stringable
{
public function __construct(
public ProgressivePriceThreshold $threshold,
public Quantity $billedUsage,
public Money $charged,
) {
}

public function __toString(): string
{
return sprintf(
"%s%s * %s = %s",
$this->billedUsage->getQuantity(),
$this->billedUsage->getUnit()->getName(),
$this->threshold->getRawPrice(),
number_format($this->charged->getAmount()/100, 2),
);
}

public function toShortString(): string
{
return sprintf(
"%s*%s",
$this->billedUsage->getQuantity(),
$this->threshold->getRawPrice(),
);
}
}
Loading

0 comments on commit a9003e6

Please sign in to comment.