From 2250f9984479bad7ad5a592c6802ba7a5aff9ceb Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Tue, 31 Dec 2024 13:50:59 +0800 Subject: [PATCH 01/16] WIP --- .../dist/shop/products/_includes/grid.twig | 11 ++- .../src/shop/products/_includes/grid.twig | 11 ++- src/Plugin.php | 2 +- src/base/Purchasable.php | 98 +++++++++++++------ src/elements/Donation.php | 6 ++ .../ProductVariantStockConditionRule.php | 4 + src/elements/db/PurchasableQuery.php | 1 + .../PurchasableStockField.php | 11 +++ ...m241219_071723_add_inventory_backorder.php | 33 +++++++ ...ventory_for_non_inventory_purchasables.php | 46 +++++++++ src/records/PurchasableStore.php | 1 + src/services/Inventory.php | 5 + src/translations/en/commerce.php | 1 + 13 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 src/migrations/m241219_071723_add_inventory_backorder.php create mode 100644 src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php diff --git a/example-templates/dist/shop/products/_includes/grid.twig b/example-templates/dist/shop/products/_includes/grid.twig index 58dab092f7..1e7e3c01e0 100644 --- a/example-templates/dist/shop/products/_includes/grid.twig +++ b/example-templates/dist/shop/products/_includes/grid.twig @@ -50,7 +50,16 @@ checked: loop.first, class: not variant.getIsAvailable() ? 'opacity-10' : '', disabled: not variant.availableForPurchase, - }) }}{{ variant.sku }} {% if variant.inventoryTracked %}({{ variant.stock ? variant.stock ~ ' available' : 'out of stock'}}){% endif %}{% if variant.onPromotion %} {{ variant.price|currency(cart.currency) }}{% endif %} {{ variant.salePrice|currency(cart.currency) }} + }) }} + {{ variant.sku }} + {% if variant.hasInventory and variant.inventoryTracked %} + ({{ variant.stock ? variant.stock ~ ' available' : 'out of stock'}}) + {% if variant.allowOutOfStockPurchases %} + {{ "Continue selling when out of stock."|t('commerce') }} + {% endif %} + {% endif %} + + {% if variant.onPromotion %} {{ variant.price|currency(cart.currency) }}{% endif %} {{ variant.salePrice|currency(cart.currency) }} {% endfor %} diff --git a/example-templates/src/shop/products/_includes/grid.twig b/example-templates/src/shop/products/_includes/grid.twig index de1db79777..8043b860ec 100644 --- a/example-templates/src/shop/products/_includes/grid.twig +++ b/example-templates/src/shop/products/_includes/grid.twig @@ -50,7 +50,16 @@ checked: loop.first, class: not variant.getIsAvailable() ? 'opacity-10' : '', disabled: not variant.availableForPurchase, - }) }}{{ variant.sku }} {% if variant.inventoryTracked %}({{ variant.stock ? variant.stock ~ ' available' : 'out of stock'}}){% endif %}{% if variant.onPromotion %} {{ variant.price|currency(cart.currency) }}{% endif %} {{ variant.salePrice|currency(cart.currency) }} + }) }} + {{ variant.sku }} + {% if variant.hasInventory and variant.inventoryTracked %} + ({{ variant.stock ? variant.stock ~ ' available' : 'out of stock'}}) + {% if variant.allowOutOfStockPurchases %} + {{ "Continue selling when out of stock."|t('commerce') }} + {% endif %} + {% endif %} + + {% if variant.onPromotion %} {{ variant.price|currency(cart.currency) }}{% endif %} {{ variant.salePrice|currency(cart.currency) }} {% endfor %} diff --git a/src/Plugin.php b/src/Plugin.php index 201e9b0bdb..aaf537392e 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -257,7 +257,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '5.2.7.1'; + public string $schemaVersion = '5.3.0.2'; /** * @inheritdoc diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 7ac8cf2de2..f88c155435 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -228,6 +228,14 @@ abstract class Purchasable extends Element implements PurchasableInterface, HasS */ public bool $inventoryTracked = false; + /** + * This is if the store to sell inventory items. + * + * @var bool + * @since 5.3.0 + */ + public bool $allowOutOfStockPurchases = false; + /** * This is the cached total available stock across all inventory locations. * @@ -254,6 +262,8 @@ public function attributes(): array $names[] = 'sku'; $names[] = 'stock'; $names[] = 'inventoryTracked'; + $names[] = 'allowOutOfStockPurchases'; + return $names; } @@ -443,9 +453,11 @@ public function getIsAvailable(): bool return false; } - // Is the inventory tracked and is there stock? - if ($this->inventoryTracked && $this->getStock() < 1) { - return false; + if ($this::hasInventory()) { + // Is the inventory tracked and is there stock? + if ($this->inventoryTracked && !$this->allowOutOfStockPurchases && $this->getStock() < 1) { + return false; + } } // Temporary SKU can not be added to the cart @@ -647,7 +659,7 @@ public function setSku(string $sku = null): void */ public function hasStock(): bool { - return !$this->inventoryTracked || $this->getStock() > 0; + return !$this->inventoryTracked || $this->allowOutOfStockPurchases || $this->getStock() > 0; } /** @@ -746,7 +758,7 @@ public function populateLineItem(LineItem $lineItem): void // Since we do not have a proper stock reservation system, we need deduct stock if they have more in the cart than is available, and to do this quietly. // If this occurs in the payment request, the user will be notified the order has changed. if (($order = $lineItem->getOrder()) && !$order->isCompleted) { - if ($this->inventoryTracked && ($lineItem->qty > $this->getStock())) { + if ($this::hasInventory() && !$this->allowOutOfStockPurchases && $this->inventoryTracked && ($lineItem->qty > $this->getStock())) { $message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock()]); /** @var OrderNotice $notice */ $notice = Craft::createObject([ @@ -809,14 +821,14 @@ function($attribute, $params, Validator $validator) use ($lineItem) { [ 'qty', function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQuantitiesById, $lineItemQuantitiesByPurchasableId) { - if (!$this->hasStock()) { + if (!$this->availableForPurchase && !$this->hasStock()) { $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItem->purchasable->getDescription()]); $validator->addError($lineItem, $attribute, $error); } $lineItemQty = $lineItem->id !== null ? $lineItemQuantitiesById[$lineItem->id] : $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId]; - if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { + if (!$this->availableForPurchase && $this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { $error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItem->purchasable->getDescription()]); $validator->addError($lineItem, $attribute, $error); } @@ -859,7 +871,7 @@ protected function defineRules(): array ], [['basePrice'], 'number'], [['basePromotionalPrice', 'minQty', 'maxQty'], 'number', 'skipOnEmpty' => true], - [['freeShipping', 'inventoryTracked', 'promotable', 'availableForPurchase'], 'boolean'], + [['freeShipping', 'inventoryTracked', 'allowOutOfStockPurchases', 'promotable', 'availableForPurchase'], 'boolean'], [['taxCategoryId', 'shippingCategoryId', 'price', 'promotionalPrice', 'productSlug', 'productTypeHandle'], 'safe'], ]); } @@ -922,7 +934,7 @@ public function getInventoryItem(): InventoryItem */ public function getHasUnlimitedStock(): bool { - return !$this->inventoryTracked; + return !$this::hasInventory() || !$this->inventoryTracked; } /** @@ -933,6 +945,14 @@ public function setHasUnlimitedStock($value): bool return $this->inventoryTracked = !$value; } + /** + * @return bool + */ + public function getInventoryUnlimited(): bool + { + return (!$this->inventoryTracked || ($this->inventoryTracked && $this->allowOutOfStockPurchases)); + } + /** * @return int */ @@ -1035,27 +1055,29 @@ public function afterSave(bool $isNew): void // Always create the inventory item even if it's a temporary draft (in the slide) since we want to allow stock to be // added to inventory before it is saved as a permanent variant. - if ($canonicalPurchasableId) { - if ($isOwnerDraftApplying && $this->duplicateOf !== null) { - /** @var InventoryItemRecord|null $inventoryItem */ - $inventoryItem = InventoryItemRecord::find()->where(['purchasableId' => $this->duplicateOf->id])->one(); - if ($inventoryItem) { - $inventoryItem->purchasableId = $canonicalPurchasableId; - $inventoryItem->save(); - $this->inventoryItemId = $inventoryItem->id; - } - } else { - // Set the inventory item data - /** @var InventoryItemRecord|null $inventoryItem */ - $inventoryItem = InventoryItemRecord::find()->where(['purchasableId' => $canonicalPurchasableId])->one(); - if (!$inventoryItem) { - $inventoryItem = new InventoryItemRecord(); - $inventoryItem->purchasableId = $canonicalPurchasableId; - $inventoryItem->countryCodeOfOrigin = ''; - $inventoryItem->administrativeAreaCodeOfOrigin = ''; - $inventoryItem->harmonizedSystemCode = ''; - $inventoryItem->save(); - $this->inventoryItemId = $inventoryItem->id; + if (static::hasInventory()) { + if ($canonicalPurchasableId) { + if ($isOwnerDraftApplying && $this->duplicateOf !== null) { + /** @var InventoryItemRecord|null $inventoryItem */ + $inventoryItem = InventoryItemRecord::find()->where(['purchasableId' => $this->duplicateOf->id])->one(); + if ($inventoryItem) { + $inventoryItem->purchasableId = $canonicalPurchasableId; + $inventoryItem->save(); + $this->inventoryItemId = $inventoryItem->id; + } + } else { + // Set the inventory item data + /** @var InventoryItemRecord|null $inventoryItem */ + $inventoryItem = InventoryItemRecord::find()->where(['purchasableId' => $canonicalPurchasableId])->one(); + if (!$inventoryItem) { + $inventoryItem = new InventoryItemRecord(); + $inventoryItem->purchasableId = $canonicalPurchasableId; + $inventoryItem->countryCodeOfOrigin = ''; + $inventoryItem->administrativeAreaCodeOfOrigin = ''; + $inventoryItem->harmonizedSystemCode = ''; + $inventoryItem->save(); + $this->inventoryItemId = $inventoryItem->id; + } } } } @@ -1076,6 +1098,7 @@ public function afterSave(bool $isNew): void $purchasableStoreRecord->basePromotionalPrice = null; $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); $purchasableStoreRecord->inventoryTracked = false; + $purchasableStoreRecord->allowOutOfStockPurchases = false; $purchasableStoreRecord->minQty = null; $purchasableStoreRecord->maxQty = null; $purchasableStoreRecord->promotable = false; @@ -1096,6 +1119,7 @@ public function afterSave(bool $isNew): void $purchasableStoreRecord->basePromotionalPrice = $purchasableStoreRecordDuplicate->basePromotionalPrice; $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); $purchasableStoreRecord->inventoryTracked = $purchasableStoreRecordDuplicate->inventoryTracked; + $purchasableStoreRecord->allowOutOfStockPurchases = $purchasableStoreRecordDuplicate->allowOutOfStockPurchases; $purchasableStoreRecord->minQty = $purchasableStoreRecordDuplicate->minQty; $purchasableStoreRecord->maxQty = $purchasableStoreRecordDuplicate->maxQty; $purchasableStoreRecord->promotable = $purchasableStoreRecordDuplicate->promotable; @@ -1111,7 +1135,8 @@ public function afterSave(bool $isNew): void $purchasableStoreRecord->basePrice = $this->basePrice; $purchasableStoreRecord->basePromotionalPrice = $this->basePromotionalPrice; $purchasableStoreRecord->stock = Plugin::getInstance()->getInventory()->getInventoryLevelsForPurchasable($this)->sum('availableTotal'); - $purchasableStoreRecord->inventoryTracked = $this->inventoryTracked; + $purchasableStoreRecord->inventoryTracked = $this::hasInventory() ? $this->inventoryTracked : false; + $purchasableStoreRecord->allowOutOfStockPurchases = $this->allowOutOfStockPurchases; $purchasableStoreRecord->minQty = $this->minQty; $purchasableStoreRecord->maxQty = $this->maxQty; $purchasableStoreRecord->promotable = $this->promotable; @@ -1317,7 +1342,7 @@ protected function attributeHtml(string $attribute): string 'height' => $this->height !== null ? Craft::$app->getFormattingLocale()->getFormatter()->asDecimal($this->$attribute) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', 'minQty' => (string)$this->minQty, 'maxQty' => (string)$this->maxQty, - 'stock' => $stock, + 'stock' => $this::hasInventory() ? $stock : '', 'dimensions' => !empty($dimensions) ? implode(' x ', $dimensions) . ' ' . Plugin::getInstance()->getSettings()->dimensionUnits : '', default => parent::attributeHtml($attribute), }; @@ -1356,6 +1381,15 @@ protected static function defineDefaultTableAttributes(string $source): array ]; } + /** + * @return bool + * @since 5.3.0 + */ + public static function hasInventory(): bool + { + return true; + } + /** * @inheritdoc */ diff --git a/src/elements/Donation.php b/src/elements/Donation.php index 20653b4a7b..55587e5f0c 100644 --- a/src/elements/Donation.php +++ b/src/elements/Donation.php @@ -37,6 +37,11 @@ class Donation extends Purchasable */ public bool $availableForPurchase = false; + public static function hasInventory(): bool + { + return false; + } + public function behaviors(): array { $behaviors = parent::behaviors(); @@ -271,6 +276,7 @@ public function afterSave(bool $isNew): void $purchasableStoreRecord->basePromotionalPrice = null; $purchasableStoreRecord->stock = null; $purchasableStoreRecord->inventoryTracked = false; + $purchasableStoreRecord->allowOutOfStockPurchases = false; $purchasableStoreRecord->minQty = null; $purchasableStoreRecord->maxQty = null; $purchasableStoreRecord->promotable = false; diff --git a/src/elements/conditions/products/ProductVariantStockConditionRule.php b/src/elements/conditions/products/ProductVariantStockConditionRule.php index a049c08b94..cf3de860b1 100644 --- a/src/elements/conditions/products/ProductVariantStockConditionRule.php +++ b/src/elements/conditions/products/ProductVariantStockConditionRule.php @@ -63,6 +63,10 @@ public function matchElement(ElementInterface $element): bool { /** @var Variant $variant */ foreach ($element->getVariants() as $variant) { + if (!$this::hasInventory()) { + return true; + } + if ($variant->inventoryTracked === true && $this->matchValue($variant->getStock())) { // Skip out early if we have a match return true; diff --git a/src/elements/db/PurchasableQuery.php b/src/elements/db/PurchasableQuery.php index bbda2206c6..e9223aff60 100755 --- a/src/elements/db/PurchasableQuery.php +++ b/src/elements/db/PurchasableQuery.php @@ -695,6 +695,7 @@ protected function beforePrepare(): bool 'purchasables_stores.maxQty', 'purchasables_stores.minQty', 'purchasables_stores.inventoryTracked', + 'purchasables_stores.allowOutOfStockPurchases', 'purchasables_stores.promotable', 'purchasables_stores.shippingCategoryId', 'subquery.price', diff --git a/src/fieldlayoutelements/PurchasableStockField.php b/src/fieldlayoutelements/PurchasableStockField.php index eeb28f0b8d..00f4640ef4 100644 --- a/src/fieldlayoutelements/PurchasableStockField.php +++ b/src/fieldlayoutelements/PurchasableStockField.php @@ -207,10 +207,21 @@ public function inputHtml(ElementInterface $element = null, bool $static = false 'disabled' => $static, ]; + $storeAllowOutOfStockPurchasesLightswitchConfig = [ + 'label' => Craft::t('commerce', 'Allow out of stock purchases'), + 'id' => 'store-backorder-allowed', + 'name' => 'allowOutOfStockPurchases', + 'small' => true, + 'on' => $element->allowOutOfStockPurchases, + 'disabled' => $static, + ]; + + return Html::beginTag('div') . Cp::lightswitchHtml($storeInventoryTrackedLightswitchConfig) . Html::beginTag('div', ['id' => $inventoryItemTrackedId, 'class' => 'hidden']) . $inventoryLevelsTable . + Cp::lightswitchFieldHtml($storeAllowOutOfStockPurchasesLightswitchConfig) . Html::endTag('div') . Html::endTag('div'); } diff --git a/src/migrations/m241219_071723_add_inventory_backorder.php b/src/migrations/m241219_071723_add_inventory_backorder.php new file mode 100644 index 0000000000..ed36f697e9 --- /dev/null +++ b/src/migrations/m241219_071723_add_inventory_backorder.php @@ -0,0 +1,33 @@ +db->columnExists(Table::PURCHASABLES_STORES, 'allowOutOfStockPurchases')) { + $this->addColumn(Table::PURCHASABLES_STORES, 'allowOutOfStockPurchases', $this->boolean()->after('inventoryTracked')->notNull()->defaultValue(false)); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m241219_071723_add_inventory_backorder cannot be reverted.\n"; + return false; + } +} diff --git a/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php b/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php new file mode 100644 index 0000000000..8926ae77ca --- /dev/null +++ b/src/migrations/m241220_082900_remove_inventory_for_non_inventory_purchasables.php @@ -0,0 +1,46 @@ +select(['items.id AS id', 'elements.type AS type']) + ->from(['items' => Table::INVENTORYITEMS]) + ->leftJoin(['elements' => CraftTable::ELEMENTS], '[[items.purchasableId]] = [[elements.id]]') + ->all(); + + // Only remove the donation inventory items that shouldn't be there, can do others later. + foreach ($purchasables as $purchasable) { + if (is_subclass_of($purchasable['type'], Donation::class)) { + if (!$purchasable['type']::hasInventory()) { // should always be false, but just in case + $this->delete(Table::INVENTORYITEMS, ['id' => $purchasable['id']]); + } + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m241220_082900_remove_inventory_for_non_inventory_purchasables cannot be reverted.\n"; + return false; + } +} diff --git a/src/records/PurchasableStore.php b/src/records/PurchasableStore.php index be51185f71..696d70fa25 100644 --- a/src/records/PurchasableStore.php +++ b/src/records/PurchasableStore.php @@ -22,6 +22,7 @@ * @property float|null $basePromotionalPrice * @property int|null $stock * @property bool $inventoryTracked + * @property bool $allowOutOfStockPurchases * @property int|null $minQty * @property int|null $maxQty * @property bool $promotable diff --git a/src/services/Inventory.php b/src/services/Inventory.php index 9150a2f392..7382dfd126 100644 --- a/src/services/Inventory.php +++ b/src/services/Inventory.php @@ -690,6 +690,11 @@ public function orderCompleteHandler(Order $order) $purchasable = $lineItem->getPurchasable(); // Don't reduce stock of unlimited items. + + if (!$purchasable::hasInventory()) { + continue; + } + if ($purchasable->inventoryTracked) { if (!isset($qtyLineItem[$purchasable->id])) { $qtyLineItem[$purchasable->id] = 0; diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 5c018323ad..da5b8f69be 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -181,6 +181,7 @@ 'Condition' => 'Condition', 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.' => 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.', 'Conditions' => 'Conditions', + 'Continue selling when out of stock.' => 'Continue selling when out of stock.', 'Control Panel Settings' => 'Control Panel Settings', 'Control panel' => 'Control panel', 'Conversion Rate' => 'Conversion Rate', From c534f9ab52632488be1e3cd12717b17302dfceb1 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Tue, 31 Dec 2024 15:18:49 +0800 Subject: [PATCH 02/16] Remove this from the available flag which is seperate from the stock check getOutOfStockPurchasesAllowed --- src/base/Purchasable.php | 15 +++---- ...rchasableAllowOutOfStockPurchasesEvent.php | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 src/events/PurchasableAllowOutOfStockPurchasesEvent.php diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index f88c155435..be4067edf9 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -453,13 +453,6 @@ public function getIsAvailable(): bool return false; } - if ($this::hasInventory()) { - // Is the inventory tracked and is there stock? - if ($this->inventoryTracked && !$this->allowOutOfStockPurchases && $this->getStock() < 1) { - return false; - } - } - // Temporary SKU can not be added to the cart if (PurchasableHelper::isTempSku($this->getSku())) { return false; @@ -968,6 +961,14 @@ private function _getStock(): int return $saleableAmount; } + /** + * @return bool + */ + public function getOutOfStockPurchasesAllowed(): bool + { + return $this->allowOutOfStockPurchases; + } + /** * Returns the cached total available stock across all inventory locations for this store. * diff --git a/src/events/PurchasableAllowOutOfStockPurchasesEvent.php b/src/events/PurchasableAllowOutOfStockPurchasesEvent.php new file mode 100644 index 0000000000..10ee3e5b2f --- /dev/null +++ b/src/events/PurchasableAllowOutOfStockPurchasesEvent.php @@ -0,0 +1,42 @@ + + * @since 5.3.0 + */ +class PurchasableAllowOutOfStockPurchasesEvent extends Event +{ + /** + * @var Order|null The order element. + */ + public ?Order $order = null; + + /** + * @var PurchasableInterface The purchasable element. + */ + public PurchasableInterface $purchasable; + + /** + * @var User|null The user performing the check. + */ + public ?User $currentUser = null; + + /** + * @var bool Is this purchasable available to be purchased when out of stock + */ + public bool $allowOutOfStockPurchases = false; +} From db4c5925cf35aab2146dcc26668b1ccd3b1a958f Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Tue, 31 Dec 2024 15:30:11 +0800 Subject: [PATCH 03/16] WIP --- CHANGELOG-WIP.md | 4 ++ src/base/Purchasable.php | 12 +---- src/elements/Donation.php | 15 ++++++- ...rchasableAllowOutOfStockPurchasesEvent.php | 2 +- src/services/Purchasables.php | 44 +++++++++++++++++++ 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index aa5c1d6e11..2f1590638f 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -18,13 +18,17 @@ ### Extensibility - Added `craft\commerce\base\InventoryItemTrait`. - Added `craft\commerce\base\InventoryLocationTrait`. +- Added `craft\commerce\elements\Purchasable::$allowOutOfStockPurchases`. +- Added `craft\commerce\elements\Purchasable::getIsOutOfStockPurchasesAllowed()`. - Added `craft\commerce\elements\conditions\orders\CouponCodeConditionRule`. - Added `craft\commerce\elements\conditions\variants\ProductConditionRule`. - Added `craft\commerce\elements\db\OrderQuery::$couponCode`. - Added `craft\commerce\elements\db\OrderQuery::couponCode()`. - Added `craft\commerce\events\CartPurgeEvent`. +- Added `craft\commerce\events\PurchasableAllowOutOfStockPurchasesEvent`. - Added `craft\commerce\services\Inventory::updateInventoryLevel()`. - Added `craft\commerce\services\Inventory::updatePurchasableInventoryLevel()`. +- Added `craft\commerce\services\Purchasable::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES`. ### System - Craft Commerce now requires Craft CMS 5.5 or later. diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index be4067edf9..050a53b54a 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -652,7 +652,7 @@ public function setSku(string $sku = null): void */ public function hasStock(): bool { - return !$this->inventoryTracked || $this->allowOutOfStockPurchases || $this->getStock() > 0; + return $this->inventoryTracked && $this->getStock() > 0; } /** @@ -938,14 +938,6 @@ public function setHasUnlimitedStock($value): bool return $this->inventoryTracked = !$value; } - /** - * @return bool - */ - public function getInventoryUnlimited(): bool - { - return (!$this->inventoryTracked || ($this->inventoryTracked && $this->allowOutOfStockPurchases)); - } - /** * @return int */ @@ -964,7 +956,7 @@ private function _getStock(): int /** * @return bool */ - public function getOutOfStockPurchasesAllowed(): bool + public function getIsOutOfStockPurchasesAllowed(): bool { return $this->allowOutOfStockPurchases; } diff --git a/src/elements/Donation.php b/src/elements/Donation.php index 55587e5f0c..e3e9a46193 100644 --- a/src/elements/Donation.php +++ b/src/elements/Donation.php @@ -32,16 +32,26 @@ */ class Donation extends Purchasable { + /** - * @var bool Is the product available for purchase. + * By default the donation is not available for purchase. + * + * @inerhitdoc */ public bool $availableForPurchase = false; + + /** + * @inheritdoc + */ public static function hasInventory(): bool { return false; } + /** + * @inheritdoc + */ public function behaviors(): array { $behaviors = parent::behaviors(); @@ -55,6 +65,9 @@ public function behaviors(): array return $behaviors; } + /** + * @inheritdoc + */ protected function defineRules(): array { $rules = parent::defineRules(); diff --git a/src/events/PurchasableAllowOutOfStockPurchasesEvent.php b/src/events/PurchasableAllowOutOfStockPurchasesEvent.php index 10ee3e5b2f..837fcfa8ad 100644 --- a/src/events/PurchasableAllowOutOfStockPurchasesEvent.php +++ b/src/events/PurchasableAllowOutOfStockPurchasesEvent.php @@ -38,5 +38,5 @@ class PurchasableAllowOutOfStockPurchasesEvent extends Event /** * @var bool Is this purchasable available to be purchased when out of stock */ - public bool $allowOutOfStockPurchases = false; + public bool $outOfStockPurchasesAllowed = false; } diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index 94c64b17e1..345dcd0375 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -14,6 +14,7 @@ use craft\commerce\elements\db\PurchasableQuery; use craft\commerce\elements\Order; use craft\commerce\elements\Variant; +use craft\commerce\events\PurchasableAllowOutOfStockPurchasesEvent; use craft\commerce\events\PurchasableAvailableEvent; use craft\commerce\events\PurchasableShippableEvent; use craft\commerce\Plugin; @@ -35,6 +36,31 @@ */ class Purchasables extends Component { + /** + * @event PurchasableAllowOutOfStockPurchasesEvent The event that is triggered when checking if the purchasable can be purchased when out of stock. + * + * This example allows users of a certain group to purchase out of stock items. + * + * ```php + * use craft\commerce\events\PurchasableAvailableEvent; + * use craft\commerce\services\Purchasables; + * use yii\base\Event; + * + * Event::on( + * Purchasables::class, + * Purchasables::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES, + * function(PurchasableAllowOutOfStockPurchasesEvent $event) { + * if($order && $user = $order->getUser()){ + * if($user->isInGroup(1)){ + * $event->outOfStockPurchasesAllowed = true; + * } + * } + * } + * ); + * ``` + */ + public const EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES = 'allowOutOfStockPurchases'; + /** * @event PurchasableAvailableEvent The event that is triggered when the availability of a purchasables is checked. * @@ -109,6 +135,24 @@ class Purchasables extends Component */ private ?Collection $_purchasableById = null; + + public function canPurchaseOutOfStock(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool + { + if ($currentUser === null) { + $currentUser = Craft::$app->getUser()->getIdentity(); + } + + $outOfStockPurchasesAllowed = $purchasable->getIsOutOfStockPurchasesAllowed(); + + $event = new PurchasableAllowOutOfStockPurchasesEvent(compact('order', 'purchasable', 'currentUser', 'outOfStockPurchasesAllowed')); + + if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES)) { + $this->trigger(self::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES, $event); + } + + return $event->outOfStockPurchasesAllowed; + } + /** * @param Order|null $order * @param User|null $currentUser From ec4aa8bdf515f35d99388ed7f925a2caf5892675 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Tue, 31 Dec 2024 15:30:59 +0800 Subject: [PATCH 04/16] Leave as is --- src/base/Purchasable.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 050a53b54a..c81cdf2db2 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -652,9 +652,8 @@ public function setSku(string $sku = null): void */ public function hasStock(): bool { - return $this->inventoryTracked && $this->getStock() > 0; + return !$this->inventoryTracked || $this->getStock() > 0; } - /** * @param int|null $taxCategoryId * @return void From e44f839b1fea2448a268f55e1850d2dbe28c93e5 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 13:05:46 +0800 Subject: [PATCH 05/16] Cleanup --- src/base/Purchasable.php | 24 +++++++++++++++++------- src/elements/Donation.php | 1 - src/services/Purchasables.php | 2 +- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index c81cdf2db2..aad743787c 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -12,6 +12,7 @@ use craft\base\NestedElementInterface; use craft\commerce\db\Table; use craft\commerce\elements\Order; +use craft\commerce\enums\LineItemType; use craft\commerce\errors\StoreNotFoundException; use craft\commerce\helpers\Currency; use craft\commerce\helpers\Localization; @@ -654,6 +655,7 @@ public function hasStock(): bool { return !$this->inventoryTracked || $this->getStock() > 0; } + /** * @param int|null $taxCategoryId * @return void @@ -805,7 +807,7 @@ function($attribute, $params, Validator $validator) use ($lineItem) { $validator->addError($lineItem, $attribute, Craft::t('commerce', 'No purchasable available.')); } - if (!$purchasable->getIsAvailable()) { + if (!Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($lineItem->getPurchasable(), $lineItem->getOrder())) { $validator->addError($lineItem, $attribute, Craft::t('commerce', 'The item is not enabled for sale.')); } }, @@ -813,16 +815,24 @@ function($attribute, $params, Validator $validator) use ($lineItem) { [ 'qty', function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQuantitiesById, $lineItemQuantitiesByPurchasableId) { - if (!$this->availableForPurchase && !$this->hasStock()) { - $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItem->purchasable->getDescription()]); - $validator->addError($lineItem, $attribute, $error); + if ($lineItem->type == LineItemType::Custom) { + return; + } + + if (!$this->hasStock()) { + if (!Plugin::getInstance()->getPurchasables()->isOutOfStockPurchasesAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { + $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItem->purchasable->getDescription()]); + $validator->addError($lineItem, $attribute, $error); + } } $lineItemQty = $lineItem->id !== null ? $lineItemQuantitiesById[$lineItem->id] : $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId]; - if (!$this->availableForPurchase && $this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { - $error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItem->purchasable->getDescription()]); - $validator->addError($lineItem, $attribute, $error); + if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { + if (!Plugin::getInstance()->getPurchasables()->isOutOfStockPurchasesAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { + $error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItem->purchasable->getDescription()]); + $validator->addError($lineItem, $attribute, $error); + } } if ($this->minQty > 1 && $lineItemQty < $this->minQty) { diff --git a/src/elements/Donation.php b/src/elements/Donation.php index e3e9a46193..f625933c1a 100644 --- a/src/elements/Donation.php +++ b/src/elements/Donation.php @@ -32,7 +32,6 @@ */ class Donation extends Purchasable { - /** * By default the donation is not available for purchase. * diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index 345dcd0375..f2f917e247 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -136,7 +136,7 @@ class Purchasables extends Component private ?Collection $_purchasableById = null; - public function canPurchaseOutOfStock(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool + public function isOutOfStockPurchasesAllowed(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool { if ($currentUser === null) { $currentUser = Craft::$app->getUser()->getIdentity(); From 38f291cbc8b685bc759ffd2db59769e24482cb59 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 13:13:27 +0800 Subject: [PATCH 06/16] fixes --- CHANGELOG-WIP.md | 3 ++- .../conditions/products/ProductVariantStockConditionRule.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 2f1590638f..973ff9049d 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -28,7 +28,8 @@ - Added `craft\commerce\events\PurchasableAllowOutOfStockPurchasesEvent`. - Added `craft\commerce\services\Inventory::updateInventoryLevel()`. - Added `craft\commerce\services\Inventory::updatePurchasableInventoryLevel()`. -- Added `craft\commerce\services\Purchasable::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES`. +- Added `craft\commerce\services\Purchasables::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES`. +- Added `craft\commerce\services\Purchasables::isOutOfStockPurchasesAllowed()`. ### System - Craft Commerce now requires Craft CMS 5.5 or later. diff --git a/src/elements/conditions/products/ProductVariantStockConditionRule.php b/src/elements/conditions/products/ProductVariantStockConditionRule.php index cf3de860b1..0cb9bd5183 100644 --- a/src/elements/conditions/products/ProductVariantStockConditionRule.php +++ b/src/elements/conditions/products/ProductVariantStockConditionRule.php @@ -63,7 +63,7 @@ public function matchElement(ElementInterface $element): bool { /** @var Variant $variant */ foreach ($element->getVariants() as $variant) { - if (!$this::hasInventory()) { + if (!$variant::hasInventory()) { return true; } From bb77ea4567a007678693f23aece2dd5b495b9107 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 13:16:51 +0800 Subject: [PATCH 07/16] Clearer for debugging --- src/services/ShippingCategories.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/ShippingCategories.php b/src/services/ShippingCategories.php index 4931c23f78..5c8a553cb0 100644 --- a/src/services/ShippingCategories.php +++ b/src/services/ShippingCategories.php @@ -108,7 +108,9 @@ public function getAllShippingCategoriesAsList(?int $storeId = null): array */ public function getShippingCategoryById(int $shippingCategoryId, ?int $storeId = null): ?ShippingCategory { - return $this->getAllShippingCategories($storeId)->firstWhere('id', $shippingCategoryId); + $shippingCategories = $this->getAllShippingCategories($storeId); + $first = $shippingCategories->firstWhere('id', $shippingCategoryId); + return $first; } /** From 57e5fcadfbb65c4d85463d3dd23fc0cc0de0457b Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 13:19:53 +0800 Subject: [PATCH 08/16] Use teller in tax adjuster --- src/adjusters/Tax.php | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php index 70c95e54b4..a2648fe913 100644 --- a/src/adjusters/Tax.php +++ b/src/adjusters/Tax.php @@ -22,6 +22,7 @@ use DvK\Vat\Validator; use Exception; use Illuminate\Support\Collection; +use Money\Teller; use yii\base\InvalidConfigException; use function in_array; @@ -146,9 +147,9 @@ private function _getAdjustments(TaxRate $taxRate): array $amount = -$this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { - $this->_costRemovedForOrderTotalPrice += $amount; + $this->_costRemovedForOrderTotalPrice = $this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $this->_costRemovedForOrderShipping += $amount; + $this->_costRemovedForOrderShipping = $this->_getTeller()->add($this->_costRemovedForOrderShipping, $amount); } $adjustment = $this->_createAdjustment($taxRate); @@ -298,17 +299,16 @@ protected function getTaxRates(?int $storeId = null): Collection */ private function _getTaxAmount($taxableAmount, $rate, $included): float { + $teller = $this->_getTeller(); if (!$included) { - $incTax = $taxableAmount * (1 + $rate); - $incTax = Currency::round($incTax); - $tax = $incTax - $taxableAmount; + $incTax = $teller->multiply($taxableAmount, (1 + $rate)); + $tax = $teller->subtract($incTax, $taxableAmount); } else { - $exTax = $taxableAmount / (1 + $rate); - $exTax = Currency::round($exTax); - $tax = $taxableAmount - $exTax; + $exTax = $teller->divide($taxableAmount, (1 + $rate)); + $tax = $teller->subtract($taxableAmount, $exTax); } - return $tax; + return (float)$tax; } /** @@ -430,4 +430,14 @@ private function _getTaxAddress(): ?Address return $address; } + + /** + * @return Teller + * @throws InvalidConfigException + * @since 5.3.0 + */ + private function _getTeller(): Teller + { + return Plugin::getInstance()->getCurrencies()->getTeller($this->_order->currency); + } } From c917748eb81c017f05cf6c7dd4a8a2c3febe1a86 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 15:24:07 +0800 Subject: [PATCH 09/16] WIP --- CHANGELOG-WIP.md | 4 ++ src/adjusters/Tax.php | 92 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 9de90153c7..5b9b410e9b 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,5 +1,9 @@ # Release Notes for Craft Commerce (WIP) +### Fixed + +- Fixed a PHP error that could occur when calculating tax totals. ([#3822](https://github.com/craftcms/commerce/issues/3822)) + ### Store Management - Order conditions can now have a “Coupon Code” rule. ([#3776](https://github.com/craftcms/commerce/discussions/3776)) - Order conditions can now have a “Payment Gateway” rule. ([#3722](https://github.com/craftcms/commerce/discussions/3722)) diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php index a2648fe913..49ee1c5360 100644 --- a/src/adjusters/Tax.php +++ b/src/adjusters/Tax.php @@ -79,12 +79,49 @@ class Tax extends Component implements AdjusterInterface private float $_costRemovedForOrderShipping = 0; /** - * Track the additional discounts created inside the tax adjuster for order total price + * Track the additional discounts created inside the tax adjuster for order shipping + * This should not be modified directly, use _addAmountRemovedForOrderShipping() instead * * @var float + * @see _addAmountRemovedForOrderTotalPrice() */ private float $_costRemovedForOrderTotalPrice = 0; + /** + * The way to internally interact with the _costRemovedForOrderShipping property + * + * @param float $amount + * @return void + * @throws Exception + */ + private function _addAmountRemovedForOrderShipping(float $amount): void + { + if($amount < 0) + { + throw new Exception('Amount added to the total removed shipping must be a positive number'); + } + + $this->_costRemovedForOrderShipping += $amount; + } + + + /** + * The way to interact with the _costRemovedForOrderTotalPrice property + * + * @param float $amount + * @return void + * @throws Exception + */ + private function _addAmountRemovedForOrderTotalPrice(float $amount): void + { + if($amount < 0) + { + throw new Exception('Amount added to the total removed price must be a positive number'); + } + + $this->_costRemovedForOrderTotalPrice += $amount; + } + /** * @inheritdoc */ @@ -123,6 +160,7 @@ private function _getAdjustments(TaxRate $taxRate): array { $adjustments = []; $hasValidEuVatId = false; + $teller = $this->_getTeller(); $zoneMatches = $taxRate->getIsEverywhere() || ($taxRate->getTaxZone() && $this->_matchAddress($taxRate->getTaxZone())); @@ -134,7 +172,7 @@ private function _getAdjustments(TaxRate $taxRate): array $removeDueToVat = ($zoneMatches && $hasValidEuVatId && $taxRate->removeVatIncluded); if ($removeIncluded || $removeDueToVat) { - // Is this an order level tax rate? + // Remove included tax for order level taxable. if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { $orderTaxableAmount = 0; @@ -144,41 +182,68 @@ private function _getAdjustments(TaxRate $taxRate): array $orderTaxableAmount = $this->_order->getTotalShippingCost(); } - $amount = -$this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); + $orderLevelAmountToBeRemovedByDiscount = $this->_getTaxAmount($orderTaxableAmount, $taxRate->rate, $taxRate->include); if ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE) { - $this->_costRemovedForOrderTotalPrice = $this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); + $this->_addAmountRemovedForOrderTotalPrice($orderLevelAmountToBeRemovedByDiscount); } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $this->_costRemovedForOrderShipping = $this->_getTeller()->add($this->_costRemovedForOrderShipping, $amount); + $this->_addAmountRemovedForOrderShipping($orderLevelAmountToBeRemovedByDiscount); } $adjustment = $this->_createAdjustment($taxRate); // We need to display the adjustment that removed the included tax $adjustment->name = Craft::t('site', $taxRate->name) . ' ' . Craft::t('commerce', 'Removed'); - $adjustment->amount = $amount; + $adjustment->amount = -$orderLevelAmountToBeRemovedByDiscount; $adjustment->type = 'discount'; // TODO Not use a discount adjustment, but modify the price of the item instead. #COM-26 $adjustment->included = false; $adjustments[] = $adjustment; } + // Not an order level taxable, add tax adjustments to the line items. if (!in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { // Not an order level taxable, add tax adjustments to the line items. foreach ($this->_order->getLineItems() as $item) { if ($item->taxCategoryId == $taxRate->taxCategoryId) { if ($taxRate->taxable == TaxRateRecord::TAXABLE_PURCHASABLE) { - $taxableAmount = $item->salePrice - Currency::round($item->getDiscount() / $item->qty); - $amount = -($taxableAmount - ($taxableAmount / (1 + $taxRate->rate))); - $amount = $amount * $item->qty; + // taxableAmount = salePrice - (discount / qty) + $taxableAmount = $teller->subtract( + $item->salePrice, + $teller->divide( + $item->getDiscount(), // float amount of discount + $item->qty + ) + ); + + // amount = taxableAmount - (taxableAmount / (1 + taxRate)) + $amount = $teller->subtract( + $taxableAmount, + $teller->divide( + $taxableAmount, + (1 + $taxRate->rate) + ) + ); + + // make amount negative + $amount = (float)$teller->multiply($amount, $item->qty); } else { $taxableAmount = $item->getTaxableSubtotal($taxRate->taxable); - $amount = -($taxableAmount - ($taxableAmount / (1 + $taxRate->rate))); + // amount = taxableAmount - (taxableAmount / (1 + taxRate)) + $amount = $teller->subtract( + $taxableAmount, + $teller->divide( + $taxableAmount, + (1 + $taxRate->rate) + ) + ); + + // make amount negative + $amount = (float)$amount; } - $amount = Currency::round($amount); $adjustment = $this->_createAdjustment($taxRate); // We need to display the adjustment that removed the included tax $adjustment->name = Craft::t('site', $taxRate->name) . ' ' . Craft::t('commerce', 'Removed'); - $adjustment->amount = $amount; + $adjustment->amount = -$amount; $adjustment->setLineItem($item); $adjustment->type = 'discount'; $adjustment->included = false; @@ -195,6 +260,7 @@ private function _getAdjustments(TaxRate $taxRate): array } } } + // Return the removed included taxes as discounts. return $adjustments; } @@ -301,7 +367,7 @@ private function _getTaxAmount($taxableAmount, $rate, $included): float { $teller = $this->_getTeller(); if (!$included) { - $incTax = $teller->multiply($taxableAmount, (1 + $rate)); + $incTax = $teller->multiply($taxableAmount, (1 + $rate)); $tax = $teller->subtract($incTax, $taxableAmount); } else { $exTax = $teller->divide($taxableAmount, (1 + $rate)); From bb9089520b7795eca448cf963c64abcca0fdd00b Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Jan 2025 07:47:23 +0000 Subject: [PATCH 10/16] Small tidy --- src/base/Purchasable.php | 3 ++- src/services/Purchasables.php | 8 ++++++++ src/translations/en/commerce.php | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index aad743787c..4c6bc66f32 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -230,7 +230,7 @@ abstract class Purchasable extends Element implements PurchasableInterface, HasS public bool $inventoryTracked = false; /** - * This is if the store to sell inventory items. + * Should this purchases of this purchasable be allowed if it is out of stock. * * @var bool * @since 5.3.0 @@ -964,6 +964,7 @@ private function _getStock(): int /** * @return bool + * @since 5.3.0 */ public function getIsOutOfStockPurchasesAllowed(): bool { diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index f2f917e247..459d4111c5 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -136,6 +136,14 @@ class Purchasables extends Component private ?Collection $_purchasableById = null; + /** + * @param PurchasableInterface $purchasable + * @param Order|null $order + * @param User|null $currentUser + * @return bool + * @throws Throwable + * @since 5.3.0 + */ public function isOutOfStockPurchasesAllowed(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool { if ($currentUser === null) { diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index da5b8f69be..4efbd8d387 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -56,6 +56,7 @@ 'Allow Checkout Without Payment' => 'Allow Checkout Without Payment', 'Allow Empty Cart On Checkout' => 'Allow Empty Cart On Checkout', 'Allow Partial Payment On Checkout' => 'Allow Partial Payment On Checkout', + 'Allow out of stock purchases' => 'Allow out of stock purchases', 'Allow' => 'Allow', 'Allowed Qty' => 'Allowed Qty', 'Alternative Phone' => 'Alternative Phone', @@ -181,7 +182,6 @@ 'Condition' => 'Condition', 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.' => 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.', 'Conditions' => 'Conditions', - 'Continue selling when out of stock.' => 'Continue selling when out of stock.', 'Control Panel Settings' => 'Control Panel Settings', 'Control panel' => 'Control panel', 'Conversion Rate' => 'Conversion Rate', From 42931fec5dce5f9f5c00d3db3d1655e7e1c7f9d8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 3 Jan 2025 07:53:34 +0000 Subject: [PATCH 11/16] Add missing install column --- src/migrations/Install.php | 1 + src/models/PurchasableStore.php | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 756520a8ec..027f2ecb2c 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -701,6 +701,7 @@ public function createTables(): void 'availableForPurchase' => $this->boolean()->notNull()->defaultValue(true), 'freeShipping' => $this->boolean()->notNull()->defaultValue(true), 'inventoryTracked' => $this->boolean()->notNull()->defaultValue(true), + 'allowOutOfStockPurchases' => $this->boolean()->notNull()->defaultValue(true), 'stock' => $this->integer(), // This is a summary value used for searching and sorting 'tracked' => $this->boolean()->notNull()->defaultValue(false), 'minQty' => $this->integer(), diff --git a/src/models/PurchasableStore.php b/src/models/PurchasableStore.php index bfd81b6a77..4fa267b968 100644 --- a/src/models/PurchasableStore.php +++ b/src/models/PurchasableStore.php @@ -72,6 +72,12 @@ class PurchasableStore extends Model */ public bool $availableForPurchase = false; + /** + * @var bool + * @since 5.3.0 + */ + public bool $allowOutOfStockPurchases = false; + /** * @var bool */ @@ -91,7 +97,7 @@ protected function defineRules(): array $rules[] = [['purchasableId', 'storeId'], 'required']; $rules[] = [['purchasableId', 'storeId', 'stock', 'minQty', 'maxQty'], 'integer']; $rules[] = [['basePrice', 'basePromotionalPrice'], 'number']; - $rules[] = [['hasUnlimitedStock', 'promotable', 'availableForPurchase', 'freeShipping'], 'boolean']; + $rules[] = [['hasUnlimitedStock', 'promotable', 'availableForPurchase', 'freeShipping', 'allowOutOfStockPurchases'], 'boolean']; $rules[] = [['shippingCategoryId'], 'safe']; return $rules; From 6ab43d62979b1cfb7934f2ef5112f3440835ff6d Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 15:45:08 +0800 Subject: [PATCH 12/16] fix install migration --- src/migrations/Install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 027f2ecb2c..11042e331d 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -701,7 +701,7 @@ public function createTables(): void 'availableForPurchase' => $this->boolean()->notNull()->defaultValue(true), 'freeShipping' => $this->boolean()->notNull()->defaultValue(true), 'inventoryTracked' => $this->boolean()->notNull()->defaultValue(true), - 'allowOutOfStockPurchases' => $this->boolean()->notNull()->defaultValue(true), + 'allowOutOfStockPurchases' => $this->boolean()->notNull()->defaultValue(false), 'stock' => $this->integer(), // This is a summary value used for searching and sorting 'tracked' => $this->boolean()->notNull()->defaultValue(false), 'minQty' => $this->integer(), From fef9ec01f429877ed1ed915abd5a382665b062bc Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 18:08:30 +0800 Subject: [PATCH 13/16] rename things --- CHANGELOG-WIP.md | 8 +++--- src/base/Purchasable.php | 27 ++++++++++++++++--- ...asableOutOfStockPurchasesAllowedEvent.php} | 4 +-- .../PurchasableStockField.php | 2 +- src/services/Purchasables.php | 14 +++++----- 5 files changed, 37 insertions(+), 18 deletions(-) rename src/events/{PurchasableAllowOutOfStockPurchasesEvent.php => PurchasableOutOfStockPurchasesAllowedEvent.php} (88%) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 973ff9049d..3531510003 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -19,17 +19,17 @@ - Added `craft\commerce\base\InventoryItemTrait`. - Added `craft\commerce\base\InventoryLocationTrait`. - Added `craft\commerce\elements\Purchasable::$allowOutOfStockPurchases`. -- Added `craft\commerce\elements\Purchasable::getIsOutOfStockPurchasesAllowed()`. +- Added `craft\commerce\elements\Purchasable::getIsOutOfStockPurchasingAllowed()`. - Added `craft\commerce\elements\conditions\orders\CouponCodeConditionRule`. - Added `craft\commerce\elements\conditions\variants\ProductConditionRule`. - Added `craft\commerce\elements\db\OrderQuery::$couponCode`. - Added `craft\commerce\elements\db\OrderQuery::couponCode()`. - Added `craft\commerce\events\CartPurgeEvent`. -- Added `craft\commerce\events\PurchasableAllowOutOfStockPurchasesEvent`. +- Added `craft\commerce\events\PurchasableOutOfStockPurchasesAllowedEvent`. - Added `craft\commerce\services\Inventory::updateInventoryLevel()`. - Added `craft\commerce\services\Inventory::updatePurchasableInventoryLevel()`. -- Added `craft\commerce\services\Purchasables::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES`. -- Added `craft\commerce\services\Purchasables::isOutOfStockPurchasesAllowed()`. +- Added `craft\commerce\services\Purchasables::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED`. +- Added `craft\commerce\services\Purchasables::isPurchasableOutOfStockPurchasingAllowed()`. ### System - Craft Commerce now requires Craft CMS 5.5 or later. diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 4c6bc66f32..2539fbd90e 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -437,6 +437,15 @@ public function getStoreId(): int return $this->getStore()->id; } + /** + * @return bool + * @throws InvalidConfigException + */ + public function getIsAvailableForPurchase(): bool + { + return Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($this); + } + /** * @inheritdoc * @throws InvalidConfigException @@ -459,6 +468,12 @@ public function getIsAvailable(): bool return false; } + if (static::hasInventory() && $this->inventoryTracked && $this->getStock() < 1) { + if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this)) { + return false; + } + } + return true; } @@ -752,7 +767,11 @@ public function populateLineItem(LineItem $lineItem): void // Since we do not have a proper stock reservation system, we need deduct stock if they have more in the cart than is available, and to do this quietly. // If this occurs in the payment request, the user will be notified the order has changed. if (($order = $lineItem->getOrder()) && !$order->isCompleted) { - if ($this::hasInventory() && !$this->allowOutOfStockPurchases && $this->inventoryTracked && ($lineItem->qty > $this->getStock())) { + if ($this::hasInventory() && + !$this->getIsOutOfStockPurchasingAllowed() && + $this->inventoryTracked && + ($lineItem->qty > $this->getStock()) + ) { $message = Craft::t('commerce', '{description} only has {stock} in stock.', ['description' => $lineItem->getDescription(), 'stock' => $this->getStock()]); /** @var OrderNotice $notice */ $notice = Craft::createObject([ @@ -820,7 +839,7 @@ function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQua } if (!$this->hasStock()) { - if (!Plugin::getInstance()->getPurchasables()->isOutOfStockPurchasesAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { + if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItem->purchasable->getDescription()]); $validator->addError($lineItem, $attribute, $error); } @@ -829,7 +848,7 @@ function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQua $lineItemQty = $lineItem->id !== null ? $lineItemQuantitiesById[$lineItem->id] : $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId]; if ($this->hasStock() && $this->inventoryTracked && $lineItemQty > $this->getStock()) { - if (!Plugin::getInstance()->getPurchasables()->isOutOfStockPurchasesAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { + if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($lineItem->getPurchasable(), $lineItem->getOrder())) { $error = Craft::t('commerce', 'There are only {num} “{description}” items left in stock.', ['num' => $this->getStock(), 'description' => $lineItem->purchasable->getDescription()]); $validator->addError($lineItem, $attribute, $error); } @@ -966,7 +985,7 @@ private function _getStock(): int * @return bool * @since 5.3.0 */ - public function getIsOutOfStockPurchasesAllowed(): bool + public function getIsOutOfStockPurchasingAllowed(): bool { return $this->allowOutOfStockPurchases; } diff --git a/src/events/PurchasableAllowOutOfStockPurchasesEvent.php b/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php similarity index 88% rename from src/events/PurchasableAllowOutOfStockPurchasesEvent.php rename to src/events/PurchasableOutOfStockPurchasesAllowedEvent.php index 837fcfa8ad..5b24e97113 100644 --- a/src/events/PurchasableAllowOutOfStockPurchasesEvent.php +++ b/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php @@ -13,12 +13,12 @@ use yii\base\Event; /** - * Class PurchasableAllowOutOfStockPurchasesEvent + * Class PurchasableOutOfStockPurchasesAllowedEvent * * @author Pixel & Tonic, Inc. * @since 5.3.0 */ -class PurchasableAllowOutOfStockPurchasesEvent extends Event +class PurchasableOutOfStockPurchasesAllowedEvent extends Event { /** * @var Order|null The order element. diff --git a/src/fieldlayoutelements/PurchasableStockField.php b/src/fieldlayoutelements/PurchasableStockField.php index 00f4640ef4..b9e7f8d369 100644 --- a/src/fieldlayoutelements/PurchasableStockField.php +++ b/src/fieldlayoutelements/PurchasableStockField.php @@ -212,7 +212,7 @@ public function inputHtml(ElementInterface $element = null, bool $static = false 'id' => 'store-backorder-allowed', 'name' => 'allowOutOfStockPurchases', 'small' => true, - 'on' => $element->allowOutOfStockPurchases, + 'on' => $element->getIsOutOfStockPurchasingAllowed(), 'disabled' => $static, ]; diff --git a/src/services/Purchasables.php b/src/services/Purchasables.php index 459d4111c5..ea4fc21f58 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -14,8 +14,8 @@ use craft\commerce\elements\db\PurchasableQuery; use craft\commerce\elements\Order; use craft\commerce\elements\Variant; -use craft\commerce\events\PurchasableAllowOutOfStockPurchasesEvent; use craft\commerce\events\PurchasableAvailableEvent; +use craft\commerce\events\PurchasableOutOfStockPurchasesAllowedEvent; use craft\commerce\events\PurchasableShippableEvent; use craft\commerce\Plugin; use craft\elements\User; @@ -59,7 +59,7 @@ class Purchasables extends Component * ); * ``` */ - public const EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES = 'allowOutOfStockPurchases'; + public const EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED = 'allowOutOfStockPurchases'; /** * @event PurchasableAvailableEvent The event that is triggered when the availability of a purchasables is checked. @@ -144,18 +144,18 @@ class Purchasables extends Component * @throws Throwable * @since 5.3.0 */ - public function isOutOfStockPurchasesAllowed(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool + public function isPurchasableOutOfStockPurchasingAllowed(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool { if ($currentUser === null) { $currentUser = Craft::$app->getUser()->getIdentity(); } - $outOfStockPurchasesAllowed = $purchasable->getIsOutOfStockPurchasesAllowed(); + $outOfStockPurchasesAllowed = $purchasable->getIsOutOfStockPurchasingAllowed(); - $event = new PurchasableAllowOutOfStockPurchasesEvent(compact('order', 'purchasable', 'currentUser', 'outOfStockPurchasesAllowed')); + $event = new PurchasableOutOfStockPurchasesAllowedEvent(compact('order', 'purchasable', 'currentUser', 'outOfStockPurchasesAllowed')); - if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES)) { - $this->trigger(self::EVENT_PURCHASABLE_ALLOW_OUT_OF_STOCK_PURCHASES, $event); + if ($this->hasEventHandlers(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED)) { + $this->trigger(self::EVENT_PURCHASABLE_OUT_OF_STOCK_PURCHASES_ALLOWED, $event); } return $event->outOfStockPurchasesAllowed; From 11a5614c90ed251f716600d50cc930ea0476ffca Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 3 Jan 2025 18:10:18 +0800 Subject: [PATCH 14/16] release notes --- CHANGELOG-WIP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 3531510003..7e0339bd64 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -16,6 +16,7 @@ - Added an `originalCart` value to `commerce/update-cart` action, for failed ajax responses. ([#430](https://github.com/craftcms/commerce/issues/430)) ### Extensibility +- Added `craft\commerce\base\Purchasable::hasInventory()`. - Added `craft\commerce\base\InventoryItemTrait`. - Added `craft\commerce\base\InventoryLocationTrait`. - Added `craft\commerce\elements\Purchasable::$allowOutOfStockPurchases`. From 45ba29ed39c6f3e2e003623497c37f4101b1bde0 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 8 Jan 2025 20:28:20 +0800 Subject: [PATCH 15/16] Fix --- src/base/Purchasable.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 2539fbd90e..e80c55ca24 100644 --- a/src/base/Purchasable.php +++ b/src/base/Purchasable.php @@ -437,15 +437,6 @@ public function getStoreId(): int return $this->getStore()->id; } - /** - * @return bool - * @throws InvalidConfigException - */ - public function getIsAvailableForPurchase(): bool - { - return Plugin::getInstance()->getPurchasables()->isPurchasableAvailable($this); - } - /** * @inheritdoc * @throws InvalidConfigException @@ -987,7 +978,7 @@ private function _getStock(): int */ public function getIsOutOfStockPurchasingAllowed(): bool { - return $this->allowOutOfStockPurchases; + return Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this); } /** From c2f3a22dc04867179cf88d8283767bc0054160ea Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 8 Jan 2025 20:34:59 +0800 Subject: [PATCH 16/16] Cleanup --- src/adjusters/Tax.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php index 2889dc356e..d854c34187 100644 --- a/src/adjusters/Tax.php +++ b/src/adjusters/Tax.php @@ -96,8 +96,7 @@ class Tax extends Component implements AdjusterInterface */ private function _addAmountRemovedForOrderShipping(float $amount): void { - if($amount < 0) - { + if ($amount < 0) { throw new Exception('Amount added to the total removed shipping must be a positive number'); } @@ -114,12 +113,11 @@ private function _addAmountRemovedForOrderShipping(float $amount): void */ private function _addAmountRemovedForOrderTotalPrice(float $amount): void { - if($amount < 0) - { + if ($amount < 0) { throw new Exception('Amount added to the total removed price must be a positive number'); } - $this->_costRemovedForOrderTotalPrice += $amount; + $this->_costRemovedForOrderTotalPrice = $this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); } /** @@ -370,7 +368,7 @@ private function _getTaxAmount($taxableAmount, $rate, $included): float { $teller = $this->_getTeller(); if (!$included) { - $incTax = $teller->multiply($taxableAmount, (1 + $rate)); + $incTax = $teller->multiply($taxableAmount, (1 + $rate)); $tax = $teller->subtract($incTax, $taxableAmount); } else { $exTax = $teller->divide($taxableAmount, (1 + $rate));