model = $model;
$this->form = new Form();
$this->misc = new Misc( self::group() );
$this->langs_data = $this->langs();
$this->form->setBasename( self::group() )->setLangs( $this->langs_data );
add_action( 'admin_menu', array( $this, 'register_submenu' ) );
add_action( 'admin_init', array( $this, 'handle_submenu_save' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
// AJAX action names are prefixed with this plugin's unique group
// (e.g. wp_ajax_x_feepro_load_method) so two active Extnify plugins never
// register the same hook — otherwise both handlers fire on one request.
$ajax = array( 'load_method', 'save_method', 'delete_method',
'copy_method', 'reorder_methods', 'get_zones',
'clear_cache', 'debug_log', 'debug_clear' );
foreach ( $ajax as $action ) {
add_action( 'wp_ajax_' . self::group() . '_' . $action, array( $this, 'ajax_' . $action ) );
}
}
private function langs() {
return require __DIR__ . '/language/en.php';
}
// ── Submenu ──────────────────────────────────────────────────────────────
public function register_submenu() {
add_submenu_page(
'woocommerce',
__( 'Extnify Fee Rules', 'extnify-fee' ),
__( 'Extnify Fee Rules', 'extnify-fee' ),
'manage_woocommerce',
self::slug(),
array( $this, 'views_settings' )
);
}
// ── Global settings save ──────────────────────────────────────────────────
public function handle_submenu_save() {
if ( ! isset( $_GET['page'] ) || self::slug() !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing
return;
}
$nonce_key = self::group() . '_nonce';
if ( ! isset( $_POST[ $nonce_key ] ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_key( $_POST[ $nonce_key ] ), self::group() . '_save' ) ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$posted = isset( $_POST[ self::group() ] ) ? (array) wp_unslash( $_POST[ self::group() ] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; sanitized in sanitize_global()
update_option( self::group(), $this->sanitize_global( $posted ) );
wp_safe_redirect( add_query_arg( array( 'page' => self::slug(), 'saved' => '1' ), admin_url( 'admin.php' ) ) );
exit;
}
private function sanitize_global( array $data ) {
$saved = array();
if ( isset( $data['heading'] ) ) {
$saved['heading'] = sanitize_text_field( $data['heading'] );
}
if ( isset( $data['sort_order'] ) ) {
$saved['sort_order'] = absint( $data['sort_order'] );
}
if ( isset( $data['debug'] ) ) {
$saved['debug'] = sanitize_key( $data['debug'] );
}
$saved['status'] = ! empty( $data['status'] ) ? '1' : '0';
return $saved;
}
// ── Page renderer ─────────────────────────────────────────────────────────
public function views_settings() {
$this->form
->setBasename( self::group() )
->setPreset( null )
->setIDPostfix( '' )
->setOptions( $this->get_global_form_options() )
->setLangs( $this->langs_data );
$this->view( 'settings', array(
'page_title' => __( 'Extnify Fee Rules', 'extnify-fee' ),
'saved' => isset( $_GET['saved'] ) && '1' === $_GET['saved'], // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing
'global_html' => $this->get_config_form(),
'methods_list' => $this->model->getData(),
) );
}
private function get_global_form_options() {
return array(
'status' => array(
array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ),
array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ),
),
'debug' => array(
array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ),
array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ),
),
);
}
private function get_config_form() {
$setting = (array) get_option( self::group(), array() );
$nonce_key = self::group() . '_nonce';
$subtabs = array(
'general' => __( 'General', 'extnify-fee' ),
'grouping' => __( 'Group Option', 'extnify-fee' ),
'import_export' => __( 'Import/Export', 'extnify-fee' ),
'help' => __( 'Help & Support', 'extnify-fee' ),
);
$html = '
';
return $html;
}
private function tab_gs_general( array $setting ) {
$this->form->setPreset( $setting );
$html = '';
return $html;
}
/**
* "Available in Pro Version" placeholder. On the wp.org build (XFEE_UPGRADE_URL
* set) it links to the Pro product page; on the WooCommerce build (constant
* empty) it stays plain text — no external upsell link (Marketplace rules).
*/
private function pro_placeholder( $text = null, $icon = 'dashicons-lock' ) {
$url = defined( 'XFEE_UPGRADE_URL' ) ? (string) XFEE_UPGRADE_URL : '';
$label = ( null === $text ) ? esc_html__( 'Available in Pro Version', 'extnify-fee' ) : esc_html( $text );
$body = ( '' !== $url )
? '' . $label . ''
: $label;
return '';
}
/** Help & Support — support links only (the live debug log is a Pro feature). */
private function tab_gs_help() {
$ver = defined( 'XFEE_PLUGIN_VERSION' ) ? XFEE_PLUGIN_VERSION : '1.0.0';
$wp_ver = get_bloginfo( 'version' );
$php_ver = preg_replace( '/^(\d+\.\d+).*/', '$1', PHP_VERSION );
$wc_ver = defined( 'WC_VERSION' ) ? WC_VERSION : ( function_exists( 'WC' ) ? WC()->version : '—' );
$sysinfo = sprintf( 'v%s · WP %s · PHP %s · WC %s', $ver, $wp_ver, $php_ver, $wc_ver );
// slug, icon, url, title, subtitle, arrow ('ext' = ↗, 'go' = ›)
$cards = array(
array( 'docs', 'dashicons-book-alt', 'https://extnify.com/docs/woocommerce/extnify-fee-rules-pro-woocommerce', __( 'Documentation', 'extnify-fee' ), __( 'Guides and how-tos', 'extnify-fee' ), 'ext' ),
array( 'support', 'dashicons-sos', 'https://extnify.com/tickets', __( 'Request support', 'extnify-fee' ), __( 'Get help from our team', 'extnify-fee' ), 'go' ),
array( 'ext', 'dashicons-admin-plugins', 'https://extnify.com/platforms/woocommerce', __( 'More extensions', 'extnify-fee' ), __( 'Other plugins by us', 'extnify-fee' ), 'ext' ),
);
$html = '';
$html .= '
'
. '
'
. '
' . esc_html__( 'Help & Support', 'extnify-fee' ) . ''
. '' . esc_html__( 'Get help and resources', 'extnify-fee' ) . '
'
. '
';
$html .= '
'
. '
'
. '
' . esc_html__( 'System healthy', 'extnify-fee' ) . ''
. '' . esc_html( $sysinfo ) . '
';
foreach ( $cards as $c ) {
list( $slug, $icon, $url, $title, $sub, $arrow ) = $c;
$arw = 'ext' === $arrow ? 'dashicons-external' : 'dashicons-arrow-right-alt2';
$html .= '
'
. ''
. '' . esc_html( $title ) . ''
. '' . esc_html( $sub ) . ''
. '';
}
// Upgrade-to-Pro card — shown only when the build sets an upgrade URL
// (WordPress.org build). The WooCommerce build ships an empty URL, so the
// card is dropped entirely (the marketplace owns the upsell there).
if ( defined( 'XFEE_UPGRADE_URL' ) && XFEE_UPGRADE_URL ) {
$html .= '
'
. ''
. '' . esc_html__( 'Upgrade to Pro', 'extnify-fee' ) . ''
. '' . esc_html__( 'Unlock advanced rules, more rate types, grouping & more', 'extnify-fee' ) . ''
. '';
}
$html .= '
'; // .ext-hs-left
// ── Live debugging log ─────────────────────────────────────────────────
$html .= '
';
$html .= '
'
. ''
. '' . esc_html__( 'Live debugging log', 'extnify-fee' ) . ''
. '' . esc_html__( 'LIVE', 'extnify-fee' ) . ''
. '
';
$html .= '
';
$html .= '
' . esc_html__( 'No log entries yet.', 'extnify-fee' ) . '
';
$html .= '';
$html .= '
'; // .ext-hs-log
$html .= '
'; // .ext-hs
return $html;
}
// ── Method form ────────────────────────────────────────────────────────────
private function get_form_builder_options() {
$opts = array();
$tc = array( array( 'value' => '', 'name' => __( 'None', 'extnify-fee' ) ) );
foreach ( WC_Tax::get_tax_classes() as $class ) {
$tc[] = array( 'value' => sanitize_title( $class ), 'name' => Misc::decode( $class ) );
}
$opts['tax_class_id'] = $tc;
$gz = array();
foreach ( WC_Shipping_Zones::get_zones() as $zone ) {
$gz[] = array( 'value' => (string) $zone['id'], 'name' => Misc::decode( $zone['zone_name'] ) );
}
$opts['geo_zone'] = $gz;
// Payment methods (chosen gateway id → title), for the Payment Method rule.
$pm = array();
if ( function_exists( 'WC' ) && WC()->payment_gateways() ) {
foreach ( WC()->payment_gateways()->payment_gateways() as $gw ) {
$pm[] = array( 'value' => (string) $gw->id, 'name' => Misc::decode( $gw->get_method_title() ) );
}
}
$opts['payment'] = $pm;
$opts['shipping'] = $this->get_shipping_options();
// Free supports ONLY a flat amount (a plain number, or N% of the subtotal).
$opts['rate_type'] = array(
array( 'value' => 'flat', 'name' => __( 'Flat Amount', 'extnify-fee' ) ),
);
$opts['status'] = array(
array( 'value' => '1', 'name' => __( 'Enabled', 'extnify-fee' ) ),
array( 'value' => '0', 'name' => __( 'Disabled', 'extnify-fee' ) ),
);
return $opts;
}
/**
* Options for the Shipping Method rule.
*
* Core WooCommerce methods (Flat rate, Free shipping, Local pickup, …) are
* listed by type — a chosen rate like "flat_rate:3" matches the "flat_rate"
* base. Extnify Shipping Rules is a dynamic method: it produces one rate per
* configured method row (rate id "_"), so it's EXPANDED
* into those rows here (read from the shared methods table), letting a fee
* target a specific shipping method — e.g. "only when 'Weight Based' is chosen".
*/
private function get_shipping_options() {
$out = array();
if ( ! function_exists( 'WC' ) || ! WC()->shipping() ) {
return $out;
}
$extnify = null; // the Extnify shipping method (free 'x_shipping' or pro 'x_shipping_pro').
foreach ( WC()->shipping()->get_shipping_methods() as $method ) {
if ( in_array( $method->id, array( 'x_shipping', 'x_shipping_pro' ), true ) ) {
$extnify = $method; // expanded into rows below — don't list the collapsed type.
continue;
}
$out[] = array( 'value' => (string) $method->id, 'name' => Misc::decode( $method->get_method_title() ) );
}
if ( $extnify ) {
global $wpdb;
$table = $wpdb->prefix . 'xshippingpro_methods';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- checking existence of a fixed prefixed table.
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
$prefix = $extnify->get_method_title();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed prefixed table; no user input.
$rows = $wpdb->get_results( "SELECT id, method_data FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A );
foreach ( (array) $rows as $r ) {
$md = json_decode( $r['method_data'], true );
$md = is_array( $md ) ? $md : array();
$name = '';
if ( ! empty( $md['name'] ) ) {
$name = is_array( $md['name'] ) ? (string) reset( $md['name'] ) : (string) $md['name'];
} elseif ( ! empty( $md['display'] ) ) {
$name = (string) $md['display'];
} else {
$name = 'Method #' . (int) $r['id'];
}
$out[] = array(
'value' => $extnify->id . '_' . (int) $r['id'],
'name' => Misc::decode( $prefix . ' — ' . $name ),
);
}
}
}
return $out;
}
private function get_default_values() {
return array(
'geo_zone' => array(),
'geo_zone_all' => '1',
'payment' => array(),
'payment_all' => '1',
'shipping' => array(),
'shipping_all' => '1',
'tax_class_id' => '',
'sort_order' => '',
'status' => '1',
'rate_type' => 'flat',
'cost' => '',
'display' => __( 'New Fee', 'extnify-fee' ),
'name' => __( 'Fee', 'extnify-fee' ),
);
}
private function get_form_data( array $method_data, $method_id, $id_postfix = '' ) {
$method_data = array_merge( $this->get_default_values(), $method_data );
$postfix = ( '' !== (string) $id_postfix ) ? (string) $id_postfix : (string) $method_id;
$this->form
->setBasename( 'xfeepro' )
->setPreset( $method_data )
->setIDPostfix( $postfix )
->setOptions( $this->get_form_builder_options() )
->setLangs( $this->langs_data );
$mid = esc_attr( $postfix );
$saved_id = ( (int) $method_id > 0 ) ? esc_attr( (string) $method_id ) : '';
$html = '';
return $html;
}
// ── AJAX ────────────────────────────────────────────────────────────────────
private function check_ajax_auth() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied.', 'extnify-fee' ) ), 403 );
}
check_ajax_referer( 'ext_method_action', 'nonce' );
}
public function ajax_reorder_methods() {
$this->check_ajax_auth();
$order = isset( $_POST['order'] ) ? array_map( 'intval', (array) $_POST['order'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
foreach ( $order as $pos => $id ) {
if ( $id > 0 ) { $this->model->updateSortOrder( $id, $pos ); }
}
wp_send_json_success();
}
public function ajax_load_method() {
$this->check_ajax_auth();
$id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
$postfix = isset( $_POST['postfix'] ) ? sanitize_html_class( wp_unslash( $_POST['postfix'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
$row = $id ? $this->model->getDataById( $id ) : null;
$mdata = $row ? $row['method_data'] : array();
$this->form->setBasename( 'xfeepro' )->setLangs( $this->langs_data );
$id_postfix = $id ? (string) $id : ( $postfix ?: 'new' );
wp_send_json_success( array( 'html' => $this->get_form_data( $mdata, $id, $id_postfix ), 'id' => $id ) );
}
public function ajax_save_method() {
$this->check_ajax_auth();
$id = isset( $_POST['ext_method_id'] ) ? sanitize_text_field( wp_unslash( $_POST['ext_method_id'] ) ) : 'new'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
$posted = isset( $_POST['xfeepro'] ) ? (array) wp_unslash( $_POST['xfeepro'] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in check_ajax_auth(); sanitized in sanitize_method()
$is_new = ( 'new' === $id || ! $id );
$mdata = $this->sanitize_method( $posted, $is_new ? 0 : (int) $id );
$display = isset( $mdata['display'] ) ? $mdata['display'] : __( 'New Method', 'extnify-fee' );
if ( $is_new ) {
$new_id = $this->model->addData( $mdata );
wp_send_json_success( array( 'id' => $new_id, 'display' => $display, 'is_new' => true ) );
}
$this->model->updateData( (int) $id, $mdata );
wp_send_json_success( array( 'id' => (int) $id, 'display' => $display, 'is_new' => false ) );
}
public function ajax_delete_method() {
$this->check_ajax_auth();
$id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
if ( $id ) { $this->model->deleteData( $id ); }
wp_send_json_success( array( 'id' => $id ) );
}
public function ajax_copy_method() {
$this->check_ajax_auth();
$id = isset( $_POST['method_id'] ) ? (int) $_POST['method_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in check_ajax_auth()
$new_id = $id ? $this->model->copyData( $id ) : false;
if ( ! $new_id ) {
wp_send_json_error( array( 'message' => __( 'Method not found.', 'extnify-fee' ) ) );
}
$row = $this->model->getDataById( $new_id );
$display = isset( $row['method_data']['display'] ) ? $row['method_data']['display'] : 'Method #' . $new_id;
wp_send_json_success( array( 'id' => $new_id, 'display' => $display ) );
}
/** Read the debug log from the DB store as { time, level, message } entries. */
public function ajax_debug_log() {
$this->check_ajax_auth();
$limit = isset( $_REQUEST['limit'] ) ? max( 1, min( 500, (int) $_REQUEST['limit'] ) ) : 60; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified in check_ajax_auth(); value is int-clamped
$log = get_transient( 'x_feepro_debug_log' );
$entries = array();
if ( is_array( $log ) ) {
foreach ( array_slice( $log, -$limit ) as $row ) {
$entries[] = array(
'time' => isset( $row['time'] ) ? (string) $row['time'] : '',
'level' => isset( $row['level'] ) ? (string) $row['level'] : 'info',
'message' => wp_strip_all_tags( isset( $row['message'] ) ? (string) $row['message'] : '' ),
);
}
}
wp_send_json_success( array( 'entries' => $entries ) );
}
/** Clear the debug log. */
public function ajax_debug_clear() {
$this->check_ajax_auth();
delete_transient( 'x_feepro_debug_log' );
wp_send_json_success();
}
/**
* Sanitise ONLY the free fields. IMPORTANT: this merges onto the existing row
* so any Pro-configured fields already saved in the JSON are preserved (a free
* user editing a Pro-made method won't wipe its Pro settings).
*/
private function sanitize_method( array $data, $existing_id = 0 ) {
// Start from the existing row so Pro-only fields already saved in the JSON
// are preserved when a free user edits a Pro-created method.
$existing = array();
if ( $existing_id ) {
$row = $this->model->getDataById( (int) $existing_id );
$existing = $row ? (array) $row['method_data'] : array();
}
$clean = $existing;
foreach ( array( 'display', 'name' ) as $k ) {
if ( isset( $data[ $k ] ) ) { $clean[ $k ] = sanitize_text_field( $data[ $k ] ); }
}
$clean['tax_class_id'] = isset( $data['tax_class_id'] ) ? sanitize_text_field( $data['tax_class_id'] ) : '';
$clean['sort_order'] = isset( $data['sort_order'] ) ? absint( $data['sort_order'] ) : 0;
$clean['status'] = ! empty( $data['status'] ) ? '1' : '0';
// Free supports only a flat amount (plain number or N% of subtotal).
$clean['rate_type'] = 'flat';
$clean['cost'] = isset( $data['cost'] ) ? sanitize_text_field( $data['cost'] ) : '';
// Rules: Shipping Zone / Payment Method / Shipping Method. "For Any" wins when
// its checkbox is ticked (…_all posts '1') OR nothing is selected — even if the
// hidden per-item checkboxes still submitted their values.
foreach ( array( 'geo_zone', 'payment', 'shipping' ) as $rule ) {
$clean[ $rule ] = isset( $data[ $rule ] ) ? array_map( 'sanitize_text_field', (array) $data[ $rule ] ) : array();
$clean[ $rule . '_all' ] = ( ! empty( $data[ $rule . '_all' ] ) || empty( $clean[ $rule ] ) ) ? '1' : '0';
}
return $clean;
}
// ── Assets ─────────────────────────────────────────────────────────────────
public function enqueue_assets( $hook ) {
if ( 'woocommerce_page_' . self::slug() !== $hook ) {
return;
}
$theme_path = XFEE_PLUGIN_PATH . 'assets/css/theme.css';
$css_path = XFEE_PLUGIN_PATH . 'assets/css/admin.css';
$js_path = XFEE_PLUGIN_PATH . 'assets/js/admin.js';
// Shared Extnify theme (brand tokens + components) loads first…
wp_enqueue_style( 'extnify-theme', XFEE_PLUGIN_URL . 'assets/css/theme.css', array(), file_exists( $theme_path ) ? filemtime( $theme_path ) : XFEE_PLUGIN_VERSION );
// …then this plugin's own overrides, so they win via the cascade.
wp_enqueue_style( 'extnify-fee-admin', XFEE_PLUGIN_URL . 'assets/css/admin.css', array( 'extnify-theme' ), file_exists( $css_path ) ? filemtime( $css_path ) : XFEE_PLUGIN_VERSION );
wp_enqueue_script( 'extnify-fee-admin', XFEE_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery', 'jquery-ui-sortable' ), file_exists( $js_path ) ? filemtime( $js_path ) : XFEE_PLUGIN_VERSION, true );
wp_localize_script( 'extnify-fee-admin', 'extnifyFeeAdmin', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'prefix' => self::group() . '_',
'nonce' => wp_create_nonce( 'ext_method_action' ),
'methodNonce' => wp_create_nonce( 'ext_method_action' ),
'i18n' => array(
'saving' => __( 'Saving…', 'extnify-fee' ),
'saved' => __( 'Saved!', 'extnify-fee' ),
'confirmDelete' => __( 'Remove this method?', 'extnify-fee' ),
'loadError' => __( 'Error loading method.', 'extnify-fee' ),
'saveError' => __( 'Error saving method.', 'extnify-fee' ),
),
) );
}
// ── View loader ───────────────────────────────────────────────────────────
private function view( $template, array $data = array() ) {
// basename() strips any path components, so $template can only resolve to a
// file inside admin/views/ — no directory traversal via the include below.
$file = XFEE_PLUGIN_PATH . 'admin/views/' . basename( (string) $template ) . '.php';
if ( ! file_exists( $file ) ) {
return;
}
extract( $data, EXTR_SKIP ); // phpcs:ignore WordPress.PHP.DontExtract
include $file;
}
}