*/ private static $record_pending = array(); /** * Post meta key for per-product regulated status. * Values: 'not_regulated' | 'regulated' | 'override_not_regulated' | (absent — treated as 'not_regulated') */ const META_KEY_PRODUCT_REGULATED_STATUS = '_agewallet_regulated_status'; /** * Term meta key for category/tag-level regulated flag. * Value: '1' if regulated, absent/'' otherwise. */ const META_KEY_TERM_REGULATED = 'agewallet_regulated'; /** * Default per-product regulated status when no post meta is set. */ const PRODUCT_REGULATED_STATUS_DEFAULT = 'not_regulated'; /** * Order meta key for the verification record: the UTC time (Y-m-d H:i:s) the order was * placed by a verified shopper. Absent on orders without a record. */ const META_KEY_ORDER_VERIFIED_AT = '_agewallet_verified_at'; /** * Order meta key set to '1' once monitor_order_status() has noted, logged and emailed that * the order has no verification record. */ const META_KEY_ORDER_NO_RECORD_NOTIFIED = '_agewallet_no_record_notified'; /** * The Store API checkout route that places an order: POST /wc/store/v1/checkout * (also /wc/store/checkout). */ const STORE_API_CHECKOUT_ROUTE = '#^/wc/store(?:/v\d+)?/checkout/?$#'; /** * The Cart and Checkout blocks' express payment area as WooCommerce draws it in the browser: * the cart's payment options, the express payment buttons, and the "Or" rule under them. */ const BLOCK_EXPRESS_PAYMENT_SELECTORS = array( '.wc-block-cart__payment-options', '.wc-block-components-express-payment', '.wc-block-components-express-payment-continue-rule', ); public static function instance() { if ( is_null( self::$instance ) ) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_filter( 'agewallet_metadata', array( $this, 'inject_checkout_metadata' ) ); // The Age Restriction box leaves product edit screens: a product's viewing rule is part // of its Age verification choice (AgeWallet_Product_Flags). add_filter( 'agewallet_meta_box_post_types', array( $this, 'drop_product_meta_box' ) ); // 1. Listing cards of products whose page is gated are masked (classic loops, the grid // blocks and the Product Collection block). Listings are the same for every visitor, // so they can be cached; the browser reveals the card from the verification cookie. add_action( 'woocommerce_before_shop_loop_item', array( $this, 'open_listing_card' ), PHP_INT_MIN ); add_action( 'woocommerce_after_shop_loop_item', array( $this, 'close_listing_card' ), PHP_INT_MAX ); add_filter( 'woocommerce_blocks_product_grid_item_html', array( $this, 'mask_product_grid_item' ), 20, 3 ); add_filter( 'render_block_woocommerce/product-template', array( $this, 'mask_product_template_block' ), 20 ); // 2. Cart, checkout and mini-cart, when the cart needs verification: the Verify my age // message, with the express checkout buttons hidden (and Place order on the checkout). // These aren't page-cached, so the server decides. add_action( 'woocommerce_proceed_to_checkout', array( $this, 'print_cart_notice' ), 1 ); add_action( 'woocommerce_before_checkout_form', array( $this, 'print_checkout_notice' ), 1 ); add_action( 'woocommerce_checkout_before_customer_details', array( $this, 'open_checkout_actions' ), PHP_INT_MIN ); add_action( 'woocommerce_checkout_before_customer_details', array( $this, 'close_order_actions' ), 9 ); add_action( 'woocommerce_review_order_before_submit', array( $this, 'open_checkout_actions' ), 1 ); add_action( 'woocommerce_review_order_after_submit', array( $this, 'close_order_actions' ), 999 ); add_action( 'woocommerce_review_order_after_payment', array( $this, 'open_checkout_actions' ), 1 ); add_action( 'woocommerce_review_order_after_payment', array( $this, 'close_order_actions' ), 999 ); add_action( 'woocommerce_widget_shopping_cart_after_buttons', array( $this, 'open_mini_cart_actions' ), PHP_INT_MIN ); add_action( 'woocommerce_widget_shopping_cart_after_buttons', array( $this, 'close_order_actions' ), PHP_INT_MAX ); add_filter( 'render_block_woocommerce/cart', array( $this, 'prepend_cart_block_notice' ) ); add_filter( 'render_block_woocommerce/checkout', array( $this, 'prepend_checkout_block_notice' ) ); // 3. Orders placed by unverified shoppers are refused on the server: at the classic // checkout's validation, at the Store API's checkout (POST only), and, for payment // plugins that build orders themselves, before the order or any of its lines is saved. add_filter( 'rest_request_before_callbacks', array( $this, 'flag_store_api_checkout' ), 10, 3 ); add_action( 'woocommerce_after_checkout_validation', array( $this, 'refuse_classic_checkout' ), 10, 2 ); add_action( 'woocommerce_check_cart_items', array( $this, 'block_unverified_checkout' ) ); add_action( 'woocommerce_before_order_object_save', array( $this, 'guard_unverified_order' ), 5 ); add_action( 'woocommerce_before_order_item_object_save', array( $this, 'guard_unverified_order_item' ), 5 ); // 4. The verification record on orders placed by verified shoppers, and the check of // paid orders without one. add_action( 'woocommerce_checkout_order_processed', array( $this, 'record_classic_order' ), 10, 3 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'record_verification' ) ); add_action( 'woocommerce_after_order_object_save', array( $this, 'fire_pending_record_hook' ) ); add_action( 'woocommerce_order_status_changed', array( $this, 'monitor_order_status' ), 10, 4 ); // 5. The order edit screen shows the verification record under the order details. add_action( 'woocommerce_admin_order_data_after_order_details', array( $this, 'show_order_verification' ) ); } /** * agewallet_meta_box_post_types: the Age Restriction meta box is not added for products. * * @param string[] $post_types * @return string[] */ public function drop_product_meta_box( $post_types ) { return array_values( array_diff( (array) $post_types, array( 'product' ) ) ); } /** * True when the current cart must be age-verified before it can be ordered, and the * visitor is not yet verified. Decides checkout validation and the cart and checkout * pages' Verify my age message: * - off → never * - force-always → the cart holds a product not set to Never require verification * - conditional-on-cart → the cart holds a regulated item */ public static function cart_requires_verification() { $mode = self::checkout_gating_mode(); if ( AgeWalletOIDCClientPro::WC_GATE_MODE_OFF === $mode ) { return false; } if ( ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) { return false; } if ( self::visitor_is_verified() ) { return false; } if ( AgeWalletOIDCClientPro::WC_GATE_MODE_FORCE_ALWAYS === $mode ) { foreach ( WC()->cart->get_cart() as $item ) { if ( ! empty( $item['data'] ) && self::product_requires_verification( $item['data'] ) ) { return true; } } return false; } // conditional-on-cart return self::cart_contains_regulated_items(); } /** * True if the visitor counts as age-verified (AgeWallet_Helpers::visitor_is_verified()): a * valid (HMAC + unexpired) verification cookie, or what the agewallet_visitor_is_verified * filter returns. A cookie that fails the check is expired in the browser by this response * (AgeWallet_Helpers::clear_verified_cookie()), once per request. */ public static function visitor_is_verified() { if ( ! class_exists( 'AgeWallet_Helpers' ) ) { return false; } static $cookie_checked = false; if ( ! $cookie_checked ) { $cookie_checked = true; if ( ! AgeWallet_Helpers::instance()->has_valid_verified_cookie() ) { AgeWallet_Helpers::instance()->clear_verified_cookie(); } } return AgeWallet_Helpers::instance()->visitor_is_verified(); } /** * True when this visitor can't have this product in an order yet: a shopper (not staff, * wp-admin, cron or scheduled tasks) who hasn't verified, and the product needs * verification. * * @param mixed $product * @return bool */ public static function refuses( $product ) { return self::product_requires_verification( $product ) && self::is_shopper_request() && ! self::visitor_is_verified(); } // --- Listing cards --- /** * woocommerce_before_shop_loop_item (classic product loops: shop, category, tag, search, * related, upsells, cross-sells), lowest priority: for a product whose page is gated, * prints the mask (card_mask_html()) and opens the hidden wrapper around the card. */ public function open_listing_card() { global $product; if ( ! self::card_is_masked( $product ) ) { return; } echo self::card_mask_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in card_mask_html(). echo ''; wp_print_inline_script_tag( self::reveal_script() ); } /** * woocommerce_blocks_product_grid_item_html (the grid blocks: Handpicked Products, Best * Selling Products, Products by Category and the others drawn from AbstractProductGrid): * the card's content masked when the product's page is gated. * * @param string $html The
  • element. * @param object $data The card's parts (image, title, price, badge, button, permalink). * @param WC_Product $product * @return string */ public function mask_product_grid_item( $html, $data, $product ) { if ( ! self::card_is_masked( $product ) ) { return $html; } return self::mask_list_item( $html ); } /** * render_block_woocommerce/product-template (the Product Collection block and the Products * block built on it): each
  • * whose product's page is gated has its content masked. * * @param string $block_content The block's HTML: a