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',