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; ?>

OrderGuard ·

$label ) : if ( 'custom' === $key ) { continue; } $url = add_query_arg( 'period', $key, $base_url ); $active = ( $period === $key ); ?>

.

__( 'Blocked', 'orderguard' ), 'flagged' => __( 'Flagged', 'orderguard' ), ); $tab_counts = array( 'blocked' => count( $blocked_rows ), 'flagged' => count( $flagged_rows ), ); $level_meta = array( 'flag' => array( __( 'Flag for review', 'orderguard' ), '#FBF1DE', '#B07D2A' ), 'block_cod' => array( __( 'COD only', 'orderguard' ), '#FBEADF', '#C2621F' ), 'hard_block' => array( __( 'Blocked', 'orderguard' ), '#FBEAEA', '#8F4444' ), ); // Manual-add level buttons (icon + label), reusing the in-order iconography. $add_levels = array( 'flag' => array( __( 'Flag', 'orderguard' ), 'dashicons-flag' ), 'block_cod' => array( __( 'Block COD', 'orderguard' ), 'dashicons-hidden' ), 'hard_block' => array( __( 'Hard block', 'orderguard' ), 'dashicons-dismiss' ), ); ?>

$lm2 ) : ?>

$label ) : $active = ( $tab === $key ); $url = ( 'blocked' === $key ) ? $base_url : add_query_arg( 'tab', $key, $base_url ); ?> ()

' . esc_html( $th ) . ''; } ?> level ] ) ? $level_meta[ $row->level ] : $level_meta['flag']; $name = ( '' !== $row->name ) ? $row->name : '—'; $del_url = wp_nonce_url( admin_url( 'admin-post.php?action=orderguard_delete_flag&flag_id=' . (int) $row->id ), 'orderguard_deleteflag_' . (int) $row->id ); $old = ( strtotime( $row->created_at ) < strtotime( '-24 months' ) ); $search = strtolower( trim( $row->name . ' ' . $row->email . ' ' . $row->phone . ' ' . $row->reason ) ); ?>
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' ) . '' : ''; ?>

'POST', 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'store_domain' => $store_domain, 'terms_accepted' => true, ) ), ) ); if ( is_wp_error( $response ) ) { /* translators: %s: connection error returned by WordPress. */ orderguard_set_notice( 'error', sprintf( __( 'Could not reach the OrderGuard server. Please try again. (%s)', 'orderguard' ), $response->get_error_message() ) ); return; } $code = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 409 === $code ) { // Store is already registered but this site has no key (reinstall / // cleared options). Self-heal by proving domain control and rotating the // key -- the same verification machinery as activation, so it's safe. orderguard_attempt_key_reissue( $store_domain ); return; } if ( 200 !== $code || ! is_array( $data ) || empty( $data['challenge_token'] ) ) { /* translators: %d: HTTP response status. */ orderguard_set_notice( 'error', sprintf( __( 'Activation failed (server said %d). Please try again or contact support.', 'orderguard' ), intval( $code ) ) ); return; } // Publish the token so the server can fetch it back from our /verify route. orderguard_update_option( 'orderguard_challenge_token', sanitize_text_field( $data['challenge_token'] ) ); // ---- STEP 2: ask the server to verify domain control + issue the key ---- $confirm = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/activate/confirm', array( 'method' => 'POST', 'timeout' => 15, // the server fetches our site during this call 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'store_domain' => $store_domain ) ), ) ); if ( is_wp_error( $confirm ) ) { /* translators: %s: connection error returned by WordPress. */ orderguard_set_notice( 'error', sprintf( __( 'Could not complete verification. Please try again. (%s)', 'orderguard' ), $confirm->get_error_message() ) ); return; } $ccode = wp_remote_retrieve_response_code( $confirm ); $cdata = json_decode( wp_remote_retrieve_body( $confirm ), true ); if ( 200 === $ccode && is_array( $cdata ) && ! empty( $cdata['api_key'] ) ) { orderguard_update_option( 'orderguard_api_key', sanitize_text_field( $cdata['api_key'] ) ); orderguard_update_option( 'orderguard_activated_at', current_time( 'mysql' ) ); orderguard_delete_option( 'orderguard_challenge_token' ); // no longer needed orderguard_set_notice( 'success', __( 'OrderGuard is now active. Your store is protected.', 'orderguard' ) ); return; } // Verification failed -- friendly message (also the reinstall-safe path). $detail = ( is_array( $cdata ) && ! empty( $cdata['detail'] ) ) ? $cdata['detail'] : 'verification could not be completed'; /* translators: %s: verification failure details. */ orderguard_set_notice( 'error', sprintf( __( 'We could not verify your site to finish activation (%s). Please try again in a moment.', 'orderguard' ), $detail ) ); } /** * Self-healing key reissue for an already-registered store that has no local * key. Runs the same challenge/verify dance as activation, then stores the * rotated key. Called automatically when /v1/activate returns 409. */ function orderguard_attempt_key_reissue( $store_domain ) { // ---- Step 1: request a reissue challenge ---- $req = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/activate/reissue-request', array( 'method' => 'POST', 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'store_domain' => $store_domain ) ), ) ); if ( is_wp_error( $req ) ) { orderguard_set_notice( 'error', __( 'Could not reach the OrderGuard server to re-issue your key. Please try again.', 'orderguard' ) ); return; } $rcode = wp_remote_retrieve_response_code( $req ); $rdata = json_decode( wp_remote_retrieve_body( $req ), true ); if ( 200 !== $rcode || ! is_array( $rdata ) || empty( $rdata['challenge_token'] ) ) { /* translators: %d: HTTP response status. */ orderguard_set_notice( 'error', sprintf( __( 'Could not start key re-issue (server said %d). Please contact support.', 'orderguard' ), intval( $rcode ) ) ); return; } // Publish the token so the server can fetch it back. orderguard_update_option( 'orderguard_challenge_token', sanitize_text_field( $rdata['challenge_token'] ) ); // ---- Step 2: confirm reissue (server verifies domain control, rotates key) ---- $conf = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/activate/reissue', array( 'method' => 'POST', 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'store_domain' => $store_domain ) ), ) ); if ( is_wp_error( $conf ) ) { orderguard_set_notice( 'error', __( 'Could not complete key re-issue. Please try again.', 'orderguard' ) ); return; } $ccode = wp_remote_retrieve_response_code( $conf ); $cdata = json_decode( wp_remote_retrieve_body( $conf ), true ); if ( 200 === $ccode && is_array( $cdata ) && ! empty( $cdata['api_key'] ) ) { orderguard_update_option( 'orderguard_api_key', sanitize_text_field( $cdata['api_key'] ) ); orderguard_update_option( 'orderguard_activated_at', current_time( 'mysql' ) ); orderguard_delete_option( 'orderguard_challenge_token' ); orderguard_set_notice( 'success', __( 'OrderGuard reconnected. Your store is protected again.', 'orderguard' ) ); return; } $detail = ( is_array( $cdata ) && ! empty( $cdata['detail'] ) ) ? $cdata['detail'] : 'verification could not be completed'; /* translators: %s: verification failure details. */ orderguard_set_notice( 'error', sprintf( __( 'We could not verify your site to re-issue the key (%s). Please try again in a moment.', 'orderguard' ), $detail ) ); } /** * --------------------------------------------------------------------------- * Handle the waitlist form (unsupported-country welcome screen) * --------------------------------------------------------------------------- * The store isn't activated (no API key exists yet), so this posts a plain * business-contact email + country to the server's unauthenticated waitlist * endpoint. Never fatal: a server hiccup just shows a friendly notice. */ add_action( 'admin_init', 'orderguard_handle_waitlist_submit' ); function orderguard_handle_waitlist_submit() { if ( ! isset( $_POST['orderguard_waitlist_submit'] ) ) { return; } if ( ! isset( $_POST['orderguard_waitlist_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['orderguard_waitlist_nonce'] ) ), 'orderguard_waitlist' ) ) { orderguard_set_notice( 'error', __( 'Security check failed.', 'orderguard' ) ); return; } if ( ! current_user_can( 'manage_woocommerce' ) ) { orderguard_set_notice( 'error', __( 'Insufficient permissions.', 'orderguard' ) ); return; } if ( orderguard_is_activated() ) { return; // already live here -- nothing to wait for } $email = isset( $_POST['orderguard_waitlist_email'] ) ? sanitize_email( wp_unslash( $_POST['orderguard_waitlist_email'] ) ) : ''; if ( '' === $email || ! is_email( $email ) ) { orderguard_set_notice( 'error', __( 'Please enter a valid email address.', 'orderguard' ) ); return; } $country = orderguard_store_country(); $country_name = orderguard_country_name( $country ); $response = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/waitlist', array( 'method' => 'POST', 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'email' => $email, 'country' => $country, 'store_domain' => wp_parse_url( home_url(), PHP_URL_HOST ), ) ), ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { orderguard_set_notice( 'error', __( "We couldn't save your email just now. Please try again in a moment.", 'orderguard' ) ); return; } orderguard_set_notice( 'success', sprintf( /* translators: %s is a country name. */ __( "Thanks — we'll let you know the moment we launch in %s.", 'orderguard' ), $country_name ) ); } /** * Simple transient-based admin notices for the activation flow. */ function orderguard_set_notice( $type, $message ) { set_transient( 'orderguard_admin_notice', array( 'type' => $type, 'message' => $message ), 30 ); } /** * --------------------------------------------------------------------------- * Render the admin page (welcome OR activated state) * --------------------------------------------------------------------------- */ function orderguard_render_admin_page() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } $activated = orderguard_is_activated(); $notice = orderguard_get_transient( 'orderguard_admin_notice' ); orderguard_delete_transient( 'orderguard_admin_notice' ); ?>
$orderguard_dpa_url, 'Политиката за поверителност' => $orderguard_privacy_url, 'Privacy Policy' => $orderguard_privacy_url, 'Условията' => $orderguard_terms_url, 'Terms' => $orderguard_terms_url, 'DPA' => $orderguard_dpa_url, ); foreach ( $orderguard_consent_links as $orderguard_word => $orderguard_url ) { $orderguard_consent = str_replace( $orderguard_word, '' . $orderguard_word . '', $orderguard_consent ); } // Inline parcel glyph -- the same box the usage-meter drift animation // uses (og-cap-box), so the two animations stay visually consistent. $orderguard_parcel_svg = ''; ?>

array( __( 'Account', 'orderguard' ), 'dashicons-admin-users' ), 'checkout' => array( __( 'Checkout', 'orderguard' ), 'dashicons-cart' ), 'features' => array( __( 'Features', 'orderguard' ), 'dashicons-admin-plugins' ), 'plan' => array( __( 'Plan', 'orderguard' ), 'dashicons-star-filled' ), 'support' => array( __( 'Support', 'orderguard' ), 'dashicons-sos' ), 'legal' => array( __( 'Legal', 'orderguard' ), 'dashicons-media-text' ), ); if ( ! isset( $tabs[ $active_tab ] ) ) { $active_tab = 'account'; } $base_url = admin_url( 'admin.php?page=orderguard' ); $orderguard_me = function_exists( 'orderguard_get_me' ) ? orderguard_get_me() : array( 'plan' => 'free' ); $orderguard_plan = ( isset( $orderguard_me['plan'] ) && 'pro' === $orderguard_me['plan'] ) ? 'pro' : 'free'; $is_pro_h = ( 'pro' === $orderguard_plan ); // Real usage meter, from /v1/me. limit === null means unlimited (Pro); // for Free it's the monthly soft cap (nudge only -- scoring never stops). $orderguard_usage = ( isset( $orderguard_me['usage'] ) && is_array( $orderguard_me['usage'] ) ) ? $orderguard_me['usage'] : array(); $cap_used = isset( $orderguard_usage['checks_this_month'] ) ? (int) $orderguard_usage['checks_this_month'] : 0; $cap_limit = ( isset( $orderguard_usage['limit'] ) && null !== $orderguard_usage['limit'] ) ? (int) $orderguard_usage['limit'] : 0; $cap_over = ( $cap_limit > 0 && $cap_used >= $cap_limit ); $cap_pct = ( $cap_limit > 0 ) ? max( 0, min( 100, round( $cap_used / $cap_limit * 100 ) ) ) : 0; // Usage-meter colour: green with headroom, amber approaching, red near/over cap. if ( $cap_pct >= 90 ) { $cap_color = '#E24B4A'; // red } elseif ( $cap_pct >= 70 ) { $cap_color = '#EF9F27'; // amber } else { $cap_color = '#5DCAA5'; // green } ?>
OrderGuard OrderGuard
/ ∞
= 2 ) { break; } } if ( '' === $initials ) { $initials = 'OG'; } ?>

0 ) ? max( 0, min( 100, round( $imp_pdone / $imp_ptot * 100 ) ) ) : 0; ?>

' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . ''; ?>

/

/

0 && ! $orderguard_hist_hidden ) : ?>

'dashicons-email-alt', 'phone' => 'dashicons-phone', 'name' => 'dashicons-admin-users' ); ?>

'#EAF6F1', 'warning' => '#FDF3E3', 'error' => '#FCEBEB' ); $ogx_fg = array( 'success' => '#0F6E56', 'warning' => '#854F0B', 'error' => '#A32D2D' ); $ogx_k = isset( $ogx_bg[ $ogx_notice['kind'] ] ) ? $ogx_notice['kind'] : 'success'; ?>

Developer tools

✓
✓
✓
⚠

#1042
1 2

style="display:none;" />

$cs_adapter ) : $cs_logo = function_exists( 'orderguard_courier_logo' ) ? orderguard_courier_logo( $cs_id ) : array( 'label' => ucfirst( $cs_id ), 'img' => '', 'icon' => 'dashicons-archive' ); $cs_n = isset( $cs_counts[ $cs_id ] ) ? (int) $cs_counts[ $cs_id ] : 0; $cs_mreja_local = ( 'econt' === $cs_id && function_exists( 'orderguard_econt_mreja_uses_local_tables' ) && orderguard_econt_mreja_uses_local_tables() ); // Courier-problem notice: an explicit account-authentication marker plus // two statistical arms covering each other's blind spot -- // orderguard_courier_looks_broken() for total // silence (never worked / worked-then-died / broken get_awb), // orderguard_courier_coverage_alarm() for PARTIAL silence (a trickle of // reads keeps the first arm's fast-clear resetting while most // outcomes are lost). Trigger reasoning + every no-fire guard: // orderguard_courier_problem_detected() in courier-sync.php. $cs_auth_problem = ( $cs_on && function_exists( 'orderguard_courier_auth_failure_active' ) && orderguard_courier_auth_failure_active( $cs_id ) ); $cs_problem = ( $cs_on && function_exists( 'orderguard_courier_problem_detected' ) && orderguard_courier_problem_detected( $cs_id ) ); // Coverage meter (0.1.9, roadmap #4): "of the shipped orders old // enough to have finished, how many did we read an outcome for?" // Shown only from a FRESH completed pass with enough measured // orders for a rate to mean anything; otherwise the row falls // back to the recorded-outcomes count (the pre-0.1.9 row, and // the first hour after upgrading, look exactly alike). $cs_cov = ( $cs_on && function_exists( 'orderguard_courier_coverage' ) ) ? orderguard_courier_coverage( $cs_id ) : array( 'denom' => 0, 'num' => 0, 'pct' => 0, 'fresh' => false, 'completed_at' => 0 ); $cs_meter = ( $cs_on && ! empty( $cs_cov['fresh'] ) && (int) $cs_cov['denom'] >= (int) ( defined( 'ORDERGUARD_COURIER_COVERAGE_SHOW_MIN' ) ? ORDERGUARD_COURIER_COVERAGE_SHOW_MIN : 5 ) ); if ( $cs_meter ) { $cs_any_meter = true; } // Meter colours -- and a problem row is ALWAYS red, even when // the (backward-looking) percentage is still high: one row, one // story. 96% green next to "there's a problem" would be two. $cs_pct = (int) $cs_cov['pct']; if ( $cs_problem || $cs_pct < 30 ) { $cs_fg = '#B23B39'; $cs_bar = '#C4534F'; } elseif ( $cs_pct < 70 ) { $cs_fg = '#BA7517'; $cs_bar = '#E0A63F'; } else { $cs_fg = '#0F6E56'; $cs_bar = '#1D9E75'; } /* translators: 1: number of shipments with a delivery result read, 2: number of shipments old enough to have one. */ $cs_fraction = sprintf( __( '%1$s of %2$s shipments', 'orderguard' ), number_format_i18n( (int) $cs_cov['num'] ), number_format_i18n( (int) $cs_cov['denom'] ) ); // Per-courier freshness -- now shown on problem rows too: // "last: 3 weeks ago" next to the notice is exactly the detail // support will ask for first. $cs_last = ( $cs_on && function_exists( 'orderguard_courier_last_recorded_label' ) ) ? orderguard_courier_last_recorded_label( $cs_id ) : ''; ?> $cs_tlabel ) : ?>
0 ) : ?>
0 ) : ?>
$cs_pt ) : // Real height rendered server-side, so the strip is correct // even if the animation never runs. The grow effect is a // pure-CSS scaleY on top -- no JS needed for the bars. $cs_pct = (int) round( ( (int) $cs_pt['count'] / $cs_peak ) * 100 ); ?>
OrderGuard
72
18

'free' ); $plan = ( isset( $me['plan'] ) && 'pro' === $me['plan'] ) ? 'pro' : 'free'; $sub = ( isset( $me['subscription'] ) && is_array( $me['subscription'] ) ) ? $me['subscription'] : array(); /* * Checkout deliberately does NOT happen inside wp-admin. The * upgrade button links out to orderguard.net, so this plugin * loads no third-party payment script, ships no billing keys, * and is independent of whichever provider sits behind that * page -- changing provider is a website change, not a plugin * update that has to clear review again. */ $pro_url = 'https://www.orderguard.net/pro/'; // 0.3.1 UI: the Free view is a Pro spotlight (monthly/yearly choice) plus a plan comparison. // Prices: fixed list prices, or the server's when it reports a valid pair (see orderguard_plan_prices()); // the exact amount, including any VAT, is shown at checkout. $orderguard_free_view = empty( $me['complimentary'] ) && 'pro' !== $plan; ?>

false ); $interval = isset( $sum['interval'] ) ? $sum['interval'] : ''; $cycle_lbl = ( 'year' === $interval ) ? __( 'billed yearly', 'orderguard' ) : ( ( 'month' === $interval ) ? __( 'billed monthly', 'orderguard' ) : '' ); $per = ( 'year' === $interval ) ? '/yr' : ( ( 'month' === $interval ) ? '/mo' : '' ); $amount = ( ! empty( $sum['amount'] ) ) ? orderguard_format_money( $sum['amount'], isset( $sum['currency'] ) ? $sum['currency'] : 'EUR' ) : ''; $cancel_at = ! empty( $sum['scheduled_cancel_at'] ) ? $sum['scheduled_cancel_at'] : ''; $next_at = ! empty( $sum['next_billed_at'] ) ? $sum['next_billed_at'] : ''; $card = ! empty( $sum['card'] ) ? $sum['card'] : null; $switch_lbl = ( 'year' === $interval ) ? __( 'Switch to monthly billing', 'orderguard' ) : __( 'Switch to yearly billing', 'orderguard' ); $switch_sub = __( 'Change your billing interval. Check the updated renewal date after switching.', 'orderguard' ); $switch_url = wp_nonce_url( admin_url( 'admin-post.php?action=orderguard_paddle_switch' ), 'orderguard_paddle_switch' ); $update_url = wp_nonce_url( admin_url( 'admin-post.php?action=orderguard_paddle_portal&which=update' ), 'orderguard_paddle_portal' ); $cancel_url = wp_nonce_url( admin_url( 'admin-post.php?action=orderguard_paddle_portal&which=cancel' ), 'orderguard_paddle_portal' ); $btn_sec = 'display:inline-flex; align-items:center; gap:5px; text-decoration:none; font-size:13px; font-weight:600; background:#fff; border:1px solid #D5DCDA; border-radius:8px; padding:7px 14px; color:#34423F; white-space:nowrap;'; $lbl = 'font-size:12px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:#0e0e0e;'; ?>
OrderGuard Pro
.

  1. → →
  2. , .

'your country', 'flag' => '', 'framework' => 'your jurisdiction', 'is_eu' => false ); $accepted = orderguard_get_option( 'orderguard_activated_at', '' ); require_once __DIR__ . '/owned/native-legal-result.php'; $owned_result = OG_Native_Legal_Result::call( 'take' ); $data_res = is_wp_error( $owned_result ) ? false : ( $owned_result['value'] ?? false ); if ( is_wp_error( $owned_result ) ) { echo '

' . esc_html( $owned_result->get_error_message() ) . '

'; } $pending_erasure = function_exists( 'orderguard_pending_gdpr_erasure_summary' ) ? orderguard_pending_gdpr_erasure_summary() : array( 'jobs' => 0, 'orders' => 0, 'updated_at' => 0 ); // Base URL for the published legal pages. Override with the // 'orderguard_legal_base_url' filter if the site structure differs. $legal_base = untrailingslashit( apply_filters( 'orderguard_legal_base_url', 'https://www.orderguard.net' ) ); // Language-aware addresses: a Bulgarian admin gets the Bulgarian // pages under /bg/ (/bg/terms/ etc.), everyone else the English // ones. (0.3.1: the site moved its Bulgarian pages from /terms-bg/ // etc. to /bg/...; the old addresses redirect permanently.) // // MUST use orderguard_should_bg() -- the same helper that drives the // gettext map -- and NOT get_user_locale(). The plugin has its // own EN|BG switcher stored in user meta, so an admin can be // reading Bulgarian while WordPress itself is in English; using // the WP locale made the UI translate but the links stay EN. $orderguard_lprefix = ( function_exists( 'orderguard_should_bg' ) && orderguard_should_bg() ) ? '/bg' : ''; $docs = array( array( __( 'Terms of Service', 'orderguard' ), __( 'EU edition · version 1.3', 'orderguard' ), 'dashicons-media-text', $legal_base . $orderguard_lprefix . '/terms/' ), array( __( 'Privacy Policy', 'orderguard' ), __( 'EU edition · version 1.3', 'orderguard' ), 'dashicons-shield', $legal_base . $orderguard_lprefix . '/privacy/' ), array( __( 'Data Processing Agreement', 'orderguard' ), __( 'EU edition · GDPR Art. 28 · version 1.3', 'orderguard' ), 'dashicons-media-default', $legal_base . $orderguard_lprefix . '/dpa/' ), array( __( 'Cookie and Consent Notice', 'orderguard' ), __( 'EU edition · version 1.0', 'orderguard' ), 'dashicons-privacy', $legal_base . $orderguard_lprefix . '/cookies/' ), array( __( 'Refund Policy', 'orderguard' ), __( 'EU edition · version 1.0', 'orderguard' ), 'dashicons-money-alt', $legal_base . $orderguard_lprefix . '/refund/' ), ); $seclbl = 'font-size:12px; font-weight:600; letter-spacing:.05em; text-transform:uppercase; color:#0e0e0e;'; $btn_sec = 'display:inline-flex; align-items:center; gap:5px; text-decoration:none; font-size:13px; font-weight:600; background:#fff; border:1px solid #D5DCDA; border-radius:8px; padding:7px 14px; color:#34423F; white-space:nowrap; cursor:pointer;'; ?>
—

·
/>
“”

“”.