Skip to content

Commit

Permalink
Added the discountable shipping fee calculator
Browse files Browse the repository at this point in the history
  • Loading branch information
fulopattila122 committed Jun 2, 2024
1 parent 8f09c82 commit 7b1e45d
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 1 deletion.
2 changes: 1 addition & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Foundation/Providers/ModuleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
100 changes: 100 additions & 0 deletions src/Foundation/Shipping/DiscountableShippingFee.php
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 src/Foundation/Shipping/DiscountableShippingFeeCalculator.php
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 src/Foundation/Tests/DiscountableShippingFeeCalculationTest.php
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());
}
}

0 comments on commit 7b1e45d

Please sign in to comment.