From 624cc8e5f5539f0a39d6b5a725c49b6a744d8840 Mon Sep 17 00:00:00 2001 From: Eduardo Iriarte-Mendez Date: Tue, 1 Feb 2022 13:37:52 +0100 Subject: [PATCH] feat: reorganize offline inst-calculation - implement unit-tests - add DateTime wrapper - throw proper exceptions ORCA-1973 Squashed commit of the following: commit d9038a95afac8c972e593daa7e6234da0f382647 Author: Eduardo Iriarte-Mendez Date: Fri Jan 28 18:11:50 2022 +0100 feat: reorganize offline inst-calculation - implement unit-tests - add DateTime wrapper - throw proper exceptions --- .../OfflineInstalmentCalculationException.php | 18 +++ src/Service/DateTime/DateTime.php | 43 ++++++ src/Service/DateTime/DateTimeCalculators.php | 57 ++++++++ src/Service/DateTime/DateTimeModifiers.php | 101 +++++++++++++ src/Service/DateTime/DateTimeTesting.php | 61 ++++++++ src/Service/Math.php | 16 ++ src/Service/OfflineInstallmentCalculation.php | 73 +++------ tests/Unit/Service/DateTimeTest.php | 95 ++++++++++++ tests/Unit/Service/MathTest.php | 24 +++ .../OfflineInstallmentCalculationTest.php | 138 +++++++++++++++++- 10 files changed, 572 insertions(+), 54 deletions(-) create mode 100644 src/Exception/OfflineInstalmentCalculationException.php create mode 100644 src/Service/DateTime/DateTime.php create mode 100644 src/Service/DateTime/DateTimeCalculators.php create mode 100644 src/Service/DateTime/DateTimeModifiers.php create mode 100644 src/Service/DateTime/DateTimeTesting.php create mode 100644 tests/Unit/Service/DateTimeTest.php diff --git a/src/Exception/OfflineInstalmentCalculationException.php b/src/Exception/OfflineInstalmentCalculationException.php new file mode 100644 index 0000000..eae5354 --- /dev/null +++ b/src/Exception/OfflineInstalmentCalculationException.php @@ -0,0 +1,18 @@ +setTime(0, 0) : new DateTime('today'); + } +} diff --git a/src/Service/DateTime/DateTimeCalculators.php b/src/Service/DateTime/DateTimeCalculators.php new file mode 100644 index 0000000..9f06e80 --- /dev/null +++ b/src/Service/DateTime/DateTimeCalculators.php @@ -0,0 +1,57 @@ +getTimestamp() - $dateTime->getTimestamp(); + } + + /** + * @param DateTime $dateTime related date time to perform calculation + * + * @return int|float difference between dates in minutes + */ + public function diffInMinutes($dateTime) + { + return $this->diffInSeconds($dateTime) / 60; + } + + /** + * @param DateTime $dateTime related date time to perform calculation + * + * @return int|float difference between dates in minutes + */ + public function diffInHours($dateTime) + { + return $this->diffInMinutes($dateTime) / 60; + } + + /** + * @param DateTime $dateTime related date time to perform calculation + * + * @return int|float difference between dates in minutes + */ + public function diffInDays($dateTime) + { + return $this->diffInHours($dateTime) / 24; + } +} diff --git a/src/Service/DateTime/DateTimeModifiers.php b/src/Service/DateTime/DateTimeModifiers.php new file mode 100644 index 0000000..181d0cb --- /dev/null +++ b/src/Service/DateTime/DateTimeModifiers.php @@ -0,0 +1,101 @@ +getDateFields(); + + return $this->setDate($date->year, $date->month, $day); + } + + /** + * @param int $daysOffset number of days to be added + * + * @return DateTime + */ + public function addDays($daysOffset) + { + $date = $this->getDateFields(); + + return $this->setDate($date->year, $date->month, $date->day + $daysOffset); + } + + /** + * @param int $month month of year to set + * + * @return DateTime + */ + public function setMonth($month) + { + $date = $this->getDateFields(); + + return $this->setDate($date->year, $month, $date->day); + } + + /** + * @param int $monthsOffset number of months to be added + * + * @return DateTime + */ + public function addMonths($monthsOffset) + { + $date = $this->getDateFields(); + + return $this->setDate($date->year, $date->month + $monthsOffset, $date->day); + } + + /** + * @param int $year year to set + * + * @return DateTime + */ + public function setYear($year) + { + $date = $this->getDateFields(); + + return $this->setDate($year, $date->month, $date->day); + } + + /** + * @param int $yearsOffset number of years to be added + * + * @return DateTime + */ + public function addYears($yearsOffset) + { + $date = $this->getDateFields(); + + return $this->setDate($date->year + $yearsOffset, $date->month, $date->day); + } + + /** + * @return object + */ + private function getDateFields() + { + return (object) [ + 'year' => (int) $this->format('Y'), + 'month' => (int) $this->format('m'), + 'day' => (int) $this->format('d'), + ]; + } +} diff --git a/src/Service/DateTime/DateTimeTesting.php b/src/Service/DateTime/DateTimeTesting.php new file mode 100644 index 0000000..0cca32d --- /dev/null +++ b/src/Service/DateTime/DateTimeTesting.php @@ -0,0 +1,61 @@ +_paymentFirstday == 28 ? (int) date('m') + 1 : (int) date('m') + 2, $this->_paymentFirstday, date('Y')); - $today = time(); - $difference = $datePaymentFirstday - $today; - - $daysTillPaymentFirstday = ceil($difference / 60 / 60 / 24) + 1; - - $interestRateMonth = - pow( - (1 + ($this->_interestRate / 100)), - (1 / 12) - ) - - - 1; - - $interestRateTillStart = - pow( - (($this->_interestRate / 100) + 1), - ($daysTillPaymentFirstday / 365) - ) - - - 1; - - $installment = - ( - $this->_basketAmount - * - (1 + ($interestRateTillStart)) - * - $interestRateMonth - * - pow( - (1 + $interestRateMonth), - ($this->_runtime - 1) - ) - ) - / - ( - pow( - (1 + $interestRateMonth), - ($this->_runtime) - ) - - - 1 - ) - + - ($this->_serviceCharge / $this->_runtime); - - return round($installment, 2); + $totalMonths = (int) ($this->_runtime ?: 0); + + if ($totalMonths === 0) { + throw new OfflineInstalmentCalculationException('Runtime of 0 months not allowed.'); + } + + $daysUntilFirstDueDate = DateTime::today() + ->addMonths($this->_paymentFirstday == 28 ? 1 : 2) + ->setDay($this->_paymentFirstday + 1) + ->diffInDays(DateTime::today()); + $monthlyInterestRate = Math::interestByInterval($this->_interestRate, (1 / 12)); + $interestRateUntilFirstDue = Math::interestByInterval($this->_interestRate, ($daysUntilFirstDueDate / 365)); + $interestRateProduct = pow((1 + $monthlyInterestRate), $totalMonths) - 1; + $monthlyServiceCharge = $this->_serviceCharge / $totalMonths; + $paymentStreamFactor = $this->_basketAmount * (1 + $interestRateUntilFirstDue) * $monthlyInterestRate; + + $monthlyInstalment = ($paymentStreamFactor / $interestRateProduct) + * pow((1 + $monthlyInterestRate), ($totalMonths - 1)) + + $monthlyServiceCharge; + + return round($monthlyInstalment, 2); } private function callZeroPercentCalculationByTime() { if ((int) $this->_runtime === 0) { - throw new \Exception('Runtime of 0 months not allowed.'); + throw new OfflineInstalmentCalculationException('Runtime of 0 months not allowed.'); } $monthlyInstalment = $this->_basketAmount / $this->_runtime; diff --git a/tests/Unit/Service/DateTimeTest.php b/tests/Unit/Service/DateTimeTest.php new file mode 100644 index 0000000..6d1cb22 --- /dev/null +++ b/tests/Unit/Service/DateTimeTest.php @@ -0,0 +1,95 @@ +assertEquals($expectedDate, $dateTime->format($format)); + } + + public function provideFakeNow() + { + return [ + ['2020-01-10T10:32:57+01:00', '2020-01-10', 'Y-m-d'], + ['2018-05-10T17:32:23+00:00', '2018-05-10 17:32:23', 'Y-m-d H:i:s'], + ['yesterday', (new \DateTime('yesterday'))->format('Y-m-d'), 'Y-m-d'], + ]; + } + + /** @dataProvider provideFakeToday */ + public function testToday($now, $expectedDate, $format) + { + DateTime::withTestingNow(new DateTime($now)); + + $dateTime = DateTime::today(); + + DateTime::resetTestingNow(); + + $this->assertEquals($expectedDate, $dateTime->format($format)); + } + + public function provideFakeToday() + { + return [ + ['2020-01-10T10:32:57+01:00', '2020-01-10', 'Y-m-d'], + ['2018-05-10T17:32:23+00:00', '2018-05-10 00:00:00', 'Y-m-d H:i:s'], + ['yesterday', (new \DateTime('yesterday'))->format('Y-m-d H:i:s'), 'Y-m-d H:i:s'], + ]; + } + + /** @dataProvider provideAddDayTransformations */ + public function testAddDayTransformation($now, $days, $expectedDate) + { + DateTime::withTestingNow(new DateTime($now)); + + $dateTime = DateTime::now()->addDays($days); + + DateTime::resetTestingNow(); + + $this->assertEquals($expectedDate, $dateTime->format('Y-m-d H:i:s')); + } + + public function provideAddDayTransformations() + { + return [ + ['2020-01-10T10:32:57+01:00', 5, '2020-01-15 10:32:57'], + ['2020-02-27T17:32:23+00:00', 3, '2020-03-01 17:32:23'], + ['2019-02-27T17:32:23+00:00', 3, '2019-03-02 17:32:23'], + ['2019-02-27T17:32:23+00:00', -3, '2019-02-24 17:32:23'], + ]; + } + + /** @dataProvider provideAddMonthTransformations */ + public function testAddMonthTransformation($now, $months, $expectedDate) + { + DateTime::withTestingNow(new DateTime($now)); + + $dateTime = DateTime::now()->addMonths($months); + + DateTime::resetTestingNow(); + + $this->assertEquals($expectedDate, $dateTime->format('Y-m-d H:i:s')); + } + + public function provideAddMonthTransformations() + { + return [ + ['2020-01-10T10:32:57+01:00', 5, '2020-06-10 10:32:57'], + ['2020-11-27T17:32:23+00:00', 3, '2021-02-27 17:32:23'], + ['2019-11-27T17:32:23+00:00', -3, '2019-08-27 17:32:23'], + ['2019-02-27T17:32:23+00:00', -3, '2018-11-27 17:32:23'], + ]; + } +} diff --git a/tests/Unit/Service/MathTest.php b/tests/Unit/Service/MathTest.php index edf73b5..26d49f4 100644 --- a/tests/Unit/Service/MathTest.php +++ b/tests/Unit/Service/MathTest.php @@ -50,4 +50,28 @@ public function provideRoundedNatPricesWithTaxRates() [29.99, 2.5, 30.74], ]; } + + /** @dataProvider provideZeroValues */ + public function testIsZero($number, $precission, $expectation) + { + $isZero = Math::isZero($number, $precission); + + $this->assertEquals($expectation, $isZero); + } + + public function provideZeroValues() + { + return [ + [ -0.000001, 0, true ], + [ -0.000001, 1, true ], + [ -0.000001, 2, true ], + [ -0.000001, 3, true ], + [ -0.000001, 4, true ], + [ -0.000001, 5, true ], + [ -0.000001, 6, false ], + [ -0.3 + 0.09 + 0.2, 0, true ], + [ -0.3 + 0.09 + 0.2, 1, true ], + [ -0.3 + 0.09 + 0.2, 2, false ], + ]; + } } diff --git a/tests/Unit/Service/OfflineInstallmentCalculationTest.php b/tests/Unit/Service/OfflineInstallmentCalculationTest.php index 90fcfd7..212a3b0 100644 --- a/tests/Unit/Service/OfflineInstallmentCalculationTest.php +++ b/tests/Unit/Service/OfflineInstallmentCalculationTest.php @@ -10,17 +10,95 @@ use PHPUnit\Framework\TestCase; use RatePAY\ModelBuilder; +use RatePAY\Service\DateTime\DateTime; use RatePAY\Service\OfflineInstallmentCalculation; class OfflineInstallmentCalculationTest extends TestCase { - public function testCallOfflineCalculation() + /** + * @dataProvider provideOfflineInstalmentCalculations + */ + public function testCallOfflineCalculation($contentData, $currentNow, $expectedAmount) { - $this->markTestSkipped('Due to lack of understanding of ModelBuilder functionalities!'); - // it also seems that `callOfflineInstallmentCalculation` is unused at all... + DateTime::withTestingNow(new DateTime($currentNow)); + + $modelBuilder = new ModelBuilder('Content'); + $modelBuilder->setArray($contentData); + $service = new OfflineInstallmentCalculation(); - $calculation = $service->callOfflineCalculation(new ModelBuilder()); - $this->assertEquals('today', $calculation); + + $instalmentCalculation = $service->callOfflineCalculation($modelBuilder) + ->subtype('calculation-by-time'); + + DateTime::resetTestingNow(); + + $this->assertEquals(round($expectedAmount, 2), $instalmentCalculation); + } + + public function provideOfflineInstalmentCalculations() + { + return [ + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 2000, + 'CalculationTime' => [ + 'Month' => 24, + ], + 'PaymentFirstday' => 28, + 'ServiceCharge' => 3.95, + 'InterestRate' => 13.6, + ], + ], + 'currentNow' => '2022-01-23', + 'expectedAmount' => 95.3, // Payment API = 95.31, + ], + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 2000, + 'CalculationTime' => [ + 'Month' => 24, + ], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 3.95, + 'InterestRate' => 10.5, + ], + ], + 'currentNow' => '2022-01-23', + 'expectedAmount' => 92.7, // Payment API = 92.71, + ], + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 1000, + 'CalculationTime' => [ + 'Month' => 24, + ], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 0, + 'InterestRate' => 13.7, + ], + ], + 'currentNow' => '2022-01-24', + 'expectedAmount' => 47.63, // Payment API = 47.16, + ], + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 1000, + 'CalculationTime' => [ + 'Month' => 24, + ], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 0, + 'InterestRate' => 13.7, + ], + ], + 'currentNow' => '2022-01-28', + 'expectedAmount' => 47.56, // Payment API = 47.1, + ], + ]; } public function testCallZeroPercentOfflineCalculation() @@ -41,4 +119,54 @@ public function testCallZeroPercentOfflineCalculation() $this->assertEquals(round(83.33, 2), $monthlyInstalment); } + + /** + * @dataProvider provideOfflineInstalmentCalculationWithZeroRuntime + */ + public function testCalculationThrowsExceptionsForZeroRuntimes() + { + $content = new ModelBuilder('Content'); + $content->setArray([ + 'InstallmentCalculation' => [ + 'Amount' => 2000, + 'CalculationTime' => ['Month' => 0], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 3.95, + 'InterestRate' => 0, + ] + ]); + + $this->expectException('RatePAY\Exception\OfflineInstalmentCalculationException'); + + $service = new OfflineInstallmentCalculation(); + $service->callOfflineCalculation($content)->subtype('calculation-by-time'); + } + + public function provideOfflineInstalmentCalculationWithZeroRuntime() + { + return [ + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 2000, + 'CalculationTime' => ['Month' => 0], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 3.95, + 'InterestRate' => 0, + ] + ], + ], + [ + 'contentData' => [ + 'InstallmentCalculation' => [ + 'Amount' => 2000, + 'CalculationTime' => ['Month' => 0], + 'PaymentFirstday' => 2, + 'ServiceCharge' => 3.95, + 'InterestRate' => 10.7, + ] + ], + ], + ]; + } }