api_client = new GC_API_Client(); $this->template_engine = new GC_Template_Engine(); $this->init_hooks(); } /** * Initialize hooks. * * @return void */ private function init_hooks(): void { // Only load on frontend. if ( is_admin() ) { return; } // Price display filter. add_filter( 'woocommerce_get_price_html', array( $this, 'maybe_show_discount_price' ), 10, 2 ); // Cart discount fee. add_action( 'woocommerce_cart_calculate_fees', array( $this, 'apply_cart_discount' ) ); // Invalidate cart discount cache on cart update. add_action( 'woocommerce_cart_updated', array( $this, 'invalidate_cart_discount_cache' ) ); // Enqueue frontend assets. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // Clear cache on logout. add_action( 'wp_logout', array( $this, 'clear_user_discount_cache' ) ); } /** * Check if discount display should be shown. * * @return bool */ private function should_show_discount(): bool { // Must be logged in. if ( ! is_user_logged_in() ) { return false; } // Check if discount display is enabled. if ( 'no' === get_option( 'genius_commerce_enable_discount_display', 'yes' ) ) { return false; } return true; } /** * Maybe show discount price HTML. * * Filters the price HTML to show customer-specific discount. * * @param string $price_html Original price HTML. * @param \WC_Product $product Product object. * @return string Modified price HTML. */ public function maybe_show_discount_price( string $price_html, \WC_Product $product ): string { // Check if we should show discount. if ( ! $this->should_show_discount() ) { return $price_html; } // Skip variable products (handled by variations). if ( $product->is_type( 'variable' ) ) { return $price_html; } // Get discount data. $discount_data = $this->get_product_discount( $product ); // No discount available, return original. // Backend response format: originalPriceIncVAT, discountedPriceIncVAT, discountPercentage, discountReason $has_discount = ! empty( $discount_data['discountPercentage'] ) && $discount_data['discountPercentage'] > 0; if ( ! $discount_data || ! $has_discount ) { return $price_html; } // Render discount price template. return $this->render_discount_price( $product, $discount_data, $price_html ); } /** * Get discount data for a product. * * @param \WC_Product $product Product object. * @return array|null Discount data or null if not available. */ private function get_product_discount( \WC_Product $product ): ?array { $user_id = get_current_user_id(); $product_id = $product->get_id(); $sku = $product->get_sku(); // SKU is required. if ( empty( $sku ) ) { return null; } // Check cache first. $cache_key = $this->get_cache_key( $user_id, $product_id ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } // Fetch from API. $discount_data = $this->fetch_discount_from_api( $user_id, $sku, (float) $product->get_price() ); // Cache the result (even if null to prevent repeated failed calls). set_transient( $cache_key, $discount_data ?? array(), self::CACHE_TTL ); return $discount_data; } /** * Fetch discount data from Genius Commerce API. * * @param int $user_id WordPress user ID. * @param string $sku Product SKU. * @param float $original_price Original product price. * @return array|null Discount data or null on failure. */ private function fetch_discount_from_api( int $user_id, string $sku, float $original_price ): ?array { $result = $this->api_client->request( 'POST', '/calculate-prices', array( 'wcUserId' => $user_id, 'products' => array( array( 'sku' => $sku, 'quantity' => 1, 'price' => $original_price, ), ), ) ); // API call failed - graceful fallback. if ( ! $result['success'] ) { Genius_Commerce::log( sprintf( 'Failed to fetch discount for SKU %s: %s', $sku, $result['error'] ?? 'Unknown error' ), 'warning' ); return null; } // Extract price data from response. $data = $result['data'] ?? array(); // Handle direct response format. if ( isset( $data['prices'] ) && is_array( $data['prices'] ) ) { foreach ( $data['prices'] as $price_data ) { if ( isset( $price_data['sku'] ) && $price_data['sku'] === $sku ) { return $price_data; } } } // Handle single product response. if ( isset( $data['sku'] ) && $data['sku'] === $sku ) { return $data; } return null; } /** * Render discount price HTML. * * @param \WC_Product $product Product object. * @param array $discount_data Discount data from API. * @param string $original_html Original price HTML. * @return string Rendered discount price HTML. */ private function render_discount_price( \WC_Product $product, array $discount_data, string $original_html ): string { // Backend response format: originalPriceIncVAT, discountedPriceIncVAT, discountPercentage, discountReason $original_price = $discount_data['originalPriceIncVAT'] ?? (float) $product->get_price(); $discount_price = $discount_data['discountedPriceIncVAT'] ?? $original_price; $discount_percentage = $discount_data['discountPercentage'] ?? 0; $discount_amount = $original_price - $discount_price; $discount_reason = $discount_data['discountReason'] ?? ''; // Format prices for display. $formatted_original = wc_price( $original_price ); $formatted_discount = wc_price( $discount_price ); // Build template arguments. $template_args = array( 'original_price' => $formatted_original, 'discount_price' => $formatted_discount, 'discount_percentage' => round( $discount_percentage ), 'discount_amount' => wc_price( $discount_amount ), 'discount_reason' => $discount_reason, 'product' => $product, 'original_html' => $original_html, ); /** * Filter the discount price template arguments. * * Allows themes to modify or add template variables. * * @since 1.0.0 * * @param array $template_args Template arguments. * @param \WC_Product $product The product object. * @param array $discount_data Raw discount data from API. */ $template_args = apply_filters( 'genius_commerce_discount_price_template_args', $template_args, $product, $discount_data ); // Render using template engine. return $this->template_engine->render( 'discount-price.php', $template_args ); } /** * Get cache key for user/product discount. * * @param int $user_id User ID. * @param int $product_id Product ID. * @return string Cache key. */ private function get_cache_key( int $user_id, int $product_id ): string { return sprintf( 'genius_commerce_discount_%d_%d', $user_id, $product_id ); } /** * Clear discount cache for current user. * * @return void */ public function clear_user_discount_cache(): void { $user_id = get_current_user_id(); if ( ! $user_id ) { return; } // We can't easily clear all product caches, but the 5 minute TTL // ensures they expire naturally. For immediate clearing on logout, // we rely on the new session not having cached data. Genius_Commerce::log( sprintf( 'User %d logged out, discount cache will expire naturally', $user_id ), 'debug' ); } /** * Enqueue frontend assets. * * @return void */ public function enqueue_assets(): void { // Only on WooCommerce pages. if ( ! is_woocommerce() && ! is_cart() && ! is_checkout() ) { return; } wp_enqueue_style( 'genius-commerce-frontend', GENIUS_COMMERCE_PLUGIN_URL . 'assets/css/frontend.css', array(), GENIUS_COMMERCE_VERSION ); } /** * Get discounts for multiple products (for cart integration). * * @param array $products Array of products with sku and quantity. * @return array|null Discount data for all products. */ public function get_cart_discounts( array $products ): ?array { if ( ! is_user_logged_in() ) { return null; } $user_id = get_current_user_id(); // Build cache key from all products. $products_hash = md5( wp_json_encode( $products ) ); $cache_key = sprintf( 'genius_commerce_cart_discount_%d_%s', $user_id, $products_hash ); // Check cache. $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } // Fetch from API. $result = $this->api_client->request( 'POST', '/calculate-prices', array( 'wcUserId' => $user_id, 'products' => $products, ) ); if ( ! $result['success'] ) { Genius_Commerce::log( sprintf( 'Failed to fetch cart discounts: %s', $result['error'] ?? 'Unknown error' ), 'warning' ); return null; } $data = $result['data'] ?? array(); // Cache the result. set_transient( $cache_key, $data, self::CACHE_TTL ); return $data; } /** * Apply customer discount as negative fee to cart. * * Hooked to woocommerce_cart_calculate_fees. * * @param \WC_Cart $cart Cart object. * @return void */ public function apply_cart_discount( \WC_Cart $cart ): void { // Must be logged in. if ( ! is_user_logged_in() ) { return; } // Check if discount display is enabled. if ( 'no' === get_option( 'genius_commerce_enable_discount_display', 'yes' ) ) { return; } // Avoid running during admin AJAX calls for cart fragments. if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { return; } // Get cart products. $products = $this->get_cart_products(); if ( empty( $products ) ) { return; } // Get discount data from API. $discount_data = $this->get_cart_discounts( $products ); if ( ! $discount_data || empty( $discount_data['prices'] ) ) { return; } // Calculate total discount from individual product discounts. $discount_result = $this->calculate_total_discount( $discount_data, $products ); if ( $discount_result['total'] <= 0 ) { return; } // Add negative fee for discount. $fee_name = $discount_result['reason'] ?: __( 'Klantkorting', 'genius-commerce' ); // WooCommerce bug: negative fees ignore taxable=false and always calculate tax // See: https://github.com/woocommerce/woocommerce/issues/25719 // Workaround: Pass ex-VAT amount with taxable=true, so WooCommerce adds VAT correctly // Our API calculates discounts on incl-VAT prices, so we need to convert to ex-VAT $discount_incl_vat = $discount_result['total']; $vat_rate = 0.21; // Standard Dutch VAT rate $discount_excl_vat = $discount_incl_vat / ( 1 + $vat_rate ); $cart->add_fee( $fee_name, -$discount_excl_vat, true // Taxable - WooCommerce will add VAT to reach the correct incl-VAT amount ); Genius_Commerce::log( sprintf( 'Applied cart discount: %s (-%s)', $fee_name, wc_price( $discount_result['total'] ) ), 'debug' ); } /** * Get all products from cart for API request. * * @return array Array of products with sku and quantity. */ private function get_cart_products(): array { $products = array(); if ( ! WC()->cart ) { return $products; } foreach ( WC()->cart->get_cart() as $cart_item ) { /** @var \WC_Product $product */ $product = $cart_item['data']; $sku = $product->get_sku(); // Skip products without SKU. if ( empty( $sku ) ) { continue; } $products[] = array( 'sku' => $sku, 'quantity' => (int) $cart_item['quantity'], ); } return $products; } /** * Calculate total discount from API response. * * @param array $discount_data API response with prices array. * @param array $products Cart products with quantities. * @return array{total: float, reason: string} Total discount and reason. */ private function calculate_total_discount( array $discount_data, array $products ): array { $total_discount = 0.0; $discount_reason = ''; // Build quantity lookup by SKU. $quantity_by_sku = array(); foreach ( $products as $product ) { $quantity_by_sku[ $product['sku'] ] = $product['quantity']; } // Calculate discount for each product. foreach ( $discount_data['prices'] as $price_data ) { $sku = $price_data['sku'] ?? ''; if ( empty( $sku ) || ! isset( $quantity_by_sku[ $sku ] ) ) { continue; } // Backend response format: originalPriceIncVAT, discountedPriceIncVAT $original_price = (float) ( $price_data['originalPriceIncVAT'] ?? 0 ); $discounted_price = (float) ( $price_data['discountedPriceIncVAT'] ?? $original_price ); $quantity = $quantity_by_sku[ $sku ]; // Calculate discount per unit and multiply by quantity. $discount_per_unit = $original_price - $discounted_price; $total_discount += $discount_per_unit * $quantity; // Capture first discount reason. if ( empty( $discount_reason ) && ! empty( $price_data['discountReason'] ) ) { $discount_reason = $price_data['discountReason']; } } // Also check for customerGroup in response for reason fallback. if ( empty( $discount_reason ) && ! empty( $discount_data['customerGroup'] ) ) { $discount_reason = sprintf( /* translators: %s: customer group name */ __( 'Klantgroep: %s', 'genius-commerce' ), $discount_data['customerGroup'] ); } return array( 'total' => $total_discount, 'reason' => $discount_reason, ); } /** * Invalidate cart discount cache. * * Hooked to woocommerce_cart_updated. * * @return void */ public function invalidate_cart_discount_cache(): void { if ( ! is_user_logged_in() ) { return; } $user_id = get_current_user_id(); // We can't easily delete specific transients without knowing the hash, // but we can delete based on a pattern using a global transient list. // For simplicity, we rely on the hash-based cache key which automatically // invalidates when cart contents change (different hash = different key). Genius_Commerce::log( sprintf( 'Cart updated for user %d, discount cache will be recalculated', $user_id ), 'debug' ); } }