cart->add_fee(). */ defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/../system/extnify-core/traits/debug-log.php'; class Extnify_Offer_Offer { use Extnify_Debug_Log; /** Free understands only the flat rate type. */ private static $free_types = array( 'flat' ); /** Whether the global Debugging setting is on (computed per calculation). */ private $debug = null; /** * Apply every matching fee to the cart. Called once per totals calculation. */ public function apply( WC_Cart $cart ): void { $this->debug = $this->debug_enabled(); $eligible = $this->build_eligible( $cart ); if ( empty( $eligible ) ) { return; } // Offers only apply when CHOSEN by the customer (defaults auto-apply once). $chosen = $this->chosen_offers( $eligible ); $collected = array(); foreach ( $eligible as $e ) { if ( ! in_array( $e['id'], $chosen, true ) ) { $this->log( 'info', sprintf( '#%d "%s" eligible but not selected — not applied.', $e['id'], $e['label'] ) ); continue; } $collected[] = $e; } usort( $collected, function ( $a, $b ) { return $a['sort_order'] <=> $b['sort_order']; } ); foreach ( $collected as $e ) { // add_fee( name, amount, taxable, tax_class ). Negative amount = discount. $cart->add_fee( $e['label'], $e['amount'], $e['taxable'], $e['tax_class'] ); } } /** * Compute every ELIGIBLE offer (passes rules + a non-zero flat amount). Returns * records {id,label,amount,taxable,tax_class,sort_order,select}. Shared by apply() * and eligible_offers(). Honors Module Status. */ private function build_eligible( WC_Cart $cart ): array { $global = (array) get_option( 'extnify_offer', array() ); if ( '1' !== ( isset( $global['status'] ) ? (string) $global['status'] : '1' ) ) { $this->log( 'info', 'Module Status is disabled — no offers applied.' ); return array(); } $rows = ( new Extnify_Offer_Model() )->getData(); if ( empty( $rows ) ) { return array(); } $subtotal = (float) $cart->get_subtotal(); $eligible = array(); foreach ( $rows as $row ) { $m = array_merge( $this->defaults(), (array) $row['method_data'] ); $id = (int) $row['id']; $name = ! empty( $m['display'] ) ? $m['display'] : ( ! empty( $m['name'] ) ? $m['name'] : 'Offer #' . $id ); if ( $m['status'] !== '1' ) { continue; } if ( ! in_array( $m['rate_type'], self::$free_types, true ) ) { continue; // Pro rate type } if ( ! $this->rule_geo_zone( $m ) || ! $this->rule_payment( $m ) || ! $this->rule_shipping( $m ) ) { continue; } $cost = $this->parse_cost( (string) ( $m['cost'] ?? '' ), $subtotal ); if ( 0.0 === $cost ) { continue; } $taxable = ! empty( $m['tax_class_id'] ) && 'none' !== $m['tax_class_id']; $label = ! empty( $m['name'] ) ? $m['name'] : ( ! empty( $m['display'] ) ? $m['display'] : __( 'Offer', 'extnify-offer' ) ); $eligible[] = array( 'id' => $id, 'label' => $label, 'amount' => $cost, 'taxable' => $taxable, 'tax_class' => $taxable ? (string) $m['tax_class_id'] : '', 'sort_order' => (int) ( $m['sort_order'] ?? 0 ), 'select' => ! empty( $m['select'] ), ); } return $eligible; } /** Public list for the storefront "Recommended Offers" widget. */ public function eligible_offers( WC_Cart $cart ): array { $eligible = $this->build_eligible( $cart ); if ( empty( $eligible ) ) { return array(); } $chosen = $this->chosen_offers( $eligible ); $out = array(); foreach ( $eligible as $e ) { $out[] = array( 'id' => $e['id'], 'title' => $e['label'], 'amount' => (float) $e['amount'], 'amount_html' => function_exists( 'wc_price' ) ? wp_strip_all_tags( wc_price( (float) $e['amount'] ) ) : number_format( (float) $e['amount'], 2 ), 'selected' => in_array( $e['id'], $chosen, true ), 'sort_order' => (int) $e['sort_order'], ); } usort( $out, function ( $a, $b ) { return $a['sort_order'] <=> $b['sort_order']; } ); return $out; } /** single | multiple offer-selection mode (default multiple). */ private function offer_type(): string { $cfg = (array) get_option( 'extnify_offer', array() ); return ( ( $cfg['offer_type'] ?? 'multiple' ) === 'single' ) ? 'single' : 'multiple'; } /** * Offer ids to APPLY: the session's chosen set, plus any eligible offer flagged * "Default Select?" auto-added once. After the shopper interacts (xoffer_touched) * their selection is authoritative. Single mode keeps one. * @param array $eligible records with id/select * @return int[] */ private function chosen_offers( array $eligible ): array { $session = ( function_exists( 'WC' ) && WC()->session ) ? WC()->session : null; $raw_c = $session ? $session->get( 'xoffer_chosen' ) : array(); $raw_p = $session ? $session->get( 'xoffer_preset' ) : array(); $chosen = is_array( $raw_c ) ? array_map( 'intval', $raw_c ) : array(); $preset = is_array( $raw_p ) ? array_map( 'intval', $raw_p ) : array(); if ( $session && $session->get( 'xoffer_touched' ) ) { return array_values( array_unique( $chosen ) ); } $single = 'single' === $this->offer_type(); $changed = false; foreach ( $eligible as $e ) { if ( empty( $e['select'] ) || in_array( $e['id'], $preset, true ) ) { continue; } $preset[] = $e['id']; $changed = true; if ( $single && ! empty( $chosen ) ) { continue; } $chosen[] = $e['id']; } if ( $single && count( $chosen ) > 1 ) { $chosen = array( (int) $chosen[0] ); $changed = true; } if ( $changed && $session ) { $session->set( 'xoffer_chosen', array_values( array_unique( $chosen ) ) ); $session->set( 'xoffer_preset', array_values( array_unique( $preset ) ) ); } return array_values( array_unique( $chosen ) ); } /** Save the shopper's selection (sanitized, single-trimmed) + mark touched. */ public function set_chosen( array $ids ): array { $ids = array_values( array_unique( array_map( 'intval', $ids ) ) ); if ( 'single' === $this->offer_type() && count( $ids ) > 1 ) { $ids = array( $ids[0] ); } if ( function_exists( 'WC' ) && WC()->session ) { WC()->session->set( 'xoffer_chosen', $ids ); WC()->session->set( 'xoffer_touched', '1' ); } return $ids; } // ── Rules ─────────────────────────────────────────────────────────────── /** Shipping Zone: customer's address falls in one of the selected WC zones. */ private function rule_geo_zone( array $m ): bool { if ( ( $m['geo_zone_all'] ?? '1' ) == '1' || empty( $m['geo_zone'] ) ) { return true; } $matched = WC_Shipping_Zones::get_zone_matching_package( $this->address_package() ); if ( ! $matched ) { return false; } return in_array( (string) $matched->get_id(), array_map( 'strval', (array) $m['geo_zone'] ), true ); } /** Payment Method: the chosen gateway is one of the selected ones. */ private function rule_payment( array $m ): bool { if ( ( $m['payment_all'] ?? '1' ) == '1' || empty( $m['payment'] ) ) { return true; } $chosen = WC()->session ? (string) WC()->session->get( 'chosen_payment_method' ) : ''; if ( '' === $chosen ) { return false; } return in_array( $chosen, array_map( 'strval', (array) $m['payment'] ), true ); } /** Shipping Method: one of the chosen shipping methods is in the selected list. */ private function rule_shipping( array $m ): bool { if ( ( $m['shipping_all'] ?? '1' ) == '1' || empty( $m['shipping'] ) ) { return true; } $chosen = WC()->session ? (array) WC()->session->get( 'chosen_shipping_methods' ) : array(); if ( empty( $chosen ) ) { return false; } $want = array_map( 'strval', (array) $m['shipping'] ); foreach ( $chosen as $rate_id ) { // $rate_id is like "flat_rate:3" — match both the full id and its base method id. $base = strtok( (string) $rate_id, ':' ); if ( in_array( (string) $rate_id, $want, true ) || in_array( (string) $base, $want, true ) ) { return true; } } return false; } /** A minimal shipping package built from the customer's destination address. */ private function address_package(): array { $c = WC()->customer; return array( 'destination' => array( 'country' => $c ? $c->get_shipping_country() : '', 'state' => $c ? $c->get_shipping_state() : '', 'postcode' => $c ? $c->get_shipping_postcode() : '', 'city' => $c ? $c->get_shipping_city() : '', ), ); } // ── Amount ────────────────────────────────────────────────────────────── /** * Cost string → amount. A plain number is flat; "N%" is a percentage of the * cart subtotal. Negative values are allowed (a discount). */ private function parse_cost( string $str, float $subtotal ): float { $str = trim( $str ); if ( '' === $str ) { return 0.0; } if ( '%' === substr( $str, -1 ) ) { return (float) rtrim( $str, '%' ) * $subtotal / 100.0; } return (float) $str; } // ── Debug log (gated by the global Debugging setting) ───────────────────── protected function debug_option_key(): string { return 'extnify_offer'; } protected function debug_log_key(): string { return 'extnify_offer_debug_log'; } /** The free subset of a method's fields. */ private function defaults(): array { return array( 'status' => '1', 'rate_type' => 'flat', 'cost' => '', 'geo_zone_all' => '1', 'geo_zone' => array(), 'payment_all' => '1', 'payment' => array(), 'shipping_all' => '1', 'shipping' => array(), 'name' => '', 'display' => '', 'sort_order' => 0, 'tax_class_id' => '', 'select' => '', ); } }