, and documented the handful of
* deliberate phpcs exemptions. No behaviour change.
* 0.2.9 -- Housekeeping only, no behaviour change. Reworded internal code
* comments, corrected two that had gone stale and no longer described
* what the code actually does, and trimmed a trailing sentence from
* the note under the customer data tools on the Account tab.
* 0.2.8 -- Pro first-install delivery: a Pro merchant without the add-on now
* gets a Download button on the Plan tab. It asks the server for a
* short-lived signed link (HMAC over store/version/expiry, 5 min)
* and redirects; entitlement is re-checked at redemption, so a
* lapsed plan is refused even with a valid signature. The
* paid-but-not-installed notice points here instead of the website.
* 0.2.7 -- Data removal (uninstall opt-in): new Account-tab card storing
* orderguard_delete_data_on_uninstall, plus uninstall.php, which deletes
* this site's OrderGuard data ONLY when that option is exactly
* 'yes'. Ships OFF -- deleting the plugin keeps everything by
* default. Strictly local: uninstall.php makes no network calls,
* so nothing already contributed to the shared network is touched.
* Also: the free plugin now tells a merchant who has subscribed to
* Pro but has not installed the add-on yet (guideline 11 pointer,
* dismissible), and the add-on serves its own updates from
* orderguard.net -- WP.org never serves this slug.
* 0.2.6 -- Block checkout (#53): COD is now hidden there for manually blocked
* customers too -- the cart hide-flag carries the manual and auto
* verdicts independently -- and a 'block_cod' shopper sees the same
* explanatory notice as on classic checkout.
* 0.2.5 -- Merchant Signals: cross-store flag/block sharing is now OFF by
* default and opt-in via a free Features-tab card (own toggle +
* privacy-policy acknowledgment); Докладвай copy is local-only.
*/
if ( ! defined( 'ORDERGUARD_VERSION' ) ) { define( 'ORDERGUARD_VERSION', '0.3.1' ); }
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Debug logging. Every diagnostic line in this plugin goes through here.
*
* On a live store nothing is written: a courier sync that logged on every poll
* would fill the host's PHP error log with noise the merchant never asked for.
* Add define( 'WP_DEBUG', true ); to wp-config.php to turn it back on while
* you are chasing something.
*/
function orderguard_log( $message ) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- this is the single, deliberately gated logging path for the whole plugin.
error_log( is_scalar( $message ) ? (string) $message : wp_json_encode( $message ) );
}
require_once plugin_dir_path( __FILE__ ) . 'stored-data.php';
/**
* Server base URL. Overridable from wp-config.php -- define ORDERGUARD_SERVER_URL there
* (e.g. define( 'ORDERGUARD_SERVER_URL', 'https://api.orderguard.example' );) so going
* live is a config change, not a code edit. Non-local URLs MUST be HTTPS:
* identity hashes and the API key travel this connection.
*/
if ( ! defined( 'ORDERGUARD_SERVER_URL' ) ) {
define( 'ORDERGUARD_SERVER_URL', 'https://api.orderguard.net' );
}
/**
* WooCommerce High-Performance Order Storage (HPOS).
*
* OrderGuard reads and writes orders exclusively through the WC_Order CRUD API
* (get_* / update_meta_data / save), never by touching the posts tables
* directly -- so it works identically whether the store uses the legacy post
* storage or HPOS. Declaring it here is what stops WooCommerce listing the
* plugin as "incompatible" on the HPOS settings screen.
*
* Also declares compatibility with the block-based (Cart & Checkout blocks)
* experience: COD hiding runs through the Store API cart extension and the
* blocks registry, not by filtering the classic checkout template (#53).
*/
add_action( 'before_woocommerce_init', 'orderguard_declare_wc_compatibility' );
function orderguard_declare_wc_compatibility() {
if ( ! class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
return;
}
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
}
/**
* Transport safety. The API key + customer hashes travel to ORDERGUARD_SERVER_URL, so
* refuse to send them over plain http (fail closed) and warn the admin.
* Loopback / .local dev hosts may use http ONLY in dev mode (ORDERGUARD_DEV defined in
* wp-config.php) -- mirroring the server's separate local-development gate, so a
* production site can never plaintext-post to a ".local" host by accident.
*/
function orderguard_server_url_insecure() {
$host = wp_parse_url( ORDERGUARD_SERVER_URL, PHP_URL_HOST );
$scheme = wp_parse_url( ORDERGUARD_SERVER_URL, PHP_URL_SCHEME );
if ( 'https' === $scheme ) {
return false;
}
$is_local = orderguard_dev_mode() && ( '127.0.0.1' === $host || '::1' === $host || '[::1]' === $host || 'localhost' === $host
|| ( is_string( $host ) && ( '.local' === substr( $host, -6 ) || '.localhost' === substr( $host, -10 ) ) ) );
return ! $is_local;
}
add_filter( 'pre_http_request', 'orderguard_block_insecure_server_calls', 10, 3 );
function orderguard_block_insecure_server_calls( $pre, $args, $url ) {
if ( orderguard_server_url_insecure() && is_string( $url ) && 0 === strpos( $url, ORDERGUARD_SERVER_URL ) ) {
return new WP_Error( 'orderguard_insecure_transport', 'OrderGuard: refusing to send data over a non-HTTPS connection. Set ORDERGUARD_SERVER_URL to an https:// URL.' );
}
return $pre;
}
add_action( 'admin_notices', 'orderguard_insecure_server_notice' );
function orderguard_insecure_server_notice() {
if ( ! orderguard_server_url_insecure() || ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
echo '
OrderGuard: the server URL (' . esc_html( ORDERGUARD_SERVER_URL ) . ') is not HTTPS. Your API key and customer data would be sent unencrypted, so OrderGuard has stopped sending. Set ORDERGUARD_SERVER_URL to an https:// address in wp-config.php. (Local development only: also add define( \'ORDERGUARD_DEV\', true ); to allow plain http to a loopback/.local server.)
';
}
require_once plugin_dir_path( __FILE__ ) . 'admin-welcome.php';
require_once plugin_dir_path( __FILE__ ) . 'store-notice.php';
require_once plugin_dir_path( __FILE__ ) . 'courier-sync.php';
require_once plugin_dir_path( __FILE__ ) . 'review-queue.php';
require_once plugin_dir_path( __FILE__ ) . 'exclusions.php';
require_once plugin_dir_path( __FILE__ ) . 'network-signal-outbox.php';
require_once __DIR__ . '/owned-exclusion-consumers.php';
require_once __DIR__ . '/owned-native-legacy-gate.php';
if ( ! defined( 'ORDERGUARD_PLUGIN_FILE' ) ) { define( 'ORDERGUARD_PLUGIN_FILE', __FILE__ ); }
require_once __DIR__ . '/package-bootstrap.php';
/**
* ---------------------------------------------------------------------------
* Localization. Loads translations and lets each admin pick EN/BG for the
* OrderGuard UI regardless of the site locale (stored per-user).
* ---------------------------------------------------------------------------
*/
/**
* Should OrderGuard render in Bulgarian for this request?
* - Admin: the merchant's per-user EN/BG toggle.
* - Frontend (customer-facing strings): the site locale.
*/
function orderguard_should_bg() {
// 0.3.1: text built for one fixed language (the Privacy Policy Guide entry).
if ( isset( $GLOBALS['orderguard_forced_language'] ) ) {
return 'bg' === $GLOBALS['orderguard_forced_language'];
}
if ( is_admin() ) {
$lang = orderguard_get_user_meta( get_current_user_id(), 'orderguard_admin_lang' );
if ( '' === $lang ) {
// No explicit choice yet -- follow this admin's own WordPress locale.
// This MUST mirror the fallback the EN|BG switcher uses to decide what
// it shows as selected; otherwise the switcher reads "BG" while nothing
// actually renders in Bulgarian.
$lang = ( 0 === strpos( get_user_locale(), 'bg' ) ) ? 'bg' : 'en';
}
return 'bg' === $lang;
}
return 0 === strpos( determine_locale(), 'bg' );
}
/**
* Bulgarian translations for OrderGuard strings (grown in batches). Keyed by the
* exact English source string. A matching .po lives in /languages for reference
* and future WordPress.org distribution.
*/
function orderguard_translations() {
static $map = null;
if ( null !== $map ) {
return $map;
}
$map = array(
'Support' => 'Поддръжка',
'Contact Support' => 'Свържете се с поддръжката',
'Support request refused.' => 'Заявката за поддръжка е отказана.',
'Support is temporarily unavailable. Return to your store and try later.' => 'Поддръжката временно не е достъпна. Върнете се в магазина си и опитайте по-късно.',
'Help with setup, courier connections, settings and identifying OrderGuard problems. Other plugins, themes, hosting and custom development are not covered.' => 'Помощ при първоначална настройка, свързване с куриери, настройки и установяване на проблеми с OrderGuard. Не обхваща други разширения, теми, хостинг или индивидуална разработка.',
'Replies Monday to Friday, in Bulgarian or English. No automatic system data is sent.' => 'Отговори от понеделник до петък на български или английски. Не се изпращат автоматично системни данни.',
'Priority support comes with Pro.' => 'Приоритетната поддръжка е включена в Pro.',
'WordPress.org support forum' => 'Форум за поддръжка в WordPress.org',
// 0.3.1 UI (Support tab, Plan tab for Free, Pro add-on install steps).
'support could not be opened right now. Please try again later, or ask on the community forum.' => 'поддръжката не може да бъде отворена в момента. Опитайте отново по-късно или задайте въпроса си във форума на общността.',
'(opens in a new tab)' => '(отваря се в нов раздел)',
'Help from the OrderGuard team, in Bulgarian or English.' => 'Помощ от екипа на OrderGuard на български или английски.',
'Replies Monday to Friday' => 'Отговори от понеделник до петък',
'Bulgarian · English' => 'Български · English',
'Community forum' => 'Форум на общността',
'Public questions and answers on WordPress.org. Open to every store.' => 'Публични въпроси и отговори в WordPress.org. Отворен за всеки магазин.',
'Open the forum' => 'Към форума',
'Priority support' => 'Приоритетна поддръжка',
'A private conversation with the OrderGuard team, answered first.' => 'Лична кореспонденция с екипа на OrderGuard, с предимство при отговора.',
'Get Pro for priority support' => 'Вземете Pro за приоритетна поддръжка',
'Covered' => 'Обхваща',
'Not covered' => 'Не обхваща',
'Setup' => 'Първоначална настройка',
'Courier connections' => 'Свързване с куриери',
'Settings' => 'Настройки',
'OrderGuard problems' => 'Проблеми с OrderGuard',
'Other plugins' => 'Други разширения',
'Themes' => 'Теми',
'Hosting' => 'Хостинг',
'Custom development' => 'Индивидуална разработка',
'No system data is sent automatically. You choose what to share.' => 'Не се изпращат автоматично системни данни. Вие решавате какво да споделите.',
'Download' => 'Изтегляне',
'Upload' => 'Качване',
'Replace' => 'Замяна',
'You’re on Free' => 'Използвате Free',
'Unlock OrderGuard Pro' => 'Отключете OrderGuard Pro',
'Your own COD limits, live order-card scoring and priority support.' => 'Собствени лимити за наложен платеж, оценяване в поръчката на живо и приоритетна поддръжка.',
'Choose how you pay' => 'Изберете как да плащате',
'Monthly' => 'Месечно',
'Yearly' => 'Годишно',
'/ month' => '/ месец',
'/ year' => '/ година',
'Cancel anytime' => 'Прекратете по всяко време',
'Billed every month. Cancel anytime.' => 'Таксува се всеки месец. Прекратете по всяко време.',
'Billed once a year.' => 'Таксува се веднъж годишно.',
'About %1$s a month · save %2$s' => 'Около %1$s на месец · спестявате %2$s',
'Continue to secure checkout' => 'Към сигурното плащане',
'The exact amount, including any VAT, is shown at checkout.' => 'Точната сума, включително ДДС, ако се дължи, се показва при плащането.',
'Compare plans' => 'Сравнение на плановете',
'Order checks' => 'Проверки на поръчки',
'Included' => 'Включено',
'Review markers, flags and blocks' => 'Маркери за преглед, докладване и блокиране',
'Merchant Signals network' => 'Мрежа „Сигнали от други магазини“',
'Automatic COD hiding' => 'Автоматично скриване на наложения платеж',
'Fixed limits' => 'Фиксирани лимити',
'Your own limits' => 'Собствени лимити',
'Snapshot + live' => 'Снимка + на живо',
'Priority, in Bulgarian or English' => 'Приоритетна, на български или английски',
'Upgrade (monthly)' => 'Преминаване към Pro (месечно)',
'Billing interval changes are not available yet. Your current billing interval stays unchanged.' => 'Промяната на периода на плащане все още не е налична. Текущият Ви период на плащане остава непроменен.',
'Upgrade (annual)' => 'Преминаване към Pro (годишно)',
'A checkout for another billing interval is still open. Finish it or wait for its opening window to expire before changing interval.' => 'Имате отворена страница за плащане за друг период. Завършете плащането или изчакайте срокът за отварянето ѝ да изтече, преди да промените периода.',
'You already have Pro (complimentary).' => 'Вече имате безплатно предоставен достъп до Pro.',
'Upgrade request refused.' => 'Заявката за преминаване към Pro е отказана.',
'Checkout could not be opened. Wait before trying again or contact support.' => 'Страницата за плащане не се отвори. Изчакайте, преди да опитате отново, или се свържете с поддръжката.',
'Change your billing interval. Check the updated renewal date after switching.' => 'Променете периода на плащане. След промяната проверете новата дата за подновяване.',
'Renewal date unavailable. Check your billing portal.' => 'Датата за подновяване не е налична. Проверете в портала за плащания.',
'See Pro plans' => 'Вижте плановете Pro',
'Checkout happens on orderguard.net. Once payment is confirmed, your plan activates here automatically — no key to copy.' => 'Плащането се извършва на orderguard.net. След потвърждение планът ви се активира тук автоматично — няма ключ за копиране.',
'Account' => 'Акаунт',
'Checkout' => 'Чекаут',
'Plan' => 'План',
'Legal' => 'Правно',
'Open' => 'Отвори',
'These are the current versions in force for your store. We’ll ask you to review again if they materially change.' => 'Това са текущите версии, които са в сила за вашия магазин. Ще ви помолим да ги прегледате отново, ако се променят съществено.',
'Pro' => 'Pro',
'Free' => 'Безплатен план',
'Order checks this month' => 'Проверени поръчки този месец',
"This order can't be completed online. Please contact the store to place your order." => 'Тази поръчка не може да бъде завършена онлайн. Моля, свържете се с магазина, за да я направите.',
"Cash on delivery isn't available for this order. Please choose another payment method." => 'Наложеният платеж не е наличен за тази поръчка. Моля, изберете друг начин на плащане.',
'Cash on delivery is not available for this order. Please choose another payment method.' => 'Наложеният платеж не е наличен за тази поръчка. Моля, изберете друг начин на плащане.',
'Whether COD should be hidden for this customer.' => 'Дали наложеният платеж да бъде скрит за този клиент.',
'Translated notice explaining why COD is unavailable; empty when there is nothing to show.' => 'Преведено съобщение, обясняващо защо наложеният платеж не е наличен; празно, когато няма какво да се покаже.',
// --- Account tab ---
'Store' => 'Магазин',
'Account email' => 'Имейл на акаунта',
'Connection' => 'Връзка',
'Verified' => 'Потвърдена',
'Order history review' => 'Преглед на историята на поръчките',
'Unlimited' => 'Неограничено', // tooltip on the ∞ usage meter
'Coming soon' => 'Очаквайте скоро',
'A one-time review of your last 24 months of orders, to get you started faster.' => 'Еднократен преглед на последните ви 24 месеца поръчки, за да започнете по-бързо.',
// Order-history import (Account tab)
'When you activate, OrderGuard automatically reviews your last 24 months of orders in the background, seeding the network and showing how many of your customers it already recognises from other stores. Raw names, phone numbers and email addresses stay on your site. The import sends hashed phone and email identifiers together with the order reference, status and refund or non-collection facts, billing country and original creation date.' => 'При активиране OrderGuard автоматично преглежда поръчките ви за последните 24 месеца във фонов режим, захранва мрежата и показва колко от клиентите ви вече разпознава от други магазини. Имената, телефонните номера и имейл адресите в четим вид остават на вашия сайт. Импортът изпраща хеширани идентификатори за телефон и имейл заедно с номера на поръчката, статуса и данните за възстановяване на сума или невземане, държавата за фактуриране и първоначалната дата на създаване.',
'Reviewing your order history in the background…' => 'Преглед на историята на поръчките ви във фонов режим…',
'Your order history will be reviewed automatically.' => 'Историята на поръчките ви ще бъде прегледана автоматично.',
'of your customers already recognised across the network' => 'от вашите клиенти са вече разпознати в мрежата',
'Coverage appears once you\'ve imported enough history and other stores in the network overlap with your customers.' => 'Покритието се появява, след като внесете достатъчно история и други магазини в мрежата съвпаднат с ваши клиенти.',
'Import order history' => 'Внасяне на историята на поръчките',
'Re-run import' => 'Повторно внасяне',
'Scanning your orders...' => 'Сканиране на поръчките ви...',
'Finishing up...' => 'Приключване...',
'Import failed. Please try again.' => 'Внасянето е неуспешно. Моля, опитайте отново.',
'Paused: your exclusion list cannot be read. The history review and hourly sweep will retry automatically.' => 'На пауза: списъкът с изключения не може да бъде прочетен. Прегледът на историята и почасовата проверка ще опитат отново автоматично.',
'Paused: your exclusion list is over the safe limit. Remove older exclusions; the history review and hourly sweep will resume automatically.' => 'На пауза: списъкът с изключения надвишава безопасния лимит. Премахнете по-стари изключения; прегледът на историята и почасовата проверка ще се възобновят автоматично.',
'The exclusion list is full. Remove an old exclusion before adding another.' => 'Списъкът с изключения е пълен. Премахнете старо изключение, преди да добавите ново.',
'Your exclusion list is over the safe limit. Remove older exclusions to resume OrderGuard.' => 'Списъкът ви с изключения надвишава безопасния лимит. Премахнете по-стари изключения, за да възобновите OrderGuard.',
'Your exclusion list cannot be read. OrderGuard network features are paused; contact support before adding exclusions.' => 'Списъкът ви с изключения не може да бъде прочетен. Мрежовите функции на OrderGuard са на пауза; свържете се с поддръжката, преди да добавяте изключения.',
'OrderGuard is not connected yet.' => 'OrderGuard все още не е свързан.',
'Could not reach the OrderGuard server. Please try again.' => 'Сървърът на OrderGuard е недостъпен. Моля, опитайте отново.',
'Server error (%d). Please try again.' => 'Грешка от сървъра (%d). Моля, опитайте отново.',
'Insufficient permissions.' => 'Недостатъчни права.',
'Security check failed.' => 'Проверката за сигурност е неуспешна.',
// Usage meter (soft nudge over the free monthly allowance)
'You\'ve passed the free monthly allowance. Scoring keeps working — upgrade to Pro for unlimited checks.' => 'Надхвърлихте безплатния месечен лимит. Оценяването продължава да работи — минете на Pro за неограничени проверки.',
'Upgrade' => 'Минете на Pro',
'The new erasure engine is active.' => 'Новият механизъм за изтриване е активен.',
'The switch was refused. Confirm the stop procedure and try again from this page.' => 'Превключването е отказано. Потвърдете процедурата за спиране и опитайте отново от тази страница.',
'This store is not ready for the switch. Reload this page to see its current state.' => 'Магазинът не е готов за превключването. Презаредете страницата, за да видите текущото му състояние.',
'Old erasure workers are still running or could not be checked. Stop them using the procedure, then try again.' => 'Старите процеси за изтриване все още работят или не можаха да бъдат проверени. Спрете ги според процедурата и опитайте отново.',
'The switch did not complete. Check the requirements above and try again, or contact OrderGuard support.' => 'Превключването не завърши. Проверете изискванията по-горе и опитайте отново или се свържете с поддръжката на OrderGuard.',
// --- Data removal card (Account tab, uninstall opt-in) ---
'Data removal' => 'Изтриване на данни',
'Choose what happens to the data OrderGuard keeps on this site if you ever delete the plugin.' => 'Изберете какво да се случи с данните, които OrderGuard съхранява на този сайт, ако някога изтриете разширението.',
'Delete all OrderGuard data when the plugin is deleted.' => 'Изтриване на всички данни на OrderGuard при изтриване на разширението.',
'This removes your local flagged and blocked customers, local erasure protections, all OrderGuard settings, and every OrderGuard note on your orders.' => 'Това премахва локалните ви докладвани и блокирани клиенти, локалните защити след заличаване, всички настройки на OrderGuard и всяка бележка на OrderGuard по поръчките ви.',
'This runs only when you delete the plugin from the Plugins screen. Deactivating it never deletes anything. Left off, everything is kept, so you can uninstall and reinstall without losing data.' => 'Това се изпълнява само когато изтриете разширението от екрана „Разширения“. Деактивирането никога не изтрива нищо. Ако е изключено, всичко се запазва и можете да деинсталирате и инсталирате отново, без да губите данни.',
'If customer erasure or exclusion cleanup is still pending, WordPress refuses to uninstall OrderGuard. Reactivate the plugin, let removal finish, then try again.' => 'Ако изтриването на клиентски данни или изчистването на изключен клиент още е в ход, WordPress отказва да деинсталира OrderGuard. Активирайте разширението отново, оставете премахването да завърши и опитайте пак.',
'Signals already contributed to the shared network are not removed by this; to ask for that, contact info@orderguard.net.' => 'Сигналите, вече допринесени към споделената мрежа, не се премахват от това; за такова искане пишете на info@orderguard.net.',
'Data removal settings saved.' => 'Настройките за изтриване на данни са запазени.',
// --- Insights ---
'Insights' => 'Статистика',
'today' => 'днес',
'this week' => 'тази седмица',
'this month' => 'този месец',
'this year' => 'тази година',
'this period' => 'този период',
'Today' => 'Днес',
'This week' => 'Тази седмица',
'This month' => 'Този месец',
'This year' => 'Тази година',
'Custom' => 'Персонализиран',
'Custom range' => 'Персонализиран период',
'From' => 'От',
'To' => 'До',
'Apply' => 'Приложи',
'Orders checked' => 'Проверени поръчки',
'Flagged for review' => 'Докладвани за преглед',
'COD auto-hidden' => 'Автоматично скрит наложен платеж',
'Activity' => 'Активност',
'No activity in this period yet.' => 'Все още няма активност през този период.',
'%1$d checked, %2$d avoided' => '%1$d проверени, %2$d избегнати',
'Showing' => 'Показва се',
'up' => 'по-високи с',
'down' => 'по-ниски с',
'Risky COD orders avoided held steady versus the previous period.' => 'Избегнатите рискови поръчки с наложен платеж се запазиха на същото ниво спрямо предходния период.',
'Risky COD orders avoided are %1$s %2$d%% versus the previous period.' => 'Избегнатите рискови поръчки с наложен платеж са %1$s %2$d%% спрямо предходния период.',
'Most of these were repeat non-collectors caught before dispatch.' => 'Повечето от тях бяха повторни клиенти, които не прибират поръчките си, разпознати преди изпращане.',
'All Insights figures are calculated and stored on your own site; this report sends no order details or customer identities.' => 'Всички данни в „Статистика“ се изчисляват и съхраняват на вашия собствен сайт; този отчет не изпраща данни за поръчки или идентификатори на клиенти.',
// --- Blocked clients ---
'Blocked' => 'Блокирани',
'Flagged' => 'Докладвани',
'COD only' => 'Само наложен платеж',
'Flag' => 'Докладване',
'Block COD' => 'Блокиране на наложен платеж',
'Blocked and flagged clients' => 'Блокирани и докладвани клиенти',
'Matching is on phone and email only, never name. Add from a customer’s order, or manually below.' => 'Съпоставянето е само по телефон и имейл, никога по име. Добавете от поръчка на клиент или ръчно по-долу.',
// (The Free-plan 100-entry cap strings lived here. The cap itself was a
// WP.org guideline-5 violation -- quota on the merchant's own local list
// -- and was removed in the free/Pro split, #38. Flags are uncapped.)
'Add a customer manually' => 'Добавяне на клиент ръчно',
'Name (optional)' => 'Име (по избор)',
'Email' => 'Имейл',
'Phone' => 'Телефон',
'Never collects COD orders' => 'Системно не взема поръчки с наложен платеж.',
'Serial returner' => 'Редовно връща поръчки',
'Payment fraud attempt' => 'Опит за измама при плащане',
'Abusive behaviour' => 'Обидно поведение',
'Other' => 'Друго',
'Add' => 'Добави',
'Enter at least an email or phone. Manually-added entries stay local to your store — they don’t contribute to the network signal.' => 'Въведете поне имейл или телефон. Ръчно добавените записи остават локални за вашия магазин — те не допринасят за мрежовия сигнал.',
'Search name, email or phone' => 'Търсене по име, имейл или телефон',
'No blocked customers yet' => 'Все още няма блокирани клиенти',
'No flagged customers yet' => 'Все още няма докладвани клиенти',
// 0.3.1: the store privacy-notice text and its reminder (store-notice.php).
'Checks and reporting also cover orders paid by methods other than cash on delivery. In addition to phone and email hashes, OrderGuard receives order dates, the billing country, internal order/item references, whether the order was cash on delivery, order-status changes, any refunded proportion, and the reporting courier where applicable. Matching exclusions suppress routine checks and reporting; a phone or email exclusion sends hashes to erase records previously submitted by this store. The full policy explains local records, optional features, retention and how erasure requests are handled.' => 'Проверките и отчитането обхващат и поръчки, платени по начин, различен от наложен платеж. Освен хешовете на телефона и имейла, OrderGuard получава датите на поръчките, държавата на фактуриране, вътрешни референции към поръчките/артикулите, дали поръчката е с наложен платеж, промените в състоянието ѝ, дела на възстановената сума, ако има такъв, и куриера, отчел резултата, когато е приложимо. Съответстващо изключване спира обичайните проверки и отчитане; при изключване по телефон или имейл се изпращат хешове, за да бъдат изтрити записи, подадени по-рано от този магазин. Пълната политика обяснява локалните записи, незадължителните функции, сроковете на съхранение и обработването на исканията за изтриване.',
'This store has not switched on OrderGuard’s optional features (automatic cash-on-delivery check, local flag/block records and Merchant Signals).' => 'Този магазин не е включил незадължителните функции на OrderGuard (автоматично управление на наложения платеж, локални маркирания/блокирания и „Сигнали от други магазини“).',
'This store has switched on the following optional OrderGuard features.' => 'Този магазин е включил следните незадължителни функции на OrderGuard.',
'Automatic cash-on-delivery check: where the reliability score indicates a high risk of refusal, the cash-on-delivery option may be hidden or restricted at checkout automatically. All other payment methods remain available. You can ask us to review this decision, express your point of view and contest it.' => 'Автоматично управление на наложения платеж: когато оценката за надеждност сочи висок риск от отказ, опцията „наложен платеж“ може автоматично да бъде скрита или ограничена при финализиране на поръчката. Всички останали начини на плащане остават достъпни. Можете да поискате от нас да преразгледаме това решение, да изразите своята гледна точка и да го оспорите.',
'Local flag/block records: we may mark a customer as flagged or blocked in our own store, for example after repeated refused deliveries. This record may include your name, email and phone number, is kept on our own website and, on its own, is not shared with other stores.' => 'Локални маркирания/блокирания: можем да маркираме или блокираме клиент в собствения си магазин – например след многократно отказани доставки. Този запис може да съдържа Вашето име, имейл и телефон, съхранява се на нашия собствен сайт и сам по себе си не се споделя с други магазини.',
'Merchant Signals: we share a coded (pseudonymised) “suspicious” or “blocked” signal with other participating stores and see signals from them. A signal is shown only once at least two independent stores have reported the same person, and no readable personal data is exchanged between stores.' => '„Сигнали от други магазини“: споделяме кодиран (псевдонимизиран) сигнал „подозрителен“ или „блокиран“ с други участващи магазини и виждаме сигналите, подадени от тях. Сигнал се показва едва след като поне два независими магазина са докладвали едно и също лице, а между магазините не се обменят лични данни в четим вид.',
'Text for your store’s privacy notice, built from the OrderGuard features this store has switched on. Add it to your privacy notice in full; it does not replace the rest of your notice.' => 'Текст за известието за поверителност на магазина, съставен според функциите на OrderGuard, включени в този магазин. Добавете го изцяло в известието си за поверителност; той не замества останалата част от известието.',
'the OrderGuard features switched on in this store, or the suggested wording, have changed since the text was last added to your privacy notice. Please update your privacy notice with the current text.' => 'включените в този магазин функции на OrderGuard или предложеният текст са се променили, откакто текстът беше добавен в известието ви за поверителност. Моля, обновете известието си с актуалния текст.',
'your store’s privacy notice must tell your customers that you use OrderGuard. The text is ready, built from the features switched on in this store.' => 'известието за поверителност на магазина ви трябва да информира клиентите, че използвате OrderGuard. Текстът е готов и е съставен според функциите, включени в този магазин.',
'Open the text' => 'Отвори текста',
'Bad nonce.' => 'Невалидна или изтекла заявка. Опитайте отново.',
'Automatic cash-on-delivery check' => 'Автоматично управление на наложения платеж',
'Local flag/block records' => 'Локални маркирания/блокирания',
'Your store’s privacy notice must tell your customers that you use OrderGuard. Add this text to it in full. It is built from the features switched on in this store, so copy it again if you change them. It does not replace the rest of your privacy notice.' => 'Известието за поверителност на магазина ви трябва да информира клиентите, че използвате OrderGuard. Добавете този текст изцяло в него. Той е съставен според функциите, включени в този магазин, затова го копирайте отново, ако ги промените. Той не замества останалата част от известието ви за поверителност.',
'Optional features switched on: none.' => 'Включени незадължителни функции: няма.',
'Optional features switched on: %s.' => 'Включени незадължителни функции: %s.',
'Copy text' => 'Копирай текста',
'The same text is in Settings → Privacy → Policy Guide, in your site’s language. OrderGuard does not change your privacy page itself.' => 'Същият текст има и в „Настройки → Поверителност → Ръководство за политиката“, на езика на сайта ви. OrderGuard не променя самата ви страница за поверителност.',
'Marked as added on %1$s by %2$s.' => 'Отбелязано като добавено на %1$s от %2$s.',
'The text has changed since it was last marked as added. Please update your privacy notice.' => 'Текстът се е променил, откакто последно беше отбелязан като добавен. Моля, обновете известието си за поверителност.',
'I have added it' => 'Добавих го',
'Add one from a customer’s order (the OrderGuard panel) or with “Add a customer manually” above.' => 'Добавете от поръчка на клиент (панела OrderGuard) или чрез „Добавяне на клиент ръчно“ по-горе.',
'Customer' => 'Клиент',
'Contact' => 'Контакт',
'Status' => 'Статус',
'Source' => 'Източник',
'Reason' => 'Причина',
'Added' => 'Добавен на',
'Manual' => 'Ръчно',
'Order #' => 'Поръчка #',
'From order' => 'От поръчка',
'over 24 months' => 'над 24 месеца',
'Remove' => 'Премахни',
'Activation failed (server said %d). Please try again or contact support.' => 'Активирането не бе успешно (отговор от сървъра: %d). Опитайте отново или се свържете с поддръжката.',
'Activation reset. You can activate again.' => 'Активирането е нулирано. Можете да активирате отново.',
'COD protection settings saved.' => 'Настройките за защита при наложен платеж са запазени.',
'Checked your order history: %d clients need a decision.' => 'Историята на поръчките е проверена: %d клиенти очакват решение.',
'Checked your order history: nothing needs a decision.' => 'Историята на поръчките е проверена: няма клиенти, които очакват решение.',
'Client marked as fine. They will not appear in this list again.' => 'Клиентът е отбелязан като изряден и няма да се показва отново в този списък.',
'Could not complete key re-issue. Please try again.' => 'Преиздаването на ключа не можа да завърши. Опитайте отново.',
'Could not complete verification. Please try again. (%s)' => 'Проверката не можа да завърши. Опитайте отново. (%s)',
'Could not reach the OrderGuard server to re-issue your key. Please try again.' => 'Няма връзка със сървъра на OrderGuard за преиздаване на ключа. Опитайте отново.',
'Could not reach the OrderGuard server. Please try again. (%s)' => 'Няма връзка със сървъра на OrderGuard. Опитайте отново. (%s)',
'Could not start key re-issue (server said %d). Please contact support.' => 'Преиздаването на ключа не можа да започне (отговор от сървъра: %d). Свържете се с поддръжката.',
'Delivery status mapping saved.' => 'Съпоставянето на статусите на доставка е запазено.',
'Feature settings saved.' => 'Настройките на функциите са запазени.',
'Flag-for-review settings saved.' => 'Настройките за отбелязване за преглед са запазени.',
'Merchant Signals settings could not be saved just now. Nothing was changed; please try again.' => 'Настройките на сигналите от търговци не можаха да бъдат запазени. Нищо не е променено; опитайте отново.',
'Merchant Signals settings saved.' => 'Настройките на сигналите от търговци са запазени.',
'OrderGuard could not read the saved courier backfill state. Nothing was restarted; please contact support.' => 'OrderGuard не можа да прочете запазеното състояние на обработката на минали доставки. Нищо не е рестартирано; свържете се с поддръжката.',
'OrderGuard could not read the saved import state. Nothing was restarted; please contact support.' => 'OrderGuard не можа да прочете запазеното състояние на импорта. Нищо не е рестартирано; свържете се с поддръжката.',
'OrderGuard could not start the order-history import because its batch size is invalid. Nothing was sent; please contact support.' => 'OrderGuard не можа да започне импорта на историята на поръчките, защото размерът на партидата е невалиден. Нищо не е изпратено; свържете се с поддръжката.',
'OrderGuard could not start the order-history import. Nothing was sent; please contact support.' => 'OrderGuard не можа да започне импорта на историята на поръчките. Нищо не е изпратено; свържете се с поддръжката.',
'OrderGuard is already active.' => 'OrderGuard вече е активен.',
'OrderGuard is now active. Your store is protected.' => 'OrderGuard вече е активен. Магазинът ви е защитен.',
'OrderGuard reconnected. Your store is protected again.' => 'Връзката с OrderGuard е възстановена. Магазинът ви отново е защитен.',
'Reconciliation sweep run. Check the server log for details.' => 'Проверката за съгласуване е изпълнена. Вижте подробностите в журнала на сървъра.',
'Security check failed. Please try again.' => 'Проверката за сигурност е неуспешна. Опитайте отново.',
'The saved review decisions could not be read. Nothing was changed; please try again.' => 'Запазените решения от прегледа не можаха да бъдат прочетени. Нищо не е променено; опитайте отново.',
'We could not verify your site to finish activation (%s). Please try again in a moment.' => 'Сайтът ви не можа да бъде потвърден, за да завърши активирането (%s). Опитайте отново след малко.',
'We could not verify your site to re-issue the key (%s). Please try again in a moment.' => 'Сайтът ви не можа да бъде потвърден за преиздаване на ключа (%s). Опитайте отново след малко.',
'You do not have permission to activate OrderGuard.' => 'Нямате разрешение да активирате OrderGuard.',
'You must accept the terms to activate OrderGuard.' => 'Трябва да приемете условията, за да активирате OrderGuard.',
'Flag/block from an order or manually, with checkout enforcement, all work now. Cross-store sharing now lives in the Merchant Signals card on the Features tab — free, off by default, and on only if you opt in.' => 'Докладването/блокирането от поръчка или ръчно, с прилагане при плащане, вече работят. Споделянето с други магазини вече е в картата „Сигнали от други магазини“ в раздел „Функции“ — безплатно, изключено по подразбиране и активно само ако се включите.',
// --- Order panel ---
'Flag for review' => 'Докладвай',
'Block COD only' => 'Блокиране само на наложен платеж',
'Hard block' => 'Пълно блокиране',
// Merchant Signals is OFF by default (orderguard_network_signal_enabled, option
// default 'no'), so the old wording -- "a private note plus a network
// signal" -- described something that does not happen on a default
// install. Worded to be true either way rather than branching.
'A private record on your own site, plus a signal to other stores if you have Merchant Signals switched on. Checkout is not affected.' => 'Личен запис във вашия магазин, плюс сигнал до другите магазини, ако сте включили „Сигнали между търговци“. Начинът на плащане не се променя.',
'They can still order by prepaying, but not with cash on delivery.' => 'Клиентът все още може да поръчва, но само с предплащане, а не с наложен платеж.',
'They cannot place any order on your store. Use sparingly.' => 'Клиентът няма да може да прави поръчки във вашия магазин. Използвайте тази опция пестеливо.',
'Flag or block this customer' => 'Докладвайте или блокирайте този клиент',
'Matched on phone and email only, never name. Stored locally in readable form' => 'Съпоставянето е само по телефон и имейл, никога по име. Съхранява се локално в четим вид',
'readable details stay on your site; if Merchant Signals is on, only one-way hashes and the signal type are shared.' => 'четимите данни остават на вашия сайт; ако „Сигнали от други магазини“ е включено, се споделят само еднопосочни хешове и видът на сигнала.',
'Currently:' => 'В момента:',
'Remove flag' => 'Премахни докладването',
'A network signal, not a rule. You decide how to handle this order.' => 'Мрежов сигнал, не правило. Вие решавате как да процедирате с тази поръчка.',
'OrderGuard isn\'t active yet.' => 'OrderGuard все още не е активен.',
'Activate it' => 'Активирайте го',
'to see reliability scores.' => 'за да видите оценките за надеждност.',
'No order data.' => 'Няма данни за поръчката.',
'checked %s ago' => 'проверено преди %s',
'Not yet checked.' => 'Все още не е проверено.',
'Collection risk' => 'Риск при получаване',
'Return risk' => 'Риск от връщане',
'This customer crossed your review limit. Consider confirming before dispatch.' => 'Този клиент надхвърли лимита ви за преглед. Обмислете потвърждение преди изпращане.',
// #38 -- "your own decision" banner at the top of the order panel.
// Body sentences deliberately reuse the flag-control strings above.
'You flagged this customer for review' => 'Докладвахте този клиент за преглед',
'You blocked cash on delivery for this customer' => 'Блокирахте наложения платеж за този клиент',
'You hard blocked this customer' => 'Приложихте пълно блокиране на този клиент',
'They cannot place any order on your store.' => 'Клиентът не може да прави никакви поръчки във вашия магазин.',
'Your reason: %s.' => 'Вашата причина: %s.',
// M-2: COD gate-state banner (order panel)
'COD is blocked for this customer' => 'Наложеният платеж е блокиран за този клиент',
'Their network history is over your COD limits right now, so cash on delivery is hidden from them at checkout. They can still pay in advance.' => 'Мрежовата им история в момента надхвърля лимитите ви за наложен платеж, затова наложеният платеж е скрит за тях при плащане. Все още могат да платят предварително.',
'The risk score above reflects this customer\'s network history. Their detailed order counts unlock once they complete their first order with your store.' => 'Оценката за риск по-горе отразява мрежовата история на този клиент. Подробните бройки на поръчките му се отключват, след като направи първата си поръчка във вашия магазин.',
// H-1: snapshot-mode variant of the counts-withheld note. The frozen
// record never changes, so the unlock promise points at FUTURE orders.
'The risk score above reflects this customer\'s network history. Their detailed order counts were still locked when this order was checked -- they unlock on the customer\'s future orders once they complete their first order with your store.' => 'Оценката за риск по-горе отразява мрежовата история на този клиент. Подробните бройки на поръчките му все още бяха заключени при проверката на тази поръчка -- те се отключват в бъдещите му поръчки, след като завърши първата си поръчка във вашия магазин.',
// 0.2.21 -- the two variants above are only true for a customer who
// really has not completed an order here. A store that IMPORTED its
// history has no non-import sighting of its own, which is what the
// server's unlock actually requires, so those merchants were told
// "their first order" about customers whose third order they packed
// themselves. These two say what is really holding the counts back.
'The risk score above reflects this customer\'s network history. Their detailed counts stay locked until OrderGuard has itself checked one of their orders at your store and recorded how it ended -- the orders imported from your history do not unlock them. Their next order with you will.' => 'Оценката за риск по-горе отразява мрежовата история на този клиент. Подробните бройки остават заключени, докато OrderGuard сам не провери негова поръчка във вашия магазин и не отбележи как е приключила -- поръчките, внесени от вашата история, не ги отключват. Следващата му поръчка при вас ще го направи.',
'The risk score above reflects this customer\'s network history. Their detailed counts were still locked when this order was checked: OrderGuard had not yet checked one of their orders at your store and recorded how it ended, and the orders imported from your history do not unlock them. This record stays as it was, but their later orders can lift the lock.' => 'Оценката за риск по-горе отразява мрежовата история на този клиент. Подробните бройки все още бяха заключени при проверката на тази поръчка: OrderGuard още не беше проверил негова поръчка във вашия магазин и не беше отбелязал как е приключила, а поръчките, внесени от вашата история, не ги отключват. Този запис остава какъвто е, но по-късните му поръчки могат да махнат заключването.',
// TASK 49 -- the panel now SHOWS the store's own counts, so what is
// still withheld is only the network view. Two notes, live and snapshot.
'Your own records' => 'Вашите собствени записи',
// TASK 50 -- own-evidence surfacing. 'your store' is the suffix that
// tells a merchant which of the two pills he is looking at.
'your store' => 'вашият магазин',
'From your order history' => 'От историята на вашите поръчки',
'Looks fine' => 'Няма проблем',
'Hide this' => 'Скрий това',
'Decide about these clients' => 'Решете за тези клиенти',
'These clients refused or returned parcels repeatedly in your own past orders. Nothing has been done to any of them — decide one at a time, and they move to Flagged or Blocked when you do. This section disappears once it is empty.' => 'Тези клиенти многократно са отказвали или връщали пратки във вашите минали поръчки. Нищо не е направено с никого от тях — решавайте един по един и те преминават в „Докладвани“ или „Блокирани“, когато решите. Този раздел изчезва, след като се изпразни.',
'From your own orders only — nothing here was sent anywhere, and nothing about these clients has changed at your checkout.' => 'Само от вашите собствени поръчки — нищо оттук не е изпращано никъде и нищо за тези клиенти не се е променило при плащането ви.',
'They refused or returned parcels repeatedly in your own past orders. You already had this — nothing has been done to any of them, and cash on delivery stays available. You decide.' => 'Те многократно са отказвали или връщали пратки във вашите минали поръчки. Вие вече разполагахте с това — нищо не е направено с никого от тях и наложеният платеж остава наличен. Решението е ваше.',
'OrderGuard: your own records show %1$d not collected and %2$d returned for this customer. Cash on delivery was not blocked.' => 'OrderGuard: вашите собствени записи показват %1$d невзети и %2$d върнати за този клиент. Наложеният платеж не беше блокиран.',
'This customer has ordered from you before' => 'Този клиент вече е купувал от вас',
'From your store only, including your imported history. Not a network score.' => 'Само от вашия магазин, включително внесената от вас история. Това не е мрежова оценка.',
'Network counts stay locked until OrderGuard has itself checked one of their orders here and recorded how it ended. Their next order with you will do that.' => 'Мрежовите бройки остават заключени, докато OrderGuard сам не провери негова поръчка тук и не отбележи как е приключила. Следващата му поръчка при вас ще го направи.',
'Network counts were still locked when this order was checked. They unlock on the customer\'s later orders, once OrderGuard has itself checked one of their orders here and recorded how it ended.' => 'Мрежовите бройки все още бяха заключени при проверката на тази поръчка. Те се отключват при по-късните му поръчки, след като OrderGuard сам провери негова поръчка тук и отбележи как е приключила.',
'Marked as not collected' => 'Отбелязана като невзета',
'Undo' => 'Отмени',
'COD order. Did the customer not collect it?' => 'Поръчка с наложен платеж. Клиентът не я е взел ?',
'Report as not collected' => 'Отбележи като невзета',
// #46 -- "was it not collected?" prompt shown after blocking a COD order.
'Did the customer not collect the order?' => 'Не взе ли клиентът поръчката?',
'You blocked this customer from cash on delivery — this applies only to your store. If they did not collect the order, mark it as not collected so all other merchants find out.' => 'Блокирахте този клиент за наложен платеж — това важи само за вашия магазин. Ако не е взел поръчката, отбележете я като невзета, за да разберат всички останали търговци.',
'Blocking' => 'Блокиране',
'only in your store' => 'само във вашия магазин',
'Uncollected' => 'Невзета',
'notifies everyone' => 'уведомява всички',
'Yes — mark as not collected' => 'Да — отбележи като невзета',
'No — just block' => 'Не — само блокирай',
'Orders marked as not collected help every store in the network recognise customers who systematically avoid cash on delivery.' => 'Отбелязаните невзети поръчки помагат на всички магазини в мрежата да разпознаят клиенти, които системно не взимат наложен платеж.',
// Manual flag / block opt-in (Flag for review settings)
'Manual flag & block controls' => 'Ръчно докладване и блокиране',
// Flag-for-review card (Features tab)
'Free feature' => 'Безплатно',
'Mark risky customers for a check before dispatch' => 'Отбелязвайте рискови клиенти за проверка преди изпращане',
'Review marker' => 'Маркер за преглед',
'Shows a "Review" marker on orders whose not-collected or returned counts cross the limits below. Merchant-only -- the customer never sees it.' => 'Показва маркер „Преглед“ върху поръчки, чиито невзети или върнати поръчки надхвърлят лимитите по-долу. Само за търговеца — клиентът никога не го вижда.',
'Adds a flag / block control to every order. Blocking affects checkout on your store only. Sharing with other stores is separate — see Merchant Signals.' => 'Добавя бутон за докладване/блокиране към всяка поръчка. Блокирането важи само за чекаута във вашия магазин. Споделянето с други магазини е отделно — вижте „Сигнали от други магазини“.',
'Adds a flag / block control to every order. Flagging shares an anonymised signal with other merchants in the network. Blocking affects checkout on your store only.' => 'Добавя контрол за докладване/блокиране към всяка поръчка. Докладването споделя анонимизиран сигнал с останалите търговци в мрежата. Блокирането влияе на чекаута само във вашия магазин.',
'Off by default.' => 'Изключено по подразбиране.',
'Forced on by ORDERGUARD_DEV on this site.' => 'Принудително включено чрез ORDERGUARD_DEV на този сайт.',
// Merchant Signals card (Features tab) -- the cross-store flag/block
// sharing opt-in (#47). Free, OFF by default. The chip reuses the
// existing 'Free feature' entry above.
'Merchant Signals' => 'Сигнали от други магазини',
'Warning about a risky customer — before you ship' => 'Предупреждение за рисков клиент — преди да изпратите',
'See when other stores have already flagged or blocked a customer — before you ship. Turn on participation to receive signals and contribute your own.' => 'Вижте кога други магазини вече са докладвали или блокирали клиент — преди да изпратите. Включете участието, за да получавате сигнали и да допринасяте със своите.',
'Network participation' => 'Участие в мрежата',
'Off · turn on to take part' => 'Изключено · включете, за да участвате',
'On · receiving and sharing signals' => 'Включено · получавате и споделяте сигнали',
'Off by default. By turning it on, you confirm you have described the network in your privacy policy.' => 'Изключено по подразбиране. При включване потвърждавате, че сте описали мрежата в правилата си за поверителност.',
'Advisory, not a rule — you decide.' => 'Съвет, не правило — вие решавате.',
'Shows only when 2+ stores have reported.' => 'Показва се само когато 2+ магазина са докладвали.',
'Only hashed codes — never names.' => 'Само хеширани кодове — никога имена.',
// Merchant Signals preview labels (inside the hub SVG).
'New order' => 'Нова поръчка',
'awaiting dispatch' => 'очаква изпращане',
'Reported by 3 stores' => 'Докладван от 3 магазина',
// Customer summary block
'OrderGuard knows this customer' => 'OrderGuard познава този клиент',
'Summary' => 'Обобщение',
'Kept orders' => 'Взети поръчки',
'Returned orders' => 'Върнати поръчки',
'Not collected orders' => 'Невзети поръчки',
'This customer mostly keeps their orders.' => 'Този клиент обикновено получава поръчките си.',
'This customer has a clean history so far.' => 'До момента този клиент има безпроблемна история.',
'This customer returns or fails to collect more often than average.' => 'Този клиент връща или не получава поръчките си по-често от средното.',
'Scores will sharpen as this customer completes more orders.' => 'Оценките ще стават по-точни, когато този клиент завърши повече поръчки.',
// #38 follow-up -- the "no resolved history" branch of the same
// function. These two shipped as raw English literals, never wrapped,
// so a Bulgarian store saw them untranslated in an otherwise
// Bulgarian panel (spotted on bambukifamily #111186).
'New to the network' => 'Нов в мрежата',
// T-31 -- the OTHER zero: seen, but nothing resolved.
'Seen, but nothing to score yet' => 'Виждан, но все още няма какво да се оцени',
'Orders start counting once they are marked completed, returned or not collected.' => 'Поръчките започват да се броят, след като бъдат отбелязани като завършени, върнати или невзети.',
'OrderGuard hasn\'t seen this customer\'s completed orders yet. Scores will sharpen as they order more.' => 'OrderGuard още не е виждал завършени поръчки от този клиент. Оценките ще стават по-точни с всяка следваща поръчка.',
'One past COD non-collection.' => 'Една предишна неполучена поръчка с наложен платеж.',
'%d past COD non-collections.' => '%d предишни неполучени поръчки с наложен платеж.',
'%1$d %2$s %3$s, reported across the network.' => '%1$d %2$s %3$s, докладвани в мрежата.',
'recently' => 'наскоро',
'over the last month' => 'през последния месец',
'over the last %d months' => 'през последните %d месеца',
'order' => 'поръчка',
// --- Admin notices ---
'enter an email or a phone to flag a customer.' => 'въведете имейл или телефон, за да докладвате клиент.',
'couldn\'t open the Paddle portal right now. Please try again in a moment.' => 'в момента не можа да се отвори порталът на Paddle. Моля, опитайте отново след малко.',
'billing interval switched. The change takes effect at your next renewal.' => 'интервалът на таксуване е сменен. Промяната влиза в сила при следващото ви подновяване.',
'couldn\'t switch your billing interval right now. Please try again in a moment.' => 'в момента не можа да се смени интервалът на таксуване. Моля, опитайте отново след малко.',
'the data your store contributed for this customer was erased. Signals from other stores are unaffected.' => 'данните, подадени от вашия магазин за този клиент, бяха изтрити. Сигналите от други магазини не се засягат.',
'couldn\'t erase that customer. They may have no orders on your store, or the server was unreachable.' => 'този клиент не можа да бъде изтрит. Може да няма поръчки във вашия магазин или сървърът да е недостъпен.',
'Flag removed.' => 'Докладването е премахнато.',
'Customer flag saved.' => 'Докладването на клиента е запазено.',
// --- Full BG pass (Plan, Legal, Checkout, misc) ---
'COD checkout protection' => 'Защита на наложен платеж при плащане',
'Hide Cash on Delivery at checkout for customers with a history of not collecting or returning orders. They can still pay with other methods.' => 'Скрийте наложения платеж в чекаут страницата за клиенти, които са докладвани, че не взимат поръчките си или редовно връщат поръчки. Те все още ще могат да платят с друг платежен метод (ако предлагате такъв).',
'Enable COD protection.' => 'Включи защитата за наложен платеж.',
'When on, Cash on Delivery is hidden and blocked for customers whose non-collected or returned order counts cross the limits below.' => 'Когато тази функция е активирана, методът за плащане с наложен платеж се скрива и блокира за клиенти, които са докладвани, че не взимат поръчките си или редовно връщат поръчки.',
'This automatically affects a customer\'s checkout options. Confirm it complies with your local consumer and data-protection rules before enabling.' => 'Това автоматично влияе на опциите за плащане на клиента. Уверете се, че съответства на местните ви правила за защита на потребителите и личните данни, преди да го включите.',
'Not-collected limit' => 'Лимит за невзети поръчки',
'orders' => 'поръчки',
'Hide COD once a customer has failed to collect this many orders.' => 'Скриване на наложения платеж, след като клиент не е взел толкова поръчки.',
'Returns limit' => 'Лимит за връщания',
'Hide COD once a customer has returned this many orders.' => 'Скриване на наложения платеж, след като клиент е върнал толкова поръчки.',
'The limits are a Pro feature. On the Free plan they stay at 5 and 12 -- upgrade to set your own.' => 'Лимитите са активни само в Pro. В безплатния план те остават 5 и 12.',
'Save settings' => 'Запази настройките',
// Features tab -- order-card scoring (snapshot vs live)
// Header chip for cards whose feature lives in the Pro add-on. Unused until
// the free/Pro split (task #38) -- "Изисква Pro" states a requirement rather
// than implying the merchant already has the tier, which a bare "Pro" does.
'Requires Pro' => 'Изисква Pro',
'Features' => 'Функции',
'Order-card scoring' => 'Оценяване в поръчката',
'How a customer\'s risk shows on each order' => 'Как се показва рискът на клиента във всяка поръчка',
'Locks the score onto the order at check-time. You always see exactly what your team saw when they chose to ship -- a permanent record for disputes.' => 'Заключва оценката към поръчката в момента на проверката. Винаги виждате точно това, което екипът ви е видял, когато е решил да изпрати -- траен запис при спорове.',
'Re-reads the network every time you open the order, so the score is always current -- while still keeping the original snapshot underneath.' => 'Проверява мрежата всеки път, когато отворите поръчката, така че оценката винаги е актуална -- като същевременно запазва оригиналната снимка отдолу.',
'Snapshot' => 'Снимка',
'Live' => 'На живо',
'A snapshot is always saved when the order is placed -- Live just layers a fresh read on top, so the record is never lost.' => 'Снимка се запазва винаги, когато е направена поръчката -- режимът „На живо“ само добавя свежа проверка отгоре, така че записът никога не се губи.',
'Live scoring is a Pro feature. Free plans always use Snapshot.' => 'Оценяването на живо е Pro функция. Безплатните планове винаги използват Снимка.',
// Free-plugin static status line on the Features card (split #38): the
// snapshot mode is what the free plan HAS, stated positively -- never a
// disabled control.
'active on your plan' => 'активно във вашия план',
'Saved with the order' => 'Запазена с поръчката',
'Snapshot kept' => 'Снимката е запазена',
'Snapshot · frozen at check-time' => 'Снимка · замразена при проверката',
'flagged by 2 stores · updating' => 'докладван от 2 магазина · обновява се',
'At check-time' => 'При проверката',
// H-1 write-once snapshot: labels for orders scored BEFORE the fix.
// Their meta holds the sweep's LAST save, not the check-time value, so
// the panel must not claim "At check-time" for them.
'Last score kept' => 'Последната оценка е запазена',
'Last scored' => 'Последна оценка',
'An older OrderGuard version kept this score updated rather than frozen; this is its last update, not the check-time score.' => 'По-стара версия на OrderGuard обновяваше тази оценка, вместо да я замрази; това е последното ѝ обновяване, а не оценката при проверката.',
'Orders checked before snapshot locking arrived show their last saved score instead, clearly labelled.' => 'Поръчки, проверени преди въвеждането на заключването на снимките, показват последната си запазена оценка, ясно обозначена.',
'Confidence' => 'Достоверност',
'low' => 'ниска',
'medium' => 'средна',
'high' => 'висока',
'Collection' => 'Получаване',
'Return' => 'Връщане',
'Preview' => 'Визуализация',
'Mark orders from customers with a shaky history so you can confirm before dispatch. This is a private note to you -- the customer never sees anything.' => 'Отбелязвайте поръчки от клиенти с несигурна история, за да можете да потвърдите преди изпращане. Това е лична бележка за вас - клиентът никога не вижда нищо.',
'Enable flag for review.' => 'Включи докладването за преглед.',
'Show a "Review" marker on orders whose non-collected or returned counts cross the limits below. Merchant-only -- the customer never sees it.' => 'Показване на маркер „Преглед“ върху поръчки, чиито бройки невзети или върнати поръчки надхвърлят лимитите по-долу. Само за търговеца -- клиентът никога не го вижда.',
'Flag once a customer has failed to collect this many orders.' => 'Докладване, след като клиент не е взел поръчката си.',
'Flag once a customer has returned this many orders.' => 'Докладване, след като клиент е върнал толкова поръчки.',
'Plan & billing' => 'План и таксуване',
'billed yearly' => 'таксуван годишно',
'billed monthly' => 'таксуван месечно',
'Switch to monthly billing' => 'Преминаване към месечно таксуване',
'Switch to yearly billing' => 'Преминаване към годишно таксуване',
'Pay monthly instead of yearly. Changes at next renewal.' => 'Плащайте месечно вместо годишно. Промяната влиза в сила при следващото подновяване.',
'Pay yearly instead of monthly. Changes at next renewal.' => 'Плащайте годишно вместо месечно. Промяната влиза в сила при следващото подновяване.',
'Current plan' => 'Текущ план',
'Cancels' => 'Прекратява се',
'Active' => 'Активен',
'incl. VAT' => 'с вкл. ДДС',
'Cancels on' => 'Прекратява се на',
'Renews on' => 'Подновява се на',
'then moves to Free' => 'след което преминава към Безплатен',
'Payment method' => 'Начин на плащане',
'%1$s ending %2$s' => '%1$s, завършваща на %2$s',
'Manage subscription' => 'Управление на абонамента',
'Switch' => 'Смени',
'Update payment method' => 'Обновяване на начина на плащане',
'Change the card on file.' => 'Сменете записаната карта.',
'Update' => 'Обнови',
'Cancel subscription' => 'Прекратяване на абонамента',
'Scheduled to cancel on' => 'Насрочен за прекратяване на',
'Stay on Pro until' => 'Оставате на Pro до',
', then move to Free.' => ', след което преминавате към Безплатен.',
'Cancel your OrderGuard Pro subscription.' => 'Прекратете абонамента си за OrderGuard Pro.',
'Manage' => 'Управление',
'Cancel' => 'Прекрати',
'Payment changes and cancellation open your billing provider’s secure portal. Your card details are never stored on OrderGuard’s servers.' => 'Промените по плащането и прекратяването се извършват в защитения портал на доставчика на плащания. Данните на картата ви никога не се съхраняват на сървърите на OrderGuard.',
'Billing history' => 'История на плащанията',
'Paid' => 'Платена',
'Issued' => 'Издадена',
'Invoice' => 'Фактура',
'You’re on the' => 'В момента ползвате',
'You’re on the Free plan.' => 'В момента ползвате Безплатен план.',
// Perk list updated in the free/Pro split (#38): the 100-flag cap is gone
// from the free plugin, so "unlimited flagged/blocked customers" is no
// longer a Pro perk and must not be advertised as one.
'You’re on the Free plan. Upgrade to Pro for custom COD limits, live order-card scoring, and more.' => 'В момента ползвате Безплатен план. Минете на Pro за персонализирани лимити за наложен платеж, оценяване на живо в поръчките и още.',
'plan.' => 'план.',
'Billing isn’t configured on the server yet. Add your Paddle keys to the server' => 'Таксуването все още не е конфигурирано на сървъра. Добавете вашите Paddle ключове към сървъра',
'to enable upgrades.' => 'за да включите Pro.',
'Pro Monthly' => 'Pro месечен',
'Billed monthly. Cancel anytime.' => 'Таксува се месечно. Прекратете по всяко време.',
'Upgrade — Monthly' => 'Pro — месечно',
'Best value' => 'Най-изгодно',
'Pro Annual' => 'Pro годишен',
'Billed yearly. Best price.' => 'Таксува се годишно. Най-добра цена.',
'Upgrade — Annual' => 'Pro — годишно',
'Secure checkout by Paddle. Your plan activates automatically once payment is confirmed.' => 'Сигурно плащане чрез Paddle. Планът ви се активира автоматично, щом плащането бъде потвърдено.',
'Payment received — activating your plan. This page will refresh in a moment…' => 'Плащането е получено — активираме плана ви. Страницата ще се презареди след момент…',
'This store uses OrderGuard to help prevent uncollected and fraudulent cash-on-delivery orders. When you place an order, a one-way coded (hashed) form of your phone number and email — never the raw details themselves — is shared with OrderGuard\'s EU-based network. This coded identifier is still personal data, processed on the basis of our legitimate interest in preventing non-collection and loss (GDPR Art. 6(1)(f)). The network records whether orders are collected or returned, and this store may add a signal that it has flagged or blocked a customer; other stores see only aggregated counts, never your name, your contact details, or which store reported. You have the right to access your data, to object to this processing, and to request its deletion at any time — please contact us, or OrderGuard at info@orderguard.net. The OrderGuard privacy policy is published at https://www.orderguard.net/privacy/.' => 'Този магазин използва OrderGuard, за да намали невзетите и измамните поръчки с наложен платеж. Когато направите поръчка, еднопосочно кодирана (хеширана) форма на вашия телефон и имейл — никога самите данни — се споделя с базираната в ЕС мрежа на OrderGuard. Този кодиран идентификатор все пак представлява лични данни, обработвани на основание на нашия легитимен интерес за предотвратяване на невземане и загуби (чл. 6, пар. 1, б. „е“ от GDPR). Мрежата записва дали поръчките са взети или върнати, а този магазин може да добави сигнал, че е докладвал или блокирал клиент; другите магазини виждат само обобщени бройки, никога вашето име, данни за контакт или кой магазин е подал сигнала. Имате право на достъп до данните си, да възразите срещу тази обработка и да поискате изтриването им по всяко време — свържете се с нас или с OrderGuard на info@orderguard.net. Политиката за поверителност на OrderGuard е публикувана на https://www.orderguard.net/bg/privacy/.',
'Terms of Service' => 'Условия за ползване',
'EU edition · version 1.3' => 'Издание за ЕС · версия 1.3',
'EU edition · version 1.0' => 'Издание за ЕС · версия 1.0',
'Privacy Policy' => 'Политика за поверителност',
'Data Processing Agreement' => 'Споразумение за обработка на данни',
'EU edition · GDPR Art. 28 · version 1.3' => 'Издание за ЕС · чл. 28 от ОРЗД · версия 1.3',
'Cookie and Consent Notice' => 'Уведомление относно „бисквитките“ и съгласието',
'Refund Policy' => 'Политика за възстановяване на суми',
'Your legal framework' => 'Вашата правна рамка',
'The documents below apply to your jurisdiction.' => 'Документите по-долу се отнасят за вашата юрисдикция.',
'Your documents' => 'Вашите документи',
'Your acceptance record' => 'Вашият запис за приемане',
'Accepted on %s' => 'Прието на %s',
'Terms accepted at activation.' => 'Условията са приети при активацията.',
'You agreed to the Terms, Privacy Policy and DPA as an authorised representative of this store. We’ll ask you to review again if these documents materially change.' => 'Приехте Условията, Политиката за поверителност и Споразумението за обработка на данни като упълномощен представител на този магазин. Ще ви помолим да ги прегледате отново, ако тези документи се променят съществено.',
'Disclose OrderGuard to your customers' => 'Уведомете клиентите си за OrderGuard',
'Add this to your store’s privacy policy. The wording is tailored to your jurisdiction.' => 'Добавете това към политиката за поверителност на магазина си. Формулировката е съобразена с вашата юрисдикция.',
'Copy' => 'Копирай',
'Customer data requests' => 'Заявки за данни на клиенти',
'If a customer asks what data exists about them, or asks to remove it, handle it here. As the store, you are the data controller for your customers.' => 'Ако клиент попита какви данни съществуват за него или поиска да бъдат премахнати, обработете заявката тук. Като магазин вие сте администраторът на данните на вашите клиенти.',
'Removal still processing' => 'Премахването все още се обработва',
'Customer-erasure protection needs repair' => 'Защитата при изтриване на клиентски данни се нуждае от поправка',
'OrderGuard found unreadable privacy-protection state or a changed protection key. Customer identity is being kept off the network as a precaution. Restore the previous ORDERGUARD_GDPR_SUPPRESSION_SECRET and site options, or contact support before changing or deleting this data.' => 'OrderGuard откри нечетимо състояние на защитата на личните данни или променен ключ за защита. Като предпазна мярка данните за самоличност на клиента не се изпращат към мрежата. Възстановете предишната стойност на ORDERGUARD_GDPR_SUPPRESSION_SECRET и опциите на сайта или се свържете с поддръжката, преди да променяте или изтривате тези данни.',
'This customer\'s OrderGuard data was erased. The order is kept out of scoring, network lookups and reporting.' => 'Данните на този клиент в OrderGuard бяха изтрити. Поръчката остава извън оценяването, мрежовите справки и докладването.',
'Matching local orders that existed when the request was submitted are already protected. OrderGuard will replay the same safe server step until it is confirmed, without starting a second deletion. During this time, the private retry record temporarily retains the coded identity hashes, matching local order IDs and an opaque operation token needed to finish the request.' => 'Съвпадащите локални поръчки, които са съществували при подаването на искането, вече са защитени. OrderGuard ще повтаря същата безопасна стъпка към сървъра, докато тя бъде потвърдена, без да започва второ изтриване. През това време частният запис за повторен опит временно пази кодираните хешове на идентичността, номерата на съвпадащите локални поръчки и непрозрачен токен на операцията, необходими за завършване на искането.',
'While local discovery is running, OrderGuard pauses identity-bearing network reports. Identity matches and exact order references found locally or confirmed by the server remain protected for the frozen request boundary. The private retry record temporarily retains the coded identity hashes, matching local order IDs and an opaque operation token needed to finish the request. Safe server steps retry automatically unless a block requiring support is listed below.' => 'Докато локалното откриване се изпълнява, OrderGuard спира мрежовите доклади, съдържащи идентификатори. Съвпаденията по самоличност и точните номера на поръчки, намерени локално или потвърдени от сървъра, остават защитени в рамките на фиксирания обхват на заявката. Частният запис за повторен опит временно пази кодираните хешове на самоличността, номерата на съвпадащите локални поръчки и непрозрачен токен на операцията. Безопасните стъпки към сървъра се повтарят автоматично, освен ако по-долу не е посочено блокиране, изискващо поддръжка.',
'Pending requests: %1$d · Protected local orders found: %2$d' => 'Чакащи заявки: %1$d · Намерени защитени локални поръчки: %2$d',
'Last progress: %s' => 'Последен напредък: %s',
'Local scanning is paused because WooCommerce could not read order(s) %s. Repair or permanently delete those corrupt order records; OrderGuard will retry automatically.' => 'Локалното сканиране е на пауза, защото WooCommerce не можа да прочете поръчка(и) %s. Поправете или изтрийте окончателно тези повредени записи; OrderGuard ще опита отново автоматично.',
'Order storage changed while this request was running. Local protection remains active, but server removal is paused until the original WooCommerce storage mode is restored or support verifies the migration.' => 'Хранилището на поръчките се промени по време на обработката. Локалната защита остава активна, но премахването от сървъра е на пауза, докато първоначалният режим на WooCommerce бъде възстановен или поддръжката потвърди миграцията.',
'A removal step could not be verified safely. Local protection remains active, but automatic removal has stopped. Review the cause below before changing privacy state or deleting the plugin.' => 'Стъпка от премахването не можа да бъде потвърдена безопасно. Локалната защита остава активна, но автоматичното премахване е спряно. Прегледайте причината по-долу, преди да променяте състоянието за поверителност или да изтривате разширението.',
'A matching local flagged/blocked record changed after this request began. Review that record on Blocked and flagged clients. If it belongs in the removal, delete it there, then retry; OrderGuard will never delete the changed row by assumption.' => 'Съвпадащ локален запис за докладван или блокиран клиент е променен след началото на заявката. Прегледайте го в „Блокирани и докладвани клиенти“. Ако трябва да бъде премахнат, изтрийте го там и опитайте отново; OrderGuard никога няма да изтрие променен ред по предположение.',
'At least one block cannot be retried in place because its frozen scope or protocol proof needs supervised reconciliation. Contact OrderGuard support.' => 'Поне едно блокиране не може да бъде повторено на място, защото фиксираният му обхват или протоколното доказателство изисква контролирано съгласуване. Свържете се с поддръжката на OrderGuard.',
'Retry repairable removal steps' => 'Повтори поправимите стъпки за премахване',
'Customer email or phone' => 'Имейл или телефон на клиента',
'Look up a customer' => 'Справка за клиент',
'Check what signals exist for a phone or email.' => 'Проверете какви сигнали съществуват за телефон или имейл.',
'Look up' => 'Справка',
'Submit a removal request' => 'Подаване на заявка за премахване',
'Erase the signals your store contributed and protect matching orders that already exist from recreating them.' => 'Изтрийте сигналите, подадени от вашия магазин, и не позволявайте на вече съществуващите съвпадащи поръчки да ги създадат отново.',
'Request removal' => 'Заявка за премахване',
'Data found for' => 'Намерени данни за',
'order(s) seen across the network' => 'поръчка(и), засечени в мрежата',
'item-level records' => 'записа на ниво артикул',
'Flagged by' => 'Докладван от',
'merchant(s) —' => 'търговец(а) —',
'suspicious,' => 'подозрителни,',
'blocked' => 'блокирани',
'Readable records kept by this store' => 'Четими записи, пазени от този магазин',
'%1$s — name: %2$s; email: %3$s; phone: %4$s; reason: %5$s; source: %6$s; order: %7$s; recorded: %8$s; updated: %9$s' => '%1$s — име: %2$s; имейл: %3$s; телефон: %4$s; причина: %5$s; източник: %6$s; поръчка: %7$s; записано: %8$s; обновено: %9$s',
'Order' => 'Поръчка',
'Part of the lookup could not be completed. Retry before treating this as a complete access response.' => 'Част от справката не можа да бъде завършена. Опитайте отново, преди да приемете това за пълен отговор на искането за достъп.',
'The complete access lookup could not be verified. Please try again in a moment.' => 'Пълната справка за достъп не можа да бъде потвърдена. Моля, опитайте отново след малко.',
'Only aggregate counts are shown — OrderGuard’s cloud service never stores this customer’s name or contact details.' => 'Показват се само обобщени бройки — облачната услуга на OrderGuard никога не съхранява името или данните за контакт на този клиент.',
'No OrderGuard data found for' => 'Няма намерени данни в OrderGuard за',
'Couldn’t reach the server. Please try again in a moment.' => 'Сървърът не можа да бъде достигнат. Моля, опитайте отново след малко.',
'Removal erases your store’s signals for this customer. This site keeps store-keyed HMAC entries for the identity and exact matching order references, plus the request time and a frozen maximum-order-ID boundary, so existing, restored or edited orders cannot recreate erased data. No readable contact detail, raw transmitted hash or readable order ID is kept in that permanent register. Later orders are processed normally. While removal is pending, a private retry record temporarily retains the coded identity hashes and matching local order IDs needed to complete it; an unreadable blocking order ID may be shown to administrators on this tab. Signals other stores contributed stay in the network — each store controls its own data. Completed local records are removed if you delete plugin data using the Data removal setting; uninstall is refused while a removal request is pending.' => 'Премахването изтрива сигналите на вашия магазин за този клиент. Сайтът пази ключирани HMAC записи за самоличността и точните съвпадащи поръчки, както и часа на искането и фиксирана горна граница за номерата на поръчките, така че съществуващи, възстановени или редактирани поръчки да не могат да възстановят изтритите данни. В постоянния регистър няма четими данни за контакт, суров предаван хеш или четим номер на поръчка. По-късните поръчки се обработват нормално. Докато премахването се обработва, частен запис временно пази кодираните хешове и номерата на съвпадащите локални поръчки; номер на нечетима блокираща поръчка може да бъде показан на администраторите в този раздел. Сигналите от други магазини остават в мрежата — всеки магазин управлява своите данни. Завършените локални записи се премахват чрез настройката „Премахване на данни“; деинсталирането се отказва, докато има чакащо искане.',
'Removal erases your store’s network signals and matching readable flagged/blocked records held by this site. Local records are frozen at acceptance as row IDs with authenticated whole-row digests; no readable contact detail is copied into the retry job, and a row changed afterwards is left untouched for administrator review rather than deleted by assumption. This site also keeps store-keyed HMAC entries for the identity and exact order references found locally or confirmed by the server, plus the request time and a frozen maximum-order-ID boundary. Those recorded orders cannot recreate erased data if restored or edited; the identity boundary protects other pre-request matches while their identity remains the same. No readable contact detail, raw transmitted hash or readable order ID is kept in that permanent register. Later orders are processed normally. While local discovery runs, identity-bearing network reports are paused. A private retry record temporarily retains the coded identity and flag hashes, matching local order IDs, the authenticated local-row manifest, and the opaque server operation proof needed to complete removal; an unreadable blocking order ID may be shown to administrators on this tab. Signals other stores contributed stay in the network — each store controls its own data. Completed local records are removed if you delete plugin data using the Data removal setting; uninstall is refused while a removal request is pending or privacy-protection state needs repair.' => 'Премахването изтрива мрежовите сигнали на вашия магазин и съвпадащите четими локални записи за докладвани или блокирани клиенти. При приемането локалните записи се фиксират като номера на редове с удостоверени HMAC отпечатъци на целия ред; в задачата за повторен опит не се копират четими данни за контакт, а променен впоследствие ред се оставя за преглед от администратор, вместо да бъде изтрит по предположение. Сайтът пази и ключирани HMAC записи за самоличността и точните номера на поръчки, намерени локално или потвърдени от сървъра, заедно с момента на заявката и фиксирана горна граница за номерата на поръчките. Тези записани поръчки не могат да създадат изтритите данни отново при възстановяване или редактиране; границата по самоличност защитава и други съвпадения отпреди заявката, докато самоличността им остава същата. В постоянния регистър не се пазят четими данни за контакт, суров предаден хеш или четим номер на поръчка. По-късните поръчки се обработват нормално. Докато тече локалното откриване, мрежовите доклади с идентификатори са спрени. Частният запис за повторен опит временно пази кодираните хешове на самоличността и сигналите, номерата на съвпадащите локални поръчки, удостоверения манифест на локалните редове и непрозрачното доказателство за сървърната операция; номер на нечетима блокираща поръчка може да бъде показан на администраторите в този раздел. Сигналите от други магазини остават в мрежата — всеки магазин управлява своите данни. Завършените локални записи се премахват чрез настройката „Премахване на данни“; деинсталирането се отказва, докато има чакаща заявка или състоянието за защита на личните данни се нуждае от поправка.',
'After confirmed removal, the OrderGuard service also keeps a permanent server-keyed HMAC for each transmitted identity spelling and the UTC request time, scoped to this store. It is never scored or returned and contains no readable contact detail, raw transmitted hash or order reference. It stops matching orders created by that time — including requests from older plugin versions without a creation time — from recreating erased server data. Genuinely later orders are processed normally.' => 'След потвърдено премахване услугата OrderGuard също пази постоянен HMAC с ключ от сървъра за всеки предаден вариант на самоличността и UTC момента на искането, ограничен до този магазин. Записът никога не участва в оценяването или отговорите и не съдържа четими данни за контакт, суров предаден хеш или номер на поръчка. Той не позволява поръчки, създадени до този момент — включително заявки от по-стари версии без дата на създаване — да възстановят изтритите данни на сървъра. Действително по-късните поръчки се обработват нормално.',
'the customer’s matching local orders are protected. Removal from the OrderGuard server is still processing and will retry automatically; progress remains visible on the Legal tab.' => 'съвпадащите локални поръчки на клиента са защитени. Премахването от сървъра на OrderGuard все още се обработва и ще се повтори автоматично; напредъкът остава видим в раздел „Правни“.',
'the removal request could not be started locally, so no server deletion was attempted. Please try again.' => 'заявката за премахване не можа да бъде стартирана локално, затова не беше направен опит за изтриване от сървъра. Моля, опитайте отново.',
'the customer flag could not be saved safely. Please retry; if a removal request is processing, let it finish first.' => 'записът за клиента не можа да бъде запазен безопасно. Опитайте отново; ако се обработва заявка за премахване, първо изчакайте тя да завърши.',
'the blocked customer-removal job was resumed with its original protected scope.' => 'блокираната задача за премахване на клиентски данни беше възобновена с първоначалния си защитен обхват.',
'there is no safely resumable customer-removal job.' => 'няма задача за премахване на клиентски данни, която може да бъде възобновена безопасно.',
'the customer-removal job could not be resumed safely. Protocol faults still require support.' => 'задачата за премахване на клиентски данни не можа да бъде възобновена безопасно. Протоколните несъответствия все още изискват съдействие от поддръжката.',
// --- Courier sync (courier-sync.php order notes) ---
'OrderGuard: %1$s reported this delivery as %2$s.' => 'OrderGuard: %1$s отчете тази доставка като %2$s.',
'delivered and accepted (kept)' => 'доставена и приета (задържана)',
'returned after delivery' => 'върната след доставка',
'not collected' => 'не е потърсена',
// --- Courier sync UI (M4: Features card + provenance chips) ---
'Courier delivery sync' => 'Синхронизация на доставките с куриера',
'Courier-reported delivery outcomes' => 'Резултати от доставката, отчетени от куриера',
'Reads the tracking data made available by your courier integration for eligible shipped orders. When it contains an unambiguous final result, OrderGuard adds a courier-reported kept, returned or not-collected outcome to the customer\'s history. Unclear results stay open and are checked again later.' => 'Прочита данните за проследяване, които вашата куриерска интеграция предоставя за допустимите изпратени поръчки. Когато те съдържат недвусмислен краен резултат, OrderGuard добавя към историята на клиента отчетен от куриера резултат: задържана, върната или невзета. Неясните резултати остават отворени и се проверяват отново по-късно.',
'On' => 'Вкл.',
'Off' => 'Изкл.',
'OrderGuard never sends courier credentials to the OrderGuard servers. API-based adapters read credentials from the courier plugin only when asking that courier about your shipments. With the mreja.net WooCommerce Econt integration, OrderGuard does not contact Econt and does not use that plugin\'s credentials: it reads the tracking data the plugin already keeps in its own local tables. What reaches the OrderGuard servers is the order reference and how the delivery ended, never customer details.' => 'OrderGuard никога не изпраща данни за достъп до куриер към сървърите на OrderGuard. Адаптерите, които използват API, прочитат данните за достъп от куриерското разширение само когато питат куриера за вашите пратки. При интеграцията WooCommerce Econt от mreja.net OrderGuard не се свързва с Еконт и не използва данните за достъп на това разширение: прочита данните за проследяване, които то вече пази в собствените си локални таблици. До сървърите на OrderGuard достигат номерът на поръчката и как е приключила доставката — никога данни на клиента.',
// ('Courier delivery sync is a Pro feature.' removed in 0.1.4 -- the
// feature is now free on every plan.)
'Detected couriers' => 'Открити куриери',
'No supported courier or tracking plugin detected yet.' => 'Все още не е открито поддържано куриерско или тракинг разширение.',
'You can still sync outcomes: map your order statuses in "Delivery status mapping" below — any plugin that moves orders between statuses will feed OrderGuard.' => 'Пак можете да синхронизирате резултати: съпоставете статусите на поръчките си в „Съпоставяне на статусите на доставка“ по-долу — всяко разширение, което мести поръчки между статуси, ще захранва OrderGuard.',
'Reading' => 'Чете',
'Status map' => 'Карта на статусите',
'Needs mapping' => 'Изисква съпоставяне',
'Synced today' => 'Синхронизирани днес',
'Synced' => 'Синхронизирани',
'%d today' => '%d днес',
// 0.1.9: 'No readings' and its "no delivery result has EVER come back"
// explainer are gone -- "ever" was false for a courier that worked for
// months and then stopped (the M-1 case the rebuilt detector catches),
// and the vague "contact support" gave the merchant nowhere to click.
// Replaced by the courier-problem notice, inline on the courier's own
// row, with a real contact link.
'Problem' => 'Проблем',
'Your courier service rejected the saved credentials. Check them in the courier plugin. If the warning remains after the next sync, contact us.' => 'Куриерската услуга отхвърли запазените данни за достъп. Проверете ги в куриерското разширение. Ако предупреждението остане след следващата синхронизация, свържете се с нас.',
'There\'s a problem receiving results from your courier plugin. Contact us and we\'ll find out what\'s causing it.' => 'Има проблем с получаването на резултати от вашето куриерско разширение. Свържете се с нас и ще проверим какво го причинява.',
'This Econt plugin does not refresh tracking in the background. Its local tracking data is refreshed only when an unsettled order is opened in the WordPress admin, so automatic coverage may remain low.' => 'Това разширение на Еконт не обновява проследяването във фонов режим. Локалните му данни за проследяване се обновяват само когато незавършена поръчка бъде отворена в администрацията на WordPress, затова автоматичното покритие може да остане ниско.',
'Contact us' => 'Свържете се с нас',
// 0.1.7 courier card legibility. A merchant read "Speedy 27, BOX NOW 24,
// Synced 435" and tried to add the first two; and read "9 today" as
// "9 orders today" when the store had ~23. Both were labelling faults,
// not data faults -- these strings are the fix.
'Recorded outcomes by courier' => 'Записани резултати по куриер',
// 0.1.8: replaced 'since per-courier tracking began' -- the backfill seeds
// historical outcomes into the per-courier counters, so a vintage claim
// became false. This wording explains the different basis without one.
'counted from the records on your orders' => 'преброени от записите по поръчките ви',
// 0.1.9 coverage meter (roadmap #4): the per-courier headline is now a
// RATE -- "of the shipped orders old enough to have finished, how many
// did we read an outcome for?" -- because a raw count has no
// denominator and merchants measure it against every order the store
// ever took. The caption states the denominator in merchant words.
'%1$s of %2$s shipments' => '%1$s от %2$s пратки',
'share of shipments older than %1$d days (last %2$d days) with a delivery result read' => 'дял от пратките, по-стари от %1$d дни (последните %2$d дни), с прочетен резултат от доставката',
// 0.1.9: 'Total since start' demoted + renamed -- the old label invited
// "will it reach my total order count?" (it never would), and the
// panicked sums it produced are exactly what the meter dissolves.
'Total recorded results' => 'Общо записани резултати',
'%d finished today' => '%d приключили днес',
'Activity · last 14 days' => 'Активност · последните 14 дни',
// 0.1.9: was 'Collecting data…' / 'Събиране на данни…' -- that reads as
// ongoing activity, but it is the EMPTY state: orderguard_courier_daily_history
// only fills as NEW outcomes are recorded, so on a quiet store it sits
// there "collecting" while nothing collects. Misled two people on the
// day it shipped, one of whom wrote it.
'Will fill as new results arrive' => 'Ще се запълни с новите резултати',
'last: %s' => 'последен: %s',
'yesterday' => 'вчера',
'%s ago' => 'преди %s',
'An outcome is recorded only when the parcel finishes — collected, returned or not collected. That is why today\'s number does not match the number of new orders.' => 'Резултат се записва само когато пратката приключи — получена, върната или невзета. Затова днешното число не отговаря на броя нови поръчки.',
// Exclusion list (merchant-scoped "ignore this person entirely").
'Excluded customers' => 'Изключени клиенти',
'Your own and your staff\'s accounts — so test orders are never counted or signalled to the network.' => 'Вашите и на служителите ви акаунти — така тестовите поръчки никога не се отчитат и не се сигнализират в мрежата.',
'name@example.com, +359… or name' => 'име@example.com, +359… или име',
'Add to the list' => 'Добави в списъка',
'%d entry' => '%d запис',
'%d entries' => '%d записа',
'Phone numbers, email addresses and names are hashed locally; the raw value is discarded, and only hashes, a masked reminder, the reason and the date stay on your site. A phone or email exclusion also erases matching records your store sent earlier. The service then keeps a store-scoped, server-keyed HMAC and the exclusion time so older orders cannot recreate those records; genuinely later orders remain eligible if the local exclusion is removed. A name-only exclusion stays local and cannot erase server records.' => 'Телефонните номера, имейл адресите и имената се хешират локално; първоначалната стойност се изхвърля, а на сайта ви остават само хешове, маскиран ориентир, причината и датата. Изключване по телефон или имейл изтрива и съвпадащите записи, изпратени по-рано от магазина ви. След това услугата пази HMAC с ключ от сървъра, ограничен до този магазин, и часа на изключването, така че по-старите поръчки да не могат да възстановят записите; действително по-късните поръчки отново са допустими, ако локалното изключване бъде премахнато. Изключване само по име остава локално и не може да изтрие записи от сървъра.',
// Exclusion reasons (stored as keys, rendered translated).
'Staff / test' => 'Служител / тест',
'Own company number' => 'Свой служебен номер',
'Other reason' => 'Друга причина',
'No one is excluded yet.' => 'Все още няма изключени клиенти.',
'Enter an email, phone or name.' => 'Въведете имейл, телефон или име.',
'That value could not be read.' => 'Стойността не може да бъде прочетена.',
'That customer is already excluded.' => 'Този клиент вече е изключен.',
'OrderGuard is not activated.' => 'OrderGuard не е активиран.',
'OrderGuard is not active. Nothing was changed.' => 'OrderGuard не е активен. Нищо не беше променено.',
'The exclusion list could not be read. Nothing was changed; please try again.' => 'Списъкът с изключени клиенти не можа да бъде прочетен. Нищо не беше променено; моля, опитайте отново.',
'The exclusion list could not be saved. Nothing was changed; please try again.' => 'Списъкът с изключени клиенти не можа да бъде запазен. Нищо не беше променено; моля, опитайте отново.',
'The exclusion record could not be verified.' => 'Записът за изключване не можа да бъде потвърден.',
'This customer cannot be removed yet because deletion of previously sent signals is still in progress.' => 'Този клиент все още не може да бъде премахнат, защото изтриването на изпратените по-рано сигнали още се обработва.',
'This customer cannot be removed yet. Network removal is waiting for server confirmation to be set up.' => 'Този клиент все още не може да бъде премахнат. Премахването от мрежата изчаква настройването на сървърното потвърждение.',
'Nothing to erase.' => 'Няма какво да се изтрие.',
'OrderGuard will finish this shortly.' => 'OrderGuard ще довърши това скоро.',
'Customer excluded. From now on they are never checked and nothing about them is sent. Clearing the signals already sent from your store is still in progress — OrderGuard will finish this shortly, nothing for you to do.' => 'Клиентът е изключен. Отсега нататък не се проверява и нищо за него не се изпраща. Изчистването на вече изпратените от вашия магазин сигнали още тече — OrderGuard ще го довърши скоро, не е нужно действие от вас.',
'Customer excluded locally. Removal from the OrderGuard network is waiting for server confirmation to be set up; nothing about this customer will be sent while it waits.' => 'Клиентът е изключен локално. Премахването от мрежата на OrderGuard изчаква настройването на сървърното потвърждение; докато чака, нищо за този клиент няма да бъде изпращано.',
'Customer excluded locally. OrderGuard could not finish saving the local name-only exclusion; please try again.' => 'Клиентът е изключен локално. OrderGuard не можа да довърши запазването на локалното изключване само по име; моля, опитайте отново.',
'Customer excluded. OrderGuard could not safely start network removal yet and will retry automatically.' => 'Клиентът е изключен. OrderGuard все още не можа безопасно да започне премахването от мрежата и ще опита отново автоматично.',
'Excluded by name — they will no longer be scored or sent. Past signals could not be erased by name: OrderGuard only ever stores phone and email. To erase what was already sent, also exclude their email or phone.' => 'Изключен по име — клиентът вече няма да се проверява и изпраща. Миналите сигнали не могат да се изтрият по име: OrderGuard съхранява само телефон и имейл. За да изтриете вече изпратеното, изключете и имейла или телефона му.',
'Clearing some earlier signals is still in progress. Everyone on this list is already excluded — this finishes by itself.' => 'Изчистването на някои по-стари сигнали още тече. Всички в този списък вече са изключени — това ще приключи само.',
'Removal from the OrderGuard network is waiting for server confirmation to be set up. Everyone on this list is already excluded locally, and nothing about them is sent while it waits.' => 'Премахването от мрежата на OrderGuard изчаква настройването на сървърното потвърждение. Всички в този списък вече са изключени локално и докато се чака, нищо за тях не се изпраща.',
'finishing up' => 'довършва се',
'waiting for setup' => 'изчаква настройка',
'They are already excluded. Clearing their earlier signals finishes by itself.' => 'Клиентът вече е изключен. Изчистването на по-старите му сигнали ще приключи само.',
'Already excluded locally; network removal is waiting for server setup.' => 'Вече е изключен локално; премахването от мрежата изчаква настройка на сървъра.',
'Customer excluded. %d past signal from your store was erased.' => 'Клиентът е изключен. Изтрит е %d минал сигнал от вашия магазин.',
'Customer excluded. %d past signals from your store were erased.' => 'Клиентът е изключен. Изтрити са %d минали сигнала от вашия магазин.',
'Customer removed from the exclusion list. Previously erased signals are not restored.' => 'Клиентът е премахнат от списъка с изключени. Вече изтритите сигнали не се възстановяват.',
'This customer is on your exclusion list. They are not scored, and nothing about them is sent to OrderGuard.' => 'Този клиент е във вашия списък с изключени. Не се проверява и нищо за него не се изпраща към OrderGuard.',
'Manage list' => 'Управление на списъка',
'Not allowed.' => 'Няма достъп.',
'Delivery source' => 'Източник на доставката',
'none' => 'няма',
'auto' => 'автоматично',
'Verified courier read' => 'Потвърден куриерски прочит',
'via status map' => 'чрез карта на статусите',
'Status mapping' => 'Съпоставяне на статуси',
'Kept' => 'Задържана',
'Returned' => 'Върната',
'Not collected' => 'Невзета',
// --- Status mapping (M5: universal mapping UI + notice) ---
'Delivery status mapping' => 'Съпоставяне на статусите на доставка',
'Turn any courier plugin\'s order statuses into outcomes' => 'Превърнете статусите на всяко куриерско разширение в резултати',
'Works with any courier or tracking plugin, no integration needed: tell OrderGuard once what each order status really means for the parcel. When an order settles in a mapped status, the outcome is recorded automatically.' => 'Работи с всяко куриерско или тракинг разширение, без нужда от интеграция: кажете на OrderGuard еднократно какво реално означава всеки статус на поръчка за пратката. Когато поръчка се установи в съпоставен статус, резултатът се записва автоматично.',
'Mapped outcomes count as merchant-mapped signals — clearly separated from verified courier reads, and never weighted above them.' => 'Съпоставените резултати се броят като сигнали, зададени от търговеца — ясно отделени от потвърдените куриерски прочити и никога с по-голяма тежест от тях.',
'Detected: %s. Suggested mappings are pre-filled below — review and save.' => 'Открито: %s. Предложените съпоставяния са попълнени по-долу — прегледайте и запазете.',
'No order statuses found (is WooCommerce active?).' => 'Не са намерени статуси на поръчки (активен ли е WooCommerce?).',
'Order status' => 'Статус на поръчката',
'Orders' => 'Поръчки',
'Delivery outcome' => 'Резултат от доставката',
'Not mapped' => 'Без съпоставяне',
'Ignore' => 'Игнорирай',
'Unmapped' => 'Несъпоставен',
'Save mapping' => 'Запази съпоставянето',
'Outcomes are only recorded while courier delivery sync (above) is switched on.' => 'Резултати се записват само докато синхронизацията на доставките с куриера (по-горе) е включена.',
'Status mapping is part of courier delivery sync, a Pro feature.' => 'Съпоставянето на статуси е част от синхронизацията на доставките с куриера — Про функция.',
'a new order status appeared with no delivery-outcome mapping yet: %s.' => 'появи се нов статус на поръчка без съпоставен резултат от доставка: %s.',
'Map it in Features' => 'Съпоставете го във „Функции“',
// --- Review queue ---
'Review' => 'За преглед',
'Orders whose customers crossed your review limits, gathered in one place so you can decide before dispatch.' => 'Поръчки, чиито клиенти надхвърлиха лимитите ви за преглед, събрани на едно място, за да решите преди изпращане.',
'Nothing to review — you’re all clear' => 'Няма нищо за преглед — всичко е чисто',
'New orders appear here when a customer crosses your not-collected or returned limits.' => 'Нови поръчки се появяват тук, когато клиент надхвърли лимитите ви за невзети или върнати поръчки.',
'Approve' => 'Одобри',
'%d not collected' => '%d невзети',
'%d returned' => '%d върнати',
'COD' => 'НП',
'Marked as reviewed.' => 'Отбелязана като прегледана.',
'Approve marks the order as reviewed — a private note to you, the customer never sees anything. Block COD hides cash on delivery for this customer’s future orders.' => 'Одобряването отбелязва поръчката като прегледана — лична бележка за вас, клиентът никога не вижда нищо. Блокирането на наложен платеж скрива наложения платеж за бъдещите поръчки на този клиент.',
'Flag for review is switched off.' => 'Докладването за преглед е изключено.',
'Turn it on from OrderGuard Settings → Features to see flagged orders here.' => 'Включете го от OrderGuard Настройки → Функции, за да виждате докладваните поръчки тук.',
'Check my order history' => 'Провери историята на поръчките ми',
'Check your own order history' => 'Проверете собствената си история на поръчките',
'Your past orders may already contain clients who refused or returned parcels repeatedly. This looks through your own orders — nothing is sent anywhere, and nothing about any client changes until you decide.' => 'В миналите ви поръчки може вече да има клиенти, които многократно не са вземали или са връщали пратки. Проверката преглежда само собствените ви поръчки — нищо не се изпраща и нищо за клиент не се променя, докато вие не решите.',
'Your history is larger than one pass: only your most recent %d matching orders were examined, so there may be more.' => 'Историята ви е по-голяма от една проверка: прегледани са само последните %d съвпадащи поръчки, затова може да има още.',
// --- Welcome / activation screen (country gate + waitlist) ---
// NOTE: 'Privacy Policy' and 'Data Processing Agreement' already exist
// above (Legal tab) and are reused by the welcome screen's legal row.
'Welcome to OrderGuard' => 'Добре дошли в OrderGuard',
'Customer insights for every order' => 'Информация за клиента при всяка поръчка',
'Great news — OrderGuard is available in %s' => 'Чудесна новина — OrderGuard е наличен в %s',
"You're ready to go." => 'Готови сте да започнете.',
'Contact details are coded before sending' => 'Данните за контакт се кодират преди изпращане',
'Phone and email are hashed on your store; OrderGuard also receives the disclosed order facts.' => 'Телефонът и имейлът се хешират във вашия магазин; OrderGuard получава и описаните данни за поръчката.',
'Automatic mode is on' => 'Автоматичният режим е включен',
'Order outcomes are shared as coded signals. Switch anytime.' => 'Резултатите от поръчките се споделят като кодирани сигнали. Може да превключите по всяко време.',
'One-time order review' => 'Еднократен преглед на поръчките',
'Your last 24 months are checked once, in the background.' => 'Последните ви 24 месеца се проверяват еднократно, във фонов режим.',
'Stored securely in the EU' => 'Съхранява се сигурно в ЕС',
'Hosted in the EU under GDPR. Request removal anytime.' => 'Хоствано в ЕС съгласно GDPR. Може да поискате изтриване по всяко време.',
'Terms' => 'Условия',
"I have read and agree to the Terms, Privacy Policy, and DPA, and I'm authorised to accept them for this store." => 'Прочетох и приемам Условията, Политиката за поверителност и Споразумението за обработка на данни, и съм упълномощен да ги приема от името на този магазин.',
'Activate OrderGuard' => 'Активирайте OrderGuard',
'You can review these terms and change your settings at any time.' => 'Може да прегледате тези условия и да промените настройките си по всяко време.',
"OrderGuard isn't available in %s just yet" => 'OrderGuard все още не е наличен в %s',
"It's coming soon. Leave your email so we know the demand is there and we'll speed things up — we'll notify you the moment we're live in %s." => 'Очаквайте скоро. Оставете имейла си, за да знаем, че има интерес и да ускорим нещата — ще ви уведомим в момента, в който стартираме в %s.',
'Notify me' => 'Уведоми ме',
"In the meantime, OrderGuard stays inactive and won't process or share any data from your store. We're expanding country by country — yours could be next." => 'Междувременно OrderGuard остава неактивен и няма да обработва или споделя данни от вашия магазин. Разширяваме се държава по държава — вашата може да е следващата.',
"Thanks — we'll let you know the moment we launch in %s." => 'Благодарим — ще ви уведомим веднага щом стартираме в %s.',
'your country' => 'вашата държава',
'Please enter a valid email address.' => 'Моля, въведете валиден имейл адрес.',
"We couldn't save your email just now. Please try again in a moment." => 'Не успяхме да запазим имейла ви в момента. Моля, опитайте отново след малко.',
// --- Licence-state notices (free plugin + Pro add-on; the add-on
// deliberately uses the 'orderguard' text domain so these entries
// serve both sides -- see the note in the Pro add-on source) ---
'your Pro plan is active, but the OrderGuard Pro add-on is not installed on this site. Install it to get the live scoring and custom COD limits you are already paying for.' => 'вашият Pro план е активен, но добавката OrderGuard Pro не е инсталирана на този сайт. Инсталирайте я, за да получите оценяването на живо и собствените лимити за наложен платеж, за които вече плащате.',
'your active OrderGuard Pro add-on does not match this version of OrderGuard. Replace it with the current Pro build to restore Pro features.' => 'вашата активна добавка OrderGuard Pro не съответства на тази версия на OrderGuard. Заменете я с текущата Pro версия, за да възстановите Pro функциите.',
'your active OrderGuard Pro add-on does not match this version of OrderGuard. Ask a site administrator to replace it with the current Pro build.' => 'вашата активна добавка OrderGuard Pro не съответства на тази версия на OrderGuard. Помолете администратор на сайта да я замени с текущата Pro версия.',
'Download OrderGuard Pro' => 'Изтеглете OrderGuard Pro',
'OrderGuard Pro' => 'OrderGuard Pro',
'Replace OrderGuard Pro' => 'Заменете OrderGuard Pro',
'Dismiss' => 'Скрий',
'your Pro subscription is not active, so Pro features are paused. Your settings are saved and everything resumes the moment the plan is active again.' => 'вашият Pro абонамент не е активен, затова Pro функциите са на пауза. Настройките ви са запазени и всичко се възобновява в момента, в който планът отново е активен.',
'View plan options' => 'Вижте опциите за плана',
'Dismiss for 30 days' => 'Скрий за 30 дни',
'Pro add-on for OrderGuard -- live order-card scoring and custom COD limits. Updates are delivered from orderguard.net to stores with an active Pro plan.' => 'Pro добавка за OrderGuard -- оценяване на живо в панела на поръчката и собствени лимити за наложен платеж. Обновяванията се доставят от orderguard.net за магазини с активен Pro план.',
// --- First-install download card (Plan tab) + its admin-post notices ---
'Install the Pro add-on' => 'Инсталирайте Pro добавката',
'Replace the Pro add-on' => 'Заменете Pro добавката',
'Your Pro plan is active. The Pro add-on is a small separate plugin that delivers the live scoring and custom COD limits you are paying for. Download it here, then upload it to this site.' => 'Вашият Pro план е активен. Pro добавката е малко отделно разширение, което доставя оценяването на живо и собствените лимити за наложен платеж, за които плащате. Изтеглете я оттук, след което я качете на този сайт.',
'The active Pro add-on does not match this version of OrderGuard. Download the current build, then replace the installed copy to restore Pro features.' => 'Активната Pro добавка не съответства на тази версия на OrderGuard. Изтеглете текущата версия, след което заменете инсталираното копие, за да възстановите Pro функциите.',
'The download link is personal to your store and stays valid for a few minutes.' => 'Връзката за изтегляне е лична за вашия магазин и е валидна няколко минути.',
'Download the add-on zip with the button above.' => 'Изтеглете zip файла на добавката с бутона по-горе.',
// The three WP admin menu labels below mirror the official bg_BG core
// translations, so the path reads the same as the merchant's menus.
'Go to' => 'Отидете на',
'Plugins' => 'Разширения',
'Add New' => 'Добавяне на ново',
'Upload Plugin' => 'Качване на разширение',
'and choose the file you just downloaded.' => 'и изберете файла, който току-що изтеглихте.',
'Click' => 'Натиснете',
'Install Now' => 'Инсталиране',
'then' => 'след това',
'Activate' => 'Активиране',
'Pro features switch on immediately.' => 'Pro функциите се включват веднага.',
'Click Install Now, choose Replace current with uploaded, and reactivate the add-on if WordPress leaves it inactive.' => 'Натиснете „Инсталиране“, изберете „Замяна на текущата с качената“ и активирайте отново добавката, ако WordPress я остави неактивна.',
'A site administrator must install or replace the Pro add-on. Contact your administrator and ask them to open this Plan tab.' => 'Администратор на сайта трябва да инсталира или замени Pro добавката. Свържете се с администратора и го помолете да отвори този раздел „План“.',
"couldn't reach the OrderGuard server to prepare your download. Please check your connection and try again in a moment." => 'не успяхме да се свържем със сървъра на OrderGuard, за да подготвим изтеглянето. Проверете връзката си и опитайте отново след малко.',
"your Pro plan is not active, so the Pro add-on can't be downloaded right now. Check your plan on this page." => 'вашият Pro план не е активен, затова Pro добавката не може да бъде изтеглена в момента. Проверете плана си на тази страница.',
"the Pro download isn't available right now. Please try again in a few minutes." => 'изтеглянето на Pro не е налично в момента. Моля, опитайте отново след няколко минути.',
'Hard block stops all orders from this customer. Continue?' => 'Пълното блокиране спира всички поръчки от този клиент. Да продължим ли?',
'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? Сигналите от други магазини няма да бъдат засегнати. Сайтът ще запази ключирана локална защита за самоличността и съвпадащите поръчки, включително фиксирана граница за номерата им, така че съществуващи, възстановени или редактирани поръчки да не могат да възстановят изтритите данни. По-късните поръчки се обработват нормално. Изтриването от сървъра не може да бъде отменено.',
// --- Release-candidate privacy and lifecycle strings ---
'This order page was processed. More cleanup remains.' => 'Тази страница с поръчки беше обработена. Остава още почистване.',
'%1$d common email form(s), matching %2$d flag row(s), were omitted because they cannot reliably identify one customer. Counts include any requester-owned flag.' => 'Пропуснати са общи имейл варианти: %1$d (съвпадащи редове с флаг: %2$d), защото не могат надеждно да посочат един клиент. Броят включва и флаговете, създадени от заявителя.',
'%1$d common phone form(s), matching %2$d flag row(s), were omitted because they cannot reliably identify one customer. Counts include any requester-owned flag.' => 'Пропуснати са общи телефонни варианти: %1$d (съвпадащи редове с флаг: %2$d), защото не могат надеждно да посочат един клиент. Броят включва и флаговете, създадени от заявителя.',
'%d seconds' => '%d секунди',
'A later OrderGuard removal request for this address is already queued. Let it finish before starting another.' => 'За този адрес вече има по-нова заявка за премахване от OrderGuard на опашката. Изчакайте я да приключи, преди да започнете нова.',
'A new network removal request was started. The local exclusion stayed in place.' => 'Започната е нова заявка за премахване от мрежата. Локалното изключване остава в сила.',
'A new removal request could not be created. Nothing was changed.' => 'Не можа да бъде създадена нова заявка за премахване. Нищо не е променено.',
'A very common email address or phone number was omitted from alias review because it cannot reliably identify one customer.' => 'Много често срещан имейл адрес или телефонен номер е пропуснат при прегледа на псевдоними, защото не може надеждно да посочи един клиент.',
'An exclusion removal needs OrderGuard support.' => 'Премахването на изключване изисква съдействие от поддръжката на OrderGuard.',
'Another privacy cleanup is still running. Nothing was changed; please try again.' => 'Друго почистване на лични данни все още е в ход. Нищо не е променено; опитайте отново.',
'Automatic attempts are exhausted. Investigate before authorising another retry cycle.' => 'Автоматичните опити са изчерпани. Проверете причината, преди да разрешите нов цикъл от опити.',
'Before release, stop or drain every PHP cleanup worker for this request, for example with a hosting-managed PHP-FPM restart and queue pause. A five-minute age or free database lock does not prove workers stopped. Do not release if you cannot confirm this.' => 'Преди освобождаване спрете или изчакайте да приключат всички фонови PHP процеси за почистване по тази заявка, например чрез рестарт на PHP-FPM от хостинга и пауза на опашката. Изминали пет минути или свободно заключване в базата данни не доказват, че процесите са спрени. Не освобождавайте, ако не можете да го потвърдите.',
'Block %1$s; request %2$d; step %3$s; released at %4$d by user %5$d; reason %6$s. Not erasure completion.' => 'Блокировка %1$s; заявка %2$d; стъпка %3$s; освободена в %4$d от потребител %5$d; причина: %6$s. Това не означава завършено изтриване.',
'Changed since capture; inspect the current record' => 'Променено след заснемането; прегледайте текущия запис',
'Cleanup or lookup-result invalidation needs review. An interrupted cleanup can keep Legal lookups unavailable until supervised recovery.' => 'Почистването или обезсилването на резултатите от справки трябва да се прегледа. Прекъснато почистване може да остави правните справки недостъпни до контролирано възстановяване.',
'Contact OrderGuard support' => 'Свържете се с поддръжката на OrderGuard',
'Could not check exclusions' => 'Изключенията не можаха да бъдат проверени',
'Customer excluded locally. OrderGuard could not safely match this removal request, so automatic retries stopped. An administrator can start a new removal request below; contact OrderGuard support if it happens again.' => 'Клиентът е изключен локално. OrderGuard не можа безопасно да съпостави тази заявка за премахване, затова автоматичните повторни опити са спрени. Администратор може да започне нова заявка за премахване по-долу; ако това се повтори, свържете се с поддръжката на OrderGuard.',
'Customer flag saved locally, but not shared because exclusions could not be checked.' => 'Флагът за клиента е запазен локално, но не е споделен, защото изключенията не можаха да бъдат проверени.',
'Editing is temporarily unavailable here. Your existing local blocks still apply.' => 'Редактирането тук временно не е достъпно. Съществуващите ви локални блокирания остават в сила.',
'Existing stores remain on the old erasure workers until this supervised switch is completed. Stop background PHP workers and scheduled jobs using the qualified procedure, then restart only the web PHP service needed for this administrator page.' => 'Съществуващите магазини продължават да използват старите фонови процеси за изтриване, докато това контролирано превключване не бъде завършено. Спрете фоновите PHP процеси и планираните задачи по одобрената процедура, след което рестартирайте само уеб PHP услугата, нужна за тази администраторска страница.',
'Flag ID %s' => 'Флаг № %s',
'Flag rows still need review before this customer’s block can be removed.' => 'Редовете с флагове трябва да бъдат прегледани, преди блокирането на този клиент да може да бъде премахнато.',
'I confirm background PHP workers, cron, queue runners and CLI jobs are stopped; only administrator web PHP is running.' => 'Потвърждавам, че фоновите PHP процеси, cron, обработчиците на опашки и CLI задачите са спрени; работи само уеб PHP за администратора.',
'I confirm no cleanup worker for this request can still be running or resume.' => 'Потвърждавам, че нито един процес за почистване по тази заявка не може все още да работи или да се възобнови.',
'I reviewed this interrupted attempt. Release only its lookup block; cleanup and erasure remain incomplete.' => 'Прегледах този прекъснат опит. Освободете само неговата блокировка на справките; почистването и изтриването остават незавършени.',
'Legal lookup results are unavailable while cleanup needs attention. Contact an administrator.' => 'Резултатите от правни справки не са достъпни, докато почистването изисква внимание. Свържете се с администратор.',
'Legal lookups remain blocked by another cleanup attempt. Review open blocks on the OrderGuard erasure attention Tools page. Erasure is incomplete.' => 'Правните справки остават блокирани от друг опит за почистване. Прегледайте отворените блокировки на страницата „Внимание: изтриване“ в Инструменти. Изтриването не е завършено.',
'Local cleanup still needs administrator review. Erasure is incomplete.' => 'Локалното почистване все още изисква преглед от администратор. Изтриването не е завършено.',
'Local cleanup was saved, but its display caches still need reconciliation.' => 'Локалното почистване е запазено, но кешовете за показване все още трябва да бъдат съгласувани.',
'Local flag cleanup could not be verified. Review the stored failure before retrying.' => 'Почистването на локалните флагове не можа да бъде потвърдено. Прегледайте запазената грешка, преди да опитате отново.',
'Local flag cleanup is unavailable.' => 'Почистването на локалните флагове не е достъпно.',
'Local flag cleanup needs administrator attention, but the warning could not be saved. Erasure is not complete.' => 'Почистването на локалните флагове изисква намеса на администратор, но предупреждението не можа да бъде запазено. Изтриването не е завършено.',
'Local flag cleanup needs administrator attention. The warning was saved; erasure is not complete.' => 'Почистването на локалните флагове изисква намеса на администратор. Предупреждението е запазено; изтриването не е завършено.',
'Local flag preflight is unavailable.' => 'Предварителната проверка на локалните флагове не е достъпна.',
'Local flag preflight needs administrator attention, but the warning could not be saved. Erasure is not complete.' => 'Предварителната проверка на локалните флагове изисква намеса на администратор, но предупреждението не можа да бъде запазено. Изтриването не е завършено.',
'Local flag preflight needs administrator attention. The warning was saved; erasure is not complete.' => 'Предварителната проверка на локалните флагове изисква намеса на администратор. Предупреждението е запазено; изтриването не е завършено.',
'Local order cleanup could not be verified. Some work may already have happened.' => 'Почистването на локалните поръчки не можа да бъде потвърдено. Част от работата може вече да е извършена.',
'Lookup-block release could not be verified.' => 'Освобождаването на блокировката не можа да бъде потвърдено.',
'Lookup-block release was not authorised.' => 'Освобождаването на блокировката не беше разрешено.',
'Matches the detached snapshot' => 'Съвпада с отделената моментна снимка',
'Next alias review page' => 'Следваща страница с псевдоними',
'Next flag review page' => 'Следваща страница с флагове',
'Next page' => 'Следваща страница',
'No current row at this ID' => 'Няма текущ запис с този номер',
'No possible alias flag IDs in this snapshot.' => 'В тази моментна снимка няма номера на флагове с възможен псевдоним.',
'No retained flag IDs in this snapshot.' => 'В тази моментна снимка няма номера на запазени флагове.',
'Only an administrator can start a new removal request.' => 'Само администратор може да започне нова заявка за премахване.',
'Open the stop-workers procedure' => 'Отвори процедурата за спиране на процесите',
'OrderGuard cannot prove that removal completed, so WooCommerce data was not anonymised. Contact support before retrying.' => 'OrderGuard не може да докаже, че премахването е завършено, затова данните в WooCommerce не са анонимизирани. Свържете се с поддръжката, преди да опитате отново.',
'OrderGuard cannot safely complete its local erasure step.' => 'OrderGuard не може безопасно да завърши своята стъпка за локално изтриване.',
'OrderGuard cannot safely fit another removal request in its local retry storage. Finish or repair the visible pending request before trying again.' => 'OrderGuard не може безопасно да побере още една заявка за премахване в локалното хранилище за повторни опити. Завършете или поправете видимата чакаща заявка, преди да опитате отново.',
'OrderGuard cannot safely fit the complete local protection record for this removal request. No server deletion was attempted.' => 'OrderGuard не може безопасно да побере пълния локален защитен запис за тази заявка за премахване. Не е направен опит за изтриване на сървъра.',
'OrderGuard cannot verify its local privacy protection. Erasure processing is blocked and operator intervention is required. WooCommerce data was not anonymised. Do not delete or reset privacy records; contact support with the diagnostic report.' => 'OrderGuard не може да провери локалната защита на личните данни. Обработката на изтривания е блокирана и е нужна намеса на оператор. Данните в WooCommerce не са анонимизирани. Не изтривайте и не нулирайте записите за лични данни; свържете се с поддръжката и приложете диагностичния отчет.',
'OrderGuard cannot verify that its personal-data eraser runs before WooCommerce. Do not run Tools → Erase Personal Data until the eraser order is corrected. Contact support; no recovery or erasure has been performed by this warning.' => 'OrderGuard не може да потвърди, че неговият механизъм за изтриване на лични данни се изпълнява преди този на WooCommerce. Не използвайте Инструменти → Изтриване на лични данни, докато редът не бъде коригиран. Свържете се с поддръжката; това предупреждение не е извършило възстановяване или изтриване.',
'OrderGuard could not acquire its privacy lock because the database did not confirm availability. WooCommerce data was not anonymised. Check database availability and retry.' => 'OrderGuard не можа да заключи обработката на лични данни, защото базата данни не потвърди, че е достъпна. Данните в WooCommerce не са анонимизирани. Проверете достъпността на базата данни и опитайте отново.',
'OrderGuard could not admit this cleanup step.' => 'OrderGuard не допусна тази стъпка за почистване.',
'OrderGuard could not admit this signal cleanup step.' => 'OrderGuard не допусна тази стъпка за почистване на сигнали.',
'OrderGuard could not advance this removal safely. Check the OrderGuard Legal tab and try again.' => 'OrderGuard не можа безопасно да продължи това премахване. Проверете раздела „Правни“ на OrderGuard и опитайте отново.',
'OrderGuard could not complete this erasure step or save its attention warning. Contact the administrator now.' => 'OrderGuard не можа да завърши тази стъпка от изтриването, нито да запази предупреждението си. Свържете се с администратора веднага.',
'OrderGuard could not complete this erasure step.' => 'OrderGuard не можа да завърши тази стъпка от изтриването.',
'OrderGuard could not confirm a background wake-up for this removal. Repair WordPress cron or Action Scheduler, then retry.' => 'OrderGuard не можа да потвърди фоново стартиране за това премахване. Поправете WordPress cron или Action Scheduler и опитайте отново.',
'OrderGuard could not protect the retained order evidence. Erasure remains incomplete.' => 'OrderGuard не можа да защити запазените доказателства за поръчки. Изтриването остава незавършено.',
'OrderGuard could not retain the complete observed order set. Erasure remains incomplete.' => 'OrderGuard не можа да запази пълния набор от открити поръчки. Изтриването остава незавършено.',
'OrderGuard could not safely enter this erasure step. No completion is confirmed.' => 'OrderGuard не можа безопасно да започне тази стъпка от изтриването. Завършване не е потвърдено.',
'OrderGuard could not validate this WordPress personal-data request.' => 'OrderGuard не можа да валидира тази заявка за лични данни в WordPress.',
'OrderGuard could not verify local order cleanup or save its warning. Contact the administrator now.' => 'OrderGuard не можа да провери почистването на локалните поръчки, нито да запази предупреждението си. Свържете се с администратора веднага.',
'OrderGuard could not verify the database packet limit, so it did not accept this removal request. Ask your host to check the database connection and try again.' => 'OrderGuard не можа да провери ограничението за размер на пакетите в базата данни, затова не прие тази заявка за премахване. Помолете хостинга да провери връзката с базата данни и опитайте отново.',
'OrderGuard could not verify the network-signal cleanup. Erasure is incomplete.' => 'OrderGuard не можа да потвърди почистването на мрежовите сигнали. Изтриването не е завършено.',
'OrderGuard did not start local order cleanup. Its prerequisites could not be verified.' => 'OrderGuard не започна почистването на локалните поръчки, защото предварителните условия не можаха да бъдат проверени.',
'OrderGuard erasure attention' => 'OrderGuard: внимание при изтриване',
'OrderGuard erasure takeover' => 'OrderGuard: поемане на изтриването',
'OrderGuard found an invalid completion receipt and stopped before WooCommerce anonymisation. Contact support before retrying.' => 'OrderGuard откри невалидна разписка за завършване и спря преди анонимизирането в WooCommerce. Свържете се с поддръжката, преди да опитате отново.',
'OrderGuard found local customer records or could not prove that its local records are clear. Connect and configure OrderGuard before retrying this erasure.' => 'OrderGuard откри локални записи за клиента или не можа да докаже, че локалните записи са изчистени. Свържете и настройте OrderGuard, преди да опитате отново това изтриване.',
'OrderGuard has never connected on this store, so it has no network data to erase.' => 'OrderGuard никога не е бил свързан в този магазин, затова няма мрежови данни за изтриване.',
'OrderGuard has not completed erasure and could not save its attention warning. Contact the administrator now; do not treat this as recorded for follow-up.' => 'OrderGuard не е завършил изтриването и не можа да запази предупреждението си. Свържете се с администратора веднага; не приемайте, че случаят е записан за проследяване.',
'OrderGuard has not completed erasure. Alias and order-reference coverage and server confirmation still require attention. Later erasers must not run yet.' => 'OrderGuard не е завършил изтриването. Покритието на псевдонимите и препратките към поръчки, както и потвърждението от сървъра, все още изискват внимание. Следващите механизми за изтриване не трябва да се изпълняват още.',
'OrderGuard is inactive. Activate it before retrying this removal. WooCommerce data was not anonymised.' => 'OrderGuard е неактивен. Активирайте го, преди да опитате отново това премахване. Данните в WooCommerce не са анонимизирани.',
'OrderGuard is waiting for its verified removal step.' => 'OrderGuard изчаква своята потвърдена стъпка за премахване.',
'OrderGuard network data' => 'Мрежови данни на OrderGuard',
'OrderGuard network signals: %1$d refused, %2$d need administrator attention.' => 'Мрежови сигнали на OrderGuard: отказани – %1$d, изискват намеса на администратор – %2$d.',
'OrderGuard owned erasure requires the supervised replacement workflow.' => 'Изтриването чрез новия механизъм на OrderGuard изисква контролирания процес на замяна.',
'OrderGuard personal data was removed and its local completion checks passed.' => 'Личните данни в OrderGuard са премахнати и локалните проверки за завършване са успешни.',
'OrderGuard privacy processing is busy with another operation. No removal was accepted by this attempt and WooCommerce data was not anonymised. Please retry shortly.' => 'Обработката на лични данни в OrderGuard е заета с друга операция. При този опит не е прието премахване и данните в WooCommerce не са анонимизирани. Опитайте отново след малко.',
'OrderGuard privacy protection could not be verified. Scoring is paused and erasure processing is blocked. Operator intervention is required; do not delete or reset privacy records. Saved scores have not been deleted by this pause.' => 'Защитата на личните данни в OrderGuard не можа да бъде проверена. Оценяването е на пауза, а обработката на изтривания е блокирана. Нужна е намеса на оператор; не изтривайте и не нулирайте записите за лични данни. Тази пауза не е изтрила запазените оценки.',
'OrderGuard removal completed and its signed local receipt was verified.' => 'Премахването на данните от OrderGuard е завършено и подписаната локална разписка е потвърдена.',
'OrderGuard removal is blocked and needs administrator attention on the OrderGuard Legal tab. WooCommerce data was not anonymised.' => 'Премахването на данните от OrderGuard е блокирано и изисква намеса на администратор в раздела „Правни“ на OrderGuard. Данните в WooCommerce не са анонимизирани.',
'OrderGuard removal is still processing. WooCommerce data has not been anonymised yet.' => 'Премахването на данните от OrderGuard все още се обработва. Данните в WooCommerce още не са анонимизирани.',
'OrderGuard removal is waiting for its recorded retry time. Try the WordPress erasure again after the pending job resumes.' => 'Премахването на данните от OrderGuard изчаква записаното време за повторен опит. Опитайте отново изтриването в WordPress, след като чакащата задача се възобнови.',
'OrderGuard setup is not yet confirmed, so network features are paused. Contact support before sending customer data.' => 'Настройката на OrderGuard все още не е потвърдена, затова мрежовите функции са на пауза. Свържете се с поддръжката, преди да изпращате клиентски данни.',
'OrderGuard storage needs attention. Sharing is not ready. No data has been removed.' => 'Хранилището на OrderGuard изисква внимание. Споделянето не е готово. Не са премахнати данни.',
'OrderGuard takeover' => 'OrderGuard: поемане',
'OrderGuard:' => 'OrderGuard:',
'OrderGuard’s new erasure engine is active but its reviewed receipt key, API credential or callback inventory is unavailable. Customer erasures remain incomplete until configuration is repaired.' => 'Новият механизъм за изтриване на OrderGuard е активен, но одобреният ключ за разписки, API идентификаторът или списъкът с обратни извиквания не са достъпни. Изтриванията на клиентски данни остават незавършени, докато конфигурацията не бъде поправена.',
'Orders retained by the store’s WooCommerce policy: %s.' => 'Поръчки, запазени според правилата на WooCommerce в магазина: %s.',
'Original order link remains' => 'Връзката към оригиналната поръчка остава',
'Personal data removed.' => 'Личните данни са премахнати.',
'Possible alias flag review' => 'Преглед на флагове с възможен псевдоним',
'Queue a fresh network-signal revision' => 'Добави на опашката нова версия на мрежовия сигнал',
'Release old lookup block (audited)' => 'Освободи стара блокировка на справките (с одит)',
'Release recorded and lookup results wiped. Retry unfinished cleanup; erasure is incomplete.' => 'Освобождаването е записано и резултатите от справките са изтрити. Опитайте отново незавършеното почистване; изтриването не е завършено.',
'Release recorded. Other cleanup blocks still prevent Legal lookups. Erasure is incomplete.' => 'Освобождаването е записано. Други блокировки от почистване все още спират правните справки. Изтриването не е завършено.',
'Release refused or unconfirmed. The block may be too recent, busy, changed, or unavailable. Review the Tools page before retrying.' => 'Освобождаването е отказано или непотвърдено. Блокировката може да е твърде скорошна, заета, променена или недостъпна. Прегледайте страницата в Инструменти, преди да опитате отново.',
'Request %1$d; step %2$s; block %3$s; start (database Unix time) %4$d; age %5$s' => 'Заявка %1$d; стъпка %2$s; блокировка %3$s; начало (Unix време на базата данни) %4$d; продължителност %5$s',
'Request %d' => 'Заявка %d',
'Retained flag reasons may describe the customer requesting erasure. Review the listed flag IDs; do not automatically change another person’s block.' => 'Причините в запазените флагове може да описват клиента, който иска изтриване. Прегледайте посочените номера на флагове; не променяйте автоматично блокирането на друг човек.',
'Retained flag text review' => 'Преглед на текста на запазени флагове',
'Review privacy status' => 'Преглед на състоянието на личните данни',
'Scoring is paused and erasure processing is blocked because local privacy protection could not be verified. Operator intervention is required. This pause does not delete saved scores.' => 'Оценяването е на пауза, а обработката на изтривания е блокирана, защото локалната защита на личните данни не можа да бъде проверена. Нужна е намеса на оператор. Тази пауза не изтрива запазените оценки.',
'Some retained flag links or possible aliases exceeded the bounded review snapshot. They were left unchanged and their omitted counts are shown in the flag review.' => 'Някои запазени връзки към флагове или възможни псевдоними надхвърлиха ограничената моментна снимка за преглед. Те са оставени непроменени, а броят на пропуснатите е показан в прегледа на флаговете.',
'Some retained flags matched an alternate email or phone spelling. Review the listed IDs; the match does not prove ownership.' => 'Някои запазени флагове съвпаднаха с друго изписване на имейла или телефона. Прегледайте посочените номера; съвпадението не доказва на кого принадлежат.',
'Start a new removal request' => 'Започни нова заявка за премахване',
'Switch to the new erasure engine' => 'Премини към новия механизъм за изтриване',
'The alias review could not be read. Do not treat this as an empty list.' => 'Прегледът на псевдонимите не можа да бъде прочетен. Не приемайте това за празен списък.',
'The attention warning could not be saved. Contact the administrator now.' => 'Предупреждението не можа да бъде запазено. Свържете се с администратора веднага.',
'The database clock and saved retry times disagree. Check the clock and in-flight work.' => 'Часовникът на базата данни и запазените времена за повторен опит не съвпадат. Проверете часовника и незавършената текуща работа.',
'The erasure request could not be verified.' => 'Заявката за изтриване не можа да бъде проверена.',
'The erasure request email is used by more than 50 flag records. Automatic deletion was refused; review it as a possible shared placeholder address, then delete this request because it cannot be processed automatically.' => 'Имейлът от заявката за изтриване се използва в повече от 50 записа с флагове. Автоматичното изтриване е отказано; прегледайте го като възможен общ адрес-заместител, след което изтрийте тази заявка, защото тя не може да бъде обработена автоматично.',
'The erasure request email matches more than 50 flag records. Automatic flag deletion was refused because the address may be a shared placeholder.' => 'Имейлът от заявката за изтриване съвпада с повече от 50 записа с флагове. Автоматичното изтриване на флагове е отказано, защото адресът може да е общ адрес-заместител.',
'The erasure request was not authorised.' => 'Заявката за изтриване не е разрешена.',
'The exclusion could not be found. Nothing was changed.' => 'Изключването не беше намерено. Нищо не е променено.',
'The flag review could not be read. Do not treat this as an empty list.' => 'Прегледът на флаговете не можа да бъде прочетен. Не приемайте това за празен списък.',
'The local exclusion is active, but an administrator must start a new network removal request.' => 'Локалното изключване е активно, но администратор трябва да започне нова заявка за премахване от мрежата.',
'The local secure-connection configuration needs attention. No attempt was consumed.' => 'Локалната настройка за защитена връзка изисква внимание. Не е изразходван опит.',
'The lookup result could not be verified. Please run the lookup again.' => 'Резултатът от справката не можа да бъде проверен. Моля, направете справката отново.',
'The network signal could not be prepared.' => 'Мрежовият сигнал не можа да бъде подготвен.',
'The new removal request could not be saved. Nothing was changed.' => 'Новата заявка за премахване не можа да бъде запазена. Нищо не е променено.',
'The next cleanup step could not be verified. Erasure is incomplete.' => 'Следващата стъпка от почистването не можа да бъде потвърдена. Изтриването не е завършено.',
'The sending workflow or its storage is unavailable.' => 'Процесът на изпращане или неговото хранилище не е достъпен.',
'The server confirmed erasure; local cleanup remains incomplete.' => 'Сървърът потвърди изтриването; локалното почистване все още не е завършено.',
'The server refused this installation. Check the trusted shop-to-installation link.' => 'Сървърът отказа тази инсталация. Проверете доверената връзка между магазина и инсталацията.',
'The server rejected the API key. Check the shop credential.' => 'Сървърът отхвърли API ключа. Проверете идентификационните данни на магазина.',
'The server requested a wait outside the retry policy. Review before resuming.' => 'Сървърът поиска изчакване извън правилата за повторни опити. Прегледайте, преди да възобновите.',
'The server requires operator review of this exact operation.' => 'Сървърът изисква преглед от оператор на точно тази операция.',
'The warning could not be saved. Contact the administrator now.' => 'Предупреждението не можа да бъде запазено. Свържете се с администратора веднага.',
'These snapshot flag IDs matched an alternate email or phone spelling found on the customer’s captured orders. The match does not prove who owns the flag. Review it without automatically deleting or changing another person’s block. This list does not mark erasure complete.' => 'Тези номера на флагове от моментната снимка съвпаднаха с друго изписване на имейл или телефон от заснетите поръчки на клиента. Съвпадението не доказва на кого принадлежи флагът. Прегледайте го, без автоматично да изтривате или променяте блокирането на друг човек. Този списък не отбелязва изтриването като завършено.',
'These snapshot flag IDs may contain reasons written about the customer requesting erasure after a legacy phone match. Review the records without assuming their current owner or changing another person’s block automatically. This list does not mark erasure complete.' => 'Тези номера на флагове от моментната снимка може да съдържат причини, написани за клиента, който иска изтриване, след съвпадение по стар телефонен запис. Прегледайте записите, без да приемате кой е текущият им собственик и без автоматично да променяте блокирането на друг човек. Този списък не отбелязва изтриването като завършено.',
'This WordPress personal-data request conflicts with the generation already recorded by OrderGuard. WooCommerce data was not anonymised.' => 'Тази заявка за лични данни в WordPress е в конфликт с вече записаното от OrderGuard поколение. Данните в WooCommerce не са анонимизирани.',
'This customer’s block record changed after capture. Cleanup left its current state untouched; review the newer change.' => 'Записът за блокиране на този клиент се е променил след заснемането. Почистването не промени текущото му състояние; прегледайте по-новата промяна.',
'This exclusion does not need a new removal request.' => 'Това изключване не изисква нова заявка за премахване.',
'This removal record is incomplete. Nothing was changed; please contact OrderGuard support.' => 'Този запис за премахване е непълен. Нищо не е променено; моля, свържете се с поддръжката на OrderGuard.',
'This switch is permanent. Continue only after the host-specific stop procedure has been completed.' => 'Това превключване е необратимо. Продължете само след като процедурата за спиране за вашия хостинг е изпълнена.',
'Unchanged since capture' => 'Непроменено след заснемането',
'WordPress Tools → Erase Personal Data waits for OrderGuard before WooCommerce anonymises billing identity. OrderGuard binds the numeric WordPress request ID and a store-keyed email HMAC to the same private retry generation, then permits WordPress to continue only after it proof-reads a signed completion receipt on that request. The receipt contains opaque request, generation and operation identifiers, a completion time and an authentication digest — no readable contact detail or raw identity hash. A blocked job, retry delay, invalid receipt or insufficient safe local capacity is reported as an error rather than completion.' => 'Инструменти → Изтриване на лични данни в WordPress изчаква OrderGuard, преди WooCommerce да анонимизира данните за фактуриране. OrderGuard свързва числовия номер на заявката в WordPress и HMAC на имейла с ключа на магазина с едно и също вътрешно поколение повторни опити и позволява на WordPress да продължи едва след като провери подписана разписка за завършване по тази заявка. Разписката съдържа непрозрачни идентификатори на заявката, поколението и операцията, време на завършване и контролна сума за удостоверяване – без четими данни за контакт и без суров хеш на самоличност. Блокирана задача, отлагане на повторен опит, невалидна разписка или недостатъчно безопасно локално място се отчитат като грешка, а не като завършване.',
'You are not allowed to restart network signals.' => 'Нямате право да рестартирате мрежовите сигнали.',
'You do not have permission to access this page.' => 'Нямате разрешение за достъп до тази страница.',
'Your earlier exclusion list needs attention before OrderGuard network features can resume. Contact support.' => 'По-ранният ви списък с изключения изисква внимание, преди мрежовите функции на OrderGuard да бъдат възобновени. Свържете се с поддръжката.',
'[deleted]' => '[изтрито]',
'needs support' => 'нужна е поддръжка',
'the removal request was not accepted because this site cannot prove enough safe local storage for its complete protection record. No server deletion was attempted. Ask your host to check the database packet limit and available option capacity before retrying.' => 'заявката за премахване не беше приета, защото сайтът не може да докаже, че има достатъчно безопасно локално място за пълния защитен запис. Не е направен опит за изтриване на сървъра. Помолете хостинга да провери ограничението за размер на пакетите в базата данни и наличното място за опции, преди да опитате отново.',
'unknown (database clock precedes start)' => 'неизвестно (часовникът на базата данни е преди началото)',
'Copied' => 'Копирано',
// Erasure screens, plain-language pass (5 October 2026). Bulgarian drafts; native review pending.
'OrderGuard’s server did not accept this store’s API key. Check the API key in the OrderGuard settings.' => 'Сървърът на OrderGuard не прие API ключа на този магазин. Проверете API ключа в настройките на OrderGuard.',
'OrderGuard’s server does not recognise this installation. Contact OrderGuard support.' => 'Сървърът на OrderGuard не разпознава тази инсталация. Свържете се с поддръжката на OrderGuard.',
'OrderGuard stopped trying automatically after several failed attempts. Find out why before you allow more attempts.' => 'OrderGuard спря автоматичните опити след няколко неуспешни. Изяснете причината, преди да разрешите още опити.',
'This site could not open a secure connection to OrderGuard. Nothing was sent and no attempt was used up. Ask your host to check the site’s secure connection settings.' => 'Сайтът не можа да отвори защитена връзка с OrderGuard. Нищо не е изпратено и не е изразходван опит. Помолете хостинга да провери настройките за защитена връзка на сайта.',
'This site’s database clock does not match the saved retry times. Check the server clock before you allow more attempts.' => 'Часовникът на базата данни на сайта не съвпада със запазените времена за повторен опит. Проверете часовника на сървъра, преди да разрешите още опити.',
'OrderGuard’s server confirmed the erasure, but cleaning up this store’s own copies is not finished yet.' => 'Сървърът на OrderGuard потвърди изтриването, но почистването на собствените копия на магазина още не е завършено.',
'OrderGuard could not confirm that this store’s own copies were cleaned up. Part of the cleanup may already have happened.' => 'OrderGuard не можа да потвърди, че собствените копия на магазина са почистени. Част от почистването може вече да е извършена.',
'OrderGuard could not send the erasure because its sending service or its storage on this site was unavailable.' => 'OrderGuard не можа да изпрати изтриването, защото услугата за изпращане или хранилището му на този сайт не бяха достъпни.',
'OrderGuard’s server stopped this erasure for review. It will not be sent again automatically.' => 'Сървърът на OrderGuard спря това изтриване за преглед. То няма да бъде изпратено отново автоматично.',
'OrderGuard’s server asked this site to wait longer than allowed before trying again. Check this before you allow more attempts.' => 'Сървърът на OrderGuard поиска от сайта да изчака по-дълго от позволеното преди нов опит. Проверете това, преди да разрешите още опити.',
'This customer’s block was changed after the erasure started. The cleanup left the newer block as it is; please review that change.' => 'Блокирането на този клиент е променено след началото на изтриването. Почистването остави новото блокиране непроменено; прегледайте тази промяна.',
'Some flags about this customer still need your review before their block can be removed.' => 'Някои сигнали за този клиент все още изискват преглед, преди блокирането му да може да бъде премахнато.',
'OrderGuard could not confirm that this customer’s flags were cleaned up. Check the saved error before trying again.' => 'OrderGuard не можа да потвърди, че сигналите за този клиент са почистени. Проверете запазената грешка, преди да опитате отново.',
'The flag cleanup was saved, but some on-screen summaries still need to catch up.' => 'Почистването на сигналите е запазено, но някои обобщения на екрана още не са обновени.',
'Some kept flags have notes that may describe the customer who asked for erasure. Review the listed flags; do not change another person’s block automatically.' => 'Някои запазени сигнали съдържат бележки, които може да описват клиента, поискал изтриване. Прегледайте изброените сигнали; не променяйте автоматично блокирането на друг човек.',
'Some kept flags matched a different spelling of this customer’s email address or phone number. Review the listed flags; a match does not prove it is the same person.' => 'Някои запазени сигнали съвпаднаха с друго изписване на имейла или телефона на този клиент. Прегледайте изброените сигнали; съвпадението не доказва, че е същият човек.',
'A very common email address or phone number was left out of the review because it cannot reliably point to one customer.' => 'Много често срещан имейл или телефонен номер беше изключен от прегледа, защото не може надеждно да посочи един клиент.',
'There were more flags or possible matches than the review can show at once. The extra ones were left unchanged; the flag review shows how many were left out.' => 'Сигналите или възможните съвпадения бяха повече, отколкото прегледът може да покаже наведнъж. Останалите не са променени; прегледът на сигналите показва колко са пропуснати.',
'The email address in this request appears on more than 50 flags, so it may be a shared placeholder address. OrderGuard did not delete those flags automatically.' => 'Имейлът в тази заявка се среща в повече от 50 сигнала, така че може да е общ заместващ адрес. OrderGuard не изтри тези сигнали автоматично.',
'A cleanup step needs review. Until it is resolved, Legal lookups may stay unavailable.' => 'Стъпка от почистването изисква преглед. Докато не бъде решена, правните справки може да останат недостъпни.',
'A server erasure proof needs review. This note stays visible even after the proof is checked again.' => 'Доказателство от сървъра за изтриване изисква преглед. Тази бележка остава видима и след повторна проверка на доказателството.',
'OrderGuard stopped checking for a server proof after too many attempts. Find out why before doing anything else.' => 'OrderGuard спря да проверява за доказателство от сървъра след твърде много опити. Изяснете причината, преди да предприемете друго.',
'When the proof was checked, this site’s address did not match the address registered with OrderGuard. Confirm the registered address. This note stays visible after it is fixed.' => 'При проверката на доказателството адресът на сайта не съвпадна с адреса, регистриран в OrderGuard. Потвърдете регистрирания адрес. Тази бележка остава видима и след поправката.',
'OrderGuard refused a restart request for this erasure. Nothing was sent again or deleted.' => 'OrderGuard отказа заявка за рестартиране на това изтриване. Нищо не е изпратено отново или изтрито.',
'The OrderGuard owner turned down a restart for this erasure. The erasure stays stopped.' => 'Собственикът на OrderGuard отказа рестартиране на това изтриване. Изтриването остава спряно.',
'A restart approval could not be verified, so it was not used. The request remains stopped. If the server signing key changed, ask the owner to renew the approval; the next hourly check collects it.' => 'Одобрение за рестартиране не можа да бъде проверено, затова не е използвано. Заявката остава спряна. Ако ключът за подписване на сървъра е сменен, помолете собственика да поднови одобрението; следващата ежечасна проверка ще го получи.',
'OrderGuard saved a warning that this version cannot describe. Contact OrderGuard support and quote this reference: %s' => 'OrderGuard запази предупреждение, което тази версия не може да опише. Свържете се с поддръжката на OrderGuard и посочете този код: %s',
'Restart requested by a store administrator' => 'Рестартиране, поискано от администратор на магазина',
'Restart request received by OrderGuard; waiting for the owner' => 'Заявката за рестартиране е получена от OrderGuard; чака решение на собственика',
'The OrderGuard owner rejected the restart' => 'Собственикът на OrderGuard отказа рестартирането',
'OrderGuard refused the restart request' => 'OrderGuard отказа заявката за рестартиране',
'A restart approval could not be verified and was not used' => 'Одобрение за рестартиране не можа да бъде проверено и не е използвано',
'The owner’s signed restart approval was verified' => 'Подписаното от собственика одобрение за рестартиране е проверено',
'Operation replaced; its records were archived, not completed' => 'Операцията е заменена; записите ѝ са архивирани, а не завършени',
'New operation issued with the original date limit and orders' => 'Издадена е нова операция с първоначалния краен срок и поръчки',
'order cleanup' => 'почистване на поръчките',
'deleting OrderGuard records' => 'изтриване на записите на OrderGuard',
'updating saved flags' => 'обновяване на запазените сигнали',
'unlinking flags from orders' => 'премахване на връзките между сигнали и поръчки',
'cleaning up old settings' => 'почистване на стари настройки',
'cleaning up old customer records' => 'почистване на стари клиентски записи',
'network signal cleanup' => 'почистване на мрежовите сигнали',
'a cleanup step' => 'стъпка от почистването',
'interrupted cleanup reviewed' => 'прегледано прекъснато почистване',
'reason not recorded' => 'причината не е записана',
'time unknown' => 'неизвестно време',
'unknown (the database clock is behind the start time)' => 'неизвестно (часовникът на базата данни изостава от началния час)',
'Old erasure system (the new erasure engine has not been set up on this store)' => 'Стара система за изтриване (новата система не е настроена в този магазин)',
'Setup finished on this site. Waiting for: OrderGuard to confirm this store’s privacy setup. This record does not mean sharing with the OrderGuard network is ready, and nothing is shared because of it. Next step: nothing to do now; if OrderGuard support asks, give them the support reference below.' => 'Настройката на този сайт е завършена. Очаква се: OrderGuard да потвърди настройката за поверителност на този магазин. Този запис не означава, че споделянето с мрежата на OrderGuard е готово, и заради него нищо не се споделя. Следваща стъпка: засега нищо не е нужно; ако поддръжката на OrderGuard поиска, дайте ѝ референтния номер по-долу.',
'OrderGuard’s storage was already set up when OrderGuard was activated again. This is a record of that earlier setup, not a check that everything works now.' => 'Хранилището на OrderGuard вече беше настроено, когато OrderGuard беше активиран отново. Това е запис от онази по-ранна настройка, а не проверка, че всичко работи в момента.',
'This store was updated from an earlier OrderGuard version and its new storage is prepared. It keeps using the earlier erasure system until a supervised switch-over, which never starts by itself. This is a record of the update, not a confirmation that sharing is ready.' => 'Този магазин е обновен от по-ранна версия на OrderGuard и новото му хранилище е подготвено. Той продължава да използва по-ранната система за изтриване до контролирано превключване, което никога не започва само. Това е запис от обновяването, а не потвърждение, че споделянето е готово.',
'OrderGuard could not finish preparing data from an earlier version on this site. Nothing was removed. Contact OrderGuard support.' => 'OrderGuard не можа да завърши подготовката на данни от по-ранна версия на този сайт. Нищо не е премахнато. Свържете се с поддръжката на OrderGuard.',
'OrderGuard does not support WordPress multisite networks. Use it on a single WordPress site.' => 'OrderGuard не поддържа мрежи от сайтове (multisite) в WordPress. Използвайте го на самостоятелен сайт с WordPress.',
'OrderGuard found its storage in an unexpected state and stopped setup. Nothing was removed. Contact OrderGuard support before changing anything.' => 'OrderGuard откри хранилището си в неочаквано състояние и спря настройката. Нищо не е премахнато. Свържете се с поддръжката на OrderGuard, преди да променяте каквото и да е.',
'OrderGuard could not use the database during setup. Nothing was removed. Ask your host to check the database, then reload this page to try again.' => 'OrderGuard не можа да използва базата данни по време на настройката. Нищо не е премахнато. Помолете хостинг доставчика си да провери базата данни, след това презаредете тази страница, за да опитате отново.',
'OrderGuard’s storage does not match this site’s database table prefix. Nothing was removed. Contact OrderGuard support before changing anything.' => 'Хранилището на OrderGuard не съответства на префикса на таблиците в базата данни на този сайт. Нищо не е премахнато. Свържете се с поддръжката на OrderGuard, преди да променяте каквото и да е.',
'OrderGuard could not confirm whether setup finished. Nothing was removed. Reload this page to try again; if this message stays, contact OrderGuard support.' => 'OrderGuard не можа да потвърди дали настройката е завършила. Нищо не е премахнато. Презаредете тази страница, за да опитате отново; ако това съобщение остане, свържете се с поддръжката на OrderGuard.',
'OrderGuard setup has not finished on this site, and nothing is shared until it does. Reload this page to try again; if this message stays, contact OrderGuard support.' => 'Настройката на OrderGuard на този сайт не е завършена и нищо не се споделя, докато не завърши. Презаредете тази страница, за да опитате отново; ако това съобщение остане, свържете се с поддръжката на OrderGuard.',
'OrderGuard setup:' => 'Настройка на OrderGuard:',
'Support reference: %s' => 'Референтен номер за поддръжката: %s',
'Setup status' => 'Състояние на настройката',
'Erasure system now:' => 'Система за изтриване в момента:',
'Last recorded setup outcome:' => 'Последен записан резултат от настройката:',
'Ready to switch: the store still uses the old erasure system' => 'Готов за превключване: магазинът все още използва старата система за изтриване',
'The new erasure engine is active' => 'Новата система за изтриване е активна',
'Could not be checked' => 'Не можа да бъде проверено',
'You are not allowed to do this, or the page has expired. Reload it and try again.' => 'Нямате право да направите това или страницата е изтекла. Презаредете я и опитайте отново.',
'More attempts could not be allowed. Nothing was sent.' => 'Не можаха да бъдат разрешени още опити. Нищо не е изпратено.',
'Restart requests are not available on this site right now: the new erasure service is not active here, or its keys or OrderGuard connection are not ready. Nothing was sent or changed.' => 'Заявките за рестартиране в момента не са налични на този сайт: новата услуга за изтриване не е активна тук или нейните ключове или връзката с OrderGuard не са готови. Нищо не е изпратено или променено.',
'This request changed since the page was loaded. Reload the page.' => 'Заявката е променена след зареждането на страницата. Презаредете страницата.',
'The restart request could not be processed. Nothing was erased or marked complete.' => 'Заявката за рестартиране не можа да бъде обработена. Нищо не е изтрито или отбелязано като завършено.',
'The operation history could not be checked. Do not assume it is empty.' => 'Историята на операциите не можа да бъде проверена. Не приемайте, че е празна.',
'Operations for this request' => 'Операции по тази заявка',
'Each time this erasure is sent to OrderGuard it uses an operation with its own number. A restart replaces the operation; the old one is kept as a record.' => 'Всеки път, когато това изтриване се изпраща към OrderGuard, то използва операция със собствен номер. Рестартирането заменя операцията; старата се пази като запис.',
'current operation' => 'текуща операция',
'replaced by an owner-approved restart (kept as evidence, never completed)' => 'заменена чрез одобрено от собственика рестартиране (пази се като доказателство, не е завършена)',
'earlier operation' => 'предишна операция',
'A restart request is waiting for the OrderGuard owner’s decision. Nothing has been resent.' => 'Заявка за рестартиране чака решение на собственика на OrderGuard. Нищо не е изпратено отново.',
'A restart request was saved but not yet confirmed by OrderGuard. Check again to send it.' => 'Заявка за рестартиране е запазена, но още не е потвърдена от OrderGuard. Проверете отново, за да я изпратите.',
'Check the owner’s decision' => 'Проверете решението на собственика',
'OrderGuard stopped this erasure for review. You can ask the OrderGuard owner to approve a restart. A restart sends a new operation with the original date limit and the same orders, so customer data added later is never included. Nothing is sent again until the owner approves.' => 'OrderGuard спря това изтриване за преглед. Можете да помолите собственика на OrderGuard да одобри рестартиране. Рестартирането изпраща нова операция с първоначалния краен срок и същите поръчки, така че по-късно добавени клиентски данни никога не се включват. Нищо не се изпраща отново, докато собственикът не одобри.',
'Ask the OrderGuard owner to approve a restart' => 'Помолете собственика на OrderGuard да одобри рестартиране',
'Paused Legal lookups' => 'Спрени правни справки',
'OrderGuard could not check for paused Legal lookups. Do not assume there are none.' => 'OrderGuard не можа да провери за спрени правни справки. Не приемайте, че няма такива.',
'No Legal lookups are paused. This does not mean every erasure is complete.' => 'Няма спрени правни справки. Това не означава, че всяко изтриване е завършено.',
'While a cleanup runs, Legal lookups are paused for the whole site. A cleanup may still be running or may have been interrupted; its age alone does not show which. After five minutes an administrator can release the pause, and the release is recorded. Nothing is released automatically.' => 'Докато тече почистване, правните справки са спрени за целия сайт. Почистването може още да тече или да е прекъснато; само по продължителността не може да се разбере кое. След пет минути администратор може да освободи спирането и това се записва. Нищо не се освобождава автоматично.',
'Request %1$d: %2$s, started %3$s (%4$s ago). Support reference: %5$s' => 'Заявка %1$d: %2$s, започнало %3$s (преди %4$s). Код за поддръжката: %5$s',
'Releasing a pause from this page is not available yet. Contact OrderGuard support.' => 'Освобождаването на спиране от тази страница още не е достъпно. Свържете се с поддръжката на OrderGuard.',
'This page lists saved warnings about customer erasures. It is not a list of finished or unfinished erasures. Allowing more attempts does not send anything straight away; OrderGuard tries again in the background.' => 'Тази страница показва запазени предупреждения за изтриване на клиентски данни. Тя не е списък на завършени или незавършени изтривания. Разрешаването на още опити не изпраща нищо веднага; OrderGuard опитва отново във фонов режим.',
'Allowing more attempts gives OrderGuard six more tries for the same erasure. The first try can start straight away, even if the server asked to wait; after that it waits at least five minutes between tries.' => 'Разрешаването на още опити дава на OrderGuard шест нови опита за същото изтриване. Първият може да започне веднага, дори ако сървърът е поискал изчакване; след това между опитите има поне пет минути.',
'The saved warnings could not be read. Do not assume there are none.' => 'Запазените предупреждения не можаха да бъдат прочетени. Не приемайте, че няма такива.',
'Check for a server erasure proof (does not restart the erasure)' => 'Проверка за доказателство от сървъра за изтриване (не рестартира изтриването)',
'Review the kept flags' => 'Прегледайте запазените сигнали',
'I fixed the settings' => 'Поправих настройките',
'The service is working again' => 'Услугата отново работи',
'I reviewed it' => 'Прегледах го',
'Allow six more attempts' => 'Разреши още шест опита',
'More attempts cannot be allowed for this erasure right now.' => 'В момента не могат да бъдат разрешени още опити за това изтриване.',
'OrderGuard has customer-erasure warnings or paused Legal lookups to review, or could not check for them. Open the OrderGuard erasure attention page.' => 'OrderGuard има предупреждения за изтриване на клиентски данни или спрени правни справки за преглед или не можа да провери за такива. Отворете страницата за внимание при изтриване на OrderGuard.',
'More attempts allowed. This button sent nothing; OrderGuard will try again in the background.' => 'Още опити са разрешени. Този бутон не изпрати нищо; OrderGuard ще опита отново във фонов режим.',
'More attempts could not be allowed. Reload the page and check the erasure first.' => 'Не можаха да бъдат разрешени още опити. Презаредете страницата и първо проверете изтриването.',
'The restart request is waiting for the OrderGuard owner’s decision. Nothing has been resent.' => 'Заявката за рестартиране чака решение на собственика на OrderGuard. Нищо не е изпратено отново.',
'The owner’s approval was verified. A new operation with the original date limit and orders is ready; run the erasure request again to send it. Nothing is complete yet.' => 'Одобрението на собственика е проверено. Нова операция с първоначалния краен срок и поръчки е готова; изпълнете отново заявката за изтриване, за да я изпратите. Нищо още не е завършено.',
'The OrderGuard owner rejected the restart. The request remains stopped.' => 'Собственикът на OrderGuard отказа рестартирането. Заявката остава спряна.',
'OrderGuard refused the restart request. The request remains stopped.' => 'OrderGuard отказа заявката за рестартиране. Заявката остава спряна.',
'The approval could not be verified and was not used. The request remains stopped. If the server signing key changed, ask the owner to renew the approval; the next hourly check collects it.' => 'Одобрението не можа да бъде проверено и не е използвано. Заявката остава спряна. Ако ключът за подписване на сървъра е сменен, помолете собственика да поднови одобрението; следващата ежечасна проверка ще го получи.',
'OrderGuard could not be reached. The restart request is saved; check again later.' => 'Няма връзка с OrderGuard. Заявката за рестартиране е запазена; проверете отново по-късно.',
'Released Legal lookup pauses' => 'Освободени спирания на правни справки',
'The list of released pauses could not be checked. Do not assume it is empty.' => 'Списъкът с освободени спирания не можа да бъде проверен. Не приемайте, че е празен.',
'Request %1$d: the pause for %2$s was released on %3$s by user %4$d (%5$s). This does not complete the erasure. Support reference: %6$s' => 'Заявка %1$d: спирането за %2$s е освободено на %3$s от потребител %4$d (%5$s). Това не завършва изтриването. Код за поддръжката: %6$s',
'The administrator confirmed that background tasks were stopped (their statement; OrderGuard could not check this).' => 'Администраторът потвърди, че фоновите задачи са спрени (по негово твърдение; OrderGuard не можа да провери това).',
'This older entry does not record whether background tasks were stopped.' => 'Този по-стар запис не показва дали фоновите задачи са били спрени.',
'Next page of released pauses' => 'Следваща страница с освободени спирания',
'This site’s address does not match the store registered with OrderGuard. Confirm the registered address with OrderGuard, then try again. No proof was saved.' => 'Адресът на сайта не съвпада с магазина, регистриран в OrderGuard. Потвърдете регистрирания адрес с OrderGuard и опитайте отново. Не е запазено доказателство.',
'The server proof was checked and saved. This does not complete the erasure or allow a restart.' => 'Доказателството от сървъра е проверено и запазено. Това не завършва изтриването и не разрешава рестартиране.',
'Proofs cannot be saved on this site. Check the WordPress security keys in wp-config.php.' => 'Доказателствата не могат да бъдат запазени на този сайт. Проверете ключовете за сигурност на WordPress в wp-config.php.',
'Proof checks are not available on this site right now: the new erasure service is not active here, or its keys or OrderGuard connection are not ready. Nothing was saved.' => 'Проверките за доказателства в момента не са налични на този сайт: новата услуга за изтриване не е активна тук или нейните ключове или връзката с OrderGuard не са готови. Нищо не е записано.',
'The proof check needs attention. A warning was saved on the OrderGuard erasure attention page.' => 'Проверката за доказателство изисква внимание. Запазено е предупреждение на страницата за внимание при изтриване на OrderGuard.',
'OrderGuard has tried too many times to get this proof. A warning was saved on the OrderGuard erasure attention page.' => 'OrderGuard опита твърде много пъти да получи това доказателство. Запазено е предупреждение на страницата за внимание при изтриване на OrderGuard.',
'No proof was confirmed. Check the OrderGuard erasure attention page or try again later. The erasure has not been marked complete.' => 'Не е потвърдено доказателство. Проверете страницата за внимание при изтриване на OrderGuard или опитайте отново по-късно. Изтриването не е отбелязано като завършено.',
'OrderGuard erasure proofs' => 'OrderGuard: доказателства за изтриване',
'This page lists erasure warnings and lets you ask OrderGuard’s server for proof that an erasure happened. A proof is only a record: it never restarts an erasure, cleans up data or marks a request complete. Warnings stay visible after a proof is checked again.' => 'Тази страница показва предупреждения за изтриване и ви позволява да поискате от сървъра на OrderGuard доказателство, че изтриването е извършено. Доказателството е само запис: то никога не рестартира изтриване, не почиства данни и не отбелязва заявка като завършена. Предупрежденията остават видими след повторна проверка.',
'Checking for a proof does not restart a stopped erasure. Erasures that need attention or have run out of attempts stay stopped until they are handled on the OrderGuard erasure attention page.' => 'Проверката за доказателство не рестартира спряно изтриване. Изтриванията, които изискват внимание или са изчерпали опитите си, остават спрени, докато не бъдат обработени на страницата за внимание при изтриване на OrderGuard.',
'If a warning says this site’s address did not match the address registered with OrderGuard, confirm the correct address with OrderGuard; if the site really moved, the registration may need updating. With an unchanged address you can check again 24 hours after the last mismatch; earlier checks send nothing. The warning stays visible after it is fixed.' => 'Ако предупреждение казва, че адресът на сайта не съвпада с регистрирания в OrderGuard, потвърдете правилния адрес с OrderGuard; ако сайтът наистина е преместен, регистрацията може да се нуждае от обновяване. При непроменен адрес можете да проверите отново 24 часа след последното несъвпадение; по-ранните проверки не изпращат нищо. Предупреждението остава видимо и след поправката.',
'Check for a server proof' => 'Проверка за доказателство от сървъра',
'No warnings to show here. This does not mean every erasure is complete.' => 'Няма предупреждения за показване. Това не означава, че всяко изтриване е завършено.',
'OrderGuard cannot save erasure proofs because the WordPress security keys in wp-config.php are missing or invalid. Ask your hosting administrator to configure AUTH_KEY and SECURE_AUTH_KEY. Do not send these keys to support.' => 'OrderGuard не може да запазва доказателства за изтриване, защото ключовете за сигурност на WordPress в wp-config.php липсват или са невалидни. Помолете администратора на хостинга да настрои AUTH_KEY и SECURE_AUTH_KEY. Не изпращайте тези ключове на поддръжката.',
'OrderGuard’s new erasure service is off on this site because wp-config.php defines %s. Nothing is sent or marked complete because of it.' => 'Новата услуга на OrderGuard за изтриване е изключена на този сайт, защото wp-config.php задава %s. Заради това нищо не се изпраща и нищо не се отбелязва като завършено.',
'%s is allowed only in development mode. Remove it from wp-config.php.' => 'Настройката %s е разрешена само в режим на разработка. Премахнете я от wp-config.php.',
'ORDERGUARD_NATIVE_RECEIPT_REVOKED is not a valid revocation list. Correct it so that it still lists every key ID you meant to revoke, each set to true. Do not simply delete it: a key you revoked could then be trusted again.' => 'ORDERGUARD_NATIVE_RECEIPT_REVOKED не е валиден списък с отменени ключове. Поправете го така, че да съдържа всеки идентификатор на ключ, който сте искали да отмените, със стойност true. Не го изтривайте просто така: тогава отменен от вас ключ може отново да бъде приет за доверен.',
'Outside development mode ORDERGUARD_NATIVE_ENVIRONMENT must be production. Change it to production or remove it.' => 'Извън режим на разработка ORDERGUARD_NATIVE_ENVIRONMENT трябва да е production. Променете го на production или го премахнете.',
'Removals from the OrderGuard network are waiting on this site because wp-config.php defines %s. Nothing is sent because of it.' => 'Премахванията от мрежата на OrderGuard чакат на този сайт, защото wp-config.php задава %s. Заради това нищо не се изпраща.',
'Outside development mode ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT must be production. Change it to production or remove it.' => 'Извън режим на разработка ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT трябва да е production. Променете го на production или го премахнете.',
'OrderGuard’s built-in erasure verification keys could not be read. Reinstall the plugin. Until then, the new erasure service stays off; nothing is sent or marked complete.' => 'Вградените ключове на OrderGuard за проверка на изтриванията не можаха да бъдат прочетени. Инсталирайте отново разширението. Дотогава новата услуга за изтриване остава изключена; нищо не се изпраща и нищо не се отбелязва като завършено.',
'OrderGuard cannot verify erasure records on this site because of a wp-config.php setting (see the notice at the top of the page). Nothing was sent or changed.' => 'OrderGuard не може да проверява записите за изтриване на този сайт заради настройка в wp-config.php (вижте известието в горната част на страницата). Нищо не е изпратено или променено.',
'Customer excluded locally. Removal from the OrderGuard network is waiting because a wp-config.php setting is refused (see the notice at the top of the page); nothing about this customer will be sent while it waits.' => 'Клиентът е изключен локално. Премахването от мрежата на OrderGuard чака, защото настройка в wp-config.php е отхвърлена (вижте известието в горната част на страницата); докато чака, нищо за този клиент няма да бъде изпращано.',
'OrderGuard’s new erasure service is not ready on this site because its settings or its connection to OrderGuard are missing or refused. Customer erasures are not complete; contact OrderGuard support before switching erasure systems.' => 'Новата услуга за изтриване на OrderGuard не е готова на този сайт, защото нейните настройки или връзката ѝ с OrderGuard липсват или са отказани. Изтриванията на клиентски данни не са завършени; свържете се с поддръжката на OrderGuard, преди да смените системата за изтриване.',
'Current state:' => 'Текущо състояние:',
'This cleanup cannot run on this store yet. Nothing was changed.' => 'Това почистване още не може да се изпълни в този магазин. Нищо не е променено.',
'The order records are kept safe for now. The erasure is not complete.' => 'Записите за поръчките засега са запазени. Изтриването не е завършено.',
'OrderGuard’s server has not confirmed the erasure yet. No cleanup was started on this store.' => 'Сървърът на OrderGuard още не е потвърдил изтриването. В този магазин не е започнало почистване.',
'The order cleanup needs attention. Part of it may already have happened. The erasure is not complete.' => 'Почистването на поръчките изисква внимание. Част от него може вече да е извършена. Изтриването не е завършено.',
'The orders and their OrderGuard details were cleaned up. Flag cleanup is next.' => 'Поръчките и техните данни в OrderGuard са почистени. Следва почистване на сигналите.',
'A batch of flags was cleaned up. More cleanup remains.' => 'Една група сигнали е почистена. Остава още почистване.',
'A batch of links between flags and orders was cleaned up. More cleanup remains.' => 'Една група връзки между сигнали и поръчки е почистена. Остава още почистване.',
'The flags were cleaned up. Old saved copies are next.' => 'Сигналите са почистени. Следват старите запазени копия.',
'The flag cleanup needs attention. Part of it may already have happened. The erasure is not complete.' => 'Почистването на сигналите изисква внимание. Част от него може вече да е извършена. Изтриването не е завършено.',
'OrderGuard is waiting for a network signal to be withdrawn. The erasure is not complete yet.' => 'OrderGuard чака мрежов сигнал да бъде оттеглен. Изтриването още не е завършено.',
'The cleanup of old saved copies needs attention. Part of it may already have happened. The erasure is not complete.' => 'Почистването на старите запазени копия изисква внимание. Част от него може вече да е извършена. Изтриването не е завършено.',
'Old saved copies were cleaned up. Network signal cleanup is next.' => 'Старите запазени копия са почистени. Следва почистване на мрежовите сигнали.',
'A batch of network signal records was cleaned up. More cleanup remains.' => 'Една група записи за мрежови сигнали е почистена. Остава още почистване.',
'A batch of old saved copies was cleaned up. More cleanup remains.' => 'Една група стари запазени копия е почистена. Остава още почистване.',
'The flag cleanup cannot run yet. Nothing was changed.' => 'Почистването на сигналите още не може да се изпълни. Нищо не е променено.',
'The flags for this erasure could not be prepared. Nothing was changed.' => 'Сигналите за това изтриване не можаха да бъдат подготвени. Нищо не е променено.',
'This cleanup step could not continue. Nothing was changed; run the erasure again.' => 'Тази стъпка от почистването не можа да продължи. Нищо не е променено; изпълнете изтриването отново.',
'OrderGuard could not confirm the cleanup of this store’s orders. Some orders may already be anonymised; the erasure is not complete.' => 'OrderGuard не можа да потвърди почистването на поръчките в този магазин. Някои поръчки може вече да са анонимизирани; изтриването не е завършено.',
'OrderGuard cannot start this erasure safely right now. Nothing was changed.' => 'OrderGuard не може да започне безопасно това изтриване в момента. Нищо не е променено.',
'OrderGuard could not verify that its erasure service is ready. Nothing has been marked complete. Retry after the service is available.' => 'OrderGuard не можа да потвърди, че услугата му за изтриване е готова. Нищо не е отбелязано като завършено. Опитайте отново, когато услугата е достъпна.',
'OrderGuard kept this batch of order records safe for now. The erasure is not complete.' => 'OrderGuard засега запази тази група записи за поръчки. Изтриването не е завършено.',
'OrderGuard has not completed this erasure. Check it on the OrderGuard erasure attention page; this screen will not try again automatically.' => 'OrderGuard не е завършил това изтриване. Проверете го на страницата за внимание при изтриване на OrderGuard; този екран няма да опита отново автоматично.',
'%1$d links to orders and %2$d possible matches did not fit in the review. They were left unchanged and need an administrator’s review.' => '%1$d връзки към поръчки и %2$d възможни съвпадения не се побраха в прегледа. Те не са променени и изискват преглед от администратор.',
'OrderGuard’s saved record for this erasure is damaged or does not match the request. Contact OrderGuard support before trying again.' => 'Запазеният в OrderGuard запис за това изтриване е повреден или не съответства на заявката. Свържете се с поддръжката на OrderGuard, преди да опитате отново.',
'The date limit saved for this erasure is in the future, which usually means a clock problem. Check the server clock and contact OrderGuard support.' => 'Крайният срок, запазен за това изтриване, е в бъдещето, което обикновено означава проблем с часовника. Проверете часовника на сървъра и се свържете с поддръжката на OrderGuard.',
'The WordPress erasure request could not be read or is not a confirmed erasure request. Check it under Tools → Erase Personal Data.' => 'Заявката за изтриване в WordPress не можа да бъде прочетена или не е потвърдена заявка за изтриване. Проверете я в Инструменти → Изтриване на лични данни.',
'This WordPress erasure request changed after OrderGuard started on it (its email address, date or status). Create a new erasure request instead of editing this one.' => 'Тази заявка за изтриване в WordPress е променена, след като OrderGuard започна работа по нея (имейл, дата или статус). Създайте нова заявка за изтриване, вместо да редактирате тази.',
'OrderGuard’s saved details for this request do not match what WordPress shows now. Contact OrderGuard support before trying again.' => 'Запазените в OrderGuard данни за тази заявка не съответстват на показаното сега в WordPress. Свържете се с поддръжката на OrderGuard, преди да опитате отново.',
'OrderGuard could not use the WordPress request table safely because it does not use the InnoDB database engine. Ask your host to check the database.' => 'OrderGuard не може да използва безопасно таблицата със заявки на WordPress, защото тя не използва InnoDB. Помолете хостинга да провери базата данни.',
'OrderGuard’s own storage on this site could not be used. Ask your host to check the database, then try again.' => 'Собственото хранилище на OrderGuard на този сайт не можа да бъде използвано. Помолете хостинга да провери базата данни и опитайте отново.',
'OrderGuard could not yet collect every order and contact detail for this erasure, or its server has not confirmed it. The erasure is not complete.' => 'OrderGuard все още не е събрал всички поръчки и данни за контакт за това изтриване или сървърът му още не го е потвърдил. Изтриването не е завършено.',
'A cleanup step needs an administrator. The erasure is not complete.' => 'Стъпка от почистването изисква администратор. Изтриването не е завършено.',
'A cleanup step needs an administrator, and its warning could not be saved. Contact the administrator now.' => 'Стъпка от почистването изисква администратор, а предупреждението не можа да бъде запазено. Свържете се незабавно с администратора.',
'OrderGuard could not save the record that protects this erasure. An administrator needs to look at it; the erasure is not complete.' => 'OrderGuard не можа да запази записа, който защитава това изтриване. Администратор трябва да го прегледа; изтриването не е завършено.',
'less than a minute' => 'по-малко от минута',
);
return $map;
}
add_filter( 'gettext', 'orderguard_filter_gettext', 10, 3 );
function orderguard_filter_gettext( $translation, $text, $domain ) {
if ( 'orderguard' !== $domain || ! orderguard_should_bg() ) {
return $translation;
}
if ( $translation !== $text ) {
return $translation;
}
$map = orderguard_translations();
return isset( $map[ $text ] ) ? $map[ $text ] : $translation;
}
add_filter( 'ngettext', 'orderguard_filter_ngettext', 10, 5 );
function orderguard_filter_ngettext( $translation, $single, $plural, $number, $domain ) {
if ( 'orderguard' !== $domain || ! orderguard_should_bg() ) {
return $translation;
}
$source = ( 1 === (int) $number ) ? $single : $plural;
if ( $translation !== $source ) {
return $translation;
}
$pl = array(
'%d other merchant flagged this customer' => array( '%d друг търговец докладва този клиент', '%d други търговци докладват този клиент' ),
'%d blocked them' => array( '%d го е блокирал', '%d са го блокирали' ),
'%d order needs review' => array( '%d поръчка чака преглед', '%d поръчки чакат преглед' ),
'%d order with you, all time' => array( '%d поръчка при вас, за цялото време', '%d поръчки при вас, за цялото време' ),
// TASK 49 -- the "Your own records" headline. Distinct msgids from the
// line above: that one is the store's TRUE all-time WooCommerce total,
// this one is what OrderGuard itself holds for its own store (24-month
// window). Same customer, two different numbers, on purpose.
'%1$d order over the last %2$d months, from your own store history.' => array(
'%1$d поръчка през последните %2$d месеца, от собствената история на магазина ви.',
'%1$d поръчки през последните %2$d месеца, от собствената история на магазина ви.',
),
'%d order in your own store history.' => array(
'%d поръчка в собствената история на магазина ви.',
'%d поръчки в собствената история на магазина ви.',
),
// TASK 50 -- the history-clients section and the import notice.
'%d more waiting. Deal with these first — the rest appear as this list clears.' => array(
'Още %d чака. Първо се погрижете за тези — останалите се появяват, докато списъкът се изчиства.',
'Още %d чакат. Първо се погрижете за тези — останалите се появяват, докато списъкът се изчиства.',
),
'%d client from your own history still needs a decision.' => array(
'%d клиент от вашата собствена история все още чака решение.',
'%d клиенти от вашата собствена история все още чакат решение.',
),
'flagged by %d store' => array( 'докладван от %d магазин', 'докладван от %d магазина' ),
'OrderGuard has seen %d order from this customer, but it has not reached an outcome that can be scored.' => array(
'OrderGuard е виждал %d поръчка от този клиент, но тя не е достигнала резултат, който може да бъде оценен.',
'OrderGuard е виждал %d поръчки от този клиент, но нито една не е достигнала резултат, който може да бъде оценен.',
),
);
if ( isset( $pl[ $single ] ) ) {
return ( 1 === (int) $number ) ? $pl[ $single ][0] : $pl[ $single ][1];
}
return $translation;
}
/**
* Locale-aware date formatting that also respects the per-user EN/BG admin
* toggle (date_i18n() alone follows only the site locale). Full month names
* are listed before the 3-letter abbreviations so strtr matches longest first.
*/
function orderguard_i18n_date( $format, $ts ) {
$out = date_i18n( $format, $ts );
if ( function_exists( 'orderguard_should_bg' ) && orderguard_should_bg() ) {
$out = strtr( $out, array(
'January'=>'януари','February'=>'февруари','March'=>'март','April'=>'април','May'=>'май','June'=>'юни','July'=>'юли','August'=>'август','September'=>'септември','October'=>'октомври','November'=>'ноември','December'=>'декември',
'Jan'=>'яну','Feb'=>'фев','Mar'=>'мар','Apr'=>'апр','Jun'=>'юни','Jul'=>'юли','Aug'=>'авг','Sep'=>'сеп','Oct'=>'окт','Nov'=>'ное','Dec'=>'дек',
' at '=>' в ',
) );
}
return $out;
}
add_action( 'admin_post_orderguard_set_lang', 'orderguard_handle_set_lang' );
function orderguard_handle_set_lang() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_set_lang' ) ) {
wp_die( 'Bad nonce.' );
}
$lang = ( isset( $_GET['lang'] ) && 'bg' === $_GET['lang'] ) ? 'bg' : 'en';
update_user_meta( get_current_user_id(), 'orderguard_admin_lang', $lang );
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard' ) );
exit;
}
/**
* Small inline flag SVG for the language switcher. 'bg' = Bulgaria tricolour,
* anything else = UK (English). Clip-path ids are made unique per call so
* multiple UK flags on one page don't collide.
*/
function orderguard_flag_svg( $lang ) {
if ( 'bg' === $lang ) {
return '';
}
static $n = 0;
$n++;
$s = 'ogfs' . $n;
$t = 'ogft' . $n;
return '';
}
/**
* Identity fields. Only phone/email are collected+matched. name disabled until
* the corroboration feature; ip removed (non-unique, data minimization).
*/
function orderguard_identity_fields() {
return array(
'phone' => array( 'enabled' => true, 'label' => 'Phone' ),
'email' => array( 'enabled' => true, 'label' => 'Email' ),
'name' => array( 'enabled' => false, 'label' => 'Name' ),
);
}
function orderguard_hash_value( $value ) {
$normalised = strtolower( trim( $value ) );
return hash( 'sha256', $normalised );
}
/**
* Normalise a phone for consistent flag matching: keep digits and a leading '+',
* strip spaces/dashes/parentheses/dots. (National-vs-international prefix mapping
* -- e.g. 0888... <-> +359888... -- is deferred to the corroboration/identity
* build; this handles the common formatting differences.)
*/
function orderguard_normalize_phone( $phone ) {
$phone = trim( (string) $phone );
if ( '' === $phone ) {
return '';
}
$has_plus = ( 0 === strpos( $phone, '+' ) );
$digits = preg_replace( '/\D+/', '', $phone );
if ( '' === $digits ) {
return '';
}
return ( $has_plus ? '+' : '' ) . $digits;
}
/**
* SHA-256 of a normalised phone (for flag matching). '' when there's no phone.
*
* FROZEN. This is the LEGACY spelling hash and its output must never change:
* every sighting row, exclusion entry and flag hash ever written was computed
* with exactly this function, and the server matches by hash equality. Changing
* it would silently orphan all of them (staff exclusions would stop matching
* and test orders would leak again -- with no error anywhere). The national<->
* international mapping the docblock above defers lives in orderguard_phone_e164()
* below, as a SECOND hash sent alongside this one, never instead of it.
*/
function orderguard_phone_hash( $phone ) {
$n = orderguard_normalize_phone( $phone );
return ( '' !== $n ) ? orderguard_hash_value( $n ) : '';
}
/**
* ---------------------------------------------------------------------------
* H-2: E.164 phone identity (the national <-> international mapping).
* ---------------------------------------------------------------------------
* orderguard_normalize_phone() keeps a number as the shopper spelt it, so "0888123456"
* and "+359888123456" -- one human -- hash to two identities that never meet.
* Store A (national-format checkout) and Store B (country-code plugin) share no
* customers at all, and the failure is silent: recognition just reads low.
*
* orderguard_phone_e164() maps a number to its canonical + form so the plugin
* can send BOTH hashes (transitional dual-send; the server matches on either).
*
* THE RULE THAT MATTERS MOST HERE: DO NOT GUESS. A national-format number is
* only interpretable if you know the country -- "0721123456" is one human in
* Bulgaria (+359721123456) and a DIFFERENT human in Romania (+40721123456).
* Deriving with the wrong country doesn't miss a match; it MERGES TWO STRANGERS
* into one reputation, which is the worst failure this product can have. A
* declined derivation, by contrast, is just today's status quo (legacy hash
* only) and fully recoverable. So every branch below prefers '' over a guess:
*
* - The country key is the CUSTOMER'S BILLING COUNTRY (their own selection),
* never the store's, on every path that can create identity.
* - Only countries whose numbering we actually know derive at all (BG/RO/GR).
* Anything else -- including an empty country -- declines.
* - Only MOBILE plans are derived. COD customers are overwhelmingly mobile,
* and the three mobile patterns are pairwise disjoint (BG 08x/09x, RO 07x,
* GR 69x), so even a WRONG billing-country claim cannot turn one supported
* country's valid mobile into another's. Landlines keep legacy-only
* matching -- a missed link, not a wrong one.
* - International forms ('+', '00') are self-declaring; the embedded dial
* code is trusted over the billing country, but the national number must
* still validate against that country's mobile plan or we decline.
*
* Deliberately NOT a full libphonenumber: a normaliser that is right for
* BG/RO/GR and declines elsewhere beats one that is confidently wrong globally.
*/
function orderguard_phone_e164_plans() {
// cc = E.164 country code (these three are prefix-free among themselves
// and against all other assigned codes, so prefix-matching is safe).
// nsn = regex the NATIONAL SIGNIFICANT NUMBER (digits after cc / trunk)
// must match. MOBILE ranges only -- see the header comment.
// trunk = national trunk prefix dropped when internationalising ('' = none;
// Greece dials the full NSN domestically).
return array(
'BG' => array( 'cc' => '359', 'nsn' => '/^(8[789]|9[89])[0-9]{7}$/', 'trunk' => '0' ),
'RO' => array( 'cc' => '40', 'nsn' => '/^7[2-9][0-9]{7}$/', 'trunk' => '0' ),
'GR' => array( 'cc' => '30', 'nsn' => '/^69[0-9]{8}$/', 'trunk' => '' ),
);
}
/**
* Canonical E.164 form ('+') of a phone, or '' when it cannot be
* derived SAFELY. See the block comment above for the safety rules; when in
* doubt this function declines, it never guesses.
*
* @param string $phone Raw phone as typed / stored on the order.
* @param string $country ISO 3166-1 alpha-2 BILLING country ('' = unknown).
*/
function orderguard_phone_e164( $phone, $country ) {
$phone = trim( (string) $phone );
if ( '' === $phone ) {
return '';
}
$has_plus = ( 0 === strpos( $phone, '+' ) );
$digits = preg_replace( '/\D+/', '', $phone );
if ( '' === $digits ) {
return '';
}
// "00" is the ITU / pan-European international call prefix: "00359..." is
// the same dial string as "+359...". Only ever accepted when the digits
// after it match a KNOWN dial code below -- "00" followed by anything else
// declines rather than inventing a country code.
if ( ! $has_plus && 0 === strpos( $digits, '00' ) ) {
$digits = substr( $digits, 2 );
$has_plus = true;
}
$plans = orderguard_phone_e164_plans();
if ( $has_plus ) {
// International form: self-declaring. The dial code names the country;
// the billing field is neither needed nor trusted over the number.
foreach ( $plans as $plan ) {
if ( 0 !== strpos( $digits, $plan['cc'] ) ) {
continue;
}
$nsn = substr( $digits, strlen( $plan['cc'] ) );
// Trunk zero retained after the country code ("+359 0888...") is a
// common checkout spelling. No valid NSN in these plans starts with
// '0', so dropping ONE leading zero can never move between two real
// numbers -- it either recovers this one or still fails validation.
if ( '' !== $plan['trunk'] && ! preg_match( $plan['nsn'], $nsn )
&& 0 === strpos( $nsn, $plan['trunk'] ) ) {
$stripped = substr( $nsn, strlen( $plan['trunk'] ) );
if ( preg_match( $plan['nsn'], $stripped ) ) {
$nsn = $stripped;
}
}
return preg_match( $plan['nsn'], $nsn ) ? '+' . $plan['cc'] . $nsn : '';
}
return ''; // unknown dial code -> decline (never guess)
}
// National form: meaningless without a country, and only safe for one
// whose numbering plan we know. Empty/unknown/unsupported -> decline.
$country = strtoupper( substr( trim( (string) $country ), 0, 2 ) );
if ( '' === $country || ! isset( $plans[ $country ] ) ) {
return '';
}
$plan = $plans[ $country ];
// Bare "cc + nsn" without '+' ("359888123456"): accepted only when the
// claimed country's OWN dial code is embedded AND the NSN validates. A
// different country's code embedded in the digits never derives here.
if ( 0 === strpos( $digits, $plan['cc'] ) ) {
$nsn = substr( $digits, strlen( $plan['cc'] ) );
if ( preg_match( $plan['nsn'], $nsn ) ) {
return '+' . $plan['cc'] . $nsn;
}
// Not a cc-prefixed spelling; it may still parse as plain national.
}
$nsn = $digits;
if ( '' !== $plan['trunk'] ) {
if ( 0 !== strpos( $digits, $plan['trunk'] ) ) {
return '';
}
$nsn = substr( $digits, strlen( $plan['trunk'] ) );
}
return preg_match( $plan['nsn'], $nsn ) ? '+' . $plan['cc'] . $nsn : '';
}
/**
* SHA-256 of the E.164 form, or '' when it is not derivable OR when it would
* equal the legacy hash (international-format input normalises identically, so
* the second hash would add nothing -- one identity, one hash, and the server
* would just skip the twin anyway). Callers can therefore always do:
* $e164 = orderguard_phone_hash_e164( $phone, $country );
* if ( '' !== $e164 ) { $body['phone_hash_e164'] = $e164; }
* and an omitted field means "nothing NEW to add", never an error.
*/
function orderguard_phone_hash_e164( $phone, $country ) {
$e164 = orderguard_phone_e164( $phone, $country );
if ( '' === $e164 ) {
return '';
}
$hash = orderguard_hash_value( $e164 );
return ( $hash === orderguard_phone_hash( $phone ) ) ? '' : $hash;
}
/**
* ---------------------------------------------------------------------------
* SPELLING VARIANTS -- local flag matching across one human's phone spellings.
* ---------------------------------------------------------------------------
* orderguard_phone_hash() is FROZEN and hashes a number AS SPELT, so one human typing
* "0888123456", "+359888123456" and "359888123456" yields three unrelated
* hashes. On the NETWORK path H-2 solved this by SENDING a second E.164 hash
* alongside. The LOCAL flags table never got that treatment -- it has a single
* phone_hash column and no phone_hash_e164 -- so a customer blocked under one
* spelling was neither hidden from COD nor stopped at submission under another.
* The block was bypassable by spelling alone.
*
* Migrating the table is impossible: it holds hashes, not raw phones, so old
* rows could never be re-hashed. Instead we expand the number the SHOPPER typed
* into every spelling it could have been STORED as, hash each with the frozen
* function, and match any. This repairs flags ALREADY in the table, with no
* schema change and no re-adding of customers.
*
* SAFETY: every variant is generated from ONE canonical E.164 number, so this
* widens matching across spellings of a single human and never across two
* different ones. Where orderguard_phone_e164() declines -- unknown/unsupported country,
* landline, anything ambiguous -- we fall back to the exact spelling alone,
* which is today's behaviour. It never guesses.
*/
function orderguard_phone_spelling_variants( $phone, $country ) {
$variants = array();
$legacy = orderguard_normalize_phone( $phone );
if ( '' !== $legacy ) {
$variants[] = $legacy;
}
$e164 = orderguard_phone_e164( $phone, $country );
if ( '' === $e164 ) {
return $variants; // not safely derivable -> exact spelling only
}
$digits = substr( $e164, 1 ); // 359888123456
$variants[] = $e164; // +359888123456
$variants[] = $digits; // 359888123456
$variants[] = '00' . $digits; // 00359888123456
foreach ( orderguard_phone_e164_plans() as $plan ) {
if ( 0 !== strpos( $digits, $plan['cc'] ) ) {
continue;
}
$nsn = substr( $digits, strlen( $plan['cc'] ) );
$trunk = $plan['trunk'];
$variants[] = $trunk . $nsn; // 0888123456 (GR: bare NSN)
$variants[] = '+' . $plan['cc'] . $trunk . $nsn; // +3590888123456 (trunk retained)
$variants[] = $plan['cc'] . $trunk . $nsn; // 3590888123456
break;
}
return array_values( array_unique( array_filter( $variants ) ) );
}
/**
* Frozen-format hashes of every spelling of one phone. Empty array when there is
* no usable phone at all.
*/
function orderguard_phone_hashes_for_lookup( $phone, $country ) {
$hashes = array();
foreach ( orderguard_phone_spelling_variants( $phone, $country ) as $variant ) {
$hashes[] = orderguard_hash_value( $variant );
}
return array_values( array_unique( $hashes ) );
}
/**
* The store's own base country (ISO alpha-2), for the few places that have no
* order/checkout context: values TYPED BY THE MERCHANT at their own desk (GDPR
* lookup/erase, flag retraction). Mirrors orderguard_exclusion_store_dial_code()'s
* resolution (kept separate on purpose -- that function returns a dial code
* and predates this one; merging them would touch working exclusion code).
*
* NEVER use this for checkout/order identity -- there the customer's billing
* country is the only correct key. Store-country derivation on a DELETE path
* matches the trade-off the exclusion list already ships (its variants use the
* store dial code): the subject is standing at the merchant's counter, so
* store-country is almost always right, and the residual risk is bounded to
* this store's own rows.
*/
function orderguard_store_base_country() {
$country = '';
if ( function_exists( 'wc_get_base_location' ) ) {
$base = wc_get_base_location();
$country = isset( $base['country'] ) ? strtoupper( (string) $base['country'] ) : '';
}
if ( '' === $country ) {
$raw = (string) get_option( 'woocommerce_default_country', '' );
$country = strtoupper( substr( $raw, 0, 2 ) );
}
return preg_match( '/^[A-Z]{2}$/', $country ) ? $country : '';
}
/**
* Billing country as currently known at CHECKOUT (no order exists yet).
* Posted field first (the shopper's own selection), then the customer session.
* '' when unknown -- and '' means orderguard_phone_e164 declines, which is the intended
* fail-safe: no country, no derivation, legacy behaviour.
*/
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only display logic, not a form handler: this runs on cart/checkout page loads and on WooCommerce's update_order_review AJAX, writes nothing, and changes no state, so there is no action to protect with a nonce. Each field is sanitised and pattern-validated after parse_str(), which PHPCS cannot follow, and anything unrecognised fails open.
function orderguard_get_checkout_country() {
if ( isset( $_POST['billing_country'] ) ) {
$c = strtoupper( sanitize_text_field( wp_unslash( $_POST['billing_country'] ) ) );
if ( preg_match( '/^[A-Z]{2}$/', $c ) ) {
return $c;
}
}
// Same blind spot the identity read had: on WooCommerce's update_order_review
// AJAX the billing country is inside the packed post_data blob (the top-level
// key there is 'country'), not a billing_country field. Without this the
// country can come back empty mid-checkout, orderguard_phone_e164() then declines,
// and phone-spelling matching silently degrades to exact-match only.
if ( isset( $_POST['post_data'] ) ) {
$raw = wp_unslash( $_POST['post_data'] );
if ( is_string( $raw ) && '' !== $raw ) {
$parsed = array();
parse_str( $raw, $parsed );
if ( isset( $parsed['billing_country'] ) && is_string( $parsed['billing_country'] ) ) {
$c = strtoupper( sanitize_text_field( $parsed['billing_country'] ) );
if ( preg_match( '/^[A-Z]{2}$/', $c ) ) {
return $c;
}
}
}
}
if ( function_exists( 'WC' ) && WC() && isset( WC()->customer ) && WC()->customer ) {
$c = strtoupper( (string) WC()->customer->get_billing_country() );
if ( preg_match( '/^[A-Z]{2}$/', $c ) ) {
return $c;
}
}
return '';
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
/**
* Hash an identity array (field => raw value) for the server. Phone ALWAYS goes
* through orderguard_phone_hash() so formatting variants ('0888 123 456' vs
* '0888123456') yield the same hash everywhere; other fields use
* orderguard_hash_value(). Fields that normalise to nothing are dropped.
*/
function orderguard_hash_identity( $identity ) {
$hashed = array();
foreach ( $identity as $field => $value ) {
$hash = ( 'phone' === $field ) ? orderguard_phone_hash( $value ) : orderguard_hash_value( $value );
if ( '' !== $hash ) {
$hashed[ $field ] = $hash;
}
}
return $hashed;
}
function orderguard_get_item_refs( $order ) {
$refs = array();
foreach ( $order->get_items() as $item_id => $item ) {
$refs[] = (string) $item_id;
}
if ( empty( $refs ) ) {
$refs[] = '0';
}
return $refs;
}
/**
* Durably replay an order-backed transport paused by GDPR scope discovery.
*
* Only an order ID and retry number enter Action Scheduler. Identity is read
* afresh after the pause, so neither raw customer data nor unhashed identity is
* copied into the scheduler tables. Changing the retry number also avoids the
* Action Scheduler trap where a unique reschedule sees its own RUNNING action
* and refuses to create the successor.
*/
function orderguard_schedule_gdpr_deferred_order( $order_id, $attempt = 1 ) {
$order_id = absint( $order_id );
$attempt = max( 1, min( 1000000, (int) $attempt ) );
if ( $order_id <= 0 ) {
return false;
}
$delays = array( 1 => 60, 2 => 300, 3 => 900 );
$delay = isset( $delays[ $attempt ] ) ? $delays[ $attempt ] : HOUR_IN_SECONDS;
$args = array( 'order_id' => $order_id, 'attempt' => $attempt );
if ( function_exists( 'as_schedule_single_action' ) ) {
try {
$action_id = as_schedule_single_action(
time() + $delay,
'orderguard_gdpr_deferred_order',
$args,
'orderguard',
true
);
if ( false !== $action_id && (int) $action_id > 0 ) {
return true;
}
// A unique enqueue returns zero both for a duplicate and for some
// storage failures. Only a fresh PENDING lookup proves another
// action will replay this work; the current RUNNING action is not
// a successor and must not count.
if ( function_exists( 'as_get_scheduled_actions' ) ) {
$pending = as_get_scheduled_actions(
array(
'hook' => 'orderguard_gdpr_deferred_order',
'args' => $args,
'group' => 'orderguard',
'status' => 'pending',
'per_page' => 1,
),
'ids'
);
if ( ! empty( $pending ) ) {
return true;
}
}
} catch ( Throwable $throwable ) {
orderguard_log( 'OrderGuard: Action Scheduler threw while queuing a privacy-paused order replay; trying WordPress cron.' );
}
orderguard_log( 'OrderGuard: privacy-paused order #' . $order_id . ' could not be queued in Action Scheduler; trying WordPress cron.' );
}
if ( function_exists( 'wp_schedule_single_event' ) ) {
$cron_args = array_values( $args );
$next = wp_next_scheduled( 'orderguard_gdpr_deferred_order', $cron_args );
if ( false !== $next ) {
return true;
}
$scheduled = wp_schedule_single_event(
time() + $delay,
'orderguard_gdpr_deferred_order',
$cron_args,
true
);
return ! is_wp_error( $scheduled ) && true === $scheduled;
}
orderguard_log( 'OrderGuard: privacy-paused order #' . $order_id . ' could not be scheduled for replay.' );
return false;
}
add_action( 'orderguard_gdpr_deferred_order', 'orderguard_run_gdpr_deferred_order', 10, 2 );
/** Replay every reconstructible network fact for one privacy-paused order. */
function orderguard_run_gdpr_deferred_order( $order_id, $attempt = 1 ) {
if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) {
orderguard_owned_exclusion_reset_request_snapshot();
}
if ( ! orderguard_is_activated() || ! function_exists( 'wc_get_order' ) ) {
return;
}
try {
$order = wc_get_order( absint( $order_id ) );
} catch ( Throwable $throwable ) {
$order = false;
}
if ( ! $order instanceof WC_Order ) {
orderguard_log( 'OrderGuard: privacy-paused order #' . absint( $order_id ) . ' no longer exists; deferred replay retired.' );
return;
}
$disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( 'suppress' === $disposition ) {
return;
}
if ( 'allow' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), (int) $attempt + 1 );
return;
}
// A privacy pause can happen on the order's very first /v1/check. Going
// straight to reconciliation is not equivalent: a pending or otherwise
// signalless order may receive a 404 and intentionally decline recovery,
// leaving both its network sighting and immutable score snapshot missing.
// Recreate the original new-order sequence first, then reconcile facts.
if ( '' === (string) orderguard_get_order_meta( $order, '_orderguard_checked_at' ) ) {
if ( orderguard_is_checkout_rejected_dormant( $order ) ) {
return;
}
$identity = orderguard_read_identity_from_order( $order );
$hashed = orderguard_hash_identity( $identity );
if ( empty( orderguard_usable_primary_identity_fields( $order, $hashed ) ) ) {
orderguard_log( 'OrderGuard: privacy-paused order #' . $order->get_id() . ' has no usable identity; deferred initial check retired.' );
return;
}
$withheld = null;
$scores = orderguard_send_to_server( $order, $hashed, $withheld );
if ( ! is_array( $scores ) ) {
if ( 'excluded' === $withheld ) {
return;
}
if ( 'exclusion_unknown' === $withheld ) {
$order_status = (string) $order->get_status();
$created = $order->get_date_created();
$created_at = $created ? (int) $created->getTimestamp() : 0;
$chain_expired = ( $created_at > 0 && time() - $created_at >= 7 * DAY_IN_SECONDS )
|| ( $created_at <= 0 && (int) $attempt >= 168 );
if ( in_array( $order_status, array( 'completed', 'refunded', 'cancelled', 'trash' ), true )
|| ( in_array( $order_status, array( 'failed', 'cancelled' ), true )
&& orderguard_order_is_signalless_dead( $order ) ) ) {
// The ordinary sweep owns resolved orders, while a
// signalless dead order has no useful fact to replay.
return;
}
if ( $chain_expired ) {
orderguard_log( 'OrderGuard: exclusion-source replay for order #' . $order->get_id() . ' retired after the bounded retry window; the ordinary sweep will handle any later resolution.' );
return;
}
}
if ( 'suppress' !== orderguard_gdpr_transport_disposition_for_order( $order ) ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), (int) $attempt + 1 );
}
return;
}
orderguard_save_scores( $order, $scores );
orderguard_insights_record_new_order( $order, $scores );
orderguard_maybe_note_own_history( $order );
if ( '' === (string) orderguard_get_order_meta( $order, '_orderguard_checked_at' ) ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), (int) $attempt + 1 );
return;
}
}
orderguard_reconcile_order( $order );
// A locally stored merchant flag is not part of reconciliation's event set.
// Re-publish it after the same pause so a legitimate admin action is not
// silently discarded merely because another customer's scope was freezing.
if ( function_exists( 'orderguard_flag_find_for_order' ) ) {
$flag = orderguard_flag_find_for_order( $order );
if ( $flag && isset( $flag->level ) ) {
$identity = orderguard_flag_identity_from_order( $order );
$created = $order->get_date_created();
orderguard_send_flag_signal(
$identity['email'],
$identity['phone'],
orderguard_flag_signal_type( (string) $flag->level ),
$order->get_billing_country(),
$created ? (int) $created->getTimestamp() : 1,
$order->get_id()
);
}
}
}
/**
* ---------------------------------------------------------------------------
* Send an EVENT to the server, with retry-on-failure via Action Scheduler.
* ---------------------------------------------------------------------------
* Transient failure (unreachable / 5xx) -> re-queue a retry (idempotent, safe).
* Permanent failure (4xx) -> not retried; a 404 is left for the sweep.
*/
function orderguard_send_event( $event, $attempt = 1 ) {
$order_id = isset( $event['order_ref'] ) ? absint( $event['order_ref'] ) : 0;
$privacy_lock = false;
if ( $order_id > 0 ) {
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $order_id );
if ( 'suppress' === $disposition ) {
return null;
}
if ( 'allow' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
return null;
}
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
return null;
}
// The mutex closes the final check-to-socket race with a newly accepted
// merchant erasure. Re-read all privacy state only after owning it.
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $order_id );
if ( 'allow' !== $disposition ) {
if ( 'suppress' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
}
orderguard_release_gdpr_erasure_lifecycle_lock();
return null;
}
}
try {
// Events carry only an order reference, so derive the complete identity
// from that order and read the owned exclusion source afresh at the last
// practical moment. Unknown is not permission and neither local outcome
// enters the legacy retry queue; durable order facts remain eligible for
// the normal reconciliation sweep after the source recovers.
if ( $order_id > 0 ) {
$exclusion = orderguard_owned_exclusion_order_ref_state( $order_id );
if ( 'not_excluded' !== ( $exclusion['status'] ?? 'unknown' ) ) {
return null;
}
}
$response = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/events',
array(
'method' => 'POST',
'timeout' => 5,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( $event ),
)
);
if ( is_wp_error( $response ) ) {
orderguard_log( 'OrderGuard: event send FAILED (' . $event['event'] . ') order #' . $event['order_ref'] . ' -- ' . $response->get_error_message() . ' -- scheduling retry' );
orderguard_schedule_event_retry( $event, $attempt );
return null;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
orderguard_log( 'OrderGuard: event "' . $event['event'] . '" order #' . $event['order_ref'] . ' -- server responded ' . $code );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
return is_array( $data ) ? $data : null;
}
if ( $code >= 500 || 429 === $code ) {
orderguard_log( 'OrderGuard: event "' . $event['event'] . '" order #' . $event['order_ref'] . ' -- server ' . $code . ' -- scheduling retry' );
orderguard_schedule_event_retry( $event, $attempt );
return null;
}
orderguard_log( 'OrderGuard: event "' . $event['event'] . '" order #' . $event['order_ref'] . ' -- server ' . $code . ' -- NOT retrying (permanent)' );
return null;
} finally {
if ( $privacy_lock ) {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
}
function orderguard_schedule_event_retry( $event, $attempt ) {
$backoff = array( 1 => 60, 2 => 300, 3 => 1800, 4 => 7200 );
if ( ! isset( $backoff[ $attempt ] ) ) {
orderguard_log( 'OrderGuard: event "' . $event['event'] . '" order #' . $event['order_ref'] . ' -- retries exhausted; leaving for the sweep' );
return;
}
if ( ! function_exists( 'as_schedule_single_action' ) ) {
orderguard_log( 'OrderGuard: Action Scheduler unavailable; cannot schedule retry' );
return;
}
as_schedule_single_action(
time() + $backoff[ $attempt ],
'orderguard_retry_event',
array( 'event' => $event, 'attempt' => $attempt + 1 ),
'orderguard'
);
}
add_action( 'orderguard_retry_event', 'orderguard_do_event_retry', 10, 2 );
function orderguard_do_event_retry( $event, $attempt ) {
if ( ! orderguard_is_activated() ) {
return;
}
orderguard_log( 'OrderGuard: retrying event "' . $event['event'] . '" order #' . $event['order_ref'] . ' (attempt ' . $attempt . ')' );
orderguard_send_event( $event, $attempt );
}
/**
* New order -> check in (create pending sightings + return scores).
*/
add_action( 'woocommerce_new_order', 'orderguard_handle_new_order', 10, 1 );
function orderguard_handle_new_order( $order_id ) {
if ( ! orderguard_is_activated() ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
// M-5: do NOT check in an order this same checkout is about to REJECT.
// woocommerce_new_order (here, priority 10) fires before the enforcement
// hooks (orderguard_enforce_manual_block_on_order at 15, orderguard_enforce_cod_on_order
// at 20) get their chance to throw -- so without this gate every blocked
// customer's aborted attempt created server-side sightings that stayed
// 'pending' forever, surfaced in /v1/pending on every sweep, and burned
// the new-identity velocity budget (the C-1 enumeration wall). The
// decision here is THE SAME function the enforcers throw on
// (orderguard_checkout_rejection_reason), so the prediction cannot drift from the
// enforcement. If the order somehow proceeds anyway (admin/REST creation,
// where the checkout enforcement hooks never run), the mark below keeps
// it out of the 404-recovery path only while it LOOKS aborted; once it
// resolves for real, the sweep checks it in with an honest late
// timestamp (H-1 recovery behaviour), and a successful checkout retry
// checks it in immediately via orderguard_clear_checkout_rejection().
if ( '' !== orderguard_checkout_rejection_reason( $order ) ) {
orderguard_mark_checkout_rejected( $order );
orderguard_log( 'OrderGuard: order #' . $order->get_id() . ' is headed for checkout rejection -- check-in withheld (no sightings created)' );
return;
}
$identity = orderguard_read_identity_from_order( $order );
$hashed = orderguard_hash_identity( $identity );
$withheld = null;
$scores = orderguard_send_to_server( $order, $hashed, $withheld );
if ( 'exclusion_unknown' === $withheld ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), 1 );
}
if ( is_array( $scores ) ) {
orderguard_save_scores( $order, $scores );
orderguard_insights_record_new_order( $order, $scores );
// TASK 50 -- after the snapshot, never before: the note reads the meta
// orderguard_save_scores() has just written.
orderguard_maybe_note_own_history( $order );
}
}
/**
* Write ONE private order note when a cash-on-delivery order arrives from a
* customer this store's own records already warn about (task 50).
*
* NOT A NOTIFICATION. Nothing is sent: no email, no push, no webhook. This is a
* WooCommerce private note, which nobody sees unless they open the order and
* scroll -- and that is the whole point of it. Its job is to be the timestamped
* record that lives with the order, survives OrderGuard being deactivated, and
* can be pointed at weeks later when a customer disputes a refused parcel or a
* courier bills for a return. Attention is the orders-list badge's job and the
* Review queue's job.
*
* PRIVATE, and it must stay private: add_order_note()'s second argument
* ($is_customer_note) is left at its default of false, so WooCommerce does not
* email the customer. Never pass true here.
*
* COD ONLY. A prepaid order carries no collection risk worth a note -- the money
* is already taken -- and noting every order would make the notes column
* useless, which is the failure mode that matters for something this quiet.
*
* ONCE PER ORDER. A guard meta rather than a text search: an order is saved many
* times over its life (status changes, courier sync, the hourly sweep), and a
* note per save would bury the real history under our own duplicates.
*/
function orderguard_maybe_note_own_history( $order ) {
if ( ! $order instanceof WC_Order || ! function_exists( 'orderguard_is_cod_order' ) ) {
return;
}
if ( ! orderguard_is_cod_order( $order ) ) {
return;
}
if ( 'yes' === (string) orderguard_get_order_meta( $order, '_orderguard_own_note_added' ) ) {
return;
}
$reason = orderguard_own_flag_reason( $order );
if ( null === $reason ) {
return;
}
$order->add_order_note( sprintf(
/* translators: 1: parcels not collected, 2: parcels returned -- both from this store's OWN records. */
__( 'OrderGuard: your own records show %1$d not collected and %2$d returned for this customer. Cash on delivery was not blocked.', 'orderguard' ),
(int) $reason['not_collected'],
(int) $reason['returned']
) );
$order->update_meta_data( '_orderguard_own_note_added', 'yes' );
$order->save();
}
/**
* Status change -> status_changed event (raw status; server interprets).
*/
add_action( 'woocommerce_order_status_changed', 'orderguard_handle_status_change', 10, 4 );
function orderguard_handle_status_change( $order_id, $old_status, $new_status, $order ) {
if ( ! orderguard_is_activated() ) {
return;
}
$data = orderguard_send_event( array(
'order_ref' => (string) $order_id,
'event' => 'status_changed',
'status' => (string) $new_status,
) );
if ( is_array( $data ) && isset( $data['scores'] ) ) {
$o = wc_get_order( $order_id );
if ( $o ) {
orderguard_save_scores( $o, $data['scores'] );
}
}
}
/**
* Refund -> refund event with cumulative fraction.
*/
add_action( 'woocommerce_order_refunded', 'orderguard_handle_refund', 10, 2 );
function orderguard_handle_refund( $order_id, $refund_id ) {
if ( ! orderguard_is_activated() ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$fraction = orderguard_compute_return_fraction( $order );
if ( $fraction < 0 ) { $fraction = 0.0; }
if ( $fraction > 1 ) { $fraction = 1.0; }
// 'cod' -- recorded now, WEIGHED LATER. Scoring is unchanged.
//
// A refund is what turns an order into a 'returned' mark, and the two kinds
// of refund are not the same behaviour at all:
// COD -> they took delivery, paid cash, then sent it back.
// PREPAID -> they bought at a distance, changed their mind, and exercised
// their EU 14-day right of withdrawal. That is a statutory
// right, exercised lawfully, and it should not read the same as
// a cash-on-delivery failure -- OrderGuard exists to predict
// uncollected COD, not to score people for returning things
// they are entitled to return.
// Not hypothetical: BOX NOW Bulgaria is prepaid-only, so every return on a
// BOX NOW order is this second kind.
//
// Recording it costs nothing and keeps the option open. Not recording it
// makes the distinction unrecoverable -- the data would be gone.
$data = orderguard_send_event( array(
'order_ref' => (string) $order_id,
'event' => 'refund',
'cumulative_fraction' => round( $fraction, 4 ),
'cod' => function_exists( 'orderguard_is_cod_order' ) ? (bool) orderguard_is_cod_order( $order ) : null,
) );
if ( is_array( $data ) && isset( $data['scores'] ) ) {
orderguard_save_scores( $order, $data['scores'] );
}
}
function orderguard_compute_return_fraction( $order ) {
$order_total = (float) $order->get_total();
$total_refunded = (float) $order->get_total_refunded();
if ( $order_total > 0 ) {
return $total_refunded / $order_total;
}
return 0.0;
}
/**
* ===========================================================================
* AUTOMATIC ORDER-HISTORY IMPORT (cold-start seeding + recognition)
* ===========================================================================
* Runs ONCE, automatically, as soon as a store is activated -- no button, no
* user action. It crawls the store's last 24 months of orders (all payment
* methods, matching the live flow) in background batches (Action Scheduler),
* hashes each customer LOCALLY, and
* posts each batch to /v1/import/history. Customer details never leave the store
* -- only one-way hashes + the order outcome. When every batch is done it pulls
* the aggregate network-coverage summary (/v1/import/recognition).
*
* RESILIENT: if the server is unreachable for a batch, that batch is retried
* (same ID cursor, short delay) rather than skipped -- so a launch-phase outage
* only pauses the import, it never loses or double-counts history (the server
* dedupes per order).
*
* PRIVACY/SECURITY: identical hashing to the live path (orderguard_hash_identity). The
* server marks these rows source='import' -- they seed the shared network but
* grant this store NO per-identity read (see the server's _own_hashes /
* _store_has_corroborated). So import can't be used to enumerate the network.
*/
if ( ! defined( 'ORDERGUARD_IMPORT_BATCH' ) ) { define( 'ORDERGUARD_IMPORT_BATCH', 100 ); }
if ( ! defined( 'ORDERGUARD_IMPORT_MONTHS' ) ) { define( 'ORDERGUARD_IMPORT_MONTHS', 24 ); }
/** Return the one batch size both the local cursor and server can accept. */
function orderguard_import_batch_size( &$ok = null ) {
$ok = false;
$value = ORDERGUARD_IMPORT_BATCH;
if ( is_string( $value ) && preg_match( '/^[1-9][0-9]*$/', $value ) ) {
$value = (int) $value;
}
if ( ! is_int( $value ) || $value < 1 || $value > 500 ) {
return 0;
}
$ok = true;
return $value;
}
/**
* Resolve the authoritative WooCommerce order table used by the import cursor.
*
* WC_Order_Query cannot express an ID range on both supported data stores.
* The returned identifiers are fixed core schema names; every variable value
* in the keyset queries remains prepared.
*/
function orderguard_import_order_table_spec( &$ok = null, $expected_mode = '' ) {
$ok = false;
if ( ! function_exists( 'orderguard_gdpr_erasure_order_id_storage' ) ) {
return array();
}
$storage = orderguard_gdpr_erasure_order_id_storage( $storage_ok, $expected_mode );
if ( ! $storage_ok || empty( $storage['mode'] ) ) {
return array();
}
$ok = true;
if ( 'hpos' === $storage['mode'] ) {
return array(
'table' => $storage['table'],
'id' => $storage['id'],
'type' => 'type',
'status' => 'status',
'date' => 'date_created_gmt',
'utc' => true,
'mode' => 'hpos',
);
}
return array(
'table' => $storage['table'],
'id' => $storage['id'],
'type' => 'post_type',
'status' => 'post_status',
// WC_Order_Query maps an epoch date_created predicate to post_date_gmt.
// Keeping the replacement on UTC also prevents a site-timezone change
// from moving rows into or out of a frozen import while it is running.
'date' => 'post_date_gmt',
'utc' => true,
'mode' => 'classic',
);
}
/** Freeze the WooCommerce order types and statuses the old query considered. */
function orderguard_import_query_scope( &$ok = null ) {
$ok = false;
if ( ! function_exists( 'wc_get_order_types' )
|| ! function_exists( 'wc_get_order_statuses' ) ) {
return array();
}
// Freeze WooCommerce's actual query scope. Do not infer it from the order
// type's registered class: extensions may select the runtime class per ID
// with `woocommerce_order_class`, and the old WC_Order_Query respected it.
$types = array_values(
array_filter(
array_map( 'sanitize_key', (array) wc_get_order_types( 'view-orders' ) )
)
);
$statuses = array_values(
array_filter(
array_map( 'sanitize_key', array_keys( (array) wc_get_order_statuses() ) ),
static function ( $status ) {
return 0 === strpos( $status, 'wc-' );
}
)
);
$types = array_values( array_unique( $types ) );
$statuses = array_values( array_unique( $statuses ) );
if ( empty( $types ) || empty( $statuses ) || count( $types ) > 64 || count( $statuses ) > 128 ) {
return array();
}
$ok = true;
return array(
'types' => $types,
'statuses' => $statuses,
);
}
/** Validate a scope previously frozen into durable import state. */
function orderguard_normalize_import_query_scope( $types, $statuses, &$ok = null ) {
$ok = false;
$types = is_array( $types ) ? array_values( $types ) : array();
$statuses = is_array( $statuses ) ? array_values( $statuses ) : array();
if ( empty( $types ) || empty( $statuses ) || count( $types ) > 64 || count( $statuses ) > 128 ) {
return array();
}
foreach ( $types as &$type ) {
if ( ! is_string( $type ) || sanitize_key( $type ) !== $type || '' === $type ) {
return array();
}
}
unset( $type );
foreach ( $statuses as &$status ) {
if ( ! is_string( $status ) || sanitize_key( $status ) !== $status || 0 !== strpos( $status, 'wc-' ) ) {
return array();
}
}
unset( $status );
if ( count( array_unique( $types ) ) !== count( $types )
|| count( array_unique( $statuses ) ) !== count( $statuses ) ) {
return array();
}
$ok = true;
return array(
'types' => $types,
'statuses' => $statuses,
);
}
/**
* Validate every invariant that makes a persisted keyset state resumable.
*
* The returned array deliberately preserves additive fields used by a repair
* run, but numeric and token fields are normalised only after their original
* types and relationships have been proved. A malformed durable row must pause
* the import; it must never fall back to today's storage mode or query scope.
*/
function orderguard_normalize_keyset_import_state( $state, &$ok = null ) {
$ok = false;
if ( ! is_array( $state )
|| 'keyset-v1' !== ( isset( $state['pagination'] ) ? $state['pagination'] : '' )
|| ! isset( $state['status'], $state['storage_mode'], $state['order_types'], $state['order_statuses'] )
|| ! array_key_exists( 'cursor', $state )
|| ! array_key_exists( 'offset', $state )
|| ! array_key_exists( 'processed', $state )
|| ! array_key_exists( 'total', $state )
|| ! array_key_exists( 'high_water_id', $state )
|| ! array_key_exists( 'window', $state )
|| ! array_key_exists( 'started', $state )
|| ! array_key_exists( 'updated_at', $state )
|| ! array_key_exists( 'revision', $state )
|| ! isset( $state['run_token'], $state['mutation_token'] ) ) {
return array();
}
$status = (string) $state['status'];
$mode = (string) $state['storage_mode'];
if ( ! in_array( $status, array( 'running', 'finalizing', 'done' ), true )
|| ! in_array( $mode, array( 'classic', 'hpos' ), true )
|| ! is_string( $state['run_token'] )
|| ! is_string( $state['mutation_token'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $state['run_token'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $state['mutation_token'] ) ) {
return array();
}
foreach ( array( 'cursor', 'offset', 'processed', 'total', 'high_water_id', 'window', 'started', 'updated_at', 'revision' ) as $field ) {
if ( ! is_int( $state[ $field ] ) && ! ( is_string( $state[ $field ] ) && preg_match( '/^[0-9]+$/', $state[ $field ] ) ) ) {
return array();
}
}
$cursor = (int) $state['cursor'];
$offset = (int) $state['offset'];
$processed = (int) $state['processed'];
$total = (int) $state['total'];
$high_water_id = (int) $state['high_water_id'];
$window = (int) $state['window'];
$started = (int) $state['started'];
$updated_at = (int) $state['updated_at'];
$revision = (int) $state['revision'];
if ( $cursor < 0 || $offset !== $cursor || $cursor > $high_water_id
|| $processed < 0 || $total < $processed || $high_water_id < 0
|| $window <= 0 || $started <= 0 || $updated_at <= 0 || $revision < 1 ) {
return array();
}
$scope = orderguard_normalize_import_query_scope( $state['order_types'], $state['order_statuses'], $scope_ok );
if ( ! $scope_ok ) {
return array();
}
$normalised = $state;
$normalised['status'] = $status;
$normalised['storage_mode'] = $mode;
$normalised['order_types'] = $scope['types'];
$normalised['order_statuses'] = $scope['statuses'];
$normalised['cursor'] = $cursor;
$normalised['offset'] = $offset;
$normalised['processed'] = $processed;
$normalised['total'] = $total;
$normalised['high_water_id'] = $high_water_id;
$normalised['window'] = $window;
$normalised['started'] = $started;
$normalised['updated_at'] = $updated_at;
$normalised['revision'] = $revision;
if ( array_key_exists( 'last_defer_reason', $state ) ) {
if ( ! is_string( $state['last_defer_reason'] )
|| ! preg_match( '/^[a-z0-9_]{1,64}$/D', $state['last_defer_reason'] )
|| ! isset( $state['last_defer_at'], $state['defer_count'] )
|| ( ! is_int( $state['last_defer_at'] ) && ! ctype_digit( (string) $state['last_defer_at'] ) )
|| ( ! is_int( $state['defer_count'] ) && ! ctype_digit( (string) $state['defer_count'] ) ) ) {
return array();
}
$normalised['last_defer_at'] = (int) $state['last_defer_at'];
$normalised['defer_count'] = (int) $state['defer_count'];
if ( $normalised['last_defer_at'] <= 0 || $normalised['defer_count'] < 1 ) {
return array();
}
}
$ok = true;
return $normalised;
}
/** Build the fixed prepared type/status predicate shared by every keyset read. */
function orderguard_import_scope_sql( $spec, $types, $statuses, &$values, &$ok = null ) {
$values = array();
$ok = false;
$scope = orderguard_normalize_import_query_scope( $types, $statuses, $scope_ok );
if ( ! $scope_ok ) {
return '';
}
$type_placeholders = implode( ', ', array_fill( 0, count( $scope['types'] ), '%s' ) );
$status_placeholders = implode( ', ', array_fill( 0, count( $scope['statuses'] ), '%s' ) );
$values = array_merge( $scope['types'], $scope['statuses'] );
$ok = true;
return "{$spec['type']} IN ({$type_placeholders}) AND {$spec['status']} IN ({$status_placeholders})";
}
/** Freeze the largest in-window order ID and the initial progress estimate. */
function orderguard_import_keyset_boundary( $window, &$total, &$ok = null, $expected_mode = '', $types = null, $statuses = null ) {
global $wpdb;
$total = 0;
$ok = false;
$spec = orderguard_import_order_table_spec( $spec_ok, $expected_mode );
if ( ! $spec_ok ) {
return 0;
}
if ( null === $types || null === $statuses ) {
$scope = orderguard_import_query_scope( $scope_ok );
$types = $scope_ok ? $scope['types'] : array();
$statuses = $scope_ok ? $scope['statuses'] : array();
}
$scope_sql = orderguard_import_scope_sql( $spec, $types, $statuses, $scope_values, $scope_sql_ok );
if ( ! $scope_sql_ok ) {
return 0;
}
$cutoff = $spec['utc']
? gmdate( 'Y-m-d H:i:s', (int) $window )
: get_date_from_gmt( gmdate( 'Y-m-d H:i:s', (int) $window ), 'Y-m-d H:i:s' );
$sql = "SELECT COALESCE(MAX({$spec['id']}), 0) AS high_water_id, COUNT(*) AS total FROM {$spec['table']} WHERE {$scope_sql} AND {$spec['date']} > %s"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed WooCommerce/core identifiers and generated placeholders; all values are prepared.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql contains validated identifiers and placeholders; values are prepared below; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- validated identifiers and generated placeholders; all values supplied here; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound
$wpdb->prepare( "SELECT COALESCE(MAX({$spec['id']}), 0) AS high_water_id, COUNT(*) AS total FROM {$spec['table']} WHERE {$scope_sql} AND {$spec['date']} > %s", array_merge( $scope_values, array( $cutoff ) ) ),
ARRAY_A
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- bounded read of the authoritative order table.
if ( ! is_array( $row ) || '' !== (string) $wpdb->last_error ) {
return 0;
}
$total = max( 0, (int) $row['total'] );
$ok = true;
return max( 0, (int) $row['high_water_id'] );
}
/** Read one deletion-safe keyset page, plus one look-ahead ID. */
function orderguard_import_keyset_page( $window, $cursor, $high_water_id, &$has_more, &$ok = null, $expected_mode = '', $types = null, $statuses = null ) {
global $wpdb;
$has_more = false;
$ok = false;
$spec = orderguard_import_order_table_spec( $spec_ok, $expected_mode );
$cursor = max( 0, (int) $cursor );
$high_water_id = max( 0, (int) $high_water_id );
if ( ! $spec_ok ) {
return array();
}
if ( $cursor >= $high_water_id ) {
$ok = true;
return array();
}
if ( null === $types || null === $statuses ) {
$scope = orderguard_import_query_scope( $scope_ok );
$types = $scope_ok ? $scope['types'] : array();
$statuses = $scope_ok ? $scope['statuses'] : array();
}
$scope_sql = orderguard_import_scope_sql( $spec, $types, $statuses, $scope_values, $scope_sql_ok );
if ( ! $scope_sql_ok ) {
return array();
}
$cutoff = $spec['utc']
? gmdate( 'Y-m-d H:i:s', (int) $window )
: get_date_from_gmt( gmdate( 'Y-m-d H:i:s', (int) $window ), 'Y-m-d H:i:s' );
$batch_size = orderguard_import_batch_size( $batch_ok );
if ( ! $batch_ok ) {
return array();
}
$limit = $batch_size + 1;
$sql = "SELECT {$spec['id']} FROM {$spec['table']} WHERE {$scope_sql} AND {$spec['date']} > %s AND {$spec['id']} > %d AND {$spec['id']} <= %d ORDER BY {$spec['id']} ASC LIMIT %d"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed WooCommerce/core identifiers and generated placeholders; all values are prepared.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql contains validated identifiers and placeholders; values are prepared below; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$ids = $wpdb->get_col(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- validated identifiers and generated placeholders; all values supplied here; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; placeholders are generated to match the bound array, which the checker cannot count
$wpdb->prepare( "SELECT {$spec['id']} FROM {$spec['table']} WHERE {$scope_sql} AND {$spec['date']} > %s AND {$spec['id']} > %d AND {$spec['id']} <= %d ORDER BY {$spec['id']} ASC LIMIT %d", array_merge( $scope_values, array( $cutoff, $cursor, $high_water_id, $limit ) ) )
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- bounded keyset read of the authoritative order table.
if ( ! is_array( $ids ) || '' !== (string) $wpdb->last_error ) {
return array();
}
$ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
$has_more = count( $ids ) > $batch_size;
if ( $has_more ) {
$ids = array_slice( $ids, 0, $batch_size );
}
$ok = true;
return $ids;
}
/**
* Is an unreadable row still inside the exact cohort frozen for this import?
*
* A row may be deleted, trashed, or moved to another order type after the page
* query but before CRUD loads it. Those are safe skips. Only a row that still
* satisfies the frozen query is an integrity failure that must retain the page.
*/
function orderguard_import_order_row_exists( $order_id, &$ok = null, $expected_mode = '', $window = 0, $types = null, $statuses = null ) {
global $wpdb;
$ok = false;
$order_id = absint( $order_id );
$spec = orderguard_import_order_table_spec( $spec_ok, $expected_mode );
if ( ! $spec_ok || $order_id <= 0 ) {
return false;
}
if ( null === $types || null === $statuses ) {
$scope = orderguard_import_query_scope( $scope_ok );
$types = $scope_ok ? $scope['types'] : array();
$statuses = $scope_ok ? $scope['statuses'] : array();
}
$scope_sql = orderguard_import_scope_sql( $spec, $types, $statuses, $scope_values, $scope_sql_ok );
if ( ! $scope_sql_ok || (int) $window <= 0 ) {
return false;
}
$cutoff = gmdate( 'Y-m-d H:i:s', (int) $window );
$sql = "SELECT {$spec['id']} FROM {$spec['table']} WHERE {$spec['id']} = %d AND {$scope_sql} AND {$spec['date']} > %s LIMIT 1"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers and placeholder list are derived from the frozen validated scope; all values are prepared.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql contains validated identifiers and placeholders; values are prepared below; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- validated identifiers and generated placeholders; all values supplied here; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; placeholders are generated to match the bound array, which the checker cannot count
$wpdb->prepare( "SELECT {$spec['id']} FROM {$spec['table']} WHERE {$spec['id']} = %d AND {$scope_sql} AND {$spec['date']} > %s LIMIT 1", array_merge( array( $order_id ), $scope_values, array( $cutoff ) ) )
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- verifies whether an unreadable row still belongs to the frozen cohort.
if ( '' !== (string) $wpdb->last_error ) {
return false;
}
$ok = true;
return null !== $row;
}
/** Unique markers make import state transitions verifiable after a lost DB ack. */
function orderguard_new_import_state_token() {
return hash( 'sha256', wp_generate_uuid4() . '|' . microtime( true ) . '|' . wp_rand() );
}
/** Read the import state row directly, including the exact bytes needed by CAS. */
function orderguard_read_import_state_row( &$raw = null, &$found = null, &$ok = null ) {
global $wpdb;
$raw = null;
$found = false;
$ok = false;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's fixed core options table.
'orderguard_import_state'
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact bytes are required for concurrency-safe import transitions.
if ( '' !== (string) $wpdb->last_error ) {
return array();
}
$found = null !== $value;
if ( ! $found ) {
$ok = true;
return array();
}
if ( ! is_string( $value ) || strlen( $value ) > 128 * 1024 ) {
return array();
}
$state = maybe_unserialize( $value );
if ( ! is_array( $state ) ) {
return array();
}
$raw = $value;
$ok = true;
return $state;
}
/** Add a fresh import state exactly once under concurrent admin requests. */
function orderguard_insert_import_state( $state, &$already_exists = null ) {
global $wpdb;
$already_exists = false;
if ( ! is_array( $state ) || empty( $state['run_token'] ) ) {
return false;
}
if ( 'keyset-v1' === ( isset( $state['pagination'] ) ? $state['pagination'] : '' ) ) {
$state = orderguard_normalize_keyset_import_state( $state, $state_ok );
if ( ! $state_ok ) {
return false;
}
}
$serialised = maybe_serialize( $state );
if ( strlen( $serialised ) > 128 * 1024 ) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's fixed core options table.
'orderguard_import_state',
$serialised
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic first claimant for one import run.
orderguard_invalidate_option_caches( 'orderguard_import_state', false );
if ( 1 === (int) $written ) {
return true;
}
$current = orderguard_read_import_state_row( $raw, $found, $read_ok );
if ( $read_ok && $found && isset( $current['run_token'] )
&& hash_equals( (string) $state['run_token'], (string) $current['run_token'] ) ) {
// The insert committed but its acknowledgement was lost.
return true;
}
$already_exists = $read_ok && $found;
return false;
}
/** Produce the next exact state revision without mutating its run identity. */
function orderguard_next_import_state( $state ) {
if ( ! is_array( $state ) || empty( $state['run_token'] ) ) {
return false;
}
$state['revision'] = isset( $state['revision'] ) ? (int) $state['revision'] + 1 : 1;
$state['mutation_token'] = orderguard_new_import_state_token();
$state['updated_at'] = time();
return $state;
}
/** Compare-and-swap the complete import state row. */
function orderguard_compare_and_swap_import_state( $expected_raw, $replacement, &$conflict = null ) {
global $wpdb;
$conflict = false;
if ( ! is_string( $expected_raw ) || ! is_array( $replacement )
|| empty( $replacement['run_token'] ) || empty( $replacement['mutation_token'] ) ) {
return false;
}
if ( 'keyset-v1' === ( isset( $replacement['pagination'] ) ? $replacement['pagination'] : '' ) ) {
$replacement = orderguard_normalize_keyset_import_state( $replacement, $state_ok );
if ( ! $state_ok ) {
return false;
}
}
$serialised = maybe_serialize( $replacement );
if ( strlen( $serialised ) > 128 * 1024 ) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s, autoload = 'off' WHERE option_name = %s AND BINARY option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's fixed core options table.
$serialised,
'orderguard_import_state',
$expected_raw
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact-state ownership prevents stale import workers from advancing or finalising.
orderguard_invalidate_option_caches( 'orderguard_import_state', false );
if ( 1 === (int) $written ) {
return true;
}
$current = orderguard_read_import_state_row( $raw, $found, $read_ok );
if ( $read_ok && $found && $raw === $serialised ) {
// This exact mutation committed but its acknowledgement was lost.
return true;
}
if ( $read_ok && $found && $raw !== $expected_raw ) {
$conflict = true;
}
return false;
}
/**
* Auto-start the import once per store: the first time we see an activated store
* that hasn't imported yet. Fires right after activation (the welcome screen
* lands back in wp-admin) and also back-fills any store activated before this
* feature existed. Idempotent: the orderguard_import_state option means "already
* started", so it never runs twice.
*/
add_action( 'admin_init', 'orderguard_maybe_start_history_import' );
function orderguard_maybe_start_history_import() {
if ( ! orderguard_is_activated() ) {
return;
}
$state_ok = false;
$state = orderguard_get_option( 'orderguard_import_state', false, $state_ok );
if ( ! $state_ok ) {
return;
}
if ( false !== $state ) {
$valid = is_array( $state )
&& in_array( isset( $state['status'] ) ? $state['status'] : '', array( 'running', 'finalizing', 'done' ), true );
if ( $valid && 'keyset-v1' === ( isset( $state['pagination'] ) ? $state['pagination'] : '' ) ) {
orderguard_normalize_keyset_import_state( $state, $valid );
}
if ( ! $valid ) {
orderguard_log( 'OrderGuard: automatic order-history import blocked by malformed state; no restart was attempted.' );
orderguard_set_notice( 'error', __( 'OrderGuard could not read the saved import state. Nothing was restarted; please contact support.', 'orderguard' ) );
}
return; // already started (running or done)
}
if ( ! function_exists( 'as_enqueue_async_action' ) ) {
return; // Action Scheduler (bundled with WooCommerce) required
}
$blocking = null;
$blocking_name = '';
$confirm_ok = false;
if ( ! orderguard_option_absent_for_fresh_start( 'orderguard_import_state', $blocking, $blocking_name, $confirm_ok ) ) {
if ( $confirm_ok && null !== $blocking
&& ( ! is_array( $blocking ) || ! in_array( isset( $blocking['status'] ) ? $blocking['status'] : '', array( 'running', 'finalizing', 'done' ), true ) ) ) {
orderguard_log( 'OrderGuard: automatic order-history import blocked by malformed state in ' . $blocking_name . '; no restart was attempted.' );
orderguard_set_notice( 'error', __( 'OrderGuard could not read the saved import state. Nothing was restarted; please contact support.', 'orderguard' ) );
}
return;
}
orderguard_start_history_import();
}
/**
* Record state and queue the first batch.
*/
function orderguard_start_history_import() {
orderguard_import_batch_size( $batch_ok );
if ( ! $batch_ok ) {
orderguard_log( 'OrderGuard: automatic order-history import has an invalid batch size; no state was created.' );
orderguard_set_notice( 'error', __( 'OrderGuard could not start the order-history import because its batch size is invalid. Nothing was sent; please contact support.', 'orderguard' ) );
return false;
}
$now = time();
$window = $now - ORDERGUARD_IMPORT_MONTHS * MONTH_IN_SECONDS;
$spec = orderguard_import_order_table_spec( $spec_ok );
$scope = orderguard_import_query_scope( $scope_ok );
if ( ! $spec_ok || ! $scope_ok ) {
orderguard_log( 'OrderGuard: automatic order-history import could not freeze its storage/query scope; no state was created.' );
orderguard_set_notice( 'error', __( 'OrderGuard could not start the order-history import. Nothing was sent; please contact support.', 'orderguard' ) );
return false;
}
$total = 0;
$high_water_id = orderguard_import_keyset_boundary(
$window,
$total,
$boundary_ok,
$spec['mode'],
$scope['types'],
$scope['statuses']
);
if ( ! $boundary_ok ) {
orderguard_log( 'OrderGuard: automatic order-history import could not freeze its order boundary; no state was created.' );
orderguard_set_notice( 'error', __( 'OrderGuard could not start the order-history import. Nothing was sent; please contact support.', 'orderguard' ) );
return false;
}
$state = array(
'status' => 'running',
// `offset` remains the Action Scheduler argument for compatibility,
// but from keyset-v1 onward it is an order-ID cursor. `processed`
// remains the merchant-facing count.
'offset' => 0,
'cursor' => 0,
'processed' => 0,
'total' => $total,
'started' => $now,
'pagination' => 'keyset-v1',
'high_water_id' => $high_water_id,
'storage_mode' => $spec['mode'],
'order_types' => $scope['types'],
'order_statuses' => $scope['statuses'],
'run_token' => orderguard_new_import_state_token(),
'revision' => 1,
'mutation_token' => orderguard_new_import_state_token(),
'updated_at' => $now,
// The time window and largest eligible ID are immutable for this
// run. ID > cursor cannot shift when an earlier row disappears.
'window' => $window,
);
if ( ! orderguard_insert_import_state( $state, $already_exists ) ) {
if ( $already_exists ) {
// Another request won the atomic first-start claim. Its state and
// scheduler recovery are authoritative; do not fork a second run.
return true;
}
orderguard_log( 'OrderGuard: automatic order-history import could not persist its frozen boundary; no work was queued.' );
return false;
}
orderguard_delete_option( 'orderguard_import_summary' );
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
if ( orderguard_schedule_import_batch( 0, 0 ) ) {
orderguard_log( 'OrderGuard: automatic order-history import started' );
} else {
orderguard_log( 'OrderGuard: automatic order-history import started, but its first batch could not be queued; recovery will retry.' );
}
return true;
}
/**
* Prove that an import action for one exact offset already exists.
*
* A worker scheduling a same-offset retry must not count its own RUNNING
* action as a successor. Recovery callers opt into RUNNING because their job
* is to avoid forking a worker which is still making progress.
*/
function orderguard_import_batch_action_exists( $offset, $include_running = false, &$ok = null ) {
global $wpdb;
$ok = false;
if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
return false;
}
$statuses = $include_running ? array( 'pending', 'in-progress' ) : array( 'pending' );
try {
foreach ( $statuses as $status ) {
$wpdb->last_error = '';
$actions = as_get_scheduled_actions(
array(
'hook' => 'orderguard_import_batch',
'args' => array( (int) $offset ),
'group' => 'orderguard',
'status' => $status,
'per_page' => 1,
),
'ids'
);
if ( ! is_array( $actions ) || '' !== (string) $wpdb->last_error ) {
orderguard_log( 'OrderGuard: automatic order-history import scheduler query failed.' );
return false;
}
if ( ! empty( $actions ) ) {
$ok = true;
return true;
}
}
} catch ( Throwable $throwable ) {
orderguard_log( 'OrderGuard: automatic order-history import could not inspect its scheduled work.' );
return false;
}
$ok = true;
return false;
}
/** Remember when durable import state first had no proven successor. */
function orderguard_mark_import_schedule_missing() {
orderguard_update_option( 'orderguard_import_recovery_seen_at', time(), false );
}
/**
* Atomically claim one stalled-import recovery attempt.
*
* The import action itself must remain non-unique so a RUNNING worker can
* enqueue its own same-offset retry. Recovery therefore needs a separate
* compare-and-swap: without it, two page requests can both observe no action
* and fork the chain before either insert becomes visible to the other.
*/
function orderguard_claim_import_schedule_recovery( $expected_seen_at ) {
global $wpdb;
$expected_seen_at = (int) $expected_seen_at;
if ( $expected_seen_at <= 0 ) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$claimed = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s, autoload = 'off' WHERE option_name = %s AND BINARY option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's fixed core options table.
(string) time(),
'orderguard_import_recovery_seen_at',
(string) $expected_seen_at
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact-value recovery claim prevents two replacement chains.
if ( false === $claimed ) {
orderguard_log( 'OrderGuard: automatic order-history import recovery claim failed.' );
return false;
}
if ( 1 !== (int) $claimed ) {
return false;
}
orderguard_invalidate_option_caches( 'orderguard_import_recovery_seen_at' );
return true;
}
/**
* Queue one import batch (async, or after $delay seconds for a retry).
*
* Action Scheduler returns zero both for a duplicate unique action and for a
* storage failure. Zero is therefore not success unless a fresh PENDING query
* proves that the exact successor already exists. A RUNNING action is never
* such proof: for a same-offset retry it is the worker making this call.
*/
function orderguard_schedule_import_batch( $offset, $delay = 0 ) {
if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_enqueue_async_action' ) ) {
orderguard_mark_import_schedule_missing();
return false;
}
$args = array( (int) $offset ); // positional -> orderguard_run_import_batch( $offset )
if ( $delay > 0 ) {
$action_id = as_schedule_single_action( time() + (int) $delay, 'orderguard_import_batch', $args, 'orderguard' );
} else {
$action_id = as_enqueue_async_action( 'orderguard_import_batch', $args, 'orderguard' );
}
if ( false !== $action_id && (int) $action_id > 0 ) {
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
return true;
}
$query_ok = false;
$pending = orderguard_import_batch_action_exists( $offset, false, $query_ok );
if ( $query_ok && $pending ) {
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
return true;
}
orderguard_mark_import_schedule_missing();
orderguard_log( 'OrderGuard: automatic order-history import batch at offset ' . (int) $offset . ' could not be queued; recovery will retry.' );
return false;
}
/** Wake a source-paused import after a verified exclusion edit made the source
* readable again. Exact-state CAS prevents this from rewinding a live worker. */
function orderguard_resume_import_after_exclusion_change() {
if ( ! function_exists( 'orderguard_owned_exclusion_fresh_snapshot' ) ) {
return false;
}
$snapshot = orderguard_owned_exclusion_fresh_snapshot();
if ( 'ready' !== ( $snapshot['status'] ?? 'unknown' ) ) {
return false;
}
$state = orderguard_read_import_state_row( $raw, $found, $read_ok );
if ( ! $read_ok || ! $found || ! is_array( $state ) || empty( $state['last_defer_reason'] ) ) {
return false;
}
$state = orderguard_normalize_keyset_import_state( $state, $state_ok );
if ( ! $state_ok || ! in_array( $state['status'], array( 'running', 'finalizing' ), true ) ) {
return false;
}
$resumed = orderguard_next_import_state( $state );
if ( false === $resumed ) {
return false;
}
unset( $resumed['last_defer_reason'], $resumed['last_defer_at'], $resumed['defer_count'] );
if ( ! orderguard_compare_and_swap_import_state( $raw, $resumed, $conflict ) ) {
return false;
}
// The saved 24-hour backoff belongs to the paused state just replaced.
// Remove it before installing the one-minute successor so recovery has one
// authoritative pending action rather than two harmless but noisy wakes.
if ( function_exists( 'as_unschedule_all_actions' ) ) {
try {
as_unschedule_all_actions( 'orderguard_import_batch', array( (int) $resumed['cursor'] ), 'orderguard' );
} catch ( Throwable $throwable ) {
orderguard_log( 'OrderGuard: the previous paused-import retry could not be removed; the resumed state remains authoritative.' );
}
}
orderguard_schedule_import_batch( (int) $resumed['cursor'], MINUTE_IN_SECONDS );
return true;
}
add_action( 'orderguard_import_batch', 'orderguard_run_import_batch', 10, 1 );
/**
* Recover durable running import state whose Action Scheduler job disappeared.
*
* The observation marker provides a grace period for commit visibility and
* for a worker between persisting its next offset and enqueuing the successor.
* Exact PENDING/RUNNING checks prevent a second chain; an uncertain scheduler
* read fails closed rather than guessing that no action exists.
*/
function orderguard_recover_history_import_schedule() {
$state = orderguard_read_import_state_row( $raw, $found, $state_ok );
if ( ! $state_ok ) {
return;
}
if ( ! $found || ! in_array( isset( $state['status'] ) ? $state['status'] : '', array( 'running', 'finalizing' ), true ) ) {
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
return;
}
$offset = isset( $state['offset'] ) ? max( 0, (int) $state['offset'] ) : 0;
$query_ok = false;
$active = orderguard_import_batch_action_exists( $offset, true, $query_ok );
if ( ! $query_ok ) {
return;
}
if ( $active ) {
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
return;
}
if ( function_exists( 'orderguard_compat_has_unfinished_legacy_import_action' ) ) {
try {
$legacy_query_ok = false;
$legacy_active = orderguard_compat_has_unfinished_legacy_import_action( $legacy_query_ok );
if ( ! $legacy_query_ok ) {
return;
}
if ( $legacy_active ) {
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
return;
}
} catch ( Throwable $throwable ) {
// An uncertain legacy-queue read is not proof that recovery is safe.
return;
}
}
$seen_ok = false;
$seen_at = (int) orderguard_get_option( 'orderguard_import_recovery_seen_at', 0, $seen_ok );
if ( ! $seen_ok ) {
return;
}
if ( $seen_at <= 0 ) {
orderguard_mark_import_schedule_missing();
return;
}
if ( time() - $seen_at < 5 * MINUTE_IN_SECONDS ) {
return;
}
if ( ! orderguard_claim_import_schedule_recovery( $seen_at ) ) {
return;
}
// schedule_import_batch() refreshes the marker on failure and removes it
// only after a positive action ID or an exact PENDING successor is proven.
orderguard_schedule_import_batch( $offset, 0 );
}
add_action( 'action_scheduler_init', 'orderguard_recover_history_import_schedule', 20 );
/** Finish a terminal import only after its state is durably `finalizing`. */
function orderguard_finalize_history_import_state( $state, $expected_raw ) {
$summary = orderguard_import_fetch_recognition( $summary_ok );
$cursor = isset( $state['cursor'] ) ? max( 0, (int) $state['cursor'] ) : 0;
if ( ! $summary_ok ) {
orderguard_schedule_import_batch( $cursor, 5 * MINUTE_IN_SECONDS );
return false;
}
$summary['scanned'] = isset( $state['processed'] ) ? max( 0, (int) $state['processed'] ) : 0;
$summary_written = orderguard_update_option( 'orderguard_import_summary', $summary, false );
if ( ! $summary_written ) {
$saved_ok = false;
$saved = orderguard_get_option( 'orderguard_import_summary', array(), $saved_ok );
if ( ! $saved_ok || $saved !== $summary ) {
orderguard_schedule_import_batch( $cursor, 5 * MINUTE_IN_SECONDS );
return false;
}
}
$done = orderguard_next_import_state( $state );
if ( false === $done ) {
orderguard_schedule_import_batch( $cursor, 5 * MINUTE_IN_SECONDS );
return false;
}
$done['status'] = 'done';
$done['finished'] = time();
if ( ! orderguard_compare_and_swap_import_state( $expected_raw, $done, $conflict ) ) {
// A competing finalizer or newer state is authoritative. Never write
// over it and never fork work from this stale copy.
return false;
}
orderguard_delete_option( 'orderguard_import_recovery_seen_at' );
orderguard_clear_plan_cache();
orderguard_log( 'OrderGuard: automatic order-history import complete (' . $done['processed'] . ' scanned)' );
return true;
}
/**
* Process one batch, advance state, and queue the next -- or finalise.
*/
function orderguard_run_import_batch( $offset = 0 ) {
if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) {
orderguard_owned_exclusion_reset_request_snapshot();
}
if ( ! orderguard_is_activated() ) {
return;
}
$state = orderguard_read_import_state_row( $state_raw, $state_found, $state_ok );
if ( ! $state_ok || ! $state_found || ! is_array( $state ) ) {
return;
}
$status = isset( $state['status'] ) ? (string) $state['status'] : '';
if ( 'done' === $status ) {
return; // finished or cleared -- stop the chain
}
if ( ! in_array( $status, array( 'running', 'finalizing' ), true ) ) {
return;
}
$keyset_declared = 'keyset-v1' === ( isset( $state['pagination'] ) ? $state['pagination'] : '' );
$normalised = $keyset_declared
? orderguard_normalize_keyset_import_state( $state, $keyset_ok )
: array();
if ( $keyset_declared && ! $keyset_ok ) {
orderguard_log( 'OrderGuard: automatic order-history import is blocked by malformed keyset state.' );
return;
}
$keyset_state = $keyset_declared && $keyset_ok;
if ( $keyset_state ) {
$state = $normalised;
}
$expected_cursor = $keyset_state
? max( 0, (int) $state['cursor'] )
: max( 0, (int) ( isset( $state['offset'] ) ? $state['offset'] : 0 ) );
if ( (int) $offset !== $expected_cursor ) {
return; // a newer sequential batch already advanced the chain
}
if ( 'finalizing' === $status ) {
if ( ! $keyset_state ) {
orderguard_log( 'OrderGuard: automatic order-history import finalization is blocked by malformed state.' );
return;
}
orderguard_finalize_history_import_state( $state, $state_raw );
return;
}
if ( ! $keyset_state ) {
// A row offset from an older build cannot be translated safely after
// earlier rows have disappeared. Restart at ID zero under a boundary
// frozen now. The server deduplicates by order_ref, so replay is safe;
// guessing an ID cursor would lose history.
$anchor = ! empty( $state['started'] ) ? (int) $state['started'] : time();
$window = ! empty( $state['window'] )
? (int) $state['window']
: $anchor - ORDERGUARD_IMPORT_MONTHS * MONTH_IN_SECONDS;
$spec = orderguard_import_order_table_spec( $spec_ok );
$scope = orderguard_import_query_scope( $scope_ok );
$total = 0;
$high_water_id = ( $spec_ok && $scope_ok )
? orderguard_import_keyset_boundary(
$window,
$total,
$boundary_ok,
$spec['mode'],
$scope['types'],
$scope['statuses']
)
: 0;
if ( ! $spec_ok || ! $scope_ok || ! $boundary_ok ) {
orderguard_schedule_import_batch( $expected_cursor, 5 * MINUTE_IN_SECONDS );
return;
}
$converted = $state;
$converted['pagination'] = 'keyset-v1';
$converted['high_water_id'] = $high_water_id;
$converted['storage_mode'] = $spec['mode'];
$converted['order_types'] = $scope['types'];
$converted['order_statuses'] = $scope['statuses'];
$converted['cursor'] = 0;
$converted['offset'] = 0;
$converted['processed'] = 0;
$converted['total'] = $total;
$converted['window'] = $window;
$converted['run_token'] = orderguard_new_import_state_token();
$converted['revision'] = 1;
$converted['mutation_token'] = orderguard_new_import_state_token();
$converted['updated_at'] = time();
if ( ! orderguard_compare_and_swap_import_state( $state_raw, $converted, $conflict ) ) {
return;
}
orderguard_log( 'OrderGuard: automatic order-history import safely restarted under keyset pagination.' );
orderguard_schedule_import_batch( 0, 0 );
return;
}
orderguard_import_batch_size( $batch_ok );
orderguard_import_order_table_spec( $mode_ok, (string) $state['storage_mode'] );
if ( ! $batch_ok || ! $mode_ok ) {
orderguard_log( 'OrderGuard: automatic order-history import paused because its frozen storage/query scope is unavailable.' );
orderguard_schedule_import_batch( $expected_cursor, HOUR_IN_SECONDS );
return;
}
$res = orderguard_import_process_batch( (int) $offset, $state );
// Server unreachable / transient error -> retry the SAME offset shortly, so a
// launch-phase outage just pauses the import instead of skipping orders.
if ( 'failed' === $res['status'] ) {
$retry_delay = isset( $res['retry_delay'] ) ? max( 60, (int) $res['retry_delay'] ) : 5 * MINUTE_IN_SECONDS;
if ( ! empty( $res['defer_reason'] ) ) {
$paused = orderguard_next_import_state( $state );
if ( false === $paused ) {
return;
}
$paused['last_defer_reason'] = sanitize_key( $res['defer_reason'] );
$paused['last_defer_at'] = time();
$paused['defer_count'] = isset( $state['defer_count'] ) ? min( 1000000, (int) $state['defer_count'] + 1 ) : 1;
$backoff_hours = array( 1, 2, 4, 8, 16, 24 );
$hours = $backoff_hours[ min( count( $backoff_hours ) - 1, $paused['defer_count'] - 1 ) ];
$retry_delay = max( $retry_delay, $hours * HOUR_IN_SECONDS );
if ( ! orderguard_compare_and_swap_import_state( $state_raw, $paused, $conflict ) ) {
return;
}
} elseif ( ! empty( $res['source_read'] ) && ! empty( $state['last_defer_reason'] ) ) {
// The exclusion source recovered; do not keep blaming it when the
// retained page is now waiting on an unrelated server failure.
$recovered = orderguard_next_import_state( $state );
if ( false === $recovered ) {
return;
}
unset( $recovered['last_defer_reason'], $recovered['last_defer_at'], $recovered['defer_count'] );
if ( ! orderguard_compare_and_swap_import_state( $state_raw, $recovered, $conflict ) ) {
return;
}
}
orderguard_schedule_import_batch( (int) $offset, $retry_delay );
return;
}
$next = orderguard_next_import_state( $state );
if ( false === $next ) {
orderguard_schedule_import_batch( (int) $offset, 5 * MINUTE_IN_SECONDS );
return;
}
$next['processed'] = $res['processed'];
$next['total'] = $res['total'];
$next['offset'] = $res['next_offset'];
$next['cursor'] = $res['next_offset'];
unset( $next['last_defer_reason'], $next['last_defer_at'], $next['defer_count'] );
if ( $res['done'] ) {
$next['status'] = 'finalizing';
if ( ! orderguard_compare_and_swap_import_state( $state_raw, $next, $conflict ) ) {
return;
}
// Durable state precedes the read-once recognition endpoint. A crash
// anywhere below resumes finalization instead of replaying the page.
orderguard_mark_import_schedule_missing();
orderguard_finalize_history_import_state( $next, maybe_serialize( $next ) );
} else {
if ( ! orderguard_compare_and_swap_import_state( $state_raw, $next, $conflict ) ) {
return;
}
orderguard_schedule_import_batch( (int) $res['next_offset'], 0 );
}
}
/**
* Crawl + post ONE batch of COD orders. Returns:
* status: 'sent' | 'empty' | 'failed' (failed = server unreachable; retry)
* processed, total, next_offset, done
*/
function orderguard_import_gdpr_deferred_result( $offset, $total, $processed = null, $reason = '', $retry_delay = null ) {
$result = array(
'status' => 'failed',
'processed' => null === $processed ? (int) $offset : max( 0, (int) $processed ),
'total' => max( 0, (int) $total ),
'next_offset' => (int) $offset,
'done' => false,
'retry_delay' => null === $retry_delay ? 5 * MINUTE_IN_SECONDS : max( 60, (int) $retry_delay ),
);
if ( is_string( $reason ) && '' !== $reason ) {
$result['defer_reason'] = sanitize_key( $reason );
}
return $result;
}
/**
* Accept an import response only when it proves the complete page was handled.
*
* Authentication, endpoint, validation and proxy failures all replay the same
* cursor. The endpoint has no row-level error contract, so guessing which row
* to skip would silently lose history.
*/
function orderguard_import_history_response_status( $response, $expected_rows, &$retry_delay ) {
$retry_delay = 5 * MINUTE_IN_SECONDS;
if ( is_wp_error( $response ) ) {
return 'failed';
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
if ( 429 === $code ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
if ( $retry_after > 0 ) {
$retry_delay = min( DAY_IN_SECONDS, max( 5 * MINUTE_IN_SECONDS, $retry_after ) );
}
} elseif ( $code >= 400 && $code < 500 ) {
$retry_delay = HOUR_IN_SECONDS;
}
orderguard_log( 'OrderGuard: automatic order-history import retained its page after HTTP ' . $code . '.' );
return 'failed';
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body )
|| ! array_key_exists( 'orders_imported', $body )
|| ! array_key_exists( 'orders_skipped', $body )
|| ! is_int( $body['orders_imported'] )
|| ! is_int( $body['orders_skipped'] )
|| $body['orders_imported'] < 0
|| $body['orders_skipped'] < 0
|| (int) $expected_rows !== $body['orders_imported'] + $body['orders_skipped'] ) {
$retry_delay = HOUR_IN_SECONDS;
orderguard_log( 'OrderGuard: automatic order-history import retained its page after an invalid success response.' );
return 'failed';
}
return 'sent';
}
/** Map the owned three-state source to an import page action. */
function orderguard_import_exclusion_action_for_hashes( $hashes, $snapshot = null ) {
$state = null === $snapshot
? orderguard_owned_exclusion_hashes_state( $hashes )
: orderguard_owned_exclusion_snapshot_hashes_state( $hashes, $snapshot );
$status = $state['status'] ?? 'unknown';
if ( 'unknown' === $status && 'identity_unavailable' === ( $state['reason'] ?? '' ) ) {
return 'skip';
}
return 'not_excluded' === $status ? 'include' : ( 'excluded' === $status ? 'skip' : 'defer' );
}
function orderguard_import_exclusion_action_for_order( $order, $snapshot = null ) {
$hashes = orderguard_owned_exclusion_order_aliases( $order );
if ( null === $hashes || empty( $hashes ) ) {
return 'skip';
}
return orderguard_import_exclusion_action_for_hashes( $hashes, $snapshot );
}
function orderguard_import_process_batch( $offset, $provided_state = null ) {
// Page only against state whose complete boundary and query scope were
// frozen before the first send. A damaged supplied state must never fall
// back to today's storage mode or scope: that would silently change which
// identities this run is allowed to upload.
if ( null !== $provided_state ) {
$state = orderguard_normalize_keyset_import_state( $provided_state, $state_ok );
} else {
$state = orderguard_read_import_state_row( $state_raw, $state_found, $state_ok );
$state_ok = $state_ok && $state_found;
if ( $state_ok ) {
$state = orderguard_normalize_keyset_import_state( $state, $state_ok );
}
}
if ( ! $state_ok ) {
return array(
'status' => 'failed',
'processed' => (int) $offset,
'total' => 0,
'next_offset' => (int) $offset,
'done' => false,
);
}
$window = (int) $state['window'];
$processed_base = isset( $state['processed'] )
? max( 0, (int) $state['processed'] )
: max( 0, (int) $offset );
$total = array_key_exists( 'total', $state ) ? max( 0, (int) $state['total'] ) : 0;
$high_water_id = (int) $state['high_water_id'];
$mode = (string) $state['storage_mode'];
$scope = array(
'types' => $state['order_types'],
'statuses' => $state['order_statuses'],
);
orderguard_import_batch_size( $batch_ok );
if ( ! $batch_ok ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
// A discovery/PREPARE pause is temporary. Treat it exactly like a
// transport outage so the import retries this same offset; filtering all
// rows and advancing would permanently skip an otherwise valid page.
$pause_ok = false;
if ( orderguard_gdpr_erasure_pauses_all_identity_transports( null, $pause_ok ) || ! $pause_ok ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
$ids = orderguard_import_keyset_page(
$window,
(int) $offset,
$high_water_id,
$has_more,
$page_ok,
$mode,
$scope['types'],
$scope['statuses']
);
if ( ! $page_ok ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
// Let WooCommerce and extensions apply the same order-query filters the
// original importer honoured. SQL above chooses only a deletion-safe,
// bounded candidate page; this query may narrow it, never expand it.
try {
$queried_orders = empty( $ids ) ? array() : wc_get_orders(
array(
// WP_Query understands post__in directly, and HPOS remaps it
// to its native id predicate. WC_Order_Query has no `include`
// query var; using it silently returns unrelated orders.
'post__in' => $ids,
'limit' => -1,
'return' => 'objects',
'type' => $scope['types'],
'status' => $scope['statuses'],
'date_created' => '>' . $window,
'orderby' => 'ID',
'order' => 'ASC',
)
);
} catch ( Throwable $throwable ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
if ( ! is_array( $queried_orders ) ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
$queried_by_id = array();
foreach ( $queried_orders as $queried_order ) {
if ( ! $queried_order instanceof WC_Abstract_Order ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
$queried_id = absint( $queried_order->get_id() );
if ( $queried_id <= 0 || ! in_array( $queried_id, $ids, true ) ) {
// An extension may narrow this page, but may never inject a new ID.
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
$queried_by_id[ $queried_id ] = $queried_order;
}
$orders = array();
foreach ( $ids as $order_id ) {
if ( isset( $queried_by_id[ $order_id ] ) ) {
// Preserve the old loop's actual-object rule: refunds and other
// WC_Abstract_Order siblings are intentionally skipped.
if ( $queried_by_id[ $order_id ] instanceof WC_Order ) {
$orders[] = $queried_by_id[ $order_id ];
}
continue;
}
// Absence may be an extension's intentional query exclusion. Prove the
// row is independently loadable before treating it as that safe skip.
try {
$order = wc_get_order( $order_id );
} catch ( Throwable $throwable ) {
$order = false;
}
if ( $order instanceof WC_Abstract_Order ) {
continue;
}
$row_exists = orderguard_import_order_row_exists(
$order_id,
$row_read_ok,
$mode,
$window,
$scope['types'],
$scope['statuses']
);
if ( ! $row_read_ok || $row_exists ) {
orderguard_log( 'OrderGuard: automatic order-history import could not load order #' . absint( $order_id ) . '; the page was retained.' );
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
// The authoritative row left the frozen cohort after this page was
// read. Advancing over that now-ineligible ID cannot lose an order.
orderguard_log( 'OrderGuard: automatic order-history import observed one concurrently ineligible order and continued.' );
}
// Capture once for the page. Every row below is a pure in-memory match;
// the send boundary takes one more fresh snapshot under the erasure lock.
$exclusion_snapshot = orderguard_owned_exclusion_fresh_snapshot();
if ( 'ready' !== ( $exclusion_snapshot['status'] ?? 'unknown' ) ) {
$reason = $exclusion_snapshot['reason'] ?? 'exclusion_source_unavailable';
orderguard_log( 'OrderGuard: automatic order-history import retained its page because the exclusion list could not be read (' . sanitize_key( $reason ) . ').' );
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base, $reason, HOUR_IN_SECONDS );
}
// Build the batch payload: ALL orders with an identity, hashed locally --
// matching the live flow, which checks in every order (not just COD). Returns
// and completions come from any order; the server derives the outcome from
// status/refund. (Non-collection is COD-only and only ever set by the manual
// action, so non-COD orders simply never carry that signal.)
$payload = array();
foreach ( $orders as $order ) {
if ( ! $order instanceof WC_Order ) {
continue;
}
$hashed = orderguard_hash_identity( orderguard_read_identity_from_order( $order ) );
if ( empty( $hashed['phone'] ) && empty( $hashed['email'] ) ) {
continue;
}
// EXCLUSION GUARD -- history import is the bulk path, so an unguarded
// import would re-upload every historical test order in one go and
// undo the erase the merchant just performed.
$identity_hashes = orderguard_owned_exclusion_order_aliases( $order );
if ( null === $identity_hashes || empty( $identity_hashes ) ) {
// A single malformed/imported identity cannot stall the store-wide
// page. It has no safe identity-bearing row to send, so skip it.
continue;
}
$exclusion_action = orderguard_import_exclusion_action_for_hashes( $identity_hashes, $exclusion_snapshot );
if ( 'defer' === $exclusion_action ) {
// An unreadable source is a page-level pause, never permission and
// never a reason to advance past this historical order.
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
if ( 'skip' === $exclusion_action ) {
continue;
}
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( 'suppress' === $privacy_disposition ) {
continue;
}
if ( 'allow' !== $privacy_disposition ) {
// Scope discovery began after the page-level check. Do not turn a
// temporary global pause into per-row exclusions and then advance
// this offset with a partial payload; replay the identical page.
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
// M-5 -- an order OrderGuard itself rejected at checkout (still in its
// aborted shape) is not history, it is an artifact of the rejection.
// Importing it would seed the network with a row for an order that
// never existed commercially.
if ( orderguard_is_checkout_rejected_dormant( $order ) ) {
continue;
}
// #23 -- do not import an order whose only reportable fact is a
// status that carries no usable outcome. Two distinct reasons, worth
// keeping apart:
// 'pending'/'failed' map to NO signal, so the sighting stays at
// outcome='pending' and the hourly sweep re-reports it through
// /v1/pending forever. This is the bug (#23).
// 'cancelled' maps to 'unknown', which IS a resolution -- so those
// rows never loop. They are skipped because scoring ignores
// 'unknown' before counting, so the row can never affect a score,
// an order count or months_active. Inert either way; don't write it.
// Same test the sweep's 404-recovery guard uses, and it fails open:
// a refund, a COD non-collection mark or a courier-verified outcome
// all make it false, so a dead-status order carrying real evidence is
// still imported. If one is later rescued by hand (paid by phone, set
// to processing), the live status-change path reports it and
// 404-recovery rebuilds the record -- skipping it here loses nothing.
if ( orderguard_order_is_signalless_dead( $order ) ) {
continue;
}
$created = $order->get_date_created();
// H-2 dual-send, same strict rule as orderguard_send_to_server: import CREATES
// network identity, so the customer's own billing country is the only
// acceptable key and doubt means decline (null).
$e164_hash = orderguard_phone_hash_e164( $order->get_billing_phone(), $order->get_billing_country() );
$payload[] = array(
'order_ref' => (string) $order->get_id(),
'phone_hash' => isset( $hashed['phone'] ) ? $hashed['phone'] : null,
'email_hash' => isset( $hashed['email'] ) ? $hashed['email'] : null,
'phone_hash_e164' => ( '' !== $e164_hash ) ? $e164_hash : null,
'status' => (string) $order->get_status(),
'refunded_fraction' => round( orderguard_compute_return_fraction( $order ), 4 ),
'not_collected' => ( 'yes' === orderguard_get_order_meta( $order, '_orderguard_not_collected' ) ),
// Absolute Woo creation instant in explicit UTC. The server uses
// this immutable boundary to distinguish pre-erasure history from
// a genuinely later transaction by the same customer.
'created_at' => $created ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $created->getTimestamp() ) : null,
'country' => $order->get_billing_country(),
// Local-only aliases for the final exclusion recheck. This carries
// name and every phone spelling; it is removed before JSON.
'_orderguard_identity_hashes' => $identity_hashes,
);
}
$status = 'empty';
$source_read = false;
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
try {
// The page can take longer to build than the erasure discovery window.
// Re-check while holding the same mutex used to accept a request. This
// makes the final filter and the socket one indivisible privacy step.
$pause_ok = false;
if ( orderguard_gdpr_erasure_pauses_all_identity_transports( null, $pause_ok ) || ! $pause_ok ) {
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
$locked_exclusion_snapshot = orderguard_owned_exclusion_fresh_snapshot();
if ( 'ready' !== ( $locked_exclusion_snapshot['status'] ?? 'unknown' ) ) {
$reason = $locked_exclusion_snapshot['reason'] ?? 'exclusion_source_unavailable';
orderguard_log( 'OrderGuard: automatic order-history import retained its page because the final exclusion check could not be read (' . sanitize_key( $reason ) . ').' );
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base, $reason, HOUR_IN_SECONDS );
}
$source_read = true;
$sendable_payload = array();
foreach ( $payload as $row ) {
if ( ! is_array( $row ) || ! isset( $row['order_ref'] ) ) {
continue;
}
$identity_hashes = isset( $row['_orderguard_identity_hashes'] ) && is_array( $row['_orderguard_identity_hashes'] )
? $row['_orderguard_identity_hashes']
: array();
$order_ref = absint( $row['order_ref'] );
if ( $order_ref <= 0 || empty( $identity_hashes ) ) {
continue;
}
$exclusion_action = orderguard_import_exclusion_action_for_hashes( $identity_hashes, $locked_exclusion_snapshot );
if ( 'defer' === $exclusion_action ) {
// Preserve this exact page for retry. Advancing after a source
// failure could permanently omit historical orders.
return orderguard_import_gdpr_deferred_result( $offset, $total, $processed_base );
}
if ( 'skip' === $exclusion_action ) {
continue;
}
$created_at = ! empty( $row['created_at'] ) ? strtotime( (string) $row['created_at'] ) : 0;
if ( orderguard_is_gdpr_erased_order_ref( $order_ref )
|| orderguard_is_gdpr_erased_hash(
$identity_hashes,
false === $created_at ? 0 : (int) $created_at,
$order_ref
) ) {
continue;
}
unset( $row['_orderguard_identity_hashes'] );
$sendable_payload[] = $row;
}
$payload = $sendable_payload;
if ( ! empty( $payload ) ) {
$resp = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/import/history', array(
'method' => 'POST',
'timeout' => 20,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( array( 'orders' => $payload ) ),
) );
$status = orderguard_import_history_response_status( $resp, count( $payload ), $retry_delay );
}
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
$count = count( $ids );
$processed = $processed_base + $count;
$next_cursor = empty( $ids ) ? $high_water_id : max( $ids );
$done = ( 'failed' !== $status ) && ! $has_more;
if ( $done ) {
// Deletion can shrink the frozen cohort. Finish with a truthful
// denominator instead of leaving the progress display at 99%.
$total = $processed;
} else {
$total = max( $total, $processed );
}
$result = array(
'status' => $status,
'processed' => $processed,
'total' => $total,
'next_offset' => $next_cursor,
'done' => $done,
);
if ( 'failed' === $status ) {
$result['processed'] = $processed_base;
$result['next_offset'] = (int) $offset;
$result['done'] = false;
$result['retry_delay'] = isset( $retry_delay ) ? $retry_delay : 5 * MINUTE_IN_SECONDS;
$result['source_read'] = $source_read;
}
return $result;
}
/**
* Read-only import status for the Account panel's live display (no action --
* it just reflects the background job so the page can show progress/results).
*/
add_action( 'wp_ajax_orderguard_import_status', 'orderguard_ajax_import_status' );
function orderguard_import_pause_message( $reason ) {
if ( 'exclusion_source_too_large' === (string) $reason ) {
return __( 'Paused: your exclusion list is over the safe limit. Remove older exclusions; the history review and hourly sweep will resume automatically.', 'orderguard' );
}
return __( 'Paused: your exclusion list cannot be read. The history review and hourly sweep will retry automatically.', 'orderguard' );
}
function orderguard_ajax_import_status() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array(), 403 );
}
if ( ! check_ajax_referer( 'orderguard_import_status', 'nonce', false ) ) {
wp_send_json_error( array(), 403 );
}
$state = orderguard_get_option( 'orderguard_import_state', array() );
$imp = orderguard_get_option( 'orderguard_import_summary', array() );
wp_send_json_success( array(
'status' => ( is_array( $state ) && isset( $state['status'] ) ) ? $state['status'] : 'idle',
'processed' => ( is_array( $state ) && isset( $state['processed'] ) ) ? (int) $state['processed'] : 0,
'total' => ( is_array( $state ) && isset( $state['total'] ) ) ? (int) $state['total'] : 0,
'customers' => ( is_array( $imp ) && isset( $imp['customers'] ) ) ? (int) $imp['customers'] : 0,
'recognized' => ( is_array( $imp ) && array_key_exists( 'recognized', $imp ) && null !== $imp['recognized'] ) ? (int) $imp['recognized'] : null,
'paused' => is_array( $state ) && ! empty( $state['last_defer_reason'] ),
'pause_message' => is_array( $state ) && ! empty( $state['last_defer_reason'] )
? orderguard_import_pause_message( $state['last_defer_reason'] )
: '',
'done' => ( is_array( $state )
&& isset( $state['status'] )
&& 'done' === $state['status']
&& is_array( $imp )
&& ! empty( $imp['ts'] ) ),
) );
}
/**
* Fetch the aggregate network-coverage summary after an import run.
* Returns array( customers, recognized|null, ts ). 'recognized' is null when the
* server withholds it (too few imported customers to report safely). Coverage
* only -- no per-customer risk figure is exposed, by design (anti-enumeration).
*/
function orderguard_import_fetch_recognition( &$ok = null ) {
$ok = false;
$out = array( 'customers' => 0, 'recognized' => null, 'ts' => time() );
$resp = wp_remote_get( ORDERGUARD_SERVER_URL . '/v1/import/recognition', array(
'timeout' => 20,
'headers' => array( 'X-OrderGuard-Key' => orderguard_get_api_key() ),
) );
if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
return $out;
}
$d = json_decode( wp_remote_retrieve_body( $resp ), true );
if ( ! is_array( $d )
|| ! array_key_exists( 'customers', $d )
|| ! array_key_exists( 'recognized', $d )
|| ! is_int( $d['customers'] )
|| $d['customers'] < 0
|| ( null !== $d['recognized'] && ( ! is_int( $d['recognized'] ) || $d['recognized'] < 0 ) ) ) {
orderguard_log( 'OrderGuard: automatic order-history import retained finalization after an invalid recognition response.' );
return $out;
}
$out['customers'] = $d['customers'];
$out['recognized'] = $d['recognized'];
$ok = true;
return $out;
}
/**
* Handle the COD checkout-protection settings form.
*/
add_action( 'admin_init', 'orderguard_handle_cod_settings_submit' );
function orderguard_handle_cod_settings_submit() {
if ( ! isset( $_POST['orderguard_cod_settings_submit'] ) ) {
return;
}
if ( ! isset( $_POST['orderguard_cod_settings_nonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['orderguard_cod_settings_nonce'] ) ), 'orderguard_cod_settings' ) ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
// Master switch (checkbox present => 'yes').
orderguard_update_option( 'orderguard_cod_gating', isset( $_POST['orderguard_cod_gating'] ) ? 'yes' : 'no' );
// EXTENSION POINT (free/Pro split, #38). Custom thresholds are a Pro feature
// and their save handler lives in the orderguard-pro add-on, hooked here.
// Fires AFTER the nonce + capability checks above, so a hooked add-on
// inherits both. Context arg tells the add-on which settings form posted.
// The free plugin itself persists nothing beyond the master switch: its
// thresholds are the fixed defaults (see orderguard_cod_notcollected_threshold()).
do_action( 'orderguard_features_save', 'cod_settings' );
if ( function_exists( 'orderguard_set_notice' ) ) {
orderguard_set_notice( 'success', __( 'COD protection settings saved.', 'orderguard' ) );
}
}
/**
* ---------------------------------------------------------------------------
* COD "not collected" action (admin-post.php, NOT a nested form).
* ---------------------------------------------------------------------------
*/
add_action( 'admin_post_orderguard_notcollected', 'orderguard_handle_not_collected_action' );
function orderguard_handle_not_collected_action() {
$order_id = isset( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
$do = isset( $_GET['do'] ) ? sanitize_text_field( wp_unslash( $_GET['do'] ) ) : '';
if ( ! isset( $_GET['_wpnonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_notcollected_' . $order_id ) ) {
wp_die( esc_html__( 'Security check failed.', 'orderguard' ) );
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Insufficient permissions.' );
}
$order = wc_get_order( $order_id );
orderguard_apply_not_collected_intent( $order, $do );
$redirect = $order ? $order->get_edit_order_url() : admin_url();
wp_safe_redirect( $redirect );
exit;
}
/** Persist a merchant's COD decision without losing it behind another erasure. */
function orderguard_apply_not_collected_intent( $order, $do ) {
if ( ! orderguard_is_activated()
|| ! $order instanceof WC_Order
|| ! orderguard_is_cod_order( $order ) ) {
return false;
}
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( in_array( $privacy_disposition, array( 'suppress', 'block' ), true ) ) {
return false;
}
$undo = 'undo' === (string) $do;
$event = $undo ? 'cod_non_collection_reverted' : 'cod_non_collection_confirmed';
// Local intent is the durable source for sweep/deferred replay. Store it
// before the network attempt so another customer's discovery pause cannot
// turn a confirmed merchant click into a silent no-op.
$order->update_meta_data( '_orderguard_not_collected', $undo ? 'no' : 'yes' );
$order->save();
$data = orderguard_send_event(
array(
'order_ref' => (string) $order->get_id(),
'event' => $event,
)
);
if ( is_array( $data ) && isset( $data['scores'] ) ) {
orderguard_save_scores( $order, $data['scores'] );
}
return true;
}
function orderguard_is_cod_order( $order ) {
return 'cod' === $order->get_payment_method();
}
/**
* ===========================================================================
* COD CHECKOUT-GATING -- silently hide Cash on Delivery for risky customers
* ===========================================================================
* When a customer at checkout has a CONFIDENT, high collection-risk score, we
* remove COD from the offered payment methods. No note, no flag to the customer
* -- they simply see the other (prepaid) methods. Everything fails OPEN: any
* doubt, missing identity, low confidence, or server hiccup => COD stays.
*
* This is automated processing that affects a person, so it is OFF BY DEFAULT
* behind the orderguard_cod_gating_enabled() flag. A merchant has to switch it on
* deliberately, per store, having decided it suits their business.
*/
/**
* Master switch. OFF until a merchant explicitly enables it. Structured so the
* settings UI / Pro logic can later drive this from a stored option.
*/
function orderguard_cod_gating_enabled() {
// Read the saved master switch (default OFF). The filter still overrides
// for dev/testing.
$enabled = ( 'yes' === orderguard_get_option( 'orderguard_cod_gating', 'no' ) );
return (bool) apply_filters( 'orderguard_cod_gating_enabled', $enabled );
}
/**
* COD is hidden when a customer reaches EITHER of these raw counts (OR logic):
* - non-collected orders >= orderguard_cod_notcollected_threshold() (default 5)
* - returned orders >= orderguard_cod_returned_threshold() (default 12)
*
* Raw counts, not scores -- a merchant reads "5 non-collections" far more
* clearly than "collection-risk 70". Free merchants get these defaults; a Pro
* merchant can override each via the filters (the settings-UI sliders feed
* them). Non-collections trigger sooner than returns: a non-collection is a
* total loss (shipped, never paid), a return still got paid then came back.
*/
function orderguard_cod_notcollected_threshold() {
// FIXED default on the free plugin. The filter is the extension point the
// orderguard-pro add-on uses to feed in a merchant-set value (free/Pro
// split, #38) -- the free plugin neither stores nor reads a custom value.
// Either way these are advisory: /v1/cod-decision receives them but the
// SERVER, knowing the account's plan, is the authority on whether a custom
// value is honoured (a Free account always gets the server defaults).
return (int) apply_filters( 'orderguard_cod_notcollected_threshold', 5 );
}
function orderguard_cod_returned_threshold() {
return (int) apply_filters( 'orderguard_cod_returned_threshold', 12 );
}
/**
* ===========================================================================
* FLAG FOR REVIEW -- the soft, merchant-only "worth a second look" tier.
* ===========================================================================
* Unlike COD gating, flagging changes NOTHING the customer sees -- it only
* surfaces a warning to the merchant (in the order panel and orders list) so
* they can decide to confirm/call before dispatch. It triggers on a SOFTER
* threshold than COD (a watch zone before the act zone).
*
* NOTE: flagging surfaces cross-merchant behaviour to influence a merchant
* decision, so it lives behind its own switch (default ON) -- set
* orderguard_flag_enabled to false to turn it off for a store.
*/
function orderguard_flag_enabled() {
// Default ON. A merchant can disable it via the option or the filter.
$enabled = ( 'no' !== orderguard_get_option( 'orderguard_flag_enabled', 'yes' ) );
return (bool) apply_filters( 'orderguard_flag_enabled', $enabled );
}
function orderguard_flag_notcollected_threshold() {
$value = (int) orderguard_get_option( 'orderguard_flag_notcollected_threshold', 2 );
$value = max( 1, min( 50, $value ) );
return (int) apply_filters( 'orderguard_flag_notcollected_threshold', $value );
}
function orderguard_flag_returned_threshold() {
$value = (int) orderguard_get_option( 'orderguard_flag_returned_threshold', 4 );
$value = max( 1, min( 50, $value ) );
return (int) apply_filters( 'orderguard_flag_returned_threshold', $value );
}
/**
* Decide whether a customer's counts warrant a review flag. Count-based, OR
* logic. Returns false when flagging is disabled.
*/
function orderguard_should_flag( $not_collected, $returned ) {
if ( ! orderguard_flag_enabled() ) {
return false;
}
$not_collected = (int) $not_collected;
$returned = (int) $returned;
return ( $not_collected >= orderguard_flag_notcollected_threshold()
|| $returned >= orderguard_flag_returned_threshold() );
}
/**
* Does THIS STORE'S OWN record of the customer cross the review limits?
*
* Task 50. The counterpart of orderguard_should_flag() for own-store evidence
* (_orderguard_own_* meta, written by orderguard_save_scores() from the server's own_history --
* see task 49). Returns array( 'not_collected' => int, 'returned' => int ) when
* the row deserves the merchant's attention, or null.
*
* WHY THIS IS A SEPARATE FUNCTION rather than widening orderguard_should_flag(). That
* helper is called with NETWORK counts in several places and its meaning --
* "the network says this customer crossed your limit" -- is load-bearing in all
* of them. Widening it to silently mean "or your own books do" would change the
* order panel's flag banner, the network signal path and the COD copy at the
* same time, which is how one intended change becomes four unintended ones. The
* thresholds are deliberately SHARED (orderguard_flag_notcollected_threshold /
* orderguard_flag_returned_threshold): a merchant sets one limit, not two, and the
* source of the evidence does not change how many refused parcels he considers
* too many.
*
* WHAT THIS DELIBERATELY DOES NOT DO: it does not touch checkout. Cash on
* delivery stays exactly as available as it was. Giuliano, 30 July 2026: "I
* don't want to hide it or turn it off. I want to treat it as a live mark. The
* customer should be able to order via cod but the merchant should be notified
* in some way." Notified, here, means it appears on screens he was already
* looking at -- nothing is sent anywhere.
*
* EXCLUSION is honoured for the same reason every other surface honours it: a
* badge that survives the exclusion list reads as "the exclusion didn't work".
*/
function orderguard_own_flag_reason( $order ) {
if ( ! $order instanceof WC_Order || ! orderguard_flag_enabled() ) {
return null;
}
if ( 'display_not_excluded' !== orderguard_owned_exclusion_display_order( $order )
|| orderguard_is_gdpr_erased_order( $order ) ) {
return null;
}
$nc = (int) orderguard_get_order_meta( $order, '_orderguard_own_not_collected_count' );
$rt = (int) orderguard_get_order_meta( $order, '_orderguard_own_returned_count' );
if ( ! orderguard_should_flag( $nc, $rt ) ) {
return null;
}
return array( 'not_collected' => $nc, 'returned' => $rt );
}
/**
* One-line reason for an own-evidence row: "2 not collected", "4 returned", or
* both. Only the counts that actually crossed a limit are named -- listing a
* count that did not cross reads as evidence when it is noise.
*
* Returns '' when nothing crossed, so callers can use it as the test.
*/
function orderguard_own_reason_text( $reason ) {
if ( ! is_array( $reason ) ) {
return '';
}
$parts = array();
$nc = isset( $reason['not_collected'] ) ? (int) $reason['not_collected'] : 0;
$rt = isset( $reason['returned'] ) ? (int) $reason['returned'] : 0;
// The SAME msgids the Review queue and the order panel already use, on
// purpose: these are translated, and a second near-identical string for the
// same sentence is how two surfaces end up disagreeing in Bulgarian.
if ( $nc >= orderguard_flag_notcollected_threshold() ) {
/* translators: %d is a count of non-collected orders. */
$parts[] = sprintf( __( '%d not collected', 'orderguard' ), $nc );
}
if ( $rt >= orderguard_flag_returned_threshold() ) {
/* translators: %d is a count of returned orders. */
$parts[] = sprintf( __( '%d returned', 'orderguard' ), $rt );
}
if ( empty( $parts ) ) {
return '';
}
return implode( ' · ', $parts ) . ' · ' . __( 'your store', 'orderguard' );
}
/**
* ===========================================================================
* MANUAL FLAG / BLOCK -- merchant-initiated, per-customer.
* ===========================================================================
* Lets a merchant flag or block a specific customer from that customer's order.
* Levels: 'flag' (advisory note, no checkout effect), 'block_cod' (COD removed
* for them), 'hard_block' (cannot order). Stored locally in a table on the
* merchant's own site; matching is phone/email hashes only, never name.
*
* This is a merchant's own judgement about their own customer, held on their
* own site, plus (for blocks) checkout enforcement on that same site. None of
* it reaches other stores -- broadcasting is a separate, off-by-default switch,
* orderguard_network_signal_enabled(). Controlled by orderguard_manual_flag_enabled().
*/
/**
* Dev mode. TRUE only when the site explicitly defines ORDERGUARD_DEV (add
* define( 'ORDERGUARD_DEV', true ); to wp-config.php on a dev/staging site). Production
* never defines it, so every dev-only affordance defaults OFF.
*/
function orderguard_dev_mode() {
if ( defined( 'ORDERGUARD_DEV' ) ) {
return (bool) ORDERGUARD_DEV;
}
// Remove the read-only legacy override fallback in 0.3.2 only after task #69.
return defined( 'ORDERGUARD_DEV' ) && (bool) ORDERGUARD_DEV;
}
function orderguard_manual_flag_enabled() {
// The order-panel flag/block controls + local COD blocking. Own store, own
// data, own decision. Fresh stores default OFF; the upgrade installer
// preserves the former ON behaviour for existing stores. This does NOT
// control cross-merchant sharing -- that is orderguard_network_signal_enabled(); a
// store keeps these controls while the network broadcast stays separately
// gated. Disable per store via the option ('no') or the filter.
$opt = ( 'yes' === orderguard_get_option( 'orderguard_manual_flag_enabled', 'no' ) );
return (bool) apply_filters( 'orderguard_manual_flag_enabled', $opt || orderguard_dev_mode() );
}
/**
* CROSS-MERCHANT signal switch -- deliberately SEPARATE from manual flagging.
*
* orderguard_manual_flag_enabled() turns on LOCAL blocking (your store, your data).
* THIS turns on BROADCASTING a store's flags/blocks to the network AND reading
* other stores' counts -- i.e. sharing a negative assessment of an identifiable
* person between independent businesses. That is the legally-sensitive part
* (GDPR: a hashed phone/email is still personal data; a shared blocklist affects
* the data subject's options at other shops), which is why it ships OFF and
* only a deliberate per-store opt-in turns it on.
*
* DEFAULT OFF (operator decision, 0.2.5) -- free for every store, no Pro gate.
* The opt-in lives on the Merchant Signals card (Features tab): turning it on
* records orderguard_network_signal_optin_at, and the card's acknowledgment note
* reminds the merchant to describe the network in their privacy policy.
* Off by default plus a deliberate opt-in is the posture the whole design
* rests on. Still filterable and still switchable per store:
* orderguard_update_option( 'orderguard_network_signal_enabled', 'yes' ); // or the filter
*/
function orderguard_network_signal_enabled() {
return (bool) apply_filters(
'orderguard_network_signal_enabled',
'yes' === orderguard_get_option( 'orderguard_network_signal_enabled', 'no' )
);
}
define( 'ORDERGUARD_FLAGS_DB_VERSION', '2' );
function orderguard_flags_table() {
global $wpdb;
return $wpdb->prefix . 'orderguard_flags';
}
/**
* Create/upgrade the local flags table behind a version check on admin_init, so
* it also installs on an already-active plugin (activation hook alone wouldn't
* re-fire).
*/
add_action( 'admin_init', 'orderguard_flags_ensure_table' );
function orderguard_flags_ensure_table() {
$setup = get_option( 'orderguard_owned_install_status' );
if ( is_array( $setup ) && ( $setup['activation_pending'] ?? false ) === true ) { return; }
if ( orderguard_get_option( 'orderguard_flags_db_version' ) === ORDERGUARD_FLAGS_DB_VERSION ) {
return;
}
global $wpdb;
$table = orderguard_flags_table();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL DEFAULT '',
email VARCHAR(191) NOT NULL DEFAULT '',
phone VARCHAR(64) NOT NULL DEFAULT '',
email_hash CHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL DEFAULT '',
level VARCHAR(20) NOT NULL DEFAULT 'flag',
reason VARCHAR(191) NOT NULL DEFAULT '',
order_ref VARCHAR(64) NOT NULL DEFAULT '',
source VARCHAR(20) NOT NULL DEFAULT 'order',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY email_hash (email_hash),
KEY phone_hash (phone_hash)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
orderguard_update_option( 'orderguard_flags_db_version', ORDERGUARD_FLAGS_DB_VERSION );
}
/**
* Severity levels and their human labels.
*/
function orderguard_flag_levels() {
return array(
'flag' => __( 'Flag for review', 'orderguard' ),
'block_cod' => __( 'Block COD only', 'orderguard' ),
'hard_block' => __( 'Hard block', 'orderguard' ),
);
}
/**
* Pull the customer's identity (name/email/phone + hashes) from an order.
*/
function orderguard_flag_identity_from_order( $order ) {
$identity = orderguard_read_identity_from_order( $order );
$email = isset( $identity['email'] ) ? $identity['email'] : '';
$phone = isset( $identity['phone'] ) ? $identity['phone'] : '';
return array(
'name' => trim( $order->get_formatted_billing_full_name() ),
'email' => $email,
'phone' => $phone,
'email_hash' => ( '' !== $email ) ? orderguard_hash_value( $email ) : '',
'phone_hash' => orderguard_phone_hash( $phone ),
// H-2: e164 spelling, billing-country keyed; '' when not derivable.
// Extra key only -- existing readers ignore it.
'phone_hash_e164' => orderguard_phone_hash_e164( $phone, $order->get_billing_country() ),
);
}
/**
* Find an existing flag entry matching an order's customer (phone/email hash).
*/
function orderguard_flag_find_for_order( $order ) {
if ( ! $order instanceof WC_Order ) {
return null;
}
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( in_array( $privacy_disposition, array( 'suppress', 'block' ), true ) ) {
return null;
}
$id = orderguard_flag_identity_from_order( $order );
if ( '' === $id['email_hash'] && '' === $id['phone_hash'] ) {
return null;
}
// Match EVERY spelling of this order's phone, not just the one stored on it.
// This is the submission-enforcement side of the same gap: without it a
// blocked customer who types "359888123456" where the flag was saved as
// "0888123456" is not merely shown COD -- they can complete the order.
$phone = ( $order instanceof WC_Order ) ? (string) $order->get_billing_phone() : '';
$country = ( $order instanceof WC_Order ) ? (string) $order->get_billing_country() : '';
return orderguard_flag_find_by_hash_set(
$id['email_hash'],
orderguard_phone_hashes_for_lookup( $phone, $country )
);
}
/**
* Create or update the flag entry for an order's customer. Provenance is
* guaranteed: we act from the customer's own order on this store.
*/
function orderguard_flag_store( $name, $email, $phone, $level, $reason, $source, $order_ref = '', $identity_created_at = 0, $identity_order_id = 0 ) {
global $wpdb;
$levels = orderguard_flag_levels();
if ( ! isset( $levels[ $level ] ) ) {
$level = 'flag';
}
$source = ( 'order' === $source ) ? 'order' : 'manual';
$name = trim( (string) $name );
$email = trim( (string) $email );
$phone = trim( (string) $phone );
$email_hash = ( '' !== $email ) ? orderguard_hash_value( $email ) : '';
$phone_hash = orderguard_phone_hash( $phone );
if ( '' === $email_hash && '' === $phone_hash ) {
return 0; // nothing to match on
}
// Every local flag mutation shares the erasure lifecycle mutex. Otherwise
// an upsert that passed its earlier check could land readable PII after the
// erasure froze its exact row manifest. Recheck privacy state only after the
// lock is held. An unrelated order may be locally recorded while its network
// signal is deferred; a suppressed or unreadable identity may not.
if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) {
return 0;
}
try {
if ( 'order' === $source ) {
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $identity_order_id );
if ( in_array( $disposition, array( 'suppress', 'block' ), true ) ) {
return 0;
}
} else {
$manual_hashes = array_values( array_filter( array( $email_hash, $phone_hash ), 'is_string' ) );
if ( orderguard_is_gdpr_erased_hash( $manual_hashes, max( 1, (int) $identity_created_at ), 0 ) ) {
return 0;
}
}
$table = orderguard_flags_table();
$now = current_time( 'mysql' );
// A read may match either identifier because checkout must honour a block
// when the shopper changes one field. A write is different: matching a
// shared phone (or placeholder email) must not merge two people's rows or
// attach this order to the other person's flag. With both identifiers
// available, require both to match the same row; one-field inputs may only
// match that exact field.
$existing = orderguard_flag_find_for_write( $email_hash, $phone_hash );
if ( '' !== $wpdb->last_error ) {
return 0;
}
if ( $existing ) {
$data = array(
'level' => $level,
'reason' => $reason,
'updated_at' => $now,
// Order provenance is stronger than manual; never downgrade it.
'source' => ( 'order' === $existing->source || 'order' === $source ) ? 'order' : 'manual',
);
if ( '' === $existing->name && '' !== $name ) {
$data['name'] = $name;
}
if ( '' !== $order_ref && '' === $existing->order_ref ) {
$data['order_ref'] = $order_ref;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->update( $table, $data, array( 'id' => (int) $existing->id ) );
if ( false === $written ) {
return 0;
}
$flag_id = (int) $existing->id;
} else {
// New entry. No plan cap: this is the merchant's own list in their own
// database, and WP.org guideline 5 forbids quota-locking it.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these
$written = $wpdb->insert(
$table,
array(
'name' => $name,
'email' => $email,
'phone' => $phone,
'email_hash' => $email_hash,
'phone_hash' => $phone_hash,
'level' => $level,
'reason' => $reason,
'order_ref' => $order_ref,
'source' => $source,
'created_at' => $now,
'updated_at' => $now,
)
);
if ( false === $written || (int) $wpdb->insert_id <= 0 ) {
return 0;
}
$flag_id = (int) $wpdb->insert_id;
}
if ( function_exists( 'orderguard_review_queue_invalidate' ) ) {
orderguard_review_queue_invalidate();
}
if ( function_exists( 'orderguard_history_forget' ) ) {
orderguard_history_forget( $email, $phone );
}
return $flag_id;
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/**
* Create/update a flag from an order (provenance = 'order'). Also contributes
* the anonymised signal to the cross-merchant network.
*/
function orderguard_flag_upsert( $order, $level, $reason, &$share_status = null ) {
$share_status = 'not_shared';
if ( ! $order instanceof WC_Order ) {
return false;
}
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( in_array( $privacy_disposition, array( 'suppress', 'block' ), true ) ) {
return false;
}
$id = orderguard_flag_identity_from_order( $order );
$created = $order->get_date_created();
$created_at = $created ? (int) $created->getTimestamp() : 1;
$res = orderguard_flag_store( $id['name'], $id['email'], $id['phone'], $level, $reason, 'order', (string) $order->get_id(), $created_at, $order->get_id() );
// Billing country from the order: the signal's e164 spelling must be
// derived with the same key the order's own sightings were. (This used to
// be skipped when orderguard_flag_store() returned -1 = "Free cap reached"; the cap
// was removed in #38, so the store call can no longer refuse an insert.)
// The sender durably defers this order when another customer's erasure is
// still discovering scope. Local merchant intent is therefore preserved
// without allowing a permanently erased identity back onto the network.
if ( $res > 0 ) {
$share_status = orderguard_send_flag_signal( $id['email'], $id['phone'], orderguard_flag_signal_type( $level ), $order->get_billing_country(), $created_at, $order->get_id() );
}
return $res;
}
/**
* Map an internal level to the network signal type.
*/
function orderguard_flag_signal_type( $level ) {
if ( 'flag' === $level ) {
return 'suspicious';
}
if ( 'block_cod' === $level || 'hard_block' === $level ) {
return 'blocked';
}
return 'none';
}
/**
* Send this store's anonymised flag/block signal to the network (best-effort).
* Only hashes leave the store -- never readable identity. $signal_type is
* 'suspicious' | 'blocked' | 'none' (retract). A failed send is non-fatal (the
* signal is advisory; not worth a retry queue).
*/
function orderguard_send_flag_signal( $email, $phone, $signal_type, $country, $identity_created_at, $identity_order_id ) {
// Local blocking runs on orderguard_manual_flag_enabled alone; SHARING across
// merchants is gated separately and ships OFF until the merchant opts in
// via the Merchant Signals card (orderguard_network_signal_enabled). So a store can
// block locally without ever broadcasting anything new while the switch is off.
//
// A RETRACTION ('none'), though, must always be able to reach the server to
// remove a signal this store broadcast earlier -- even after the merchant has
// turned the switch (or manual flagging) back off. Deletions always pass, the
// same principle the exclusion guard below already applies; the only hard
// requirement is an active connection. A real flag/block send stays gated on
// all three. (#54)
if ( 'none' === $signal_type ) {
if ( ! orderguard_is_activated() ) {
return 'not_shared';
}
} elseif ( ! orderguard_manual_flag_enabled() || ! orderguard_is_activated() || ! orderguard_network_signal_enabled() ) {
return 'not_shared';
}
$identity_order_id = (int) $identity_order_id;
// A positive signal must remain tied to the order that proves this store's
// provenance. Retractions are deletion-only and may legitimately come from
// an older local row without a usable order reference.
if ( 'none' !== $signal_type && $identity_order_id <= 0 ) {
return 'not_shared';
}
if ( 'none' !== $signal_type ) {
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $identity_order_id );
if ( 'suppress' === $disposition ) {
return 'not_shared';
}
if ( 'allow' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $identity_order_id, 1 );
return 'not_shared';
}
}
if ( 'none' !== $signal_type
&& orderguard_is_gdpr_erased_order_id( $identity_order_id, $identity_created_at ) ) {
return 'not_shared';
}
// H-2 dual-send. Callers with an order pass its billing country; otherwise
// fall back to the store's own country. The fallback is safe HERE (unlike
// on /v1/check) because this endpoint cannot create identity: the server
// only records a signal for hashes this store already OWNS a sighting for,
// and those sightings were written under the strict billing-country rule.
// A wrongly-derived hash is simply unowned and dropped. For a retraction
// ('none') a wider hash set only deletes more of THIS store's own advisory
// rows -- the completeness direction a retraction wants.
if ( '' === $country ) {
$country = orderguard_store_base_country();
}
$body = array(
'phone_hash' => orderguard_phone_hash( $phone ) ?: null,
'email_hash' => ( '' !== $email ) ? orderguard_hash_value( $email ) : null,
'phone_hash_e164' => orderguard_phone_hash_e164( $phone, $country ) ?: null,
'signal_type' => $signal_type,
);
if ( 'none' !== $signal_type ) {
$body['order_ref'] = (string) $identity_order_id;
$body['order_created_at'] = (int) $identity_created_at;
}
if ( 'none' !== $signal_type
&& orderguard_is_gdpr_erased_hash( array( $body['phone_hash'], $body['email_hash'], $body['phone_hash_e164'] ), $identity_created_at, $identity_order_id ) ) {
return 'not_shared';
}
$privacy_lock = false;
if ( 'none' !== $signal_type ) {
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
orderguard_schedule_gdpr_deferred_order( $identity_order_id, 1 );
return 'not_shared';
}
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $identity_order_id );
if ( 'allow' !== $disposition ) {
if ( 'suppress' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $identity_order_id, 1 );
}
orderguard_release_gdpr_erasure_lifecycle_lock();
return 'not_shared';
}
}
try {
// Positive sharing needs a fresh three-state exclusion decision at the
// last practical moment. An unreadable source is never permission to
// send. The local flag has already been saved, and this build does not
// queue the withheld signal; the outbox has its own release gate.
// Retractions remain deletion-only and deliberately bypass exclusions.
if ( 'none' !== $signal_type ) {
$exclusion = orderguard_owned_exclusion_order_ref_state( $identity_order_id );
if ( 'excluded' === ( $exclusion['status'] ?? 'unknown' ) ) {
return 'excluded';
}
if ( 'not_excluded' !== ( $exclusion['status'] ?? 'unknown' ) ) {
return 'exclusion_unknown';
}
}
// The direct legacy POST is retired. Positive signals and retractions
// enter the same revisioned outbox; only its receipt-verifying worker
// may contact the server.
return orderguard_signal_outbox_enqueue(
$email,
$phone,
$signal_type,
$country,
(int) $identity_created_at,
(int) $identity_order_id,
$privacy_lock
);
} finally {
if ( $privacy_lock ) {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
}
function orderguard_flag_delete_by_id( $id ) {
// TASK 50: invalidate at the MUTATION, not at the handler. Hooking each
// admin_post action was both incomplete (it missed the manual-add path, so a
// client flagged from Blocked clients sat in the history section for up to
// twelve hours looking like a button that had done nothing) and unsafe (the
// hook fired before the handler's nonce and capability checks). Every write to
// the flags table passes through here, and nothing reaches here without having
// gone through those checks first.
if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) {
return false;
}
try {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$deleted = $wpdb->delete( orderguard_flags_table(), array( 'id' => (int) $id ), array( '%d' ) );
if ( false !== $deleted && function_exists( 'orderguard_review_queue_invalidate' ) ) {
orderguard_review_queue_invalidate();
}
return $deleted;
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
function orderguard_flags_get_all( $filter = 'all' ) {
global $wpdb;
$table = orderguard_flags_table();
$where = '';
if ( 'flagged' === $filter || 'suspicious' === $filter ) {
$where = "WHERE level = 'flag'";
} elseif ( 'blocked' === $filter ) {
$where = "WHERE level IN ( 'block_cod', 'hard_block' )";
} elseif ( 'old' === $filter ) {
$where = "WHERE created_at < DATE_SUB( NOW(), INTERVAL 24 MONTH )";
}
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
return $wpdb->get_results( "SELECT * FROM {$table} {$where} ORDER BY updated_at DESC" );
}
function orderguard_flags_count() {
global $wpdb;
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- orderguard_flags_table() is $wpdb->prefix . 'orderguard_flags'; a table name cannot be a placeholder and no user input reaches it.
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . orderguard_flags_table() );
}
/**
* admin-post: create/update a flag from an order.
*/
/**
* Drop the Review caches. Call this from INSIDE a handler, after its nonce and
* capability checks -- never as its own early-priority hook on admin_post.
*
* It was originally hooked to admin_post_orderguard_save_flag at priority 9, ahead of
* the handler that does the checking. admin_post fires for ANY logged-in user,
* so a subscriber could hit admin-post.php?action=orderguard_save_flag with no nonce and
* force a full queue rebuild -- a 50-order meta_query plus a per-row flag lookup
* -- on the next admin page load of every administrator, as fast as they cared
* to loop. Cheap for them, not for the store.
*/
function orderguard_review_queue_invalidate_safe() {
if ( function_exists( 'orderguard_review_queue_invalidate' ) ) {
orderguard_review_queue_invalidate();
}
}
add_action( 'admin_post_orderguard_save_flag', 'orderguard_handle_save_flag' );
function orderguard_handle_save_flag() {
if ( ! orderguard_manual_flag_enabled() || ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
$order_id = isset( $_GET['order_id'] ) ? (int) $_GET['order_id'] : 0;
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_saveflag_' . $order_id ) ) {
wp_die( 'Bad nonce.' );
}
$order = wc_get_order( $order_id );
if ( ! $order ) {
wp_die( 'No order.' );
}
$level = isset( $_GET['level'] ) ? sanitize_key( wp_unslash( $_GET['level'] ) ) : 'flag';
$reason = isset( $_GET['reason'] ) ? sanitize_text_field( wp_unslash( $_GET['reason'] ) ) : '';
if ( '' !== $reason && ! array_key_exists( $reason, orderguard_flag_reasons() ) ) {
$reason = '';
}
$share_status = 'not_shared';
$saved = orderguard_flag_upsert( $order, $level, $reason, $share_status );
if ( false !== $saved ) {
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'exclusion_unknown' === $share_status ? 'saved_local' : 'saved', 120 );
}
// #46: blocking is LOCAL; the "not collected" mark is the signal that reaches
// the network. When a COD order is blocked from its own screen and is not
// already marked, arm a one-time prompt (orderguard_render_notcollected_prompt) that
// offers to record the non-collection too. Nothing is sent from here -- the
// prompt's "yes" reuses the existing orderguard_notcollected action.
if ( orderguard_should_arm_notcollected_prompt( $saved, $level, $order ) ) {
set_transient( 'orderguard_ask_nc_' . get_current_user_id(), $order_id, 60 );
}
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
exit;
}
/**
* The follow-up prompt opens a separate network writer. Arm it from a fresh
* three-state exclusion decision, independently of the optional Merchant
* Signals flag-sharing feature. Local-only, excluded and unknown saves must
* not create a second route around that decision.
*/
function orderguard_should_arm_notcollected_prompt( $saved, $level, $order ) {
return false !== $saved
&& in_array( $level, array( 'block_cod', 'hard_block' ), true )
&& orderguard_is_cod_order( $order )
&& 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_not_collected' )
&& 'not_excluded' === ( orderguard_owned_exclusion_order_state( $order )['status'] ?? 'unknown' );
}
/**
* #46 -- one-time "was it not collected?" prompt, shown on an order's own screen
* right after the merchant blocked that COD order (transient armed in
* orderguard_handle_save_flag). Blocking is local to the store; recording "not collected"
* is what teaches the network -- and merchants reasonably assume the block does
* both, so most non-collections were never reaching the network.
*
* The "Yes" button links to the EXISTING orderguard_notcollected admin-post action
* (nonce and all), so this adds no new send path and nothing new can reach the
* network by mistake. "No" just dismisses -- the block already stands.
*/
add_action( 'admin_footer', 'orderguard_render_notcollected_prompt' );
function orderguard_render_notcollected_prompt() {
if ( ! function_exists( 'orderguard_manual_flag_enabled' ) || ! orderguard_manual_flag_enabled()
|| ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$tkey = 'orderguard_ask_nc_' . get_current_user_id();
$order_id = (int) orderguard_get_transient( $tkey );
if ( ! $order_id ) {
return;
}
// Only on the edit screen of THAT order (HPOS 'id' / classic 'post').
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$viewing = isset( $_GET['id'] ) ? (int) $_GET['id'] : ( isset( $_GET['post'] ) ? (int) $_GET['post'] : 0 );
if ( $viewing !== $order_id ) {
return; // wrong screen -> leave the transient for the right one
}
orderguard_delete_transient( $tkey ); // one-time
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order
|| orderguard_is_gdpr_erased_order( $order )
|| ! orderguard_is_cod_order( $order )
|| 'yes' === (string) orderguard_get_order_meta( $order, '_orderguard_not_collected' ) ) {
return; // state changed since the block -> nothing to ask
}
// The transient can outlive the save request and the exclusion source can
// change meanwhile. Re-read the owned three-state source immediately before
// exposing the legacy event action; excluded and unknown are both silent.
if ( 'not_excluded' !== ( orderguard_owned_exclusion_order_state( $order )['status'] ?? 'unknown' ) ) {
return;
}
$mark_url = wp_nonce_url(
admin_url( 'admin-post.php?action=orderguard_notcollected&do=mark&order_id=' . $order_id ),
'orderguard_notcollected_' . $order_id
);
$name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
$subtitle = '#' . $order_id . ( '' !== $name ? ' · ' . $name : '' );
?>
prefix); the id IS a %d placeholder.
$row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . orderguard_flags_table() . ' WHERE id = %d', $flag_id ) );
if ( $row && 'order' === $row->source ) {
// Retractions deliberately bypass erasure suppression. The explicit
// timestamp keeps the enqueue helper fail-closed for future flag callers.
// Do not erase the only local evidence until its retraction is durable.
$retraction = orderguard_send_flag_signal( $row->email, $row->phone, 'none', '', 0, 0 );
if ( orderguard_is_activated() && 'queued' !== $retraction ) {
orderguard_set_notice( 'error', 'The flag was not removed because its network retraction could not be saved. Please try again.' );
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
exit;
}
}
if ( false === orderguard_flag_delete_by_id( $flag_id ) ) {
orderguard_set_notice( 'error', 'The flag could not be removed. Please try again.' );
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
exit;
}
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'removed', 120 );
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
exit;
}
/**
* admin-post: create a flag manually from the Blocked clients page (no order).
* source = 'manual' -> local-only, no network provenance.
*/
add_action( 'admin_post_orderguard_add_flag_manual', 'orderguard_handle_add_flag_manual' );
function orderguard_handle_add_flag_manual() {
if ( ! orderguard_manual_flag_enabled() || ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_POST['orderguard_addflag_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['orderguard_addflag_nonce'] ) ), 'orderguard_addflag' ) ) {
wp_die( 'Bad nonce.' );
}
$name = isset( $_POST['orderguard_f_name'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_f_name'] ) ) : '';
$email = isset( $_POST['orderguard_f_email'] ) ? sanitize_email( wp_unslash( $_POST['orderguard_f_email'] ) ) : '';
$phone = isset( $_POST['orderguard_f_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_f_phone'] ) ) : '';
$level = isset( $_POST['orderguard_f_level'] ) ? sanitize_key( wp_unslash( $_POST['orderguard_f_level'] ) ) : 'flag';
$reason = isset( $_POST['orderguard_f_reason'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_f_reason'] ) ) : '';
if ( '' !== $reason && ! array_key_exists( $reason, orderguard_flag_reasons() ) ) {
$reason = '';
}
$redirect = wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard-blocked' );
if ( '' === $email && '' === $phone ) {
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'noident', 120 );
wp_safe_redirect( $redirect );
exit;
}
$saved = orderguard_flag_store( $name, $email, $phone, $level, $reason, 'manual', '', time(), 0 );
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), $saved > 0 ? 'saved' : 'save_err', 120 );
wp_safe_redirect( $redirect );
exit;
}
/**
* Success/error notice after a flag action (order screen / admin).
*/
add_action( 'admin_notices', 'orderguard_flag_admin_notice' );
function orderguard_flag_admin_notice() {
$key = 'orderguard_flag_notice_' . get_current_user_id();
$val = orderguard_get_transient( $key );
if ( ! $val ) {
return;
}
orderguard_delete_transient( $key );
if ( 'noident' === $val ) {
echo '
OrderGuard: ' . esc_html__( 'enter an email or a phone to flag a customer.', 'orderguard' ) . '
';
return;
}
if ( 'save_err' === $val ) {
echo '
OrderGuard: ' . esc_html__( 'the customer flag could not be saved safely. Please retry; if a removal request is processing, let it finish first.', 'orderguard' ) . '
OrderGuard: ' . esc_html__( 'A checkout for another billing interval is still open. Finish it or wait for its opening window to expire before changing interval.', 'orderguard' ) . '
';
return;
}
if ( 'checkout_err' === $val || 'checkout_comped' === $val ) {
$message = 'checkout_comped' === $val ? __( 'You already have Pro (complimentary).', 'orderguard' ) : __( 'Checkout could not be opened. Wait before trying again or contact support.', 'orderguard' );
echo '
OrderGuard: ' . esc_html__( 'couldn\'t reach the OrderGuard server to prepare your download. Please check your connection and try again in a moment.', 'orderguard' ) . '
OrderGuard: ' . esc_html__( 'your Pro plan is not active, so the Pro add-on can\'t be downloaded right now. Check your plan on this page.', 'orderguard' ) . '
OrderGuard: ' . esc_html__( 'couldn\'t switch your billing interval right now. Please try again in a moment.', 'orderguard' ) . '
';
return;
}
if ( 'erased' === $val ) {
echo '
OrderGuard: ' . esc_html__( 'the data your store contributed for this customer was erased. Signals from other stores are unaffected.', 'orderguard' ) . '
OrderGuard: ' . esc_html__( 'the customer’s matching local orders are protected. Removal from the OrderGuard server is still processing and will retry automatically; progress remains visible on the Legal tab.', 'orderguard' ) . '
';
return;
}
if ( 'erase_err' === $val ) {
echo '
OrderGuard: ' . esc_html__( 'the removal request could not be started locally, so no server deletion was attempted. Please try again.', 'orderguard' ) . '
OrderGuard: ' . esc_html__( 'the removal request was not accepted because this site cannot prove enough safe local storage for its complete protection record. No server deletion was attempted. Ask your host to check the database packet limit and available option capacity before retrying.', 'orderguard' ) . '
';
}
/**
* Canonical flag reasons: stable KEY => English source label. We STORE the key
* (so a flag created in one UI language still matches the dropdown in another);
* the label is translated at render time.
*/
function orderguard_flag_reasons() {
return array(
'never_collects' => 'Never collects COD orders',
'serial_returner' => 'Serial returner',
'payment_fraud' => 'Payment fraud attempt',
'abusive' => 'Abusive behaviour',
'other' => 'Other',
);
}
/**
* The same reasons, as TRANSLATED labels.
*
* Deliberately separate from orderguard_flag_reasons(): that one has to keep returning
* the untranslated English, because rows written by older versions stored the
* label rather than the key and still have to match in any locale. Every string
* here is a literal, which is what makes them extractable -- __( $variable )
* is invisible to the string scanner and cannot be translated.
*/
function orderguard_flag_reason_labels() {
return array(
'never_collects' => __( 'Never collects COD orders', 'orderguard' ),
'serial_returner' => __( 'Serial returner', 'orderguard' ),
'payment_fraud' => __( 'Payment fraud attempt', 'orderguard' ),
'abusive' => __( 'Abusive behaviour', 'orderguard' ),
'other' => __( 'Other', 'orderguard' ),
);
}
/**
* Translate a stored reason for display. Accepts a stable key (new rows) or a
* legacy English/translated label (older rows); falls back to the raw value.
*/
function orderguard_flag_reason_label( $stored ) {
$labels = orderguard_flag_reason_labels();
if ( isset( $labels[ $stored ] ) ) {
return $labels[ $stored ];
}
foreach ( orderguard_flag_reasons() as $key => $eng ) {
if ( $stored === $eng || ( isset( $labels[ $key ] ) && $stored === $labels[ $key ] ) ) {
return $labels[ $key ];
}
}
return $stored;
}
/**
* Normalise a stored reason back to its stable key (or '' if unknown), so the
* dropdown pre-selects the right option regardless of the language it was
* created in.
*/
function orderguard_flag_reason_key_of( $stored ) {
$reasons = orderguard_flag_reasons();
if ( isset( $reasons[ $stored ] ) ) {
return $stored;
}
$labels = orderguard_flag_reason_labels();
foreach ( $reasons as $key => $eng ) {
if ( $stored === $eng || ( isset( $labels[ $key ] ) && $stored === $labels[ $key ] ) ) {
return $key;
}
}
return '';
}
/**
* Confidence is stored as one of a fixed set of keys. Same reason as above:
* the lookup has to be a switch over literals, not __( $value ).
*/
function orderguard_confidence_label( $value ) {
switch ( (string) $value ) {
case 'high':
return __( 'high', 'orderguard' );
case 'medium':
return __( 'medium', 'orderguard' );
case 'low':
return __( 'low', 'orderguard' );
case 'none':
return __( 'none', 'orderguard' );
}
return (string) $value;
}
/**
* Render the in-order flag/block control (the approved icon design). Returns
* HTML for the order panel. Uses radios + a reason select read by a small inline
* script that navigates to the admin-post handler (no nested form).
*/
function orderguard_render_flag_control( $order, $disabled = false ) {
if ( ! $order instanceof WC_Order || orderguard_is_gdpr_erased_order( $order ) ) {
return '';
}
$order_id = $order->get_id();
$existing = orderguard_flag_find_for_order( $order );
$current = $existing ? $existing->level : 'flag';
$levels = orderguard_flag_levels();
// Presentation only: legacy handlers are not safe local-only writers yet.
// A disabled panel exposes neither an action URL nor a nonce.
$save_base = $disabled ? '' : admin_url( 'admin-post.php?action=orderguard_save_flag&order_id=' . $order_id );
$save_nonce = $disabled ? '' : wp_create_nonce( 'orderguard_saveflag_' . $order_id );
$meta = array(
'flag' => array( 'dashicons-flag', '#FBF1DE', '#B07D2A', __( 'A private record on your own site, plus a signal to other stores if you have Merchant Signals switched on. Checkout is not affected.', 'orderguard' ) ),
'block_cod' => array( 'dashicons-hidden', '#FBEADF', '#C2621F', __( 'They can still order by prepaying, but not with cash on delivery.', 'orderguard' ) ),
'hard_block' => array( 'dashicons-dismiss', '#FBEAEA', '#8F4444', __( 'They cannot place any order on your store. Use sparingly.', 'orderguard' ) ),
);
$reasons = orderguard_flag_reasons();
$current_reason_key = $existing ? orderguard_flag_reason_key_of( $existing->reason ) : '';
ob_start();
?>
( '' !== $id['phone_hash'] ) ? $id['phone_hash'] : null,
'email_hash' => ( '' !== $id['email_hash'] ) ? $id['email_hash'] : null,
// H-2: a flag recorded under the other spelling of this phone (another
// store, or this one before the update) counts too.
'phone_hash_e164' => ( '' !== $id['phone_hash_e164'] ) ? $id['phone_hash_e164'] : null,
);
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
return null;
}
try {
// Serialize the last guard with erasure acceptance. Without the mutex,
// a request accepted between the check above and this socket could
// still use the erased identity once more.
if ( 'not_excluded' !== ( orderguard_owned_exclusion_hashes_state( $identity_hashes )['status'] ?? 'unknown' )
|| orderguard_is_gdpr_erased_order( $order ) ) {
return null;
}
$resp = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/flag-signal-counts',
array(
'timeout' => 3,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( $body ),
)
);
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
return null; // don't cache failures -- retry next view
}
$data = json_decode( wp_remote_retrieve_body( $resp ), true );
if ( ! is_array( $data ) ) {
return null;
}
$result = array(
'suspicious' => isset( $data['suspicious'] ) ? (int) $data['suspicious'] : 0,
'blocked' => isset( $data['blocked'] ) ? (int) $data['blocked'] : 0,
);
set_transient( $cache_key, $result, 10 * MINUTE_IN_SECONDS );
return $result;
}
/**
* The advisory network-signal line for the order panel. Returns '' when there's
* nothing to show. Explicitly framed as advisory, never a rule.
*/
function orderguard_render_network_signal_line( $order ) {
$sig = orderguard_fetch_network_signal( $order );
if ( ! $sig || ( $sig['suspicious'] < 1 && $sig['blocked'] < 1 ) ) {
return '';
}
$bits = array();
if ( $sig['suspicious'] > 0 ) {
$bits[] = sprintf(
/* translators: %d = number of merchants */
_n( '%d other merchant flagged this customer', '%d other merchants flagged this customer', $sig['suspicious'], 'orderguard' ),
$sig['suspicious']
);
}
if ( $sig['blocked'] > 0 ) {
$bits[] = sprintf(
/* translators: %d is the number of stores that blocked this customer. */ _n( '%d blocked them', '%d blocked them', $sig['blocked'], 'orderguard' ),
$sig['blocked']
);
}
$sentence = implode( ' · ', $bits );
$html = '
';
$html .= '';
$html .= '
' . esc_html( $sentence ) . '
';
$html .= '
' . esc_html__( 'A network signal, not a rule. You decide how to handle this order.', 'orderguard' ) . '
';
$html .= '
';
return $html;
}
/**
* Find a flag entry by pre-computed email/phone hashes.
*/
function orderguard_flag_find_by_hashes( $email_hash, $phone_hash ) {
global $wpdb;
if ( '' === $email_hash && '' === $phone_hash ) {
return null;
}
$table = orderguard_flags_table();
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound
"SELECT * FROM {$table} WHERE ( email_hash <> '' AND email_hash = %s ) OR ( phone_hash <> '' AND phone_hash = %s ) ORDER BY CASE level WHEN 'hard_block' THEN 3 WHEN 'block_cod' THEN 2 ELSE 1 END DESC, id DESC LIMIT 1",
$email_hash,
$phone_hash
)
);
return $row ? $row : null;
}
/**
* Find the one local flag row that a writer may update.
*
* Read paths intentionally use OR semantics so an existing local block still
* applies when a shopper changes an email or phone. Reusing that rule for writes
* joins people who merely share a household phone or placeholder email. It can
* also restore an erased order reference to somebody else's retained flag.
* Writers therefore require the exact two-field shape. A missing input matches
* only a missing stored field, never another person's populated identifier.
*/
function orderguard_flag_find_for_write( $email_hash, $phone_hash ) {
global $wpdb;
$email_hash = (string) $email_hash;
$phone_hash = (string) $phone_hash;
if ( '' === $email_hash && '' === $phone_hash ) {
return null;
}
$table = orderguard_flags_table();
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound
"SELECT * FROM {$table} WHERE email_hash = %s AND phone_hash = %s ORDER BY id DESC LIMIT 1",
$email_hash,
$phone_hash
)
);
return $row ? $row : null;
}
/**
* Find a flag by an email hash OR ANY of a set of phone-spelling hashes.
*
* The stored row holds whatever spelling the merchant or the order happened to
* use, so we test every spelling of the number in hand (see
* orderguard_phone_spelling_variants). Without this, a blocked customer could walk
* straight through both the COD hiding AND the submission enforcement simply by
* typing the same number a different way.
*/
function orderguard_flag_find_by_hash_set( $email_hash, $phone_hashes ) {
global $wpdb;
$email_hash = (string) $email_hash;
$phone_hashes = array_values( array_unique( array_filter( (array) $phone_hashes ) ) );
if ( '' === $email_hash && empty( $phone_hashes ) ) {
return null;
}
$table = orderguard_flags_table();
$where = array();
$params = array();
if ( '' !== $email_hash ) {
$where[] = "( email_hash <> '' AND email_hash = %s )";
$params[] = $email_hash;
}
if ( ! empty( $phone_hashes ) ) {
$slots = implode( ', ', array_fill( 0, count( $phone_hashes ), '%s' ) );
$where[] = "( phone_hash <> '' AND phone_hash IN ( {$slots} ) )";
$params = array_merge( $params, $phone_hashes );
}
$sql = "SELECT * FROM {$table} WHERE " . implode( ' OR ', $where ) . " ORDER BY CASE level WHEN 'hard_block' THEN 3 WHEN 'block_cod' THEN 2 ELSE 1 END DESC, id DESC LIMIT 1";
$row = $wpdb->get_row( $wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
return $row ? $row : null;
}
/**
* Resolve network flag hashes linked by this store's order-sourced flag rows.
*
* A matching order-sourced row may contain the other identity field that was
* broadcast with the same real order. Manual rows are deliberately excluded:
* an administrator may have paired unrelated values and a local-only row must
* never enlarge server deletion authority. The server accepts at most 1,024
* flag hashes, so ambiguity or overflow fails closed instead of truncating.
*/
function orderguard_gdpr_erasure_local_order_flag_hashes( $match_hashes, &$ok = null, &$reason = null ) {
global $wpdb;
$ok = false;
$reason = 'invalid';
$hashes = array();
foreach ( (array) $match_hashes as $hash ) {
$hash = strtolower( trim( (string) $hash ) );
if ( ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
return array();
}
$hashes[ $hash ] = true;
}
$hashes = array_keys( $hashes );
if ( empty( $hashes ) ) {
$ok = true;
$reason = '';
return array();
}
if ( count( $hashes ) > 1024 ) {
$reason = 'limit';
return array();
}
$table = orderguard_flags_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$found = $wpdb->get_var(
$wpdb->prepare(
'SHOW TABLES LIKE %s',
$wpdb->esc_like( $table )
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- privacy erasure must inspect the local flags table directly.
$table_state = orderguard_table_result_state( $table, $found, $wpdb->last_error );
if ( 'error' === $table_state ) {
$reason = 'storage';
return array();
}
if ( 'missing' === $table_state ) {
$ok = true;
$reason = '';
return array();
}
// Resolve the complete connected component, not just the first matching
// row. An order-sourced row can prove email E1 belongs with phone P, while
// a later order-sourced row proves P belongs with email E2. Deleting only
// one hop would remove the local rows for both orders but leave E2's
// network flag behind with no surviving local evidence from which to retry.
// Only order-sourced rows may expand this authority; manual rows are still
// picked up by the final local manifest, but can never bridge identities.
$known_hashes = array_fill_keys( $hashes, true );
$frontier = $known_hashes;
$seen_rows = array();
$resolved = array();
while ( ! empty( $frontier ) ) {
$frontier_hashes = array_keys( $frontier );
$slots = implode( ', ', array_fill( 0, count( $frontier_hashes ), '%s' ) );
$params = array_merge( array( 'order' ), $frontier_hashes, $frontier_hashes );
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, email_hash, phone_hash, phone, order_ref FROM {$table} WHERE source = %s AND ( email_hash IN ( {$slots} ) OR phone_hash IN ( {$slots} ) ) ORDER BY id ASC LIMIT 1025", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table is derived only from $wpdb->prefix; all values use placeholders.
$params
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded privacy-erasure authority lookup.
if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) {
$reason = 'storage';
return array();
}
$next_frontier = array();
foreach ( $rows as $row ) {
$row_id = isset( $row->id ) ? (int) $row->id : 0;
if ( $row_id <= 0 ) {
$reason = 'invalid';
return array();
}
if ( isset( $seen_rows[ $row_id ] ) ) {
continue;
}
$seen_rows[ $row_id ] = true;
if ( count( $seen_rows ) > 1024 ) {
$reason = 'limit';
return array();
}
$row_hashes = array();
foreach ( array( 'email_hash', 'phone_hash' ) as $field ) {
$hash = strtolower( trim( (string) ( $row->{$field} ?? '' ) ) );
if ( '' === $hash ) {
continue;
}
if ( ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
$reason = 'invalid';
return array();
}
$row_hashes[ $hash ] = true;
}
// Prefer the provenance order's billing country. Older plugin
// builds broadcast E.164 flag siblings without an order_ref,
// though, and the local flag can outlive both that order and its
// sighting. For this deletion-only scope it is safe to try every
// explicitly supported mobile plan against the row's raw phone.
// Never use the mutable store default.
$order_ref = trim( (string) ( $row->order_ref ?? '' ) );
$order = false;
if ( '' !== $order_ref && ctype_digit( $order_ref ) && (int) $order_ref > 0
&& function_exists( 'wc_get_order' ) ) {
try {
$order = wc_get_order( (int) $order_ref );
} catch ( Throwable $throwable ) {
$order = false;
}
}
$countries = $order instanceof WC_Order
? array( (string) $order->get_billing_country() )
: array_keys( orderguard_phone_e164_plans() );
foreach ( $countries as $country ) {
$e164 = orderguard_phone_hash_e164(
(string) ( $row->phone ?? '' ),
$country
);
if ( '' !== $e164 ) {
$row_hashes[ $e164 ] = true;
}
}
foreach ( array_keys( $row_hashes ) as $hash ) {
$resolved[ $hash ] = true;
if ( ! isset( $known_hashes[ $hash ] ) ) {
$known_hashes[ $hash ] = true;
$next_frontier[ $hash ] = true;
if ( count( $known_hashes ) > 1024 ) {
$reason = 'limit';
return array();
}
}
}
}
// A full result page with no new row would mean at least one more
// matching distinct row is hidden beyond this bounded read. Refuse
// instead of accepting an incomplete authority graph.
if ( count( $rows ) > 1024 && count( $seen_rows ) <= 1024 ) {
$reason = 'limit';
return array();
}
$frontier = $next_frontier;
}
$resolved = array_keys( $resolved );
sort( $resolved, SORT_STRING );
$ok = true;
$reason = '';
return $resolved;
}
/** Canonical, complete representation of one readable local flag row. */
function orderguard_gdpr_local_flag_row( $row ) {
if ( ! is_object( $row ) && ! is_array( $row ) ) {
return false;
}
$row = (array) $row;
$id = isset( $row['id'] ) ? (int) $row['id'] : 0;
if ( $id <= 0 ) {
return false;
}
$canonical = array( 'id' => $id );
foreach ( array( 'name', 'email', 'phone', 'email_hash', 'phone_hash', 'level', 'reason', 'order_ref', 'source', 'created_at', 'updated_at' ) as $field ) {
if ( ! array_key_exists( $field, $row ) || ! is_scalar( $row[ $field ] ) ) {
return false;
}
$canonical[ $field ] = (string) $row[ $field ];
}
return $canonical;
}
/** Authenticate a frozen local flag row without persisting its readable PII. */
function orderguard_gdpr_local_flag_row_digest( $row ) {
$row = orderguard_gdpr_local_flag_row( $row );
if ( false === $row ) {
return '';
}
$encoded = wp_json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( ! is_string( $encoded ) ) {
return '';
}
return hash_hmac(
'sha256',
'orderguard-gdpr-local-flag-v1|' . $encoded,
orderguard_gdpr_suppression_secret()
);
}
/** Strictly normalise the bounded, PII-free manifest stored with an erasure. */
function orderguard_normalize_gdpr_local_flag_manifest( $manifest, &$ok = null ) {
$ok = false;
if ( ! is_array( $manifest ) || count( $manifest ) > 1024 ) {
return array();
}
$normalised = array();
foreach ( $manifest as $item ) {
if ( ! is_array( $item )
|| array( 'id', 'digest' ) !== array_keys( $item )
|| ( ! is_int( $item['id'] ) && ! ( is_string( $item['id'] ) && ctype_digit( $item['id'] ) ) )
|| (int) $item['id'] <= 0
|| ! is_string( $item['digest'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $item['digest'] ) ) {
return array();
}
$id = (int) $item['id'];
if ( isset( $normalised[ $id ] ) && ! hash_equals( $normalised[ $id ]['digest'], $item['digest'] ) ) {
return array();
}
$normalised[ $id ] = array(
'id' => $id,
'digest' => $item['digest'],
);
}
ksort( $normalised, SORT_NUMERIC );
$ok = true;
return array_values( $normalised );
}
/** Merge two unsent snapshots, letting the later explicit request renew an ID. */
function orderguard_merge_gdpr_local_flag_manifests( $existing, $incoming, &$ok = null ) {
$existing_ok = false;
$incoming_ok = false;
$existing = orderguard_normalize_gdpr_local_flag_manifest( $existing, $existing_ok );
$incoming = orderguard_normalize_gdpr_local_flag_manifest( $incoming, $incoming_ok );
if ( ! $existing_ok || ! $incoming_ok ) {
$ok = false;
return array();
}
$merged = array();
foreach ( array_merge( $existing, $incoming ) as $item ) {
$merged[ (int) $item['id'] ] = $item;
}
ksort( $merged, SORT_NUMERIC );
return orderguard_normalize_gdpr_local_flag_manifest( array_values( $merged ), $ok );
}
/**
* Read every local flag row matching the submitted identity.
*
* This is used both by the authenticated admin access response and, while the
* lifecycle mutex is held, to freeze the rows an erasure is authorised to
* remove. Callers must fail closed on $ok=false: reporting an empty result when
* the table could not be read would make the privacy response dishonest.
*/
function orderguard_gdpr_local_flag_rows_for_hashes( $match_hashes, &$ok = null, &$reason = null ) {
global $wpdb;
$ok = false;
$reason = 'invalid';
$hashes_ok = false;
$hashes = orderguard_normalize_gdpr_erasure_hash_list( (array) $match_hashes, 1024, $hashes_ok );
if ( ! $hashes_ok || empty( $hashes ) ) {
return array();
}
$table = orderguard_flags_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$found = $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- an access/erasure request must distinguish an absent local table from a failed read.
$table_state = orderguard_table_result_state( $table, $found, $wpdb->last_error );
if ( 'error' === $table_state ) {
$reason = 'storage';
return array();
}
if ( 'missing' === $table_state ) {
$ok = true;
$reason = '';
return array();
}
$slots = implode( ', ', array_fill( 0, count( $hashes ), '%s' ) );
$params = array_merge( $hashes, $hashes );
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE email_hash IN ( {$slots} ) OR phone_hash IN ( {$slots} ) ORDER BY id ASC LIMIT 1025", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table derives only from $wpdb->prefix; values use placeholders; placeholders are generated to match the bound array, which the checker cannot count
$params
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- bounded, authoritative privacy lookup.
if ( '' !== $wpdb->last_error || ! is_array( $rows ) || count( $rows ) > 1024 ) {
$reason = count( (array) $rows ) > 1024 ? 'limit' : 'storage';
return array();
}
$canonical = array();
foreach ( $rows as $row ) {
$item = orderguard_gdpr_local_flag_row( $row );
if ( false === $item ) {
$reason = 'invalid';
return array();
}
$canonical[] = $item;
}
$ok = true;
$reason = '';
return $canonical;
}
/** Freeze only row IDs plus authenticated digests; no readable PII enters job state. */
function orderguard_gdpr_local_flag_manifest_for_hashes( $match_hashes, &$ok = null, &$reason = null ) {
$rows = orderguard_gdpr_local_flag_rows_for_hashes( $match_hashes, $ok, $reason );
if ( ! $ok ) {
return array();
}
$manifest = array();
foreach ( $rows as $row ) {
$digest = orderguard_gdpr_local_flag_row_digest( $row );
if ( '' === $digest ) {
$ok = false;
$reason = 'invalid';
return array();
}
$manifest[] = array(
'id' => (int) $row['id'],
'digest' => $digest,
);
}
return orderguard_normalize_gdpr_local_flag_manifest( $manifest, $ok );
}
/**
* Delete a bounded page of the exact local flag rows frozen at acceptance.
*
* Missing rows are an idempotent success. A changed/reused ID is never deleted:
* that row was not part of the merchant's frozen request and needs supervised
* review. Exact-value DELETE closes the read/delete race without a transaction.
*/
function orderguard_delete_gdpr_local_flag_manifest_batch( $manifest, $limit = 50 ) {
global $wpdb;
$manifest_ok = false;
$manifest = orderguard_normalize_gdpr_local_flag_manifest( $manifest, $manifest_ok );
if ( ! $manifest_ok ) {
return array( 'kind' => 'blocked', 'reason' => 'local_flag_manifest_invalid', 'remaining' => array() );
}
if ( empty( $manifest ) ) {
return array( 'kind' => 'done', 'reason' => '', 'remaining' => array() );
}
$table = orderguard_flags_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$found = $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- absence means the local PII is already gone; a query failure must retry.
$table_state = orderguard_table_result_state( $table, $found, $wpdb->last_error );
if ( 'error' === $table_state ) {
return array( 'kind' => 'retry', 'reason' => 'local_flag_storage', 'remaining' => $manifest );
}
if ( 'missing' === $table_state ) {
return array( 'kind' => 'done', 'reason' => '', 'remaining' => array() );
}
$remaining = $manifest;
$limit = max( 1, min( 100, (int) $limit ) );
$processed = 0;
while ( ! empty( $remaining ) && $processed < $limit ) {
$expected = $remaining[0];
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d LIMIT 1", $expected['id'] ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table derives only from $wpdb->prefix.
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- exact privacy deletion requires a fresh authoritative row.
if ( '' !== $wpdb->last_error ) {
return array( 'kind' => 'retry', 'reason' => 'local_flag_storage', 'remaining' => $remaining );
}
if ( ! $row ) {
array_shift( $remaining );
++$processed;
continue;
}
$canonical = orderguard_gdpr_local_flag_row( $row );
$digest = orderguard_gdpr_local_flag_row_digest( $canonical );
if ( false === $canonical || '' === $digest || ! hash_equals( $expected['digest'], $digest ) ) {
return array( 'kind' => 'blocked', 'reason' => 'local_flag_changed', 'remaining' => $remaining );
}
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$deleted = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$table} WHERE id = %d AND BINARY name = BINARY %s AND BINARY email = BINARY %s AND BINARY phone = BINARY %s AND BINARY email_hash = BINARY %s AND BINARY phone_hash = BINARY %s AND BINARY level = BINARY %s AND BINARY reason = BINARY %s AND BINARY order_ref = BINARY %s AND BINARY source = BINARY %s AND BINARY created_at = BINARY %s AND BINARY updated_at = BINARY %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table derives only from $wpdb->prefix; every row value is a placeholder.
$canonical['id'],
$canonical['name'],
$canonical['email'],
$canonical['phone'],
$canonical['email_hash'],
$canonical['phone_hash'],
$canonical['level'],
$canonical['reason'],
$canonical['order_ref'],
$canonical['source'],
$canonical['created_at'],
$canonical['updated_at']
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact compare-and-delete prevents deleting a concurrently changed/reused PII row.
if ( false === $deleted ) {
return array( 'kind' => 'retry', 'reason' => 'local_flag_storage', 'remaining' => $remaining );
}
if ( 0 === (int) $deleted ) {
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$verify = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d LIMIT 1", $expected['id'] ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table derives only from $wpdb->prefix.
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- distinguish a lost delete acknowledgement from a concurrent row change.
if ( '' !== $wpdb->last_error ) {
return array( 'kind' => 'retry', 'reason' => 'local_flag_storage', 'remaining' => $remaining );
}
if ( $verify ) {
$verify_digest = orderguard_gdpr_local_flag_row_digest( $verify );
return array(
'kind' => hash_equals( $expected['digest'], $verify_digest ) ? 'retry' : 'blocked',
'reason' => hash_equals( $expected['digest'], $verify_digest ) ? 'local_flag_storage' : 'local_flag_changed',
'remaining' => $remaining,
);
}
}
if ( (int) $deleted > 0 ) {
if ( function_exists( 'orderguard_history_forget' ) ) {
orderguard_history_forget( $canonical['email'], $canonical['phone'] );
}
if ( function_exists( 'orderguard_review_queue_invalidate' ) ) {
orderguard_review_queue_invalidate();
}
}
array_shift( $remaining );
++$processed;
}
return array(
'kind' => empty( $remaining ) ? 'done' : 'more',
'reason' => '',
'remaining' => $remaining,
);
}
/**
* Manual-block level for a checkout identity array ('email','phone').
* Returns '', 'flag', 'block_cod', or 'hard_block'.
*/
function orderguard_manual_block_level_for_identity( $identity, $country = null ) {
if ( ! orderguard_manual_flag_enabled() ) {
return '';
}
$email = isset( $identity['email'] ) ? $identity['email'] : '';
$phone = isset( $identity['phone'] ) ? $identity['phone'] : '';
// The SHOPPER'S OWN billing country, never the store's -- deriving under the
// wrong country is the single mistake that could match a different human, so
// callers with an order pass its country and checkout uses the live
// selection. orderguard_phone_e164() declines outright on anything it cannot be sure
// of, in which case we match the exact spelling only.
$country = ( is_string( $country ) && preg_match( '/^[A-Za-z]{2}$/', $country ) )
? strtoupper( $country )
: orderguard_get_checkout_country();
$flag = orderguard_flag_find_by_hash_set(
( '' !== $email ) ? orderguard_hash_value( $email ) : '',
orderguard_phone_hashes_for_lookup( $phone, $country )
);
return $flag ? $flag->level : '';
}
/**
* ===========================================================================
* CHECKOUT REJECTION (M-5) -- one decision, three consumers.
* ===========================================================================
* "Would OrderGuard reject this order at checkout submission?" answered in ONE
* place, so the three consumers can never drift apart:
* 1. orderguard_enforce_manual_block_on_order (priority 15) throws on the manual
* reasons;
* 2. orderguard_enforce_cod_on_order (priority 20) throws on the COD-gate reason;
* 3. orderguard_handle_new_order (woocommerce_new_order, which fires BEFORE both)
* uses it to WITHHOLD the /v1/check check-in for an order that is about
* to be rejected -- otherwise the aborted attempt leaves orphaned
* 'pending' sightings on the server forever and burns the new-identity
* velocity budget (the C-1 anti-enumeration wall).
*
* Returns '' (proceed) or the first applicable reason in enforcement order:
* 'hard_block' | 'block_cod' | 'cod_gate'. Each layer keeps its own feature
* gates INSIDE this function, so the prediction sees exactly what the
* enforcers see.
*
* COST NOTE: the flags lookup is one local indexed query; orderguard_cod_risk_decision
* is transient-cached per identity (~5 min), and at checkout that cache is
* almost always warm from the gateway filter that just rendered the payment
* methods -- so the pre-check adds no server round-trip in practice.
*/
function orderguard_checkout_rejection_reason( $order ) {
if ( ! $order instanceof WC_Order || orderguard_is_gdpr_erased_order( $order ) ) {
return '';
}
// Layer 1: manual flags (local table; works even without server activation).
if ( orderguard_manual_flag_enabled() ) {
$flag = orderguard_flag_find_for_order( $order );
if ( $flag ) {
if ( 'hard_block' === $flag->level ) {
return 'hard_block';
}
if ( 'block_cod' === $flag->level && 'cod' === $order->get_payment_method() ) {
return 'block_cod';
}
}
}
// Layer 2: the automatic COD gate (server decision; fail-open on any doubt).
if ( orderguard_is_activated() && orderguard_cod_gating_enabled() && 'cod' === $order->get_payment_method() ) {
$identity = orderguard_identity_from_order( $order );
if ( '' !== $identity['phone'] || '' !== $identity['email'] ) {
if ( 'hide' === orderguard_cod_risk_decision( $identity ) ) {
return 'cod_gate';
}
}
}
return '';
}
/**
* Record "this order's checkout was (or is about to be) rejected" on the order
* itself. Written by orderguard_handle_new_order (when it withholds the check-in) and
* by both enforcers just before they throw. Persisted with an explicit save():
* the enforcement throw aborts WooCommerce's own later order write, so staged
* meta without a save() here would be silently lost (the same trap the
* COD-block insights dedup avoids with a transient). Failure to persist must
* never block the customer-facing rejection, hence the Throwable guard.
*/
function orderguard_mark_checkout_rejected( $order ) {
try {
if ( 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_checkout_rejected' ) ) {
$order->update_meta_data( '_orderguard_checkout_rejected', 'yes' );
$order->save();
}
} catch ( Throwable $t ) {
orderguard_log( 'OrderGuard: could not persist checkout-rejected mark on order #' . $order->get_id() . ' -- ' . $t->getMessage() );
}
}
/**
* TRUE while a checkout-rejected order still LOOKS like the aborted artifact
* the rejection left behind (never paid, auto-cancelled by hold-stock, etc.).
* The status condition is load-bearing: a merchant can rescue such an order by
* hand (take prepayment over the phone, set it to processing) -- from that
* moment it is a real order and every guard keyed on this helper must stand
* down so the normal reporting/recovery machinery picks it up again.
*/
function orderguard_is_checkout_rejected_dormant( $order ) {
if ( ! $order instanceof WC_Order ) {
return false;
}
if ( 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_checkout_rejected' ) ) {
return false;
}
return in_array(
$order->get_status(),
array( 'pending', 'cancelled', 'failed', 'checkout-draft', 'draft', 'auto-draft', 'trash' ),
true
);
}
/**
* TRUE for an order whose ONLY reportable fact is a no-signal status -- no
* refund, no COD non-collection mark, no courier-verified outcome, and a
* terminal-or-parked status the server maps to "no signal" ('cancelled' ->
* unknown, 'pending'/'failed' -> ignored; outcomes.py _status_to_signal).
* Creating sightings for such an order can never resolve them: they would sit
* 'pending' in /v1/pending forever (or settle to 'unknown' junk rows) and
* charge the velocity budget for nothing. Used by the sweep's 404-recovery
* guard and by the history import's skip guard (0.2.16) -- NEVER at
* check-in time (a brand-new order is legitimately signal-less; its
* signals are its future).
*
* Deliberately reads _orderguard_courier_outcome without the courier-sync consent
* gate: consent gates TRANSMITTING courier data; here it only proves locally
* that the order was real, i.e. that recovery is worth doing.
*/
function orderguard_order_is_signalless_dead( $order ) {
if ( ! $order instanceof WC_Order ) {
return false;
}
if ( ! in_array( $order->get_status(), array( 'pending', 'cancelled', 'failed' ), true ) ) {
return false;
}
if ( (float) $order->get_total_refunded() > 0 ) {
return false;
}
if ( 'yes' === (string) orderguard_get_order_meta( $order, '_orderguard_not_collected' ) ) {
return false;
}
$courier_outcomes = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' );
if ( is_array( $courier_outcomes ) ) {
foreach ( $courier_outcomes as $courier_outcome ) {
if ( in_array( $courier_outcome, array( 'kept', 'returned', 'not_collected' ), true ) ) {
return false;
}
}
}
return true;
}
/**
* Priority 30 on both checkout hooks -- runs ONLY when no enforcer threw
* (an exception at 15/20 unwinds do_action and later callbacks never fire),
* i.e. exactly when the checkout genuinely passed. If this order carries a
* rejection mark from an earlier attempt (classic and block checkout both
* REUSE the session's pending/draft order on retry, and woocommerce_new_order
* does not fire again), the mark is lifted and the check-in that
* orderguard_handle_new_order withheld runs now -- so a customer who was refused COD
* and then paid in advance is checked in like any other customer, at the
* moment their order became real.
*/
add_action( 'woocommerce_checkout_order_processed', 'orderguard_clear_checkout_rejection', 30, 1 );
add_action( 'woocommerce_store_api_checkout_order_processed', 'orderguard_clear_checkout_rejection', 30, 1 );
function orderguard_clear_checkout_rejection( $order_or_id ) {
$order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( (int) $order_or_id );
if ( ! $order instanceof WC_Order ) {
return;
}
if ( 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_checkout_rejected' ) ) {
return; // the overwhelmingly common case: one meta read, done
}
// Clear FIRST (this hook's knowledge -- "enforcement passed" -- is ground
// truth), so the transport chokepoint guard cannot refuse the check-in
// below on the strength of a mark that just became stale.
orderguard_delete_order_meta( $order, '_orderguard_checkout_rejected' );
$order->save();
orderguard_log( 'OrderGuard: order #' . $order->get_id() . ' passed checkout after an earlier rejection -- checking it in now' );
orderguard_handle_new_order( $order->get_id() );
}
/**
* ENFORCEMENT -- apply a manual block at order submission. Works on classic +
* block + incognito because it reads identity from the ORDER. The un-bypassable
* layer, independent of the automatic COD-gating toggle.
* - hard_block : reject ANY order from this customer.
* - block_cod : reject a COD order (they may still prepay).
* - flag : no checkout effect (advisory only).
*/
add_action( 'woocommerce_checkout_order_processed', 'orderguard_enforce_manual_block_on_order', 15, 1 );
add_action( 'woocommerce_store_api_checkout_order_processed', 'orderguard_enforce_manual_block_on_order', 15, 1 );
/**
* Hard guarantee: reject a blocked customer's order at submission (classic +
* Store API). Throwing aborts checkout and surfaces the message to the customer.
*
* KNOWN EDGE (verify on the target Woo version before launch, same for
* orderguard_enforce_cod_on_order below): these hooks fire AFTER the order row exists,
* so an aborted attempt can leave a stray 'pending' order (Woo's hold-stock cron
* auto-cancels it after ~60 min). If undesirable, either move the check to a
* pre-creation validation hook (woocommerce_checkout_process /
* woocommerce_store_api_validate_checkout) or mark the order cancelled here
* before throwing. Not changed blindly -- needs a live blocked-order test.
*/
function orderguard_enforce_manual_block_on_order( $order_or_id ) {
$order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( (int) $order_or_id );
if ( ! $order instanceof WC_Order ) {
return;
}
// M-5: the throw conditions live in orderguard_checkout_rejection_reason() (its
// manual layer is this function's old body, gates included), shared with
// the check-in pre-check so the two can never diverge.
$why = orderguard_checkout_rejection_reason( $order );
if ( 'hard_block' === $why ) {
orderguard_mark_checkout_rejected( $order );
throw new Exception( esc_html__( "This order can't be completed online. Please contact the store to place your order.", 'orderguard' ) );
}
if ( 'block_cod' === $why ) {
orderguard_mark_checkout_rejected( $order );
throw new Exception( esc_html__( "Cash on delivery isn't available for this order. Please choose another payment method.", 'orderguard' ) );
}
}
/**
* UX layer -- hide COD in classic checkout for a manually COD-blocked (or hard-
* blocked) customer, independent of the auto-gating toggle. Block checkout is
* covered by the submission enforcement above (the guarantee on every checkout).
*/
add_filter( 'woocommerce_available_payment_gateways', 'orderguard_manual_block_hide_cod', 21, 1 );
function orderguard_manual_block_hide_cod( $gateways ) {
if ( is_admin() || ! isset( $gateways['cod'] ) || ! orderguard_manual_flag_enabled() ) {
return $gateways;
}
$identity = orderguard_get_checkout_identity();
if ( empty( $identity['phone'] ) && empty( $identity['email'] ) ) {
return $gateways;
}
$level = orderguard_manual_block_level_for_identity( $identity );
if ( 'block_cod' === $level || 'hard_block' === $level ) {
unset( $gateways['cod'] );
}
return $gateways;
}
/**
* #43 -- tell the shopper WHY cash on delivery disappeared, rather than letting
* it vanish silently mid-checkout. Rendered just above the payment methods.
*
* Deliberately RECOMPUTED here instead of reading a flag set by the gateway
* filter: this action fires BEFORE the payment block renders, and rendering the
* payment block is what triggers that filter -- a flag would not be set yet. The
* extra cost is one local indexed lookup; no server call, no PII leaves.
*
* Shown for 'block_cod' ONLY. A 'hard_block' shopper cannot complete the order
* by any method, so "please choose another payment method" would be wrong
* advice -- they are stopped at submission with the correct message instead.
*
* Reuses the exact sentence the submission enforcement throws, so the shopper
* reads one consistent message and it is already in the BG translation map.
*/
add_action( 'woocommerce_review_order_before_payment', 'orderguard_cod_blocked_notice' );
function orderguard_cod_blocked_notice() {
if ( is_admin() || ! orderguard_manual_flag_enabled() ) {
return;
}
// Only speak up if this store actually offers COD, otherwise the sentence
// is nonsense on a store that never had it.
if ( ! function_exists( 'WC' ) ) {
return;
}
$gw = WC()->payment_gateways();
if ( ! $gw || ! method_exists( $gw, 'payment_gateways' ) ) {
return;
}
$all = $gw->payment_gateways();
if ( ! isset( $all['cod'] ) || ! is_object( $all['cod'] ) || 'yes' !== $all['cod']->enabled ) {
return;
}
$identity = orderguard_get_checkout_identity();
if ( '' === $identity['phone'] && '' === $identity['email'] ) {
return; // unknown shopper -> say nothing (fail open)
}
if ( 'block_cod' !== orderguard_manual_block_level_for_identity( $identity ) ) {
return;
}
echo '
'
. esc_html__( "Cash on delivery isn't available for this order. Please choose another payment method.", 'orderguard' )
. '
';
}
/**
* Would COD have been hidden for this order's customer at checkout? Mirrors the
* two gateway filters exactly (orderguard_maybe_hide_cod + orderguard_manual_block_hide_cod), so
* the Insights 'hidden' metric counts only orders where COD was genuinely
* withheld -- not merely orders from an over-threshold customer. Reuses the same
* cached server verdict the checkout already computed, so there's no extra
* server round-trip in practice. Fails closed (returns false) on no identity.
*/
function orderguard_cod_was_hidden_for_order( $order ) {
if ( ! $order instanceof WC_Order || orderguard_is_gdpr_erased_order( $order ) ) {
return false;
}
$identity = orderguard_identity_from_order( $order );
if ( '' === $identity['phone'] && '' === $identity['email'] ) {
return false; // no identity => checkout would have failed open (COD shown)
}
// Auto-gating: the server's authoritative hide verdict (as orderguard_maybe_hide_cod).
if ( orderguard_cod_gating_enabled() && 'hide' === orderguard_cod_risk_decision( $identity ) ) {
return true;
}
// Manual block-COD / hard-block (as orderguard_manual_block_hide_cod).
$level = orderguard_manual_block_level_for_identity( $identity );
return ( 'block_cod' === $level || 'hard_block' === $level );
}
/**
* ===========================================================================
* INSIGHTS -- local, privacy-preserving activity tally.
* ===========================================================================
* Counts (only) of two things OrderGuard did for this store:
* - 'blocked' : a risky customer's COD order rejected at checkout (Layer 1).
* - 'flagged' : an order whose customer crossed the flag-for-review limits.
*
* Stored ENTIRELY on the merchant's site (never sent to the server) as per-day
* buckets in a single option -- no order values, no identities, just counts.
* This powers the Insights report's time filtering by summing the buckets in a
* date range. Buckets older than the retention window are pruned on write.
*/
define( 'ORDERGUARD_INSIGHTS_OPTION', 'orderguard_insights_data' );
define( 'ORDERGUARD_INSIGHTS_RETENTION_DAYS', 730 ); // ~24 months
/**
* THE INSIGHTS CLOCK -- the single definition of "today" for the daily
* buckets. SITE-LOCAL date (Europe/Sofia on the pilot stores), written as the
* standard WP idiom gmdate() over current_time('timestamp'), which is also
* exactly how the Insights page (admin-welcome.php orderguard_render_insights_page)
* derives its $today and range endpoints. Writer and reader MUST share one
* clock: if the write bucketed in site time while the read ranged in UTC,
* every event recorded between local midnight and 02:00/03:00 (Sofia is
* UTC+2/+3) would land in a "tomorrow" bucket the dashboard's range does not
* cover yet -- counts silently missing until the small hours. Any future
* caller computing a bucket key or a range endpoint must use THIS function
* (or byte-match the idiom), never bare gmdate('Y-m-d') / date('Y-m-d').
*/
function orderguard_insights_today() {
return gmdate( 'Y-m-d', current_time( 'timestamp' ) );
}
/**
* Record one insights event for today. $type is one of:
* 'checked' | 'flagged' | 'blocked' | 'hidden'.
* $value is the order's total (optional) -- summed into the day's 'value' so the
* Insights hero can show the money value of intercepted orders in store currency.
*/
function orderguard_insights_record( $type, $value = 0 ) {
if ( ! in_array( $type, array( 'checked', 'flagged', 'blocked', 'hidden' ), true ) ) {
return;
}
$insights_ok = false;
$data = orderguard_get_option( ORDERGUARD_INSIGHTS_OPTION, array(), $insights_ok );
if ( ! $insights_ok ) {
return;
}
if ( ! is_array( $data ) ) {
$data = array();
}
$today = orderguard_insights_today(); // site timezone -- same clock as the Insights page reads with
if ( ! isset( $data[ $today ] ) || ! is_array( $data[ $today ] ) ) {
$data[ $today ] = array();
}
$current = isset( $data[ $today ][ $type ] ) ? (int) $data[ $today ][ $type ] : 0;
$data[ $today ][ $type ] = $current + 1;
if ( $value > 0 ) {
$current_value = isset( $data[ $today ]['value'] ) ? (float) $data[ $today ]['value'] : 0.0;
$data[ $today ]['value'] = $current_value + (float) $value;
}
$data = orderguard_insights_prune( $data );
// autoload=false: this is only read on the Insights page, not every request.
orderguard_update_option( ORDERGUARD_INSIGHTS_OPTION, $data, false );
}
/**
* Drop buckets older than the retention window (keeps the option bounded).
* 'Y-m-d' strings sort chronologically, so a plain string compare is safe and
* timezone-agnostic.
*
* The cutoff is computed in the DATE-STRING domain: take today from the one
* insights clock (orderguard_insights_today), then step back N days in pure UTC
* arithmetic anchored at noon. Subtracting N*86400 straight from
* current_time('timestamp') -- the old form -- applied TODAY'S UTC offset to
* an instant two years back, so around a DST change the cutoff could land a
* day off. Immaterial for a ~24-month retention, but this function must not
* model a second, subtly different clock next to orderguard_insights_today().
*/
function orderguard_insights_prune( $data ) {
if ( ! is_array( $data ) ) {
return array();
}
$cutoff_day = gmdate( 'Y-m-d', strtotime( orderguard_insights_today() . ' 12:00:00 UTC' ) - ( ORDERGUARD_INSIGHTS_RETENTION_DAYS * DAY_IN_SECONDS ) );
foreach ( array_keys( $data ) as $day ) {
if ( (string) $day < $cutoff_day ) {
unset( $data[ $day ] );
}
}
return $data;
}
/**
* Sum insights metrics between two 'Y-m-d' dates (inclusive). Pass null for
* $start and/or $end to leave that side open. Returns an array with keys
* checked / flagged / blocked / hidden (ints) and value (float). The derived
* "COD auto-hidden / risky orders avoided" figure is blocked + hidden.
*/
function orderguard_insights_totals( $start, $end ) {
$data = orderguard_get_option( ORDERGUARD_INSIGHTS_OPTION, array() );
if ( ! is_array( $data ) ) {
$data = array();
}
$totals = array( 'checked' => 0, 'flagged' => 0, 'blocked' => 0, 'hidden' => 0, 'value' => 0.0 );
foreach ( $data as $day => $counts ) {
$day = (string) $day;
if ( null !== $start && $day < $start ) {
continue;
}
if ( null !== $end && $day > $end ) {
continue;
}
if ( ! is_array( $counts ) ) {
continue;
}
$totals['checked'] += isset( $counts['checked'] ) ? (int) $counts['checked'] : 0;
$totals['flagged'] += isset( $counts['flagged'] ) ? (int) $counts['flagged'] : 0;
$totals['blocked'] += isset( $counts['blocked'] ) ? (int) $counts['blocked'] : 0;
$totals['hidden'] += isset( $counts['hidden'] ) ? (int) $counts['hidden'] : 0;
$totals['value'] += isset( $counts['value'] ) ? (float) $counts['value'] : 0.0;
}
return $totals;
}
/**
* Build a small time series for the activity chart between two 'Y-m-d' dates.
* Short ranges (<= 14 days) bucket by DAY; longer ranges bucket by WEEK (Mon).
* Each entry: array( 'label' => string, 'checked' => int, 'prevented' => int ).
* Capped to the most recent 12 buckets so the chart never overflows.
*
* CLOCK CONTRACT: $start/$end must be SITE-LOCAL dates from the insights
* clock (orderguard_insights_today / the Insights page's identical idiom). Inside,
* the strtotime()/gmdate() round-trips work purely in the DATE-STRING domain
* -- strtotime('Y-m-d') is UTC midnight, +86400 steps have no DST in UTC, and
* gmdate() maps each step back to the exact next date string -- so the keys
* fed to orderguard_insights_totals() are byte-stable against the recorded bucket
* keys. Do NOT "fix" these gmdate() calls to current_time()/wp_date(): that
* would shift the internal arithmetic into a DST-bearing timezone and break
* the string round-trip this function depends on.
*/
function orderguard_insights_series( $start, $end ) {
$start_ts = strtotime( $start );
$end_ts = strtotime( $end );
if ( false === $start_ts || false === $end_ts || $start_ts > $end_ts ) {
return array();
}
$series = array();
$span_days = ( ( $end_ts - $start_ts ) / DAY_IN_SECONDS ) + 1;
if ( $span_days <= 14 ) {
for ( $ts = $start_ts; $ts <= $end_ts; $ts += DAY_IN_SECONDS ) {
$d = gmdate( 'Y-m-d', $ts );
$t = orderguard_insights_totals( $d, $d );
$series[] = array(
'label' => orderguard_i18n_date( 'j M', $ts ),
'checked' => (int) $t['checked'],
'prevented' => (int) $t['blocked'] + (int) $t['hidden'],
);
}
} else {
$week_start = strtotime( 'monday this week', $start_ts );
if ( $week_start > $start_ts ) {
$week_start = strtotime( '-7 days', $week_start );
}
for ( $ws = $week_start; $ws <= $end_ts; $ws += 7 * DAY_IN_SECONDS ) {
$ds = gmdate( 'Y-m-d', $ws );
$de = gmdate( 'Y-m-d', $ws + ( 6 * DAY_IN_SECONDS ) );
$t = orderguard_insights_totals( $ds, $de );
$series[] = array(
'label' => orderguard_i18n_date( 'j M', $ws ),
'checked' => (int) $t['checked'],
'prevented' => (int) $t['blocked'] + (int) $t['hidden'],
);
}
}
if ( count( $series ) > 12 ) {
$series = array_slice( $series, -12 );
}
return $series;
}
/**
* Record the new-order insights events (checked / flagged / hidden), each guarded
* by order meta so a repeated woocommerce_new_order fire can't double-count.
* - checked : every order OrderGuard checks.
* - flagged : customer crosses the flag-for-review limits.
* - hidden : a customer COD was actually withheld from who then paid another
* way -- i.e. COD was hidden from them (Layer 2). Gated on the same
* decision the checkout uses to hide COD (orderguard_cod_was_hidden_for_order)
* so it can't over-count merely-over-threshold customers whose COD
* was never going to be hidden. The COD-order-refused case (Layer 1)
* is recorded as 'blocked' in the enforcement path instead, so the
* two never overlap.
*/
function orderguard_insights_record_new_order( $order, $scores ) {
if ( ! $order instanceof WC_Order ) {
return;
}
$dirty = false;
if ( 'yes' !== orderguard_get_order_meta( $order, '_orderguard_insights_checked' ) ) {
orderguard_insights_record( 'checked' );
$order->update_meta_data( '_orderguard_insights_checked', 'yes' );
$dirty = true;
}
$nc = isset( $scores['not_collected_count'] ) ? (int) $scores['not_collected_count'] : 0;
$rt = isset( $scores['returned_count'] ) ? (int) $scores['returned_count'] : 0;
if ( 'yes' !== orderguard_get_order_meta( $order, '_orderguard_insights_flagged' ) && orderguard_should_flag( $nc, $rt ) ) {
orderguard_insights_record( 'flagged' );
$order->update_meta_data( '_orderguard_insights_flagged', 'yes' );
$dirty = true;
}
if ( 'yes' !== orderguard_get_order_meta( $order, '_orderguard_insights_hidden' )
&& 'cod' !== $order->get_payment_method()
&& orderguard_cod_was_hidden_for_order( $order ) ) {
orderguard_insights_record( 'hidden', (float) $order->get_total() );
$order->update_meta_data( '_orderguard_insights_hidden', 'yes' );
$dirty = true;
}
if ( $dirty ) {
$order->save();
}
}
/**
* The gateway filter: decide whether to remove COD for the current checkout.
*/
add_filter( 'woocommerce_available_payment_gateways', 'orderguard_maybe_hide_cod', 20, 1 );
function orderguard_maybe_hide_cod( $gateways ) {
// Only relevant in the checkout context, only if COD is even offered.
if ( is_admin() || ! isset( $gateways['cod'] ) ) {
return $gateways;
}
if ( ! orderguard_is_activated() || ! orderguard_cod_gating_enabled() ) {
return $gateways; // dormant or disabled => never touch payment methods
}
// Get the customer's identity from the checkout session. Fail OPEN if we
// have nothing to look them up with.
$identity = orderguard_get_checkout_identity();
if ( empty( $identity['phone'] ) && empty( $identity['email'] ) ) {
return $gateways; // no identity yet => keep COD
}
$decision = orderguard_cod_risk_decision( $identity );
// Only hide when a count threshold is reached. Anything else keeps COD.
if ( 'hide' === $decision ) {
unset( $gateways['cod'] );
}
return $gateways;
}
/**
* Read whatever identity is available for the CURRENT checkout render.
* At checkout the fields may be partially filled; we use what's present.
*
* FOUR sources, in precedence order. Phone and email are resolved INDEPENDENTLY
* -- a shopper can be recognised by whichever of the two we manage to see.
*
* 1. Top-level $_POST -- the classic checkout SUBMISSION, and any caller
* that posts the fields directly.
* 2. $_POST['post_data'] -- #43. WooCommerce's update_order_review AJAX (the
* call that RE-RENDERS the payment methods) sends
* the whole checkout form as ONE url-encoded
* string; the individual fields are NOT top-level
* keys. Without unpacking it, both gateway filters
* ran blind on every re-render, so a blocked GUEST
* kept seeing COD right up to the final click.
* 3. WC()->customer -- the session. A blocked customer is by definition
* a REPEAT customer, so in their own browser Woo
* usually still holds the phone/email from their
* last order -- which lets us drop COD on the very
* FIRST render, before they type anything. It is
* also the only source block checkout keeps
* current (cf. orderguard_should_hide_cod_for_session).
* 4. Logged-in user meta -- the original fallback.
*
* READ-ONLY: this decides what to DISPLAY, it changes nothing, so there is no
* nonce to verify here. Everything downstream fails OPEN -- an identity we
* cannot resolve means COD stays visible, never that a shopper is blocked.
*/
// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same as orderguard_get_checkout_country(): read-only display logic that writes nothing and changes no state. Phone via sanitize_text_field(), email via sanitize_email(), both after a type-checked parse_str().
function orderguard_get_checkout_identity() {
$phone = '';
$email = '';
// 1) Explicit top-level POST.
if ( isset( $_POST['billing_phone'] ) ) {
$phone = sanitize_text_field( wp_unslash( $_POST['billing_phone'] ) );
}
if ( isset( $_POST['billing_email'] ) ) {
$email = sanitize_email( wp_unslash( $_POST['billing_email'] ) );
}
// 2) The packed update_order_review payload (#43).
if ( ( '' === $phone || '' === $email ) && isset( $_POST['post_data'] ) ) {
$raw = wp_unslash( $_POST['post_data'] );
if ( is_string( $raw ) && '' !== $raw ) {
$parsed = array();
parse_str( $raw, $parsed );
if ( '' === $phone && isset( $parsed['billing_phone'] ) && is_string( $parsed['billing_phone'] ) ) {
$phone = sanitize_text_field( $parsed['billing_phone'] );
}
if ( '' === $email && isset( $parsed['billing_email'] ) && is_string( $parsed['billing_email'] ) ) {
$email = sanitize_email( $parsed['billing_email'] );
}
}
}
// 3) The WooCommerce customer session (returning guest + block checkout).
if ( ( '' === $phone || '' === $email ) && function_exists( 'WC' ) && WC()->customer ) {
if ( '' === $phone ) {
$phone = (string) WC()->customer->get_billing_phone();
}
if ( '' === $email ) {
$email = (string) WC()->customer->get_billing_email();
}
}
// 4) Logged-in customer's stored details.
if ( '' === $phone && '' === $email && is_user_logged_in() ) {
$customer = wp_get_current_user();
$email = $customer->user_email;
$phone = get_user_meta( $customer->ID, 'billing_phone', true );
}
return array( 'phone' => (string) $phone, 'email' => (string) $email );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
/**
* Ask the server for this identity's raw counts and decide 'hide' | 'keep'.
* COD is hidden if the customer's non-collected count OR returned count has
* reached its threshold. Fails OPEN ('keep') on ANY uncertainty: server error,
* timeout, or counts below both thresholds. Result is cached briefly per
* identity to avoid calling the server on every checkout re-render.
*
* $country (optional, M-2): a two-letter country to use for the E.164 phone
* derivation instead of the live checkout context. Checkout callers pass
* nothing -- the shopper's own selection wins, exactly as before. The ORDER
* PANEL passes the order's billing country: wp-admin has no checkout POST, so
* without it the derivation would fall back to the store base country and the
* panel's gate-state read could miss an e164-recorded history. The country is
* part of the cache fingerprint below, so a panel verdict can never be served
* to a checkout derived under a different country (and vice versa).
*/
function orderguard_cod_risk_decision( $identity, $country = null ) {
$hashed = array();
$phone_hash = orderguard_phone_hash( $identity['phone'] );
if ( '' !== $phone_hash ) {
$hashed['phone_hash'] = $phone_hash;
}
// H-2 dual-send. Country = the shopper's own checkout selection ('' if
// absent -> no derivation). This is a READ (the server additionally drops
// any hash this store has no sighting for), so a shopper who typed a
// national number is recognised even if their past orders here were
// recorded in international form -- the case COD-gating was silently
// missing. Also lets the exclusion guard below see the e164 spelling.
$checkout_country = ( is_string( $country ) && preg_match( '/^[A-Z]{2}$/', $country ) )
? $country
: orderguard_get_checkout_country();
$e164_hash = orderguard_phone_hash_e164( $identity['phone'], $checkout_country );
if ( '' !== $e164_hash ) {
$hashed['phone_hash_e164'] = $e164_hash;
}
if ( '' !== $identity['email'] ) {
$hashed['email_hash'] = orderguard_hash_value( $identity['email'] );
}
// EXCLUSION GUARD -- an excluded customer is never COD-gated. Returns the
// fail-open default, so a staff member placing a test order is treated
// exactly like an unknown customer: nothing hidden, nothing asked.
//
// MUST come BEFORE the transient read below. A decision cached as 'hide'
// lives ~5 minutes, so guarding after the cache would keep COD-gating
// someone for five minutes after the merchant excluded them -- exactly when
// they're at the checkout testing whether the exclusion worked.
if ( 'not_excluded' !== ( orderguard_owned_exclusion_request_hashes_state( array_values( $hashed ) )['status'] ?? 'unknown' )
|| orderguard_pending_gdpr_erasure_matches_hashes( array_values( $hashed ) ) ) {
return 'keep';
}
// Build a short-lived cache key from the identity (hashed, not raw).
// Includes the checkout country: the e164 derivation depends on it, so a
// shopper switching the country select must not be served a verdict cached
// under the other country's derivation.
$fingerprint = md5( strtolower( trim( $identity['phone'] . '|' . $identity['email'] ) ) . '|' . $checkout_country );
$cache_key = 'orderguard_cod_' . orderguard_gdpr_erasure_cache_epoch() . '_' . $fingerprint;
$cached = orderguard_get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$decision = 'keep'; // default fail-open
// Ask the SERVER to make the hide/keep decision. We send the merchant's
// desired thresholds, but the server -- knowing this account's plan -- is
// the authority: Free accounts always get the server defaults, Pro accounts
// get their custom thresholds. The plugin never applies the thresholds
// itself (so editing the plugin can't unlock Pro behaviour). We only obey
// the returned hide_cod boolean. SHORT timeout -- never make a shopper wait.
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
return 'keep';
}
try {
// Recheck inside the same mutex used to accept an erasure. Checkout
// remains fail-open, but an accepted request can no longer race one
// final identity lookup onto the wire.
if ( 'not_excluded' !== ( orderguard_owned_exclusion_hashes_state( array_values( $hashed ) )['status'] ?? 'unknown' )
|| orderguard_pending_gdpr_erasure_matches_hashes( array_values( $hashed ) ) ) {
return 'keep';
}
$response = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/cod-decision',
array(
'method' => 'POST',
'timeout' => 2, // aggressive: checkout must stay fast
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( array(
'phone_hash' => isset( $hashed['phone_hash'] ) ? $hashed['phone_hash'] : null,
'email_hash' => isset( $hashed['email_hash'] ) ? $hashed['email_hash'] : null,
'name_hash' => null,
'phone_hash_e164' => isset( $hashed['phone_hash_e164'] ) ? $hashed['phone_hash_e164'] : null,
'notcollected_threshold' => orderguard_cod_notcollected_threshold(),
'returned_threshold' => orderguard_cod_returned_threshold(),
) ),
)
);
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_array( $data ) && ! empty( $data['hide_cod'] ) ) {
$decision = 'hide';
}
}
// On wp_error / non-200 / timeout: $decision stays 'keep' (fail-open).
// Cache briefly so repeated filter fires in one checkout don't re-call.
set_transient( $cache_key, $decision, 5 * MINUTE_IN_SECONDS );
return $decision;
}
/**
* ===========================================================================
* PLAN AWARENESS -- is this store Free or Pro? (for showing/hiding Pro UI)
* ===========================================================================
* Asks the server (/v1/me) and CACHES the answer in a transient, so the admin
* pages don't hit the server on every load. Plan changes are rare, so a short
* cache is fine and keeps things fast at scale. DISPLAY ONLY -- Pro entitlement
* is always enforced server-side, so a stale/tampered plan here can't unlock
* anything.
*
* Fail-safe: on any error, returns 'free' (never wrongly presents Pro as
* unlocked when we can't confirm).
*/
define( 'ORDERGUARD_PLAN_CACHE_KEY', 'orderguard_plan_cache' );
define( 'ORDERGUARD_PLAN_CACHE_TTL', 10 * MINUTE_IN_SECONDS );
/**
* Fetch this account's /v1/me (plan, subscription status, Paddle public config),
* cached as a whole array. Fail-safe to array( 'plan' => 'free' ).
*/
require_once __DIR__ . '/billing-upgrade.php';
require_once __DIR__ . '/support.php';
function orderguard_get_me( $force_refresh = false ) {
if ( ! orderguard_is_activated() ) {
return array( 'plan' => 'free' );
}
if ( ! $force_refresh ) {
$cached = orderguard_get_transient( ORDERGUARD_PLAN_CACHE_KEY );
if ( is_array( $cached ) ) {
return $cached;
}
}
$me = array( 'plan' => 'free' ); // fail-safe default
$response = wp_remote_get(
ORDERGUARD_SERVER_URL . '/v1/me',
array(
'timeout' => 4,
'headers' => array(
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
)
);
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_array( $data ) ) {
$me = $data;
}
}
set_transient( ORDERGUARD_PLAN_CACHE_KEY, $me, ORDERGUARD_PLAN_CACHE_TTL );
return $me;
}
function orderguard_get_plan( $force_refresh = false ) {
$me = orderguard_get_me( $force_refresh );
return ( isset( $me['plan'] ) && 'pro' === $me['plan'] ) ? 'pro' : 'free';
}
/**
* True if this store is on the Pro plan. Convenience wrapper.
*/
function orderguard_is_pro() {
return ( 'pro' === orderguard_get_plan() );
}
/**
* Clear the cached plan (e.g. after an upgrade, or the manual "refresh" link).
*/
function orderguard_clear_plan_cache() {
orderguard_delete_transient( ORDERGUARD_PLAN_CACHE_KEY );
do_action( 'orderguard_plan_cache_cleared' );
}
/**
* ---------------------------------------------------------------------------
* Licence-state notice: PAID BUT NOT INSTALLED
* ---------------------------------------------------------------------------
* The account's plan is Pro but the OrderGuard Pro add-on is not active on
* this site -- the merchant is paying for features nothing here delivers, and
* this free plugin is the only thing installed that can tell them. That makes
* this notice free-plugin territory BY NECESSITY, and guideline 11 territory
* BY NATURE: it is a pointer to a separately distributed add-on, not locked
* functionality (no Pro code ships here -- guideline 5 holds).
*
* Quiet by design: only on the dashboard, the plugins screen and OrderGuard's
* own pages; capability-gated; dismissed per user via an explicit nonce-
* checked link (server-side, so it STAYS dismissed -- the core notice X only
* hides a notice until the next page load). The plan test runs LAST because
* orderguard_is_pro() is transient-cached but a cold cache costs an HTTP call.
*/
add_action( 'admin_notices', 'orderguard_pro_not_installed_notice' );
add_action( 'admin_notices', 'orderguard_pro_compatibility_notice' );
add_action( 'admin_init', 'orderguard_suppress_legacy_pro_notice' );
add_action( 'admin_post_orderguard_dismiss_pro_install', 'orderguard_dismiss_pro_install_notice' );
/**
* Is the Pro add-on active on this site or network?
*
* The plugin basename is the production-frozen cross-version identity shared
* by the build, delivery channel and four active Pro installations. Unlike a
* Pro constant or function, it is unaffected by a runtime-prefix change. W8
* may move the repository source directory, but it must never change this
* shipped basename.
*/
function orderguard_pro_addon_active() {
$plugin = 'orderguard-pro/orderguard-pro.php';
$active = (array) get_option( 'active_plugins', array() );
if ( in_array( $plugin, $active, true ) ) {
return true;
}
if ( ! is_multisite() ) {
return false;
}
$network_active = (array) get_site_option( 'active_sitewide_plugins', array() );
return array_key_exists( $plugin, $network_active );
}
/**
* Did the active Pro add-on finish booting against this free-plugin build?
*/
function orderguard_pro_addon_ready() {
return did_action( 'orderguard_pro_ready' ) > 0;
}
/**
* Old Pro reports that the free plugin is missing when its compatibility gate
* cannot find the renamed runtime API. Replace that stale notice with the
* accurate recovery notice below. admin_init runs after Pro registered its
* notice on plugins_loaded and before admin_notices renders.
*/
function orderguard_suppress_legacy_pro_notice() {
if ( orderguard_pro_addon_active() && ! orderguard_pro_addon_ready() ) {
remove_action( 'admin_notices', 'ogp_missing_free_notice' );
}
}
/**
* Screens where OrderGuard licence notices may appear. Everywhere else stays
* quiet -- an installation nudge is not an every-page emergency.
*/
function orderguard_license_notice_screen_ok() {
if ( ! function_exists( 'get_current_screen' ) ) {
return false;
}
$screen = get_current_screen();
if ( ! $screen || ! isset( $screen->id ) ) {
return false;
}
if ( 'dashboard' === $screen->id || 'plugins' === $screen->id ) {
return true;
}
return ( false !== strpos( (string) $screen->id, 'orderguard' ) );
}
function orderguard_pro_not_installed_notice() {
if ( ! current_user_can( 'manage_woocommerce' ) || ! orderguard_is_activated() ) {
return;
}
if ( orderguard_pro_addon_active() ) {
return;
}
if ( ! orderguard_license_notice_screen_ok() ) {
return;
}
if ( orderguard_get_user_meta( get_current_user_id(), 'orderguard_pro_install_notice_dismissed' ) ) {
return;
}
if ( ! orderguard_is_pro() ) {
return; // free plan without the add-on is simply the Free product
}
$dismiss_url = wp_nonce_url( admin_url( 'admin-post.php?action=orderguard_dismiss_pro_install' ), 'orderguard_dismiss_pro_install' );
echo '
OrderGuard: '
. esc_html__( 'your Pro plan is active, but the OrderGuard Pro add-on is not installed on this site. Install it to get the live scoring and custom COD limits you are already paying for.', 'orderguard' )
. '
';
}
/**
* The Pro add-on is present but did not complete its compatibility gate.
*/
function orderguard_pro_compatibility_notice() {
if ( ! current_user_can( 'manage_woocommerce' ) || ! orderguard_is_activated() ) {
return;
}
if ( ! orderguard_pro_addon_active() || orderguard_pro_addon_ready() ) {
return;
}
if ( ! orderguard_license_notice_screen_ok() ) {
return;
}
if ( ! orderguard_is_pro() ) {
return;
}
echo '
OrderGuard: ';
if ( current_user_can( 'install_plugins' ) ) {
echo esc_html__( 'your active OrderGuard Pro add-on does not match this version of OrderGuard. Replace it with the current Pro build to restore Pro features.', 'orderguard' )
. '
'
. esc_html__( 'Replace OrderGuard Pro', 'orderguard' ) . '';
} else {
echo esc_html__( 'your active OrderGuard Pro add-on does not match this version of OrderGuard. Ask a site administrator to replace it with the current Pro build.', 'orderguard' );
}
echo '
';
}
function orderguard_dismiss_pro_install_notice() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_dismiss_pro_install' ) ) {
wp_die( 'Bad nonce.' );
}
update_user_meta( get_current_user_id(), 'orderguard_pro_install_notice_dismissed', time() );
$back = wp_get_referer();
if ( ! $back ) {
$back = admin_url();
}
wp_safe_redirect( $back );
exit;
}
/**
* admin-post: FIRST-INSTALL download of the Pro add-on zip.
*
* Asks the server (API-key authed) to mint a short-lived SIGNED URL for the
* current Pro release, then sends the merchant's browser there. The browser
* fetches the zip straight from orderguard.net -- this plugin never fetches,
* stores or installs any Pro code (the merchant uploads the zip themselves
* via Plugins > Add New > Upload Plugin), which is what keeps the free
* plugin clean for .org review.
*
* The link is a courtesy hop, not an entitlement: the server re-checks the
* plan when the link is REDEEMED, so nothing that happens here can hand a
* zip to a store whose plan lapsed after the click. Failure is never fatal:
* every problem lands back on the Plan tab with a plain-language notice
* (see orderguard_flag_admin_notice for the three prodl_* messages).
*/
add_action( 'admin_post_orderguard_pro_zip', 'orderguard_handle_pro_zip' );
function orderguard_handle_pro_zip() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_pro_zip' ) ) {
wp_die( 'Bad nonce.' );
}
$back = admin_url( 'admin.php?page=orderguard&tab=plan' );
$resp = wp_remote_get(
ORDERGUARD_SERVER_URL . '/v1/pro/download-link',
array(
'timeout' => 9,
'headers' => array( 'X-OrderGuard-Key' => orderguard_get_api_key() ),
)
);
if ( is_wp_error( $resp ) ) {
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'prodl_offline', 120 );
wp_safe_redirect( $back );
exit;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
$data = json_decode( wp_remote_retrieve_body( $resp ), true );
$path = ( 200 === $code && is_array( $data ) && isset( $data['download_path'] ) )
? (string) $data['download_path']
: '';
// The server returns a PATH (same idiom as the update channel): the
// plugin owns the base URL, so a compromised proxy response can never
// bounce the merchant's browser to another host. Belt and braces, only
// the expected endpoint path is accepted.
if ( '' !== $path && 0 === strpos( $path, '/v1/pro/download?token=' ) ) {
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- cross-host to our own API; the base is the ORDERGUARD_SERVER_URL constant and the path is prefix-validated above.
wp_redirect( esc_url_raw( ORDERGUARD_SERVER_URL . $path ) );
exit;
}
if ( 402 === $code || 403 === $code ) {
// The server disagrees with our cached plan -- refresh the cache so
// the Plan tab the merchant lands on tells the truth.
orderguard_clear_plan_cache();
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'prodl_denied', 120 );
} else {
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'prodl_unavailable', 120 );
}
wp_safe_redirect( $back );
exit;
}
/**
* admin-post: open the Paddle customer portal (manage / cancel subscription).
* Asks the server for an authenticated portal URL and redirects there.
*/
add_action( 'admin_post_orderguard_paddle_portal', 'orderguard_handle_paddle_portal' );
/**
* Is this a URL we are willing to send a logged-in shop owner's browser to? (0.3.1)
*
* The portal handler below takes a URL out of an API response and redirects to
* it. Without a host check that is an open redirect: a spoofed or compromised
* response could bounce a shop owner anywhere. Since 0.3.1 the only redirect
* left is the billing portal ($purpose 'portal'); invoices are streamed as a
* PDF through the OrderGuard server and never redirect, so every other purpose
* is refused.
*
* Fixed checks that no filter can change: the URL is a string without
* whitespace or control characters; scheme https; no user or password part;
* no port or port 443; a host, compared lower-cased and exactly (no wildcards,
* no suffix matching).
*
* Built-in portal hosts: this plugin's own API host and Paddle's customer
* portal (customer-portal.paddle.com). Paddle's SANDBOX portal
* (sandbox-customer-portal.paddle.com) is added only when the plugin talks to
* the OrderGuard sandbox API endpoint exactly (https://sandbox-api.orderguard.net),
* so a production store can never be sent to the sandbox portal.
*
* The orderguard_allowed_billing_hosts filter exists only so a site whose
* ORDERGUARD_SERVER_URL points at an operator-run OrderGuard API on another
* host can add THAT host for portal redirects:
*
* add_filter( 'orderguard_allowed_billing_hosts', function ( $hosts ) {
* $hosts[] = 'portal.example.com';
* return $hosts;
* } );
*
* Its result is normalised (exact lower-case host names only; entries with
* '*', '/', ':', '@' or whitespace are dropped), the sandbox portal is removed
* again unless the sandbox endpoint rule holds, and the fixed checks above
* always apply. It has no effect on invoices.
*
* @param string $url URL from an API response.
* @param string $purpose Only 'portal' can pass.
* @return bool
*/
function orderguard_is_trusted_billing_url( $url, $purpose = 'portal' ) {
if ( 'portal' !== $purpose || ! is_string( $url ) || '' === $url ) {
return false;
}
if ( preg_match( '/[\x00-\x20\x7f\\\\]/', $url ) ) { // Whitespace, control characters and backslashes.
return false;
}
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
return false;
}
if ( 'https' !== strtolower( $parts['scheme'] ) ) {
return false;
}
if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
return false;
}
if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) {
return false;
}
return in_array( strtolower( $parts['host'] ), orderguard_portal_hosts(), true );
}
/**
* Is this plugin talking to the OrderGuard sandbox API endpoint (exactly)?
*
* @return bool
*/
function orderguard_is_sandbox_endpoint() {
return 'https://sandbox-api.orderguard.net' === untrailingslashit( (string) ORDERGUARD_SERVER_URL );
}
/**
* The exact host names a portal redirect may go to (see orderguard_is_trusted_billing_url()).
*
* @return string[]
*/
function orderguard_portal_hosts() {
$sandbox = 'sandbox-customer-portal.paddle.com';
$hosts = array_filter( array( wp_parse_url( ORDERGUARD_SERVER_URL, PHP_URL_HOST ), 'customer-portal.paddle.com' ) );
if ( orderguard_is_sandbox_endpoint() ) {
$hosts[] = $sandbox;
}
$hosts = apply_filters( 'orderguard_allowed_billing_hosts', array_values( $hosts ) );
$out = array();
foreach ( (array) $hosts as $host ) {
if ( ! is_string( $host ) ) {
continue;
}
$host = strtolower( $host );
if ( '' === $host || preg_match( '/[*\/:@\s]/', $host ) ) {
continue;
}
if ( $sandbox === $host && ! orderguard_is_sandbox_endpoint() ) {
continue;
}
$out[ $host ] = true;
}
return array_keys( $out );
}
function orderguard_handle_paddle_portal() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_paddle_portal' ) ) {
wp_die( 'Bad nonce.' );
}
$which = isset( $_GET['which'] ) ? sanitize_key( wp_unslash( $_GET['which'] ) ) : 'general';
// UX-1 (0.3.1): a change made in the portal reaches the Plan tab only after
// Paddle's webhook reaches the server. For the next 300 seconds every Plan-tab
// load asks the server afresh instead of using the 60-second cache. The flag
// is time-based (never consumed by a page load) and holds no URL.
orderguard_delete_transient( 'orderguard_paddle_summary' );
set_transient( 'orderguard_paddle_summary_bypass', 1, 300 );
$url = '';
$resp = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/paddle/portal',
array(
'timeout' => 9,
'headers' => array( 'X-OrderGuard-Key' => orderguard_get_api_key() ),
)
);
if ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) {
$data = json_decode( wp_remote_retrieve_body( $resp ), true );
if ( is_array( $data ) ) {
$fallback = isset( $data['portal_url'] ) ? $data['portal_url'] : '';
if ( 'update' === $which ) {
$url = ! empty( $data['update_payment_url'] ) ? $data['update_payment_url'] : $fallback;
} elseif ( 'cancel' === $which ) {
$url = ! empty( $data['cancel_url'] ) ? $data['cancel_url'] : $fallback;
} else {
$url = $fallback;
}
}
}
if ( $url && orderguard_is_trusted_billing_url( $url, 'portal' ) ) {
// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- cross-host by design; orderguard_is_trusted_billing_url() has already required https and an allowlisted portal host.
wp_redirect( esc_url_raw( $url ) );
exit;
}
if ( $url ) {
// Reached the provider but refused to follow the URL it gave us. Say so:
// silently showing "couldn't open the portal" would send someone hunting
// for a network fault that isn't there. Add the host via the
// orderguard_allowed_billing_hosts filter if it is genuinely yours.
// Scheme and host only: an invoice URL carries a signed token, and the
// host is the whole of what anyone needs to fix the allowlist.
$orderguard_bad = wp_parse_url( $url );
orderguard_log( 'OrderGuard: billing URL refused, host not allowlisted -- '
. ( isset( $orderguard_bad['scheme'] ) ? $orderguard_bad['scheme'] : '?' ) . '://'
. ( isset( $orderguard_bad['host'] ) ? $orderguard_bad['host'] : '?' ) );
}
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'portal_err', 120 );
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=plan' ) );
exit;
}
/**
* admin-post: switch the subscription to the other billing interval.
*/
add_action( 'admin_post_orderguard_paddle_switch', 'orderguard_handle_paddle_switch' );
function orderguard_handle_paddle_switch() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_paddle_switch' ) ) {
wp_die( 'Bad nonce.' );
}
// No provider request until the paid-time policy passes sandbox qualification.
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'switch_held', 120 );
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=plan' ) );
exit;
}
/**
* admin-post: download the invoice PDF for one transaction (0.3.1).
*
* The OrderGuard server checks that the transaction belongs to this store,
* fetches the PDF from the billing provider and returns the PDF itself; the
* browser is never redirected to a provider URL. The plugin accepts only a
* bounded, real PDF and otherwise takes the existing error path.
*/
add_action( 'admin_post_orderguard_paddle_invoice', 'orderguard_handle_paddle_invoice' );
if ( ! defined( 'ORDERGUARD_INVOICE_MAX_BYTES' ) ) {
define( 'ORDERGUARD_INVOICE_MAX_BYTES', 2097152 ); // 2 MiB, the same cap as the server.
}
/**
* Fetch and check one invoice PDF from the OrderGuard server.
*
* @param string $txn Transaction id, already format-checked.
* @return string|false The PDF bytes, or false for any failure.
*/
function orderguard_fetch_invoice_pdf( $txn ) {
$resp = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/paddle/invoice/pdf',
array(
'timeout' => 20,
'redirection' => 0,
'limit_response_size' => ORDERGUARD_INVOICE_MAX_BYTES + 1,
'stream' => false,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( array( 'transaction_id' => $txn ) ),
)
);
if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
return false;
}
$type = strtolower( trim( strtok( (string) wp_remote_retrieve_header( $resp, 'content-type' ), ';' ) ) );
if ( 'application/pdf' !== $type ) {
return false;
}
$body = wp_remote_retrieve_body( $resp );
$len = strlen( $body );
if ( $len < 5 || $len > ORDERGUARD_INVOICE_MAX_BYTES || 0 !== strncmp( $body, '%PDF-', 5 ) ) {
return false; // Empty, cut off at the cap (cap + 1 bytes), or not a PDF.
}
return $body;
}
function orderguard_handle_paddle_invoice() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'orderguard_paddle_invoice' ) ) {
wp_die( 'Bad nonce.' );
}
$txn = isset( $_GET['transaction_id'] ) ? sanitize_text_field( wp_unslash( $_GET['transaction_id'] ) ) : '';
$pdf = preg_match( '/^txn_[a-z0-9]{26}$/', $txn ) ? orderguard_fetch_invoice_pdf( $txn ) : false;
if ( false !== $pdf ) {
nocache_headers();
header( 'Content-Type: application/pdf' );
header( 'Content-Disposition: attachment; filename="orderguard-invoice-' . $txn . '.pdf"' );
header( 'X-Content-Type-Options: nosniff' );
header( 'Content-Length: ' . strlen( $pdf ) );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF download, checked by orderguard_fetch_invoice_pdf() (type, size, %PDF- signature).
echo $pdf;
exit;
}
// Nothing of the response is shown or logged (it may be part of a signed provider answer).
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), 'portal_err', 120 );
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=plan' ) );
exit;
}
/**
* Fetch the live Paddle subscription summary for the Plan tab (cached 60s;
* not cached for 300 s after a portal click, see orderguard_handle_paddle_portal()).
*/
function orderguard_get_paddle_summary( $force = false ) {
if ( ! orderguard_is_activated() ) {
return array( 'has_subscription' => false );
}
// UX-1: shortly after a portal click, always ask the server afresh.
if ( ! $force && ! get_transient( 'orderguard_paddle_summary_bypass' ) ) {
$cached = orderguard_get_transient( 'orderguard_paddle_summary' );
if ( is_array( $cached ) ) {
return $cached;
}
}
$out = array( 'has_subscription' => false );
$resp = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/paddle/summary',
array(
'timeout' => 9,
'headers' => array( 'X-OrderGuard-Key' => orderguard_get_api_key() ),
)
);
if ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) {
$data = json_decode( wp_remote_retrieve_body( $resp ), true );
if ( is_array( $data ) ) {
$out = $data;
}
}
set_transient( 'orderguard_paddle_summary', $out, 60 );
return $out;
}
/**
* Billing-history status label (UX-3, 0.3.1). Presentation only: it never
* affects the plan or any entitlement.
*
* Paddle's 'completed' and 'paid' are paid. 'billed' means the invoice was
* issued, not that it was paid, so it reads "Issued". Any other or unknown
* status shows no label at all.
*
* @param mixed $status Paddle transaction status from the summary.
* @return array{state:string,text:string} state 'paid', 'issued' or ''.
*/
function orderguard_invoice_status_label( $status ) {
$status = is_string( $status ) ? strtolower( $status ) : '';
if ( 'completed' === $status || 'paid' === $status ) {
return array( 'state' => 'paid', 'text' => __( 'Paid', 'orderguard' ) );
}
if ( 'billed' === $status ) {
return array( 'state' => 'issued', 'text' => __( 'Issued', 'orderguard' ) );
}
return array( 'state' => '', 'text' => '' );
}
/**
* Format a minor-unit amount (e.g. "4600") in its currency for display.
*/
function orderguard_format_money( $minor, $currency ) {
$currency = strtoupper( (string) $currency );
$amount = is_numeric( $minor ) ? ( (float) $minor / 100 ) : 0.0;
$num = number_format_i18n( $amount, 2 );
$before = array( 'EUR' => '€', 'USD' => '$', 'GBP' => '£' );
$after = array( 'BGN' => ' лв.', 'RON' => ' lei' );
if ( isset( $before[ $currency ] ) ) {
return $before[ $currency ] . $num;
}
if ( isset( $after[ $currency ] ) ) {
return $num . $after[ $currency ];
}
return $num . ' ' . $currency;
}
/**
* The merchant's legal jurisdiction, derived from the WooCommerce base country.
* Returns array( code, country, flag, framework, is_eu ).
*/
function orderguard_legal_jurisdiction() {
$code = '';
if ( function_exists( 'wc_get_base_location' ) ) {
$loc = wc_get_base_location();
$code = isset( $loc['country'] ) ? strtoupper( $loc['country'] ) : '';
}
if ( '' === $code ) {
$code = strtoupper( substr( (string) get_option( 'woocommerce_default_country', '' ), 0, 2 ) );
}
$eu_names = array(
'BG' => 'Bulgaria', 'RO' => 'Romania', 'GR' => 'Greece', 'DE' => 'Germany',
'FR' => 'France', 'ES' => 'Spain', 'IT' => 'Italy', 'NL' => 'Netherlands',
'PL' => 'Poland', 'AT' => 'Austria', 'BE' => 'Belgium', 'IE' => 'Ireland',
'PT' => 'Portugal', 'HR' => 'Croatia', 'CZ' => 'Czechia', 'HU' => 'Hungary',
'SK' => 'Slovakia', 'SI' => 'Slovenia', 'FI' => 'Finland', 'SE' => 'Sweden',
'DK' => 'Denmark', 'EE' => 'Estonia', 'LV' => 'Latvia', 'LT' => 'Lithuania',
'LU' => 'Luxembourg', 'MT' => 'Malta', 'CY' => 'Cyprus',
);
$is_eu = isset( $eu_names[ $code ] );
$country = $is_eu ? $eu_names[ $code ] : ( '' !== $code ? $code : 'your country' );
$flag = '';
if ( 2 === strlen( $code ) && ctype_alpha( $code ) && function_exists( 'mb_chr' ) ) {
$flag = mb_chr( 0x1F1E6 + ( ord( $code[0] ) - 65 ) ) . mb_chr( 0x1F1E6 + ( ord( $code[1] ) - 65 ) );
}
return array(
'code' => $code,
'country' => $country,
'flag' => $flag,
'framework' => $is_eu ? 'European Union (GDPR)' : 'your jurisdiction',
'is_eu' => $is_eu,
);
}
/**
* Turn an email-or-phone query into the exact hashes sent to a data endpoint.
*
* Keeping this separate lets the erasure path freeze its local protection
* population before the destructive request leaves the store.
*/
function orderguard_data_request_body( $query ) {
$query = trim( (string) $query );
if ( '' === $query ) {
return null;
}
$is_email = ( false !== strpos( $query, '@' ) );
// H-2: also send the e164 spelling of a typed phone, so a lookup/erasure
// reaches rows recorded under the other form. The person's rows may predate
// dual-send (e.g. they typed "+359..." at checkout while the merchant types
// "0888..." here) -- without this, an erasure "succeeds" while half their
// records stay behind. Country key = the STORE's own country: the request
// subject is this store's customer talking to this merchant, the same
// trust model the exclusion list's variants already use. Store-country
// derivation is confined to store-scoped rows (lookup additionally requires
// an owned sighting server-side), so a mis-keyed guess is bounded to this
// store's own data. See H2_PHONE_IDENTITY_REPORT.md for the residual.
$body = array(
'email_hash' => $is_email ? orderguard_hash_value( strtolower( $query ) ) : null,
'phone_hash' => $is_email ? null : ( orderguard_phone_hash( $query ) ?: null ),
'phone_hash_e164' => $is_email ? null : ( orderguard_phone_hash_e164( $query, orderguard_store_base_country() ) ?: null ),
);
return array_filter(
$body,
static function ( $value ) {
return is_string( $value ) && '' !== $value;
}
);
}
/** Validate and canonicalise one server-side erasure request body. */
function orderguard_normalize_gdpr_erasure_request_body( $body ) {
if ( ! is_array( $body ) ) {
return array();
}
$normalised = array();
foreach ( array( 'email_hash', 'phone_hash', 'phone_hash_e164' ) as $field ) {
$hash = isset( $body[ $field ] ) ? (string) $body[ $field ] : '';
if ( preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
$normalised[ $field ] = $hash;
}
}
ksort( $normalised, SORT_STRING );
return $normalised;
}
/** Deduplicate erasure bodies without discarding an earlier spelling. */
function orderguard_unique_gdpr_erasure_request_bodies( $bodies ) {
$unique = array();
foreach ( (array) $bodies as $body ) {
$normalised = orderguard_normalize_gdpr_erasure_request_body( $body );
if ( empty( $normalised ) ) {
continue;
}
$unique[ wp_json_encode( $normalised ) ] = $normalised;
}
ksort( $unique, SORT_STRING );
return array_values( $unique );
}
/**
* Freeze the exact merchant spelling plus its safely derived E.164 spelling.
*
* Explicit re-submissions are retained as separate bodies. Deliberately do
* not transmit the broader local-exclusion variants: bare subscriber forms
* are useful for conservative local suppression but are not safe authority
* for destructive server-side erasure.
*/
function orderguard_gdpr_erasure_request_bodies( $query ) {
$query = trim( (string) $query );
if ( '' === $query ) {
return array();
}
return orderguard_unique_gdpr_erasure_request_bodies(
array( orderguard_data_request_body( $query ) )
);
}
/** Infer the one identity field an erasure request is allowed to expand. */
function orderguard_gdpr_erasure_identity_kind( $bodies ) {
$kind = '';
foreach ( orderguard_unique_gdpr_erasure_request_bodies( $bodies ) as $body ) {
$body_kind = isset( $body['email_hash'] ) ? 'email' : 'phone';
if ( '' !== $kind && $kind !== $body_kind ) {
return '';
}
$kind = $body_kind;
}
return $kind;
}
/**
* Exact server spelling carried by one locally matched order.
*
* A phone erasure deliberately sends the order's frozen legacy phone hash.
* Every network sighting created from that order contains this hash, even
* when it also contains E.164. Sending the exact local spelling closes gaps
* such as 0888... versus bare 888... without authorising speculative variants.
*/
function orderguard_gdpr_erasure_order_request_body( $order, $identity_kind ) {
if ( ! $order instanceof WC_Order ) {
return array();
}
return orderguard_gdpr_erasure_identity_request_body(
$order->get_billing_email(),
$order->get_billing_phone(),
$identity_kind
);
}
/** Exact server spelling from raw local billing identity fields. */
function orderguard_gdpr_erasure_identity_request_body( $email, $phone, $identity_kind ) {
if ( 'email' === $identity_kind ) {
$email = trim( (string) $email );
return '' === $email
? array()
: array( 'email_hash' => orderguard_hash_value( strtolower( $email ) ) );
}
if ( 'phone' !== $identity_kind ) {
return array();
}
$phone_hash = orderguard_phone_hash( $phone );
return '' === $phone_hash ? array() : array( 'phone_hash' => $phone_hash );
}
/** Identity hashes on one order that can match this erasure's field. */
function orderguard_gdpr_erasure_order_match_hashes( $order, $identity_kind ) {
$body = orderguard_gdpr_erasure_order_request_body( $order, $identity_kind );
if ( empty( $body ) ) {
return array();
}
if ( 'phone' === $identity_kind ) {
$e164 = orderguard_phone_hash_e164(
$order->get_billing_phone(),
$order->get_billing_country()
);
if ( '' !== $e164 ) {
$body['phone_hash_e164'] = $e164;
}
}
return array_values( array_unique( array_values( $body ) ) );
}
/** Match hashes from raw local billing identity fields. */
function orderguard_gdpr_erasure_identity_match_hashes( $email, $phone, $country, $identity_kind ) {
$body = orderguard_gdpr_erasure_identity_request_body( $email, $phone, $identity_kind );
if ( empty( $body ) ) {
return array();
}
if ( 'phone' === $identity_kind ) {
$e164 = orderguard_phone_hash_e164( $phone, $country );
if ( '' !== $e164 ) {
$body['phone_hash_e164'] = $e164;
}
}
return array_values( array_unique( array_values( $body ) ) );
}
/** Every primary identity hash an order-backed network flag may contain. */
function orderguard_gdpr_erasure_identity_flag_hashes( $email, $phone, $country ) {
$hashes = array();
$email = strtolower( trim( (string) $email ) );
if ( '' !== $email ) {
$hashes[] = orderguard_hash_value( $email );
}
$phone_hash = orderguard_phone_hash( $phone );
if ( '' !== $phone_hash ) {
$hashes[] = $phone_hash;
}
$e164 = orderguard_phone_hash_e164( $phone, $country );
if ( '' !== $e164 ) {
$hashes[] = $e164;
}
$valid = array();
foreach ( array_unique( $hashes ) as $hash ) {
if ( is_string( $hash ) && preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
$valid[] = $hash;
}
}
sort( $valid, SORT_STRING );
return $valid;
}
/** Primary network-flag hashes proven by one loadable WooCommerce order. */
function orderguard_gdpr_erasure_order_flag_hashes( $order ) {
if ( ! $order instanceof WC_Order ) {
return array();
}
return orderguard_gdpr_erasure_identity_flag_hashes(
$order->get_billing_email(),
$order->get_billing_phone(),
$order->get_billing_country()
);
}
/**
* Shared: turn an email-or-phone query into hashes and POST to a data endpoint.
* Returns the decoded array, or null.
*/
function orderguard_data_request_call( $endpoint, $query ) {
$body = orderguard_data_request_body( $query );
if ( empty( $body ) ) {
return null;
}
return orderguard_data_request_call_body( $endpoint, $body );
}
/**
* POST a previously frozen data-request body.
*/
function orderguard_data_request_call_body( $endpoint, $body ) {
if ( ! is_array( $body ) || empty( $body ) ) {
return null;
}
// EXCLUSION GUARD -- for LOOKUPS only. Same reasoning as
// orderguard_fetch_network_signal(): asking the network about someone is itself a
// use of their data, and an excluded identity must not be used.
// /v1/data/erase is deliberately NOT guarded: it DELETES, and suppressing a
// deletion would strand data we were asked to remove.
if ( false === strpos( $endpoint, 'erase' ) ) {
$identity_hashes = array_values( $body );
if ( 'not_excluded' !== ( orderguard_owned_exclusion_hashes_state( $identity_hashes )['status'] ?? 'unknown' )
|| orderguard_pending_gdpr_erasure_matches_hashes( $identity_hashes ) ) {
return null;
}
}
$privacy_lock = false;
if ( false === strpos( $endpoint, 'erase' ) ) {
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
return null;
}
}
try {
if ( $privacy_lock ) {
$identity_hashes = array_values( $body );
if ( 'not_excluded' !== ( orderguard_owned_exclusion_hashes_state( $identity_hashes )['status'] ?? 'unknown' )
|| orderguard_pending_gdpr_erasure_matches_hashes( $identity_hashes ) ) {
return null;
}
}
$resp = wp_remote_post(
ORDERGUARD_SERVER_URL . $endpoint,
array(
'timeout' => 8,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( $body ),
)
);
} finally {
if ( $privacy_lock ) {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $resp ), true );
return is_array( $data ) ? $data : null;
}
/**
* admin-post: GDPR customer data lookup (read-only). Result -> transient ->
* rendered on the Legal tab.
*/
require_once __DIR__ . '/owned/native-legal-result.php';
add_action( 'admin_post_orderguard_data_lookup', 'orderguard_handle_data_lookup' );
function orderguard_handle_data_lookup() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
check_admin_referer( 'orderguard_data_request' );
$cache_ticket = OG_Native_Legal_Result::call( 'begin' );
if ( is_wp_error( $cache_ticket ) ) { wp_die( esc_html( $cache_ticket->get_error_message() ) ); }
$q = isset( $_POST['orderguard_data_query'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_data_query'] ) ) : '';
$result = orderguard_data_request_call( '/v1/data/lookup', $q );
$local_ok = false;
$local_rows = array();
if ( orderguard_acquire_gdpr_erasure_lifecycle_lock() ) {
try {
$local_rows = orderguard_gdpr_local_flag_rows_for_hashes(
orderguard_gdpr_erasure_query_hashes( $q ),
$local_ok
);
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
$saved = OG_Native_Legal_Result::call( 'publish', $cache_ticket['ticket'], array(
'query' => $q, 'lookup' => $result, 'local_flags' => $local_rows, 'local_flags_ok' => $local_ok,
) );
if ( is_wp_error( $saved ) ) { wp_die( esc_html( $saved->get_error_message() ) ); }
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=legal' ) );
exit;
}
/** Register before WooCommerce so identity cannot be anonymised first. */
add_filter( 'wp_privacy_personal_data_erasers', 'orderguard_register_personal_data_eraser', 5 );
add_filter( 'wp_privacy_personal_data_erasure_page', 'orderguard_filter_personal_data_erasure_page', 9, 6 );
add_action( 'admin_notices', 'orderguard_native_eraser_order_notice' );
/** Inspect the actual filtered order, not merely registration priorities. */
function orderguard_native_eraser_order_is_safe( $erasers = null ) {
if ( null === $erasers ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applies an existing WordPress or WooCommerce filter; not a new hook
$erasers = apply_filters( 'wp_privacy_personal_data_erasers', array() );
}
if ( ! is_array( $erasers ) ) {
return false;
}
$seen = false;
foreach ( $erasers as $key => $eraser ) {
if ( 'orderguard' === $key ) {
$seen = is_array( $eraser )
&& isset( $eraser['callback'] )
&& 'orderguard_personal_data_eraser_placeholder' === $eraser['callback'];
}
if ( 0 === strpos( (string) $key, 'woocommerce-' ) && ! $seen ) {
return false;
}
}
return $seen;
}
function orderguard_native_eraser_order_notice() {
if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
if ( orderguard_native_eraser_order_is_safe() ) {
return;
}
echo '
' . esc_html__( 'OrderGuard cannot verify that its personal-data eraser runs before WooCommerce. Do not run Tools → Erase Personal Data until the eraser order is corrected. Contact support; no recovery or erasure has been performed by this warning.', 'orderguard' ) . '
';
}
function orderguard_register_personal_data_eraser( $erasers ) {
if ( ! is_array( $erasers ) ) {
return $erasers;
}
$erasers['orderguard'] = array(
'eraser_friendly_name' => __( 'OrderGuard network data', 'orderguard' ),
// Core's eraser callback does not receive request_id. The priority-9
// response filter below receives the validated six-argument context
// and performs the durable work before core's priority-10 completion.
'callback' => 'orderguard_personal_data_eraser_placeholder',
);
return $erasers;
}
function orderguard_personal_data_eraser_placeholder( $email_address, $page = 1 ) {
return array(
'items_removed' => false,
'items_retained' => true,
'messages' => array( __( 'OrderGuard is waiting for its verified removal step.', 'orderguard' ) ),
'done' => false,
);
}
function orderguard_gdpr_native_waiting_response() {
return array(
'items_removed' => false,
'items_retained' => true,
'messages' => array( __( 'OrderGuard removal is still processing. WooCommerce data has not been anonymised yet.', 'orderguard' ) ),
'done' => false,
);
}
function orderguard_gdpr_native_complete_response() {
return array(
'items_removed' => true,
'items_retained' => false,
'messages' => array( __( 'OrderGuard removal completed and its signed local receipt was verified.', 'orderguard' ) ),
'done' => true,
);
}
function orderguard_gdpr_native_error( $reason ) {
$reason = sanitize_key( (string) $reason );
$messages = array(
'capacity_packet_unknown' => __( 'OrderGuard could not verify the database packet limit, so it did not accept this removal request. Ask your host to check the database connection and try again.', 'orderguard' ),
'capacity_pending' => __( 'OrderGuard cannot safely fit another removal request in its local retry storage. Finish or repair the visible pending request before trying again.', 'orderguard' ),
'capacity_suppression' => __( 'OrderGuard cannot safely fit the complete local protection record for this removal request. No server deletion was attempted.', 'orderguard' ),
'native_queue_full' => __( 'A later OrderGuard removal request for this address is already queued. Let it finish before starting another.', 'orderguard' ),
'native_backoff' => __( 'OrderGuard removal is waiting for its recorded retry time. Try the WordPress erasure again after the pending job resumes.', 'orderguard' ),
'native_blocked' => __( 'OrderGuard removal is blocked and needs administrator attention on the OrderGuard Legal tab. WooCommerce data was not anonymised.', 'orderguard' ),
'native_scheduler' => __( 'OrderGuard could not confirm a background wake-up for this removal. Repair WordPress cron or Action Scheduler, then retry.', 'orderguard' ),
'native_receipt_invalid' => __( 'OrderGuard found an invalid completion receipt and stopped before WooCommerce anonymisation. Contact support before retrying.', 'orderguard' ),
'native_receipt_missing' => __( 'OrderGuard cannot prove that removal completed, so WooCommerce data was not anonymised. Contact support before retrying.', 'orderguard' ),
'native_request_invalid' => __( 'OrderGuard could not validate this WordPress personal-data request.', 'orderguard' ),
'native_request_conflict' => __( 'This WordPress personal-data request conflicts with the generation already recorded by OrderGuard. WooCommerce data was not anonymised.', 'orderguard' ),
'lifecycle_unavailable' => __( 'OrderGuard cannot verify its local privacy protection. Erasure processing is blocked and operator intervention is required. WooCommerce data was not anonymised. Do not delete or reset privacy records; contact support with the diagnostic report.', 'orderguard' ),
'lifecycle_busy' => __( 'OrderGuard privacy processing is busy with another operation. No removal was accepted by this attempt and WooCommerce data was not anonymised. Please retry shortly.', 'orderguard' ),
'lifecycle_storage_unavailable' => __( 'OrderGuard could not acquire its privacy lock because the database did not confirm availability. WooCommerce data was not anonymised. Check database availability and retry.', 'orderguard' ),
'plugin_inactive' => __( 'OrderGuard is inactive. Activate it before retrying this removal. WooCommerce data was not anonymised.', 'orderguard' ),
);
$message = isset( $messages[ $reason ] )
? $messages[ $reason ]
: __( 'OrderGuard could not advance this removal safely. Check the OrderGuard Legal tab and try again.', 'orderguard' );
return new WP_Error( 'orderguard_' . ( '' === $reason ? 'native_erasure_failed' : $reason ), $message );
}
function orderguard_gdpr_entry_has_native_request( $entry, $binding ) {
if ( ! is_array( $entry ) ) {
return false;
}
if ( orderguard_gdpr_native_request_equals(
isset( $entry['native_request'] ) ? $entry['native_request'] : array(),
$binding
) ) {
return true;
}
return ! empty( $entry['queued_request'] )
&& orderguard_gdpr_native_request_equals(
isset( $entry['queued_request']['native_request'] )
? $entry['queued_request']['native_request']
: array(),
$binding
);
}
/**
* Bind core's validated request ID, then advance at most one durable step.
*
* Returning done=true is deliberately impossible without a signed receipt
* written before ACK clearance and proof-read from the request post meta.
*/
function orderguard_filter_personal_data_erasure_page( $response, $eraser_index, $email_address, $page, $request_id, $eraser_key ) {
if ( 'orderguard' !== $eraser_key ) {
return $response;
}
if ( ! OG_Native_Legacy_Gate::allowed() ) {
return new WP_Error( 'orderguard_owned_takeover', __( 'OrderGuard owned erasure requires the supervised replacement workflow.', 'orderguard' ) );
}
$lifecycle_reason = '';
if ( ! orderguard_gdpr_native_ensure_lifecycle( $lifecycle_reason ) ) {
return orderguard_gdpr_native_error( $lifecycle_reason );
}
$binding_reason = '';
$binding = orderguard_gdpr_native_request_binding(
$request_id,
$email_address,
$binding_reason
);
if ( false === $binding || (int) $page < 1 ) {
return orderguard_gdpr_native_error( 'native_request_invalid' );
}
$hashes = orderguard_gdpr_erasure_query_hashes( $email_address );
$fingerprint = orderguard_gdpr_erasure_fingerprint( $hashes );
if ( '' === $fingerprint ) {
return orderguard_gdpr_native_error( 'native_request_invalid' );
}
$receipt_status = '';
$receipt = orderguard_gdpr_native_receipt(
$binding,
$fingerprint,
'',
'',
$receipt_status
);
if ( false === $receipt && 'missing' !== $receipt_status ) {
return orderguard_gdpr_native_error( 'native_receipt_invalid' );
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return orderguard_gdpr_native_error( 'privacy_state_invalid' );
}
if ( false !== $receipt && ! isset( $pending[ $fingerprint ] ) ) {
return orderguard_gdpr_native_complete_response();
}
if ( false === $receipt ) {
$prepare_reason = '';
$prepared = orderguard_prepare_gdpr_erasure(
$email_address,
$binding,
$prepare_reason
);
if ( false === $prepared ) {
return orderguard_gdpr_native_error( $prepare_reason );
}
$fingerprint = $prepared['fingerprint'];
if ( ! empty( $prepared['receipt_complete'] ) ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return orderguard_gdpr_native_error( 'privacy_state_invalid' );
}
if ( ! isset( $pending[ $fingerprint ] ) ) {
return orderguard_gdpr_native_complete_response();
}
}
}
// One invocation performs no more than one local/server worker step.
$step = orderguard_run_gdpr_erasure_job( $fingerprint );
$receipt_status = '';
$receipt = orderguard_gdpr_native_receipt(
$binding,
$fingerprint,
'',
'',
$receipt_status
);
if ( false === $receipt && 'missing' !== $receipt_status ) {
return orderguard_gdpr_native_error( 'native_receipt_invalid' );
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return orderguard_gdpr_native_error( 'privacy_state_invalid' );
}
if ( false !== $receipt && ! isset( $pending[ $fingerprint ] ) ) {
return orderguard_gdpr_native_complete_response();
}
if ( ! isset( $pending[ $fingerprint ] ) ) {
return orderguard_gdpr_native_error( 'native_receipt_missing' );
}
$entry = $pending[ $fingerprint ];
if ( false === $receipt && ! orderguard_gdpr_entry_has_native_request( $entry, $binding ) ) {
return orderguard_gdpr_native_error( 'native_request_conflict' );
}
if ( 'blocked' === $entry['status'] || 'failed' === $step ) {
return orderguard_gdpr_native_error( 'native_blocked' );
}
if ( (int) $entry['next_attempt_at'] > time() ) {
return orderguard_gdpr_native_error( 'native_backoff' );
}
if ( ! orderguard_schedule_gdpr_erasure_job( $fingerprint, 1, true ) ) {
return orderguard_gdpr_native_error( 'native_scheduler' );
}
return orderguard_gdpr_native_waiting_response();
}
/**
* admin-post: GDPR erasure. Deletes the customer's data server-side.
*/
add_action( 'admin_post_orderguard_data_erase', 'orderguard_handle_data_erase' );
function orderguard_handle_data_erase() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
check_admin_referer( 'orderguard_data_request' );
$q = isset( $_POST['orderguard_data_query'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_data_query'] ) ) : '';
$prepare_reason = '';
$pending = orderguard_prepare_gdpr_erasure( $q, array(), $prepare_reason );
$status = is_array( $pending )
? orderguard_run_gdpr_erasure_job( $pending['fingerprint'] )
: 'failed';
$notice = 'complete' === $status
? 'erased'
: ( 'pending' === $status
? 'erase_pending'
: ( 0 === strpos( $prepare_reason, 'capacity_' ) ? 'erase_capacity' : 'erase_err' ) );
set_transient( 'orderguard_flag_notice_' . get_current_user_id(), $notice, 120 );
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=legal' ) );
exit;
}
/** Explicitly resume only job-local erasure blocks after an administrator fixes them. */
add_action( 'admin_post_orderguard_resume_data_erase', 'orderguard_handle_resume_data_erase' );
function orderguard_handle_resume_data_erase() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( 'Not allowed.' );
}
check_admin_referer( 'orderguard_resume_data_erase' );
$resumed = orderguard_resume_blocked_gdpr_erasures();
set_transient(
'orderguard_flag_notice_' . get_current_user_id(),
false === $resumed ? 'erase_resume_err' : ( $resumed > 0 ? 'erase_resumed' : 'erase_resume_none' ),
120
);
wp_safe_redirect( admin_url( 'admin.php?page=orderguard&tab=legal' ) );
exit;
}
/**
* Read identity (phone/email) from an ORDER object rather than $_POST. Reliable
* on BOTH classic and block checkout, because by the time an order exists it
* carries the final billing details regardless of how they were submitted.
*/
function orderguard_identity_from_order( $order ) {
if ( ! $order instanceof WC_Order ) {
return array( 'phone' => '', 'email' => '' );
}
return array(
'phone' => (string) $order->get_billing_phone(),
'email' => (string) $order->get_billing_email(),
);
}
/** Strictly normalise a bounded list of SHA-256 identity hashes. */
function orderguard_normalize_gdpr_erasure_hash_list( $hashes, $limit, &$ok = null ) {
$ok = false;
if ( ! is_array( $hashes ) ) {
return array();
}
$normalised = array();
foreach ( $hashes as $hash ) {
if ( ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
return array();
}
$normalised[ $hash ] = true;
}
$normalised = array_keys( $normalised );
sort( $normalised, SORT_STRING );
if ( count( $normalised ) > max( 0, (int) $limit ) ) {
return array();
}
$ok = true;
return $normalised;
}
/** Strictly normalise a bounded list of positive WooCommerce order IDs. */
function orderguard_normalize_gdpr_erasure_ref_list( $refs, $limit, &$ok = null ) {
$ok = false;
if ( ! is_array( $refs ) ) {
return array();
}
$normalised = array();
foreach ( $refs as $ref ) {
if ( ( ! is_int( $ref ) && ! ( is_string( $ref ) && ctype_digit( $ref ) ) )
|| (int) $ref <= 0 ) {
return array();
}
$normalised[ (int) $ref ] = true;
}
$normalised = array_keys( $normalised );
sort( $normalised, SORT_NUMERIC );
if ( count( $normalised ) > max( 0, (int) $limit ) ) {
return array();
}
$ok = true;
return $normalised;
}
/** Strictly normalise the exact identity bodies bound to PREPARE. */
function orderguard_normalize_gdpr_erasure_body_list( $bodies, &$ok = null ) {
$ok = false;
if ( ! is_array( $bodies ) ) {
return array();
}
$allowed = array_fill_keys( array( 'email_hash', 'phone_hash', 'phone_hash_e164' ), true );
foreach ( $bodies as $body ) {
if ( ! is_array( $body ) || empty( $body ) ) {
return array();
}
foreach ( $body as $field => $hash ) {
if ( ! isset( $allowed[ $field ] )
|| ! is_string( $hash )
|| ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
return array();
}
}
}
$normalised = orderguard_unique_gdpr_erasure_request_bodies( $bodies );
if ( empty( $normalised ) || count( $normalised ) > 256 ) {
return array();
}
$ok = true;
return $normalised;
}
/** Strictly normalise the WordPress privacy request bound to one generation. */
function orderguard_normalize_gdpr_native_request( $request ) {
if ( empty( $request ) ) {
return array();
}
if ( ! is_array( $request )
|| array( 'request_id', 'email_digest' ) !== array_keys( $request )
|| ! is_int( $request['request_id'] )
|| $request['request_id'] <= 0
|| ! is_string( $request['email_digest'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $request['email_digest'] ) ) {
return false;
}
return array(
'request_id' => $request['request_id'],
'email_digest' => $request['email_digest'],
);
}
/** Current on-disk schema for accepted and queued erasure requests. */
function orderguard_gdpr_erasure_state_schema_version() {
return 6;
}
/** Validate the one unsent merchant request queued behind an active operation. */
function orderguard_normalize_queued_gdpr_erasure_request( $request ) {
if ( empty( $request ) ) {
return array();
}
if ( ! is_array( $request ) ) {
return false;
}
// Internal constructors from the immediately preceding development
// schema may omit this new optional binding; canonical stored output
// always contains it, so a persisted non-canonical row still fails.
if ( ! array_key_exists( 'native_request', $request ) ) {
$request['native_request'] = array();
}
$allowed_fields = array(
'schema_version',
'request_token',
'started_at',
'suppression_cutoff',
'suppression_max_order_id',
'order_storage_mode',
'identity_kind',
'match_hashes',
'request_bodies',
'flag_hashes',
'local_flag_manifest',
'native_request',
);
$provided_fields = array_keys( $request );
sort( $allowed_fields, SORT_STRING );
sort( $provided_fields, SORT_STRING );
if ( orderguard_gdpr_erasure_state_schema_version() !== ( $request['schema_version'] ?? null )
|| $provided_fields !== $allowed_fields ) {
return false;
}
$token = isset( $request['request_token'] ) ? (string) $request['request_token'] : '';
$kind = isset( $request['identity_kind'] ) ? (string) $request['identity_kind'] : '';
$mode = isset( $request['order_storage_mode'] ) ? (string) $request['order_storage_mode'] : '';
$hashes_ok = false;
$hashes = orderguard_normalize_gdpr_erasure_hash_list(
isset( $request['match_hashes'] ) ? $request['match_hashes'] : null,
1024,
$hashes_ok
);
$bodies_ok = false;
$bodies = orderguard_normalize_gdpr_erasure_body_list(
isset( $request['request_bodies'] ) ? $request['request_bodies'] : null,
$bodies_ok
);
$flag_hashes_ok = false;
$flag_hashes = orderguard_normalize_gdpr_erasure_hash_list(
isset( $request['flag_hashes'] ) ? $request['flag_hashes'] : null,
1024,
$flag_hashes_ok
);
$local_flags_ok = false;
$local_flag_manifest = orderguard_normalize_gdpr_local_flag_manifest(
isset( $request['local_flag_manifest'] ) ? $request['local_flag_manifest'] : null,
$local_flags_ok
);
$native_request = orderguard_normalize_gdpr_native_request(
isset( $request['native_request'] ) ? $request['native_request'] : null
);
$started_at = isset( $request['started_at'] ) ? (int) $request['started_at'] : 0;
$cutoff = isset( $request['suppression_cutoff'] ) ? (int) $request['suppression_cutoff'] : 0;
$high_water = isset( $request['suppression_max_order_id'] )
? (int) $request['suppression_max_order_id']
: -2;
if ( ! preg_match( '/^[a-f0-9]{64}$/', $token )
|| ! $hashes_ok
|| ! $bodies_ok
|| ! $flag_hashes_ok
|| ! $local_flags_ok
|| false === $native_request
|| ! in_array( $kind, array( 'email', 'phone' ), true )
|| $kind !== orderguard_gdpr_erasure_identity_kind( $bodies )
|| ! in_array( $mode, array( 'classic', 'hpos' ), true )
|| $started_at <= 0
|| $cutoff < $started_at
|| $high_water < 0 ) {
return false;
}
return array(
'schema_version' => orderguard_gdpr_erasure_state_schema_version(),
'request_token' => $token,
'started_at' => $started_at,
'suppression_cutoff' => $cutoff,
'suppression_max_order_id' => $high_water,
'order_storage_mode' => $mode,
'identity_kind' => $kind,
'match_hashes' => $hashes,
'request_bodies' => $bodies,
'flag_hashes' => $flag_hashes,
'local_flag_manifest' => $local_flag_manifest,
'native_request' => $native_request,
);
}
/** Normalize one locally pending erasure entry. */
function orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $entry ) {
if ( ! is_string( $fingerprint )
|| ! preg_match( '/^[a-f0-9]{64}$/', $fingerprint )
|| ! is_array( $entry ) ) {
return false;
}
if ( ! array_key_exists( 'native_request', $entry ) ) {
$entry['native_request'] = array();
}
$allowed_fields = array(
'schema_version',
'request_token',
'mutation_token',
'revision',
'started_at',
'suppression_cutoff',
'suppression_max_order_id',
'updated_at',
'next_attempt_at',
'status',
'scan_after_id',
'blocked_order_id',
'order_storage_mode',
'storage_mode_mismatch',
'attempts',
'identity_kind',
'match_hashes',
'request_bodies',
'flag_hashes',
'local_flag_manifest',
'order_refs',
'server_refs',
'server_cutoff_at',
'operation_id',
'operation_token',
'blocked_reason',
'blocked_from',
'queued_request',
'native_request',
);
$provided_fields = array_keys( $entry );
sort( $allowed_fields, SORT_STRING );
sort( $provided_fields, SORT_STRING );
if ( orderguard_gdpr_erasure_state_schema_version() !== ( $entry['schema_version'] ?? null )
|| $provided_fields !== $allowed_fields ) {
return false;
}
$refs_ok = false;
$refs = orderguard_normalize_gdpr_erasure_ref_list(
isset( $entry['order_refs'] ) ? $entry['order_refs'] : array(),
20000,
$refs_ok
);
$server_refs_ok = false;
$server_refs = orderguard_normalize_gdpr_erasure_ref_list(
isset( $entry['server_refs'] ) ? $entry['server_refs'] : array(),
20000,
$server_refs_ok
);
$hashes_ok = false;
$match_hashes = orderguard_normalize_gdpr_erasure_hash_list(
isset( $entry['match_hashes'] ) ? $entry['match_hashes'] : array(),
1024,
$hashes_ok
);
$flag_hashes_ok = false;
$flag_hashes = orderguard_normalize_gdpr_erasure_hash_list(
isset( $entry['flag_hashes'] ) ? $entry['flag_hashes'] : array(),
1024,
$flag_hashes_ok
);
$local_flags_ok = false;
$local_flag_manifest = orderguard_normalize_gdpr_local_flag_manifest(
isset( $entry['local_flag_manifest'] ) ? $entry['local_flag_manifest'] : null,
$local_flags_ok
);
$native_request = orderguard_normalize_gdpr_native_request(
isset( $entry['native_request'] ) ? $entry['native_request'] : null
);
$bodies_ok = false;
$request_bodies = orderguard_normalize_gdpr_erasure_body_list(
isset( $entry['request_bodies'] ) ? $entry['request_bodies'] : null,
$bodies_ok
);
$identity_kind = isset( $entry['identity_kind'] ) ? (string) $entry['identity_kind'] : '';
if ( '' === $identity_kind ) {
$identity_kind = orderguard_gdpr_erasure_identity_kind( $request_bodies );
}
if ( ! $refs_ok
|| ! $server_refs_ok
|| ! $hashes_ok
|| ! $flag_hashes_ok
|| ! $local_flags_ok
|| false === $native_request
|| ! $bodies_ok
|| empty( $match_hashes )
|| ! in_array( $identity_kind, array( 'email', 'phone' ), true )
|| $identity_kind !== orderguard_gdpr_erasure_identity_kind( $request_bodies ) ) {
return false;
}
$status = isset( $entry['status'] ) ? (string) $entry['status'] : '';
if ( ! in_array( $status, array( 'scanning', 'prepare', 'commit', 'local_flags', 'ack', 'blocked' ), true ) ) {
return false;
}
$started_at = isset( $entry['started_at'] ) ? (int) $entry['started_at'] : 0;
$suppression_cutoff = isset( $entry['suppression_cutoff'] ) ? (int) $entry['suppression_cutoff'] : 0;
$request_token = isset( $entry['request_token'] ) ? (string) $entry['request_token'] : '';
$mutation_token = isset( $entry['mutation_token'] ) ? (string) $entry['mutation_token'] : '';
$order_storage_mode = isset( $entry['order_storage_mode'] ) ? (string) $entry['order_storage_mode'] : '';
if ( ! preg_match( '/^[a-f0-9]{64}$/', $request_token )
|| ! preg_match( '/^[a-f0-9]{64}$/', $mutation_token )
|| $started_at <= 0
|| $suppression_cutoff < $started_at
|| ! in_array( $order_storage_mode, array( 'classic', 'hpos' ), true ) ) {
return false;
}
$storage_mode_mismatch = isset( $entry['storage_mode_mismatch'] ) ? (string) $entry['storage_mode_mismatch'] : '';
if ( ! in_array( $storage_mode_mismatch, array( '', 'classic', 'hpos', 'unavailable' ), true ) ) {
return false;
}
$queued = orderguard_normalize_queued_gdpr_erasure_request(
isset( $entry['queued_request'] ) ? $entry['queued_request'] : array()
);
if ( false === $queued ) {
return false;
}
$operation_id = isset( $entry['operation_id'] ) ? (string) $entry['operation_id'] : '';
$operation_token = isset( $entry['operation_token'] ) ? (string) $entry['operation_token'] : '';
$server_cutoff_at = isset( $entry['server_cutoff_at'] )
? (int) $entry['server_cutoff_at']
: -1;
if ( ( '' !== $operation_id && ! preg_match( '/^[A-Za-z0-9_-]{16,128}$/', $operation_id ) )
|| ( '' !== $operation_token && ! preg_match( '/^[a-f0-9]{64}$/', $operation_token ) )
|| ( in_array( $status, array( 'commit', 'local_flags', 'ack' ), true )
&& ( '' === $operation_id || '' === $operation_token ) ) ) {
return false;
}
$blocked_reason = isset( $entry['blocked_reason'] ) ? (string) $entry['blocked_reason'] : '';
$blocked_from = isset( $entry['blocked_from'] ) ? (string) $entry['blocked_from'] : '';
if ( ( '' !== $blocked_reason && ! preg_match( '/^[a-z0-9_-]{1,64}$/', $blocked_reason ) )
|| ( '' !== $blocked_from && ! in_array( $blocked_from, array( 'scanning', 'prepare', 'commit', 'local_flags', 'ack' ), true ) )
|| ( 'blocked' === $status && ( '' === $blocked_reason || '' === $blocked_from ) ) ) {
return false;
}
$phase = 'blocked' === $status ? $blocked_from : $status;
$combined_refs = array_values( array_unique( array_merge( $refs, $server_refs ) ) );
$scan_after_id = isset( $entry['scan_after_id'] ) ? (int) $entry['scan_after_id'] : -1;
$max_order_id = isset( $entry['suppression_max_order_id'] )
? (int) $entry['suppression_max_order_id']
: -1;
$blocked_order_id = isset( $entry['blocked_order_id'] )
? (int) $entry['blocked_order_id']
: -1;
$revision = isset( $entry['revision'] ) ? (int) $entry['revision'] : 0;
$updated_at = isset( $entry['updated_at'] ) ? (int) $entry['updated_at'] : 0;
$next_attempt_at = isset( $entry['next_attempt_at'] ) ? (int) $entry['next_attempt_at'] : -1;
$attempts = isset( $entry['attempts'] ) ? (int) $entry['attempts'] : -1;
if ( count( $combined_refs ) > 20000
|| $revision < 1
|| $updated_at < $started_at
|| $next_attempt_at < 0
|| $server_cutoff_at < 0
|| $attempts < 0
|| $attempts > 1000000
|| $max_order_id < 0
|| $scan_after_id < 0
|| $blocked_order_id < 0
|| ( $max_order_id >= 0 && $scan_after_id > $max_order_id )
|| ( $max_order_id >= 0 && $blocked_order_id > $max_order_id )
|| ( in_array( $phase, array( 'scanning', 'prepare' ), true )
&& ( '' !== $operation_id
|| '' !== $operation_token
|| 0 !== $server_cutoff_at
|| ! empty( $server_refs ) ) )
|| ( in_array( $phase, array( 'commit', 'local_flags', 'ack' ), true )
&& ( '' === $operation_id
|| '' === $operation_token
|| $server_cutoff_at <= 0
|| $server_cutoff_at > $suppression_cutoff ) )
|| ( 'blocked' !== $status && ( '' !== $blocked_reason || '' !== $blocked_from ) ) ) {
return false;
}
return array(
'schema_version' => orderguard_gdpr_erasure_state_schema_version(),
'request_token' => $request_token,
'mutation_token' => $mutation_token,
'revision' => $revision,
'started_at' => $started_at,
'suppression_cutoff' => $suppression_cutoff,
'suppression_max_order_id' => $max_order_id,
'updated_at' => $updated_at,
'next_attempt_at' => $next_attempt_at,
'status' => $status,
'scan_after_id' => $scan_after_id,
'blocked_order_id' => $blocked_order_id,
'order_storage_mode' => $order_storage_mode,
'storage_mode_mismatch' => $storage_mode_mismatch,
'attempts' => $attempts,
'identity_kind' => $identity_kind,
'match_hashes' => $match_hashes,
'request_bodies' => $request_bodies,
'flag_hashes' => $flag_hashes,
'local_flag_manifest' => $local_flag_manifest,
'order_refs' => $refs,
'server_refs' => $server_refs,
'server_cutoff_at' => $server_cutoff_at,
'operation_id' => $operation_id,
'operation_token' => $operation_token,
'blocked_reason' => $blocked_reason,
'blocked_from' => $blocked_from,
'queued_request' => $queued,
'native_request' => $native_request,
);
}
/**
* Read and validate locally pending erasures.
*
* The raw email/phone is never stored. A pending job temporarily retains the
* request hashes needed for bounded background scanning and server retry,
* plus local order IDs, progress and timestamps. A WordPress-native request
* adds only its numeric request ID and a site-keyed email digest, never the
* readable address. The row is non-autoloaded and is deleted as soon as both
* sides confirm completion.
*/
function orderguard_pending_gdpr_erasures( $force_refresh = false, &$ok = null ) {
static $pending_by_blog = array();
static $valid_by_blog = array();
$blog_id = get_current_blog_id();
if ( $force_refresh ) {
unset( $pending_by_blog[ $blog_id ] );
unset( $valid_by_blog[ $blog_id ] );
}
if ( isset( $pending_by_blog[ $blog_id ] ) ) {
$ok = ! empty( $valid_by_blog[ $blog_id ] );
return $pending_by_blog[ $blog_id ];
}
$found = false;
$read_ok = false;
$stored = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_pending',
$found,
$read_ok
);
$pending = array();
$valid = $read_ok && ( ! $found || is_array( $stored ) );
if ( $valid && $found ) {
foreach ( $stored as $fingerprint => $entry ) {
$normalised = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $entry );
if ( false === $normalised || $normalised !== $entry ) {
$pending = array();
$valid = false;
break;
}
$pending[ $fingerprint ] = $normalised;
}
}
$pending_by_blog[ $blog_id ] = $pending;
$valid_by_blog[ $blog_id ] = $valid;
$ok = $valid;
return $pending_by_blog[ $blog_id ];
}
/** A unique merchant-request generation, independent of second-resolution time. */
function orderguard_new_gdpr_erasure_request_token() {
return hash( 'sha256', wp_generate_uuid4() . '|' . microtime( true ) . '|' . wp_rand() );
}
/** Uniquely identify one attempted state transition, including lost acks. */
function orderguard_new_gdpr_erasure_mutation_token() {
return hash( 'sha256', wp_generate_uuid4() . '|' . hrtime( true ) . '|' . wp_rand() );
}
/** Start a new, uniquely identifiable revision from an existing entry. */
function orderguard_next_pending_gdpr_erasure_entry( $entry ) {
if ( ! is_array( $entry ) ) {
return false;
}
$entry['revision'] = isset( $entry['revision'] ) ? (int) $entry['revision'] + 1 : 1;
$entry['mutation_token'] = orderguard_new_gdpr_erasure_mutation_token();
return $entry;
}
/**
* Maximum bytes accepted for one aggregate privacy option value.
*
* Both stores are single-row compare-and-swap registers, so an update sends
* the old and new value in one statement. Keep each value below one quarter
* of max_allowed_packet as well as a fixed application ceiling. The filter is
* intentionally shrink-only so tests and constrained hosts can fail earlier
* without allowing runtime code to raise the safety ceiling.
*/
function orderguard_gdpr_option_serialized_size_limit( $option_name, &$packet_known = null ) {
global $wpdb;
$static_limits = array(
'orderguard_gdpr_erasure_pending' => 2 * 1024 * 1024,
'orderguard_gdpr_erasure_suppressions' => 4 * 1024 * 1024,
);
if ( ! isset( $static_limits[ $option_name ] ) ) {
$packet_known = false;
return 0;
}
static $packet_bytes_by_connection = array();
$connection_key = is_object( $wpdb ) ? spl_object_hash( $wpdb ) : 'default';
if ( ! array_key_exists( $connection_key, $packet_bytes_by_connection ) ) {
$packet = $wpdb->get_var( 'SELECT @@max_allowed_packet' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bounds privacy CAS payloads to this connection's server limit, which must be read live, not cached
$packet_bytes_by_connection[ $connection_key ] = '' === $wpdb->last_error
&& is_numeric( $packet )
&& (int) $packet > 0
? (int) $packet
: 0;
}
$packet_bytes = $packet_bytes_by_connection[ $connection_key ];
$packet_known = $packet_bytes > 0;
$limit = $static_limits[ $option_name ];
if ( $packet_bytes > 0 ) {
$limit = min( $limit, max( 1, (int) floor( $packet_bytes / 4 ) ) );
}
$filtered = apply_filters(
'orderguard_gdpr_option_serialized_size_limit',
$limit,
$option_name,
$packet_bytes
);
if ( ! is_numeric( $filtered ) || (int) $filtered <= 0 ) {
return 0;
}
return min( $limit, (int) $filtered );
}
/** Refuse oversized aggregate privacy state without truncating or evicting. */
function orderguard_gdpr_option_serialized_size_ok( $option_name, $serialised ) {
$limit = orderguard_gdpr_option_serialized_size_limit( $option_name );
return $limit > 0
&& is_string( $serialised )
&& strlen( $serialised ) <= $limit;
}
/** Exact byte size of a signed suppression envelope plus reserved 64-byte keys. */
function orderguard_gdpr_projected_suppression_size( $entries, $reserved_entries, &$ok = null ) {
$ok = false;
$reserved_entries = (int) $reserved_entries;
$entries_ok = false;
$entries = orderguard_normalize_gdpr_suppression_entries( $entries, $entries_ok );
if ( ! $entries_ok || $reserved_entries < 0 ) {
return PHP_INT_MAX;
}
$entry_count = count( $entries ) + $reserved_entries;
if ( $entry_count < count( $entries ) ) {
return PHP_INT_MAX;
}
$entries_size = strlen( 'a:' . $entry_count . ':{' ) + 1;
foreach ( $entries as $storage_key => $boundary ) {
$entries_size += strlen( serialize( $storage_key ) );
$entries_size += strlen( serialize( $boundary ) );
}
$reserved_size = strlen( serialize( str_repeat( 'f', 64 ) ) )
+ strlen(
serialize(
array(
'cutoff' => PHP_INT_MAX,
'max_order_id' => PHP_INT_MAX,
)
)
);
if ( $reserved_entries > 0
&& $reserved_size > intdiv( PHP_INT_MAX - $entries_size, $reserved_entries ) ) {
return PHP_INT_MAX;
}
$entries_size += $reserved_entries * $reserved_size;
$empty_entries = serialize( array() );
$envelope_size = strlen(
serialize(
array(
'schema_version' => 3,
'generation' => str_repeat( 'f', 64 ),
'entries' => array(),
'digest' => str_repeat( 'f', 64 ),
)
)
) - strlen( $empty_entries ) + $entries_size;
$ok = true;
return $envelope_size;
}
/** Add one active or queued generation to a worst-case suppression projection. */
function orderguard_project_gdpr_erasure_scope_capacity( $scope, $active, &$entries, &$reserved_entries ) {
if ( ! is_array( $scope ) || ! is_array( $entries ) || ! is_int( $reserved_entries ) ) {
return false;
}
$normalised = $active
? orderguard_normalize_pending_gdpr_erasure_entry(
orderguard_gdpr_erasure_fingerprint( isset( $scope['match_hashes'] ) ? $scope['match_hashes'] : array() ),
$scope
)
: orderguard_normalize_queued_gdpr_erasure_request( $scope );
if ( false === $normalised || empty( $normalised ) ) {
return false;
}
$known_ref_keys = array();
foreach ( $normalised['match_hashes'] as $hash ) {
$storage_key = orderguard_gdpr_suppression_storage_key( $hash );
if ( '' === $storage_key ) {
return false;
}
$entries[ $storage_key ] = array(
'cutoff' => PHP_INT_MAX,
'max_order_id' => PHP_INT_MAX,
);
}
$known_refs = $active
? array_values( array_unique( array_merge( $normalised['order_refs'], $normalised['server_refs'] ) ) )
: array();
foreach ( $known_refs as $order_ref ) {
$storage_key = orderguard_gdpr_order_ref_suppression_storage_key( $order_ref );
if ( '' === $storage_key ) {
return false;
}
$known_ref_keys[ $storage_key ] = true;
$entries[ $storage_key ] = array(
'cutoff' => PHP_INT_MAX,
'max_order_id' => PHP_INT_MAX,
);
}
$phase = $active
? ( 'blocked' === $normalised['status'] ? $normalised['blocked_from'] : $normalised['status'] )
: 'scanning';
if ( in_array( $phase, array( 'scanning', 'prepare' ), true ) ) {
// At most one ref can exist for each positive ID under the frozen
// allocator boundary, and the wire/state contract caps the scope at
// 20,000. Unknown refs are treated as collision-free across scopes.
$maximum_refs = min(
20000,
max( 0, (int) $normalised['suppression_max_order_id'] )
);
$additional = max( 0, $maximum_refs - count( $known_ref_keys ) );
if ( $reserved_entries > PHP_INT_MAX - $additional ) {
return false;
}
$reserved_entries += $additional;
}
return true;
}
/** Reserve retry-state growth as refs and protocol receipts arrive. */
function orderguard_gdpr_projected_pending_size( $candidate ) {
$size = strlen( 'a:' . count( $candidate ) . ':{' ) + 1;
foreach ( $candidate as $fingerprint => $entry ) {
$scopes = array( $entry );
if ( ! empty( $entry['queued_request'] ) ) {
// Count a promoted generation as well as the current descriptor.
// This overestimates their overlap, but never under-reserves it.
$scopes[] = orderguard_activate_gdpr_erasure_request( $entry['queued_request'], 1 );
}
$size += strlen( serialize( $fingerprint ) );
foreach ( $scopes as $scope ) {
if ( ! is_array( $scope ) ) {
return PHP_INT_MAX;
}
$maximum_refs = max(
count( $scope['order_refs'] ),
count( $scope['server_refs'] ),
min( 20000, (int) $scope['suppression_max_order_id'] )
);
// Values are placeholders only in this local size calculation.
// Both lists may contain every ref, even though their union is capped.
$scope['order_refs'] = array_fill( 0, $maximum_refs, PHP_INT_MAX );
$scope['server_refs'] = $scope['order_refs'];
foreach ( array( 'revision', 'updated_at', 'next_attempt_at', 'scan_after_id', 'blocked_order_id', 'attempts', 'server_cutoff_at' ) as $field ) {
$scope[ $field ] = PHP_INT_MAX;
}
$scope['operation_id'] = str_repeat( 'f', 128 );
$scope['operation_token'] = str_repeat( 'f', 64 );
$scope['blocked_reason'] = str_repeat( 'f', 64 );
$scope['blocked_from'] = 'local_flags';
$scope['status'] = 'local_flags';
$scope['storage_mode_mismatch'] = 'unavailable';
$size += strlen( serialize( $scope ) );
}
}
return $size;
}
/**
* Prove before publication that every accepted generation can fit locally.
*
* This runs under the lifecycle lock. It projects active and queued scopes
* through their maximum bounded scans without persisting placeholder rows.
*/
function orderguard_gdpr_erasure_admission_capacity_ok( $pending, $fingerprint, $replacement, &$reason = null ) {
$reason = '';
if ( ! is_array( $pending )
|| ! is_string( $fingerprint )
|| ! preg_match( '/^[a-f0-9]{64}$/', $fingerprint ) ) {
$reason = 'capacity_state_invalid';
return false;
}
$replacement = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $replacement );
if ( false === $replacement ) {
$reason = 'capacity_state_invalid';
return false;
}
$candidate = $pending;
$candidate[ $fingerprint ] = $replacement;
foreach ( $candidate as $candidate_fingerprint => $entry ) {
$entry = orderguard_normalize_pending_gdpr_erasure_entry( $candidate_fingerprint, $entry );
if ( false === $entry ) {
$reason = 'capacity_state_invalid';
return false;
}
$candidate[ $candidate_fingerprint ] = $entry;
}
$packet_known = false;
$pending_limit = orderguard_gdpr_option_serialized_size_limit(
'orderguard_gdpr_erasure_pending',
$packet_known
);
if ( ! $packet_known ) {
$reason = 'capacity_packet_unknown';
return false;
}
if ( $pending_limit <= 0 || orderguard_gdpr_projected_pending_size( $candidate ) > $pending_limit ) {
$reason = 'capacity_pending';
return false;
}
$register_ok = false;
$projected = orderguard_gdpr_erasure_suppression_hash_map( true, $register_ok );
if ( ! $register_ok ) {
$reason = 'capacity_state_invalid';
return false;
}
$reserved_entries = 0;
foreach ( $candidate as $entry ) {
if ( ! orderguard_project_gdpr_erasure_scope_capacity(
$entry,
true,
$projected,
$reserved_entries
) ) {
$reason = 'capacity_state_invalid';
return false;
}
if ( ! empty( $entry['queued_request'] )
&& ! orderguard_project_gdpr_erasure_scope_capacity(
$entry['queued_request'],
false,
$projected,
$reserved_entries
) ) {
$reason = 'capacity_state_invalid';
return false;
}
}
$projection_ok = false;
$projected_size = orderguard_gdpr_projected_suppression_size(
$projected,
$reserved_entries,
$projection_ok
);
$packet_known = false;
$register_limit = orderguard_gdpr_option_serialized_size_limit(
'orderguard_gdpr_erasure_suppressions',
$packet_known
);
if ( ! $packet_known ) {
$reason = 'capacity_packet_unknown';
return false;
}
if ( ! $projection_ok || $register_limit <= 0 || $projected_size > $register_limit ) {
$reason = 'capacity_suppression';
return false;
}
return true;
}
/** Read one privacy option directly, distinguishing absence from read failure. */
function orderguard_gdpr_read_option_row( $option_name, &$found = null, &$ok = null ) {
global $wpdb;
$found = false;
$ok = false;
if ( ! is_string( $option_name ) || ! preg_match( '/^orderguard_gdpr_[a-z0-9_]+$/', $option_name ) ) {
return null;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- privacy guards must distinguish a missing row from a database/cache failure.
if ( '' !== $wpdb->last_error ) {
return null;
}
$found = null !== $raw;
if ( $found
&& in_array(
$option_name,
array( 'orderguard_gdpr_erasure_pending', 'orderguard_gdpr_erasure_suppressions' ),
true
)
&& ! orderguard_gdpr_option_serialized_size_ok( $option_name, $raw ) ) {
orderguard_log( 'OrderGuard: aggregate GDPR privacy storage exceeds its safe serialized-size limit.' );
return null;
}
$ok = true;
return $found ? maybe_unserialize( $raw ) : null;
}
/**
* Atomically replace or remove exactly one pending erasure entry.
*
* The whole option row is compare-and-swapped because fingerprints share one
* row, but every retry starts from fresh bytes and changes only this key.
* Expected target state prevents stale workers from regressing, resurrecting
* or clearing a newer merchant request. A changed unrelated key is retried;
* a changed target is reported as a conflict and never overwritten.
*/
function orderguard_compare_and_swap_pending_gdpr_erasure( $fingerprint, $expected_entry, $replacement_entry, &$target_conflict = null ) {
global $wpdb;
$target_conflict = false;
if ( ! is_string( $fingerprint ) || ! preg_match( '/^[a-f0-9]{64}$/', $fingerprint ) ) {
return false;
}
$expected = null;
if ( null !== $expected_entry ) {
$expected = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $expected_entry );
if ( false === $expected ) {
return false;
}
}
$replacement = null;
if ( null !== $replacement_entry ) {
$replacement = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $replacement_entry );
if ( false === $replacement ) {
return false;
}
$required_revision = null === $expected ? 1 : ( (int) $expected['revision'] + 1 );
if ( $required_revision !== (int) $replacement['revision'] ) {
orderguard_log( 'OrderGuard: pending GDPR erasure mutation refused a non-sequential revision.' );
return false;
}
if ( ! isset( $replacement_entry['mutation_token'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', (string) $replacement_entry['mutation_token'] )
|| ( null !== $expected && hash_equals( $expected['mutation_token'], $replacement['mutation_token'] ) ) ) {
orderguard_log( 'OrderGuard: pending GDPR erasure mutation refused a missing or reused mutation token.' );
return false;
}
}
$option_name = 'orderguard_gdpr_erasure_pending';
$write_attempts = 0;
$write_attempted = false;
$verify_after_error = false;
while ( $write_attempts < 10 || $verify_after_error ) {
$wpdb->last_error = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- concurrency requires the current row bytes.
if ( '' !== $wpdb->last_error ) {
orderguard_log( 'OrderGuard: pending GDPR erasure storage could not be read.' );
return false;
}
$found = null !== $raw;
if ( $found && ! orderguard_gdpr_option_serialized_size_ok( $option_name, $raw ) ) {
orderguard_log( 'OrderGuard: pending GDPR erasure storage exceeds its safe serialized-size limit; mutation refused.' );
return false;
}
$map = $found ? maybe_unserialize( $raw ) : array();
if ( ! is_array( $map ) ) {
orderguard_log( 'OrderGuard: pending GDPR erasure storage is invalid; mutation refused.' );
return false;
}
// This shared row is one integrity domain. A mutation of customer A
// must never carry an unreadable customer-B entry forward and make the
// global transport guard depend on which key happens to be read next.
// Every writer emits this canonical shape, so a non-canonical sibling
// is corruption rather than a harmless representation difference.
foreach ( $map as $stored_fingerprint => $stored_entry ) {
$stored_normalised = orderguard_normalize_pending_gdpr_erasure_entry(
$stored_fingerprint,
$stored_entry
);
if ( false === $stored_normalised || $stored_normalised !== $stored_entry ) {
orderguard_log( 'OrderGuard: pending GDPR erasure map contains a non-canonical entry; mutation refused.' );
return false;
}
}
$current = null;
if ( array_key_exists( $fingerprint, $map ) ) {
$current = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $map[ $fingerprint ] );
if ( false === $current ) {
orderguard_log( 'OrderGuard: pending GDPR erasure entry is invalid; mutation refused.' );
return false;
}
}
if ( $write_attempted && $current === $replacement ) {
// The mutation may have committed while its acknowledgement was
// lost. A per-mutation token proves this exact invocation landed;
// an equivalent transition from another worker cannot satisfy it.
orderguard_pending_gdpr_erasures( true );
return true;
}
if ( $current !== $expected ) {
$target_conflict = true;
orderguard_pending_gdpr_erasures( true );
return false;
}
if ( $verify_after_error ) {
orderguard_log( 'OrderGuard: pending GDPR erasure mutation failed and its state could not be verified.' );
return false;
}
$next = $map;
if ( null === $replacement ) {
unset( $next[ $fingerprint ] );
} else {
$next[ $fingerprint ] = $replacement;
}
$serialised = maybe_serialize( $next );
if ( ! orderguard_gdpr_option_serialized_size_ok( $option_name, $serialised ) ) {
orderguard_log( 'OrderGuard: pending GDPR erasure storage reached its safe serialized-size limit; mutation refused.' );
return false;
}
if ( $found && $serialised === $raw ) {
orderguard_pending_gdpr_erasures( true );
return true;
}
++$write_attempts;
$write_attempted = true;
if ( ! $found ) {
if ( empty( $next ) ) {
orderguard_pending_gdpr_erasures( true );
return true;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name,
$serialised
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic first writer for the shared pending map.
} elseif ( empty( $next ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name,
$raw
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact-value last-key removal.
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s, autoload = 'off' WHERE option_name = %s AND BINARY option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$serialised,
$option_name,
$raw
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact-value pending-map compare-and-swap.
}
orderguard_invalidate_option_caches( $option_name, false );
if ( false === $written ) {
// Some database layers can lose the result after committing. Do
// one fresh read and accept only this invocation's unique state.
$verify_after_error = true;
continue;
}
if ( 1 === (int) $written ) {
orderguard_pending_gdpr_erasures( true );
return true;
}
// A different fingerprint changed the shared row. Re-read and merge
// only this target; a same-fingerprint change is caught above.
}
orderguard_log( 'OrderGuard: pending GDPR erasure storage stayed busy; mutation deferred.' );
return false;
}
/**
* Secret used to make the local erasure register resistant to offline guesses.
*
* A deployment should pin this independently in wp-config.php. Without that
* constant, wp_salt() may ultimately use WordPress's database-backed secret_key,
* which does not protect this register from an attacker holding the same DB.
* Rotating either key makes the authenticated register unreadable and all
* outbound identity paths fail closed, so preserve it across migrations.
*/
function orderguard_gdpr_suppression_secret() {
if ( defined( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' ) ) {
$configured = constant( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' );
if ( is_string( $configured ) && strlen( $configured ) >= 32 ) {
return $configured;
}
}
return wp_salt( 'orderguard_gdpr_suppression' );
}
/** Stable random namespace that prevents cross-site register correlation. */
function orderguard_gdpr_suppression_site_namespace( $create = false ) {
$found = false;
$ok = false;
$site_id = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_site_id',
$found,
$ok
);
if ( $ok && $found && is_string( $site_id ) && preg_match( '/^[a-f0-9]{64}$/', $site_id ) ) {
return $site_id;
}
if ( ! $ok || $found || ! $create || ! orderguard_initialize_gdpr_suppression_lifecycle() ) {
return '';
}
$site_id = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_site_id',
$found,
$ok
);
return $ok && $found && is_string( $site_id ) && preg_match( '/^[a-f0-9]{64}$/', $site_id )
? $site_id
: '';
}
/** Fresh, unpredictable generation used to invalidate verified map caches. */
function orderguard_new_gdpr_suppression_generation() {
return hash( 'sha256', wp_generate_uuid4() . '|' . hrtime( true ) . '|' . wp_rand() );
}
/** Expected keyed canary for one namespace/register generation. */
function orderguard_gdpr_suppression_integrity_digest( $namespace, $generation ) {
if ( ! is_string( $namespace )
|| ! preg_match( '/^[a-f0-9]{64}$/', $namespace )
|| ! is_string( $generation )
|| ! preg_match( '/^[a-f0-9]{64}$/', $generation ) ) {
return '';
}
return hash_hmac(
'sha256',
'orderguard-gdpr-register-v2|' . $namespace . '|' . $generation,
orderguard_gdpr_suppression_secret()
);
}
/** Build the small signed generation marker paired with the full register. */
function orderguard_gdpr_suppression_integrity_record( $namespace, $generation ) {
$digest = orderguard_gdpr_suppression_integrity_digest( $namespace, $generation );
if ( '' === $digest ) {
return false;
}
return array(
'schema_version' => 2,
'generation' => $generation,
'digest' => $digest,
);
}
/** Verify and canonicalise the small signed generation marker. */
function orderguard_normalize_gdpr_suppression_integrity_record( $stored, $namespace, &$ok = null ) {
$ok = false;
if ( ! is_array( $stored )
|| array( 'schema_version', 'generation', 'digest' ) !== array_keys( $stored )
|| 2 !== $stored['schema_version']
|| ! is_string( $stored['generation'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $stored['generation'] )
|| ! is_string( $stored['digest'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $stored['digest'] ) ) {
return array();
}
$expected = orderguard_gdpr_suppression_integrity_digest(
$namespace,
$stored['generation']
);
if ( '' === $expected || ! hash_equals( $expected, $stored['digest'] ) ) {
return array();
}
$ok = true;
return $stored;
}
/** Authenticate the non-PII marker for an unreconciled server replay. */
function orderguard_gdpr_protocol_fault_digest( $namespace, $reason, $detected_at ) {
$reason = (string) $reason;
$detected_at = (int) $detected_at;
$allowed_reasons = array(
'protocol_replay_conflict',
'protocol_http_409',
'protocol_terminal_response',
'protocol_state_unrecoverable',
'scope_limit_exceeded',
);
if ( ! preg_match( '/^[a-f0-9]{64}$/', (string) $namespace )
|| ! in_array( $reason, $allowed_reasons, true )
|| $detected_at <= 0 ) {
return '';
}
return hash_hmac(
'sha256',
'orderguard-gdpr-protocol-fault-v1|' . $namespace . '|' . $reason . '|' . $detected_at,
orderguard_gdpr_suppression_secret()
);
}
/** Verify the durable, non-PII global protocol-fault marker. */
function orderguard_normalize_gdpr_protocol_fault( $stored, $namespace, &$ok = null ) {
$ok = false;
if ( ! is_array( $stored )
|| array( 'schema_version', 'reason', 'detected_at', 'digest' ) !== array_keys( $stored )
|| 1 !== $stored['schema_version']
|| ! is_string( $stored['reason'] )
|| ! is_int( $stored['detected_at'] )
|| ! is_string( $stored['digest'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $stored['digest'] ) ) {
return array();
}
$expected = orderguard_gdpr_protocol_fault_digest(
$namespace,
$stored['reason'],
$stored['detected_at']
);
if ( '' === $expected || ! hash_equals( $expected, $stored['digest'] ) ) {
return array();
}
$ok = true;
return $stored;
}
/**
* Persist a global fail-closed marker after an impossible server replay.
*
* This marker has no automatic clear. Recovery requires a supervised state
* reconciliation because continuing could either recreate erased identity or
* discard a merchant request that the server already acknowledged.
*/
function orderguard_set_gdpr_protocol_fault( $reason ) {
global $wpdb;
$namespace = orderguard_gdpr_suppression_site_namespace();
$detected_at = time();
$digest = orderguard_gdpr_protocol_fault_digest( $namespace, $reason, $detected_at );
if ( '' === $digest ) {
return false;
}
$record = array(
'schema_version' => 1,
'reason' => (string) $reason,
'detected_at' => $detected_at,
'digest' => $digest,
);
$option_name = 'orderguard_gdpr_erasure_protocol_fault';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off') ON DUPLICATE KEY UPDATE option_value = VALUES(option_value), autoload = 'off'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name,
maybe_serialize( $record )
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the disaster marker must bypass stale option caches and survive ambiguous acknowledgements.
orderguard_invalidate_option_caches( $option_name, false );
$found = false;
$read_ok = false;
$stored = orderguard_gdpr_read_option_row( $option_name, $found, $read_ok );
$record_ok = false;
$verified = $read_ok && $found
? orderguard_normalize_gdpr_protocol_fault( $stored, $namespace, $record_ok )
: array();
return $record_ok
&& $verified['reason'] === $record['reason']
&& $verified['detected_at'] >= $record['detected_at'];
}
/** Strictly canonicalise the permanent register's keyed boundary entries. */
function orderguard_normalize_gdpr_suppression_entries( $entries, &$ok = null ) {
$ok = false;
if ( ! is_array( $entries ) ) {
return array();
}
$normalised = array();
foreach ( $entries as $storage_key => $boundary ) {
if ( ! is_string( $storage_key )
|| ! preg_match( '/^[a-f0-9]{64}$/', $storage_key )
|| ! is_array( $boundary )
|| array( 'cutoff', 'max_order_id' ) !== array_keys( $boundary )
|| ! is_int( $boundary['cutoff'] )
|| ! is_int( $boundary['max_order_id'] )
|| $boundary['cutoff'] <= 0
|| $boundary['max_order_id'] < -1 ) {
return array();
}
$normalised[ $storage_key ] = array(
'cutoff' => $boundary['cutoff'],
'max_order_id' => $boundary['max_order_id'],
);
}
ksort( $normalised, SORT_STRING );
$ok = true;
return $normalised;
}
/** HMAC the complete canonical register, not merely its key canary. */
function orderguard_gdpr_suppression_entries_digest( $entries, $namespace, $generation ) {
$entries_ok = false;
$entries = orderguard_normalize_gdpr_suppression_entries( $entries, $entries_ok );
if ( ! $entries_ok
|| ! is_string( $namespace )
|| ! preg_match( '/^[a-f0-9]{64}$/', $namespace )
|| ! is_string( $generation )
|| ! preg_match( '/^[a-f0-9]{64}$/', $generation ) ) {
return '';
}
$encoded = wp_json_encode(
array(
'schema_version' => 3,
'namespace' => $namespace,
'generation' => $generation,
'entries' => $entries,
),
JSON_UNESCAPED_SLASHES
);
if ( ! is_string( $encoded ) || '' === $encoded ) {
return '';
}
return hash_hmac( 'sha256', $encoded, orderguard_gdpr_suppression_secret() );
}
/** Build one atomic, authenticated suppression-register value. */
function orderguard_gdpr_suppression_envelope( $entries, $namespace, $generation ) {
$entries_ok = false;
$entries = orderguard_normalize_gdpr_suppression_entries( $entries, $entries_ok );
$digest = $entries_ok
? orderguard_gdpr_suppression_entries_digest( $entries, $namespace, $generation )
: '';
if ( '' === $digest ) {
return false;
}
return array(
'schema_version' => 3,
'generation' => $generation,
'entries' => $entries,
'digest' => $digest,
);
}
/** Verify and unpack one complete authenticated suppression-register value. */
function orderguard_normalize_gdpr_suppression_envelope( $stored, &$ok = null, $namespace = '', $expected_generation = '' ) {
$ok = false;
if ( ! is_array( $stored )
|| array( 'schema_version', 'generation', 'entries', 'digest' ) !== array_keys( $stored )
|| 3 !== $stored['schema_version']
|| ! is_string( $stored['generation'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $stored['generation'] )
|| ! is_string( $stored['digest'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $stored['digest'] ) ) {
return array();
}
$entries_ok = false;
$entries = orderguard_normalize_gdpr_suppression_entries( $stored['entries'], $entries_ok );
if ( '' === $namespace ) {
$namespace = orderguard_gdpr_suppression_site_namespace();
}
if ( '' !== $expected_generation && ! hash_equals( $expected_generation, $stored['generation'] ) ) {
return array();
}
$expected = $entries_ok
? orderguard_gdpr_suppression_entries_digest(
$entries,
$namespace,
$stored['generation']
)
: '';
if ( '' === $expected || ! hash_equals( $expected, $stored['digest'] ) ) {
return array();
}
$ok = true;
return $entries;
}
/**
* Atomically initialise the namespace, signed generation and empty register.
*
* A single multi-row INSERT avoids a crash leaving a site-ID-only lifecycle.
* A concurrent winner is accepted only after all three rows authenticate.
*/
function orderguard_initialize_gdpr_suppression_lifecycle() {
global $wpdb;
$names = array(
'orderguard_gdpr_erasure_site_id',
'orderguard_gdpr_erasure_integrity',
'orderguard_gdpr_erasure_suppressions',
);
foreach ( $names as $option_name ) {
$found = false;
$ok = false;
orderguard_gdpr_read_option_row( $option_name, $found, $ok );
if ( ! $ok || $found ) {
return false;
}
}
$namespace = hash( 'sha256', wp_generate_uuid4() . '|' . microtime( true ) . '|' . wp_rand() );
$generation = orderguard_new_gdpr_suppression_generation();
$integrity = orderguard_gdpr_suppression_integrity_record( $namespace, $generation );
$register = orderguard_gdpr_suppression_envelope( array(), $namespace, $generation );
if ( false === $integrity || false === $register ) {
return false;
}
$register_serialised = maybe_serialize( $register );
if ( ! orderguard_gdpr_option_serialized_size_ok( $names[2], $register_serialised ) ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression storage limit is too small to initialise safely.' );
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off'), (%s, %s, 'off'), (%s, %s, 'off')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one atomic first-write for all suppression lifecycle rows.
$names[0],
$namespace,
$names[1],
maybe_serialize( $integrity ),
$names[2],
$register_serialised
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the three-row invariant cannot be established safely with separate option API calls.
foreach ( $names as $option_name ) {
orderguard_invalidate_option_caches( $option_name, false );
}
if ( false !== $written && 3 === (int) $written ) {
return true;
}
// A competing request may have initialised all rows first, or a database
// layer may have lost this statement's acknowledgement. Never mistake a
// still-pristine or partial lifecycle for success: require all three rows
// before delegating to their cryptographic verification.
foreach ( $names as $option_name ) {
$found = false;
$ok = false;
orderguard_gdpr_read_option_row( $option_name, $found, $ok );
if ( ! $ok || ! $found ) {
return false;
}
}
return orderguard_gdpr_suppression_integrity_ok( false );
}
/** Create or verify the complete versioned suppression lifecycle. */
function orderguard_gdpr_suppression_integrity_ok( $create = false ) {
$values = array();
$found = array();
foreach ( array(
'site' => 'orderguard_gdpr_erasure_site_id',
'canary' => 'orderguard_gdpr_erasure_integrity',
'register' => 'orderguard_gdpr_erasure_suppressions',
) as $key => $option_name ) {
$read_ok = false;
$values[ $key ] = orderguard_gdpr_read_option_row(
$option_name,
$found[ $key ],
$read_ok
);
if ( ! $read_ok ) {
return false;
}
}
$present = (int) $found['site'] + (int) $found['canary'] + (int) $found['register'];
if ( 0 === $present ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok || ! empty( $pending ) ) {
return false;
}
return $create ? orderguard_initialize_gdpr_suppression_lifecycle() : true;
}
if ( 3 !== $present
|| ! is_string( $values['site'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $values['site'] ) ) {
return false;
}
$canary_ok = false;
$canary = orderguard_normalize_gdpr_suppression_integrity_record(
$values['canary'],
$values['site'],
$canary_ok
);
if ( ! $canary_ok ) {
return false;
}
$register_ok = false;
orderguard_normalize_gdpr_suppression_envelope(
$values['register'],
$register_ok,
$values['site'],
$canary['generation']
);
return $register_ok;
}
/**
* Validate both privacy-state stores from fresh option values.
*
* Corrupt state must fail closed: silently dropping a malformed accepted job
* or an unreadable permanent register would resume identity transmission.
*/
/** Prospective version observations; never invent a historical install date. */
function orderguard_record_version_history( $event = 'observed' ) {
global $wpdb;
// Do not manufacture legacy inventory while a fresh activation is retrying.
$setup = get_option( 'orderguard_owned_install_status' );
if ( is_array( $setup ) && ( $setup['activation_pending'] ?? false ) === true ) { return false; }
if ( ! in_array( $event, array( 'observed', 'activated', 'upgraded' ), true ) ) { return false; }
$header = get_file_data( __FILE__, array( 'version' => 'Version' ) );
$version = $header['version'];
if ( ! preg_match( '/^[A-Za-z0-9.+-]{1,32}$/D', $version ) ) { return false; }
$name = 'orderguard_version_history';
for ( $attempt = 0; $attempt < 3; ++$attempt ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
if ( '' !== $wpdb->last_error || ( null !== $raw && strlen( $raw ) > 131072 ) ) { return false; }
$history = null === $raw ? array() : @unserialize( $raw, array( 'allowed_classes' => false ) );
if ( ! is_array( $history ) || count( $history ) > 256 ) { return false; }
foreach ( $history as $key => $events ) {
if ( ! is_string( $key ) || ! preg_match( '/^[A-Za-z0-9.+-]{1,32}$/D', $key ) || ! is_array( $events ) ) { return false; }
foreach ( $events as $kind => $timestamp ) {
if ( ! in_array( $kind, array( 'observed', 'activated', 'upgraded' ), true ) || ! is_int( $timestamp ) || $timestamp <= 0 ) { return false; }
}
}
if ( isset( $history[$version][$event] ) ) { return true; }
if ( ! isset( $history[$version] ) && count( $history ) >= 256 ) { return false; }
$history[$version][$event] = time();
$next = serialize( $history );
$written = null === $raw
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
? $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $name, $next ) )
: $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", $next, $name, $raw ) );
orderguard_invalidate_option_caches( $name );
if ( false === $written ) { return false; }
if ( 1 === $written ) { return true; }
}
return false;
}
add_action( 'admin_init', static function () {
if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! wp_doing_ajax() ) { orderguard_record_version_history(); }
} );
register_activation_hook( __FILE__, static function () { orderguard_record_version_history( 'activated' ); } );
add_action( 'upgrader_process_complete', static function ( $upgrader, $options ) {
if ( 'plugin' === ( $options['type'] ?? '' ) && 'update' === ( $options['action'] ?? '' )
&& in_array( plugin_basename( __FILE__ ), (array) ( $options['plugins'] ?? array() ), true ) ) {
orderguard_record_version_history( 'upgraded' );
}
}, 10, 2 );
/** Metadata-only support export. Unknown evidence is never reported as zero. */
function orderguard_gdpr_diagnostics_manifest() {
global $wpdb, $wp_version;
$manifest = array(
'format_version' => 1, 'generated_at_utc' => gmdate( 'c' ),
'plugin_version' => ORDERGUARD_VERSION, 'wordpress_version' => $wp_version,
'woocommerce_version' => defined( 'WC_VERSION' ) ? WC_VERSION : null,
'multisite' => is_multisite(),
'blog_id_digest' => hash( 'sha256', 'orderguard-blog-v1|' . home_url() . '|' . get_current_blog_id() ),
'object_cache_dropin' => file_exists( WP_CONTENT_DIR . '/object-cache.php' ),
'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON,
'authenticated_store_identifier_digest' => null,
'server_identity_verification' => 'not_performed_read_only_export',
'local_store_domain_digest' => hash( 'sha256', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ),
'rows' => array(), 'tables' => array(),
'server_erasure_history' => 'unknown_requires_operator_audit',
);
$values = array();
$names = array( 'site_id', 'integrity', 'suppressions', 'pending', 'protocol_fault' );
foreach ( $names as $suffix ) {
$name = 'orderguard_gdpr_erasure_' . $suffix;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT OCTET_LENGTH(option_value) AS bytes, autoload, CASE WHEN OCTET_LENGTH(option_value) <= 8388608 THEN option_value ELSE NULL END AS value FROM {$wpdb->options} WHERE option_name = %s", $name
), ARRAY_A );
$read_ok = '' === $wpdb->last_error;
$value = null;
if ( $read_ok && $row && null !== $row['value'] ) {
$value = is_serialized( $row['value'] ) ? @unserialize( $row['value'], array( 'allowed_classes' => false ) ) : $row['value'];
}
$values[$suffix] = $value;
$manifest['rows'][$suffix] = array(
'present' => $read_ok ? null !== $row : null,
'bytes' => $row ? (int) $row['bytes'] : null,
'autoload' => $row ? ( in_array( $row['autoload'], array( 'yes', 'no', 'on', 'off', 'auto', 'auto-on', 'auto-off' ), true ) ? $row['autoload'] : 'unrecognized' ) : null,
'schema_version' => is_array( $value ) && isset( $value['schema_version'] ) && is_int( $value['schema_version'] ) ? $value['schema_version'] : null,
'signature_validity' => 'not_checked',
'read_status' => ! $read_ok ? 'error' : ( $row && null === $row['value'] ? 'oversized' : 'ok' ),
);
}
$namespace = $values['site_id'];
$namespace_valid = is_string( $namespace ) && 1 === preg_match( '/^[a-f0-9]{64}$/D', $namespace );
$manifest['site_id_digest'] = $namespace_valid ? hash( 'sha256', $namespace ) : null;
$manifest['rows']['site_id']['signature_validity'] = 'not_applicable_unsigned_identifier';
// Do not invoke wp_salt here: on a damaged site it can create key rows.
// Fallback-key installations therefore export unknown validity explicitly.
$can_verify = $namespace_valid && defined( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' )
&& is_string( ORDERGUARD_GDPR_SUPPRESSION_SECRET ) && strlen( ORDERGUARD_GDPR_SUPPRESSION_SECRET ) >= 32;
$canary = array();
foreach ( array( 'integrity', 'suppressions', 'protocol_fault' ) as $suffix ) {
if ( ! $manifest['rows'][$suffix]['present'] ) { continue; }
if ( ! $can_verify ) {
$manifest['rows'][$suffix]['signature_validity'] = 'unknown_without_nonmutating_key_verification';
continue;
}
$valid = false;
if ( 'integrity' === $suffix ) {
$canary = orderguard_normalize_gdpr_suppression_integrity_record( $values[$suffix], $namespace, $valid );
} elseif ( 'suppressions' === $suffix ) {
orderguard_normalize_gdpr_suppression_envelope( $values[$suffix], $valid, $namespace, $canary['generation'] ?? '' );
} else {
orderguard_normalize_gdpr_protocol_fault( $values[$suffix], $namespace, $valid );
}
$manifest['rows'][$suffix]['signature_validity'] = $valid ? 'valid' : 'invalid';
}
$manifest['rows']['pending']['signature_validity'] = 'not_applicable_unsigned_state_not_validated_by_export';
$manifest['pending_entries'] = is_array( $values['pending'] ) ? count( $values['pending'] ) : null;
$manifest['register_entries'] = is_array( $values['suppressions'] ) && isset( $values['suppressions']['entries'] ) && is_array( $values['suppressions']['entries'] ) ? count( $values['suppressions']['entries'] ) : null;
$manifest['last_pending_activity_utc_epoch'] = null;
foreach ( is_array( $values['pending'] ) ? $values['pending'] : array() as $entry ) {
if ( is_array( $entry ) && isset( $entry['updated_at'] ) && is_int( $entry['updated_at'] ) ) {
$manifest['last_pending_activity_utc_epoch'] = max( (int) $manifest['last_pending_activity_utc_epoch'], $entry['updated_at'] );
}
}
foreach ( array( 'flags' => $wpdb->prefix . 'orderguard_flags', 'postmeta' => $wpdb->postmeta, 'hpos_meta' => $wpdb->prefix . 'wc_orders_meta', 'actions' => $wpdb->prefix . 'actionscheduler_actions' ) as $label => $table ) {
if ( ! preg_match( '/^[A-Za-z0-9_]+$/D', $table ) ) { $manifest['tables'][$label] = array( 'exists' => null ); continue; }
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- exact counts across OrderGuard, WordPress, WooCommerce and Action Scheduler tables; no cached API gives these; must count the current rows; caching would be wrong
$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
$manifest['tables'][$label] = array( 'exists' => '' === $wpdb->last_error ? null !== $exists : null );
if ( ! $exists ) { continue; }
if ( 'flags' === $label ) {
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; exact counts across OrderGuard, WordPress, WooCommerce and Action Scheduler tables; no cached API gives these; must count the current rows; caching would be wrong
$count = $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$manifest['tables'][$label]['row_count'] = '' === $wpdb->last_error ? (int) $count : null;
} elseif ( 'actions' === $label ) {
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; exact counts across OrderGuard, WordPress, WooCommerce and Action Scheduler tables; no cached API gives these; must count the current rows; caching would be wrong
$counts = $wpdb->get_results( "SELECT status, COUNT(*) AS count FROM `{$table}` WHERE hook IN ('orderguard_process_gdpr_erasure', 'orderguard_exclusion_erase_retry') GROUP BY status", ARRAY_A );
$status_counts = array();
foreach ( (array) $counts as $count ) {
$status = in_array( $count['status'], array( 'pending', 'in-progress', 'complete', 'failed', 'canceled' ), true ) ? $count['status'] : 'unrecognized';
$status_counts[$status] = ( $status_counts[$status] ?? 0 ) + (int) $count['count'];
}
$manifest['tables'][$label]['erasure_actions_by_status'] = '' === $wpdb->last_error ? $status_counts : null;
} else {
foreach ( array( 'erasure_markers' => '_orderguard_gdpr_erased_at', 'native_receipts' => '_orderguard_gdpr_erasure_receipt' ) as $kind => $key ) {
// No status filter: includes trash/refunds and inactive storage rows.
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE meta_key = %s", $key ) );
$manifest['tables'][$label][$kind] = '' === $wpdb->last_error ? (int) $count : null;
}
}
}
$manifest['wp_cron_erasure_events'] = array();
foreach ( (array) _get_cron_array() as $timestamp => $hooks ) {
foreach ( array( 'orderguard_process_gdpr_erasure', 'orderguard_exclusion_erase_retry' ) as $hook ) {
if ( isset( $hooks[$hook] ) && is_array( $hooks[$hook] ) ) {
$manifest['wp_cron_erasure_events'][] = array( 'hook' => $hook, 'scheduled_at' => (int) $timestamp, 'count' => count( $hooks[$hook] ) );
}
}
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$cursor = $wpdb->get_row( $wpdb->prepare( "SELECT OCTET_LENGTH(option_value) AS bytes, autoload FROM {$wpdb->options} WHERE option_name = %s", 'orderguard_exclusion_erase_recovery_cursor' ), ARRAY_A );
$manifest['exclusion_erasure_cursor'] = array( 'present' => '' === $wpdb->last_error ? null !== $cursor : null, 'bytes' => $cursor ? (int) $cursor['bytes'] : null );
$manifest['other_receipt_kind'] = 'order_erasure_markers_reported_separately_no_second_receipt_table';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$history_raw = $wpdb->get_var( $wpdb->prepare( "SELECT CASE WHEN OCTET_LENGTH(option_value) <= 131072 THEN option_value ELSE NULL END FROM {$wpdb->options} WHERE option_name = %s", 'orderguard_version_history' ) );
$history = is_string( $history_raw ) ? @unserialize( $history_raw, array( 'allowed_classes' => false ) ) : null;
$manifest['version_history'] = array();
foreach ( is_array( $history ) ? $history : array() as $version => $events ) {
if ( ! is_string( $version ) || ! preg_match( '/^[A-Za-z0-9.+-]{1,32}$/D', $version ) || ! is_array( $events ) ) { continue; }
foreach ( array( 'observed', 'activated', 'upgraded' ) as $kind ) {
if ( isset( $events[$kind] ) && is_int( $events[$kind] ) && $events[$kind] > 0 ) {
$manifest['version_history'][$version][$kind] = $events[$kind];
}
}
}
$manifest['version_history_status'] = '' !== $wpdb->last_error ? 'read_error' : ( is_array( $history ) ? 'available_prospective_only' : 'unavailable' );
$manifest['state_code'] = $namespace_valid && false === $manifest['rows']['integrity']['present'] && false === $manifest['rows']['suppressions']['present']
? 'lone_valid_namespace' : ( true === $manifest['rows']['suppressions']['present'] && false === $manifest['rows']['site_id']['present'] ? 'register_without_namespace' : 'inspect_row_evidence' );
$manifest['limitations'] = array( 'metadata_only_not_a_backup', 'counts_are_not_unique_orders_across_storage_modes', 'no_historical_absence_proof', 'no_server_authentication_performed', 'database_queries_not_a_single_point_in_time_snapshot' );
$manifest['manifest_sha256'] = hash( 'sha256', wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES ) );
return $manifest;
}
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_CLI' ) ) {
WP_CLI::add_command( 'orderguard privacy-diagnostics', static function () {
WP_CLI::line( wp_json_encode( orderguard_gdpr_diagnostics_manifest(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
} );
}
/** Read-only classification; never initializes or repairs lifecycle state. */
function orderguard_gdpr_diagnostic_state() {
$reason = '';
if ( orderguard_gdpr_erasure_state_integrity( $reason ) ) {
return 'verified';
}
$rows = array();
$found = array();
foreach ( array(
'namespace' => 'orderguard_gdpr_erasure_site_id',
'integrity' => 'orderguard_gdpr_erasure_integrity',
'register' => 'orderguard_gdpr_erasure_suppressions',
) as $key => $name ) {
$ok = false;
$rows[$key] = orderguard_gdpr_read_option_row( $name, $found[$key], $ok );
if ( ! $ok ) {
return 'storage_unreadable';
}
}
if ( $found['namespace'] && ( ! is_string( $rows['namespace'] )
|| ! preg_match( '/^[a-f0-9]{64}$/D', $rows['namespace'] ) ) ) {
return 'invalid_namespace';
}
if ( $found['register'] && ! $found['namespace'] ) {
return 'register_without_namespace';
}
if ( $found['namespace'] && ! $found['integrity'] && ! $found['register'] ) {
return 'lone_valid_namespace';
}
if ( $found['integrity'] && ! $found['namespace'] ) {
return 'integrity_without_namespace';
}
if ( $found['namespace'] && $found['integrity'] ) {
$valid = false;
orderguard_normalize_gdpr_suppression_integrity_record( $rows['integrity'], $rows['namespace'], $valid );
if ( ! $valid ) {
return 'invalid_integrity_signature_or_shape';
}
if ( ! $found['register'] ) {
return 'missing_register';
}
}
if ( $found['namespace'] && $found['register'] && ! $found['integrity'] ) {
return 'missing_integrity';
}
// Only fixed codes escape; no stored data is included in a notice.
return 'register_invalid' === $reason ? 'invalid_register_signature_or_shape'
: ( 'pending_invalid' === $reason ? 'invalid_pending_state' : 'privacy_state_unverified' );
}
add_action( 'admin_notices', 'orderguard_gdpr_integrity_notice' );
function orderguard_gdpr_integrity_notice() {
if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$state = orderguard_gdpr_diagnostic_state();
if ( 'verified' === $state ) {
return;
}
echo '
'
. esc_html__( 'OrderGuard privacy protection could not be verified. Scoring is paused and erasure processing is blocked. Operator intervention is required; do not delete or reset privacy records. Saved scores have not been deleted by this pause.', 'orderguard' )
. ' ' . esc_html( $state ) . ' '
. ''
. esc_html__( 'Contact OrderGuard support', 'orderguard' ) . '
';
}
function orderguard_gdpr_erasure_state_integrity( &$reason = null ) {
$reason = '';
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
$reason = 'pending_invalid';
return false;
}
foreach ( $pending as $entry ) {
// Ordinary deterministic refusals (for example a 401 or an oversized
// local scope) pause only their own merchant request. A protocol_*
// block remains a fail-closed fallback if its signed global marker
// could not be persisted after an impossible server replay.
if ( 'blocked' === $entry['status']
&& 0 === strpos( $entry['blocked_reason'], 'protocol_' ) ) {
$reason = 'protocol_fault_' . $entry['blocked_reason'];
return false;
}
}
$fault_found = false;
$fault_read_ok = false;
$fault = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_protocol_fault',
$fault_found,
$fault_read_ok
);
if ( ! $fault_read_ok ) {
$reason = 'protocol_fault_unreadable';
return false;
}
$values = array();
$found = array();
foreach ( array(
'site' => 'orderguard_gdpr_erasure_site_id',
'canary' => 'orderguard_gdpr_erasure_integrity',
) as $key => $option_name ) {
$read_ok = false;
$values[ $key ] = orderguard_gdpr_read_option_row(
$option_name,
$found[ $key ],
$read_ok
);
if ( ! $read_ok ) {
$reason = 'register_unreadable';
return false;
}
}
$present = (int) $found['site'] + (int) $found['canary'];
if ( 0 === $present ) {
if ( $fault_found ) {
$reason = 'protocol_fault_invalid';
return false;
}
$register_found = false;
$register_ok = false;
orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_suppressions',
$register_found,
$register_ok
);
if ( ! $register_ok ) {
$reason = 'register_unreadable';
return false;
}
if ( $register_found ) {
$reason = 'register_key_mismatch';
return false;
}
if ( ! empty( $pending ) ) {
$reason = 'register_missing';
return false;
}
return true;
}
if ( 2 !== $present ) {
$reason = 'register_key_mismatch';
return false;
}
if ( ! is_string( $values['site'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $values['site'] ) ) {
$reason = 'register_key_mismatch';
return false;
}
if ( $fault_found ) {
$fault_ok = false;
$fault = orderguard_normalize_gdpr_protocol_fault(
$fault,
$values['site'],
$fault_ok
);
$reason = $fault_ok
? 'protocol_fault_' . $fault['reason']
: 'protocol_fault_invalid';
return false;
}
$canary_ok = false;
$canary = orderguard_normalize_gdpr_suppression_integrity_record(
$values['canary'],
$values['site'],
$canary_ok
);
if ( ! $canary_ok ) {
$reason = 'register_key_mismatch';
return false;
}
$map_ok = false;
// Authenticate the register envelope from storage on every boundary.
// Reusing a warm map after only the site/canary check could miss a
// deleted or tampered register row whose canary was left unchanged.
orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
$register_found = false;
$register_read_ok = false;
orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_suppressions',
$register_found,
$register_read_ok
);
$reason = ! $register_read_ok
? 'register_unreadable'
: ( $register_found ? 'register_invalid' : 'register_missing' );
return false;
}
return true;
}
/** Convert one transmitted identity hash into a store-local keyed identifier. */
function orderguard_gdpr_suppression_storage_key( $hash ) {
if ( ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
return '';
}
$namespace = orderguard_gdpr_suppression_site_namespace();
if ( '' === $namespace ) {
return '';
}
return hash_hmac(
'sha256',
// The persisted per-site namespace already separates multisite blogs.
// Do not add the mutable numeric blog ID: a subsite restored as a
// standalone site must keep recognising its existing suppressions.
$namespace . '|identity|' . $hash,
orderguard_gdpr_suppression_secret()
);
}
/** Convert one local order reference into a store-local keyed identifier. */
function orderguard_gdpr_order_ref_suppression_storage_key( $order_ref ) {
$order_ref = trim( (string) $order_ref );
if ( '' === $order_ref || ! ctype_digit( $order_ref ) || (int) $order_ref <= 0 ) {
return '';
}
$namespace = orderguard_gdpr_suppression_site_namespace();
if ( '' === $namespace ) {
return '';
}
return hash_hmac(
'sha256',
$namespace . '|order-ref|' . $order_ref,
orderguard_gdpr_suppression_secret()
);
}
/**
* Permanent local identity suppressions created when erasure requests begin.
*
* A bounded order scan is useful for writing fast per-order tombstones, but it
* cannot be the privacy guarantee: its result set may shrink while it pages.
* Store-local HMACs of the transmitted SHA-256 hashes remain non-autoloaded so
* any matching order that already existed when the request began is blocked at
* every transport boundary. Keyed order-reference entries protect the exact
* matched orders if their identity is later edited or a deleted order is
* restored. Later orders are new processing and are not suppressed. No
* readable identity, raw transmitted hash or readable order ID is stored here,
* and nothing in this option is sent.
*/
function orderguard_gdpr_erasure_suppression_hash_map( $force_refresh = false, &$ok = null ) {
static $maps_by_blog = array();
static $generation_by_blog = array();
static $namespace_by_blog = array();
static $valid_by_blog = array();
$blog_id = get_current_blog_id();
if ( $force_refresh ) {
unset( $maps_by_blog[ $blog_id ] );
unset( $generation_by_blog[ $blog_id ] );
unset( $namespace_by_blog[ $blog_id ] );
unset( $valid_by_blog[ $blog_id ] );
}
$site_found = false;
$site_ok = false;
$namespace = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_site_id',
$site_found,
$site_ok
);
$canary_found = false;
$canary_read_ok = false;
$canary_stored = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_integrity',
$canary_found,
$canary_read_ok
);
if ( ! $site_ok || ! $canary_read_ok ) {
$ok = false;
return array();
}
if ( ! $site_found && ! $canary_found ) {
$register_found = false;
$register_ok = false;
orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_suppressions',
$register_found,
$register_ok
);
$valid = $register_ok && ! $register_found;
$maps_by_blog[ $blog_id ] = array();
$generation_by_blog[ $blog_id ] = '';
$namespace_by_blog[ $blog_id ] = '';
$valid_by_blog[ $blog_id ] = $valid;
$ok = $valid;
return array();
}
if ( ! $site_found || ! $canary_found
|| ! is_string( $namespace )
|| ! preg_match( '/^[a-f0-9]{64}$/', $namespace ) ) {
$ok = false;
return array();
}
$canary_ok = false;
$canary = orderguard_normalize_gdpr_suppression_integrity_record(
$canary_stored,
$namespace,
$canary_ok
);
if ( ! $canary_ok ) {
$ok = false;
return array();
}
if ( isset( $maps_by_blog[ $blog_id ] )
&& ! empty( $valid_by_blog[ $blog_id ] )
&& isset( $generation_by_blog[ $blog_id ], $namespace_by_blog[ $blog_id ] )
&& hash_equals( $generation_by_blog[ $blog_id ], $canary['generation'] )
&& hash_equals( $namespace_by_blog[ $blog_id ], $namespace ) ) {
$ok = true;
return $maps_by_blog[ $blog_id ];
}
$register_found = false;
$register_read_ok = false;
$stored = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_suppressions',
$register_found,
$register_read_ok
);
$valid = $register_read_ok && $register_found;
$map = $valid
? orderguard_normalize_gdpr_suppression_envelope(
$stored,
$valid,
$namespace,
$canary['generation']
)
: array();
$maps_by_blog[ $blog_id ] = $map;
$generation_by_blog[ $blog_id ] = $canary['generation'];
$namespace_by_blog[ $blog_id ] = $namespace;
$valid_by_blog[ $blog_id ] = $valid;
$ok = $valid;
return $maps_by_blog[ $blog_id ];
}
/** Persist frozen identity boundaries and exact keyed order refs, then verify. */
function orderguard_save_gdpr_erasure_suppressions( $hashes, $cutoff, $max_order_id, $order_refs = array() ) {
global $wpdb;
$cutoff = (int) $cutoff;
$max_order_id = (int) $max_order_id;
if ( $cutoff <= 0 || $max_order_id < -1 ) {
return false;
}
if ( ! orderguard_gdpr_suppression_integrity_ok( true ) ) {
orderguard_log( 'OrderGuard: GDPR suppression integrity check failed; server erasure deferred.' );
return false;
}
$valid = array();
foreach ( (array) $hashes as $hash ) {
$storage_key = orderguard_gdpr_suppression_storage_key( $hash );
if ( '' !== $storage_key ) {
$valid[ $storage_key ] = true;
}
}
foreach ( (array) $order_refs as $order_ref ) {
$storage_key = orderguard_gdpr_order_ref_suppression_storage_key( $order_ref );
if ( '' !== $storage_key ) {
$valid[ $storage_key ] = true;
}
}
if ( empty( $valid ) ) {
return false;
}
$option_name = 'orderguard_gdpr_erasure_suppressions';
$integrity_name = 'orderguard_gdpr_erasure_integrity';
// This list only grows. A normal read/merge/update_option sequence can
// lose one customer's suppression when two erasures run concurrently, so
// merge with an exact-value compare-and-swap instead. The authenticated
// register and its small cache-generation marker change in one SQL
// statement, so neither a crash nor a competing writer can publish one
// without the other.
for ( $attempt = 0; $attempt < 10; ++$attempt ) {
$site_found = false;
$site_ok = false;
$namespace = orderguard_gdpr_read_option_row(
'orderguard_gdpr_erasure_site_id',
$site_found,
$site_ok
);
if ( ! $site_ok || ! $site_found
|| ! is_string( $namespace )
|| ! preg_match( '/^[a-f0-9]{64}$/', $namespace ) ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression namespace could not be read; server erasure deferred.' );
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$canary_raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$integrity_name
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact bytes are required for the paired compare-and-swap.
if ( '' !== $wpdb->last_error || null === $canary_raw ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression generation could not be read; server erasure deferred.' );
return false;
}
$canary_ok = false;
$canary = orderguard_normalize_gdpr_suppression_integrity_record(
maybe_unserialize( $canary_raw ),
$namespace,
$canary_ok
);
if ( ! $canary_ok ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression generation is invalid; server erasure deferred.' );
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- a fresh value is required for the monotonic compare-and-swap.
if ( '' !== $wpdb->last_error ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression could not be read; server erasure deferred.' );
return false;
}
$found = null !== $raw;
if ( $found && ! orderguard_gdpr_option_serialized_size_ok( $option_name, $raw ) ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression storage exceeds its safe serialized-size limit; server erasure deferred.' );
return false;
}
$envelope_ok = false;
$map = $found
? orderguard_normalize_gdpr_suppression_envelope(
maybe_unserialize( $raw ),
$envelope_ok,
$namespace,
$canary['generation']
)
: array();
if ( ! $envelope_ok ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression storage is invalid; server erasure deferred.' );
return false;
}
$changed = false;
foreach ( array_keys( $valid ) as $storage_key ) {
$existing = isset( $map[ $storage_key ] ) && is_array( $map[ $storage_key ] )
? $map[ $storage_key ]
: array( 'cutoff' => 0, 'max_order_id' => -1 );
$merged = array(
'cutoff' => max( (int) $existing['cutoff'], $cutoff ),
'max_order_id' => max( (int) $existing['max_order_id'], $max_order_id ),
);
if ( ! isset( $map[ $storage_key ] ) || $map[ $storage_key ] !== $merged ) {
$map[ $storage_key ] = $merged;
$changed = true;
}
}
if ( ! $changed ) {
$saved_ok = false;
$saved = orderguard_gdpr_erasure_suppression_hash_map( true, $saved_ok );
if ( ! $saved_ok ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression authentication could not be verified; server erasure deferred.' );
return false;
}
foreach ( array_keys( $valid ) as $storage_key ) {
if ( ! isset( $saved[ $storage_key ] )
|| (int) $saved[ $storage_key ]['cutoff'] < $cutoff
|| (int) $saved[ $storage_key ]['max_order_id'] < $max_order_id ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression could not be verified; server erasure deferred.' );
return false;
}
}
return true;
}
$generation = orderguard_new_gdpr_suppression_generation();
$envelope = orderguard_gdpr_suppression_envelope(
$map,
$namespace,
$generation
);
$next_canary = orderguard_gdpr_suppression_integrity_record(
$namespace,
$generation
);
if ( false === $envelope || false === $next_canary ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression authentication failed; server erasure deferred.' );
return false;
}
$serialised = maybe_serialize( $envelope );
if ( ! orderguard_gdpr_option_serialized_size_ok( $option_name, $serialised ) ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression storage reached its safe serialized-size limit; server erasure deferred.' );
return false;
}
$next_canary_serialised = maybe_serialize( $next_canary );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->options} AS register_row INNER JOIN {$wpdb->options} AS integrity_row ON integrity_row.option_name = %s AND BINARY integrity_row.option_value = BINARY %s SET register_row.option_value = %s, integrity_row.option_value = %s WHERE register_row.option_name = %s AND BINARY register_row.option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- both aliases are the current blog's fixed core options table.
$integrity_name,
$canary_raw,
$serialised,
$next_canary_serialised,
$option_name,
$raw
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- paired exact-value CAS makes register publication and cache invalidation indivisible.
orderguard_invalidate_option_caches( $option_name, false );
orderguard_invalidate_option_caches( $integrity_name, false );
// Proof-read after every result. This recognises a committed write
// whose acknowledgement was lost and also accepts a competing writer
// that already established an equal-or-stronger monotonic boundary.
$saved_ok = false;
$saved = orderguard_gdpr_erasure_suppression_hash_map( true, $saved_ok );
if ( ! $saved_ok ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression authentication could not be verified; server erasure deferred.' );
return false;
}
$covered = true;
foreach ( array_keys( $valid ) as $storage_key ) {
if ( ! isset( $saved[ $storage_key ] )
|| (int) $saved[ $storage_key ]['cutoff'] < $cutoff
|| (int) $saved[ $storage_key ]['max_order_id'] < $max_order_id ) {
$covered = false;
break;
}
}
if ( $covered ) {
return true;
}
if ( false === $written ) {
orderguard_log( 'OrderGuard: permanent local GDPR suppression could not be persisted; server erasure deferred.' );
return false;
}
}
orderguard_log( 'OrderGuard: permanent local GDPR suppression stayed busy; server erasure deferred.' );
return false;
}
/**
* Pause every identity-bearing transport until local discovery reaches PREPARE.
*
* A customer may edit an order's billing identity while the bounded scan is
* between pages. Until discovery is frozen at the server, matching only the
* submitted hashes or already-found refs would let that order leave under its
* replacement identity. A blocked scan/prepare retains the same conservative
* pause; COMMIT, local cleanup and ACK already have complete keyed-ref and
* identity coverage.
*/
function orderguard_gdpr_erasure_pauses_all_identity_transports( $pending = null, &$ok = null ) {
if ( null === $pending ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
$ok = false;
return true;
}
} elseif ( ! is_array( $pending ) ) {
$ok = false;
return true;
}
foreach ( $pending as $entry ) {
if ( ! empty( $entry['queued_request'] ) ) {
$ok = true;
return true;
}
$phase = 'blocked' === $entry['status'] ? $entry['blocked_from'] : $entry['status'];
if ( in_array( $phase, array( 'scanning', 'prepare' ), true ) ) {
$ok = true;
return true;
}
}
$ok = true;
return false;
}
/**
* TRUE while any matching merchant erasure is still unfinished.
*
* Discovery/PREPARE suppresses all traffic because its exact scope is not yet
* frozen. COMMIT/local-cleanup/ACK applies the stored boundary, so later orders are not
* pulled into an already-frozen server operation. Callers without an order
* boundary retain the conservative identity-wide pending guard.
*/
function orderguard_pending_gdpr_erasure_matches_hashes( $hashes, $identity_created_at = 0, $identity_order_id = 0 ) {
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return true;
}
$targets = array_fill_keys(
array_values( array_filter( (array) $hashes, 'is_string' ) ),
true
);
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return true;
}
$pause_ok = false;
if ( orderguard_gdpr_erasure_pauses_all_identity_transports( $pending, $pause_ok ) || ! $pause_ok ) {
return true;
}
if ( empty( $targets ) ) {
return false;
}
foreach ( $pending as $entry ) {
$pending_hashes = $entry['match_hashes'];
if ( ! empty( $entry['queued_request'] ) ) {
$pending_hashes = array_merge(
$pending_hashes,
$entry['queued_request']['match_hashes']
);
}
foreach ( $pending_hashes as $hash ) {
if ( isset( $targets[ $hash ] ) ) {
$phase = 'blocked' === $entry['status'] ? $entry['blocked_from'] : $entry['status'];
$has_boundary = (int) $identity_created_at > 0 || (int) $identity_order_id > 0;
if ( $has_boundary
&& in_array( $phase, array( 'commit', 'local_flags', 'ack' ), true )
&& ! orderguard_gdpr_erasure_boundary_covers(
$identity_created_at,
$identity_order_id,
$entry['suppression_cutoff'],
$entry['suppression_max_order_id']
) ) {
continue;
}
return true;
}
}
}
return false;
}
/**
* TRUE when a supplied identity matches an erasure whose cutoff covers it.
*
* A missing creation time is treated as fresh processing and fails open. Every
* order-backed transport passes the immutable WooCommerce creation timestamp.
*/
function orderguard_is_gdpr_erased_hash( $hashes, $identity_created_at, $identity_order_id ) {
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return true;
}
$identity_created_at = (int) $identity_created_at;
$identity_order_id = (int) $identity_order_id;
if ( $identity_created_at <= 0 && $identity_order_id <= 0 ) {
return false;
}
// This is a privacy boundary, so do not trust a process-lifetime negative
// cache left by an earlier action in the same Action Scheduler runner.
// Writer order is pending first, permanent suppression second. Read the
// permanent map first and pending state second: if the writer falls between
// those reads, the fresh pending read closes the insertion handoff.
$hashes = array_values( array_filter( (array) $hashes, 'is_string' ) );
$storage_keys = array();
foreach ( $hashes as $hash ) {
$storage_key = orderguard_gdpr_suppression_storage_key( $hash );
if ( '' !== $storage_key ) {
$storage_keys[] = $storage_key;
}
}
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return true;
}
foreach ( $storage_keys as $storage_key ) {
if ( isset( $map[ $storage_key ] )
&& orderguard_gdpr_erasure_boundary_covers(
$identity_created_at,
$identity_order_id,
$map[ $storage_key ]['cutoff'],
$map[ $storage_key ]['max_order_id']
) ) {
return true;
}
}
if ( orderguard_pending_gdpr_erasure_matches_hashes(
$hashes,
$identity_created_at,
$identity_order_id
) ) {
return true;
}
// Complete the B -> A -> B collect. A worker can persist permanent
// suppression and clear pending state between the first two reads; this
// second fresh permanent read closes that completion handoff.
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return true;
}
foreach ( $storage_keys as $storage_key ) {
if ( isset( $map[ $storage_key ] )
&& orderguard_gdpr_erasure_boundary_covers(
$identity_created_at,
$identity_order_id,
$map[ $storage_key ]['cutoff'],
$map[ $storage_key ]['max_order_id']
) ) {
return true;
}
}
return false;
}
/**
* Classify one order at an outbound privacy boundary.
*
* `orderguard_is_gdpr_erased_order()` deliberately fails closed for every
* order while any erasure is still discovering its scope. That is safe for a
* read-only screen, but an asynchronous sender needs to distinguish two very
* different outcomes: this exact order must be suppressed permanently, or an
* unrelated order must be deferred and replayed after PREPARE freezes the
* scope. Returning a small fixed vocabulary keeps that distinction out of the
* individual transports.
*
* @return string allow|suppress|defer|block
*/
function orderguard_gdpr_transport_disposition_for_order( $order ) {
if ( ! $order instanceof WC_Order ) {
return 'block';
}
if ( '' !== (string) orderguard_get_order_meta( $order, '_orderguard_gdpr_erased_at' ) ) {
return 'suppress';
}
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return 'block';
}
$order_id = (int) $order->get_id();
$created = $order->get_date_created();
$created_at = $created ? (int) $created->getTimestamp() : 0;
$hashes = array_values( array_filter( orderguard_exclusion_order_hashes( $order ), 'is_string' ) );
$hash_set = array_fill_keys( $hashes, true );
$identity_storage_keys = array();
foreach ( $hashes as $hash ) {
$storage_key = orderguard_gdpr_suppression_storage_key( $hash );
if ( '' !== $storage_key ) {
$identity_storage_keys[] = $storage_key;
}
}
$ref_storage_key = orderguard_gdpr_order_ref_suppression_storage_key( $order_id );
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return 'block';
}
if ( '' !== $ref_storage_key && isset( $map[ $ref_storage_key ] ) ) {
return 'suppress';
}
foreach ( $identity_storage_keys as $storage_key ) {
if ( isset( $map[ $storage_key ] )
&& orderguard_gdpr_erasure_boundary_covers(
$created_at,
$order_id,
$map[ $storage_key ]['cutoff'],
$map[ $storage_key ]['max_order_id']
) ) {
return 'suppress';
}
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return 'block';
}
$defer = false;
foreach ( $pending as $entry ) {
if ( in_array( $order_id, array_merge( $entry['order_refs'], $entry['server_refs'] ), true ) ) {
return 'suppress';
}
$phase = 'blocked' === $entry['status'] ? $entry['blocked_from'] : $entry['status'];
if ( in_array( $phase, array( 'scanning', 'prepare' ), true ) ) {
$defer = true;
}
foreach ( $entry['match_hashes'] as $hash ) {
if ( isset( $hash_set[ $hash ] )
&& orderguard_gdpr_erasure_boundary_covers(
$created_at,
$order_id,
$entry['suppression_cutoff'],
$entry['suppression_max_order_id']
) ) {
return 'suppress';
}
}
if ( ! empty( $entry['queued_request'] ) ) {
$defer = true;
foreach ( $entry['queued_request']['match_hashes'] as $hash ) {
if ( isset( $hash_set[ $hash ] )
&& orderguard_gdpr_erasure_boundary_covers(
$created_at,
$order_id,
$entry['queued_request']['suppression_cutoff'],
$entry['queued_request']['suppression_max_order_id']
) ) {
return 'suppress';
}
}
}
}
// Complete the permanent -> pending -> permanent hand-off. A worker may
// persist the final keyed scope and clear pending between the two reads.
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return 'block';
}
if ( '' !== $ref_storage_key && isset( $map[ $ref_storage_key ] ) ) {
return 'suppress';
}
foreach ( $identity_storage_keys as $storage_key ) {
if ( isset( $map[ $storage_key ] )
&& orderguard_gdpr_erasure_boundary_covers(
$created_at,
$order_id,
$map[ $storage_key ]['cutoff'],
$map[ $storage_key ]['max_order_id']
) ) {
return 'suppress';
}
}
return $defer ? 'defer' : 'allow';
}
/** Classify an event/check carrying only an order reference. */
function orderguard_gdpr_transport_disposition_for_order_ref( $order_ref ) {
$order_ref = trim( (string) $order_ref );
if ( '' === $order_ref || ! ctype_digit( $order_ref ) || (int) $order_ref <= 0 ) {
return 'block';
}
if ( function_exists( 'wc_get_order' ) ) {
try {
$order = wc_get_order( (int) $order_ref );
} catch ( Throwable $throwable ) {
$order = false;
}
if ( $order instanceof WC_Order ) {
return orderguard_gdpr_transport_disposition_for_order( $order );
}
}
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return 'block';
}
$storage_key = orderguard_gdpr_order_ref_suppression_storage_key( $order_ref );
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return 'block';
}
if ( '' !== $storage_key && isset( $map[ $storage_key ] ) ) {
return 'suppress';
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return 'block';
}
$defer = false;
foreach ( $pending as $entry ) {
if ( in_array( (int) $order_ref, array_merge( $entry['order_refs'], $entry['server_refs'] ), true ) ) {
return 'suppress';
}
$phase = 'blocked' === $entry['status'] ? $entry['blocked_from'] : $entry['status'];
if ( in_array( $phase, array( 'scanning', 'prepare' ), true ) || ! empty( $entry['queued_request'] ) ) {
$defer = true;
}
}
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return 'block';
}
if ( '' !== $storage_key && isset( $map[ $storage_key ] ) ) {
return 'suppress';
}
return $defer ? 'defer' : 'allow';
}
/**
* Every local spelling hash that may identify the data subject.
*/
function orderguard_gdpr_erasure_query_hashes( $query ) {
$query = trim( (string) $query );
if ( '' === $query ) {
return array();
}
$field = ( false !== strpos( $query, '@' ) ) ? 'email' : 'phone';
$hashes = orderguard_exclusion_hashes_for( $field, $query );
// The server body has its own conservative E.164 derivation. Permanent
// local protection must cover every hash we authorise the server to erase,
// even if a future normalizer difference omits one from local variants.
foreach ( orderguard_gdpr_erasure_request_bodies( $query ) as $body ) {
$hashes = array_merge( $hashes, array_values( $body ) );
}
sort( $hashes, SORT_STRING );
return array_values( array_unique( array_filter( $hashes, 'is_string' ) ) );
}
/**
* Keyed local retry handle. The fingerprint reveals no identity; the pending
* job separately retains only the hashes needed to finish the requested work.
*/
function orderguard_gdpr_erasure_fingerprint( $hashes ) {
if ( empty( $hashes ) ) {
return '';
}
return hash_hmac( 'sha256', implode( '|', $hashes ), wp_salt( 'auth' ) );
}
/**
* Version identity-derived transient keys after an erasure is accepted.
*
* Incrementing in SQL avoids losing one invalidation when two requests race.
* Old transient rows can expire naturally, including in persistent caches;
* no later lookup can reach them once the epoch changes.
*/
function orderguard_gdpr_erasure_cache_epoch( $bump = false ) {
global $wpdb;
$option_name = 'orderguard_gdpr_erasure_cache_epoch';
if ( $bump ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$written = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, '1', 'off') ON DUPLICATE KEY UPDATE option_value = CAST(option_value AS UNSIGNED) + 1, autoload = 'off'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
$option_name
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic cache-generation bump.
orderguard_invalidate_option_caches( $option_name, false );
if ( false === $written ) {
orderguard_log( 'OrderGuard: GDPR erasure could not invalidate identity-derived caches.' );
return false;
}
}
return max( 0, (int) get_option( $option_name, 0 ) );
}
/** Resolve the authoritative order-ID table used by the bounded keyset scan. */
function orderguard_gdpr_erasure_order_id_storage( &$ok = null, $expected_mode = '' ) {
global $wpdb;
$ok = false;
$order_util = '\\Automattic\\WooCommerce\\Utilities\\OrderUtil';
if ( ! class_exists( $order_util )
|| ! is_callable( array( $order_util, 'custom_orders_table_usage_is_enabled' ) ) ) {
return array();
}
try {
$hpos = $order_util::custom_orders_table_usage_is_enabled();
} catch ( Throwable $throwable ) {
orderguard_log( 'OrderGuard: local GDPR erasure could not resolve authoritative order storage -- ' . $throwable->getMessage() );
return array();
}
$mode = $hpos ? 'hpos' : 'classic';
if ( '' !== $expected_mode && $mode !== $expected_mode ) {
return array();
}
$ok = true;
return $hpos
? array( 'table' => $wpdb->prefix . 'wc_orders', 'id' => 'id', 'type' => 'type', 'mode' => 'hpos' )
: array( 'table' => $wpdb->posts, 'id' => 'ID', 'type' => 'post_type', 'mode' => 'classic' );
}
/** Resolve only the current WooCommerce order-storage authority. */
function orderguard_gdpr_erasure_order_storage_mode( &$ok = null ) {
$storage = orderguard_gdpr_erasure_order_id_storage( $ok );
return $ok && isset( $storage['mode'] ) ? (string) $storage['mode'] : '';
}
/**
* Freeze the highest order ID already allocated when the request begins.
*
* MAX(existing ID) is not sufficient: if the highest historical order was
* deleted before the request, its server row may still be erased and that
* order may later be restored from backup. MySQL's next AUTO_INCREMENT value
* preserves that allocation boundary even when the row itself is gone.
*/
function orderguard_gdpr_erasure_order_high_water( &$ok = null, $expected_mode = '' ) {
global $wpdb;
$ok = false;
$storage_ok = false;
$storage = orderguard_gdpr_erasure_order_id_storage( $storage_ok, $expected_mode );
if ( ! $storage_ok ) {
return 0;
}
// WooCommerce allocates every order ID with wp_insert_post(), including
// HPOS where that row is a backup/placeholder. The posts allocator is
// therefore the one durable ceiling shared by both storage modes.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$next_id = $wpdb->get_var(
$wpdb->prepare(
'SELECT AUTO_INCREMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s LIMIT 1',
$wpdb->posts
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the allocation ceiling survives deletion and later restore of an old order ID.
if ( '' !== $wpdb->last_error || null === $next_id || (int) $next_id < 1 ) {
orderguard_log( 'OrderGuard: local GDPR erasure allocation-boundary query failed.' );
return 0;
}
// MySQL can expose the pre-transaction AUTO_INCREMENT value while an
// authoritative WooCommerce row with a higher explicitly allocated ID is
// already visible to this request. Keep the larger live order ID so a
// request accepted in that window never excludes an order that existed.
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$max_order_id = $wpdb->get_var(
"SELECT MAX({$storage['id']}) FROM {$storage['table']} WHERE {$storage['type']} = 'shop_order'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers are selected by the authoritative storage resolver above and the type is fixed.
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- closes allocator-metadata lag inside the request that freezes the boundary.
if ( '' !== $wpdb->last_error ) {
orderguard_log( 'OrderGuard: local GDPR erasure highest-order query failed.' );
return 0;
}
$candidate = max( 0, (int) $next_id - 1, (int) $max_order_id );
// Retain the largest value observed in this PHP request. Some MySQL
// versions keep information_schema's AUTO_INCREMENT snapshot stale inside
// a transaction even after WooCommerce allocated a higher explicit ID;
// the live MAX covers that first read and this request-local floor covers
// a later read after the row is deleted. Do not persist a global floor:
// restoring an older database could otherwise suppress genuinely new IDs.
static $observed = array();
$observed_key = get_current_blog_id() . '|' . $storage['mode'];
$observed[ $observed_key ] = max(
isset( $observed[ $observed_key ] ) ? (int) $observed[ $observed_key ] : 0,
$candidate
);
$ok = true;
return $observed[ $observed_key ];
}
/**
* TRUE when a frozen request boundary covers an order.
*
* A usable ID/high-water pair is authoritative because IDs are monotonic.
* The date is only a compatibility fallback for records without an ID or for
* legacy suppression entries created before the ID boundary existed. This
* prevents a later import with an intentionally backdated creation time from
* being mistaken for pre-request processing.
*/
function orderguard_gdpr_erasure_boundary_covers( $created_at, $order_id, $cutoff, $max_order_id ) {
$created_at = (int) $created_at;
$order_id = (int) $order_id;
$cutoff = (int) $cutoff;
$max_order_id = (int) $max_order_id;
if ( $order_id > 0 && $max_order_id >= 0 ) {
return $order_id <= $max_order_id;
}
return $created_at > 0 && $cutoff > 0 && $created_at <= $cutoff;
}
/**
* Read the minimum billing identity directly when WooCommerce CRUD cannot
* instantiate an authoritative order row.
*
* This is deliberately a narrow recovery path, not a second order API. It
* lets an unrelated corrupt row advance safely and lets a matching corrupt
* row contribute its exact historical spelling. A database read failure is
* different from an empty billing address and remains a hard, visible pause.
*/
function orderguard_gdpr_erasure_raw_order_identity( $order_id, &$ok = null, $expected_mode = '' ) {
global $wpdb;
$ok = false;
$order_id = absint( $order_id );
$storage_ok = false;
$storage = orderguard_gdpr_erasure_order_id_storage( $storage_ok, $expected_mode );
if ( $order_id <= 0 || ! $storage_ok ) {
return array();
}
$identity = array( 'email' => '', 'phone' => '', 'country' => '' );
if ( $storage['table'] === $wpdb->posts ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key IN ('_billing_email', '_billing_phone', '_billing_country') ORDER BY meta_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core postmeta table and fixed key literals.
$order_id
),
ARRAY_A
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- recovery only when WooCommerce CRUD cannot load an authoritative order.
if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) {
return array();
}
$keys = array(
'_billing_email' => 'email',
'_billing_phone' => 'phone',
'_billing_country' => 'country',
);
foreach ( $rows as $row ) {
$key = isset( $row['meta_key'] ) ? (string) $row['meta_key'] : '';
if ( isset( $keys[ $key ] ) && '' === $identity[ $keys[ $key ] ] ) {
$identity[ $keys[ $key ] ] = isset( $row['meta_value'] ) ? (string) $row['meta_value'] : '';
}
}
} else {
$addresses = $wpdb->prefix . 'wc_order_addresses';
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT email, phone, country FROM {$addresses} WHERE order_id = %d AND address_type = 'billing' LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- fixed HPOS billing-address table and type.
$order_id
),
ARRAY_A
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- same narrow CRUD recovery path for HPOS.
if ( '' !== $wpdb->last_error ) {
return array();
}
if ( is_array( $row ) ) {
foreach ( array_keys( $identity ) as $field ) {
$identity[ $field ] = isset( $row[ $field ] ) ? (string) $row[ $field ] : '';
}
}
}
$ok = true;
return $identity;
}
/**
* Scan one bounded keyset page of every authoritative local order.
*
* The ID cursor cannot skip a row when an earlier order is purged between
* actions. Direct ID selection includes trash, checkout drafts and custom
* statuses; WooCommerce CRUD still supplies the identity and meta layer.
*/
function orderguard_local_gdpr_erasure_order_batch( $hashes, $identity_kind, $after_id, $max_order_id, $limit, $until, &$ok = null, $expected_mode = '' ) {
global $wpdb;
$ok = false;
$after_id = max( 0, (int) $after_id );
$max_order_id = (int) $max_order_id;
$limit = max( 1, min( 250, (int) $limit ) );
$until = (float) $until;
if ( empty( $hashes )
|| ! in_array( $identity_kind, array( 'email', 'phone' ), true )
|| $max_order_id < 0
|| $until <= 0
|| ! function_exists( 'wc_get_order' ) ) {
return array();
}
$storage_ok = false;
$storage = orderguard_gdpr_erasure_order_id_storage( $storage_ok, $expected_mode );
if ( ! $storage_ok ) {
return array();
}
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT {$storage['id']} FROM {$storage['table']} WHERE {$storage['type']} = %s AND {$storage['id']} > %d AND {$storage['id']} <= %d ORDER BY {$storage['id']} ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- identifiers come from the authoritative storage resolver above.
'shop_order',
$after_id,
$max_order_id,
$limit
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- deletion-safe keyset enumeration across classic and HPOS storage.
if ( '' !== $wpdb->last_error || ! is_array( $ids ) ) {
orderguard_log( 'OrderGuard: local GDPR erasure scan query failed.' );
return array();
}
$targets = array_fill_keys( $hashes, true );
$refs = array();
$request_bodies = array();
$flag_hashes = array();
$processed_ids = array();
$timed_out = false;
$blocked_order_id = 0;
foreach ( $ids as $order_id ) {
if ( ! empty( $processed_ids ) && microtime( true ) >= $until ) {
$timed_out = true;
break;
}
try {
$order = wc_get_order( (int) $order_id );
} catch ( Throwable $throwable ) {
$order = false;
}
if ( $order instanceof WC_Order ) {
$order_hashes = orderguard_gdpr_erasure_order_match_hashes( $order, $identity_kind );
$body = orderguard_gdpr_erasure_order_request_body( $order, $identity_kind );
$candidate_flag_hashes = orderguard_gdpr_erasure_order_flag_hashes( $order );
} else {
$raw_ok = false;
$raw = orderguard_gdpr_erasure_raw_order_identity( (int) $order_id, $raw_ok, $expected_mode );
if ( ! $raw_ok ) {
// Do not advance the keyset cursor. A transient data-store
// failure may hide the only historical spelling needed to
// erase this identity. The Legal tab surfaces this exact ID.
$blocked_order_id = (int) $order_id;
orderguard_log( 'OrderGuard: local GDPR erasure could not read order #' . $blocked_order_id . '; scan paused before this ID and will retry.' );
break;
}
$order_hashes = orderguard_gdpr_erasure_identity_match_hashes(
$raw['email'],
$raw['phone'],
$raw['country'],
$identity_kind
);
$body = orderguard_gdpr_erasure_identity_request_body(
$raw['email'],
$raw['phone'],
$identity_kind
);
$candidate_flag_hashes = orderguard_gdpr_erasure_identity_flag_hashes(
$raw['email'],
$raw['phone'],
$raw['country']
);
}
$processed_ids[] = (int) $order_id;
$matched = false;
foreach ( $order_hashes as $hash ) {
if ( isset( $targets[ $hash ] ) ) {
$matched = true;
break;
}
}
if ( ! $matched ) {
continue;
}
$refs[] = (int) $order_id;
if ( ! empty( $body ) ) {
$request_bodies[] = $body;
}
$flag_hashes = array_merge( $flag_hashes, $candidate_flag_hashes );
}
$ids = array_values( array_map( 'absint', $ids ) );
$last_id = empty( $processed_ids ) ? $after_id : max( $processed_ids );
$ok = true;
return array(
'order_refs' => array_values( array_unique( array_filter( $refs ) ) ),
'request_bodies' => orderguard_unique_gdpr_erasure_request_bodies( $request_bodies ),
'flag_hashes' => array_values( array_unique( $flag_hashes ) ),
'next_after_id' => $last_id,
'scanned' => count( $processed_ids ),
'blocked_order_id' => $blocked_order_id,
'done' => 0 === $blocked_order_id
&& ! $timed_out
&& ( count( $ids ) < $limit || $last_id >= $max_order_id ),
);
}
/** One database-wide mutex shared with uninstall.php. */
function orderguard_gdpr_erasure_lifecycle_lock_name() {
global $wpdb;
return 'orderguard_gdpr_' . substr(
hash( 'sha256', (string) $wpdb->dbhost . '|' . (string) $wpdb->dbname ),
0,
40
);
}
function orderguard_acquire_gdpr_erasure_lifecycle_lock( &$reason = null ) {
global $wpdb;
$reason = '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$claimed = $wpdb->get_var(
$wpdb->prepare(
'SELECT GET_LOCK(%s, 0)',
orderguard_gdpr_erasure_lifecycle_lock_name()
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- serialises erasure acceptance against uninstall.
if ( '1' === (string) $claimed && '' === $wpdb->last_error ) {
return true;
}
$reason = '0' === (string) $claimed && '' === $wpdb->last_error
? 'lifecycle_busy' : 'lifecycle_storage_unavailable';
return false;
}
function orderguard_release_gdpr_erasure_lifecycle_lock() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$wpdb->get_var(
$wpdb->prepare(
'SELECT RELEASE_LOCK(%s)',
orderguard_gdpr_erasure_lifecycle_lock_name()
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- releases the matching lifecycle mutex.
}
/** Fresh activation check used after acquiring the uninstall mutex. */
function orderguard_plugin_is_active_for_erasure() {
global $wpdb;
$basename = 'orderguard/orderguard.php';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table.
'active_plugins'
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- must observe a concurrent deactivation without an object-cache race.
if ( '' !== $wpdb->last_error ) {
return false;
}
$active = maybe_unserialize( $raw );
if ( is_array( $active ) && in_array( $basename, $active, true ) ) {
return true;
}
if ( ! is_multisite() ) {
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong
$network = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core network metadata table.
(int) $wpdb->siteid,
'active_sitewide_plugins'
)
); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- same fresh lifecycle check for network activation.
if ( '' !== $wpdb->last_error ) {
return false;
}
$network = maybe_unserialize( $network );
return is_array( $network ) && isset( $network[ $basename ] );
}
/** Store no address: bind a core privacy request with a site-keyed digest. */
function orderguard_gdpr_native_email_digest( $email ) {
$email = strtolower( trim( (string) $email ) );
if ( '' === $email || ! is_email( $email ) ) {
return '';
}
$namespace = orderguard_gdpr_suppression_site_namespace();
if ( '' === $namespace ) {
return '';
}
return hash_hmac(
'sha256',
$namespace . '|native-erasure-email|' . $email,
orderguard_gdpr_suppression_secret()
);
}
/** Initialise the signing lifecycle under the uninstall/erasure mutex. */
function orderguard_gdpr_native_ensure_lifecycle( &$reason = null ) {
$reason = 'plugin_inactive';
if ( ! orderguard_plugin_is_active_for_erasure() ) {
return false;
}
if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock( $reason ) ) {
return false;
}
try {
if ( ! orderguard_plugin_is_active_for_erasure() ) {
$reason = 'plugin_inactive';
return false;
}
$integrity_reason = '';
$ok = orderguard_gdpr_erasure_state_integrity( $integrity_reason )
&& orderguard_gdpr_suppression_integrity_ok( true );
$reason = $ok ? '' : 'lifecycle_unavailable';
return $ok;
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/** Revalidate the request ID and address that core already authorised. */
function orderguard_gdpr_native_request_binding( $request_id, $email, &$reason = null ) {
$reason = '';
$request_id = (int) $request_id;
$email = strtolower( trim( (string) $email ) );
$request = $request_id > 0 && function_exists( 'wp_get_user_request' )
? wp_get_user_request( $request_id )
: false;
if ( ! $request
|| 'remove_personal_data' !== (string) $request->action_name
|| ! is_email( $email )
|| ! is_email( (string) $request->email )
|| $email !== strtolower( trim( (string) $request->email ) ) ) {
$reason = 'native_request_invalid';
return false;
}
$digest = orderguard_gdpr_native_email_digest( $email );
if ( '' === $digest ) {
$reason = 'native_request_unavailable';
return false;
}
return array(
'request_id' => $request_id,
'email_digest' => $digest,
);
}
function orderguard_gdpr_native_request_equals( $left, $right ) {
$left = orderguard_normalize_gdpr_native_request( $left );
$right = orderguard_normalize_gdpr_native_request( $right );
return false !== $left
&& false !== $right
&& ! empty( $left )
&& ! empty( $right )
&& $left['request_id'] === $right['request_id']
&& hash_equals( $left['email_digest'], $right['email_digest'] );
}
function orderguard_gdpr_native_receipt_meta_key() {
return '_orderguard_gdpr_erasure_receipt';
}
/** Sign all completion fields with the same independently protected site key. */
function orderguard_gdpr_native_receipt_digest( $receipt ) {
$namespace = orderguard_gdpr_suppression_site_namespace();
if ( '' === $namespace || ! is_array( $receipt ) ) {
return '';
}
unset( $receipt['digest'] );
$encoded = wp_json_encode( $receipt, JSON_UNESCAPED_SLASHES );
if ( ! is_string( $encoded ) || '' === $encoded ) {
return '';
}
return hash_hmac(
'sha256',
$namespace . '|native-erasure-receipt-v1|' . $encoded,
orderguard_gdpr_suppression_secret()
);
}
/** Authenticate the one proof that permits core to advance to WooCommerce. */
function orderguard_normalize_gdpr_native_receipt( $receipt, &$ok = null ) {
$ok = false;
if ( ! is_array( $receipt )
|| array(
'schema_version',
'request_id',
'email_digest',
'fingerprint',
'request_token',
'operation_id',
'completed_at',
'digest',
) !== array_keys( $receipt )
|| 1 !== $receipt['schema_version']
|| ! is_int( $receipt['request_id'] )
|| $receipt['request_id'] <= 0
|| ! is_int( $receipt['completed_at'] )
|| $receipt['completed_at'] <= 0 ) {
return false;
}
foreach ( array( 'email_digest', 'fingerprint', 'request_token', 'digest' ) as $field ) {
if ( ! is_string( $receipt[ $field ] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $receipt[ $field ] ) ) {
return false;
}
}
if ( ! is_string( $receipt['operation_id'] )
|| ! preg_match( '/^[A-Za-z0-9_-]{16,128}$/', $receipt['operation_id'] ) ) {
return false;
}
$expected = orderguard_gdpr_native_receipt_digest( $receipt );
if ( '' === $expected || ! hash_equals( $expected, $receipt['digest'] ) ) {
return false;
}
$ok = true;
return $receipt;
}
/** Proof-read exactly one signed receipt, optionally against one generation. */
function orderguard_gdpr_native_receipt( $binding, $fingerprint = '', $request_token = '', $operation_id = '', &$status = null ) {
$status = 'invalid';
$binding = orderguard_normalize_gdpr_native_request( $binding );
if ( false === $binding || empty( $binding ) ) {
return false;
}
$values = get_post_meta(
$binding['request_id'],
orderguard_gdpr_native_receipt_meta_key(),
false
);
if ( empty( $values ) ) {
$status = 'missing';
return false;
}
if ( ! is_array( $values ) || 1 !== count( $values ) ) {
return false;
}
$receipt_ok = false;
$receipt = orderguard_normalize_gdpr_native_receipt( reset( $values ), $receipt_ok );
if ( ! $receipt_ok
|| $receipt['request_id'] !== $binding['request_id']
|| ! hash_equals( $receipt['email_digest'], $binding['email_digest'] )
|| ( '' !== $fingerprint && ! hash_equals( $receipt['fingerprint'], $fingerprint ) )
|| ( '' !== $request_token && ! hash_equals( $receipt['request_token'], $request_token ) )
|| ( '' !== $operation_id && ! hash_equals( $receipt['operation_id'], $operation_id ) ) ) {
return false;
}
$status = 'valid';
return $receipt;
}
/** Persist and proof-read completion before ACK may clear pending state. */
function orderguard_store_gdpr_native_receipt( $fingerprint, $entry, &$reason = null ) {
$reason = '';
$binding = isset( $entry['native_request'] )
? orderguard_normalize_gdpr_native_request( $entry['native_request'] )
: array();
if ( false === $binding || empty( $binding ) || 'ack' !== ( $entry['status'] ?? '' ) ) {
$reason = 'native_receipt_state_invalid';
return false;
}
$request = function_exists( 'wp_get_user_request' )
? wp_get_user_request( $binding['request_id'] )
: false;
$binding_reason = '';
$current_binding = $request
? orderguard_gdpr_native_request_binding(
$binding['request_id'],
(string) $request->email,
$binding_reason
)
: false;
if ( false === $current_binding
|| ! orderguard_gdpr_native_request_equals( $binding, $current_binding ) ) {
$reason = 'native_request_invalid';
return false;
}
$status = '';
$existing = orderguard_gdpr_native_receipt(
$binding,
$fingerprint,
(string) $entry['request_token'],
(string) $entry['operation_id'],
$status
);
if ( false !== $existing ) {
return true;
}
if ( 'missing' !== $status ) {
$reason = 'native_receipt_invalid';
return false;
}
$receipt = array(
'schema_version' => 1,
'request_id' => $binding['request_id'],
'email_digest' => $binding['email_digest'],
'fingerprint' => $fingerprint,
'request_token' => (string) $entry['request_token'],
'operation_id' => (string) $entry['operation_id'],
'completed_at' => time(),
);
$receipt['digest'] = orderguard_gdpr_native_receipt_digest( $receipt );
if ( '' === $receipt['digest'] ) {
$reason = 'native_receipt_unavailable';
return false;
}
add_post_meta(
$binding['request_id'],
orderguard_gdpr_native_receipt_meta_key(),
$receipt,
true
);
$status = '';
$verified = orderguard_gdpr_native_receipt(
$binding,
$fingerprint,
(string) $entry['request_token'],
(string) $entry['operation_id'],
$status
);
if ( false === $verified ) {
$reason = 'missing' === $status
? 'native_receipt_unavailable'
: 'native_receipt_invalid';
return false;
}
return true;
}
/** Build one unsent, immutable merchant-request descriptor. */
function orderguard_new_gdpr_erasure_request( $token, $submitted_at, $high_water, $storage_mode, $identity_kind, $hashes, $bodies, $flag_hashes, $local_flag_manifest = array(), $native_request = array() ) {
return orderguard_normalize_queued_gdpr_erasure_request(
array(
'schema_version' => orderguard_gdpr_erasure_state_schema_version(),
'request_token' => $token,
'started_at' => (int) $submitted_at,
'suppression_cutoff' => (int) $submitted_at,
'suppression_max_order_id' => (int) $high_water,
'order_storage_mode' => (string) $storage_mode,
'identity_kind' => (string) $identity_kind,
'match_hashes' => (array) $hashes,
'request_bodies' => (array) $bodies,
'flag_hashes' => (array) $flag_hashes,
'local_flag_manifest' => (array) $local_flag_manifest,
'native_request' => $native_request,
)
);
}
/** Turn an unsent request descriptor into a fresh active scan. */
function orderguard_activate_gdpr_erasure_request( $request, $revision = 1 ) {
$request = orderguard_normalize_queued_gdpr_erasure_request( $request );
if ( false === $request || empty( $request ) ) {
return false;
}
return orderguard_normalize_pending_gdpr_erasure_entry(
orderguard_gdpr_erasure_fingerprint( $request['match_hashes'] ),
array_merge(
$request,
array(
'mutation_token' => orderguard_new_gdpr_erasure_mutation_token(),
'revision' => max( 1, (int) $revision ),
'updated_at' => time(),
'next_attempt_at' => 0,
'status' => 'scanning',
'scan_after_id' => 0,
'blocked_order_id' => 0,
'storage_mode_mismatch' => '',
'attempts' => 0,
'order_refs' => array(),
'server_refs' => array(),
'server_cutoff_at' => 0,
'operation_id' => '',
'operation_token' => '',
'blocked_reason' => '',
'blocked_from' => '',
'queued_request' => array(),
)
)
);
}
/** Merge only unsent requests; an active PREPARE generation never changes. */
function orderguard_merge_queued_gdpr_erasure_request( $existing, $incoming ) {
$incoming = orderguard_normalize_queued_gdpr_erasure_request( $incoming );
if ( false === $incoming || empty( $incoming ) ) {
return false;
}
if ( empty( $existing ) ) {
return $incoming;
}
$existing = orderguard_normalize_queued_gdpr_erasure_request( $existing );
if ( false === $existing
|| empty( $existing )
|| $existing['identity_kind'] !== $incoming['identity_kind']
|| $existing['order_storage_mode'] !== $incoming['order_storage_mode'] ) {
return false;
}
if ( ! empty( $existing['native_request'] ) || ! empty( $incoming['native_request'] ) ) {
return orderguard_gdpr_native_request_equals(
$existing['native_request'],
$incoming['native_request']
) ? $existing : false;
}
$hashes_ok = false;
$hashes = orderguard_normalize_gdpr_erasure_hash_list(
array_merge( $existing['match_hashes'], $incoming['match_hashes'] ),
1024,
$hashes_ok
);
$bodies_ok = false;
$bodies = orderguard_normalize_gdpr_erasure_body_list(
array_merge( $existing['request_bodies'], $incoming['request_bodies'] ),
$bodies_ok
);
$flag_hashes_ok = false;
$flag_hashes = orderguard_normalize_gdpr_erasure_hash_list(
array_merge( $existing['flag_hashes'], $incoming['flag_hashes'] ),
1024,
$flag_hashes_ok
);
$local_flags_ok = false;
$local_flag_manifest = orderguard_merge_gdpr_local_flag_manifests(
$existing['local_flag_manifest'],
$incoming['local_flag_manifest'],
$local_flags_ok
);
if ( ! $hashes_ok || ! $bodies_ok || ! $flag_hashes_ok || ! $local_flags_ok ) {
return false;
}
return orderguard_normalize_queued_gdpr_erasure_request(
array(
'schema_version' => orderguard_gdpr_erasure_state_schema_version(),
// Keep the first unsent token: repeated clicks expand one queued
// intent without changing any request already sent to the server.
'request_token' => $existing['request_token'],
'started_at' => max( $existing['started_at'], $incoming['started_at'] ),
'suppression_cutoff' => max( $existing['suppression_cutoff'], $incoming['suppression_cutoff'] ),
'suppression_max_order_id' => max(
$existing['suppression_max_order_id'],
$incoming['suppression_max_order_id']
),
'order_storage_mode' => $existing['order_storage_mode'],
'identity_kind' => $incoming['identity_kind'],
'match_hashes' => $hashes,
'request_bodies' => $bodies,
'flag_hashes' => $flag_hashes,
'local_flag_manifest' => $local_flag_manifest,
'native_request' => array(),
)
);
}
/**
* Freeze a new erasure job before any scan or destructive request. A retry
* reached through another spelling resolves to the same keyed fingerprint.
*
* Uninstall takes the same database mutex after WordPress deactivates the
* plugin. Rechecking activation inside the mutex means an already-running
* admin request either publishes first (and uninstall refuses) or cannot
* publish after the uninstaller's empty preflight.
*/
function orderguard_prepare_gdpr_erasure( $query, $native_request = array(), &$reason = null ) {
if ( ! OG_Native_Legacy_Gate::allowed() ) { $reason = 'owned_takeover'; return false; }
$reason = '';
$native_request = orderguard_normalize_gdpr_native_request( $native_request );
if ( false === $native_request ) {
$reason = 'native_request_invalid';
return false;
}
if ( ! orderguard_plugin_is_active_for_erasure() ) {
$reason = 'plugin_inactive';
return false;
}
if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock( $reason ) ) {
return false;
}
try {
if ( ! orderguard_plugin_is_active_for_erasure() ) {
$reason = 'plugin_inactive';
return false;
}
return orderguard_prepare_gdpr_erasure_locked( $query, $native_request, $reason );
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/** Prepare while holding the shared erasure/uninstall lifecycle mutex. */
function orderguard_prepare_gdpr_erasure_locked( $query, $native_request = array(), &$reason = null ) {
if ( ! OG_Native_Legacy_Gate::allowed() ) { $reason = 'owned_takeover'; return false; }
$reason = '';
$native_request = orderguard_normalize_gdpr_native_request( $native_request );
if ( false === $native_request ) {
$reason = 'native_request_invalid';
return false;
}
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason )
|| ! orderguard_gdpr_suppression_integrity_ok( true ) ) {
orderguard_log( 'OrderGuard: GDPR erasure request refused because privacy state needs repair (' . $integrity_reason . ').' );
$reason = 'privacy_state_invalid';
return false;
}
$hashes = orderguard_gdpr_erasure_query_hashes( $query );
$bodies = orderguard_gdpr_erasure_request_bodies( $query );
$identity_kind = orderguard_gdpr_erasure_identity_kind( $bodies );
$fingerprint = orderguard_gdpr_erasure_fingerprint( $hashes );
// Re-check completion while holding the same lifecycle lock as ACK. This
// closes the race where the outer Ajax read sees no receipt immediately
// before a worker writes it and clears pending state.
if ( ! empty( $native_request ) && '' === $fingerprint ) {
$reason = 'native_request_invalid';
return false;
}
if ( ! empty( $native_request ) ) {
$receipt_status = '';
$receipt = orderguard_gdpr_native_receipt(
$native_request,
$fingerprint,
'',
'',
$receipt_status
);
if ( false !== $receipt ) {
return array(
'fingerprint' => $fingerprint,
'request_token' => $receipt['request_token'],
'receipt_complete' => true,
);
}
if ( 'missing' !== $receipt_status ) {
$reason = 'native_receipt_invalid';
return false;
}
}
// Freeze time before the allocator ceiling. An order created between the
// reads is covered by at least one boundary; a later backdated import is
// still outside the monotonic ID boundary.
$submitted_at = time();
$storage_mode_ok = false;
$storage_mode = orderguard_gdpr_erasure_order_storage_mode( $storage_mode_ok );
$high_water_ok = false;
$high_water = $storage_mode_ok
? orderguard_gdpr_erasure_order_high_water( $high_water_ok, $storage_mode )
: 0;
$flag_hashes_ok = false;
$flag_hashes_reason = '';
$flag_hashes = orderguard_gdpr_erasure_local_order_flag_hashes(
$hashes,
$flag_hashes_ok,
$flag_hashes_reason
);
$local_flags_ok = false;
$local_flags_reason = '';
$local_flag_manifest = orderguard_gdpr_local_flag_manifest_for_hashes(
array_merge( $hashes, $flag_hashes ),
$local_flags_ok,
$local_flags_reason
);
$request_token = orderguard_new_gdpr_erasure_request_token();
$request = orderguard_new_gdpr_erasure_request(
$request_token,
$submitted_at,
$high_water,
$storage_mode,
$identity_kind,
$hashes,
$bodies,
$flag_hashes,
$local_flag_manifest,
$native_request
);
if ( ! $flag_hashes_ok ) {
orderguard_log( 'OrderGuard: GDPR erasure request refused because linked local flag hashes could not be read safely (' . sanitize_key( $flag_hashes_reason ) . ').' );
$reason = 'local_flag_hashes_unavailable';
return false;
}
if ( ! $local_flags_ok ) {
orderguard_log( 'OrderGuard: GDPR erasure request refused because matching local flags could not be frozen safely (' . sanitize_key( $local_flags_reason ) . ').' );
$reason = 'local_flags_unavailable';
return false;
}
if ( '' === $fingerprint || ! $high_water_ok || ! $storage_mode_ok || false === $request ) {
$reason = 'request_scope_invalid';
return false;
}
$saved_entry = null;
$saved_scope = null;
$already_accepted = false;
for ( $attempt = 0; $attempt < 10; ++$attempt ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
$reason = 'privacy_state_invalid';
return false;
}
$expected = isset( $pending[ $fingerprint ] ) ? $pending[ $fingerprint ] : null;
if ( null === $expected ) {
$replacement = orderguard_activate_gdpr_erasure_request( $request, 1 );
$scope = $replacement;
} else {
if ( ! empty( $native_request )
&& ! empty( $expected['native_request'] )
&& $expected['native_request']['request_id'] === $native_request['request_id']
&& ! orderguard_gdpr_native_request_equals( $expected['native_request'], $native_request ) ) {
$reason = 'native_request_conflict';
return false;
}
if ( ! empty( $native_request )
&& ! empty( $expected['queued_request']['native_request'] )
&& $expected['queued_request']['native_request']['request_id'] === $native_request['request_id']
&& ! orderguard_gdpr_native_request_equals(
$expected['queued_request']['native_request'],
$native_request
) ) {
$reason = 'native_request_conflict';
return false;
}
if ( ! empty( $native_request )
&& orderguard_gdpr_native_request_equals( $expected['native_request'], $native_request ) ) {
$saved_entry = $expected;
$saved_scope = $expected;
$already_accepted = true;
break;
}
if ( ! empty( $native_request )
&& ! empty( $expected['queued_request'] )
&& orderguard_gdpr_native_request_equals(
$expected['queued_request']['native_request'],
$native_request
) ) {
$saved_entry = $expected;
$saved_scope = $expected['queued_request'];
$already_accepted = true;
break;
}
if ( ! empty( $native_request ) && ! empty( $expected['queued_request'] ) ) {
$reason = 'native_queue_full';
return false;
}
// Once an active generation can have reached PREPARE, changing it
// would make an ambiguous response unrecoverable. Explicit later
// requests therefore live in one unsent queue until ACK promotes
// them atomically.
$queued = orderguard_merge_queued_gdpr_erasure_request(
$expected['queued_request'],
$request
);
if ( false === $queued ) {
$reason = 'queue_conflict';
return false;
}
$replacement = orderguard_next_pending_gdpr_erasure_entry( $expected );
$replacement['queued_request'] = $queued;
$replacement['updated_at'] = $submitted_at;
$scope = $queued;
}
if ( false === $replacement || ! is_array( $scope ) ) {
$reason = 'request_scope_invalid';
return false;
}
$capacity_reason = '';
if ( ! orderguard_gdpr_erasure_admission_capacity_ok(
$pending,
$fingerprint,
$replacement,
$capacity_reason
) ) {
$reason = $capacity_reason;
orderguard_log( 'OrderGuard: GDPR erasure request refused by local capacity admission (' . sanitize_key( $capacity_reason ) . ').' );
return false;
}
// Cached decisions must become unreachable before the pending guard
// is published. Admission above has proved the whole bounded lifecycle
// can fit; a redundant bump after a CAS conflict is harmless.
if ( false === orderguard_gdpr_erasure_cache_epoch( true ) ) {
$reason = 'cache_epoch_unavailable';
return false;
}
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$expected,
$replacement,
$conflict
) ) {
$saved_entry = orderguard_normalize_pending_gdpr_erasure_entry( $fingerprint, $replacement );
$saved_scope = $scope;
break;
}
if ( ! $conflict ) {
orderguard_log( 'OrderGuard: local GDPR erasure job could not be persisted; server request refused.' );
$reason = 'pending_write_failed';
return false;
}
}
if ( ! is_array( $saved_entry ) || ! is_array( $saved_scope ) ) {
orderguard_log( 'OrderGuard: local GDPR erasure job stayed busy; server request refused.' );
$reason = 'pending_busy';
return false;
}
// Pending state closes the hand-off into this permanent boundary. If the
// monotonic write is temporarily unavailable, the accepted job remains
// visible and its worker retries before any server operation.
if ( ! $already_accepted ) {
orderguard_save_gdpr_erasure_suppressions(
$saved_scope['match_hashes'],
$saved_scope['suppression_cutoff'],
$saved_scope['suppression_max_order_id']
);
}
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
return array(
'fingerprint' => $fingerprint,
'request_token' => $saved_scope['request_token'],
);
}
/**
* Queue the next bounded scan/server attempt.
*
* A worker ignores its own RUNNING action so it can enqueue a continuation.
* Recovery callers include RUNNING actions so they cannot shorten a backoff
* while a worker is between starting and scheduling its next attempt.
*/
function orderguard_schedule_gdpr_erasure_job( $fingerprint, $delay = 1, $include_running = false ) {
$fingerprint = trim( (string) $fingerprint );
if ( ! preg_match( '/^[a-f0-9]{64}$/', $fingerprint ) ) {
return false;
}
$args = array( 'fingerprint' => $fingerprint );
$scheduled_at = time() + max( 1, (int) $delay );
$as_ready = false;
if ( function_exists( 'as_schedule_single_action' ) ) {
try {
if ( function_exists( 'as_get_scheduled_actions' ) ) {
$scheduled_before = function_exists( 'as_get_datetime_object' )
? as_get_datetime_object( $scheduled_at )
: new DateTime( '@' . $scheduled_at );
$statuses = $include_running ? array( 'pending', 'in-progress' ) : array( 'pending' );
foreach ( $statuses as $status ) {
$scheduled = as_get_scheduled_actions(
array(
'hook' => 'orderguard_process_gdpr_erasure',
'args' => $args,
'group' => 'orderguard',
'status' => $status,
'date' => $scheduled_before,
'date_compare' => '<=',
'orderby' => 'date',
'order' => 'ASC',
'per_page' => 1,
),
'ids'
);
if ( ! empty( $scheduled ) ) {
$as_ready = true;
break;
}
}
}
if ( ! $as_ready ) {
$action_id = as_schedule_single_action(
$scheduled_at,
'orderguard_process_gdpr_erasure',
$args,
'orderguard'
);
$as_ready = false !== $action_id && (int) $action_id > 0;
}
} catch ( Throwable $throwable ) {
orderguard_log( 'OrderGuard: Action Scheduler threw while queuing a GDPR erasure step; trying WordPress cron.' );
}
if ( ! $as_ready ) {
orderguard_log( 'OrderGuard: Action Scheduler could not queue a GDPR erasure step; retaining the independent WordPress cron wake-up.' );
}
}
// This is deliberately independent of Action Scheduler. A PENDING row is
// not proof that its queue runner is alive, so it must never suppress the
// second wake-up path. Concurrent callbacks are harmless: the lifecycle
// lock and exact entry CAS admit only the current revision.
$next = wp_next_scheduled( 'orderguard_process_gdpr_erasure', $args );
if ( false !== $next && (int) $next <= $scheduled_at ) {
return true;
}
$scheduled = wp_schedule_single_event(
$scheduled_at,
'orderguard_process_gdpr_erasure',
$args,
true
);
if ( true === $scheduled ) {
return true;
}
// Never remove a later valid wake-up merely to move it earlier. Core may
// reject the new event as a ten-minute duplicate, and a crash between an
// unschedule/re-schedule pair would otherwise leave only request traffic
// to rediscover the durable job.
return $as_ready || false !== $next;
}
add_action( 'orderguard_process_gdpr_erasure', 'orderguard_process_gdpr_erasure_action', 10, 1 );
add_action( 'init', 'orderguard_ensure_gdpr_erasure_jobs_scheduled', 20 );
/** Re-create a lost retry action while a durable job remains pending. */
function orderguard_ensure_gdpr_erasure_jobs_scheduled() {
$doing_cron = function_exists( 'wp_doing_cron' )
? wp_doing_cron()
: ( defined( 'DOING_CRON' ) && DOING_CRON );
$doing_cli = defined( 'WP_CLI' ) && WP_CLI;
if ( ! is_admin() && ! $doing_cron && ! $doing_cli ) {
return;
}
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return;
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return;
}
$now = time();
foreach ( $pending as $fingerprint => $entry ) {
if ( 'blocked' === $entry['status'] ) {
continue;
}
$delay = max( 1, (int) $entry['next_attempt_at'] - $now );
orderguard_schedule_gdpr_erasure_job( $fingerprint, $delay, true );
}
}
function orderguard_process_gdpr_erasure_action( $fingerprint ) {
orderguard_run_gdpr_erasure_job( $fingerprint );
}
/** Schedule from freshly persisted state without shortening its due time. */
function orderguard_defer_current_gdpr_erasure_job( $fingerprint, $minimum_delay = 1 ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
// A read outage is not proof that the durable job disappeared. Keep a
// conservative wake-up alive so a later request can recover it.
orderguard_log( 'OrderGuard: GDPR erasure state could not be read while deferring; scheduling a conservative retry.' );
return orderguard_schedule_gdpr_erasure_job(
$fingerprint,
max( 300, (int) $minimum_delay )
);
}
if ( ! isset( $pending[ $fingerprint ] ) ) {
return false;
}
$delay = max(
1,
(int) $minimum_delay,
(int) $pending[ $fingerprint ]['next_attempt_at'] - time()
);
return orderguard_schedule_gdpr_erasure_job( $fingerprint, $delay );
}
/** Persist a retry time before relying on a scheduled continuation. */
function orderguard_delay_gdpr_erasure_job( $fingerprint, $expected_entry, $delay ) {
$delay = max( 1, (int) $delay );
$delayed = orderguard_next_pending_gdpr_erasure_entry( $expected_entry );
$due = time() + $delay;
$delayed['updated_at'] = time();
$delayed['next_attempt_at'] = max( (int) $expected_entry['next_attempt_at'], $due );
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$expected_entry,
$delayed,
$conflict
) ) {
return orderguard_schedule_gdpr_erasure_job(
$fingerprint,
max( 1, (int) $delayed['next_attempt_at'] - time() )
);
}
if ( $conflict ) {
// Another writer owns the current state and therefore its due time.
return orderguard_defer_current_gdpr_erasure_job( $fingerprint );
}
// If persistence itself is unavailable, retain the intended delay in the
// best-effort action. Recovery may retry sooner, but cannot lose the job.
return orderguard_schedule_gdpr_erasure_job( $fingerprint, $delay );
}
/**
* Keep every local-discovery and destructive server phase bound to the
* WooCommerce storage authority frozen when the merchant submitted it.
*/
function orderguard_confirm_gdpr_erasure_storage_mode( $fingerprint, &$entry ) {
if ( in_array( $entry['status'], array( 'local_flags', 'ack' ), true ) ) {
// COMMIT already succeeded and every ref is durably suppressed. A
// storage switch must not prevent the non-destructive ACK/cleanup.
return true;
}
$mode_ok = false;
$mode = orderguard_gdpr_erasure_order_storage_mode( $mode_ok );
$expected_mode = (string) $entry['order_storage_mode'];
if ( $mode_ok && '' === $expected_mode ) {
$updated = orderguard_next_pending_gdpr_erasure_entry( $entry );
$updated['order_storage_mode'] = $mode;
$updated['storage_mode_mismatch'] = '';
$updated['updated_at'] = time();
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$updated,
$conflict
) ) {
// A CAS conflict means a newer generation or phase owns the
// current due time. Do not impose this stale worker's pause.
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return false;
}
$entry = $updated;
return true;
}
if ( ! $mode_ok || $mode !== $expected_mode ) {
$mismatch = $mode_ok ? $mode : 'unavailable';
$paused = orderguard_next_pending_gdpr_erasure_entry( $entry );
$paused['storage_mode_mismatch'] = $mismatch;
$paused['updated_at'] = time();
$paused['next_attempt_at'] = time() + 21600;
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$paused,
$conflict
) ) {
orderguard_log( 'OrderGuard: GDPR erasure paused because WooCommerce order-storage authority changed.' );
orderguard_schedule_gdpr_erasure_job( $fingerprint, 21600 );
} else {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
}
return false;
}
if ( '' !== $entry['storage_mode_mismatch'] ) {
$restored = orderguard_next_pending_gdpr_erasure_entry( $entry );
$restored['storage_mode_mismatch'] = '';
$restored['updated_at'] = time();
$restored['next_attempt_at'] = 0;
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$restored,
$conflict
) ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return false;
}
$entry = $restored;
}
return true;
}
/** Canonical protocol payload size, bounded before it reaches HTTP or state. */
function orderguard_gdpr_erasure_scope_size_ok( $identity_bodies, $flag_hashes, $order_refs ) {
$encoded = wp_json_encode(
array(
'identity_bodies' => array_values( (array) $identity_bodies ),
'flag_hashes' => array_values( (array) $flag_hashes ),
'local_order_refs' => array_values( (array) $order_refs ),
),
JSON_UNESCAPED_SLASHES
);
return is_string( $encoded ) && strlen( $encoded ) <= 8 * 1024 * 1024;
}
/** Convert validated local IDs to the protocol's canonical decimal strings. */
function orderguard_gdpr_erasure_ref_strings( $refs ) {
$ok = false;
$refs = orderguard_normalize_gdpr_erasure_ref_list( $refs, 20000, $ok );
return $ok ? array_map( 'strval', $refs ) : false;
}
/** Parse Retry-After without allowing an untrusted response to park forever. */
function orderguard_gdpr_erasure_retry_after_seconds( $response ) {
$header = wp_remote_retrieve_header( $response, 'retry-after' );
if ( is_array( $header ) ) {
$header = reset( $header );
}
$header = trim( (string) $header );
if ( '' === $header ) {
return 0;
}
if ( ctype_digit( $header ) ) {
return min( 21600, max( 1, (int) $header ) );
}
$when = strtotime( $header );
return false === $when
? 0
: min( 21600, max( 1, $when - time() ) );
}
function orderguard_gdpr_erasure_protocol_request( $phase, $body ) {
if ( ! OG_Native_Legacy_Gate::allowed() ) { return array( 'kind' => 'owned_takeover', 'code' => 0, 'retry_after' => 0, 'data' => null ); }
$paths = array(
'prepare' => '/v1/data/erase/prepare',
'commit' => '/v1/data/erase/commit',
'ack' => '/v1/data/erase/ack',
);
if ( ! isset( $paths[ $phase ] ) || ! is_array( $body ) || empty( $body ) ) {
return array( 'kind' => 'local_error', 'code' => 0, 'retry_after' => 0, 'data' => null );
}
$encoded = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
// The bounded identity/ref scope may itself be 8 MiB; allow only a small,
// fixed envelope for the generation, cutoff and high-water fields.
if ( ! is_string( $encoded ) || '' === $encoded || strlen( $encoded ) > 8 * 1024 * 1024 + 262144 ) {
return array( 'kind' => 'local_error', 'code' => 0, 'retry_after' => 0, 'data' => null );
}
$response = wp_remote_post(
ORDERGUARD_SERVER_URL . $paths[ $phase ],
array(
'timeout' => 12,
'redirection' => 0,
'sslverify' => true,
// PREPARE may legitimately return the full 8 MiB canonical ref
// scope plus its fixed receipt fields. Truncation would turn a
// valid large erasure into three misleading malformed retries.
'limit_response_size' => 8 * 1024 * 1024 + 65536,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => $encoded,
)
);
if ( is_wp_error( $response ) ) {
return array( 'kind' => 'retry', 'code' => 0, 'retry_after' => 0, 'data' => null );
}
$code = (int) wp_remote_retrieve_response_code( $response );
$retry_after = orderguard_gdpr_erasure_retry_after_seconds( $response );
if ( 408 === $code || 423 === $code || 425 === $code || 429 === $code
|| ( $code >= 500 && $code <= 599 ) || $code <= 0 ) {
return array( 'kind' => 'retry', 'code' => $code, 'retry_after' => $retry_after, 'data' => null );
}
if ( 200 !== $code ) {
return array( 'kind' => 'terminal', 'code' => $code, 'retry_after' => 0, 'data' => null );
}
$raw = wp_remote_retrieve_body( $response );
$data = json_decode( $raw, true );
if ( ! is_array( $data ) || JSON_ERROR_NONE !== json_last_error() ) {
return array( 'kind' => 'malformed', 'code' => 200, 'retry_after' => 0, 'data' => null );
}
return array( 'kind' => 'ok', 'code' => 200, 'retry_after' => 0, 'data' => $data );
}
/** Require an exact response object, not merely the fields a caller happens to read. */
function orderguard_gdpr_erasure_response_has_fields( $data, $fields ) {
if ( ! is_array( $data ) ) {
return false;
}
$actual = array_keys( $data );
sort( $actual, SORT_STRING );
sort( $fields, SORT_STRING );
return $actual === $fields;
}
/** Strict server-ref validation for the immutable PREPARE scope. */
function orderguard_gdpr_erasure_response_refs( $refs, $max_order_id, &$ok = null ) {
$ok = false;
if ( ! is_array( $refs ) || count( $refs ) > 20000 ) {
return array();
}
$seen = array();
$canonical = array();
foreach ( $refs as $ref ) {
// Prefix the map key: PHP silently casts a canonical decimal string
// such as "123" to integer 123 when used directly as an array key,
// which would make the later strict string comparison reject every
// valid server response.
$seen_key = is_string( $ref ) ? 'ref:' . $ref : '';
if ( ! is_string( $ref )
|| ! preg_match( '/^[1-9][0-9]*$/', $ref )
|| strlen( $ref ) > 19
|| (string) (int) $ref !== $ref
|| (int) $ref > (int) $max_order_id
|| isset( $seen[ $seen_key ] ) ) {
return array();
}
$seen[ $seen_key ] = true;
$canonical[] = $ref;
}
sort( $canonical, SORT_STRING );
if ( $canonical !== array_values( $refs )
|| ! orderguard_gdpr_erasure_scope_size_ok( array(), array(), $canonical ) ) {
return array();
}
$ok = true;
return array_map( 'intval', $canonical );
}
/** Validate PREPARE and return only the exact state bound into COMMIT. */
function orderguard_validate_gdpr_erasure_prepare_response( $data, $entry, &$replayed_ack = null ) {
$replayed_ack = false;
if ( is_array( $data )
&& isset( $data['status'] )
&& 'acknowledged' === $data['status'] ) {
$replayed_ack = orderguard_gdpr_erasure_response_has_fields(
$data,
array( 'status', 'acknowledged', 'operation_id', 'generation', 'order_refs' )
)
&& true === $data['acknowledged']
&& is_string( $data['operation_id'] )
&& preg_match( '/^[A-Za-z0-9_-]{16,128}$/', $data['operation_id'] )
&& is_string( $data['generation'] )
&& hash_equals( $entry['request_token'], $data['generation'] )
&& array() === $data['order_refs'];
return false;
}
if ( ! orderguard_gdpr_erasure_response_has_fields(
$data,
array( 'status', 'operation_id', 'generation', 'cutoff_at', 'operation_token', 'order_refs' )
)
|| 'prepared' !== $data['status']
|| ! is_string( $data['operation_id'] )
|| ! preg_match( '/^[A-Za-z0-9_-]{16,128}$/', $data['operation_id'] )
|| ! is_string( $data['generation'] )
|| ! hash_equals( $entry['request_token'], $data['generation'] )
|| ! is_int( $data['cutoff_at'] )
|| $data['cutoff_at'] <= 0
|| $data['cutoff_at'] > (int) $entry['suppression_cutoff']
|| ! is_string( $data['operation_token'] )
|| ! preg_match( '/^[a-f0-9]{64}$/', $data['operation_token'] ) ) {
return false;
}
$refs_ok = false;
$refs = orderguard_gdpr_erasure_response_refs(
$data['order_refs'],
$entry['suppression_max_order_id'],
$refs_ok
);
$local_strings = orderguard_gdpr_erasure_ref_strings( $entry['order_refs'] );
if ( ! $refs_ok || false === $local_strings
|| ! empty( array_diff( $local_strings, $data['order_refs'] ) ) ) {
return false;
}
return array(
'operation_id' => $data['operation_id'],
'operation_token' => $data['operation_token'],
'server_cutoff_at' => $data['cutoff_at'],
'server_refs' => $refs,
);
}
/** Strictly validate the idempotent COMMIT receipt. */
function orderguard_validate_gdpr_erasure_commit_response( $data, $entry ) {
if ( ! orderguard_gdpr_erasure_response_has_fields(
$data,
array( 'status', 'erased', 'operation_id', 'generation', 'cutoff_at', 'sightings', 'events', 'flag_signals', 'tombstones' )
)
|| 'committed' !== $data['status']
|| true !== $data['erased']
|| ! is_string( $data['operation_id'] )
|| ! hash_equals( $entry['operation_id'], $data['operation_id'] )
|| ! is_string( $data['generation'] )
|| ! hash_equals( $entry['request_token'], $data['generation'] )
|| ! is_int( $data['cutoff_at'] )
|| $data['cutoff_at'] !== (int) $entry['server_cutoff_at'] ) {
return false;
}
foreach ( array( 'sightings', 'events', 'flag_signals', 'tombstones' ) as $field ) {
if ( ! is_int( $data[ $field ] ) || $data[ $field ] < 0 ) {
return false;
}
}
return count( $entry['server_refs'] ) === $data['tombstones'];
}
/** Strictly validate the final scope-scrub acknowledgement. */
function orderguard_validate_gdpr_erasure_ack_response( $data, $entry ) {
return orderguard_gdpr_erasure_response_has_fields(
$data,
array( 'status', 'acknowledged', 'operation_id', 'generation' )
)
&& 'acknowledged' === $data['status']
&& true === $data['acknowledged']
&& is_string( $data['operation_id'] )
&& hash_equals( $entry['operation_id'], $data['operation_id'] )
&& is_string( $data['generation'] )
&& hash_equals( $entry['request_token'], $data['generation'] );
}
/** Ordinary operator-resolvable refusal; unlike a protocol fault it is job-local. */
function orderguard_block_gdpr_erasure_job( $fingerprint, $expected_entry, $reason, $phase ) {
$reason = sanitize_key( $reason );
if ( '' === $reason || strlen( $reason ) > 64
|| ! in_array( $phase, array( 'scanning', 'prepare', 'commit', 'local_flags', 'ack' ), true ) ) {
return false;
}
$blocked = orderguard_next_pending_gdpr_erasure_entry( $expected_entry );
$blocked['status'] = 'blocked';
$blocked['blocked_reason'] = $reason;
$blocked['blocked_from'] = $phase;
$blocked['updated_at'] = time();
$blocked['next_attempt_at'] = 0;
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$expected_entry,
$blocked,
$conflict
) ) {
orderguard_log( 'OrderGuard: GDPR erasure paused for supervised review (' . $reason . ').' );
return true;
}
if ( $conflict ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint );
}
return false;
}
/** Whether an administrator can safely retry one job-local block in place. */
function orderguard_gdpr_erasure_block_is_resumable( $reason, $phase ) {
$reason = sanitize_key( (string) $reason );
$phase = sanitize_key( (string) $phase );
if ( '' === $reason
|| ! in_array( $phase, array( 'scanning', 'prepare', 'commit', 'local_flags', 'ack' ), true )
|| 0 === strpos( $reason, 'protocol_' ) ) {
return false;
}
// Retrying cannot shrink a scope already frozen into this generation, and
// an invalid manifest cannot be replaced without a new request generation.
// Both require supervised reconciliation rather than a button that loops.
return ! in_array(
$reason,
array( 'scope_limit_exceeded', 'local_flag_manifest_invalid' ),
true
);
}
/**
* Resume supervised, job-local refusals without weakening a signed protocol fault.
*
* The original immutable request body, operation binding, cutoff and row
* manifest stay untouched. A repeated terminal answer simply blocks again.
*/
function orderguard_resume_blocked_gdpr_erasures() {
if ( ! orderguard_plugin_is_active_for_erasure()
|| ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) {
return false;
}
$resumed = 0;
try {
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return false;
}
for ( $pass = 0; $pass < 10; ++$pass ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return false;
}
$changed = false;
foreach ( $pending as $fingerprint => $entry ) {
if ( 'blocked' !== $entry['status']
|| ! orderguard_gdpr_erasure_block_is_resumable(
$entry['blocked_reason'],
$entry['blocked_from']
) ) {
continue;
}
$next = orderguard_next_pending_gdpr_erasure_entry( $entry );
$next['status'] = $entry['blocked_from'];
$next['blocked_reason'] = '';
$next['blocked_from'] = '';
$next['attempts'] = 0;
$next['updated_at'] = time();
$next['next_attempt_at'] = 0;
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$next,
$conflict
) ) {
++$resumed;
$changed = true;
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
continue;
}
if ( ! $conflict ) {
return false;
}
$changed = true;
break;
}
if ( ! $changed ) {
return $resumed;
}
}
return false;
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/** Consecutive retry schedule for a phase whose request is safe to replay. */
function orderguard_retry_gdpr_erasure_phase( $fingerprint, $entry, $retry_after = 0, $malformed = false ) {
$attempts = (int) $entry['attempts'] + 1;
if ( $malformed && $attempts >= 3 ) {
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
'malformed_response',
$entry['status']
);
return 'failed';
}
$backoff = array( 60, 300, 1800, 7200, 21600 );
$delay = isset( $backoff[ $attempts - 1 ] ) ? $backoff[ $attempts - 1 ] : 21600;
$delay = max( $delay, min( 21600, max( 0, (int) $retry_after ) ) );
$retry = orderguard_next_pending_gdpr_erasure_entry( $entry );
$retry['attempts'] = $attempts;
$retry['updated_at'] = time();
$retry['next_attempt_at'] = time() + $delay;
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$retry,
$conflict
) ) {
orderguard_schedule_gdpr_erasure_job( $fingerprint, $delay );
return 'pending';
}
// A conflicting writer owns the new phase and due time. Never impose this
// stale request's backoff on a renewed merchant generation.
orderguard_defer_current_gdpr_erasure_job( $fingerprint );
return 'pending';
}
/** Durably stop all identity traffic after an impossible server replay. */
function orderguard_block_gdpr_erasure_protocol( $fingerprint, $expected_entry, $reason, $phase ) {
$reason = (string) $reason;
$phase = (string) $phase;
$entry_saved = false;
$current = $expected_entry;
$request_token = is_array( $expected_entry ) && isset( $expected_entry['request_token'] )
? (string) $expected_entry['request_token']
: '';
for ( $attempt = 0; $attempt < 10; ++$attempt ) {
if ( ! is_array( $current )
|| ! hash_equals( $request_token, (string) $current['request_token'] ) ) {
break;
}
if ( 'blocked' === $current['status']
&& $reason === $current['blocked_reason']
&& $phase === $current['blocked_from'] ) {
$entry_saved = true;
break;
}
$blocked = orderguard_next_pending_gdpr_erasure_entry( $current );
$blocked['status'] = 'blocked';
$blocked['blocked_reason'] = $reason;
$blocked['blocked_from'] = $phase;
$blocked['updated_at'] = time();
$blocked['next_attempt_at'] = 0;
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$current,
$blocked,
$conflict
) ) {
$entry_saved = true;
break;
}
if ( ! $conflict ) {
break;
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok || ! isset( $pending[ $fingerprint ] ) ) {
break;
}
$current = $pending[ $fingerprint ];
}
$fault_saved = orderguard_set_gdpr_protocol_fault( $reason );
orderguard_log( 'OrderGuard: GDPR erasure protocol entered a fail-closed recovery state (' . $reason . ').' );
if ( ! $entry_saved && ! $fault_saved ) {
// No process can make a failed database write durable, but never let a
// scheduler row be the only chance to revisit it. The pending option is
// still the source of truth; this best-effort action gives recovery a
// prompt second opportunity if storage becomes writable again.
orderguard_schedule_gdpr_erasure_job( $fingerprint, 60 );
}
return $entry_saved || $fault_saved;
}
/**
* Advance one erasure job within a small request budget.
*
* @return string complete|pending|failed
*/
function orderguard_run_gdpr_erasure_job( $fingerprint ) {
if ( ! OG_Native_Legacy_Gate::allowed() ) { return 'failed'; }
if ( ! orderguard_plugin_is_active_for_erasure() ) {
return 'failed';
}
if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) {
// init recovery sees the current action as running, so explicitly
// create a successor when another erasure owns the database mutex.
orderguard_schedule_gdpr_erasure_job( $fingerprint, 30 );
return 'pending';
}
try {
// A queued worker may have booted before deactivation and waited for
// uninstall. Never recreate options after the plugin became inactive.
if ( ! orderguard_plugin_is_active_for_erasure() ) {
return 'failed';
}
return orderguard_run_gdpr_erasure_job_locked( $fingerprint );
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/** Advance one erasure job while holding the lifecycle/concurrency mutex. */
function orderguard_run_gdpr_erasure_job_locked( $fingerprint ) {
if ( ! OG_Native_Legacy_Gate::allowed() ) { return 'failed'; }
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
orderguard_log( 'OrderGuard: GDPR erasure state needs repair (' . $integrity_reason . '); job paused.' );
// A signed protocol fault is deliberately operator-cleared. Repeated
// actions cannot repair it and would only create a permanent queue.
if ( 0 !== strpos( $integrity_reason, 'protocol_fault_' ) ) {
orderguard_schedule_gdpr_erasure_job( $fingerprint, 21600 );
return 'pending';
}
return 'failed';
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok || ! isset( $pending[ $fingerprint ] ) ) {
return 'failed';
}
$entry = $pending[ $fingerprint ];
if ( 'blocked' === $entry['status'] ) {
return 'failed';
}
if ( (int) $entry['next_attempt_at'] > time() ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint );
return 'pending';
}
if ( ! orderguard_confirm_gdpr_erasure_storage_mode( $fingerprint, $entry ) ) {
return 'pending';
}
// Pending state is the hand-off guard, while this authenticated permanent
// register remains the recovery proof if PHP stops after a server phase.
if ( ! orderguard_save_gdpr_erasure_suppressions(
$entry['match_hashes'],
$entry['suppression_cutoff'],
$entry['suppression_max_order_id'],
array_merge( $entry['order_refs'], $entry['server_refs'] )
) ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $entry, 300 );
return 'pending';
}
if ( 'scanning' === $entry['status'] ) {
$budget = (float) apply_filters( 'orderguard_gdpr_erasure_scan_budget_seconds', 3.0 );
$budget = max( 0.25, min( 10.0, $budget ) );
$scan_ok = false;
$batch = orderguard_local_gdpr_erasure_order_batch(
$entry['match_hashes'],
$entry['identity_kind'],
$entry['scan_after_id'],
$entry['suppression_max_order_id'],
100,
microtime( true ) + $budget,
$scan_ok,
$entry['order_storage_mode']
);
if ( ! $scan_ok || ! is_array( $batch ) ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $entry, 300 );
return 'pending';
}
// Resolve authority again after the query. Results obtained from one
// store while WooCommerce switched authority must never enter PREPARE.
$mode_ok = false;
$mode = orderguard_gdpr_erasure_order_storage_mode( $mode_ok );
if ( ! $mode_ok || $mode !== $entry['order_storage_mode'] ) {
orderguard_confirm_gdpr_erasure_storage_mode( $fingerprint, $entry );
return 'pending';
}
$refs = array_values(
array_unique( array_merge( $entry['order_refs'], $batch['order_refs'] ) )
);
sort( $refs, SORT_NUMERIC );
$bodies = orderguard_unique_gdpr_erasure_request_bodies(
array_merge( $entry['request_bodies'], $batch['request_bodies'] )
);
$flag_hashes_ok = false;
$flag_hashes = orderguard_normalize_gdpr_erasure_hash_list(
array_merge( $entry['flag_hashes'], $batch['flag_hashes'] ),
1024,
$flag_hashes_ok
);
$ref_strings = orderguard_gdpr_erasure_ref_strings( $refs );
if ( false === $ref_strings
|| count( $bodies ) > 256
|| ! $flag_hashes_ok
|| ! orderguard_gdpr_erasure_scope_size_ok( $bodies, $flag_hashes, $ref_strings ) ) {
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
'scope_limit_exceeded',
'scanning'
);
return 'failed';
}
$progress = orderguard_next_pending_gdpr_erasure_entry( $entry );
$progress['order_refs'] = $refs;
$progress['request_bodies'] = $bodies;
$progress['flag_hashes'] = $flag_hashes;
$progress['scan_after_id'] = max(
(int) $entry['scan_after_id'],
(int) $batch['next_after_id']
);
$progress['blocked_order_id'] = isset( $batch['blocked_order_id'] )
? absint( $batch['blocked_order_id'] )
: 0;
$progress['attempts'] = 0;
$progress['updated_at'] = time();
$progress['next_attempt_at'] = empty( $batch['blocked_order_id'] ) ? 0 : time() + 300;
if ( ! empty( $batch['done'] ) ) {
$progress['status'] = 'prepare';
}
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$progress,
$conflict
) ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return 'pending';
}
// Persist discoveries before any destructive phase and do only bounded
// fast-path order-meta work after the CAS has made the refs durable.
if ( ! orderguard_save_gdpr_erasure_suppressions(
$progress['match_hashes'],
$progress['suppression_cutoff'],
$progress['suppression_max_order_id'],
$progress['order_refs']
) ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $progress, 300 );
return 'pending';
}
orderguard_mark_gdpr_erased_order_refs_bounded( $batch['order_refs'] );
orderguard_schedule_gdpr_erasure_job(
$fingerprint,
empty( $batch['blocked_order_id'] ) ? 1 : 300
);
return 'pending';
}
if ( 'local_flags' === $entry['status'] ) {
$local_result = orderguard_delete_gdpr_local_flag_manifest_batch(
$entry['local_flag_manifest'],
50
);
if ( 'blocked' === $local_result['kind'] ) {
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
$local_result['reason'],
'local_flags'
);
return 'failed';
}
if ( 'retry' === $local_result['kind'] ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $entry, 300 );
return 'pending';
}
$next = orderguard_next_pending_gdpr_erasure_entry( $entry );
$next['local_flag_manifest'] = $local_result['remaining'];
$next['status'] = 'done' === $local_result['kind'] ? 'ack' : 'local_flags';
$next['attempts'] = 0;
$next['updated_at'] = time();
$next['next_attempt_at'] = 0;
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$next,
$conflict
) ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return 'pending';
}
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
return 'pending';
}
$phase = $entry['status'];
if ( 'prepare' === $phase ) {
$local_refs = orderguard_gdpr_erasure_ref_strings( $entry['order_refs'] );
if ( false === $local_refs
|| ! orderguard_gdpr_erasure_scope_size_ok(
$entry['request_bodies'],
$entry['flag_hashes'],
$local_refs
) ) {
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
'scope_limit_exceeded',
'prepare'
);
return 'failed';
}
$request_body = array(
'generation' => $entry['request_token'],
'cutoff_at' => (int) $entry['suppression_cutoff'],
'max_order_id' => (int) $entry['suppression_max_order_id'],
'identity_bodies' => $entry['request_bodies'],
'flag_hashes' => $entry['flag_hashes'],
'local_order_refs' => $local_refs,
);
} elseif ( 'commit' === $phase ) {
$protected_refs = orderguard_gdpr_erasure_ref_strings( $entry['server_refs'] );
if ( false === $protected_refs ) {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
'protocol_state_unrecoverable',
'commit'
);
return 'failed';
}
// The exact PREPARE scope is permanent and has its bounded per-order
// markers before COMMIT can delete the only remote copy.
if ( ! orderguard_save_gdpr_erasure_suppressions(
$entry['match_hashes'],
$entry['suppression_cutoff'],
$entry['suppression_max_order_id'],
$entry['server_refs']
) ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $entry, 300 );
return 'pending';
}
orderguard_mark_gdpr_erased_order_refs_bounded( $entry['server_refs'] );
$request_body = array(
'operation_id' => $entry['operation_id'],
'generation' => $entry['request_token'],
'operation_token' => $entry['operation_token'],
'protected_order_refs' => $protected_refs,
);
} elseif ( 'ack' === $phase ) {
$request_body = array(
'operation_id' => $entry['operation_id'],
'generation' => $entry['request_token'],
'operation_token' => $entry['operation_token'],
);
} else {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
'protocol_state_unrecoverable',
'scanning'
);
return 'failed';
}
// Exactly one remote request per action. Every ambiguous outcome retains
// the same immutable phase/body so the server's idempotent replay is used.
$result = orderguard_gdpr_erasure_protocol_request( $phase, $request_body );
if ( 'retry' === $result['kind'] ) {
return orderguard_retry_gdpr_erasure_phase(
$fingerprint,
$entry,
$result['retry_after'],
false
);
}
if ( 'malformed' === $result['kind'] ) {
return orderguard_retry_gdpr_erasure_phase( $fingerprint, $entry, 0, true );
}
if ( 'terminal' === $result['kind'] ) {
$code = (int) $result['code'];
if ( 409 === $code
|| ( in_array( $phase, array( 'commit', 'ack' ), true ) && 404 === $code ) ) {
// Once a phase is durably bound, conflict/missing-operation
// answers contradict this store's replayable protocol state. A
// single-job refusal is unsafe: all identity traffic must stop
// until the server/local state is reconciled by an operator.
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
409 === $code ? 'protocol_http_409' : 'protocol_state_unrecoverable',
$phase
);
} else {
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
'http_' . max( 0, $code ),
$phase
);
}
return 'failed';
}
if ( 'ok' !== $result['kind'] ) {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
'protocol_state_unrecoverable',
$phase
);
return 'failed';
}
if ( 'prepare' === $phase ) {
$replayed_ack = false;
$binding = orderguard_validate_gdpr_erasure_prepare_response(
$result['data'],
$entry,
$replayed_ack
);
if ( false === $binding ) {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
$replayed_ack ? 'protocol_replay_conflict' : 'protocol_terminal_response',
'prepare'
);
return 'failed';
}
$next = orderguard_next_pending_gdpr_erasure_entry( $entry );
$next['status'] = 'commit';
$next['server_refs'] = $binding['server_refs'];
$next['server_cutoff_at'] = $binding['server_cutoff_at'];
$next['operation_id'] = $binding['operation_id'];
$next['operation_token'] = $binding['operation_token'];
$next['attempts'] = 0;
$next['updated_at'] = time();
$next['next_attempt_at'] = 0;
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$next,
$conflict
) ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return 'pending';
}
if ( ! orderguard_save_gdpr_erasure_suppressions(
$next['match_hashes'],
$next['suppression_cutoff'],
$next['suppression_max_order_id'],
$next['server_refs']
) ) {
orderguard_delay_gdpr_erasure_job( $fingerprint, $next, 300 );
return 'pending';
}
orderguard_mark_gdpr_erased_order_refs_bounded( $next['server_refs'] );
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
return 'pending';
}
if ( 'commit' === $phase ) {
if ( ! orderguard_validate_gdpr_erasure_commit_response( $result['data'], $entry ) ) {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
'protocol_terminal_response',
'commit'
);
return 'failed';
}
$next = orderguard_next_pending_gdpr_erasure_entry( $entry );
$next['status'] = 'local_flags';
$next['attempts'] = 0;
$next['updated_at'] = time();
$next['next_attempt_at'] = 0;
$conflict = false;
if ( ! orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$entry,
$next,
$conflict
) ) {
orderguard_defer_current_gdpr_erasure_job( $fingerprint, $conflict ? 1 : 300 );
return 'pending';
}
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
return 'pending';
}
if ( ! orderguard_validate_gdpr_erasure_ack_response( $result['data'], $entry ) ) {
orderguard_block_gdpr_erasure_protocol(
$fingerprint,
$entry,
'protocol_terminal_response',
'ack'
);
return 'failed';
}
if ( function_exists( 'orderguard_signal_outbox_erase_identity' )
&& ! orderguard_signal_outbox_erase_identity( array_merge( $entry['match_hashes'], $entry['flag_hashes'] ) ) ) {
// Retractions that may still be needed remain durable; a database
// failure or unfinished retraction keeps this erasure incomplete.
orderguard_schedule_gdpr_erasure_job( $fingerprint, 300 );
return 'pending';
}
if ( ! empty( $entry['native_request'] ) ) {
$receipt_reason = '';
if ( ! orderguard_store_gdpr_native_receipt( $fingerprint, $entry, $receipt_reason ) ) {
if ( 'native_receipt_unavailable' === $receipt_reason ) {
return orderguard_retry_gdpr_erasure_phase( $fingerprint, $entry, 300, false );
}
orderguard_block_gdpr_erasure_job(
$fingerprint,
$entry,
sanitize_key( $receipt_reason ),
'ack'
);
return 'failed';
}
}
if ( false === orderguard_gdpr_erasure_cache_epoch( true ) ) {
return orderguard_retry_gdpr_erasure_phase( $fingerprint, $entry, 300, false );
}
$completed = orderguard_complete_or_promote_gdpr_erasure( $fingerprint, $entry );
if ( 'complete' === $completed || 'promoted' === $completed ) {
return 'complete';
}
// Completion CAS conflicts belong to the freshly-read successor.
orderguard_defer_current_gdpr_erasure_job( $fingerprint );
return 'pending';
}
/** Clear only the exact generation/revision whose server call succeeded. */
function orderguard_clear_pending_gdpr_erasure( $fingerprint, $expected_entry ) {
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$expected_entry,
null,
$conflict
) ) {
return true;
}
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
return $pending_ok && $conflict && ! isset( $pending[ $fingerprint ] );
}
/**
* Clear an acknowledged operation or atomically promote its latest unsent
* merchant request. A concurrent re-submission can only enlarge the queue; it
* can never be erased by a worker holding an older copy of the shared map.
*/
function orderguard_complete_or_promote_gdpr_erasure( $fingerprint, $expected_entry ) {
$expected_token = isset( $expected_entry['request_token'] )
? (string) $expected_entry['request_token']
: '';
for ( $attempt = 0; $attempt < 10; ++$attempt ) {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return 'conflict';
}
if ( ! isset( $pending[ $fingerprint ] ) ) {
return 'complete';
}
$current = $pending[ $fingerprint ];
if ( ! hash_equals( $expected_token, $current['request_token'] ) ) {
// This exact generation was already cleared or promoted by a
// concurrent worker. Never mutate its successor.
return 'promoted';
}
if ( 'ack' !== $current['status'] ) {
return 'conflict';
}
if ( empty( $current['queued_request'] ) ) {
$replacement = null;
} else {
$replacement = orderguard_activate_gdpr_erasure_request(
$current['queued_request'],
(int) $current['revision'] + 1
);
if ( false === $replacement ) {
return 'conflict';
}
}
$conflict = false;
if ( orderguard_compare_and_swap_pending_gdpr_erasure(
$fingerprint,
$current,
$replacement,
$conflict
) ) {
if ( null !== $replacement ) {
orderguard_schedule_gdpr_erasure_job( $fingerprint, 1 );
return 'promoted';
}
return 'complete';
}
if ( ! $conflict ) {
return 'conflict';
}
}
return 'conflict';
}
function orderguard_pending_gdpr_erasure_order_refs( &$ok = null ) {
$refs = array();
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
$ok = false;
return array();
}
foreach ( $pending as $entry ) {
foreach ( array_merge( $entry['order_refs'], $entry['server_refs'] ) as $order_ref ) {
$refs[ (int) $order_ref ] = true;
}
}
$ok = true;
return $refs;
}
/**
* Exact order-reference guard, collected permanent -> pending -> permanent.
*
* The second permanent read closes the hand-off where a worker stores the
* keyed ref and clears pending state between this request's first two reads.
*/
function orderguard_is_gdpr_erased_order_id( $order_ref, $created_at = 0 ) {
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return true;
}
$pause_ok = false;
if ( orderguard_gdpr_erasure_pauses_all_identity_transports( null, $pause_ok ) || ! $pause_ok ) {
return true;
}
$storage_key = orderguard_gdpr_order_ref_suppression_storage_key( $order_ref );
if ( '' === $storage_key ) {
return false;
}
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
if ( ! $map_ok ) {
return true;
}
// An exact ref is not an identity-wide time boundary. It was either found
// locally before PREPARE or returned inside the server's frozen operation
// scope, then keyed and verified before COMMIT. Keep it suppressed even if
// the order is restored above a reset allocator, its date is edited, or
// WooCommerce cannot load it. The server tombstone uses the same permanent
// order-ref semantics.
if ( isset( $map[ $storage_key ] ) ) {
return true;
}
$pending_ok = false;
$pending_refs = orderguard_pending_gdpr_erasure_order_refs( $pending_ok );
if ( ! $pending_ok ) {
return true;
}
if ( isset( $pending_refs[ (int) $order_ref ] ) ) {
return true;
}
$map_ok = false;
$map = orderguard_gdpr_erasure_suppression_hash_map( true, $map_ok );
return ! $map_ok || isset( $map[ $storage_key ] );
}
/** A pending identity suppresses matching orders covered by its frozen cutoff. */
function orderguard_pending_gdpr_erasure_matches_order( $order ) {
if ( ! $order instanceof WC_Order ) {
return false;
}
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return true;
}
$order_hashes = array_fill_keys( orderguard_exclusion_order_hashes( $order ), true );
$order_id = (int) $order->get_id();
$created = $order->get_date_created();
$created_at = $created ? (int) $created->getTimestamp() : 0;
// This guard sits directly on outbound paths; refresh so a long-lived
// worker cannot reuse an empty map cached before another request erased.
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
if ( ! $pending_ok ) {
return true;
}
$pause_ok = false;
if ( orderguard_gdpr_erasure_pauses_all_identity_transports( $pending, $pause_ok ) || ! $pause_ok ) {
return true;
}
foreach ( $pending as $entry ) {
if ( in_array(
$order_id,
array_merge( $entry['order_refs'], $entry['server_refs'] ),
true
) ) {
return true;
}
$pending_hashes = $entry['match_hashes'];
if ( ! empty( $entry['queued_request'] ) ) {
$pending_hashes = array_merge(
$pending_hashes,
$entry['queued_request']['match_hashes']
);
}
foreach ( $pending_hashes as $hash ) {
if ( isset( $order_hashes[ $hash ] ) ) {
$phase = 'blocked' === $entry['status'] ? $entry['blocked_from'] : $entry['status'];
if ( in_array( $phase, array( 'commit', 'local_flags', 'ack' ), true )
&& ! orderguard_gdpr_erasure_boundary_covers(
$created_at,
$order_id,
$entry['suppression_cutoff'],
$entry['suppression_max_order_id']
) ) {
continue;
}
return true;
}
}
}
return false;
}
/** Display-safe aggregate for the Legal tab; exposes no identity or order IDs. */
function orderguard_pending_gdpr_erasure_summary() {
$pending_ok = false;
$pending = orderguard_pending_gdpr_erasures( true, $pending_ok );
$integrity_reason = '';
$integrity_ok = orderguard_gdpr_erasure_state_integrity( $integrity_reason );
$summary = array(
'jobs' => count( $pending ),
'queued' => 0,
'orders' => 0,
'blocked_order_ids' => array(),
'blocked_reasons' => array(),
'resumable_blocks' => 0,
'nonresumable_blocks' => 0,
'storage_mode_mismatches' => array(),
'oldest_at' => 0,
'updated_at' => 0,
'integrity_error' => $integrity_ok && $pending_ok ? '' : ( $integrity_reason ?: 'pending_invalid' ),
);
foreach ( $pending as $entry ) {
$summary['orders'] += count(
array_unique( array_merge( $entry['order_refs'], $entry['server_refs'] ) )
);
if ( ! empty( $entry['blocked_order_id'] ) ) {
$summary['blocked_order_ids'][] = (int) $entry['blocked_order_id'];
}
if ( 'blocked' === $entry['status'] && '' !== $entry['blocked_reason'] ) {
$summary['blocked_reasons'][] = $entry['blocked_reason'];
if ( orderguard_gdpr_erasure_block_is_resumable(
$entry['blocked_reason'],
$entry['blocked_from']
) ) {
++$summary['resumable_blocks'];
} else {
++$summary['nonresumable_blocks'];
}
}
if ( '' !== $entry['storage_mode_mismatch'] ) {
$summary['storage_mode_mismatches'][] = $entry['storage_mode_mismatch'];
}
if ( ! empty( $entry['queued_request'] ) ) {
++$summary['queued'];
++$summary['jobs'];
if ( 0 === $summary['oldest_at']
|| $entry['queued_request']['started_at'] < $summary['oldest_at'] ) {
$summary['oldest_at'] = $entry['queued_request']['started_at'];
}
}
if ( 0 === $summary['oldest_at'] || $entry['started_at'] < $summary['oldest_at'] ) {
$summary['oldest_at'] = $entry['started_at'];
}
$summary['updated_at'] = max( $summary['updated_at'], $entry['updated_at'] );
}
$summary['blocked_order_ids'] = array_values( array_unique( $summary['blocked_order_ids'] ) );
$summary['blocked_reasons'] = array_values( array_unique( $summary['blocked_reasons'] ) );
$summary['storage_mode_mismatches'] = array_values( array_unique( $summary['storage_mode_mismatches'] ) );
return $summary;
}
/**
* A successful merchant-side erasure leaves this local tombstone. The server
* cannot retain order references for the erased person, while the local sweep
* needs an order-scoped fact that tells every transport path to stand down.
*/
function orderguard_is_gdpr_erased_order( $order ) {
if ( ! $order instanceof WC_Order ) {
return false;
}
if ( '' !== (string) orderguard_get_order_meta( $order, '_orderguard_gdpr_erased_at' ) ) {
return true;
}
$created = $order->get_date_created();
$created_at = $created ? (int) $created->getTimestamp() : 1;
if ( orderguard_is_gdpr_erased_order_id( $order->get_id(), $created_at ) ) {
return true;
}
if ( orderguard_is_gdpr_erased_hash( orderguard_exclusion_order_hashes( $order ), $created_at, $order->get_id() ) ) {
return true;
}
// Hash matching above reads permanent suppression then pending state. This
// final fresh pending read also retains the fast order-ref path when a
// merchant edited the order's identity after the request.
return orderguard_pending_gdpr_erasure_matches_order( $order );
}
function orderguard_is_gdpr_erased_order_ref( $order_ref ) {
$integrity_reason = '';
if ( ! orderguard_gdpr_erasure_state_integrity( $integrity_reason ) ) {
return true;
}
$order_ref = trim( (string) $order_ref );
if ( '' === $order_ref || ! ctype_digit( $order_ref ) || (int) $order_ref <= 0 ) {
return false;
}
// Exact pending and permanent refs are authoritative even when WooCommerce
// is unavailable or cannot load an order restored after erasure.
if ( orderguard_is_gdpr_erased_order_id( (int) $order_ref ) ) {
return true;
}
if ( ! function_exists( 'wc_get_order' ) ) {
return false;
}
$order = wc_get_order( $order_ref );
if ( $order instanceof WC_Order ) {
return orderguard_is_gdpr_erased_order( $order );
}
return false;
}
/**
* Best-effort per-order marker work, capped so it cannot starve the durable
* erasure job. Exact keyed refs remain authoritative if any marker fails.
*/
function orderguard_mark_gdpr_erased_order_refs_bounded( $order_refs, $limit = 20 ) {
$refs = array_values( array_unique( array_filter( array_map( 'absint', (array) $order_refs ) ) ) );
if ( empty( $refs ) ) {
return true;
}
return orderguard_mark_gdpr_erased_order_refs(
array_slice( $refs, 0, max( 1, min( 20, (int) $limit ) ) )
);
}
/**
* Mark the locally matched orders protected before the server call. The
* server response is only cross-check evidence because server-unknown local
* orders are the exact population this tombstone exists to protect.
* Returns false when an existing order's fast-path marker could not be
* verified. Permanent identity and keyed-ref suppression remain the privacy
* guarantee.
*/
function orderguard_mark_gdpr_erased_order_refs( $order_refs ) {
if ( ! is_array( $order_refs ) || ! function_exists( 'wc_get_order' ) ) {
return false;
}
$ok = true;
foreach ( array_unique( array_map( 'strval', $order_refs ) ) as $order_ref ) {
if ( ! ctype_digit( $order_ref ) || (int) $order_ref <= 0 ) {
continue;
}
try {
$order = wc_get_order( (int) $order_ref );
} catch ( Throwable $throwable ) {
$ok = false;
orderguard_log( 'OrderGuard: could not load erased order #' . (int) $order_ref . ' for its fast-path marker; permanent identity suppression remains active.' );
continue;
}
if ( ! $order instanceof WC_Order ) {
continue;
}
try {
if ( '' === (string) orderguard_get_order_meta( $order, '_orderguard_gdpr_erased_at' ) ) {
$order->update_meta_data( '_orderguard_gdpr_erased_at', time() );
$order->save();
}
// WC data stores can discard a failed meta write without throwing.
// Force a fresh read and report a missing fast-path marker.
$verified = wc_get_order( (int) $order_ref );
if ( ! $verified instanceof WC_Order ) {
$ok = false;
orderguard_log( 'OrderGuard: could not re-read erased order #' . (int) $order_ref . ' after writing its fast-path marker; permanent identity suppression remains active.' );
continue;
}
$verified->read_meta_data( true );
if ( '' === (string) orderguard_get_order_meta( $verified, '_orderguard_gdpr_erased_at' ) ) {
$ok = false;
orderguard_log( 'OrderGuard: fast-path marker for erased order #' . (int) $order_ref . ' did not persist; permanent identity suppression remains active.' );
}
} catch ( Throwable $throwable ) {
$ok = false;
orderguard_log( 'OrderGuard: could not write the fast-path marker for erased order #' . (int) $order_ref . '; permanent identity suppression remains active -- ' . $throwable->getMessage() );
}
}
return $ok;
}
/**
* ===========================================================================
* COD ENFORCEMENT (Layer 1) -- server-side order rejection.
* ===========================================================================
* The un-bypassable half of COD gating: when a COD order is submitted by a
* customer the server says to hide COD for, we reject the order and ask them to
* pick another payment method. Works on classic AND block checkout, regardless
* of JavaScript or incognito, because it validates the ORDER (which always has
* the real billing details) at submission. The client-side hiding (Layer 2) is
* UX on top; THIS is the actual enforcement.
*
* OFF unless the merchant enabled gating (orderguard_cod_gating_enabled, default false).
*/
add_action( 'woocommerce_checkout_order_processed', 'orderguard_enforce_cod_on_order', 20, 1 );
add_action( 'woocommerce_store_api_checkout_order_processed', 'orderguard_enforce_cod_on_order', 20, 1 );
function orderguard_enforce_cod_on_order( $order_or_id ) {
// Normalise to an order object (classic passes an ID, blocks pass the order).
$order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( (int) $order_or_id );
if ( ! $order instanceof WC_Order ) {
return;
}
// M-5: the gate conditions (activation, gating enabled, COD order,
// identity present, server says 'hide') live in
// orderguard_checkout_rejection_reason() -- its cod_gate layer is this function's
// old body -- shared with the check-in pre-check so the two can never
// diverge. Anything but 'cod_gate' means this enforcer stands down
// (a manual-block reason throws at priority 15 before we run).
if ( 'cod_gate' !== orderguard_checkout_rejection_reason( $order ) ) {
return;
}
// Insights: count this interception, deduped per order (a resubmit of the
// same order shouldn't double-count). A transient is used rather than order
// meta because the throw below aborts the order write. Counts only.
$orderguard_blk_key = 'orderguard_ins_blk_' . $order->get_id();
if ( false === orderguard_get_transient( $orderguard_blk_key ) ) {
orderguard_insights_record( 'blocked', (float) $order->get_total() );
set_transient( $orderguard_blk_key, 1, DAY_IN_SECONDS );
}
orderguard_mark_checkout_rejected( $order );
// Reject: throw so WooCommerce aborts the order and shows the notice. The
// message is intentionally minimal / non-accusatory.
throw new Exception( esc_html__( 'Cash on delivery is not available for this order. Please choose another payment method.', 'orderguard' ) );
}
/**
* ===========================================================================
* COD HIDING (Layer 2, part 1) -- expose a hide-flag in the Store API cart data.
* ===========================================================================
* Block checkout is a React app that decides payment-method visibility client-
* side. The robust pattern: the SERVER computes the hide/keep decision and
* exposes it as a flag in the cart response; the client JS (part 2) reads that
* flag in a canMakePayment callback and hides COD. This keeps risk data on the
* server -- only a boolean and an already-translated notice string cross to
* the browser (never a score, count, block level or identity) -- and avoids
* doing a live server call inside the (frequently re-invoked) JS callback.
*
* The decision recomputes whenever the cart data refreshes, which block checkout
* does as the customer edits their address -- exactly when we want to re-check.
*
* Fail-open: if no COD-hiding feature is on (auto gating OR manual blocks,
* #53), or we can't identify the customer yet, the flag is false (COD shown).
* Layer 1 still enforces at submission regardless.
*/
add_action( 'woocommerce_blocks_loaded', 'orderguard_register_cod_cart_flag' );
function orderguard_register_cod_cart_flag() {
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
return;
}
woocommerce_store_api_register_endpoint_data(
array(
'endpoint' => 'cart',
'namespace' => 'orderguard',
'data_callback' => 'orderguard_cod_cart_flag_data',
'schema_callback' => 'orderguard_cod_cart_flag_schema',
'schema_type' => ARRAY_A,
)
);
}
/**
* The data exposed under cart.extensions.orderguard in the Store API response.
*/
function orderguard_cod_cart_flag_data() {
return array(
'hide_cod' => orderguard_should_hide_cod_for_session(),
'cod_notice' => orderguard_cod_notice_for_session(),
);
}
function orderguard_cod_cart_flag_schema() {
return array(
'hide_cod' => array(
'description' => __( 'Whether COD should be hidden for this customer.', 'orderguard' ),
'type' => 'boolean',
'readonly' => true,
),
'cod_notice' => array(
'description' => __( 'Translated notice explaining why COD is unavailable; empty when there is nothing to show.', 'orderguard' ),
'type' => 'string',
'readonly' => true,
),
);
}
/**
* Decide hide/keep for the CURRENT session customer (cart context -- there is no
* $_POST here). Reads identity from WC()->customer, which block checkout keeps
* updated as the shopper edits their address.
*
* #53: carries BOTH verdicts INDEPENDENTLY, mirroring the two classic gateway
* filters -- the manual one (orderguard_manual_block_hide_cod: 'block_cod' or
* 'hard_block' hides, gated on orderguard_manual_flag_enabled) and the auto one
* (orderguard_maybe_hide_cod: server risk verdict, gated on orderguard_cod_gating_enabled).
* Either alone hides. Auto-gating ships OFF by default, so a manual-only store
* must never depend on it. Fail open on empty identity or any doubt.
*/
function orderguard_should_hide_cod_for_session() {
if ( ! orderguard_is_activated() ) {
return false;
}
if ( ! function_exists( 'WC' ) || ! WC()->customer ) {
return false;
}
$email = (string) WC()->customer->get_billing_email();
$phone = (string) WC()->customer->get_billing_phone();
if ( '' === $email && '' === $phone ) {
return false; // don't know who they are yet -> show COD (fail open)
}
$identity = array( 'phone' => $phone, 'email' => $email );
// Manual path: local flags table, spelling variants handled inside the
// level lookup. Same levels, same semantics as orderguard_manual_block_hide_cod.
if ( orderguard_manual_flag_enabled() ) {
$level = orderguard_manual_block_level_for_identity( $identity );
if ( 'block_cod' === $level || 'hard_block' === $level ) {
return true;
}
}
// Auto path: the server's COD risk verdict, only while gating is on.
if ( orderguard_cod_gating_enabled() && 'hide' === orderguard_cod_risk_decision( $identity ) ) {
return true;
}
return false;
}
/**
* #53 -- block-checkout equivalent of orderguard_cod_blocked_notice(): the translated
* sentence explaining WHY cash on delivery is missing, or '' when there is
* nothing to say. Travels inside cart.extensions.orderguard and is rendered by
* orderguard-cod-blocks.js next to the payment methods; only this already-translated
* string crosses to the browser -- never a level, score, count or identity.
*
* Non-empty for 'block_cod' ONLY, never 'hard_block' -- a hard-blocked shopper
* cannot complete the order by ANY method, so "please choose another payment
* method" would be wrong advice; they are stopped at submission with the
* correct message instead. Same rule and same guards as the classic notice,
* including "only speak up if this store actually offers COD".
*
* Reuses the exact sentence the classic notice and the manual-block submission
* enforcement already throw, so the shopper reads one consistent message and
* it is already in the BG translation map.
*
* Like the classic notice, this deliberately RECOMPUTES the level (one local
* indexed lookup; no server call). Fail open: any doubt -> '' (say nothing);
* the hide flag and Layer 1 enforcement do not depend on this string.
*/
function orderguard_cod_notice_for_session() {
if ( ! orderguard_is_activated() || ! orderguard_manual_flag_enabled() ) {
return '';
}
if ( ! function_exists( 'WC' ) || ! WC()->customer ) {
return '';
}
// Only speak up if this store actually offers COD, otherwise the sentence
// is nonsense on a store that never had it (same guard as classic).
$gw = WC()->payment_gateways();
if ( ! $gw || ! method_exists( $gw, 'payment_gateways' ) ) {
return '';
}
$all = $gw->payment_gateways();
if ( ! isset( $all['cod'] ) || ! is_object( $all['cod'] ) || 'yes' !== $all['cod']->enabled ) {
return '';
}
$email = (string) WC()->customer->get_billing_email();
$phone = (string) WC()->customer->get_billing_phone();
if ( '' === $email && '' === $phone ) {
return ''; // unknown shopper -> say nothing (fail open)
}
$identity = array( 'phone' => $phone, 'email' => $email );
if ( 'block_cod' !== orderguard_manual_block_level_for_identity( $identity ) ) {
return '';
}
return __( "Cash on delivery isn't available for this order. Please choose another payment method.", 'orderguard' );
}
/**
* COD HIDING (Layer 2, part 2) -- enqueue the block-checkout JS that reads the
* cart flag and hides COD in the UI. Loaded on the frontend where the blocks
* registry is available. The JS fails open (shows COD) on any problem; Layer 1
* still enforces at submission regardless.
*/
add_action( 'wp_enqueue_scripts', 'orderguard_enqueue_cod_blocks_js' );
function orderguard_enqueue_cod_blocks_js() {
// Only bother when a feature that can hide COD is active: auto gating OR
// manual blocks (#53 -- the cart flag carries both verdicts).
if ( ! orderguard_is_activated() || ( ! orderguard_cod_gating_enabled() && ! orderguard_manual_flag_enabled() ) ) {
return;
}
// NOTE: we deliberately do NOT gate on is_checkout() -- it is unreliable on
// block checkout. The script is tiny and does nothing unless the blocks
// registry is present (it fails open otherwise).
$path = plugin_dir_path( __FILE__ ) . 'assets/js/orderguard-cod-blocks.js';
$url = plugin_dir_url( __FILE__ ) . 'assets/js/orderguard-cod-blocks.js';
$ver = file_exists( $path ) ? (string) filemtime( $path ) : '1';
// Use wc-blocks-registry as a dependency ONLY if it is registered; a missing
// dependency would silently block the script from loading.
$deps = array();
if ( wp_script_is( 'wc-blocks-registry', 'registered' ) ) {
$deps[] = 'wc-blocks-registry';
}
wp_enqueue_script(
'orderguard-cod-blocks',
$url,
$deps,
$ver,
true
);
}
/**
* #43 -- CLASSIC checkout: ask WooCommerce to re-render the payment methods once
* the shopper has actually entered contact details.
*
* Woo re-renders on address/country/postcode/shipping changes only; it never
* does so for billing phone or email. Combined with orderguard_get_checkout_identity()
* now unpacking post_data, this is what makes COD disappear for a blocked guest
* BEFORE they reach the payment step instead of at the final click.
*
* Loaded only when a feature that depends on knowing who the shopper is can
* actually act, so a store using neither gets no extra script. Block checkout
* needs none of this -- it keeps WC()->customer current by itself.
*/
add_action( 'wp_enqueue_scripts', 'orderguard_enqueue_checkout_refresh_js' );
function orderguard_enqueue_checkout_refresh_js() {
$manual = function_exists( 'orderguard_manual_flag_enabled' ) && orderguard_manual_flag_enabled();
$auto = orderguard_is_activated() && orderguard_cod_gating_enabled();
if ( ! $manual && ! $auto ) {
return;
}
// As with orderguard_enqueue_cod_blocks_js above, deliberately NOT gated on
// is_checkout() -- it is unreliable on some themes and page builders. The
// script returns immediately when the classic contact fields are absent.
$path = plugin_dir_path( __FILE__ ) . 'assets/js/orderguard-checkout-refresh.js';
$url = plugin_dir_url( __FILE__ ) . 'assets/js/orderguard-checkout-refresh.js';
$ver = file_exists( $path ) ? (string) filemtime( $path ) : '1';
wp_enqueue_script( 'orderguard-checkout-refresh', $url, array( 'jquery' ), $ver, true );
}
/**
* ===========================================================================
* RECONCILIATION SWEEP -- drift safety-net (Piece 2).
* ===========================================================================
*/
/**
* ---------------------------------------------------------------------------
* UNCHANGED-FACTS SKIP (0.1.8) -- the sweep only re-sends what changed.
* ---------------------------------------------------------------------------
* Before this existed, orderguard_reconcile_order() re-sent every order's full fact
* set on every hourly sweep, forever, unchanged or not: ~100 orders x ~2-3
* events x 24 sweeps = thousands of identical events per store per day, all
* of which the server had to receive, arbitrate and dedupe into no-ops.
*
* THE RULE: hash the EXACT event payload list the sweep is about to send and
* skip the send when the same payload list was already CONFIRMED delivered.
*
* Design points, each load-bearing:
*
* - WHAT IS HASHED: wp_json_encode( $events ) -- the byte-exact list of
* event bodies built above the send loop. Not a hand-picked field list:
* hashing the payload itself means every fact the server acts on (status,
* refund fraction + its cod flag, the not-collected mark AND its undo,
* courier outcome/courier/source_kind) is covered BY CONSTRUCTION, and a
* future field added to the events is covered automatically -- its first
* appearance changes the hash, which forces one honest re-send. A missed
* field here would be silent data loss, the exact disease this plugin
* keeps catching in itself; hashing the wire payload makes that class
* structurally impossible.
*
* - CONFIRMED means confirmed: the hash is persisted ONLY after every event
* in the set came back 2xx in THIS run. Any wp_error, timeout, 4xx or 5xx
* leaves the meta untouched, so the next sweep retries the whole set (the
* server dedupes the parts that did land). The 404-recovery path never
* persists either -- it re-sends through orderguard_send_event(), whose return
* value cannot distinguish "delivered" from "retry scheduled", so the
* recovered order stays eligible and the NEXT sweep's confirmed pass is
* what marks it sent. One extra hour of redundancy, zero chance of
* marking an undelivered fact as delivered.
*
* - FIRST EMISSION IS NEVER SKIPPED: an order with no stored hash (every
* order, the first time; every pre-0.1.8 order once) never matches.
*
* - THE SKIP EXPIRES (ORDERGUARD_SWEEP_REEMIT_DAYS): a confirmed hash older than
* this re-sends anyway. This is the honest answer to the one failure the
* plugin cannot see from inside: a server restored from backup has
* forgotten events the plugin correctly marked delivered. Orders the
* restore reverted to 'pending' are caught within the hour by sweep
* phase 1; orders it reverted to an OLDER resolved state are invisible
* to every plugin-side signal, so the periodic re-emit is the ceiling on
* that staleness: at most N days, instead of forever. (Disaster lever:
* deleting the _orderguard_sweep_fact_sent_at meta re-arms every order at once.)
*
* - CLOCK HONESTY: a sent_at in the future (clock stepped back) reads as
* "not confirmed" and re-sends -- the redundant direction, never the
* silent one.
*
* What this deliberately does NOT do: suppress the 404-and-no-signal orders
* (they were never confirmed, so they keep knocking once per sweep -- cheap,
* and marking a rejected send as "sent" would be a lie); touch the live event
* handlers (they still send immediately on real changes -- the sweep then
* re-sends that changed set once more and settles, which is one redundant
* event per real change, accepted for having exactly one writer of the mark).
*/
if ( ! defined( 'ORDERGUARD_SWEEP_REEMIT_DAYS' ) ) { define( 'ORDERGUARD_SWEEP_REEMIT_DAYS', 7 ); }
if ( ! defined( 'ORDERGUARD_REPAIR_CANDIDATE_RETENTION_DAYS' ) ) { define( 'ORDERGUARD_REPAIR_CANDIDATE_RETENTION_DAYS', 730 ); }
/**
* The fact-set fingerprint: sha256 over the exact JSON the send loop emits.
* 'v1:' prefixed so any future change to the hashing scheme un-matches every
* stored value and forces one clean re-send instead of comparing apples to
* old oranges.
*/
function orderguard_sweep_fact_hash( $events ) {
$json = wp_json_encode( $events );
if ( ! is_string( $json ) || '' === $json ) {
// Encoding failed (cannot happen for these scalar payloads, but if it
// ever does): fail OPEN, toward sending. A constant fallback hash
// would make every unencodable fact-set "equal" and let the skip
// suppress real changes -- the one direction this feature must never
// fail in.
return 'v1:unencodable-' . microtime( true ) . '-' . wp_rand();
}
return 'v1:' . hash( 'sha256', $json );
}
/**
* TRUE when this exact fact-set was already confirmed sent, recently enough
* that the periodic forced re-emit is not yet due.
*/
function orderguard_sweep_facts_already_sent( $order, $fact_hash ) {
if ( (string) orderguard_get_order_meta( $order, '_orderguard_sweep_fact_hash' ) !== (string) $fact_hash ) {
return false; // first emission, or some fact changed -> send
}
$sent_at = (int) orderguard_get_order_meta( $order, '_orderguard_sweep_fact_sent_at' );
if ( $sent_at <= 0 ) {
return false; // hash without a timestamp -- distrust it, send
}
$age = time() - $sent_at;
if ( $age < 0 ) {
return false; // stamped in the future (clock change) -- distrust it, send
}
return $age < ORDERGUARD_SWEEP_REEMIT_DAYS * DAY_IN_SECONDS;
}
/**
* Record "this fact-set is on the server" -- call ONLY after every event of
* the set was 2xx-acknowledged in this run. A failed or unverifiable send
* must leave the order eligible, or the sweep quietly stops being a safety
* net while the logs stay plausible.
*/
function orderguard_sweep_mark_facts_sent( $order, $fact_hash ) {
$order->update_meta_data( '_orderguard_sweep_fact_hash', (string) $fact_hash );
$order->update_meta_data( '_orderguard_sweep_fact_sent_at', time() );
$order->save();
}
/**
* Return usable primary identity field names for a check request. Values and
* hashes never leave this helper. Name is deliberately not a primary field.
*/
function orderguard_usable_primary_identity_fields( $order, $hashed = null ) {
if ( ! $order instanceof WC_Order ) {
return array();
}
if ( ! is_array( $hashed ) ) {
$hashed = orderguard_hash_identity( orderguard_read_identity_from_order( $order ) );
}
$fields = array();
if ( ! empty( $hashed['phone'] ) ) {
$fields[] = 'phone';
}
$e164_hash = orderguard_phone_hash_e164(
$order->get_billing_phone(),
$order->get_billing_country()
);
if ( '' !== $e164_hash && ( empty( $hashed['phone'] ) || $e164_hash !== $hashed['phone'] ) ) {
$fields[] = 'phone_e164';
}
if ( ! empty( $hashed['email'] ) ) {
$fields[] = 'email';
}
return $fields;
}
/** Exact local signature of the identity-less check defect; candidate only. */
function orderguard_poisoned_snapshot_signature( $order ) {
if ( ! $order instanceof WC_Order
|| 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_snapshot_locked' )
|| '' === (string) orderguard_get_order_meta( $order, '_orderguard_checked_at' )
|| 'none' !== (string) orderguard_get_order_meta( $order, '_orderguard_confidence' )
|| 'yes' !== (string) orderguard_get_order_meta( $order, '_orderguard_counts_withheld' ) ) {
return array();
}
$keys = array(
'_orderguard_collection_risk',
'_orderguard_return_risk',
'_orderguard_order_count',
'_orderguard_months_active',
'_orderguard_kept_count',
'_orderguard_returned_count',
'_orderguard_not_collected_count',
'_orderguard_seen_count',
'_orderguard_unresolved_count',
'_orderguard_own_order_count',
'_orderguard_own_months_active',
'_orderguard_own_kept_count',
'_orderguard_own_returned_count',
'_orderguard_own_not_collected_count',
);
$signature = array();
foreach ( $keys as $key ) {
$value = (int) orderguard_get_order_meta( $order, $key );
if ( 0 !== $value ) {
return array();
}
$signature[ $key ] = $value;
}
$signature['_orderguard_confidence'] = 'none';
$signature['_orderguard_counts_withheld'] = 'yes';
$signature['_orderguard_snapshot_locked'] = 'yes';
return $signature;
}
/**
* Preserve the fleeting 404 evidence for a later supervised repair decision.
* This marker is not proof and nothing in this release consumes it.
*/
function orderguard_maybe_mark_repair_candidate( $order, $usable_fields ) {
$existing_marker = $order instanceof WC_Order
? orderguard_get_order_meta( $order, '_orderguard_repair_candidate' )
: null;
if ( ! $order instanceof WC_Order
|| empty( $usable_fields )
|| orderguard_is_gdpr_erased_order( $order )
|| ! empty( $existing_marker ) ) {
return false;
}
$created = $order->get_date_created();
if ( ! $created ) {
return false;
}
$age = time() - (int) $created->getTimestamp();
if ( $age < 0 || $age > ORDERGUARD_REPAIR_CANDIDATE_RETENTION_DAYS * DAY_IN_SECONDS ) {
return false;
}
$signature = orderguard_poisoned_snapshot_signature( $order );
if ( empty( $signature ) ) {
return false;
}
$order->update_meta_data(
'_orderguard_repair_candidate',
array(
'seen_at' => time(),
'checked_at' => (string) orderguard_get_order_meta( $order, '_orderguard_checked_at' ),
'signature_hash' => hash( 'sha256', wp_json_encode( $signature ) ),
'usable_fields' => array_values( $usable_fields ),
'reason' => 'server_missing_stamped_zero_snapshot',
'plugin_version' => ORDERGUARD_VERSION,
)
);
$order->save();
return true;
}
/**
* Build the complete, reproducible fact set for one order.
*
* Both the hourly reconciliation sweep and the durable privacy-pause replay
* use this helper. Keeping one builder prevents a deferred run from omitting a
* refund, COD reversal, or courier correction that the normal sweep sends.
*/
function orderguard_reconciliation_events( $order ) {
if ( ! $order instanceof WC_Order ) {
return array();
}
$order_id = $order->get_id();
$events = array(
array(
'order_ref' => (string) $order_id,
'event' => 'status_changed',
'status' => (string) $order->get_status(),
),
);
if ( (float) $order->get_total_refunded() > 0 ) {
$events[] = array(
'order_ref' => (string) $order_id,
'event' => 'refund',
'cumulative_fraction' => round( orderguard_compute_return_fraction( $order ), 4 ),
'cod' => function_exists( 'orderguard_is_cod_order' ) ? (bool) orderguard_is_cod_order( $order ) : null,
);
}
$cod = orderguard_get_order_meta( $order, '_orderguard_not_collected' );
if ( 'yes' === $cod ) {
$events[] = array(
'order_ref' => (string) $order_id,
'event' => 'cod_non_collection_confirmed',
);
} elseif ( 'no' === $cod ) {
$events[] = array(
'order_ref' => (string) $order_id,
'event' => 'cod_non_collection_reverted',
);
}
$courier_outcomes = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' );
if ( function_exists( 'orderguard_courier_sync_enabled' )
&& orderguard_courier_sync_enabled()
&& is_array( $courier_outcomes ) ) {
$courier_provenance = orderguard_get_order_meta_map( $order, '_orderguard_courier_provenance' );
if ( ! is_array( $courier_provenance ) ) {
$courier_provenance = array();
}
foreach ( $courier_outcomes as $courier_source => $courier_outcome ) {
if ( ! in_array( $courier_outcome, array( 'kept', 'returned', 'not_collected' ), true ) ) {
continue;
}
$courier_event = array(
'order_ref' => (string) $order_id,
'event' => 'carrier_reported',
'outcome' => (string) $courier_outcome,
'courier' => (string) $courier_source,
);
if ( isset( $courier_provenance[ $courier_source ]['kind'] )
&& '' !== $courier_provenance[ $courier_source ]['kind'] ) {
$kind = (string) $courier_provenance[ $courier_source ]['kind'];
} else {
$kind = function_exists( 'orderguard_courier_source_kind' )
? orderguard_courier_source_kind( (string) $courier_source )
: 'status_map';
}
if ( 'carrier' !== $kind ) {
$courier_event['source_kind'] = $kind;
}
$events[] = $courier_event;
}
}
return $events;
}
/**
* Reconcile ONE order: work out its true state and re-report it. Re-checks
* first if the server returns 404 ("order unknown"). Since 0.1.8 it skips
* the send entirely when the exact same fact-set was already confirmed
* delivered (see the UNCHANGED-FACTS SKIP section above) -- outcome CHANGES
* still go out on the next sweep after they happen, and every fact-set is
* force-re-sent every ORDERGUARD_SWEEP_REEMIT_DAYS as the backup-restore safety net.
*/
function orderguard_reconcile_order( $order ) {
// Guard: wc_get_orders() can return refund objects. Only reconcile real
// orders (refunds lack get_total_refunded() etc.).
if ( ! $order instanceof WC_Order ) {
return;
}
// Second line of defence against the sweep/import race (see
// orderguard_import_in_progress): per-order actions already queued before the import
// started must also stand down, or they would 404 -> re-check -> create live
// rows for orders the import is about to write.
if ( function_exists( 'orderguard_import_in_progress' ) && orderguard_import_in_progress() ) {
return;
}
// Read once for this order-level batch. Unknown and excluded both stand down
// without writing the fact hash, so recovery or later un-exclusion leaves
// every fact eligible. Each transport still re-checks inside its lock.
if ( 'not_excluded' !== ( orderguard_owned_exclusion_order_state( $order )['status'] ?? 'unknown' ) ) {
return;
}
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( 'suppress' === $privacy_disposition ) {
return;
}
if ( 'allow' !== $privacy_disposition ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), 1 );
return;
}
$order_id = $order->get_id();
// Build ALL applicable facts for this order -- do NOT pick one. The same
// helper is used by durable privacy-pause replay so the two paths cannot
// drift apart.
$events = orderguard_reconciliation_events( $order );
// UNCHANGED-FACTS SKIP (0.1.8, see the section above orderguard_reconcile_order):
// if this exact fact-set was already confirmed delivered, and the periodic
// forced re-emit isn't due, there is nothing to say -- don't say it. The
// hash covers the whole $events payload, so ANY change to ANY fact above
// (status, refund fraction, cod flag, not-collected mark or its undo,
// courier outcome/courier/kind, courier-sync consent flipping the carrier
// events in or out) un-matches it and the order re-sends on this sweep.
$fact_hash = orderguard_sweep_fact_hash( $events );
if ( orderguard_sweep_facts_already_sent( $order, $fact_hash ) ) {
return;
}
// Send them. If any send reveals the server doesn't know this order (404),
// re-check ONCE to create the sightings, then re-send ALL facts.
// Capture the freshest scores as we go so a NEVER-CHECKED order (missed at
// creation, or 404-recovered here) can get its first snapshot -- otherwise
// it stays "not checked" in the panel even though the server now has it.
// For an order that already has a snapshot these captured scores go
// nowhere: orderguard_save_scores() is write-once (H-1), and the hourly sweep must
// never rewrite the check-time record it used to overwrite.
$latest_scores = null;
$needs_recheck = false;
$all_confirmed = true; // stays true only if EVERY event is 2xx-acknowledged below
foreach ( $events as $event ) {
$result = orderguard_send_event_with_code( $event );
if ( 404 === $result['code'] ) {
$needs_recheck = true;
break;
}
if ( $result['code'] < 200 || $result['code'] >= 300 ) {
// wp_error (0), 5xx, or a non-404 4xx: this fact-set is NOT on the
// server. Keep sending the rest (unchanged behaviour -- the server
// dedupes whatever lands twice) but never mark the set sent, so
// the next sweep retries the whole thing.
$all_confirmed = false;
}
if ( is_array( $result['data'] ) && isset( $result['data']['scores'] ) ) {
// TASK 49: carry own_history too, or a first snapshot taken here
// freezes the store's own counts at 0. See orderguard_with_own_history().
$latest_scores = orderguard_with_own_history( $result['data']['scores'], $result['data'] );
}
}
if ( $needs_recheck ) {
// The recovery path below re-sends through orderguard_send_event(), whose
// return value cannot distinguish "delivered" from "retry scheduled"
// -- so nothing in this branch can honestly claim confirmation. The
// order stays eligible; the next sweep's normal path (now that the
// server knows the order) is what confirms and marks it.
$all_confirmed = false;
// M-5: never "recover" an order that would only pollute. Two shapes:
// - an order OrderGuard itself rejected at checkout, still in its
// aborted form (marked; the check-in was deliberately withheld);
// - any order whose ONLY fact is a no-signal status (unmarked --
// e.g. aborted before this fix, or cancelled while the server was
// down). Re-checking those creates sightings that can never
// resolve: they sit in /v1/pending forever (or settle to
// 'unknown' junk) and charge the new-identity velocity budget for
// an order that never happened. The moment such an order gains a
// real fact (refund, COD mark, courier outcome, a live status),
// both tests go false and recovery proceeds normally.
if ( orderguard_is_checkout_rejected_dormant( $order ) || orderguard_order_is_signalless_dead( $order ) ) {
orderguard_log( 'OrderGuard: sweep -- order #' . $order_id . ' unknown to server and carries no signal (aborted/dead checkout); NOT creating sightings for it' );
return;
}
$identity = orderguard_read_identity_from_order( $order );
$hashed = orderguard_hash_identity( $identity );
$usable_fields = orderguard_usable_primary_identity_fields( $order, $hashed );
if ( empty( $usable_fields ) ) {
orderguard_log( 'OrderGuard: sweep -- order #' . $order_id . ' unknown to server but has no usable phone/email identity; NOT sending a check the server will refuse' );
return;
}
orderguard_maybe_mark_repair_candidate( $order, $usable_fields );
orderguard_log( 'OrderGuard: sweep -- order #' . $order_id . ' unknown to server; re-checking then re-sending all facts' );
$check_scores = orderguard_send_to_server( $order, $hashed ); // creates pending sightings
if ( is_array( $check_scores ) ) {
$latest_scores = $check_scores;
}
foreach ( $events as $event ) {
$data = orderguard_send_event( $event );
if ( is_array( $data ) && isset( $data['scores'] ) ) {
// TASK 49: this loop runs AFTER the recovery re-check above, so
// it overwrites the own_history that check captured. Carry it.
$latest_scores = orderguard_with_own_history( $data['scores'], $data );
}
}
}
// Persist the fact-hash ONLY on full confirmation (every event 2xx in
// this very run). This is the send-failure retry path in one line: a
// partial or unverifiable delivery writes nothing, so the order simply
// stays eligible and the next sweep re-sends the whole set.
if ( $all_confirmed ) {
orderguard_sweep_mark_facts_sent( $order, $fact_hash );
}
// Fill the snapshot ONLY if this order never got one (orderguard_save_scores is
// write-once) -- the recovery case this tail exists for. For an
// already-checked order this is a no-op by design: routine sweep passes
// report outcomes above, they do not rewrite the check-time record (H-1).
if ( is_array( $latest_scores ) ) {
orderguard_save_scores( $order, $latest_scores );
}
}
/**
* Like orderguard_send_event() but returns the HTTP code (so the sweep can detect 404).
*/
function orderguard_send_event_with_code( $event ) {
$order_id = isset( $event['order_ref'] ) ? absint( $event['order_ref'] ) : 0;
$privacy_lock = false;
if ( $order_id > 0 ) {
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $order_id );
if ( 'suppress' === $disposition ) {
return array( 'code' => 200, 'data' => null );
}
if ( 'allow' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
return array( 'code' => 0, 'data' => null );
}
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
return array( 'code' => 0, 'data' => null );
}
$disposition = orderguard_gdpr_transport_disposition_for_order_ref( $order_id );
if ( 'allow' !== $disposition ) {
if ( 'suppress' !== $disposition ) {
orderguard_schedule_gdpr_deferred_order( $order_id, 1 );
}
orderguard_release_gdpr_erasure_lifecycle_lock();
return array(
'code' => 'suppress' === $disposition ? 200 : 0,
'data' => null,
);
}
}
try {
// A local refusal is not a delivery. Code zero prevents the sweep from
// persisting its delivered fact hash; the next ordinary sweep may try
// again after an unreadable source recovers or an exclusion is removed.
if ( $order_id > 0 ) {
$exclusion = orderguard_owned_exclusion_order_ref_state( $order_id );
if ( 'not_excluded' !== ( $exclusion['status'] ?? 'unknown' ) ) {
return array(
'code' => 0,
'data' => null,
'exclusion' => (string) ( $exclusion['status'] ?? 'unknown' ),
);
}
}
$response = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/events',
array(
'method' => 'POST',
'timeout' => 5,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( $event ),
)
);
if ( is_wp_error( $response ) ) {
orderguard_schedule_event_retry( $event, 1 );
return array( 'code' => 0, 'data' => null );
}
// Unlike orderguard_send_event(), this sweep helper does not create a
// second 5xx retry chain; the recurring sweep is already its retry.
$code = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
return array( 'code' => $code, 'data' => is_array( $data ) ? $data : null );
} finally {
if ( $privacy_lock ) {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
}
/**
* The sweep: Phase 1 (pending list) + Phase 2 (invisible-order scan).
* Idempotent -- safe to run repeatedly.
*/
/**
* Is the one-time history import still running?
*
* The reconciliation sweep MUST stand down while it is. Otherwise the sweep
* sends events for orders the import has not reached yet, receives a legitimate
* 404 ("order unknown"), and "recovers" the order with a live /v1/check -- which
* creates LIVE sightings for a historical order. Minutes later the import writes
* its own rows for that same order, so one order ends up recorded twice (in the
* store's collapsed import bucket AND under its own live key). That inflates the
* customer's history, defeats the bounded-import cap, and burns both the usage
* meter and the new-identity daily budget. The bigger the store, the longer the
* import, the worse the race.
*/
function orderguard_import_in_progress() {
$state = orderguard_read_import_state_row( $raw, $found, $state_ok );
if ( ! $state_ok ) {
return true;
}
if ( ! $found ) {
// A fleet build may run before stored-data migration has copied the
// legacy row. The compatibility accessor is the only bounded fallback
// here; uncertainty or malformed legacy state blocks the sweep.
$state = orderguard_get_option( 'orderguard_import_state', false, $fallback_ok );
if ( ! $fallback_ok ) {
return true;
}
if ( false === $state ) {
return false;
}
}
if ( ! is_array( $state ) || ! isset( $state['status'] ) ) {
return true;
}
if ( 'keyset-v1' === ( isset( $state['pagination'] ) ? $state['pagination'] : '' ) ) {
orderguard_normalize_keyset_import_state( $state, $keyset_ok );
if ( ! $keyset_ok ) {
return true;
}
}
if ( 'done' === $state['status'] ) {
return false;
}
// Keep reconciliation stopped for every unfinished import state, including
// an exclusion-source pause. Letting it wake at source recovery can race
// the retained import page and create the same historical order twice.
return true;
}
function orderguard_run_reconciliation_sweep() {
if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) {
orderguard_owned_exclusion_reset_request_snapshot();
}
if ( ! orderguard_is_activated() ) {
return;
}
// Never sweep while the history import is still in flight -- see
// orderguard_import_in_progress(). The next scheduled sweep picks things up once the
// import has finished.
if ( orderguard_import_in_progress() ) {
orderguard_log( 'OrderGuard: reconciliation sweep skipped -- history import still running' );
return;
}
orderguard_log( 'OrderGuard: reconciliation sweep starting' );
// --- Phase 1: reconcile orders the server still has as 'pending' ---
$response = wp_remote_get(
ORDERGUARD_SERVER_URL . '/v1/pending',
array(
'timeout' => 10,
'headers' => array( 'X-OrderGuard-Key' => orderguard_get_api_key() ),
)
);
if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$refs = ( is_array( $data ) && isset( $data['pending_order_refs'] ) ) ? $data['pending_order_refs'] : array();
orderguard_log( 'OrderGuard: sweep phase 1 -- dispatching ' . count( $refs ) . ' pending order(s)' );
foreach ( $refs as $ref ) {
orderguard_enqueue_order_reconcile( (int) $ref );
}
} else {
orderguard_log( 'OrderGuard: sweep phase 1 -- could not fetch pending list' );
}
// --- Phase 2: invisible-order scan (recent resolved orders) ---
$recent = wc_get_orders( array(
'limit' => 100,
'orderby' => 'date',
'order' => 'DESC',
'status' => array( 'wc-completed', 'wc-refunded', 'wc-cancelled' ),
'date_created' => '>' . ( time() - 60 * DAY_IN_SECONDS ),
'return' => 'objects',
) );
orderguard_log( 'OrderGuard: sweep phase 2 -- dispatching ' . count( $recent ) . ' recent resolved order(s)' );
foreach ( $recent as $order ) {
if ( $order instanceof WC_Order ) {
orderguard_enqueue_order_reconcile( $order->get_id() );
}
}
orderguard_log( 'OrderGuard: reconciliation sweep dispatch complete' );
}
/**
* Enqueue a single-order reconciliation as its own async Action Scheduler
* action. Deduped by args so the same order isn't queued twice in one sweep.
*
* @param int $order_id
*/
function orderguard_enqueue_order_reconcile( $order_id ) {
if ( ! function_exists( 'as_enqueue_async_action' ) ) {
// Action Scheduler unavailable -- fall back to inline (small stores/dev).
$order = wc_get_order( (int) $order_id );
if ( $order ) {
orderguard_reconcile_order( $order );
}
return;
}
// as_enqueue_async_action with a unique=true guard avoids duplicate queued
// actions for the same order within a sweep.
as_enqueue_async_action(
'orderguard_reconcile_single',
array( 'order_id' => (int) $order_id ),
'orderguard'
);
}
/**
* Per-order reconciliation, run as its own Action Scheduler action.
*
* The sweep dispatches ONE of these per order (as_enqueue_async_action) instead
* of reconciling all orders inline -- so no single action risks Action
* Scheduler's ~30s runtime limit on large stores.
*
* @param int $order_id
*/
add_action( 'orderguard_reconcile_single', 'orderguard_run_single_order_reconcile', 10, 1 );
function orderguard_run_single_order_reconcile( $order_id ) {
if ( ! orderguard_is_activated() ) {
return;
}
$order = wc_get_order( (int) $order_id );
if ( $order ) {
orderguard_reconcile_order( $order );
}
}
/**
* Schedule the recurring sweep (hourly -- see ORDERGUARD_SWEEP_INTERVAL below) via
* Action Scheduler.
*/
add_action( 'init', 'orderguard_maybe_schedule_sweep' );
/**
* How often the reconciliation sweep runs. Hourly, so orders created while the
* server was briefly unreachable check themselves in within the hour instead of
* waiting up to a day. The sweep is idempotent and bounded (Phase 2 re-checks
* at most ~100 recent orders), so hourly is cheap. Tunable via the filter.
*
* NOTE: bump ORDERGUARD_SWEEP_SCHED_VER whenever ORDERGUARD_SWEEP_INTERVAL changes -- the
* interval is fixed when the recurring action is first registered, so a version
* change is what triggers orderguard_maybe_schedule_sweep() to unschedule the old
* cadence and re-register at the new one (existing installs included).
*/
if ( ! defined( 'ORDERGUARD_SWEEP_INTERVAL' ) ) { define( 'ORDERGUARD_SWEEP_INTERVAL', HOUR_IN_SECONDS ); }
if ( ! defined( 'ORDERGUARD_SWEEP_SCHED_VER' ) ) { define( 'ORDERGUARD_SWEEP_SCHED_VER', '2' ); } // v2 = hourly (was daily)
function orderguard_maybe_schedule_sweep() {
if ( ! orderguard_is_activated() || ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) {
return;
}
$interval = (int) apply_filters( 'orderguard_sweep_interval', ORDERGUARD_SWEEP_INTERVAL );
if ( $interval < MINUTE_IN_SECONDS ) {
$interval = MINUTE_IN_SECONDS; // floor -- never hammer the server
}
// Migration: if the desired cadence changed, drop the old recurring action so
// it can be re-registered below at the new interval.
$schedule_ok = false;
$stored_sched_ver = orderguard_get_option( 'orderguard_sweep_sched_ver', false, $schedule_ok );
if ( ! $schedule_ok ) {
return;
}
if ( $stored_sched_ver !== ORDERGUARD_SWEEP_SCHED_VER ) {
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( 'orderguard_reconciliation_sweep', array(), 'orderguard' );
}
orderguard_update_option( 'orderguard_sweep_sched_ver', ORDERGUARD_SWEEP_SCHED_VER, false );
}
if ( ! as_has_scheduled_action( 'orderguard_reconciliation_sweep', array(), 'orderguard' ) ) {
as_schedule_recurring_action(
time() + 5 * MINUTE_IN_SECONDS, // first run shortly after (re)scheduling
$interval,
'orderguard_reconciliation_sweep',
array(),
'orderguard'
);
orderguard_log( 'OrderGuard: scheduled reconciliation sweep every ' . $interval . 's' );
}
}
add_action( 'orderguard_reconciliation_sweep', 'orderguard_run_reconciliation_sweep' );
/**
* Save scores onto an order as meta.
*
* WRITE-ONCE (H-1 fix). The first successful save is the order's permanent
* check-time snapshot -- the record the Features tab sells for disputes
* ("exactly what your team saw when they chose to ship"). Every later call is
* a deliberate no-op: all six callers (new-order check, status-change, refund,
* COD mark/undo, courier outcome, reconciliation sweep) receive fresh scores
* in their server responses, and before this gate each of them silently
* rewrote the "snapshot" with current network values -- the sweep alone did it
* hourly for every recent resolved order. Current values belong to Live mode
* (/v1/score at render time), never on top of the record.
*
* The gate lives HERE, at the single meta chokepoint, not in the callers --
* same reasoning as the exclusion guard in orderguard_send_to_server(): a caller-side
* guard is one forgotten caller away from reopening the bug.
*
* The one legitimate late write still works through this gate: an order the
* server missed at creation (down/timeout) has no _orderguard_checked_at yet, so its
* first successful score -- from an event response or the sweep's 404
* recovery -- becomes the snapshot, stamped with the time that actually
* happened. That is the recovery behaviour the sweep's tail was written for.
*
* _orderguard_snapshot_locked marks snapshots taken under this rule. Orders scored
* BEFORE the fix hold whatever score was saved last (the sweep kept
* refreshing them for as long as they stayed in its window); their true
* check-time values are unrecoverable. The panel therefore labels them "Last
* scored" instead of claiming "At check-time" -- see
* orderguard_render_order_meta_box(). Do NOT backfill the marker onto old orders:
* its entire meaning is "this value was never rewritten".
*
* NOTE: this function no longer guarantees an $order->save() ride-along.
* Callers persisting their own meta must save it themselves (see
* orderguard_handle_not_collected_action()).
*/
function orderguard_save_scores( $order, $scores ) {
if ( ! is_array( $scores ) ) {
return;
}
if ( '' !== (string) orderguard_get_order_meta( $order, '_orderguard_checked_at' ) ) {
return; // snapshot already taken -- the record is never overwritten
}
$order->update_meta_data( '_orderguard_collection_risk', isset( $scores['collection_risk'] ) ? (int) $scores['collection_risk'] : 0 );
$order->update_meta_data( '_orderguard_return_risk', isset( $scores['return_risk'] ) ? (int) $scores['return_risk'] : 0 );
$order->update_meta_data( '_orderguard_confidence', isset( $scores['confidence'] ) ? $scores['confidence'] : 'none' );
$order->update_meta_data( '_orderguard_order_count', isset( $scores['order_count'] ) ? (int) $scores['order_count'] : 0 );
$order->update_meta_data( '_orderguard_months_active', isset( $scores['months_active'] ) ? (int) $scores['months_active'] : 0 );
$order->update_meta_data( '_orderguard_kept_count', isset( $scores['kept_count'] ) ? (int) $scores['kept_count'] : 0 );
$order->update_meta_data( '_orderguard_returned_count', isset( $scores['returned_count'] ) ? (int) $scores['returned_count'] : 0 );
$order->update_meta_data( '_orderguard_not_collected_count', isset( $scores['not_collected_count'] ) ? (int) $scores['not_collected_count'] : 0 );
// T-31: how many orders the network has SEEN for this customer, and how
// many of those carried no outcome. Every other count here has already
// been filtered down to orders that produced a signal, so without these
// two "we have never met this person" and "we have met them repeatedly
// and learned nothing" are indistinguishable -- both arrive as
// order_count 0. Absent on orders scored before the server returned them,
// which reads as 0 and falls through to the old copy.
$order->update_meta_data( '_orderguard_seen_count', isset( $scores['seen_count'] ) ? (int) $scores['seen_count'] : 0 );
$order->update_meta_data( '_orderguard_unresolved_count', isset( $scores['unresolved_count'] ) ? (int) $scores['unresolved_count'] : 0 );
// TASK 49 -- THIS STORE'S OWN BOOKS, imported orders included. The network
// counts above are withheld until the store has corroborated the customer
// with a non-import resolved order, which a store that backfilled its
// history has by definition not done -- so those merchants saw two dashes
// and a padlock about customers whose parcels they had packed themselves.
// These five are the merchant's own data, scoped server-side to his own rows.
//
// Written under the same write-once rule as everything else here (the early
// return above), which is correct rather than convenient: the panel is a
// frozen record of what was known WHEN THE ORDER ARRIVED, and that is also
// the only honest input to a decision about THIS parcel. A count that
// silently grew afterwards would rewrite history on a completed order.
// Absent on orders scored before this version -- reads as 0, and the panel
// falls through to its older wording.
$orderguard_own = ( isset( $scores['own_history'] ) && is_array( $scores['own_history'] ) ) ? $scores['own_history'] : array();
$order->update_meta_data( '_orderguard_own_order_count', isset( $orderguard_own['order_count'] ) ? (int) $orderguard_own['order_count'] : 0 );
$order->update_meta_data( '_orderguard_own_months_active', isset( $orderguard_own['months_active'] ) ? (int) $orderguard_own['months_active'] : 0 );
$order->update_meta_data( '_orderguard_own_kept_count', isset( $orderguard_own['kept_count'] ) ? (int) $orderguard_own['kept_count'] : 0 );
$order->update_meta_data( '_orderguard_own_returned_count', isset( $orderguard_own['returned_count'] ) ? (int) $orderguard_own['returned_count'] : 0 );
$order->update_meta_data( '_orderguard_own_not_collected_count', isset( $orderguard_own['not_collected_count'] ) ? (int) $orderguard_own['not_collected_count'] : 0 );
// Server withholds raw counts for a customer this store hasn't corroborated
// yet (anti-enumeration). Record it so the panel shows a note, not "0".
$order->update_meta_data( '_orderguard_counts_withheld', ! empty( $scores['counts_withheld'] ) ? 'yes' : 'no' );
// Provenance marker: this snapshot was written under the write-once rule,
// so "At check-time" is a true claim for it (H-1).
$order->update_meta_data( '_orderguard_snapshot_locked', 'yes' );
$order->update_meta_data( '_orderguard_checked_at', current_time( 'mysql' ) );
$order->save();
}
function orderguard_read_identity_from_order( $order ) {
$fields = orderguard_identity_fields();
$result = array();
foreach ( $fields as $key => $config ) {
if ( empty( $config['enabled'] ) ) {
continue;
}
switch ( $key ) {
case 'phone':
$value = $order->get_billing_phone();
break;
case 'email':
$value = $order->get_billing_email();
break;
case 'name':
$value = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
break;
default:
$value = '';
break;
}
if ( '' !== $value && null !== $value ) {
$result[ $key ] = $value;
}
}
return $result;
}
/**
* /v1/check -- create pending sightings, return scores.
*/
function orderguard_send_to_server( $order, $hashed, &$withheld = null ) {
$withheld = null;
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( 'suppress' === $privacy_disposition ) {
return null;
}
if ( 'allow' !== $privacy_disposition ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), 1 );
return null;
}
// M-5 chokepoint guard (same philosophy as the exclusion guard above): an
// order whose checkout OrderGuard rejected, while it still looks like the
// aborted artifact, is never checked in -- by ANY caller, present or
// future. The status test inside the helper is what re-arms check-in the
// moment a merchant rescues the order by hand.
if ( orderguard_is_checkout_rejected_dormant( $order ) ) {
return null;
}
// H-2 dual-send: the E.164 spelling of the phone rides alongside the legacy
// hash so the network can link "0888..." and "+359888..." checkouts of the
// same human. Keyed on the CUSTOMER'S billing country -- this call CREATES
// network identity, so it uses the strict rule (billing country only, no
// store-country fallback, decline on doubt). '' -> field omitted -> the
// server behaves exactly as before this feature existed.
$e164_hash = orderguard_phone_hash_e164( $order->get_billing_phone(), $order->get_billing_country() );
if ( empty( orderguard_usable_primary_identity_fields( $order, $hashed ) ) ) {
orderguard_log( 'OrderGuard: check-in withheld for order #' . $order->get_id() . ' -- no usable phone or email identity yet' );
return null;
}
$created = $order->get_date_created();
$body = array(
'country' => $order->get_billing_country(),
'order_ref' => (string) $order->get_id(),
// Immutable UTC epoch. The server uses it only to keep a pre-erasure
// order suppressed while allowing a genuinely later transaction by
// the same customer. Missing dates fail closed only for an identity
// covered by a merchant- or operator-erasure tombstone.
'order_created_at' => $created ? (int) $created->getTimestamp() : null,
'item_refs' => orderguard_get_item_refs( $order ),
'phone_hash' => isset( $hashed['phone'] ) ? $hashed['phone'] : null,
'email_hash' => isset( $hashed['email'] ) ? $hashed['email'] : null,
'name_hash' => isset( $hashed['name'] ) ? $hashed['name'] : null,
'phone_hash_e164' => ( '' !== $e164_hash ) ? $e164_hash : null,
);
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), 1 );
return null;
}
try {
$privacy_disposition = orderguard_gdpr_transport_disposition_for_order( $order );
if ( 'allow' !== $privacy_disposition ) {
if ( 'suppress' !== $privacy_disposition ) {
orderguard_schedule_gdpr_deferred_order( $order->get_id(), 1 );
}
return null;
}
// A check-in creates network identity, so unknown is not permission.
// Read the owned source only after the erasure lock and its privacy
// re-check, at the last practical moment before HTTP. Withheld orders
// keep their local facts and remain eligible for ordinary sweep/404
// recovery once the source is readable again.
$exclusion = orderguard_owned_exclusion_order_state( $order );
if ( 'not_excluded' !== ( $exclusion['status'] ?? 'unknown' ) ) {
$withheld = 'excluded' === ( $exclusion['status'] ?? 'unknown' ) ? 'excluded' : 'exclusion_unknown';
return null;
}
$response = wp_remote_post(
ORDERGUARD_SERVER_URL . '/v1/check',
array(
'method' => 'POST',
'timeout' => 5,
'headers' => array(
'Content-Type' => 'application/json',
'X-OrderGuard-Key' => orderguard_get_api_key(),
),
'body' => wp_json_encode( $body ),
)
);
if ( is_wp_error( $response ) ) {
orderguard_log( 'OrderGuard: send FAILED for order #' . $order->get_id() . ' -- ' . $response->get_error_message() );
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
orderguard_log( 'OrderGuard: sent order #' . $order->get_id() . ' -- server responded ' . wp_remote_retrieve_response_code( $response ) );
if ( is_array( $data ) && isset( $data['scores'] ) ) {
// M-5: a successful check-in supersedes any stale rejection mark.
if ( 'yes' === (string) orderguard_get_order_meta( $order, '_orderguard_checkout_rejected' ) ) {
orderguard_delete_order_meta( $order, '_orderguard_checkout_rejected' );
$order->save();
}
// TASK 49 -- see orderguard_with_own_history().
return orderguard_with_own_history( $data['scores'], $data );
}
return null;
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
}
/**
* The OrderGuard meta box on the order edit screen.
*/
/**
* ===========================================================================
* FLAG FOR REVIEW -- orders-list "Review" column (HPOS + classic).
* ===========================================================================
* Adds an OrderGuard column to the Orders table showing a "Review" badge for
* flagged customers, so a merchant can scan the list without opening each order.
* Reads the STORED count meta (no per-row server calls). Gated on flagging being
* enabled.
*/
// HPOS orders table.
add_filter( 'woocommerce_shop_order_list_table_columns', 'orderguard_add_orders_column' );
add_action( 'woocommerce_shop_order_list_table_custom_column', 'orderguard_render_orders_column_hpos', 10, 2 );
// Classic (post-based) orders table.
add_filter( 'manage_edit-shop_order_columns', 'orderguard_add_orders_column' );
add_action( 'manage_shop_order_posts_custom_column', 'orderguard_render_orders_column_classic', 10, 2 );
function orderguard_add_orders_column( $columns ) {
if ( ! orderguard_flag_enabled() ) {
return $columns;
}
// Insert our column just before the order total, if present.
$new = array();
foreach ( $columns as $key => $label ) {
if ( 'order_total' === $key ) {
$new['orderguard_review'] = 'OrderGuard';
}
$new[ $key ] = $label;
}
if ( ! isset( $new['orderguard_review'] ) ) {
$new['orderguard_review'] = 'OrderGuard';
}
return $new;
}
/**
* Shared badge HTML for an order id. Empty string when not flagged.
*/
function orderguard_orders_column_badge( $order ) {
if ( ! orderguard_flag_enabled() || ! $order instanceof WC_Order ) {
return '';
}
// EXCLUSION -- the badge is drawn from locally cached meta, so it would keep
// showing a verdict for an excluded person even though nothing is being
// scored any more. No data leaves the store, but the merchant would
// reasonably read it as "the exclusion didn't work".
if ( orderguard_is_gdpr_erased_order( $order ) ) {
return '';
}
$exclusion_display = orderguard_owned_exclusion_display_order( $order );
if ( 'display_unknown' === $exclusion_display ) {
return '' . esc_html__( 'Could not check exclusions', 'orderguard' ) . '';
}
if ( 'display_excluded' === $exclusion_display ) {
return '';
}
$nc = (int) orderguard_get_order_meta( $order, '_orderguard_not_collected_count' );
$rt = (int) orderguard_get_order_meta( $order, '_orderguard_returned_count' );
if ( orderguard_should_flag( $nc, $rt ) ) {
// THE NETWORK is speaking: red, flag icon, no number. The count is the
// network's and is withheld from the list on purpose (it is precise
// cross-store history); "Review" is the action, which is all this column
// has room to say.
return ''
. ''
. esc_html__( 'Review', 'orderguard' ) . '';
}
// TASK 50 -- THIS STORE'S OWN BOOKS. Amber, store icon, and the actual
// number, because unlike the network count this one is the merchant's own
// data and there is nothing to withhold. The two pills can never be
// confused: red flag = the network, amber store = your own records.
//
// This is the fix for the quiet half of the bug. A store that imported its
// history had _orderguard_not_collected_count = 0 for its own worst customers (the
// server withholds those counts until the store corroborates), so this
// badge never drew -- on the one screen the merchant is looking at while he
// prints the labels.
$own = orderguard_own_flag_reason( $order );
$txt = orderguard_own_reason_text( $own );
if ( '' === $txt ) {
return '';
}
return ''
. ''
. esc_html( $txt ) . '';
}
// HPOS passes the order object.
function orderguard_render_orders_column_hpos( $column, $order ) {
if ( 'orderguard_review' !== $column ) {
return;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_orders_column_badge() returns a fixed markup literal and interpolates nothing.
echo orderguard_orders_column_badge( $order );
}
// Classic passes the column key + post ID.
function orderguard_render_orders_column_classic( $column, $post_id ) {
if ( 'orderguard_review' !== $column ) {
return;
}
$order = wc_get_order( $post_id );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_orders_column_badge() returns a fixed markup literal and interpolates nothing.
echo orderguard_orders_column_badge( $order );
}
add_action( 'add_meta_boxes', 'orderguard_add_order_meta_box' );
/**
* WordPress screen IDs on which the OrderGuard order panel is registered.
*
* The asset gate consumes this same list so classic/HPOS and edit/new-order
* support cannot drift apart.
*/
function orderguard_order_meta_box_screens() {
return array( 'shop_order', 'woocommerce_page_wc-orders' );
}
function orderguard_add_order_meta_box() {
foreach ( orderguard_order_meta_box_screens() as $screen ) {
add_meta_box( 'orderguard_order_panel', 'OrderGuard', 'orderguard_render_order_meta_box', $screen, 'side', 'high' );
}
}
/**
* Panel scoring mode. FALSE (default) = snapshot: the panel shows the scores
* stored on the order at check time (stable, historical, no server call).
* TRUE = live: an add-on supplies current scores via orderguard_panel_live_scores.
*
* EXTENSION POINT (free/Pro split, #38). The free plugin ships snapshot-only:
* there is no option, no gate, and no live-fetch code here -- the entire live
* feature lives in the orderguard-pro add-on, which flips this filter on and
* answers the orderguard_panel_live_scores filter below. Whatever the mode, the
* check-time snapshot is saved on the order at its first successful check and
* never overwritten -- enforced by the write-once gate in orderguard_save_scores()
* (H-1 fix). Live only changes what the order panel DISPLAYS.
*/
function orderguard_panel_live_scoring() {
return (bool) apply_filters( 'orderguard_panel_live_scoring', false );
}
/**
* Save handler for the Features tab's scoring card. The free plugin has no
* setting of its own on that card (snapshot is the only mode it knows), so
* this handler only verifies the request and hands off to the add-on via the
* orderguard_features_save action (free/Pro split, #38). Without an add-on nothing
* ever posts orderguard_features_submit -- the free card renders no submit button.
*/
add_action( 'admin_init', 'orderguard_handle_features_submit' );
function orderguard_handle_features_submit() {
if ( ! isset( $_POST['orderguard_features_submit'] ) ) {
return;
}
if ( ! isset( $_POST['orderguard_features_nonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['orderguard_features_nonce'] ) ), 'orderguard_features' ) ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
// EXTENSION POINT: fires after nonce + capability checks, so a hooked
// add-on inherits both. Context arg = which settings form posted.
do_action( 'orderguard_features_save', 'features' );
if ( function_exists( 'orderguard_set_notice' ) ) {
orderguard_set_notice( 'success', __( 'Feature settings saved.', 'orderguard' ) );
}
}
/**
* How many orders has this customer placed with THIS store, ever?
*
* Read straight from WooCommerce -- deliberately NOT from the network:
* * it is the merchant's own data (nothing is revealed that isn't already in
* their admin), so there is no oracle and no privacy question;
* * it is the TRUE all-time figure, including orders older than the 24-month
* window OrderGuard imports -- which is exactly the gap that makes the panel
* look wrong ("I can see 5 orders, why does it say 1?").
*
* Matched on billing email OR billing phone, mirroring how OrderGuard matches
* identity. Cached on the order so a big store doesn't re-query on every render.
* Returns an int, or null when the customer can't be identified.
*/
function orderguard_store_order_count( $order ) {
if ( ! $order instanceof WC_Order
|| orderguard_is_gdpr_erased_order( $order )
|| ! function_exists( 'wc_get_orders' ) ) {
return null;
}
$cached = orderguard_get_order_meta( $order, '_orderguard_store_order_count' );
if ( '' !== $cached && null !== $cached && false !== $cached ) {
return (int) $cached;
}
$email = trim( (string) $order->get_billing_email() );
$phone = trim( (string) $order->get_billing_phone() );
if ( '' === $email && '' === $phone ) {
return null;
}
$ids = array();
// Two narrow, indexed lookups beat one broad meta scan on a large store.
if ( '' !== $email ) {
$by_email = wc_get_orders( array(
'billing_email' => $email,
'limit' => 200,
'return' => 'ids',
'status' => 'any',
) );
if ( is_array( $by_email ) ) {
$ids = array_merge( $ids, $by_email );
}
}
if ( '' !== $phone ) {
$by_phone = wc_get_orders( array(
'billing_phone' => $phone,
'limit' => 200,
'return' => 'ids',
'status' => 'any',
) );
if ( is_array( $by_phone ) ) {
$ids = array_merge( $ids, $by_phone );
}
}
$count = count( array_unique( array_map( 'intval', $ids ) ) );
// Cache for this order -- the customer's past order count doesn't change
// retroactively, and the panel is re-rendered on every admin page load.
$order->update_meta_data( '_orderguard_store_order_count', $count );
$order->save();
return $count;
}
/**
* Render the "N orders with you" line. Local data only.
*/
function orderguard_render_store_order_count( $order ) {
$count = orderguard_store_order_count( $order );
if ( null === $count || $count <= 0 ) {
return '';
}
$label = sprintf(
/* translators: %d: number of orders this customer has placed with this store. */
_n( '%d order with you, all time', '%d orders with you, all time', $count, 'orderguard' ),
$count
);
return '
'
. ''
. esc_html( $label )
. '
';
}
/**
* Has this customer completed an order with THIS store before the one on screen?
*
* Local WooCommerce read, exactly like orderguard_store_order_count() above and for the
* same reason: it is the merchant's own data, so there is no oracle and no
* privacy question. Nothing is asked of the server.
*
* WHY THIS EXISTS. The panel's counts-withheld note promised the merchant that
* detailed counts "unlock once they complete their first order with your
* store". For a store that imported its history that sentence is false: the
* server's unlock condition is a NON-IMPORT resolved sighting of the store's
* own (_corroborated_hashes, `source != "import"`), so a customer with three
* completed orders here, all of them imported, still reads as someone who has
* never ordered. This answers the one question that tells those cases apart.
*
* COMPLETED, not "any". The claim being checked is specifically about
* completing an order, so a customer whose only previous orders were cancelled,
* failed or still processing genuinely has not completed one and the original
* wording holds. `refunded` counts -- that order was completed and then
* refunded, which is still a completed order.
*
* Matched on billing email OR billing phone, mirroring how OrderGuard matches
* identity. Cached in a SHORT-TTL TRANSIENT, not in order meta: this answer
* DOES change retroactively (the customer completes another order; a pending
* one flips to completed), which is precisely the standing bug in
* orderguard_store_order_count()'s permanent-meta cache and not one to copy.
*
* Returns bool, and false whenever the customer cannot be identified -- which
* leaves the panel on its long-standing wording rather than on a new claim.
*/
function orderguard_has_prior_completed_order( $order ) {
if ( ! $order instanceof WC_Order
|| orderguard_is_gdpr_erased_order( $order )
|| ! function_exists( 'wc_get_orders' ) ) {
return false;
}
// NOT lowercased for the QUERY: billing emails are stored as sanitize_email()
// left them, so a lowercased needle can miss a mixed-case row. Lowercase is
// used only to build the cache key, where case must not fork the entry.
$email = trim( (string) $order->get_billing_email() );
$phone = trim( (string) $order->get_billing_phone() );
if ( '' === $email && '' === $phone ) {
return false;
}
$order_id = (int) $order->get_id();
$cache_key = 'orderguard_priorc_' . md5( strtolower( $email ) . '|' . $phone . '|' . $order_id );
// Compared against the two strings this function itself writes, so a stray
// value of another type in the cache re-queries instead of being coerced.
$cached = orderguard_get_transient( $cache_key );
if ( '1' === $cached || '0' === $cached ) {
return ( '1' === $cached );
}
$found = false;
// Two narrow, indexed lookups beat one broad meta scan, same as
// orderguard_store_order_count(). limit 2 rather than 1 because the order ON SCREEN
// can itself be `completed` and match: it is filtered out below, and asking
// for two keeps a real prior order in the result when that happens.
foreach ( array( 'billing_email' => $email, 'billing_phone' => $phone ) as $orderguard_field => $orderguard_value ) {
if ( '' === $orderguard_value ) {
continue;
}
$orderguard_ids = wc_get_orders( array(
$orderguard_field => $orderguard_value,
'limit' => 2,
'return' => 'ids',
// Deliberately NOT 'exclude' => array( $order_id ): support for it
// differs between the legacy post store and HPOS, and an argument
// that is silently ignored on one of them would make every
// completed order look like its own predecessor. Filtered in PHP
// below instead, where it is certain on both.
'status' => array( 'completed', 'refunded' ),
) );
if ( ! is_array( $orderguard_ids ) ) {
continue;
}
foreach ( $orderguard_ids as $orderguard_id ) {
if ( (int) $orderguard_id !== $order_id ) {
$found = true;
break 2;
}
}
}
set_transient( $cache_key, $found ? '1' : '0', 10 * MINUTE_IN_SECONDS );
return $found;
}
/**
* Carry a response's own_history into the scores array we pass around.
*
* own_history is a SEPARATE top-level key on every server response that has one
* (/v1/check, /v1/score, /v1/events). The server keeps it out of `scores` on
* purpose: it is never banded and must never be mistaken for a network score.
* The plugin, though, has exactly one persistence path -- orderguard_save_scores() --
* and threading a second parameter through every caller of it would be a wider
* change than this deserves. So it rides inside the array under its own key,
* which every existing reader ignores because they all look up named keys.
*
* ONE HELPER, not three inline copies: the sweep captures scores at THREE
* points (the normal event loop, the 404-recovery re-check, and the
* post-recovery event loop) and the first version of this change merged
* own_history at only one of them. An order that was missed at creation and
* first snapshotted by the sweep therefore froze _orderguard_own_* at 0 -- reproducing
* the exact blank-panel bug task 49 exists to fix, for that subset of orders.
*/
function orderguard_with_own_history( $scores, $response ) {
if ( ! is_array( $scores ) ) {
return $scores;
}
if ( is_array( $response ) && isset( $response['own_history'] ) && is_array( $response['own_history'] ) ) {
$scores['own_history'] = $response['own_history'];
}
return $scores;
}
/**
* This store's OWN record of the customer -- imported orders included.
*
* Returns five ints, always: order_count, months_active, kept_count,
* returned_count, not_collected_count. Never null, so no caller needs a guard.
*
* SOURCE ORDER, and the fallback is the point. If the Pro add-on passed a live
* response through the orderguard_panel_live_scores filter AND that response carried
* own_history, use it. Otherwise use the check-time snapshot on the order. An
* add-on built before task 49 does not forward the key, and this fallback is
* what keeps the panel correct on those installs instead of showing zeros --
* which would look exactly like the bug this fixes.
*
* COUNTS ONLY. The server strips risk numbers and the confidence label from
* own_history before it leaves the API (_OWN_HISTORY_KEYS), so there is nothing
* here to accidentally render as a 0-100 score. That is deliberate: this is the
* merchant's own bookkeeping, not a verdict about the customer.
*/
function orderguard_own_history_for_order( $order, $live = null ) {
$keys = array(
'order_count' => '_orderguard_own_order_count',
'months_active' => '_orderguard_own_months_active',
'kept_count' => '_orderguard_own_kept_count',
'returned_count' => '_orderguard_own_returned_count',
'not_collected_count' => '_orderguard_own_not_collected_count',
);
$src = null;
if ( is_array( $live ) && isset( $live['own_history'] ) && is_array( $live['own_history'] ) ) {
$src = $live['own_history'];
}
$out = array();
foreach ( $keys as $key => $meta_key ) {
if ( null !== $src ) {
$out[ $key ] = isset( $src[ $key ] ) ? (int) $src[ $key ] : 0;
} else {
$out[ $key ] = ( $order instanceof WC_Order ) ? (int) orderguard_get_order_meta( $order, $meta_key ) : 0;
}
}
return $out;
}
/**
* The "Your own records" block for the order panel.
*
* Structure and iconography are lifted from orderguard_customer_summary() on purpose --
* headline sentence, then one row per outcome with the same three dashicons in
* the same three colours, count right-aligned. The two blocks are the same
* component reading different sources, and they should look like it.
*
* FLAT FILL, NO OUTLINE. The panel already means something by that: outlined
* boxes are the SYSTEM speaking (the network gate, the review threshold), flat
* fills are the merchant's own data or his own act. This is his own data.
*
* Returns '' when there is nothing to report, so the caller needs no check.
*/
function orderguard_render_own_records( $own ) {
$count = isset( $own['order_count'] ) ? (int) $own['order_count'] : 0;
if ( $count <= 0 ) {
return '';
}
$months = isset( $own['months_active'] ) ? (int) $own['months_active'] : 0;
if ( $months > 0 ) {
$sub = sprintf(
/* translators: 1: number of orders with this store, 2: number of months. */
_n(
'%1$d order over the last %2$d months, from your own store history.',
'%1$d orders over the last %2$d months, from your own store history.',
$count, 'orderguard'
),
$count, $months
);
} else {
$sub = sprintf(
/* translators: %d: number of orders this customer has placed with this store. */
_n(
'%d order in your own store history.',
'%d orders in your own store history.',
$count, 'orderguard'
),
$count
);
}
// Same msgids as orderguard_customer_summary()'s rows, so these are already
// translated and the two blocks can never disagree in wording.
$rows = array(
array( __( 'Kept orders', 'orderguard' ), isset( $own['kept_count'] ) ? (int) $own['kept_count'] : 0, '#44621F', 'dashicons-yes-alt' ),
array( __( 'Returned orders', 'orderguard' ), isset( $own['returned_count'] ) ? (int) $own['returned_count'] : 0, '#7D6430', 'dashicons-image-rotate' ),
array( __( 'Not collected orders', 'orderguard' ), isset( $own['not_collected_count'] ) ? (int) $own['not_collected_count'] : 0, '#8F4444', 'dashicons-dismiss' ),
);
$html = '
'
. esc_html__( 'From your store only, including your imported history. Not a network score.', 'orderguard' ) . '
';
$html .= '
';
return $html;
}
/**
* LIVE scores for an order's customer, if an add-on supplies them.
* Returns a scores array, or null = "use the stored snapshot".
*
* EXTENSION POINT (free/Pro split, #38). The free plugin contains NO live-fetch
* code -- the orderguard-pro add-on answers the orderguard_panel_live_scores filter
* (it queries the read-only /v1/score endpoint, hashes only, records nothing).
* The free plugin keeps its snapshot path whenever the filter yields null.
*
* EXCLUSION GUARD -- deliberately HERE, before the filter fires, not inside
* the add-on's fetcher (where it used to live as part of the old built-in
* orderguard_fetch_live_scores()). An excluded identity must never be looked up, and
* this guard prevents this seam from invoking the provider on refusal. It does
* not constrain independent code in an add-on. The legacy named privacy lock
* is not the owned lifecycle gate; owned transport takeover remains required.
*/
function orderguard_panel_live_scores( $order ) {
if ( ! $order instanceof WC_Order || orderguard_is_gdpr_erased_order( $order ) ) {
return null;
}
// Cheap mode gate first: no add-on (or live mode off) => never even apply
// the scores filter, and the panel stays on the snapshot.
if ( ! orderguard_panel_live_scoring() ) {
return null;
}
if ( 'not_excluded' !== orderguard_owned_exclusion_order_state( $order )['status'] ) {
return null;
}
// Serialize the last privacy check with erasure acceptance on the free
// side of the extension seam. This protects every live-score provider,
// including older Pro builds, without expanding Pro's frozen Free-API
// dependency surface.
$privacy_lock = orderguard_acquire_gdpr_erasure_lifecycle_lock();
if ( ! $privacy_lock ) {
return null;
}
try {
if ( orderguard_is_gdpr_erased_order( $order )
|| 'not_excluded' !== orderguard_owned_exclusion_order_state( $order )['status'] ) {
return null;
}
$scores = apply_filters( 'orderguard_panel_live_scores', null, $order );
} finally {
orderguard_release_gdpr_erasure_lifecycle_lock();
}
return ( is_array( $scores ) && ! empty( $scores ) ) ? $scores : null;
}
/**
* Localized "time ago" words for the panel's "checked %s ago" line.
* human_time_diff() always returns ENGLISH unit words regardless of locale,
* so BG mode needs its own singular/plural forms. Picks the largest fitting
* unit (seconds -> minutes -> hours -> days), like human_time_diff() does.
*/
function orderguard_time_ago_words( $ts ) {
$diff = max( 1, current_time( 'timestamp' ) - (int) $ts );
$bg = orderguard_should_bg();
if ( $diff < MINUTE_IN_SECONDS ) {
$n = $diff;
$one = $bg ? '1 секунда' : '1 second';
$many = $bg ? '%d секунди' : '%d seconds';
} elseif ( $diff < HOUR_IN_SECONDS ) {
$n = (int) floor( $diff / MINUTE_IN_SECONDS );
$one = $bg ? '1 минута' : '1 minute';
$many = $bg ? '%d минути' : '%d minutes';
} elseif ( $diff < DAY_IN_SECONDS ) {
$n = (int) floor( $diff / HOUR_IN_SECONDS );
$one = $bg ? '1 час' : '1 hour';
$many = $bg ? '%d часа' : '%d hours';
} else {
$n = (int) floor( $diff / DAY_IN_SECONDS );
$one = $bg ? '1 ден' : '1 day';
$many = $bg ? '%d дни' : '%d days';
}
return ( 1 === $n ) ? $one : sprintf( $many, $n );
}
/** Render only the merchant's local flag; no scores or network lookups. */
function orderguard_render_own_flag_banner( $order ) {
// YOUR OWN DECISION, FIRST. This was already on the panel -- but only as
// a "Currently: Hard block" line inside orderguard_render_flag_control(), below
// the cards, the summary, the store order count and the flag radios. So
// an order could open on two calm score cards while the merchant's own
// block sat several scrolls underneath, which is exactly how a blocked
// customer came to read as unremarkable.
//
// Gated on orderguard_manual_flag_enabled() for the same reason ENFORCEMENT is
// (orderguard_manual_block_level_for_identity returns '' when it is off, and
// orderguard_filter_gateways bails): with the feature off a stored flag does not
// bite, and announcing an effect that is not happening is worse than
// silence.
//
// Colours, icons and wording are lifted from orderguard_render_flag_control()'s
// own $meta map and orderguard_flag_levels(), so the banner can never describe a
// different level from the radio the merchant sees below it. Flat fill,
// no border: the outlined boxes on this panel are the SYSTEM speaking
// (network gate, review threshold); this one is the merchant's own act.
$orderguard_own_flag = orderguard_manual_flag_enabled() ? orderguard_flag_find_for_order( $order ) : null;
if ( $orderguard_own_flag && isset( $orderguard_own_flag->level ) ) {
$orderguard_own_banner = array(
'flag' => array(
'dashicons-flag', '#FBF1DE', '#B07D2A',
__( 'You flagged this customer for review', 'orderguard' ),
__( 'A private record on your own site, plus a signal to other stores if you have Merchant Signals switched on. Checkout is not affected.', 'orderguard' ),
),
'block_cod' => array(
'dashicons-hidden', '#FBEADF', '#C2621F',
__( 'You blocked cash on delivery for this customer', 'orderguard' ),
__( 'They can still order by prepaying, but not with cash on delivery.', 'orderguard' ),
),
'hard_block' => array(
'dashicons-dismiss', '#FBEAEA', '#8F4444',
__( 'You hard blocked this customer', 'orderguard' ),
__( 'They cannot place any order on your store.', 'orderguard' ),
),
);
if ( isset( $orderguard_own_banner[ $orderguard_own_flag->level ] ) ) {
$orderguard_ob = $orderguard_own_banner[ $orderguard_own_flag->level ];
echo '
';
echo '';
echo '
';
echo '
' . esc_html( $orderguard_ob[3] ) . '
';
echo '
' . esc_html( $orderguard_ob[4] );
if ( ! empty( $orderguard_own_flag->reason ) ) {
echo ' ' . esc_html(
sprintf(
/* translators: %s is the reason the merchant chose when flagging, e.g. "Did not collect". */
__( 'Your reason: %s.', 'orderguard' ),
orderguard_flag_reason_label( $orderguard_own_flag->reason )
)
);
}
echo '
';
echo '
';
}
}
return $orderguard_own_flag;
}
function orderguard_render_order_meta_box( $post_or_order ) {
if ( ! orderguard_is_activated() ) {
echo '
' . esc_html__( 'Scoring is paused and erasure processing is blocked because local privacy protection could not be verified. Operator intervention is required. This pause does not delete saved scores.', 'orderguard' )
. ' '
. esc_html__( 'Review privacy status', 'orderguard' ) . '
';
return;
}
// Erasure retires the complete order-backed surface. This must precede
// Pro live scoring, COD decisions, customer-count queries and reporting
// controls so opening an old order cannot recreate a lookup or signal.
if ( orderguard_is_gdpr_erased_order( $order ) ) {
echo '
'
. ''
. '' . esc_html__( 'This customer\'s OrderGuard data was erased. The order is kept out of scoring, network lookups and reporting.', 'orderguard' ) . ''
. '
';
return;
}
// EXCLUSION -- say so plainly rather than showing an empty/zero score. A
// blank panel looks like a bug; this tells the merchant it's their own
// deliberate setting and where to undo it.
$exclusion_display = orderguard_owned_exclusion_display_order( $order );
if ( 'display_unknown' === $exclusion_display ) {
echo '
';
// Do not reach scores, network banners or the active legacy handlers.
orderguard_render_own_flag_banner( $order );
if ( orderguard_manual_flag_enabled() ) {
// The converted writer saves locally first and treats an unreadable
// exclusion source as an explicit no-share result. Local merchant
// controls can therefore remain usable without opening a sender.
echo orderguard_render_flag_control( $order, false ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper escapes every dynamic value.
}
return;
}
if ( 'display_excluded' === $exclusion_display ) {
echo '
'
. ''
. '' . esc_html__( 'This customer is on your exclusion list. They are not scored, and nothing about them is sent to OrderGuard.', 'orderguard' )
. ' ' . esc_html__( 'Manage list', 'orderguard' ) . ''
. '
';
return;
}
// Live mode (Pro add-on): current scores via the orderguard_panel_live_scores
// filter. Snapshot mode (default, and whenever the filter yields null --
// no add-on, live off, or a failed fetch): the stored check-time scores.
// Either way the panel always shows something.
$live = orderguard_panel_live_scores( $order );
if ( is_array( $live ) ) {
$collection = isset( $live['collection_risk'] ) ? (int) $live['collection_risk'] : '';
$return = isset( $live['return_risk'] ) ? (int) $live['return_risk'] : '';
$confidence = isset( $live['confidence'] ) ? $live['confidence'] : 'none';
$checked_at = current_time( 'mysql' );
$live_oc = isset( $live['order_count'] ) ? (int) $live['order_count'] : 0;
$live_ma = isset( $live['months_active'] ) ? (int) $live['months_active'] : 0;
$live_kc = isset( $live['kept_count'] ) ? (int) $live['kept_count'] : 0;
$live_rc = isset( $live['returned_count'] ) ? (int) $live['returned_count'] : 0;
$live_nc = isset( $live['not_collected_count'] ) ? (int) $live['not_collected_count'] : 0;
$live_sc = isset( $live['seen_count'] ) ? (int) $live['seen_count'] : 0;
$live_uc = isset( $live['unresolved_count'] ) ? (int) $live['unresolved_count'] : 0;
$counts_withheld = ! empty( $live['counts_withheld'] );
$snapshot_at = orderguard_get_order_meta( $order, '_orderguard_checked_at' ); // when the stored score was saved (write-once since H-1; pre-fix orders hold the sweep's LAST save)
} else {
$snapshot_at = null;
$collection = orderguard_get_order_meta( $order, '_orderguard_collection_risk' );
$return = orderguard_get_order_meta( $order, '_orderguard_return_risk' );
$confidence = orderguard_get_order_meta( $order, '_orderguard_confidence' );
$checked_at = orderguard_get_order_meta( $order, '_orderguard_checked_at' );
$live_oc = $live_ma = $live_kc = $live_rc = $live_nc = null;
$live_sc = $live_uc = null;
$counts_withheld = ( 'yes' === orderguard_get_order_meta( $order, '_orderguard_counts_withheld' ) );
}
// TASK 49 -- read the store's OWN record before anything is drawn, because
// whether it has something to say decides whether the two risk cards are
// drawn at all. Two cards reading "-" is what makes this panel look broken,
// and for an import-only customer they are the least true thing on screen.
$orderguard_own = orderguard_own_history_for_order( $order, $live );
// WHY orderguard_score_has_no_basis() IS IN THIS CONDITION -- read before touching it.
// The first version of this line was `$counts_withheld && order_count > 0`,
// on the belief that a withheld customer's risk cards are always the
// "nothing to divide by" zero. THAT IS FALSE, and it made this change hide
// the single most important thing on the panel.
//
// _band_only KEEPS collection_risk, return_risk and confidence (_BAND_KEYS);
// it strips only the raw counts. And on /v1/check the pending sighting the
// request itself just wrote satisfies _own_hashes (which filters source, not
// outcome), so the identity IS owned and the FULL network score is computed
// -- while _all_corroborated still says no, because a pending row is not a
// resolved one. So the ordinary post-import state is: counts_withheld true,
// own orders > 0, and collection_risk 42 from eight not-collected parcels at
// other stores. Suppressing the cards there showed the merchant his own
// customer's kept orders and silently dropped the network's warning -- and
// nothing else on that path backfills it (the COD banner needs
// /v1/cod-decision, which gates on ownership excluding import; the
// flag banner lives in the not-withheld branch; the orders-list badge reads
// the counts that were withheld and stored as 0).
//
// So: suppress the cards ONLY when they would genuinely be two dashes --
// which is exactly what orderguard_score_has_no_basis( $confidence ) already decides
// for orderguard_score_card() a few lines below. Same question, one answer.
$orderguard_own_only = (
$counts_withheld
&& $orderguard_own['order_count'] > 0
&& orderguard_score_has_no_basis( $confidence )
);
echo '
';
if ( '' !== $checked_at ) {
$ts = strtotime( $checked_at );
if ( $ts ) {
echo '
'
. sprintf( /* translators: %s is a human-readable interval, e.g. "3 hours". */ esc_html__( 'checked %s ago', 'orderguard' ), esc_html( orderguard_time_ago_words( $ts ) ) ) . '
';
}
}
$orderguard_own_flag = orderguard_render_own_flag_banner( $order );
// A hard block makes the network COD banner below moot -- the customer
// cannot place ANY order, so telling the merchant that cash on delivery
// specifically is hidden adds nothing and stacks a second red box on the
// first. A block_cod does NOT suppress it: same visible effect, different
// cause, and the merchant needs to know that lifting their own block will
// not restore COD while the network gate still holds.
$orderguard_own_hard_block = ( $orderguard_own_flag && isset( $orderguard_own_flag->level ) && 'hard_block' === $orderguard_own_flag->level );
// M-2: surface the COD GATE STATE directly. The gate acts on raw counts
// decided SERVER-side (orderguard_cod_risk_decision -> /v1/cod-decision), not on
// the 0-100 score, so no score-band colouring can represent it -- a gated
// customer can sit in any band (see orderguard_score_card). This banner asks the
// SAME decision the checkout enforces (same function, same 5-min cache,
// same exclusion guard, fail-open), so what the merchant sees here IS the
// action the system takes, in every counts state:
// - counts withheld (uncorroborated, C-1): the server decides from the
// full counts it holds; the banner is right even though the panel
// shows the lock note instead of numbers. It reveals only the same
// boolean the checkout already acts on visibly -- no new oracle.
// - snapshot mode: the cards are the frozen check-time record (H-1);
// the gate is CURRENT state, so the text says "right now".
// - server unreachable: decision fails open to 'keep' -> no banner --
// which is the truth, because enforcement fails open the same way.
// Deliberately shown on non-COD and not-yet-checked orders too: the gate
// is a property of the CUSTOMER's next checkout, not of this order.
if ( orderguard_cod_gating_enabled() && ! $orderguard_own_hard_block ) {
$orderguard_gate_identity = orderguard_identity_from_order( $order );
if ( '' !== $orderguard_gate_identity['phone'] || '' !== $orderguard_gate_identity['email'] ) {
if ( 'hide' === orderguard_cod_risk_decision( $orderguard_gate_identity, $order->get_billing_country() ) ) {
echo '
';
echo '';
echo '
';
echo '
' . esc_html__( 'COD is blocked for this customer', 'orderguard' ) . '
';
echo '
' . esc_html__( 'Their network history is over your COD limits right now, so cash on delivery is hidden from them at checkout. They can still pay in advance.', 'orderguard' ) . '
';
} else {
// Same $confidence the summary below already reports in words; the
// cards now agree with it instead of contradicting it in colour.
$orderguard_no_basis = orderguard_score_has_no_basis( $confidence );
// TASK 49 -- NO CARDS when, and only when, they would be two dashes AND
// the store's own records can say something true instead. Two dashes side
// by side is what a merchant reads as "this is broken"; a real risk band
// is the opposite of noise and is never suppressed. See the $orderguard_own_only
// comment above for the mistake this condition was written to correct.
// The snapshot expander and everything below it are untouched.
if ( ! $orderguard_own_only ) {
echo '
';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_score_card() escapes the label, casts the score to int and picks its colours from a fixed internal set.
echo orderguard_score_card( __( 'Collection risk', 'orderguard' ), (int) $collection, 'dashicons-car', '', $orderguard_no_basis );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_score_card() escapes the label, casts the score to int and picks its colours from a fixed internal set.
echo orderguard_score_card( __( 'Return risk', 'orderguard' ), (int) $return, 'dashicons-image-rotate', '', $orderguard_no_basis );
echo '
';
}
// Live mode: click-to-expand the stored score (below the live scores).
//
// PROVENANCE (H-1): _orderguard_snapshot_locked marks a snapshot written under
// the write-once rule -- genuinely the first score saved, so "At
// check-time" is a true claim for it. Orders scored BEFORE the fix
// hold whatever the hourly sweep last saved; their check-time values
// are gone. Calling THAT "at check-time" would freeze a false
// historical claim into something merchants take to couriers and
// courts, so those orders say "Last scored" with the honest timestamp
// of that last save, plus a footnote saying what the number is.
if ( ! empty( $snapshot_at ) ) {
$sts = strtotime( $snapshot_at );
$snap_locked = ( 'yes' === (string) orderguard_get_order_meta( $order, '_orderguard_snapshot_locked' ) );
$snap_c = (int) orderguard_get_order_meta( $order, '_orderguard_collection_risk' );
$snap_r = (int) orderguard_get_order_meta( $order, '_orderguard_return_risk' );
$snap_cf = (string) orderguard_get_order_meta( $order, '_orderguard_confidence' );
$snap_dt = $sts ? ( function_exists( 'orderguard_i18n_date' ) ? orderguard_i18n_date( 'j M H:i', $sts ) : date_i18n( 'j M H:i', $sts ) ) : '';
$snap_dm = $sts ? ( function_exists( 'orderguard_i18n_date' ) ? orderguard_i18n_date( 'j M', $sts ) : date_i18n( 'j M', $sts ) ) : '';
$oid = (int) $order->get_id();
$snap_toggle_label = $snap_locked ? __( 'Snapshot kept', 'orderguard' ) : __( 'Last score kept', 'orderguard' );
$snap_box_label = $snap_locked ? __( 'At check-time', 'orderguard' ) : __( 'Last scored', 'orderguard' );
echo '';
echo '
' . esc_html__( 'An older OrderGuard version kept this score updated rather than frozen; this is its last update, not the check-time score.', 'orderguard' ) . '
' )
. '
'
. '
';
}
if ( $counts_withheld ) {
// The risk scores above already reflect the network history; showing
// zero counts here would be misleading, so surface a clear note. The
// detailed counts unlock once this store corroborates the customer
// with a real completed order (see server F-01 oracle hardening).
//
// Two phrasings (H-1): in LIVE mode the lock is current state and
// lifts on this very panel once the customer corroborates, so the
// original promise ("unlock once they complete...") stays true. In
// SNAPSHOT mode the panel is a frozen record -- this order's meta
// will never change -- so the promise must point at FUTURE orders,
// or it would sit unfulfilled on a completed order forever.
// 0.2.21 -- FOUR phrasings, not two. Both of the originals promise
// the unlock happens when the customer completes their first order
// here, and for an IMPORTED history that is false: the server
// unlocks on a non-import resolved sighting of this store's own
// (_corroborated_hashes, `source != "import"`), which imported rows
// are not. So the merchants who did the most setup work were the
// ones told "their first order" about a customer whose second and
// third orders they packed themselves. Order 105741 at
// bambukifamily -- two completed orders, this note on screen.
//
// The plugin can separate the cases without asking the server
// anything: orderguard_has_prior_completed_order() is a local WooCommerce
// read of the merchant's own orders. It stays false when the
// customer cannot be identified, so the fallback is the wording
// that has always been there.
// TASK 49 -- when the store's own books have something to say, say
// THAT, and say it FIRST. Before this the only true line on the
// panel was orderguard_render_store_order_count()'s 12px grey footnote,
// ranked last, under two dashes and a padlock.
if ( $orderguard_own['order_count'] > 0 ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_render_own_records() int-casts every count and escapes every label it renders.
echo orderguard_render_own_records( $orderguard_own );
}
// orderguard_has_prior_completed_order() runs a local WooCommerce query, so
// only ask when the server has not already answered the question:
// own records ON SCREEN make "have they ordered here before?" moot.
$orderguard_has_prior = ( 0 === $orderguard_own['order_count'] ) && orderguard_has_prior_completed_order( $order );
if ( $orderguard_own['order_count'] > 0 ) {
// The counts are on screen; what is still locked is the NETWORK
// view, so the note explains that and nothing else.
$withheld_note = is_array( $live )
? __( 'Network counts stay locked until OrderGuard has itself checked one of their orders here and recorded how it ended. Their next order with you will do that.', 'orderguard' )
: __( 'Network counts were still locked when this order was checked. They unlock on the customer\'s later orders, once OrderGuard has itself checked one of their orders here and recorded how it ended.', 'orderguard' );
} elseif ( $orderguard_has_prior ) {
$withheld_note = is_array( $live )
? __( 'The risk score above reflects this customer\'s network history. Their detailed counts stay locked until OrderGuard has itself checked one of their orders at your store and recorded how it ended -- the orders imported from your history do not unlock them. Their next order with you will.', 'orderguard' )
: __( 'The risk score above reflects this customer\'s network history. Their detailed counts were still locked when this order was checked: OrderGuard had not yet checked one of their orders at your store and recorded how it ended, and the orders imported from your history do not unlock them. This record stays as it was, but their later orders can lift the lock.', 'orderguard' );
} else {
$withheld_note = is_array( $live )
? __( 'The risk score above reflects this customer\'s network history. Their detailed order counts unlock once they complete their first order with your store.', 'orderguard' )
: __( 'The risk score above reflects this customer\'s network history. Their detailed order counts were still locked when this order was checked -- they unlock on the customer\'s future orders once they complete their first order with your store.', 'orderguard' );
}
echo '
' . esc_html__( 'Flagged for review', 'orderguard' ) . '
';
echo '
' . esc_html__( 'This customer crossed your review limit. Consider confirming before dispatch.', 'orderguard' ) . '
';
echo '
';
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_customer_summary() int-casts every count and escapes every label it renders.
echo orderguard_customer_summary( $order_count, $months_active, $confidence, $kept_c, $returned_c, $notcoll_c, $seen_c, $unresolved_c );
}
}
// "N orders with you" -- read LOCALLY from WooCommerce, never from the
// network. This is the merchant's own data: it is the TRUE all-time total
// (including orders older than the 24-month window OrderGuard imports), it
// reveals nothing they cannot already see in their own admin, and it is the
// number they will sanity-check the panel against.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_render_store_order_count() escapes its only dynamic value.
echo orderguard_render_store_order_count( $order );
// Courier provenance chips (M4): WHERE the recorded delivery outcome came
// from -- one small chip per source, under the score/snapshot block.
// Renders nothing when the order has no courier provenance.
if ( function_exists( 'orderguard_render_courier_provenance_chips' ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_render_courier_provenance_chips() escapes every field it reads from order meta.
echo orderguard_render_courier_provenance_chips( $order );
}
if ( orderguard_is_cod_order( $order ) ) {
$marked = ( 'yes' === orderguard_get_order_meta( $order, '_orderguard_not_collected' ) );
$order_id = $order->get_id();
$mark_url = wp_nonce_url(
admin_url( 'admin-post.php?action=orderguard_notcollected&do=mark&order_id=' . $order_id ),
'orderguard_notcollected_' . $order_id
);
$undo_url = wp_nonce_url(
admin_url( 'admin-post.php?action=orderguard_notcollected&do=undo&order_id=' . $order_id ),
'orderguard_notcollected_' . $order_id
);
echo '
';
if ( $marked ) {
echo '
';
echo '';
echo '' . esc_html__( 'Marked as not collected', 'orderguard' ) . '';
echo '
';
echo '' . esc_html__( 'Undo', 'orderguard' ) . '';
} else {
// wp_kses with only allowed: the BG label breaks onto two
// lines; everything else is still stripped/escaped.
echo '
' . wp_kses( __( 'COD order. Did the customer not collect it?', 'orderguard' ), array( 'br' => array() ) ) . '
';
}
if ( orderguard_manual_flag_enabled() ) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_render_network_signal_line() int-casts the API counts and escapes the assembled sentence.
echo orderguard_render_network_signal_line( $order );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- orderguard_render_flag_control() escapes every value it takes from the flags table.
echo orderguard_render_flag_control( $order );
}
echo '
';
if ( $order_count <= 0 ) {
// TWO DIFFERENT ZEROES (T-31). order_count counts only orders that
// produced a signal, so it reads 0 both for a customer nobody has
// ever seen and for one the network has seen repeatedly whose orders
// never reached an outcome. Calling the second one "new" is the same
// false claim the green zero was: the merchant may have blocked them,
// marked a parcel not collected and be looking at a stored score,
// while the panel insists they are a stranger. seen_count is what
// tells the two apart.
if ( $seen_count > 0 ) {
$html .= '
'
. esc_html__( 'Seen, but nothing to score yet', 'orderguard' ) . '
';
$html .= '
'
. esc_html(
sprintf(
/* translators: %d is how many orders OrderGuard has seen for this customer. */
_n(
'OrderGuard has seen %d order from this customer, but it has not reached an outcome that can be scored.',
'OrderGuard has seen %d orders from this customer, but none of them have reached an outcome that can be scored.',
$seen_count,
'orderguard'
),
$seen_count
)
) . '
';
$html .= '
'
. esc_html__( 'Orders start counting once they are marked completed, returned or not collected.', 'orderguard' ) . '
';
$html .= '
';
return $html;
}
// Genuinely unknown: no sightings anywhere. The original copy, which
// is true in exactly this case and no other.
$html .= '
' . esc_html__( 'New to the network', 'orderguard' ) . '
';
$html .= '
' . esc_html__( 'OrderGuard hasn\'t seen this customer\'s completed orders yet. Scores will sharpen as they order more.', 'orderguard' ) . '
';
$html .= '';
return $html;
}
$orders_word = ( 1 === $order_count ) ? __( 'order', 'orderguard' ) : __( 'orders', 'orderguard' );
if ( $months_active <= 0 ) {
$when = __( 'recently', 'orderguard' );
} elseif ( 1 === $months_active ) {
$when = __( 'over the last month', 'orderguard' );
} else {
$when = sprintf( /* translators: %d is a number of months. */ __( 'over the last %d months', 'orderguard' ), $months_active );
}
$html .= '
'
. esc_html( sprintf( /* translators: %1$d is an order count, %2$s the word order/orders, %3$s a time span. */ __( '%1$d %2$s %3$s, reported across the network.', 'orderguard' ), $order_count, $orders_word, $when ) )
. '
';
}
$html .= '';
return $html;
}
function orderguard_deterministic_commentary( $order_count, $confidence, $kept, $returned, $not_collected ) {
if ( $order_count <= 0 ) {
return __( 'Scores will sharpen as this customer completes more orders.', 'orderguard' );
}
$parts = array();
if ( $kept > 0 && ( $returned + $not_collected ) === 0 ) {
$parts[] = __( 'This customer has a clean history so far.', 'orderguard' );
} elseif ( $kept >= ( $returned + $not_collected ) ) {
$parts[] = __( 'This customer mostly keeps their orders.', 'orderguard' );
} else {
$parts[] = __( 'This customer returns or fails to collect more often than average.', 'orderguard' );
}
if ( $not_collected > 0 ) {
$parts[] = ( 1 === $not_collected )
? __( 'One past COD non-collection.', 'orderguard' )
: sprintf( /* translators: %d is a count of past non-collected COD orders. */ __( '%d past COD non-collections.', 'orderguard' ), $not_collected );
}
return implode( ' ', $parts );
}
/**
* Does the pair of 0-100 scores rest on any resolved history at all?
*
* scoring.py returns 0 for BOTH risks when there is nothing to divide by
* (_score_for: `if resolved_weight <= 0: return 0`). That zero is an early
* return, not a measurement -- yet the panel rendered it in the green band,
* i.e. the identical card a customer with twenty clean orders earns. A
* merchant reading "0, green" about someone the network has never seen is
* being told something the server never said.
*
* The test is CONFIDENCE, never the zeros themselves. Both come from the same
* `resolved_weight <= 0` condition (_confidence_label returns 'none' there),
* but a genuinely excellent customer also scores 0 and must keep their green
* card -- so the zeros cannot distinguish the two and confidence can.
*
* Order count is deliberately NOT used as a second test. It is written to
* _orderguard_order_count, and (int) get_meta() on an order scored before that key
* existed yields 0 -- indistinguishable from a real zero. _orderguard_confidence has
* the same legacy gap but fails the SAFE way: '' is not 'none', so an
* uncharacterisable old snapshot keeps its current rendering instead of being
* silently restated as "no data", which would be a different false claim.
*
* @param string $confidence 'none' | 'low' | 'medium' | 'high' | '' (legacy).
* @return bool True when the cards must claim nothing.
*/
function orderguard_score_has_no_basis( $confidence ) {
return ( 'none' === (string) $confidence );
}
function orderguard_score_card( $label, $score, $icon = '', $suffix = '', $no_data = false ) {
// Severity bands for the 0-100 score DISPLAY only: green 0-24, amber
// 25-49, red 50+. They are NOT the COD gate and cannot be made to align
// with it (M-2): gating acts on raw counts, decided server-side
// (nc/rt >= threshold via /v1/cod-decision -- orderguard_cod_risk_decision), and
// the score is a recency-weighted, confidence-damped RATE of the same
// history -- the gate is not a function of the score. Concretely
// (scoring.py, saturation 8): 5 non-collections and nothing else -> rate
// 1.0 x damp 5/13 -> score 38, amber, GATED; 5 non-collections among 20
// kept -> rate 0.2 x damp 25/33 -> score 15, green, ALSO GATED. So the
// gate state is surfaced explicitly instead: the "COD is blocked for
// this customer" banner in orderguard_render_order_meta_box(), driven by the
// same server decision the checkout enforces.
// NO BASIS -- see orderguard_score_has_no_basis(). Drawn in the plugin's existing
// neutral tint and muted ink so the card reads as "nothing to report"
// rather than as a fourth severity band. The CALLER decides; this branch
// only draws, which keeps the three bands below owning every case where a
// 0 genuinely IS a measurement.
if ( $no_data ) {
$bg = '#F1F4F3'; $fg = '#8A9794'; // neutral -- no history to score
} elseif ( $score >= 50 ) {
$bg = '#FBEAEA'; $fg = '#8F4444'; // red -- high risk
} elseif ( $score >= 25 ) {
$bg = '#FBF1DE'; $fg = '#7D6430'; // amber -- moderate
} else {
$bg = '#EDF4E1'; $fg = '#44621F'; // green -- low
}
$ghost = ( 'dashicons-image-rotate' === $icon ) ? 'dashicons-archive' : 'dashicons-admin-users';
$icon_html = '';
if ( '' !== $icon ) {
$icon_html = '';
}
$html = '
';
$html .= '';
$html .= '
' . $icon_html . esc_html( $label ) . '
';
// En dash for the no-basis state: a literal this function owns, so there
// is nothing to escape. The scored path is unchanged.
$value = $no_data ? '–' : ( (int) $score . esc_html( $suffix ) );
$html .= '