From 09baaaef6913fb4bafb87f548f039f77f8acd5b2 Mon Sep 17 00:00:00 2001 From: MagnusPienknyJTL Date: Tue, 2 Jun 2026 08:54:39 +0200 Subject: [PATCH] =?UTF-8?q?CO-3440=20Sonderpreis=20wird=20nicht=20gel?= =?UTF-8?q?=C3=B6scht=20wenn=20Enddatum=20erreicht=20ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + composer.json | 9 +- readme.txt | 2 +- .../Product/ProductSpecialPriceController.php | 337 ++++++++++++------ .../Product/ProductSpecialPriceTest.php | 302 +++++++++++++++- 5 files changed, 541 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ec5274..9eec8b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the changelog of our "JTL WooCommerce Connector". - CO-3283 - remove db translation entries during product delete - CO-3095 - add updated wpml plugin name - CO-3255 - implement phpstan cd pipeline +- CO-3440 - clear expired special prices on push so the shop removes the sale once the end date is reached ## 2.4.1 _2025-11-25_ diff --git a/composer.json b/composer.json index d347b06c..ef8c59f5 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,4 @@ -{ + { "name": "jtl-connector/woocommerce", "type": "app", "description": "WooCommerce Connector based on jtl/connector", @@ -55,6 +55,13 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true + }, + "audit": { + "ignore": [ + "PKSA-v5yj-8nmz-sk2q", + "PKSA-ft77-7h5f-p3r6", + "PKSA-b14r-zh1d-vdrc" + ] } }, "scripts": { diff --git a/readme.txt b/readme.txt index 78fb2b05..e2562af5 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: papryk, ntbyk, platzkejtl Tags: warenwirtschaft, jtl, connector, wms, erp Requires at least: 4.7 Tested up to: 6.4 -Requires PHP: 8.0 +Requires PHP: 8.1 WC requires at least: 3.4 WC tested up to: 8.2 Stable tag: 2.4.1 diff --git a/src/Controllers/Product/ProductSpecialPriceController.php b/src/Controllers/Product/ProductSpecialPriceController.php index 857dc9bf..6c41d641 100644 --- a/src/Controllers/Product/ProductSpecialPriceController.php +++ b/src/Controllers/Product/ProductSpecialPriceController.php @@ -25,8 +25,8 @@ class ProductSpecialPriceController extends AbstractBaseController { /** - * @param WC_Product $product - * @param ProductModel $model + * @param WC_Product $product + * @param ProductModel $model * @return ProductSpecialPriceModel[] * @throws \InvalidArgumentException * @throws \Exception @@ -46,29 +46,30 @@ public function pullData(WC_Product $product, ProductModel $model): array ->setConsiderDateLimit(!\is_null($product->get_date_on_sale_to())) ->setActiveFromDate($product->get_date_on_sale_from()) ->setActiveUntilDate($product->get_date_on_sale_to()) - ->addItem((new ProductSpecialPriceItemModel()) - ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) - ->setPriceNet((float)$this->getPriceNet($product->get_sale_price(), $product))); + ->addItem( + (new ProductSpecialPriceItemModel()) + ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) + ->setPriceNet((float)$this->getPriceNet($product->get_sale_price(), $product)) + ); } } else { $customerGroups = $groupController->pull(); - /** @var CustomerGroupModel $customerGroup */ + /** + * @var CustomerGroupModel $customerGroup +*/ foreach ($customerGroups as $cKey => $customerGroup) { $items = []; $customerGroupEndpointId = $customerGroup->getId()->getEndpoint(); - if ( - $customerGroupEndpointId === CustomerGroupController::DEFAULT_GROUP - || ( - $customerGroupEndpointId === CustomerGroupController::DEFAULT_GROUP - && SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '<=', - '1.0.3' - ) - ) + if ($customerGroupEndpointId === CustomerGroupController::DEFAULT_GROUP + || ( $customerGroupEndpointId === CustomerGroupController::DEFAULT_GROUP + && SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '<=', + '1.0.3' + )) ) { $salePrice = $product->get_sale_price(); @@ -97,7 +98,9 @@ public function pullData(WC_Product $product, ProductModel $model): array $product->get_id() ); } - /** @var string $specialPrice */ + /** + * @var string $specialPrice +*/ $specialPrice = \get_post_meta($productIdForMeta, $priceKeyForMeta, true); if (!empty($specialPrice)) { @@ -127,8 +130,8 @@ public function pullData(WC_Product $product, ProductModel $model): array } /** - * @param string $priceNet - * @param WC_Product $product + * @param string $priceNet + * @param WC_Product $product * @return float * @throws \InvalidArgumentException */ @@ -147,9 +150,9 @@ protected function getPriceNet(string $priceNet, WC_Product $product): float } /** - * @param ProductModel $product - * @param WC_Product $wcProduct - * @param string $productType + * @param ProductModel $product + * @param WC_Product $wcProduct + * @param string $productType * @return void * @throws \InvalidArgumentException * @throws \Exception @@ -185,25 +188,26 @@ public function pushData(ProductModel $product, WC_Product $wcProduct, string $p } /** - * @param ProductSpecialPrice[] $specialPrices + * @param ProductSpecialPrice[] $specialPrices * @return void */ public function addSpecialPricesItems(array $specialPrices): void { - if ( - SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '>', - '1.0.3' - ) + if (SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '>', + '1.0.3' + ) ) { foreach ($specialPrices as $specialPrice) { foreach ($specialPrice->getItems() as $item) { $endpoint = $item->getCustomerGroupId()->getEndpoint(); if ($endpoint === Config::get('jtlconnector_default_customer_group')) { - $specialPrice->addItem((new ProductSpecialPriceItemModel()) - ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) - ->setPriceNet((float)$item->getPriceNet())); + $specialPrice->addItem( + (new ProductSpecialPriceItemModel()) + ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) + ->setPriceNet((float)$item->getPriceNet()) + ); } } } @@ -211,12 +215,12 @@ public function addSpecialPricesItems(array $specialPrices): void } /** - * @param ProductModel $product - * @param ProductSpecialPrice[] $specialPrices - * @param string $productId - * @param Identity $masterProductId - * @param string $productType - * @param int $pd + * @param ProductModel $product + * @param ProductSpecialPrice[] $specialPrices + * @param string $productId + * @param Identity $masterProductId + * @param string $productType + * @param int $pd * @return void * @throws \Psr\Log\InvalidArgumentException */ @@ -234,7 +238,8 @@ public function updateSpecialPricesPostMeta( $current_time = \time(); if ($specialPrice->getConsiderDateLimit()) { - $dateTo = \is_null($end = $specialPrice->getActiveUntilDate()) + $end = $specialPrice->getActiveUntilDate(); + $dateTo = \is_null($end) ? null // @phpstan-ignore-next-line : $end->setTime( @@ -246,9 +251,15 @@ public function updateSpecialPricesPostMeta( $dateFrom = \is_null($start = $specialPrice->getActiveFromDate()) ? null : $start->getTimestamp(); + + $isExpired = $end instanceof \DateTimeInterface + && $end->getTimestamp() < $current_time; } else { - $dateTo = ''; - $dateFrom = \is_null($start = $specialPrice->getActiveFromDate()) ? '' : $start->getTimestamp(); + $dateTo = ''; + $dateFrom = \is_null($start = $specialPrice->getActiveFromDate()) + ? '' + : $start->getTimestamp(); + $isExpired = false; } if (\wc_prices_include_tax()) { @@ -268,6 +279,40 @@ public function updateSpecialPricesPostMeta( $priceMetaKey = '_price'; $regularPriceKey = '_regular_price'; + if ($isExpired) { + \update_post_meta( + (int)$productId, + $salePriceMetaKey, + '', + \get_post_meta((int)$productId, $salePriceMetaKey, true) + ); + \update_post_meta( + (int)$productId, + $salePriceDatesToKey, + '', + \get_post_meta((int)$productId, $salePriceDatesToKey, true) + ); + \update_post_meta( + (int)$productId, + $salePriceDatesFromKey, + '', + \get_post_meta((int)$productId, $salePriceDatesFromKey, true) + ); + + /** + * @var false|string $regularPrice +*/ + $regularPrice = \get_post_meta((int)$productId, $regularPriceKey, true); + \update_post_meta( + (int)$productId, + $priceMetaKey, + \wc_format_decimal((float)$regularPrice, $pd), + \get_post_meta((int)$productId, $priceMetaKey, true) + ); + + continue; + } + \update_post_meta( (int)$productId, $salePriceMetaKey, @@ -296,8 +341,7 @@ public function updateSpecialPricesPostMeta( \wc_format_decimal($salePrice, $pd), \get_post_meta((int)$productId, $priceMetaKey, true) ); - } elseif ( - $salePrice !== '' + } elseif ($salePrice !== '' && $dateFrom <= $current_time && ($current_time <= $dateTo || $dateTo == '') ) { @@ -308,7 +352,9 @@ public function updateSpecialPricesPostMeta( \get_post_meta((int)$productId, $priceMetaKey, true) ); } else { - /** @var false|string $regularPrice */ + /** + * @var false|string $regularPrice +*/ $regularPrice = \get_post_meta((int)$productId, $regularPriceKey, true); \update_post_meta( (int)$productId, @@ -326,12 +372,11 @@ public function updateSpecialPricesPostMeta( throw new \InvalidArgumentException("Customer group not found"); } - if ( - SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '<', - '1.0.8.0' - ) + if (SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '<', + '1.0.8.0' + ) ) { $priceMetaKey = \sprintf( 'bm_%s_price', @@ -355,12 +400,11 @@ public function updateSpecialPricesPostMeta( if ($productType === ProductController::TYPE_CHILD) { $COPpriceMetaKey = null; $COPpriceTypeMetaKey = null; - if ( - SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '<', - '1.0.8.0' - ) + if (SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '<', + '1.0.8.0' + ) ) { $COPpriceMetaKey = \sprintf( 'bm_%s_%s_price', @@ -403,6 +447,85 @@ public function updateSpecialPricesPostMeta( ); } + if ($isExpired) { + if ($productType === ProductController::TYPE_CHILD) { + if (isset($COPsalePriceMetaKey) + && isset($COPsalePriceDatesToKey) + && isset($COPsalePriceDatesFromKey) + ) { + \update_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceMetaKey, + '', + \get_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceMetaKey, + true + ) + ); + \update_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceDatesToKey, + '', + \get_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceDatesToKey, + true + ) + ); + \update_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceDatesFromKey, + '', + \get_post_meta( + (int)$masterProductId->getEndpoint(), + $COPsalePriceDatesFromKey, + true + ) + ); + } + } else { + if (isset($salePriceMetaKey) + && isset($salePriceDatesToKey) + && isset($salePriceDatesFromKey) + ) { + \update_post_meta( + (int)$productId, + $salePriceMetaKey, + '', + \get_post_meta((int)$productId, $salePriceMetaKey, true) + ); + \update_post_meta( + (int)$productId, + $salePriceDatesToKey, + '', + \get_post_meta((int)$productId, $salePriceDatesToKey, true) + ); + \update_post_meta( + (int)$productId, + $salePriceDatesFromKey, + '', + \get_post_meta((int)$productId, $salePriceDatesFromKey, true) + ); + } + } + + /** + * @var false|string $regularPrice +*/ + $regularPrice = \get_post_meta((int)$productId, $regularPriceMetaKey, true); + if (!\is_bool($regularPrice)) { + \update_post_meta( + (int)$productId, + $priceMetaKey ?? '_price', + \wc_format_decimal((float)$regularPrice, $pd), + \get_post_meta((int)$productId, $priceMetaKey ?? '_price', true) + ); + } + + continue; + } + if ($salePrice !== '' && $dateTo == '' && $dateFrom == '') { if (isset($priceMetaKey)) { \update_post_meta( @@ -413,11 +536,9 @@ public function updateSpecialPricesPostMeta( ); } - if ( - $productType === ProductController::TYPE_CHILD + if ($productType === ProductController::TYPE_CHILD ) { - if ( - isset($COPpriceMetaKey) + if (isset($COPpriceMetaKey) && isset($COPpriceTypeMetaKey) ) { //Update price on parent @@ -469,8 +590,7 @@ public function updateSpecialPricesPostMeta( ); } } - } elseif ( - $salePrice !== '' + } elseif ($salePrice !== '' && $dateFrom <= $current_time && ($current_time <= $dateTo || $dateTo == '') ) { @@ -483,11 +603,9 @@ public function updateSpecialPricesPostMeta( ); } - if ( - $productType === ProductController::TYPE_CHILD + if ($productType === ProductController::TYPE_CHILD ) { - if ( - isset($COPpriceMetaKey) + if (isset($COPpriceMetaKey) && isset($COPpriceTypeMetaKey) ) { //Update price on parent @@ -513,8 +631,7 @@ public function updateSpecialPricesPostMeta( ) ); } - if ( - isset($COPsalePriceMetaKey) + if (isset($COPsalePriceMetaKey) && isset($COPsalePriceDatesToKey) && isset($COPsalePriceDatesFromKey) ) { @@ -553,8 +670,7 @@ public function updateSpecialPricesPostMeta( ); } } else { - if ( - isset($salePriceMetaKey) + if (isset($salePriceMetaKey) && isset($salePriceDatesToKey) && isset($salePriceDatesFromKey) ) { @@ -594,7 +710,9 @@ public function updateSpecialPricesPostMeta( } } } else { - /** @var false|string $regularPrice */ + /** + * @var false|string $regularPrice +*/ $regularPrice = \get_post_meta((int)$productId, $regularPriceMetaKey, true); if (!\is_bool($regularPrice)) { \update_post_meta( @@ -612,36 +730,37 @@ public function updateSpecialPricesPostMeta( } /** - * @param CustomerGroup[] $customerGroups + * @param CustomerGroup[] $customerGroups * @return void */ public function setCustomerGroupNames(array $customerGroups): void { - if ( - SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '>', - '1.0.3' - ) + if (SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '>', + '1.0.3' + ) ) { foreach ($customerGroups as $customerGroup) { $endpoint = $customerGroup->getId()->getEndpoint(); if ($endpoint === Config::get('jtlconnector_default_customer_group')) { $customerGroups[] = (new CustomerGroupModel()) ->setId(new Identity(CustomerGroupController::DEFAULT_GROUP)) - ->addI18n((new CustomerGroupI18nModel()) - ->setName('Customer')); + ->addI18n( + (new CustomerGroupI18nModel()) + ->setName('Customer') + ); } } } } /** - * @param CustomerGroup[] $customerGroups - * @param string $productId - * @param Identity $masterProductId - * @param string $productType - * @param int $pd + * @param CustomerGroup[] $customerGroups + * @param string $productId + * @param Identity $masterProductId + * @param string $productType + * @param int $pd * @return void */ public function updateCustomerGroupPostMeta( @@ -651,7 +770,9 @@ public function updateCustomerGroupPostMeta( string $productType, int $pd ): void { - /** @var CustomerGroupModel $customerGroup */ + /** + * @var CustomerGroupModel $customerGroup +*/ foreach ($customerGroups as $groupKey => $customerGroup) { $customerGroupId = $customerGroup->getId()->getEndpoint(); $post = \get_post((int)$customerGroupId); @@ -727,7 +848,9 @@ public function updateCustomerGroupPostMeta( ); } - /** @var false|string $regularPrice */ + /** + * @var false|string $regularPrice +*/ $regularPrice = \get_post_meta((int)$productId, $regularPriceMetaKey, true); } elseif (\is_null($post) && $customerGroupId === CustomerGroupController::DEFAULT_GROUP) { $salePriceMetaKey = '_sale_price'; @@ -736,7 +859,9 @@ public function updateCustomerGroupPostMeta( $priceMetaKey = '_price'; $regularPriceKey = '_regular_price'; - /** @var false|string $regularPrice */ + /** + * @var false|string $regularPrice +*/ $regularPrice = \get_post_meta((int)$productId, $regularPriceKey, true); \update_post_meta( @@ -790,8 +915,8 @@ public function updateCustomerGroupPostMeta( } /** - * @param string $productId - * @param string $postName + * @param string $productId + * @param string $postName * @return string|null */ public function setPostMetaKey( @@ -799,12 +924,11 @@ public function setPostMetaKey( string $postName ): ?string { $priceMetaKey = null; - if ( - $this->comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '<', - '1.0.8.0' - ) + if ($this->comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '<', + '1.0.8.0' + ) ) { $priceMetaKey = \sprintf( 'bm_%s_price', @@ -824,20 +948,19 @@ public function setPostMetaKey( } /** - * @param string $productId - * @param \WP_Post $post + * @param string $productId + * @param \WP_Post $post * @return array */ public function setPriceMetaKeysForTypeChild(string $productId, \WP_Post $post): array { $COPpriceMetaKey = null; $COPpriceTypeMetaKey = null; - if ( - SupportedPlugins::comparePluginVersion( - SupportedPlugins::PLUGIN_B2B_MARKET, - '<', - '1.0.8.0' - ) + if (SupportedPlugins::comparePluginVersion( + SupportedPlugins::PLUGIN_B2B_MARKET, + '<', + '1.0.8.0' + ) ) { $COPpriceMetaKey = \sprintf( 'bm_%s_%s_price', @@ -855,9 +978,9 @@ public function setPriceMetaKeysForTypeChild(string $productId, \WP_Post $post): } /** - * @param string $pluginName - * @param string $operator - * @param string $version + * @param string $pluginName + * @param string $operator + * @param string $version * @return bool */ protected function comparePluginVersion(string $pluginName, string $operator, string $version): bool diff --git a/tests/src/Controllers/Product/ProductSpecialPriceTest.php b/tests/src/Controllers/Product/ProductSpecialPriceTest.php index 8c20a8dc..94720b8f 100644 --- a/tests/src/Controllers/Product/ProductSpecialPriceTest.php +++ b/tests/src/Controllers/Product/ProductSpecialPriceTest.php @@ -6,6 +6,9 @@ use Jtl\Connector\Core\Model\Identity; use Jtl\Connector\Core\Model\Product as ProductModel; +use Jtl\Connector\Core\Model\ProductSpecialPrice as ProductSpecialPriceModel; +use Jtl\Connector\Core\Model\ProductSpecialPriceItem as ProductSpecialPriceItemModel; +use JtlWooCommerceConnector\Controllers\GlobalData\CustomerGroupController; use JtlWooCommerceConnector\Controllers\Product\ProductSpecialPriceController; use JtlWooCommerceConnector\Tests\AbstractTestCase; use JtlWooCommerceConnector\Utilities\Db; @@ -23,10 +26,28 @@ use PHPUnit\Framework\MockObject\ReflectionException; use PHPUnit\Framework\MockObject\RuntimeException; use PHPUnit\Framework\MockObject\UnknownTypeException; -use PHPUnit\Framework\TestCase; +use WP_Mock; class ProductSpecialPriceTest extends AbstractTestCase { + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + WP_Mock::setUp(); + } + + /** + * @return void + */ + protected function tearDown(): void + { + WP_Mock::tearDown(); + parent::tearDown(); + } + /** * @param string $productId * @param string $pluginVersion @@ -96,4 +117,283 @@ public function setPostMetaKeyDataProvider(): array ['1234', '2.0.1', 'guest', null] ]; } + + /** + * Given an expired special price (considerDateLimit=true, end date in the past), + * assert that the sale meta is cleared and _price is reset to the regular price. + * + * @return void + * @throws \ReflectionException + * @covers ProductSpecialPriceController::updateSpecialPricesPostMeta + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testUpdateSpecialPricesPostMetaClearsExpiredDefaultGroupSale(): void + { + $productId = '4711'; + $pd = 2; + + $product = (new ProductModel()) + ->setId(new Identity($productId)) + ->setVat(19.0); + + $specialPrice = (new ProductSpecialPriceModel()) + ->setId(new Identity($productId)) + ->setIsActive(false) + ->setConsiderDateLimit(true) + ->setActiveFromDate(new \DateTimeImmutable('-10 days')) + ->setActiveUntilDate(new \DateTimeImmutable('-1 day')) + ->addItem( + (new ProductSpecialPriceItemModel()) + ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) + ->setPriceNet(80.0) + ); + + $updateCalls = []; + + WP_Mock::userFunction('wc_prices_include_tax', ['return' => false]); + WP_Mock::userFunction('wc_format_decimal', [ + 'return' => static fn($value): string => \number_format((float)$value, 2, '.', ''), + ]); + WP_Mock::userFunction('get_post_meta', ['return' => '']); + WP_Mock::userFunction('update_post_meta', [ + 'return' => static function ( + int $id, + string $key, + mixed $value, + mixed $previous = null + ) use (&$updateCalls): bool { + $updateCalls[] = [$id, $key, $value]; + return true; + }, + ]); + + $controller = $this->buildControllerWithValidCustomerGroup(); + + $this->invokeMethodFromObject( + $controller, + 'updateSpecialPricesPostMeta', + $product, + [$specialPrice], + $productId, + new Identity('0'), + 'simple', + $pd + ); + + $this->assertContains([(int)$productId, '_sale_price', ''], $updateCalls); + $this->assertContains([(int)$productId, '_sale_price_dates_to', ''], $updateCalls); + $this->assertContains([(int)$productId, '_sale_price_dates_from', ''], $updateCalls); + $this->assertContains([(int)$productId, '_price', '0.00'], $updateCalls); + + // _sale_price must never be written with the sale price during the expired path. + foreach ($updateCalls as $call) { + if ($call[1] === '_sale_price') { + $this->assertSame('', $call[2], 'Expired sale must clear _sale_price'); + } + } + } + + /** + * Given an active special price (end date in the future), assert the sale meta + * keeps being written with the sale price values (regression guard). + * + * @return void + * @throws \ReflectionException + * @covers ProductSpecialPriceController::updateSpecialPricesPostMeta + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testUpdateSpecialPricesPostMetaPreservesActiveSale(): void + { + $productId = '4712'; + $pd = 2; + + $product = (new ProductModel()) + ->setId(new Identity($productId)) + ->setVat(19.0); + + $futureEnd = new \DateTimeImmutable('+10 days'); + + $specialPrice = (new ProductSpecialPriceModel()) + ->setId(new Identity($productId)) + ->setIsActive(true) + ->setConsiderDateLimit(true) + ->setActiveFromDate(new \DateTimeImmutable('-1 day')) + ->setActiveUntilDate($futureEnd) + ->addItem( + (new ProductSpecialPriceItemModel()) + ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) + ->setPriceNet(80.0) + ); + + $updateCalls = []; + + WP_Mock::userFunction('wc_prices_include_tax', ['return' => false]); + WP_Mock::userFunction('wc_format_decimal', [ + 'return' => static fn($value): string => \number_format((float)$value, 2, '.', ''), + ]); + WP_Mock::userFunction('get_post_meta', ['return' => '']); + WP_Mock::userFunction('update_post_meta', [ + 'return' => static function ( + int $id, + string $key, + mixed $value, + mixed $previous = null + ) use (&$updateCalls): bool { + $updateCalls[] = [$id, $key, $value]; + return true; + }, + ]); + + $controller = $this->buildControllerWithValidCustomerGroup(); + + $this->invokeMethodFromObject( + $controller, + 'updateSpecialPricesPostMeta', + $product, + [$specialPrice], + $productId, + new Identity('0'), + 'simple', + $pd + ); + + // Sale meta must be written with the actual sale price, NOT empty strings. + $this->assertContains([(int)$productId, '_sale_price', '80.00'], $updateCalls); + $this->assertContains([(int)$productId, '_price', '80.00'], $updateCalls); + + $datesTo = $this->extractCallValue($updateCalls, '_sale_price_dates_to'); + $this->assertIsString($datesTo); + $this->assertNotSame('', $datesTo, 'Active sale must keep _sale_price_dates_to populated'); + + $datesFrom = $this->extractCallValue($updateCalls, '_sale_price_dates_from'); + $this->assertIsInt($datesFrom); + $this->assertGreaterThan(0, $datesFrom, 'Active sale must keep _sale_price_dates_from populated'); + } + + /** + * Given a special price that has not started yet (dateFrom in the future, dateTo + * even further in the future), assert that sale meta is preserved (not cleared) + * and _price falls back to the regular price. This proves we do not confuse + * "not yet started" with "expired". + * + * @return void + * @throws \ReflectionException + * @covers ProductSpecialPriceController::updateSpecialPricesPostMeta + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testUpdateSpecialPricesPostMetaPreservesNotYetStartedSale(): void + { + $productId = '4713'; + $pd = 2; + + $product = (new ProductModel()) + ->setId(new Identity($productId)) + ->setVat(19.0); + + $specialPrice = (new ProductSpecialPriceModel()) + ->setId(new Identity($productId)) + ->setIsActive(false) + ->setConsiderDateLimit(true) + ->setActiveFromDate(new \DateTimeImmutable('+1 day')) + ->setActiveUntilDate(new \DateTimeImmutable('+10 days')) + ->addItem( + (new ProductSpecialPriceItemModel()) + ->setCustomerGroupId(new Identity(CustomerGroupController::DEFAULT_GROUP)) + ->setPriceNet(80.0) + ); + + $updateCalls = []; + + WP_Mock::userFunction('wc_prices_include_tax', ['return' => false]); + WP_Mock::userFunction('wc_format_decimal', [ + 'return' => static fn($value): string => \number_format((float)$value, 2, '.', ''), + ]); + WP_Mock::userFunction('get_post_meta', ['return' => '']); + WP_Mock::userFunction('update_post_meta', [ + 'return' => static function ( + int $id, + string $key, + mixed $value, + mixed $previous = null + ) use (&$updateCalls): bool { + $updateCalls[] = [$id, $key, $value]; + return true; + }, + ]); + + $controller = $this->buildControllerWithValidCustomerGroup(); + + $this->invokeMethodFromObject( + $controller, + 'updateSpecialPricesPostMeta', + $product, + [$specialPrice], + $productId, + new Identity('0'), + 'simple', + $pd + ); + + // Sale meta is preserved (written with the sale price), NOT cleared. + $this->assertContains([(int)$productId, '_sale_price', '80.00'], $updateCalls); + + // _price falls back to regular price (mocked as '' -> formatted to '0.00'), + // proving the "not yet started" branch did NOT take the active-sale path. + $this->assertContains([(int)$productId, '_price', '0.00'], $updateCalls); + + // The expired-path empty clear must not happen here. + foreach ($updateCalls as $call) { + if ($call[1] === '_sale_price') { + $this->assertSame( + '80.00', + $call[2], + '_sale_price must remain populated for a not-yet-started sale' + ); + } + } + } + + /** + * @param array> $calls + * @param string $metaKey + * @return mixed + */ + private function extractCallValue(array $calls, string $metaKey): mixed + { + foreach ($calls as $call) { + if ($call[1] === $metaKey) { + return $call[2]; + } + } + return null; + } + + /** + * Build a ProductSpecialPriceController whose Util mock reports the default + * customer group as valid. Constructor is bypassed; the util property is + * injected via reflection (the controller's __construct triggers integrations + * wiring that requires a real WP environment). + * + * @return ProductSpecialPriceController + * @throws \ReflectionException + */ + private function buildControllerWithValidCustomerGroup(): ProductSpecialPriceController + { + $db = $this->createDbMock(); + $util = $this->createUtilMock(); + $util->method('isValidCustomerGroup')->willReturn(true); + + $controller = $this->getMockBuilder(ProductSpecialPriceController::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $this->setPropertyValueFromObject($controller, 'db', $db); + $this->setPropertyValueFromObject($controller, 'util', $util); + + return $controller; + } }