one-time challenge token, published at our own * /wp-json/orderguard/v1/verify route; * 2. /v1/activate/confirm -> server fetches that token back to prove domain * control, then issues the API key. * If the store is already registered but this site has no key (reinstall), the * plugin self-heals via the reissue flow (same verification, rotates the key). * Until a key is stored, the plugin stays fully dormant (no data sent). */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * --------------------------------------------------------------------------- * Stored key helpers * --------------------------------------------------------------------------- */ function orderguard_get_api_key() { return orderguard_get_option( 'orderguard_api_key', '' ); } function orderguard_is_activated() { return '' !== orderguard_get_api_key(); } /** * --------------------------------------------------------------------------- * Country gate helpers * --------------------------------------------------------------------------- * OrderGuard launches country by country. The store's WooCommerce base country * decides which first-run screen renders: activation (supported country) or * the waitlist (everywhere else -- the plugin stays fully dormant there). */ function orderguard_store_country() { if ( ! function_exists( 'wc_get_base_location' ) ) { return ''; } $loc = wc_get_base_location(); return ( is_array( $loc ) && ! empty( $loc['country'] ) ) ? strtoupper( (string) $loc['country'] ) : ''; } /** * Countries where OrderGuard is live. Filterable so a new market is a one-line * change (or a site filter) -- e.g. add 'GR' / 'RO' when they launch. */ function orderguard_supported_countries() { $countries = apply_filters( 'orderguard_supported_countries', array( 'BG' ) ); if ( ! is_array( $countries ) ) { $countries = array( 'BG' ); } return array_map( 'strtoupper', array_map( 'strval', $countries ) ); } function orderguard_country_supported( $cc ) { $cc = strtoupper( (string) $cc ); return '' !== $cc && in_array( $cc, orderguard_supported_countries(), true ); } /** * Localized country name from WooCommerce, falling back to the raw code, or to * a neutral phrase when WooCommerce hasn't told us a country yet. */ function orderguard_country_name( $cc ) { $cc = strtoupper( (string) $cc ); if ( '' === $cc ) { return __( 'your country', 'orderguard' ); } if ( function_exists( 'WC' ) && WC() && isset( WC()->countries ) && isset( WC()->countries->countries[ $cc ] ) ) { return (string) WC()->countries->countries[ $cc ]; } return $cc; } /** * Flag for a 2-letter country code (already-escaped HTML). Prefers a local SVG * (assets/flags/{cc}.svg -- dropping in gr.svg / ro.svg later needs no code * change), else the emoji flag from the regional-indicator pair, else a * neutral globe dashicon. */ function orderguard_country_flag_html( $cc ) { $cc = strtoupper( (string) $cc ); if ( 2 === strlen( $cc ) && ctype_alpha( $cc ) ) { $file = 'assets/flags/' . strtolower( $cc ) . '.svg'; if ( file_exists( __DIR__ . '/' . $file ) ) { return ''; } if ( function_exists( 'mb_chr' ) ) { $emoji = mb_chr( 0x1F1E6 + ( ord( $cc[0] ) - 65 ) ) . mb_chr( 0x1F1E6 + ( ord( $cc[1] ) - 65 ) ); return ''; } } return ''; } /** * --------------------------------------------------------------------------- * Domain-verification: publish the challenge token at a public REST route * --------------------------------------------------------------------------- * The server fetches /wp-json/orderguard/v1/verify to confirm we control this * domain. Returns an empty token when none is pending. */ add_action( 'rest_api_init', 'orderguard_register_verify_route' ); function orderguard_register_verify_route() { register_rest_route( 'orderguard/v1', '/verify', array( 'methods' => 'GET', 'callback' => 'orderguard_verify_route_callback', 'permission_callback' => '__return_true', // public: the (unauthenticated) server must read it ) ); } function orderguard_verify_route_callback() { return array( 'orderguard_challenge' => (string) orderguard_get_option( 'orderguard_challenge_token', '' ), ); } /** * --------------------------------------------------------------------------- * Register the admin menu page * --------------------------------------------------------------------------- */ add_action( 'admin_menu', 'orderguard_register_admin_page' ); /** * Register or read the exact hook suffixes returned by WordPress for the * OrderGuard menu pages. * * These cannot be reconstructed safely from the menu slug: the review-count * bubble is part of the parent menu title, and WordPress includes its sanitized * text in submenu hook suffixes whenever the count is non-zero. */ function orderguard_admin_page_hooks( $register = null ) { static $hooks = array(); if ( is_string( $register ) && '' !== $register ) { $hooks[ $register ] = true; } return array_keys( $hooks ); } /** * Load the sidebar icon everywhere in wp-admin, and the full OrderGuard admin * bundle only where its selectors and behaviours are used. */ add_action( 'admin_enqueue_scripts', 'orderguard_enqueue_admin_assets' ); function orderguard_enqueue_admin_assets( $hook_suffix ) { $menu_path = plugin_dir_path( __FILE__ ) . 'assets/css/admin-menu.css'; $menu_url = plugin_dir_url( __FILE__ ) . 'assets/css/admin-menu.css'; $menu_ver = file_exists( $menu_path ) ? (string) filemtime( $menu_path ) : '1'; wp_enqueue_style( 'orderguard-admin-menu', $menu_url, array(), $menu_ver ); $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $is_orderguard_page = in_array( (string) $hook_suffix, orderguard_admin_page_hooks(), true ); $is_order_screen = $screen && function_exists( 'orderguard_order_meta_box_screens' ) && in_array( (string) $screen->id, orderguard_order_meta_box_screens(), true ); if ( ! $is_orderguard_page && ! $is_order_screen ) { return; } $style_path = plugin_dir_path( __FILE__ ) . 'assets/css/admin.css'; $style_url = plugin_dir_url( __FILE__ ) . 'assets/css/admin.css'; $style_ver = file_exists( $style_path ) ? (string) filemtime( $style_path ) : '1'; $script_path = plugin_dir_path( __FILE__ ) . 'assets/js/admin.js'; $script_url = plugin_dir_url( __FILE__ ) . 'assets/js/admin.js'; $script_ver = file_exists( $script_path ) ? (string) filemtime( $script_path ) : '1'; wp_enqueue_style( 'orderguard-admin', $style_url, array( 'dashicons' ), $style_ver ); wp_enqueue_script( 'orderguard-admin', $script_url, array(), $script_ver, true ); $admin_data = array( 'hardBlockConfirm' => __( 'Hard block stops all orders from this customer. Continue?', 'orderguard' ), 'eraseDataConfirm' => __( 'Erase the OrderGuard data your store contributed for this customer? Signals from other stores are not affected. This site will keep keyed local identity and order-reference protections, including a frozen order-ID boundary, so existing, restored or edited orders cannot recreate the erased data. Later orders are processed normally. Server deletion cannot be undone.', 'orderguard' ), 'copied' => __( 'Copied', 'orderguard' ), ); if ( 'toplevel_page_orderguard' === $hook_suffix ) { $import_summary = orderguard_get_option( 'orderguard_import_summary', array() ); $import_state = orderguard_get_option( 'orderguard_import_state', array() ); $import_status = is_array( $import_state ) && isset( $import_state['status'] ) ? (string) $import_state['status'] : ''; $import_done = 'done' === $import_status && is_array( $import_summary ) && ! empty( $import_summary['ts'] ); $import_running = is_array( $import_state ) && in_array( $import_status, array( 'running', 'finalizing' ), true ) && ! $import_done; $import_paused = $import_running && ! empty( $import_state['last_defer_reason'] ); $month = function_exists( 'orderguard_i18n_date' ) ? orderguard_i18n_date( 'M', current_time( 'timestamp' ) ) : date_i18n( 'M' ); $admin_data['importRunning'] = $import_running; $admin_data['importNonce'] = wp_create_nonce( 'orderguard_import_status' ); $admin_data['importPaused'] = $import_paused; $admin_data['month'] = $month; } wp_localize_script( 'orderguard-admin', 'orderguardAdmin', $admin_data ); } /** * How many orders are waiting in the Review queue, for the sidebar bubble. * * TASK 50. This is the closest thing OrderGuard has to a notification, and it is * deliberately the strongest surface we allow ourselves: a number in the admin * menu, visible on EVERY admin page, that nothing has to be sent for. Giuliano, * 30 July 2026: "I dont want to send any notifications to merchants or clients." * So no email, no push, no webhook -- the merchant simply cannot miss the count * once he is logged in. * * READ-ONLY, and that is load-bearing -- see the comment inside the function. * The number is written by orderguard_render_review_queue_page(), which computes the * queue anyway; nothing else in wp-admin may pay for it. * * The transient is deleted by orderguard_review_queue_invalidate() whenever a row leaves * the queue, so the bubble drops immediately after the merchant acts -- a count * that stays up for five minutes after you cleared it reads as a broken feature. * After such a delete the bubble is absent until Review is opened again, which * is the deliberate trade: late badge, fast admin. */ function orderguard_review_queue_count() { // READ-ONLY. THIS FUNCTION MUST NEVER TOUCH THE DATABASE. Read the comment // below before changing that, because the first version of it made a live // WordPress install unusable. // // WHAT WENT WRONG. This ran inside admin_menu -- on EVERY wp-admin page, for // every user with manage_woocommerce -- and on a cold cache it called // orderguard_review_queue_orders(). That is a wc_get_orders() with a FOUR-clause // NUMERIC >= meta_query (four postmeta joins with CASTs, on unindexed // values), followed by a fifty-row loop doing a flags-table lookup per row. // On a store with real order volume that is seconds, not milliseconds, and // it was paid on the dashboard, on Posts, on Settings -- everywhere. The // whole admin crawled, which is exactly what a sidebar badge is not worth. // // The fix is not a longer TTL: a cold cache is guaranteed after every flag // write, every exclusion change and every five minutes, so the expensive // path would still be hit from an arbitrary page. // // So the count is now a BY-PRODUCT of the one page that computes the queue // anyway -- orderguard_render_review_queue_page() writes the transient from the rows // it already has, for free. Here we only ever read it. The cost of that // honesty: on a cold cache the bubble is absent rather than zero, so it // appears once the merchant has opened Review at least once and then stays // current. A badge that is sometimes late is a fair price for an admin that // is never slow. if ( ! function_exists( 'orderguard_flag_enabled' ) || ! orderguard_flag_enabled() ) { return 0; } $cached = orderguard_get_transient( 'orderguard_review_count' ); if ( false === $cached ) { return 0; } return (int) $cached; } /** * Drop the cached Review count. Called from every action that resolves a row. */ function orderguard_review_queue_invalidate() { orderguard_delete_transient( 'orderguard_review_count' ); // orderguard_hist_clients IS DELIBERATELY NOT DELETED HERE, and that is a fix rather // than an omission. Deleting it meant acting on ONE client destroyed the whole // list: the merchant pressed Block COD on the first of five, the page came // back, and the "From your order history" card was gone -- replaced by the // scan button, as if his click had thrown the other four away. Deciding about // one person must not cost you the other nine. // // Instead the specific client is pruned from the cached list at the moment the // decision is recorded -- orderguard_history_forget(), called from orderguard_flag_store() and // from the dismiss handler. Everyone else stays exactly where they were, and // the section disappears on its own when the last row goes. } /** * "OrderGuard" plus the WP update-bubble markup when the queue is not empty. * Same classes core uses for comments awaiting moderation, so it inherits the * red pill and the collapsed-menu behaviour for free. */ function orderguard_menu_title_with_bubble( $title ) { $count = orderguard_review_queue_count(); if ( $count < 1 ) { return $title; } return $title . ' ' . '' . (int) number_format_i18n( $count ) . ''; } function orderguard_register_admin_page() { $hook_suffix = add_menu_page( 'OrderGuard', orderguard_menu_title_with_bubble( 'OrderGuard' ), 'manage_woocommerce', 'orderguard', 'orderguard_render_admin_page', 'none', // painted via the globally enqueued admin-menu.css 56 ); orderguard_admin_page_hooks( $hook_suffix ); // First submenu re-points to the main page (so WP doesn't auto-duplicate it), // labelled "Settings". Then Blocked clients, then the Insights item. Order of // add_submenu_page calls sets the order shown in the sidebar. $hook_suffix = add_submenu_page( 'orderguard', 'OrderGuard Settings', 'Settings', 'manage_woocommerce', 'orderguard', 'orderguard_render_admin_page' ); orderguard_admin_page_hooks( $hook_suffix ); $hook_suffix = add_submenu_page( 'orderguard', 'Blocked and flagged clients', 'Blocked clients', 'manage_woocommerce', 'orderguard-blocked', 'orderguard_render_blocked_clients_page' ); orderguard_admin_page_hooks( $hook_suffix ); $hook_suffix = add_submenu_page( 'orderguard', 'OrderGuard Insights', 'Insights', 'manage_woocommerce', 'orderguard-insights', 'orderguard_render_insights_page' ); orderguard_admin_page_hooks( $hook_suffix ); } /** * Insights page -- an activity report: counts (only) of the risky COD orders * OrderGuard blocked and the orders it flagged for review, with time filtering. * Data comes from the local, privacy-preserving daily tally (orderguard_insights_*); * nothing is fetched from the server and no order values are involved. */ function orderguard_render_insights_page() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $base_url = admin_url( 'admin.php?page=orderguard-insights' ); $now_ts = current_time( 'timestamp' ); $today = gmdate( 'Y-m-d', $now_ts ); // Selected period (default: this month). // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only view filter, sanitized and allowlisted or format-checked; no state change $period = isset( $_GET['period'] ) ? sanitize_key( wp_unslash( $_GET['period'] ) ) : 'month'; if ( ! in_array( $period, array( 'today', 'week', 'month', 'year', 'custom' ), true ) ) { $period = 'month'; } // Resolve the date range (all in site timezone; 'Y-m-d' strings). switch ( $period ) { case 'today': $start = $today; $end = $today; break; case 'week': $start = gmdate( 'Y-m-d', strtotime( 'monday this week', $now_ts ) ); $end = $today; break; case 'year': $start = gmdate( 'Y', $now_ts ) . '-01-01'; $end = $today; break; case 'custom': // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only view filter, sanitized and allowlisted or format-checked; no state change $start = isset( $_GET['start'] ) ? sanitize_text_field( wp_unslash( $_GET['start'] ) ) : gmdate( 'Y-m-01', $now_ts ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only view filter, sanitized and allowlisted or format-checked; no state change $end = isset( $_GET['end'] ) ? sanitize_text_field( wp_unslash( $_GET['end'] ) ) : $today; if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $start ) ) { $start = gmdate( 'Y-m-01', $now_ts ); } if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $end ) ) { $end = $today; } if ( $start > $end ) { $swap = $start; $start = $end; $end = $swap; } break; case 'month': default: $start = gmdate( 'Y-m-01', $now_ts ); $end = $today; break; } $totals = function_exists( 'orderguard_insights_totals' ) ? orderguard_insights_totals( $start, $end ) : array(); $checked = isset( $totals['checked'] ) ? (int) $totals['checked'] : 0; $flagged = isset( $totals['flagged'] ) ? (int) $totals['flagged'] : 0; $blocked = isset( $totals['blocked'] ) ? (int) $totals['blocked'] : 0; $hidden = isset( $totals['hidden'] ) ? (int) $totals['hidden'] : 0; $prevented = $blocked + $hidden; // "COD auto-hidden" / risky orders avoided. // Previous equivalent window, for the commentary comparison. $span_days = (int) ( ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS ) + 1; $prev_end = gmdate( 'Y-m-d', strtotime( $start ) - DAY_IN_SECONDS ); $prev_start = gmdate( 'Y-m-d', strtotime( $start ) - ( $span_days * DAY_IN_SECONDS ) ); $prev = function_exists( 'orderguard_insights_totals' ) ? orderguard_insights_totals( $prev_start, $prev_end ) : array(); $prev_prevented = ( isset( $prev['blocked'] ) ? (int) $prev['blocked'] : 0 ) + ( isset( $prev['hidden'] ) ? (int) $prev['hidden'] : 0 ); // Deterministic month-over-month commentary (no AI, fully explainable). $commentary = ''; if ( $prev_prevented > 0 ) { $delta = $prevented - $prev_prevented; if ( 0 === $delta ) { $commentary = __( 'Risky COD orders avoided held steady versus the previous period.', 'orderguard' ); } else { $pct = (int) round( abs( $delta ) / $prev_prevented * 100 ); $commentary = sprintf( /* translators: %1$s is the word up or down, %2$d is a percentage. */ __( 'Risky COD orders avoided are %1$s %2$d%% versus the previous period.', 'orderguard' ), ( $delta > 0 ? __( 'up', 'orderguard' ) : __( 'down', 'orderguard' ) ), $pct ); } } if ( $prevented > 0 ) { $commentary = trim( $commentary . ' ' . __( 'Most of these were repeat non-collectors caught before dispatch.', 'orderguard' ) ); } $range_label = ( $start === $end ) ? orderguard_i18n_date( 'j M Y', strtotime( $start ) ) : orderguard_i18n_date( 'j M Y', strtotime( $start ) ) . ' – ' . orderguard_i18n_date( 'j M Y', strtotime( $end ) ); $tabs = array( 'today' => __( 'Today', 'orderguard' ), 'week' => __( 'This week', 'orderguard' ), 'month' => __( 'This month', 'orderguard' ), 'year' => __( 'This year', 'orderguard' ), 'custom' => __( 'Custom', 'orderguard' ), ); $series = function_exists( 'orderguard_insights_series' ) ? orderguard_insights_series( $start, $end ) : array(); $chart_max = 1; foreach ( $series as $b ) { $chart_max = max( $chart_max, (int) $b['checked'] + (int) $b['prevented'] ); } $is_pro = function_exists( 'orderguard_is_pro' ) ? orderguard_is_pro() : false; ?>
.
|
email ) : ?> email ); ?>
phone ) : ?>phone ); ?>
|
source ) { echo '' . esc_html__( 'Manual', 'orderguard' ) . ''; } elseif ( '' !== $row->order_ref && ctype_digit( (string) $row->order_ref ) ) { $ourl = admin_url( 'admin.php?page=wc-orders&action=edit&id=' . (int) $row->order_ref ); echo '' . esc_html__( 'Order #', 'orderguard' ) . (int) $row->order_ref . ''; } else { echo '' . esc_html__( 'From order', 'orderguard' ) . ''; } ?> | reason ? esc_html( orderguard_flag_reason_label( $row->reason ) ) : '—'; ?> | created_at ) ) ); ?>' . esc_html__( 'over 24 months', 'orderguard' ) . '' : ''; ?> |
' . '
/
' . esc_html( $owned_result->get_error_message() ) . '