-
-
Notifications
You must be signed in to change notification settings - Fork 101
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added the discountable shipping fee calculator
- Loading branch information
1 parent
8f09c82
commit 7b1e45d
Showing
5 changed files
with
348 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/** | ||
* Contains the DiscountableShippingFee class. | ||
* | ||
* @copyright Copyright (c) 2024 Vanilo UG | ||
* @author Attila Fulop | ||
* @license MIT | ||
* @since 2024-06-02 | ||
* | ||
*/ | ||
|
||
namespace Vanilo\Foundation\Shipping; | ||
|
||
use Vanilo\Adjustments\Contracts\Adjustable; | ||
use Vanilo\Adjustments\Contracts\Adjuster; | ||
use Vanilo\Adjustments\Contracts\Adjustment; | ||
use Vanilo\Adjustments\Models\AdjustmentProxy; | ||
use Vanilo\Adjustments\Models\AdjustmentTypeProxy; | ||
use Vanilo\Adjustments\Support\HasWriteableTitleAndDescription; | ||
use Vanilo\Adjustments\Support\IsLockable; | ||
use Vanilo\Adjustments\Support\IsNotIncluded; | ||
|
||
final class DiscountableShippingFee implements Adjuster | ||
{ | ||
use HasWriteableTitleAndDescription; | ||
use IsLockable; | ||
use IsNotIncluded; | ||
|
||
public const ALIAS = 'discountable_shipping_fee'; | ||
|
||
public function __construct( | ||
private float $amount, | ||
private ?float $freeThreshold = null, | ||
private ?float $discountedThreshold = null, | ||
private ?float $discountedAmount = null, | ||
) { | ||
} | ||
|
||
public static function reproduceFromAdjustment(Adjustment $adjustment): Adjuster | ||
{ | ||
$data = $adjustment->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(), | ||
]; | ||
} | ||
} |
128 changes: 128 additions & 0 deletions
128
src/Foundation/Shipping/DiscountableShippingFeeCalculator.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/** | ||
* Contains the DiscountableShippingFeeCalculator class. | ||
* | ||
* @copyright Copyright (c) 2024 Vanilo UG | ||
* @author Attila Fulop | ||
* @license MIT | ||
* @since 2024-06-02 | ||
* | ||
*/ | ||
|
||
namespace Vanilo\Foundation\Shipping; | ||
|
||
use Nette\Schema\Expect; | ||
use Nette\Schema\Schema; | ||
use Vanilo\Contracts\Buyable; | ||
use Vanilo\Contracts\CheckoutSubject; | ||
use Vanilo\Shipment\Contracts\ShippingFeeCalculator; | ||
use Vanilo\Shipment\Exceptions\InvalidShippingConfigurationException; | ||
use Vanilo\Shipment\Models\ShippingFee; | ||
use Vanilo\Support\Dto\DetailedAmount; | ||
|
||
class DiscountableShippingFeeCalculator implements ShippingFeeCalculator | ||
{ | ||
public const ID = 'discountable_shipping_fee'; | ||
|
||
public static function getName(): string | ||
{ | ||
return __('Discountable shipping fee'); | ||
} | ||
|
||
public function getAdjuster(?array $configuration = null): ?object | ||
{ | ||
[$cost, $freeThreshold, $discountedThreshold, $discountedCost] = $this->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; | ||
} | ||
} |
115 changes: 115 additions & 0 deletions
115
src/Foundation/Tests/DiscountableShippingFeeCalculationTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/** | ||
* Contains the DiscountableShippingFeeCalculationTest class. | ||
* | ||
* @copyright Copyright (c) 2024 Vanilo UG | ||
* @author Attila Fulop | ||
* @license MIT | ||
* @since 2024-06-02 | ||
* | ||
*/ | ||
|
||
namespace Vanilo\Foundation\Tests; | ||
|
||
use Vanilo\Adjustments\Contracts\AdjustmentCollection; | ||
use Vanilo\Adjustments\Models\AdjustmentType; | ||
use Vanilo\Cart\Facades\Cart; | ||
use Vanilo\Checkout\Facades\Checkout; | ||
use Vanilo\Contracts\DetailedAmount; | ||
use Vanilo\Foundation\Models\Product; | ||
use Vanilo\Foundation\Shipping\DiscountableShippingFeeCalculator; | ||
use Vanilo\Shipment\Models\ShippingMethod; | ||
|
||
class DiscountableShippingFeeCalculationTest extends TestCase | ||
{ | ||
/** @test */ | ||
public function a_normal_shipping_fee_gets_calculated_when_there_is_no_discount_deal_and_the_free_shipping_threshold_is_not_exceeded() | ||
{ | ||
$product = factory(Product::class)->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()); | ||
} | ||
} |