diff --git a/src/Helpers/helpers.php b/src/Helpers/helpers.php index a833c222..17e8d140 100644 --- a/src/Helpers/helpers.php +++ b/src/Helpers/helpers.php @@ -6,7 +6,7 @@ use Money\Money; use Money\Parser\DecimalMoneyParser; -if (! function_exists('object_to_array_recursive')) { +if (!function_exists('object_to_array_recursive')) { /** * Recursively cast an object into an array. * @@ -23,7 +23,7 @@ function object_to_array_recursive($object) } } -if (! function_exists('money')) { +if (!function_exists('money')) { /** * Create a Money object from a Mollie Amount array. * @@ -37,7 +37,7 @@ function money($value, string $currency) } } -if (! function_exists('decimal_to_money')) { +if (!function_exists('decimal_to_money')) { /** * Create a Money object from a decimal string / currency pair. * @@ -53,7 +53,7 @@ function decimal_to_money(string $value, string $currency) } } -if (! function_exists('mollie_array_to_money')) { +if (!function_exists('mollie_array_to_money')) { /** * Create a Money object from a Mollie Amount array. * @@ -66,7 +66,7 @@ function mollie_array_to_money(array $array) } } -if (! function_exists('money_to_mollie_array')) { +if (!function_exists('money_to_mollie_array')) { /** * Create a Mollie Amount array from a Money object. * @@ -84,7 +84,7 @@ function money_to_mollie_array(Money $money) } } -if (! function_exists('mollie_object_to_money')) { +if (!function_exists('mollie_object_to_money')) { /** * Create a Money object from a Mollie Amount object. * @@ -97,7 +97,7 @@ function mollie_object_to_money(object $object) } } -if (! function_exists('money_to_decimal')) { +if (!function_exists('money_to_decimal')) { /** * Format the money as basic decimal diff --git a/src/Subscription.php b/src/Subscription.php index 116e68c0..cb279b73 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -612,6 +612,30 @@ public function getCurrencyAttribute() return optional($this->plan())->amount()->getCurrency()->getCode(); } + /** + * Gets the amount to be reimbursed for the subscription's unused time. + * + * Result range value: (-X up to 0) + * + * @param \Carbon\Carbon|null $now + * @return \Money\Money + */ + public function getReimbursableAmountForUnusedTime(?Carbon $now = null): Money + { + $now = $now ?: now(); + + if ($this->onTrial()) { + return $this->zero(); + } + if (round($this->getCycleLeftAttribute($now), 5) == 0) { + return $this->zero(); + } + + return $this->reimbursableAmount() + ->negative() + ->multiply(sprintf('%.8F', $this->getCycleLeftAttribute($now))); + } + /** * Handle a failed payment. * @@ -639,7 +663,7 @@ public static function handlePaymentPaid(OrderItem $item) if ($subscription->ends_at !== null) { DB::transaction(function () use ($item, $subscription) { - if (! $subscription->scheduled_order_item_id) { + if (!$subscription->scheduled_order_item_id) { $item = $subscription->scheduleNewOrderItemAt($subscription->ends_at); } @@ -770,12 +794,10 @@ protected function reimburse(Money $amount, array $overrides = []) */ protected function reimbursableAmount() { - $zeroAmount = new Money('0.00', new Currency($this->currency)); - // Determine base amount eligible to reimburse $latestProcessedOrderItem = $this->latestProcessedOrderItem(); if (!$latestProcessedOrderItem) { - return $zeroAmount; + return $this->zero(); } $reimbursableAmount = $latestProcessedOrderItem->getTotal() @@ -809,7 +831,7 @@ protected function reimbursableAmount() // Guard against a negative value if ($reimbursableAmount->isNegative()) { - return $zeroAmount; + return $this->zero(); } return $reimbursableAmount; @@ -949,4 +971,9 @@ public function latestProcessedOrderItem() { return $this->orderItems()->processed()->orderByDesc('process_at')->first(); } + + private function zero(): Money + { + return new Money('0.00', new Currency($this->currency)); + } } diff --git a/tests/Database/Factories/OrderItemFactory.php b/tests/Database/Factories/OrderItemFactory.php index 08501e57..37dac38e 100644 --- a/tests/Database/Factories/OrderItemFactory.php +++ b/tests/Database/Factories/OrderItemFactory.php @@ -74,4 +74,11 @@ public function USD() 'currency' => 'USD', ]); } + + public function withOrder(): self + { + return $this->state(fn () => [ + 'order_id' => OrderFactory::new(), + ]); + } } diff --git a/tests/SubscriptionTest.php b/tests/SubscriptionTest.php index 0ac7c18e..add5c51b 100644 --- a/tests/SubscriptionTest.php +++ b/tests/SubscriptionTest.php @@ -589,4 +589,52 @@ public function canQueryRecurringSubscriptions() $this->assertEquals(1, Subscription::whereRecurring()->count()); $this->assertEquals(2, Subscription::whereNotRecurring()->count()); } + + /** @test */ + public function halfwayThroughSubscriptionReturnsPositiveReimbursementAmount() + { + $this->withConfiguredPlans(); + + $subscriptionHalfWayThrough = SubscriptionFactory::new() + ->has( + OrderItemFactory::new(['unit_price' => 100]) + ->processed() + ->withOrder() + ->EUR(), + 'scheduledOrderItem' + ) + ->create([ + 'cycle_started_at' => now()->subDays(20), + 'cycle_ends_at' => now()->addDays(20), + ]); + + $this->assertEquals( + money('-50', 'EUR'), + $subscriptionHalfWayThrough->getReimbursableAmountForUnusedTime() + ); + } + + /** @test */ + public function nonReimbursableSubscriptionReturnsNoReimbursementAmount() + { + $this->withConfiguredPlans(); + + $nonReimbursable = SubscriptionFactory::new() + ->has( + OrderItemFactory::new(['unit_price' => 100]) + ->processed() + ->withOrder() + ->EUR(), + 'scheduledOrderItem' + ) + ->create([ + 'cycle_started_at' => now()->subMonth(), + 'cycle_ends_at' => now()->subDay(), + ]); + + $this->assertEquals( + money(0, 'EUR'), + $nonReimbursable->getReimbursableAmountForUnusedTime() + ); + } }