diff --git a/src/action/AbstractAction.php b/src/action/AbstractAction.php index 42857d01..84352f41 100755 --- a/src/action/AbstractAction.php +++ b/src/action/AbstractAction.php @@ -55,6 +55,8 @@ abstract class AbstractAction implements \JsonSerializable, ActionInterface /** @var ActionInterface */ protected $parent; + protected float $fractionOfMonth; + /** * @param SaleInterface $sale * @param ActionInterface $parent @@ -68,7 +70,8 @@ public function __construct( DateTimeImmutable $time, SaleInterface $sale = null, ActionState $state = null, - ActionInterface $parent = null + ActionInterface $parent = null, + float $fractionOfMonth = 0.0 ) { $this->id = $id; $this->type = $type; @@ -79,6 +82,7 @@ public function __construct( $this->sale = $sale; $this->state = $state; $this->parent = $parent; + $this->fractionOfMonth = $fractionOfMonth; } /** @@ -218,6 +222,10 @@ public function setSale(SaleInterface $sale) $this->sale = $sale; } + public function getFractionOfMonth(): float + { + return $this->fractionOfMonth; + } /** * {@inheritdoc} */ @@ -232,6 +240,17 @@ public function getUsageInterval(): UsageInterval return UsageInterval::wholeMonth($this->getTime()); } - return UsageInterval::withinMonth($this->getTime(), $this->getSale()->getTime(), $this->getSale()->getCloseTime()); + if ($this->getFractionOfMonth() > 0) { + return UsageInterval::withMonthAndFraction( + $this->getTime(), + $this->getSale()->getTime(), + $this->getFractionOfMonth() + ); + } + return UsageInterval::withinMonth( + $this->getTime(), + $this->getSale()->getTime(), + $this->getSale()->getCloseTime() + ); } } diff --git a/src/action/ActionInterface.php b/src/action/ActionInterface.php index 4662c1b0..456a120a 100644 --- a/src/action/ActionInterface.php +++ b/src/action/ActionInterface.php @@ -82,4 +82,6 @@ public function hasSale(); public function setSale(SaleInterface $sale); public function getUsageInterval(): UsageInterval; + + public function getFractionOfMonth(): float; } diff --git a/src/action/UsageInterval.php b/src/action/UsageInterval.php index 2daabdaf..a1280d9f 100644 --- a/src/action/UsageInterval.php +++ b/src/action/UsageInterval.php @@ -82,6 +82,45 @@ public static function withinMonth( ); } + /** + * Calculates the usage interval for the given month for the given start date and fraction of month value. + * + * @param DateTimeImmutable $month the month to calculate the usage interval for + * @param DateTimeImmutable $start the start date of the sale + * @param float $fractionOfMonth the fraction of manth + * @return static + */ + public static function withMonthAndFraction( + DateTimeImmutable $month, + DateTimeImmutable $start, + float $fractionOfMonth + ): self { + if ($fractionOfMonth < 0 || $fractionOfMonth > 1) { + throw new InvalidArgumentException('Fraction of month must be between 0 and 1'); + } + $month = self::toMonth($month); + $nextMonth = $month->modify('+1 month'); + + if ($start >= $nextMonth) { + $start = $month; + } + + $effectiveSince = max($start, $month); + + if ($fractionOfMonth === 1.0) { + $effectiveTill = $month->modify('+1 month'); + } else { + $monthDays = (int) $month->format('t'); + $days = (int) round($monthDays * $fractionOfMonth); + $effectiveTill = $effectiveSince->modify(sprintf('+%d days', $days)); + } + + return new self( + $effectiveSince, + $effectiveTill, + ); + } + public function start(): DateTimeImmutable { return $this->start; diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index 3b790c8d..5ecdcc8e 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -188,14 +188,27 @@ public function actionIs(string $target, string $type, float $amount, string $un $type = Type::anyId($type); $target = new Target(Target::ANY, $target); $time = new DateTimeImmutable($date); + $fractionOfMonth = 1; if ($this->sale->getCloseTime() instanceof DateTimeImmutable) { - $amount = $amount * $this->getFractionOfMonth( + $fractionOfMonth = $this->getFractionOfMonth( $time, $time, $this->sale->getCloseTime() ); + $amount = $amount * $fractionOfMonth; } $quantity = Quantity::create($unit, $amount); - $this->action = new Action(null, $type, $target, $quantity, $this->customer, $time); + $this->action = new Action( + null, + $type, + $target, + $quantity, + $this->customer, + $time, + null, + null, + null, + $fractionOfMonth + ); } private function getFractionOfMonth(DateTimeImmutable $month, DateTimeImmutable $startTime, DateTimeImmutable $endTime): float diff --git a/tests/unit/action/UsageIntervalTest.php b/tests/unit/action/UsageIntervalTest.php index 12282720..4951434c 100644 --- a/tests/unit/action/UsageIntervalTest.php +++ b/tests/unit/action/UsageIntervalTest.php @@ -120,4 +120,118 @@ public function provideWithinMonth() ] ]; } + + /** + * @dataProvider provideWithMonthAndFraction + */ + public function testWithMonthAndFraction(array $constructor, array $expectations): void + { + $month = new DateTimeImmutable($constructor['month']); + $start = new DateTimeImmutable($constructor['start']); + + if (isset($expectations['expectedException'])) { + $this->expectException($expectations['expectedException']); + $this->expectExceptionMessage($expectations['expectedExceptionMessage']); + } + + $interval = UsageInterval::withMonthAndFraction($month, $start, $constructor['fraction']); + + $this->assertEquals($expectations['start'], $interval->start()->format("Y-m-d H:i:s")); + $this->assertEquals($expectations['end'], $interval->end()->format("Y-m-d H:i:s")); + $this->assertSame($expectations['ratioOfMonth'], $interval->ratioOfMonth()); + $this->assertSame($expectations['seconds'], $interval->seconds()); + $this->assertSame($expectations['secondsInMonth'], $interval->secondsInMonth()); + } + + public function provideWithMonthAndFraction() + { + yield 'For a start and end dates outside the month, the interval is the whole month' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2023-01-01 00:00:00', 'fraction' => 1], + [ + 'start' => '2023-02-01 00:00:00', + 'end' => '2023-03-01 00:00:00', + 'ratioOfMonth' => 1.0, + 'seconds' => 2_419_200, + 'secondsInMonth' => 2_419_200, + ] + ]; + + yield 'When start date is greater than a month, the interval is a fraction of month' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2023-02-15 00:00:00', 'fraction' => 0.5], + [ + 'start' => '2023-02-15 00:00:00', + 'end' => '2023-03-01 00:00:00', + 'ratioOfMonth' => 0.5, + 'seconds' => 1_209_600, + 'secondsInMonth' => 2_419_200, + ] + ]; + + yield 'When end date is less than a month, the interval is a fraction of month' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2021-10-02 19:01:10', 'fraction' => 0.5], + [ + 'start' => '2023-02-01 00:00:00', + 'end' => '2023-02-15 00:00:00', + 'ratioOfMonth' => 0.5, + 'seconds' => 1_209_600, + 'secondsInMonth' => 2_419_200, + ] + ]; + + yield 'When start and end dates are within a month, the interval is a fraction of month' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2023-02-15 00:00:00', 'fraction' => 0.17857142857142858], + [ + 'start' => '2023-02-15 00:00:00', + 'end' => '2023-02-20 00:00:00', + 'ratioOfMonth' => 0.17857142857142858, + 'seconds' => 432_000, + 'secondsInMonth' => 2_419_200, + ] + ]; + + yield 'When start date is greater than current month, the interval is zero' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2023-03-15 00:00:00', 'fraction' => 0], + [ + 'start' => '2023-02-01 00:00:00', + 'end' => '2023-02-01 00:00:00', + 'ratioOfMonth' => 0.0, + 'seconds' => 0, + 'secondsInMonth' => 2_419_200, + ] + ]; + + yield 'When end date is less than current month, the interval is zero' => [ + ['month' => '2023-02-01 00:00:00', 'start' => '2021-10-02 19:01:10', 'fraction' => 0], + [ + 'start' => '2023-02-01 00:00:00', + 'end' => '2023-02-01 00:00:00', + 'ratioOfMonth' => 0.0, + 'seconds' => 0, + 'secondsInMonth' => 2_419_200, + ] + ]; + } + + /** + * @dataProvider provideInvalidFractionOfMonthValues + */ + public function testWithMonthAndFractionInvalidValues(float $fractionOfMonth): void + { + $month = new DateTimeImmutable('2023-01-01'); + $start = new DateTimeImmutable('2023-01-15'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Fraction of month must be between 0 and 1'); + + UsageInterval::withMonthAndFraction($month, $start, $fractionOfMonth); + } + + public function provideInvalidFractionOfMonthValues(): array + { + return [ + [-0.1], + [1.1], + [2.0], + ]; + } }