From 730bad6cf5b8c23d018c55ab9ee35f1bb0367edf Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 3 Sep 2026 08:01:24 +0000 Subject: [PATCH 1/2] Add option to hide prices in the dataLayer for B2B customer groups B2B stores often only show prices to logged in (or approved) customers. Everything that ends up in the dataLayer is readable in the page source and in the browser console, so the price was still exposed there even when the storefront hid it. Adds Stores > Configuration > AdPage > AdPage GTM > Price visibility with: - Hide prices in the dataLayer (off by default, so nothing changes on upgrade) - Hide prices for customer groups (multiselect including NOT LOGGED IN) - Hide prices by: leave the key out (default) or send the price as 0 Util\PriceVisibility is the single decision point. It only guards the storefront: the admin panel, the REST API and the cron job that retries failed webhooks have no customer session and keep the real amounts. Order data is deliberately left alone as well - the purchase and refund events and the order_created webhook keep their real amounts, since a visitor who cannot see prices cannot place an order and blanking those would break revenue reporting. Covered for a hidden customer group: items[].price on every event that carries items, ecommerce.value on view_item, add_to_cart, view_cart, begin_checkout and add_payment_info, and the price in the product data block on the product page. TagParser now drops the key of a tag that resolves to null instead of pushing "key": null, which is what it already did for literal null values and what the nullable price tags rely on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UpDMDuDY9kEpftGqrUnDyJ --- Config/Config.php | 87 +++++++++++++++ DataLayer/Event/AddPaymentInfo.php | 22 +++- DataLayer/Event/AddToCart.php | 15 ++- DataLayer/Event/BeginCheckout.php | 8 +- DataLayer/Event/ViewCart.php | 8 +- DataLayer/Mapper/CartItemDataMapper.php | 21 +++- DataLayer/Mapper/ProductDataMapper.php | 15 ++- DataLayer/Tag/Cart/CartValue.php | 15 ++- DataLayer/Tag/Product/CurrentPrice.php | 19 ++-- DataLayer/TagParser.php | 12 ++- Model/Config/Source/CustomerGroups.php | 63 +++++++++++ Model/Config/Source/HidePriceMode.php | 24 +++++ Test/Unit/Util/PriceVisibilityTest.php | 134 ++++++++++++++++++++++++ USAGE.md | 27 +++++ Util/PriceVisibility.php | 109 +++++++++++++++++++ etc/adminhtml/system.xml | 35 ++++++- etc/config.xml | 7 ++ etc/di.xml | 10 ++ 18 files changed, 604 insertions(+), 27 deletions(-) create mode 100644 Model/Config/Source/CustomerGroups.php create mode 100644 Model/Config/Source/HidePriceMode.php create mode 100644 Test/Unit/Util/PriceVisibilityTest.php create mode 100644 Util/PriceVisibility.php diff --git a/Config/Config.php b/Config/Config.php index d690c4e9..b0a3030c 100644 --- a/Config/Config.php +++ b/Config/Config.php @@ -13,6 +13,7 @@ use Magento\Store\Model\StoreManagerInterface; use Magento\Framework\UrlInterface; use Tagging\GTM\DataLayer\Tag\Version; +use Tagging\GTM\Model\Config\Source\HidePriceMode; class Config implements ArgumentInterface { @@ -155,6 +156,59 @@ public function getStoreDomain(): string return (string)$this->storeManager->getStore()->getBaseUrl(UrlInterface::URL_TYPE_WEB); } + /** + * Check whether prices should be hidden in the dataLayer for specific customer groups + * + * B2B stores that only reveal prices to logged in (or approved) customers use this to keep those + * prices out of the dataLayer as well. + * + * @return bool + */ + public function isHidePricesEnabled(): bool + { + return (bool)$this->getModuleConfigValuePriceVisibility('enabled', false); + } + + /** + * The customer group ids that are not allowed to see prices in the dataLayer + * + * @return int[] + */ + public function getHidePricesCustomerGroups(): array + { + $customerGroups = $this->getRawConfigValue('GTM/price_visibility/customer_groups'); + if (!is_string($customerGroups) || $customerGroups === '') { + return []; + } + + $customerGroupIds = []; + foreach (explode(',', $customerGroups) as $customerGroupId) { + $customerGroupId = trim($customerGroupId); + if ($customerGroupId === '') { + continue; + } + + $customerGroupIds[] = (int)$customerGroupId; + } + + return $customerGroupIds; + } + + /** + * How a hidden price should be represented in the dataLayer + * + * @return string + */ + public function getHidePricesMode(): string + { + $mode = (string)$this->getModuleConfigValuePriceVisibility('mode', HidePriceMode::MODE_REMOVE); + if ($mode !== HidePriceMode::MODE_ZERO) { + return HidePriceMode::MODE_REMOVE; + } + + return $mode; + } + /** * Return a configuration value * @@ -168,6 +222,19 @@ public function getModuleConfigValue(string $key, $defaultValue = null) return $this->getConfigValue('GTM/settings/' . $key, $defaultValue); } + /** + * Return a price visibility configuration value + * + * @param string $key + * @param null $defaultValue + * + * @return mixed|null + */ + public function getModuleConfigValuePriceVisibility(string $key, $defaultValue = null) + { + return $this->getConfigValue('GTM/price_visibility/' . $key, $defaultValue); + } + public function getModuleConfigValueAdvanced(string $key, $defaultValue = null) { return $this->getConfigValue('GTM/advanced/' . $key, $defaultValue); @@ -200,6 +267,26 @@ public function getConfigValue(string $key, $defaultValue = null) return $value; } + /** + * Return a configuration value without treating "0" and other falsy values as absent + * + * @param string $key + * + * @return mixed|null + */ + public function getRawConfigValue(string $key) + { + try { + return $this->scopeConfig->getValue( + $key, + ScopeInterface::SCOPE_STORE, + $this->storeManager->getStore() + ); + } catch (NoSuchEntityException $e) { + return null; + } + } + public function getVersion(): string { return $this->version->get(); diff --git a/DataLayer/Event/AddPaymentInfo.php b/DataLayer/Event/AddPaymentInfo.php index 39d2b468..ed4fb142 100644 --- a/DataLayer/Event/AddPaymentInfo.php +++ b/DataLayer/Event/AddPaymentInfo.php @@ -7,27 +7,33 @@ use Tagging\GTM\Api\Data\EventInterface; use Tagging\GTM\DataLayer\Tag\Cart\CartItems; use Tagging\GTM\Util\PriceFormatter; +use Tagging\GTM\Util\PriceVisibility; class AddPaymentInfo implements EventInterface { private CartItems $cartItems; private CartRepositoryInterface $cartRepository; private PriceFormatter $priceFormatter; + private PriceVisibility $priceVisibility; private int $cartId; private string $paymentMethod; /** * @param CartRepositoryInterface $cartRepository * @param CartItems $cartItems + * @param PriceFormatter $priceFormatter + * @param PriceVisibility $priceVisibility */ public function __construct( CartRepositoryInterface $cartRepository, CartItems $cartItems, - PriceFormatter $priceFormatter + PriceFormatter $priceFormatter, + PriceVisibility $priceVisibility ) { $this->cartItems = $cartItems; $this->cartRepository = $cartRepository; $this->priceFormatter = $priceFormatter; + $this->priceVisibility = $priceVisibility; } /** @@ -37,16 +43,26 @@ public function get(): array { /** @var Cart $cart */ $cart = $this->cartRepository->get($this->cartId); - return [ + $value = $this->priceVisibility->filter( + $this->priceFormatter->format((float)$cart->getGrandTotal()) + ); + + $eventData = [ 'event' => 'trytagging_add_payment_info', 'ecommerce' => [ 'currency' => $cart->getQuoteCurrencyCode(), - 'value' => $this->priceFormatter->format((float)$cart->getGrandTotal()), + 'value' => $value, 'coupon' => $cart->getCouponCode(), 'payment_type' => $this->paymentMethod, 'items' => $this->cartItems->get() ] ]; + + if ($value === null) { + unset($eventData['ecommerce']['value']); + } + + return $eventData; } /** diff --git a/DataLayer/Event/AddToCart.php b/DataLayer/Event/AddToCart.php index 2b2fc838..d6a2dafd 100644 --- a/DataLayer/Event/AddToCart.php +++ b/DataLayer/Event/AddToCart.php @@ -56,16 +56,25 @@ public function get(): array $itemData = $this->productDataMapper->mapByProduct($product); $itemData['quantity'] = $qty; - $value = $itemData['price'] * $qty; - return [ + // No item price means the price is hidden for this visitor, so no value is reported either. + $price = $itemData['price'] ?? null; + $value = $price === null ? null : $this->priceFormatter->format((float)$price * $qty); + + $eventData = [ 'event' => 'trytagging_add_to_cart', 'ecommerce' => [ 'currency' => $this->currencyCode->get(), - 'value' => $this->priceFormatter->format((float)$value), + 'value' => $value, 'items' => [$itemData] ] ]; + + if ($value === null) { + unset($eventData['ecommerce']['value']); + } + + return $eventData; } /** diff --git a/DataLayer/Event/BeginCheckout.php b/DataLayer/Event/BeginCheckout.php index c73e8ea1..585592e3 100644 --- a/DataLayer/Event/BeginCheckout.php +++ b/DataLayer/Event/BeginCheckout.php @@ -35,7 +35,7 @@ public function __construct( public function get(): array { - return [ + $eventData = [ 'event' => 'trytagging_begin_checkout', 'ecommerce' => [ 'currency' => $this->currencyCode->get(), @@ -44,5 +44,11 @@ public function get(): array 'items' => $this->cartItems->get() ] ]; + + if ($eventData['ecommerce']['value'] === null) { + unset($eventData['ecommerce']['value']); + } + + return $eventData; } } diff --git a/DataLayer/Event/ViewCart.php b/DataLayer/Event/ViewCart.php index 4fbd09b4..422e94fc 100644 --- a/DataLayer/Event/ViewCart.php +++ b/DataLayer/Event/ViewCart.php @@ -43,7 +43,7 @@ public function __construct( */ public function get(): array { - return [ + $eventData = [ 'meta' => [ 'cacheable' => true, 'allowed_pages' => $this->getAllowedPages(), @@ -56,6 +56,12 @@ public function get(): array 'items' => $this->cartItems->get() ] ]; + + if ($eventData['ecommerce']['value'] === null) { + unset($eventData['ecommerce']['value']); + } + + return $eventData; } /** diff --git a/DataLayer/Mapper/CartItemDataMapper.php b/DataLayer/Mapper/CartItemDataMapper.php index b37acb60..134c9b0a 100644 --- a/DataLayer/Mapper/CartItemDataMapper.php +++ b/DataLayer/Mapper/CartItemDataMapper.php @@ -9,6 +9,7 @@ use Magento\Store\Model\ScopeInterface; use Magento\Tax\Model\Config; use Tagging\GTM\Util\PriceFormatter; +use Tagging\GTM\Util\PriceVisibility; use Tagging\GTM\Util\ProductProvider; class CartItemDataMapper @@ -17,23 +18,27 @@ class CartItemDataMapper private ProductProvider $productProvider; private PriceFormatter $priceFormatter; private ScopeConfigInterface $scopeConfig; + private PriceVisibility $priceVisibility; /** * @param ProductDataMapper $productDataMapper * @param ProductProvider $productProvider * @param PriceFormatter $priceFormatter * @param ScopeConfigInterface $scopeConfig + * @param PriceVisibility $priceVisibility */ public function __construct( ProductDataMapper $productDataMapper, ProductProvider $productProvider, PriceFormatter $priceFormatter, - ScopeConfigInterface $scopeConfig + ScopeConfigInterface $scopeConfig, + PriceVisibility $priceVisibility ) { $this->productDataMapper = $productDataMapper; $this->productProvider = $productProvider; $this->priceFormatter = $priceFormatter; $this->scopeConfig = $scopeConfig; + $this->priceVisibility = $priceVisibility; } /** @@ -50,20 +55,26 @@ public function mapByCartItem(CartItemInterface $cartItem): array $cartItemData = []; } - return array_merge($cartItemData, [ + $cartItemData = array_merge($cartItemData, [ 'item_sku' => $cartItem->getSku(), 'item_name' => $cartItem->getName(), 'order_item_id' => $cartItem->getItemId(), 'quantity' => (float) $cartItem->getQty(), 'price' => $this->getPrice($cartItem) ]); + + if ($cartItemData['price'] === null) { + unset($cartItemData['price']); + } + + return $cartItemData; } /** * @param CartItemInterface $cartItem - * @return float + * @return float|null */ - private function getPrice(CartItemInterface $cartItem): float + private function getPrice(CartItemInterface $cartItem): ?float { $displayType = (int)$this->scopeConfig->getValue( Config::CONFIG_XML_PATH_PRICE_DISPLAY_TYPE, @@ -82,6 +93,6 @@ private function getPrice(CartItemInterface $cartItem): float break; } - return $this->priceFormatter->format((float)$price); + return $this->priceVisibility->filter($this->priceFormatter->format((float)$price)); } } diff --git a/DataLayer/Mapper/ProductDataMapper.php b/DataLayer/Mapper/ProductDataMapper.php index 3f3869d4..6463c435 100644 --- a/DataLayer/Mapper/ProductDataMapper.php +++ b/DataLayer/Mapper/ProductDataMapper.php @@ -14,6 +14,7 @@ use Tagging\GTM\Config\Config; use Tagging\GTM\Util\Attribute\GetAttributeValue; use Tagging\GTM\Util\PriceFormatter; +use Tagging\GTM\Util\PriceVisibility; use Tagging\GTM\Util\CategoryProvider; use Magento\Catalog\Api\ProductRepositoryInterface; @@ -23,6 +24,7 @@ class ProductDataMapper private GetAttributeValue $getAttributeValue; private CategoryProvider $categoryProvider; private PriceFormatter $priceFormatter; + private PriceVisibility $priceVisibility; private Configurable $configurableType; private ProductRepositoryInterface $productRepository; @@ -35,6 +37,7 @@ class ProductDataMapper * @param GetAttributeValue $getAttributeValue * @param CategoryProvider $categoryProvider * @param PriceFormatter $priceFormatter + * @param PriceVisibility $priceVisibility * @param array $dataLayerMapping */ public function __construct( @@ -44,12 +47,14 @@ public function __construct( PriceFormatter $priceFormatter, Configurable $configurableType, ProductRepositoryInterface $productRepository, + PriceVisibility $priceVisibility, array $dataLayerMapping = [] ) { $this->config = $config; $this->getAttributeValue = $getAttributeValue; $this->categoryProvider = $categoryProvider; $this->priceFormatter = $priceFormatter; + $this->priceVisibility = $priceVisibility; $this->configurableType = $configurableType; $this->productRepository = $productRepository; $this->dataLayerMapping = $dataLayerMapping; @@ -102,10 +107,16 @@ public function mapByProduct(ProductInterface $product): array // Use the price info final price instead of getFinalPrice(): for grouped products the // parent product itself has no price (getFinalPrice() returns 0) and Magento resolves the // final price to the lowest priced associated product, which is what is shown on the front-end. - $productData['price'] = $this->priceFormatter->format( - (float)$product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue() + $price = $this->priceVisibility->filter( + $this->priceFormatter->format( + (float)$product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue() + ) ); + if ($price !== null) { + $productData['price'] = $price; + } + $productData = $this->attachCategoriesData($product, $productData); $productData = $this->parseDataLayerMapping($product, $productData); $productData['index'] = $this->counter++; diff --git a/DataLayer/Tag/Cart/CartValue.php b/DataLayer/Tag/Cart/CartValue.php index 0f5b7050..651709b8 100644 --- a/DataLayer/Tag/Cart/CartValue.php +++ b/DataLayer/Tag/Cart/CartValue.php @@ -7,29 +7,36 @@ use Magento\Framework\Exception\NoSuchEntityException; use Tagging\GTM\Api\Data\TagInterface; use Tagging\GTM\Util\PriceFormatter; +use Tagging\GTM\Util\PriceVisibility; class CartValue implements TagInterface { private CartModel $cartModel; private PriceFormatter $priceFormatter; + private PriceVisibility $priceVisibility; /** * @param CartModel $cartModel * @param PriceFormatter $priceFormatter + * @param PriceVisibility $priceVisibility */ public function __construct( CartModel $cartModel, - PriceFormatter $priceFormatter + PriceFormatter $priceFormatter, + PriceVisibility $priceVisibility ) { $this->cartModel = $cartModel; $this->priceFormatter = $priceFormatter; + $this->priceVisibility = $priceVisibility; } /** - * @return float + * @return float|null */ - public function get(): float + public function get(): ?float { - return $this->priceFormatter->format((float)$this->cartModel->getQuote()->getBaseGrandTotal()); + return $this->priceVisibility->filter( + $this->priceFormatter->format((float)$this->cartModel->getQuote()->getBaseGrandTotal()) + ); } } diff --git a/DataLayer/Tag/Product/CurrentPrice.php b/DataLayer/Tag/Product/CurrentPrice.php index 69f0cde7..20ad7a1d 100644 --- a/DataLayer/Tag/Product/CurrentPrice.php +++ b/DataLayer/Tag/Product/CurrentPrice.php @@ -7,34 +7,41 @@ use Tagging\GTM\Api\Data\TagInterface; use Tagging\GTM\Util\GetCurrentProduct; use Tagging\GTM\Util\PriceFormatter; +use Tagging\GTM\Util\PriceVisibility; class CurrentPrice implements TagInterface { private GetCurrentProduct $getCurrentProduct; private PriceFormatter $priceFormatter; + private PriceVisibility $priceVisibility; /** * @param GetCurrentProduct $getCurrentProduct * @param PriceFormatter $priceFormatter + * @param PriceVisibility $priceVisibility */ public function __construct( GetCurrentProduct $getCurrentProduct, - PriceFormatter $priceFormatter + PriceFormatter $priceFormatter, + PriceVisibility $priceVisibility ) { $this->getCurrentProduct = $getCurrentProduct; $this->priceFormatter = $priceFormatter; + $this->priceVisibility = $priceVisibility; } /** - * @return float + * @return float|null * @throws NoSuchEntityException */ - public function get(): float + public function get(): ?float { $product = $this->getCurrentProduct->get(); - return $this->priceFormatter->format( - // @phpstan-ignore-next-line - (float) $product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue() // @phpstan-ignore-line + return $this->priceVisibility->filter( + $this->priceFormatter->format( + // @phpstan-ignore-next-line + (float) $product->getPriceInfo()->getPrice(FinalPrice::PRICE_CODE)->getValue() // @phpstan-ignore-line + ) ); } } diff --git a/DataLayer/TagParser.php b/DataLayer/TagParser.php index 13dea5d1..519cd0b7 100644 --- a/DataLayer/TagParser.php +++ b/DataLayer/TagParser.php @@ -43,7 +43,17 @@ private function convertTag($tagName, $tagValue, array $data): array } if (is_object($tagValue)) { - $data[$tagName] = $this->getValueFromFromTagValueObject($tagValue); + $tagValue = $this->getValueFromFromTagValueObject($tagValue); + + // A tag that resolves to null has nothing to say, so its key is left out of the + // dataLayer entirely instead of being pushed as null. Tags that hide their value - + // such as the price tags on a B2B store - rely on this. + if (is_null($tagValue)) { + unset($data[$tagName]); + return $data; + } + + $data[$tagName] = $tagValue; return $data; } diff --git a/Model/Config/Source/CustomerGroups.php b/Model/Config/Source/CustomerGroups.php new file mode 100644 index 00000000..f4c4f62a --- /dev/null +++ b/Model/Config/Source/CustomerGroups.php @@ -0,0 +1,63 @@ +groupRepository = $groupRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->sortOrderFactory = $sortOrderFactory; + } + + /** + * {@inheritdoc} + */ + public function toOptionArray(): array + { + $sortOrder = $this->sortOrderFactory->create(['field' => 'customer_group_id', 'direction' => 'asc']); + $this->searchCriteriaBuilder->addSortOrder($sortOrder); + $searchCriteria = $this->searchCriteriaBuilder->create(); + + try { + $customerGroups = $this->groupRepository->getList($searchCriteria)->getItems(); + } catch (LocalizedException $localizedException) { + return []; + } + + $options = []; + foreach ($customerGroups as $customerGroup) { + $options[] = [ + 'value' => (string)$customerGroup->getId(), + 'label' => $customerGroup->getCode() + ]; + } + + return $options; + } +} diff --git a/Model/Config/Source/HidePriceMode.php b/Model/Config/Source/HidePriceMode.php new file mode 100644 index 00000000..344b3494 --- /dev/null +++ b/Model/Config/Source/HidePriceMode.php @@ -0,0 +1,24 @@ + self::MODE_REMOVE, 'label' => __('Leave the price out of the dataLayer')], + ['value' => self::MODE_ZERO, 'label' => __('Send the price as 0')], + ]; + + return $options; + } +} diff --git a/Test/Unit/Util/PriceVisibilityTest.php b/Test/Unit/Util/PriceVisibilityTest.php new file mode 100644 index 00000000..265376b0 --- /dev/null +++ b/Test/Unit/Util/PriceVisibilityTest.php @@ -0,0 +1,134 @@ +createPriceVisibility(false, [0], HidePriceMode::MODE_REMOVE, 0); + + $this->assertTrue($priceVisibility->isAllowed()); + $this->assertSame(89.95, $priceVisibility->filter(89.95)); + } + + public function testPriceIsRemovedForAHiddenCustomerGroup(): void + { + $priceVisibility = $this->createPriceVisibility(true, [0], HidePriceMode::MODE_REMOVE, 0); + + $this->assertFalse($priceVisibility->isAllowed()); + $this->assertNull($priceVisibility->filter(89.95)); + } + + public function testPriceIsZeroedForAHiddenCustomerGroupInZeroMode(): void + { + $priceVisibility = $this->createPriceVisibility(true, [0], HidePriceMode::MODE_ZERO, 0); + + $this->assertFalse($priceVisibility->isAllowed()); + $this->assertSame(0.0, $priceVisibility->filter(89.95)); + } + + public function testPriceIsKeptForACustomerGroupThatIsNotHidden(): void + { + $priceVisibility = $this->createPriceVisibility(true, [0], HidePriceMode::MODE_REMOVE, 1); + + $this->assertTrue($priceVisibility->isAllowed()); + $this->assertSame(89.95, $priceVisibility->filter(89.95)); + } + + public function testPriceIsKeptWhenNoCustomerGroupIsSelected(): void + { + $priceVisibility = $this->createPriceVisibility(true, [], HidePriceMode::MODE_REMOVE, 0); + + $this->assertTrue($priceVisibility->isAllowed()); + $this->assertSame(89.95, $priceVisibility->filter(89.95)); + } + + /** + * The order_created webhook is sent from a cron job that has no customer session, so it must + * always keep the real amounts. + */ + public function testPriceIsKeptOutsideOfTheStorefront(): void + { + $priceVisibility = $this->createPriceVisibility(true, [0], HidePriceMode::MODE_REMOVE, 0, Area::AREA_CRONTAB); + + $this->assertTrue($priceVisibility->isAllowed()); + $this->assertSame(89.95, $priceVisibility->filter(89.95)); + } + + public function testPriceIsKeptWhenTheAreaCodeIsNotSet(): void + { + $config = $this->createConfigMock(true, [0], HidePriceMode::MODE_REMOVE); + + $appState = $this->createMock(AppState::class); + $appState->method('getAreaCode')->willThrowException(new LocalizedException(__('Area code is not set'))); + + $priceVisibility = new PriceVisibility($config, $this->createCustomerSessionMock(0), $appState); + + $this->assertTrue($priceVisibility->isAllowed()); + $this->assertSame(89.95, $priceVisibility->filter(89.95)); + } + + /** + * @param bool $enabled + * @param int[] $hiddenCustomerGroups + * @param string $mode + * @param int $customerGroupId + * @param string $areaCode + * @return PriceVisibility + */ + private function createPriceVisibility( + bool $enabled, + array $hiddenCustomerGroups, + string $mode, + int $customerGroupId, + string $areaCode = Area::AREA_FRONTEND + ): PriceVisibility { + $appState = $this->createMock(AppState::class); + $appState->method('getAreaCode')->willReturn($areaCode); + + return new PriceVisibility( + $this->createConfigMock($enabled, $hiddenCustomerGroups, $mode), + $this->createCustomerSessionMock($customerGroupId), + $appState + ); + } + + /** + * @param bool $enabled + * @param int[] $hiddenCustomerGroups + * @param string $mode + * @return Config&MockObject + */ + private function createConfigMock(bool $enabled, array $hiddenCustomerGroups, string $mode): MockObject + { + $config = $this->createMock(Config::class); + $config->method('isHidePricesEnabled')->willReturn($enabled); + $config->method('getHidePricesCustomerGroups')->willReturn($hiddenCustomerGroups); + $config->method('getHidePricesMode')->willReturn($mode); + + return $config; + } + + /** + * @param int $customerGroupId + * @return CustomerSession&MockObject + */ + private function createCustomerSessionMock(int $customerGroupId): MockObject + { + $customerSession = $this->createMock(CustomerSession::class); + $customerSession->method('getCustomerGroupId')->willReturn($customerGroupId); + + return $customerSession; + } +} diff --git a/USAGE.md b/USAGE.md index 27d21a13..eef1c1dd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -11,6 +11,33 @@ The extension has the following configuration options: - **Debug**: Enable this for additional debugging in a logfile and the browser console. - **Choose script placement**: Setting this option to YES will remove the tracking script from the page. Only use this option if you want to choose where the script is placed in the page. This option is not recommended for most users. +# Hiding prices on B2B stores +B2B stores often only show prices to logged in (or approved) customers. Everything that ends up in the +dataLayer is readable in the page source and in the browser console, so a hidden price is still exposed +unless the dataLayer leaves it out as well. + +Configure this under **Admin Panel > Stores > Configuration > AdPage > AdPage GTM > Price visibility**: + +- **Hide prices in the dataLayer**: turns the feature on. Off by default, so nothing changes on existing stores. +- **Hide prices for customer groups**: the customer groups that get no prices. Select **NOT LOGGED IN** to hide + prices from guests, which is the usual B2B setup. +- **Hide prices by**: either leave the price out of the dataLayer completely (default), or send it as `0`. Pick + `0` when your GTM container expects the `price` and `value` keys to always be present. + +What is covered for a visitor in one of the selected groups: + +| Key | Where | +|---|---| +| `items[].price` | `view_item`, `view_item_list`, `view_search_results`, `add_to_wishlist`, `add_to_cart`, `remove_from_cart`, `view_cart`, `begin_checkout`, `add_shipping_info`, `add_payment_info` | +| `ecommerce.value` | `view_item`, `add_to_cart`, `view_cart`, `begin_checkout`, `add_payment_info` | +| `price` | The product data block on the product page (`window.Tagging_GTM_PRODUCT_DATA_ID_`) | + +Order data is deliberately **not** touched: the `purchase` and `refund` events on the success page and the +`order_created` webhook keep their real amounts. A visitor who cannot see prices cannot place an order, and +blanking those amounts would break revenue reporting. For the same reason the setting only applies to the +storefront - the admin panel, the REST API and the cron job that retries failed webhooks always keep the real +amounts. + # Purchase deduplication Every purchase carries an `event_id` (`purchase.`) in the dataLayer event *and* in the `order_created` webhook, with the exact same value on both sides. Map that variable onto the Event ID field of diff --git a/Util/PriceVisibility.php b/Util/PriceVisibility.php new file mode 100644 index 00000000..4bfe015f --- /dev/null +++ b/Util/PriceVisibility.php @@ -0,0 +1,109 @@ +config = $config; + $this->customerSession = $customerSession; + $this->appState = $appState; + } + + /** + * Whether prices may be added to the dataLayer for the current visitor + * + * @return bool + */ + public function isAllowed(): bool + { + if (false === $this->config->isHidePricesEnabled()) { + return true; + } + + if (false === $this->isFrontend()) { + return true; + } + + $hiddenForCustomerGroups = $this->config->getHidePricesCustomerGroups(); + if (empty($hiddenForCustomerGroups)) { + return true; + } + + return !in_array($this->getCustomerGroupId(), $hiddenForCustomerGroups, true); + } + + /** + * Whether a hidden price should be left out of the dataLayer instead of being sent as 0 + * + * @return bool + */ + public function isRemoveMode(): bool + { + return $this->config->getHidePricesMode() === HidePriceMode::MODE_REMOVE; + } + + /** + * Return the price to expose, or null when the key should be left out of the dataLayer entirely + * + * @param float|null $price + * @return float|null + */ + public function filter(?float $price): ?float + { + if ($this->isAllowed()) { + return $price; + } + + return $this->isRemoveMode() ? null : 0.0; + } + + /** + * @return int + */ + private function getCustomerGroupId(): int + { + return (int)$this->customerSession->getCustomerGroupId(); + } + + /** + * @return bool + */ + private function isFrontend(): bool + { + try { + return $this->appState->getAreaCode() === Area::AREA_FRONTEND; + } catch (LocalizedException $localizedException) { + return false; + } + } +} diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index b58f433d..fd18dd09 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -64,7 +64,40 @@ - + + + + + Magento\Config\Model\Config\Source\Yesno + + NOTE; Order data on the success page and in the order_created webhook always keeps its + real amounts, otherwise revenue reporting would break.]]> + + 1 + + + + + Tagging\GTM\Model\Config\Source\CustomerGroups + Visitors in these customer groups get no prices in the dataLayer. Select "NOT LOGGED IN" to hide prices from guests. + + 1 + 1 + + + + + Tagging\GTM\Model\Config\Source\HidePriceMode + Leaving the price out is the cleanest option. Choose "Send the price as 0" when your GTM container expects the price and value keys to always be present. + + 1 + 1 + + + + 0 diff --git a/etc/config.xml b/etc/config.xml index 7fd13b5d..c1e318c1 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -8,5 +8,12 @@ 0 + + + 0 + 0 + remove + + diff --git a/etc/di.xml b/etc/di.xml index 3392c8da..94e502bc 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -10,6 +10,16 @@ + + + + Magento\Customer\Model\Session\Proxy + + + From 72d85cd1645fed035af40322a3e319be11f37254 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 3 Sep 2026 17:49:18 +0000 Subject: [PATCH 2/2] Cover price visibility with an integration test The CI workflow only runs Test/Integration, so the unit test added alongside Util\PriceVisibility never executes there. This adds an integration test that runs in CI and exercises the real path: a guest on the storefront, the config switched on through @magentoConfigFixture, and ProductDataMapper asserted to leave the price key out (remove mode) or report it as 0 (zero mode). Asserts on the presence and absence of the key rather than an exact amount, so the store's tax configuration cannot make it brittle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UpDMDuDY9kEpftGqrUnDyJ --- Test/Integration/Util/PriceVisibilityTest.php | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 Test/Integration/Util/PriceVisibilityTest.php diff --git a/Test/Integration/Util/PriceVisibilityTest.php b/Test/Integration/Util/PriceVisibilityTest.php new file mode 100644 index 00000000..28c0b04b --- /dev/null +++ b/Test/Integration/Util/PriceVisibilityTest.php @@ -0,0 +1,104 @@ +assertTrue($this->getPriceVisibility()->isAllowed()); + + $productData = $this->mapProduct(); + $this->assertArrayHasKey('price', $productData); + $this->assertGreaterThan(0, $productData['price']); + } + + /** + * @magentoConfigFixture current_store GTM/settings/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/customer_groups 0 + * @magentoConfigFixture current_store GTM/price_visibility/mode remove + * @magentoAppArea frontend + * @magentoAppIsolation enabled + */ + public function testPriceIsLeftOutForAGuestInRemoveMode() + { + $this->assertFalse($this->getPriceVisibility()->isAllowed()); + $this->assertArrayNotHasKey('price', $this->mapProduct()); + } + + /** + * @magentoConfigFixture current_store GTM/settings/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/customer_groups 0 + * @magentoConfigFixture current_store GTM/price_visibility/mode zero + * @magentoAppArea frontend + * @magentoAppIsolation enabled + */ + public function testPriceIsZeroedForAGuestInZeroMode() + { + $this->assertFalse($this->getPriceVisibility()->isAllowed()); + + $productData = $this->mapProduct(); + $this->assertArrayHasKey('price', $productData); + $this->assertEquals(0.0, $productData['price']); + } + + /** + * A guest is in group 0, so selecting only group 1 must leave the price alone. + * + * @magentoConfigFixture current_store GTM/settings/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/enabled 1 + * @magentoConfigFixture current_store GTM/price_visibility/customer_groups 1 + * @magentoConfigFixture current_store GTM/price_visibility/mode remove + * @magentoAppArea frontend + * @magentoAppIsolation enabled + */ + public function testPriceIsExposedForACustomerGroupThatIsNotSelected() + { + $this->assertTrue($this->getPriceVisibility()->isAllowed()); + + $productData = $this->mapProduct(); + $this->assertArrayHasKey('price', $productData); + $this->assertGreaterThan(0, $productData['price']); + } + + /** + * @return array + */ + private function mapProduct(): array + { + /** @var ProductInterface $product */ + $product = $this->createProduct(1); + $productDataMapper = ObjectManager::getInstance()->get(ProductDataMapper::class); + + return $productDataMapper->mapByProduct($product); + } + + /** + * @return PriceVisibility + */ + private function getPriceVisibility(): PriceVisibility + { + return ObjectManager::getInstance()->get(PriceVisibility::class); + } +}