cart->add_fee(). */ defined( 'ABSPATH' ) || exit; class XFee_Fee { /** 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 = false; /** * Apply every matching fee to the cart. Called once per totals calculation. */ public function apply( WC_Cart $cart ): void { $this->debug = $this->debug_enabled(); // Global "Module Status" is the master switch (Global Settings → General). // When it is disabled, NO fees are applied — matching OpenCart. Absent = // enabled (so a fresh install with configured fees works out of the box). $global = (array) get_option( 'x_feepro', array() ); $status = isset( $global['status'] ) ? (string) $global['status'] : '1'; if ( '1' !== $status ) { $this->log( 'info', 'Module Status is disabled — no fees applied.' ); return; } $model = new XFee_Model(); $rows = $model->getData(); if ( empty( $rows ) ) { $this->log( 'info', 'No fee methods configured.' ); return; } $subtotal = (float) $cart->get_subtotal(); $collected = array(); // Heartbeat — with Debugging on, this appears on every cart/checkout calc, // so enabling Debug + one page load immediately shows the log is live. $this->log( 'debug', sprintf( 'Fee calculation started — %d method(s), subtotal %s.', count( $rows ), number_format( $subtotal, 2 ) ) ); 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'] : 'Fee #' . $id ); if ( $m['status'] !== '1' ) { continue; } // Free supports only the flat rate type — a Pro-only type is skipped. if ( ! in_array( $m['rate_type'], self::$free_types, true ) ) { $this->log( 'warn', sprintf( '#%d "%s" skipped — rate type "%s" is a Pro feature.', $id, $name, $m['rate_type'] ) ); continue; } // Rules gate (whole cart). if ( ! $this->rule_geo_zone( $m ) ) { $this->log( 'warn', sprintf( '#%d "%s" not applied — Shipping Zone rule did not match.', $id, $name ) ); continue; } if ( ! $this->rule_payment( $m ) ) { $this->log( 'warn', sprintf( '#%d "%s" not applied — Payment Method rule did not match.', $id, $name ) ); continue; } if ( ! $this->rule_shipping( $m ) ) { $this->log( 'warn', sprintf( '#%d "%s" not applied — Shipping Method rule did not match.', $id, $name ) ); continue; } $cost = $this->parse_cost( (string) ( $m['cost'] ?? '' ), $subtotal ); if ( 0.0 === $cost ) { $this->log( 'info', sprintf( '#%d "%s" resolved to 0 — not applied.', $id, $name ) ); continue; } $label = ! empty( $m['name'] ) ? $m['name'] : ( ! empty( $m['display'] ) ? $m['display'] : __( 'Fee', 'extnify-fee' ) ); $this->log( 'info', sprintf( '#%d "%s" applied — amount %s.', $id, $name, number_format( $cost, 2 ) ) ); $taxable = ! empty( $m['tax_class_id'] ) && 'none' !== $m['tax_class_id']; $collected[] = array( 'id' => $id, 'label' => $label, 'amount' => $cost, 'taxable' => $taxable, 'tax_class' => $taxable ? (string) $m['tax_class_id'] : '', 'sort_order' => (int) ( $m['sort_order'] ?? 0 ), ); } // Respect the per-method Sort Order. 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'] ); } } // ── 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 a flat amount; "N%" is a percentage of * the cart subtotal. Negative values are allowed (a discount). Both can be + or −. */ 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) ───────────────────── private function debug_enabled(): bool { $s = (array) get_option( 'x_feepro', array() ); return isset( $s['debug'] ) && '1' === (string) $s['debug']; } /** * Append one entry to the debug log — only when Debugging is enabled. * Stored in the database (a capped transient), never written to a file. */ private function log( string $level, string $message ): void { if ( ! $this->debug ) { return; } $entries = get_transient( 'x_feepro_debug_log' ); if ( ! is_array( $entries ) ) { $entries = array(); } $entries[] = array( 'time' => current_time( 'H:i:s' ), 'level' => strtolower( $level ), 'message' => $message, ); if ( count( $entries ) > 300 ) { $entries = array_slice( $entries, -300 ); } set_transient( 'x_feepro_debug_log', $entries, WEEK_IN_SECONDS ); } /** 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' => '', ); } }