diff --git a/Changelog.md b/Changelog.md index 8af7da21..cb861fe2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,7 @@ - Added the unidirectional links feature - Added the possibility to retrieve the link items directly using `linkItems()` method as `Get::the($type)->linkItems()->of($model)` - Added the `link_items` helper, shortcut to Get::the()->linkItems() +- Added the discountable shipping fee calculator - Added the `taxes_total`, `shipping_total` and `total` attribute getters to the Foundation `Order` model - Added the follwing getters to the default Billpayer model (proxies down to the underlying address): - `country_id` @@ -19,7 +20,6 @@ - `street_address` (fetches $billpayer->address->address) - can't use `address` since that collides with the address() relation - `address2` - `access_code` - - Changed the offline payment gateway's icon from a circle to a plug+x ## 4.0.1 diff --git a/src/Foundation/Providers/ModuleServiceProvider.php b/src/Foundation/Providers/ModuleServiceProvider.php index 364d1c55..14455d8b 100644 --- a/src/Foundation/Providers/ModuleServiceProvider.php +++ b/src/Foundation/Providers/ModuleServiceProvider.php @@ -47,6 +47,8 @@ use Vanilo\Foundation\Models\ShippingMethod; use Vanilo\Foundation\Models\Taxon; use Vanilo\Foundation\Models\Taxonomy; +use Vanilo\Foundation\Shipping\DiscountableShippingFee; +use Vanilo\Foundation\Shipping\DiscountableShippingFeeCalculator; use Vanilo\Foundation\Shipping\FlatFeeCalculator; use Vanilo\Foundation\Shipping\PaymentDependentShippingFee; use Vanilo\Foundation\Shipping\PaymentDependentShippingFeeCalculator; @@ -118,7 +120,9 @@ public function boot() ShippingFeeCalculators::register(FlatFeeCalculator::ID, FlatFeeCalculator::class); ShippingFeeCalculators::register(PaymentDependentShippingFeeCalculator::ID, PaymentDependentShippingFeeCalculator::class); + ShippingFeeCalculators::register(DiscountableShippingFeeCalculator::ID, DiscountableShippingFeeCalculator::class); AdjusterAliases::add(PaymentDependentShippingFee::ALIAS, PaymentDependentShippingFee::class); + AdjusterAliases::add(DiscountableShippingFee::ALIAS, DiscountableShippingFee::class); // Use the foundation's extended order factory $this->app->bind(OrderFactoryContract::class, OrderFactory::class); diff --git a/src/Foundation/Shipping/DiscountableShippingFee.php b/src/Foundation/Shipping/DiscountableShippingFee.php new file mode 100644 index 00000000..a4df6a4d --- /dev/null +++ b/src/Foundation/Shipping/DiscountableShippingFee.php @@ -0,0 +1,100 @@ +getData(); + + return new self( + floatval($data['amount'] ?? 0), + $data['freeThreshold'] ?? null, + $data['discountedThreshold'] ?? null, + $data['discountedAmount'] ?? null, + ); + } + + public function createAdjustment(Adjustable $adjustable): Adjustment + { + $adjustmentClass = AdjustmentProxy::modelClass(); + + return new $adjustmentClass($this->getModelAttributes($adjustable)); + } + + public function recalculate(Adjustment $adjustment, Adjustable $adjustable): Adjustment + { + $adjustment->setAmount($this->calculateAmount($adjustable)); + + return $adjustment; + } + + private function calculateAmount(Adjustable $adjustable): float + { + $priceWithoutShippingFees = $adjustable->preAdjustmentTotal() + $adjustable->adjustments()->exceptTypes(AdjustmentTypeProxy::SHIPPING())->total(); + + if (null !== $this->freeThreshold && $priceWithoutShippingFees >= $this->freeThreshold) { + return 0; + } elseif (null !== $this->discountedThreshold && $priceWithoutShippingFees >= $this->discountedThreshold) { + return $this->discountedAmount ?? $this->amount; + } + + return $this->amount; + } + + private function getModelAttributes(Adjustable $adjustable): array + { + return [ + 'type' => AdjustmentTypeProxy::SHIPPING(), + 'adjuster' => self::class, + 'origin' => null, + 'title' => $this->getTitle(), + 'description' => $this->getDescription(), + 'data' => [ + 'amount' => $this->amount, + 'freeThreshold' => $this->freeThreshold, + 'discountedThreshold' => $this->discountedThreshold, + 'discountedAmount' => $this->discountedAmount, + ], + 'amount' => $this->calculateAmount($adjustable), + 'is_locked' => $this->isLocked(), + 'is_included' => $this->isIncluded(), + ]; + } +} diff --git a/src/Foundation/Shipping/DiscountableShippingFeeCalculator.php b/src/Foundation/Shipping/DiscountableShippingFeeCalculator.php new file mode 100644 index 00000000..0e3feb2f --- /dev/null +++ b/src/Foundation/Shipping/DiscountableShippingFeeCalculator.php @@ -0,0 +1,128 @@ +toParameters($configuration); + + $adjuster = new DiscountableShippingFee($cost, $freeThreshold, $discountedThreshold, $discountedCost); + $adjuster->setTitle($configuration['title'] ?? __('Shipping fee')); + + return $adjuster; + } + + public function calculate(?object $subject = null, ?array $configuration = null): ShippingFee + { + [$cost, $freeThreshold, $discountedThreshold, $discountedCost] = $this->toParameters($configuration); + + $itemsTotal = $this->itemsTotal($subject); + if (null !== $freeThreshold && null !== $itemsTotal) { + if ($itemsTotal >= $freeThreshold) { + $amount = DetailedAmount::fromArray([ + ['title' => __('Shipping Fee'), 'amount' => $cost], + ['title' => __('Free shipping for orders above :amount', ['amount' => format_price($freeThreshold)]), 'amount' => -$cost], + ]); + + return new ShippingFee($amount, false); + } + } + + if (null !== $discountedThreshold && null !== $itemsTotal) { + if ($itemsTotal >= $discountedThreshold) { + $amount = DetailedAmount::fromArray([ + ['title' => __('Shipping Fee'), 'amount' => $cost], + ['title' => __('Shipping discount for orders above :amount', ['amount' => format_price($discountedThreshold)]), 'amount' => $discountedCost - $cost], + ]); + + return new ShippingFee($amount, false); + } + } + + return new ShippingFee($cost, true); + } + + public function getSchema(): Schema + { + return Expect::structure([ + 'title' => Expect::string(__('Shipping fee')), + 'cost' => Expect::float()->required(), + 'free_threshold' => Expect::float(), + 'discounted_threshold' => Expect::float(), + 'discounted_cost' => Expect::float(), + ]); + } + + public function getSchemaSample(array $mergeWith = null): array + { + return [ + 'title' => __('Shipping fee'), + 'cost' => 7.99, + 'free_threshold' => null, + 'discounted_threshold' => 50, + 'discounted_cost' => 4.99, + ]; + } + + private function toParameters(?array $configuration): array + { + if (!is_array($configuration) || !isset($configuration['cost'])) { + throw new InvalidShippingConfigurationException('The shipping fee can not be calculated. The `cost` configuration value is missing.'); + } + + $cost = floatval($configuration['cost']); + $freeThreshold = $configuration['free_threshold'] ?? null; + $freeThreshold = is_null($freeThreshold) ? null : floatval($freeThreshold); + $discountedThreshold = is_null($configuration['discounted_threshold'] ?? null) ? null : floatval($configuration['discounted_threshold']); + $discountedCost = is_null($configuration['discounted_cost'] ?? null) ? null : floatval($configuration['discounted_cost']); + + return [$cost, $freeThreshold, $discountedThreshold, $discountedCost]; + } + + private function itemsTotal(?object $subject): ?float + { + if (null === $subject) { + return null; + } + + if (method_exists($subject, 'itemsTotal')) { + return $subject->itemsTotal(); + } elseif ($subject instanceof CheckoutSubject) { + return $subject->getItems()->sum('total'); + } elseif ($subject instanceof Buyable) { + return $subject->getPrice(); + } + + return null; + } +} diff --git a/src/Foundation/Tests/DiscountableShippingFeeCalculationTest.php b/src/Foundation/Tests/DiscountableShippingFeeCalculationTest.php new file mode 100644 index 00000000..3840f9cd --- /dev/null +++ b/src/Foundation/Tests/DiscountableShippingFeeCalculationTest.php @@ -0,0 +1,115 @@ +create(['price' => 50]); + $shippingMethod = ShippingMethod::create([ + 'name' => 'Shippping', + 'calculator' => DiscountableShippingFeeCalculator::ID, + 'configuration' => ['cost' => 14.99], + ]); + + Cart::addItem($product); + Checkout::setCart(Cart::getFacadeRoot()); + Checkout::setShippingMethodId($shippingMethod->id); + + /** @var AdjustmentCollection $shippingAdjustments */ + $shippingAdjustments = Cart::adjustments()->byType(AdjustmentType::SHIPPING()); + $this->assertCount(1, $shippingAdjustments); + $shippingAdjustment = $shippingAdjustments->first(); + $this->assertEquals(14.99, $shippingAdjustment->getAmount()); + $this->assertTrue($shippingAdjustment->isCharge()); + $this->assertFalse($shippingAdjustment->isIncluded()); + $this->assertEquals(50, Cart::itemsTotal()); + $this->assertEquals(50 + 14.99, Cart::total()); + + $shippingAmount = Checkout::getShippingAmount(); + $this->assertInstanceOf(DetailedAmount::class, $shippingAmount); + $this->assertEquals(14.99, $shippingAmount->getValue()); + } + + /** @test */ + public function it_creates_a_shipping_adjustment_having_a_zero_sum_when_the_free_shipping_threshold_is_exceeded() + { + $product = factory(Product::class)->create(['price' => 40]); + $shippingMethod = ShippingMethod::create([ + 'name' => 'Discounted Fee Free', + 'calculator' => DiscountableShippingFeeCalculator::ID, + 'configuration' => ['cost' => 4.99, 'free_threshold' => 39.99], + ]); + + Cart::addItem($product); + Checkout::setCart(Cart::getFacadeRoot()); + Checkout::setShippingMethodId($shippingMethod->id); + + /** @var AdjustmentCollection $shippingAdjustments */ + $shippingAdjustments = Cart::adjustments()->byType(AdjustmentType::SHIPPING()); + $this->assertCount(1, $shippingAdjustments); + $shippingAdjustment = $shippingAdjustments->first(); + $this->assertEquals(0, $shippingAdjustment->getAmount()); + $this->assertEquals(40, Cart::itemsTotal()); + $this->assertEquals(40, Cart::total()); + + $shippingDetails = Checkout::getShippingAmount(); + $this->assertEquals(0, $shippingDetails->getValue()); + $this->assertCount(2, $shippingDetails->getDetails()); + } + + /** @test */ + public function it_creates_a_shipping_adjustment_with_the_discounted_price_when_the_discounted_shipping_threshold_is_exceeded() + { + $product = factory(Product::class)->create(['price' => 100]); + $shippingMethod = ShippingMethod::create([ + 'name' => 'Discounted Fee', + 'calculator' => DiscountableShippingFeeCalculator::ID, + 'configuration' => [ + 'cost' => 12.99, + 'free_threshold' => 150, + 'discounted_threshold' => 99.99, + 'discounted_cost' => 3.99, + ], + ]); + + Cart::addItem($product); + Checkout::setCart(Cart::getFacadeRoot()); + Checkout::setShippingMethodId($shippingMethod->id); + + /** @var AdjustmentCollection $shippingAdjustments */ + $shippingAdjustments = Cart::adjustments()->byType(AdjustmentType::SHIPPING()); + $this->assertCount(1, $shippingAdjustments); + $shippingAdjustment = $shippingAdjustments->first(); + $this->assertEquals(3.99, $shippingAdjustment->getAmount()); + $this->assertEquals(100, Cart::itemsTotal()); + $this->assertEquals(103.99, Cart::total()); + + $shippingDetails = Checkout::getShippingAmount(); + $this->assertEquals(3.99, $shippingDetails->getValue()); + $this->assertCount(2, $shippingDetails->getDetails()); + } +}