craftcms / commerce

Fully integrated ecommerce for Craft CMS.
https://craftcms.com/commerce
Other
218 stars 170 forks source link

[4.x]: getShippingMethod on Order can't find custom shipping options added by module #2986

Closed hatyi closed 1 year ago

hatyi commented 1 year ago

What happened?

Description

Hello, I'm reaching out because I'm trying upgrade a Craft CMS + Commerce website from 3 to 4. Specifically from Craft 3.5.19.1 and Commerce 3.2.7 to the latest available releases.

We have many custom shipping options added by our module using the EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS event.

Everything works as expected until the point where I'm trying to save the selected custom shipping option on a cart using the standard commerce/cart/update-cart controller.

I can query these options in twig by cart.getAvailableShippingMethodOptions(), and I can also see the selected option with the other available options in CP (if I check the cart, it shows up as a select input on the right panel with the "Shipping Method" label), the shippingMethodHandle attribute is also getting saved properly containing the selected options' handle.

But it looks like that for some reason the cart itself can not resolve the shipping option by this saved handle. So it's actually not added to the cart, neither increases it's price.

Screenshot from 2022-10-04 16-51-52

I was trying to trace back the error in the craftcms/commerce/src/elements/Order.php class and I've found that inside the getShippingMethod() method the Plugin::getInstance()->getShippingMethods() call returns an empty array and it looks like it runs before our module registers our custom shipping options.

This is just absolutely a hunch from my side, maybe I'm doing something wrong here but since I couldn't find any related open issue or conversation about this I thought it's worth creating one to ask for help, report this.

Steps to reproduce

1.Try adding custom shipping options by a module, select it and save it to a cart.

Expected behavior

It's added to the cart, works as it would in Craft Commerce 3.

Actual behavior

Please see description.

Craft CMS version

4.2.2

Craft Commerce version

4.1.2

PHP version

8.1.9

Operating system and version

Ubuntu 20.04

Database type and version

MySQL 8.0.30

Image driver and version

No response

Installed plugins and versions

"ext-json": "*", "aelvan/mailchimp-subscribe": "^4.0.0", "algolia/algoliasearch-client-php": "^2.7", "clubstudioltd/craft-asset-rev": "^7.0", "craftcms/cms": "4.2.2", "craftcms/commerce": "4.1.2", "craftcms/commerce-omnipay": "^4.0", "craftcms/redactor": "^3.0.0", "dolphiq/redirect": "2.0.0", "league/omnipay": "^3", "mailchimp/marketing": "^3.0", "mmikkel/incognito-field": "1.3.0", "monolog/monolog": "^2.8", "nystudio107/craft-seomatic": "^4.0.0", "verbb/navigation": "^2.0.0", "verbb/super-table": "^3.0.0", "vlucas/phpdotenv": "^3.4.0"

nfourtythree commented 1 year ago

Hi @hatyi

Thank you for your message.

Are you able to share your custom shipping method class? If you would rather not post it publicly you can send it over to support@craftcms.com where we can pick it up and take a look.

Thanks!

hatyi commented 1 year ago

Hello @nfourtythree, thanks a lot for the quick response.

Sure no problem, we use a base class we are extending by many others:


abstract class BaseNavisionShippingMethod {

    private $cachedShippingPrices = [];
    private static $cartService;

    public $id;
    public $name;
    public $handle;
    public $enabled;
    public $isLite;
    public $price;
    public $dateCreated;
    public $dateUpdated;

    public function __construct() {
        $this->id = $this->getId();
        $this->name = $this->getName();
        $this->handle = $this->getHandle();
        $this->enabled = $this->getIsEnabled();
        $this->isLite = false;

        if(! self::$cartService ) {
            self::$cartService = new Carts();
        }

        $this->price = $this->getPriceForOrder( self::$cartService->getCart() );
    }

    public abstract function getHandle(): ?string;

    public abstract function getName(): ?string;

    public abstract function getType(): ?string;

    public abstract function getId(): ?int;

    /**
     * @param Order $order
     * @return float
     */
    public function getPriceForOrder(Order $order) : float {
        if (isset($this->cachedShippingPrices[$order->getId()])) {
            return $this->cachedShippingPrices[$order->getId()];
        }

        $shippingCost = 0;
        if(! is_null( $order->availableNavisionShippingMethods ) || !empty( $order->availableNavisionShippingMethods ) ) {
            $savedOptions = json_decode($order->availableNavisionShippingMethods);
            foreach ($savedOptions as $so) {
                if ($so->ShippingCode === $this->getHandle()) {
                    $shippingCost = (float) str_replace(',', '.', $so->PriceIncVat);
                    break;
                }
            }
        }

        $this->cachedShippingPrices[$order->getId()] = $shippingCost;
        return $shippingCost;
    }

    /**
     * Returns the control panel URL to manage this method and its rules.
     * An empty string will result in no link.
     *
     * @return string
     */
    public function getCpEditUrl(): string {
        return '';
    }

    /**
     * Returns an array of rules that meet the `ShippingRules` interface.
     *
     * @return ShippingRuleInterface[] The array of ShippingRules
     */
    public function getShippingRules() : array {
        $shippingRule = BaseNavisionShippingRule::getInstance();
        $shippingRule->setShippingOption($this);
        return [$shippingRule];
    }

    /**
     * Returns whether this shipping method is enabled for listing and selection by customers.
     *
     * @return bool
     */
    public function getIsEnabled(): bool {
        return true;
    }

    /**
     * The first matching shipping rule for this shipping method
     *
     * @param Order $order
     * @return null|ShippingRuleInterface
     */
    public function getMatchingShippingRule(Order $order) :? ShippingRuleInterface {
        $rules = $this->getShippingRules();
        return $rules[0] ?? null;
    }

    /**
     * Is this shipping method available to the order?
     *
     * @param Order $order
     * @return bool
     */
    public function matchOrder(Order $order): bool {
        if(! is_null( $order->availableNavisionShippingMethods ) || !empty( $order->availableNavisionShippingMethods ) ) {
            $savedOptions = json_decode($order->availableNavisionShippingMethods);
            foreach ($savedOptions as $so) {
                if ($so->ShippingCode === $this->getHandle()) {
                    return true;
                }
            }
        }
        return false;
    }
}

And this is an example of one we actually use:

class BusinessOvernightShippingOption extends BaseNavisionShippingMethod implements ShippingMethodInterface {
    /**
     * Returns the type of Shipping Method. This might be the name of the plugin or provider.
     * The core shipping methods have type: `Custom`. This is shown in the control panel only.
     *
     * @return string
     */
    public function getType(): string {
        return 'BNATT';
    }

    /**
     * Returns the ID of this Shipping Method, if it is managed by Craft Commerce.
     *
     * @return int|null The shipping method ID, or null if it is not managed by Craft Commerce
     */
    public function getId() :?int {
        return 11;
    }

    /**
     * Returns the name of this Shipping Method as displayed to the customer and in the control panel.
     *
     * @return string
     */
    public function getName(): string {
        return 'Bedrift over natten';
    }

    /**
     * Returns the unique handle of this Shipping Method.
     *
     * @return string
     */
    public function getHandle(): string {
        return 'BNATT';
    }

}
nfourtythree commented 1 year ago

Hi @hatyi

Thank you for raising the issue.

We have pushed fixes for both 3.x and 4.x to address the problem. These fixes will be included in the upcoming releases.

To get this early, change your craftcms/commerce requirement in your project's composer.json to:

"require": {
  "craftcms/commerce": "dev-develop#a2b6413b41abd56cf61a6d8dc578488f2bbf61fc as 4.1.2",
  "...": "..."
}

Then run composer update.

Thanks!