diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index aa5c1d6e11..f9888df676 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 - It is now possible to design card views for Products and Variants. ([#3809](https://github.com/craftcms/commerce/pull/3809)) - Order conditions can now have a “Coupon Code” rule. ([#3776](https://github.com/craftcms/commerce/discussions/3776)) @@ -16,15 +20,21 @@ - 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`. +- 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\PurchasableOutOfStockPurchasesAllowedEvent`. - Added `craft\commerce\services\Inventory::updateInventoryLevel()`. - Added `craft\commerce\services\Inventory::updatePurchasableInventoryLevel()`. +- 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/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 c809208c7d..7c0182da97 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -257,7 +257,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '5.2.9.1'; + public string $schemaVersion = '5.3.0.2'; /** * @inheritdoc diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php index 28211077d0..d854c34187 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; @@ -78,12 +79,47 @@ 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 = $this->_getTeller()->add($this->_costRemovedForOrderTotalPrice, $amount); + } + /** * @inheritdoc */ @@ -125,6 +161,7 @@ private function _getAdjustments(TaxRate $taxRate): array { $adjustments = []; $hasValidEuVatId = false; + $teller = $this->_getTeller(); $zoneMatches = $taxRate->getIsEverywhere() || ($taxRate->getTaxZone() && $this->_matchAddress($taxRate->getTaxZone())); @@ -136,7 +173,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; @@ -146,41 +183,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 += $amount; + $this->_addAmountRemovedForOrderTotalPrice($orderLevelAmountToBeRemovedByDiscount); } elseif ($taxRate->taxable === TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING) { - $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; @@ -197,6 +261,7 @@ private function _getAdjustments(TaxRate $taxRate): array } } } + // Return the removed included taxes as discounts. return $adjustments; } @@ -301,17 +366,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; } /** @@ -433,4 +497,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); + } } diff --git a/src/base/Purchasable.php b/src/base/Purchasable.php index 7ac8cf2de2..e80c55ca24 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; @@ -228,6 +229,14 @@ abstract class Purchasable extends Element implements PurchasableInterface, HasS */ public bool $inventoryTracked = false; + /** + * Should this purchases of this purchasable be allowed if it is out of stock. + * + * @var bool + * @since 5.3.0 + */ + public bool $allowOutOfStockPurchases = false; + /** * This is the cached total available stock across all inventory locations. * @@ -254,6 +263,8 @@ public function attributes(): array $names[] = 'sku'; $names[] = 'stock'; $names[] = 'inventoryTracked'; + $names[] = 'allowOutOfStockPurchases'; + return $names; } @@ -443,16 +454,17 @@ public function getIsAvailable(): bool return false; } - // Is the inventory tracked and is there stock? - if ($this->inventoryTracked && $this->getStock() < 1) { - return false; - } - // Temporary SKU can not be added to the cart if (PurchasableHelper::isTempSku($this->getSku())) { return false; } + if (static::hasInventory() && $this->inventoryTracked && $this->getStock() < 1) { + if (!Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this)) { + return false; + } + } + return true; } @@ -746,7 +758,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->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([ @@ -801,7 +817,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.')); } }, @@ -809,16 +825,24 @@ function($attribute, $params, Validator $validator) use ($lineItem) { [ 'qty', function($attribute, $params, Validator $validator) use ($lineItem, $lineItemQuantitiesById, $lineItemQuantitiesByPurchasableId) { + if ($lineItem->type == LineItemType::Custom) { + return; + } + if (!$this->hasStock()) { - $error = Craft::t('commerce', '“{description}” is currently out of stock.', ['description' => $lineItem->purchasable->getDescription()]); - $validator->addError($lineItem, $attribute, $error); + 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); + } } $lineItemQty = $lineItem->id !== null ? $lineItemQuantitiesById[$lineItem->id] : $lineItemQuantitiesByPurchasableId[$lineItem->purchasableId]; if ($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 (!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); + } } if ($this->minQty > 1 && $lineItemQty < $this->minQty) { @@ -859,7 +883,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 +946,7 @@ public function getInventoryItem(): InventoryItem */ public function getHasUnlimitedStock(): bool { - return !$this->inventoryTracked; + return !$this::hasInventory() || !$this->inventoryTracked; } /** @@ -948,6 +972,15 @@ private function _getStock(): int return $saleableAmount; } + /** + * @return bool + * @since 5.3.0 + */ + public function getIsOutOfStockPurchasingAllowed(): bool + { + return Plugin::getInstance()->getPurchasables()->isPurchasableOutOfStockPurchasingAllowed($this); + } + /** * Returns the cached total available stock across all inventory locations for this store. * @@ -1035,27 +1068,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 +1111,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 +1132,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 +1148,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 +1355,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 +1394,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..f625933c1a 100644 --- a/src/elements/Donation.php +++ b/src/elements/Donation.php @@ -33,10 +33,24 @@ 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(); @@ -50,6 +64,9 @@ public function behaviors(): array return $behaviors; } + /** + * @inheritdoc + */ protected function defineRules(): array { $rules = parent::defineRules(); @@ -271,6 +288,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..0cb9bd5183 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 (!$variant::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/events/PurchasableOutOfStockPurchasesAllowedEvent.php b/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php new file mode 100644 index 0000000000..5b24e97113 --- /dev/null +++ b/src/events/PurchasableOutOfStockPurchasesAllowedEvent.php @@ -0,0 +1,42 @@ + + * @since 5.3.0 + */ +class PurchasableOutOfStockPurchasesAllowedEvent 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 $outOfStockPurchasesAllowed = false; +} diff --git a/src/fieldlayoutelements/PurchasableStockField.php b/src/fieldlayoutelements/PurchasableStockField.php index eeb28f0b8d..b9e7f8d369 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->getIsOutOfStockPurchasingAllowed(), + '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/Install.php b/src/migrations/Install.php index 756520a8ec..11042e331d 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(false), '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/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/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; 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 5ce7ebb9fd..d486c40da4 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/services/Purchasables.php b/src/services/Purchasables.php index accb6d512d..aa09e36885 100644 --- a/src/services/Purchasables.php +++ b/src/services/Purchasables.php @@ -15,6 +15,7 @@ use craft\commerce\elements\Order; use craft\commerce\elements\Variant; use craft\commerce\events\PurchasableAvailableEvent; +use craft\commerce\events\PurchasableOutOfStockPurchasesAllowedEvent; use craft\commerce\events\PurchasableShippableEvent; use craft\commerce\Plugin; use craft\elements\User; @@ -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_OUT_OF_STOCK_PURCHASES_ALLOWED = 'allowOutOfStockPurchases'; + /** * @event PurchasableAvailableEvent The event that is triggered when the availability of a purchasables is checked. * @@ -109,6 +135,32 @@ 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 isPurchasableOutOfStockPurchasingAllowed(PurchasableInterface $purchasable, Order $order = null, User $currentUser = null): bool + { + if ($currentUser === null) { + $currentUser = Craft::$app->getUser()->getIdentity(); + } + + $outOfStockPurchasesAllowed = $purchasable->getIsOutOfStockPurchasingAllowed(); + + $event = new PurchasableOutOfStockPurchasesAllowedEvent(compact('order', 'purchasable', 'currentUser', 'outOfStockPurchasesAllowed')); + + 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; + } + /** * @param Order|null $order * @param User|null $currentUser 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; } /** diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 5c018323ad..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',