carrier_reported event. * M1 Registry -- adapter registry + detection + AWB locator + logos. * M2 Poller -- hourly Action Scheduler job, batched by courier. * M3 Econt adapter -- official, Bulgarisation and mreja.net variants. * * PRIVACY / SECURITY INVARIANTS (hold these when adding adapters): * - Plugin-side data plane ONLY. Courier API credentials and any raw * customer data inside tracking responses NEVER leave this store. The only * thing sent to the OrderGuard server is the existing event payload * (order_ref + normalized outcome + courier id) -- the customer identity * was already hashed locally by the /v1/check path, like every event. * - Credentials are read from the merchant's OWN courier-plugin options at * call time; OrderGuard never copies, stores or centralizes them. * - DEFAULT OFF: everything here no-ops unless the merchant explicitly * opts in by setting the orderguard_courier_sync_enabled option to 'yes'. * - Conservative classification: an outcome is only reported when the * courier data is unambiguous; anything unclear stays 'unknown' and is * simply re-checked on a later run. */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * =========================================================================== * Consent switch (default OFF -- explicit merchant opt-in). * =========================================================================== * Enable via the Features-tab toggle (shipped in M4) or directly: * orderguard_update_option( 'orderguard_courier_sync_enabled', 'yes' ); (the filter is the * usual dev/test override, mirroring orderguard_cod_gating_enabled().) */ function orderguard_courier_sync_enabled( &$ok = null ) { $enabled = ( 'yes' === orderguard_get_option( 'orderguard_courier_sync_enabled', 'no', $ok ) ); return (bool) apply_filters( 'orderguard_courier_sync_enabled', $enabled ); } /** * =========================================================================== * M0 -- CORE ENGINE: report a courier-verified outcome as an event. * =========================================================================== */ /** * Normalize a courier outcome to the canonical vocabulary. Anything not * clearly terminal collapses to 'unknown' (= "say nothing"). */ function orderguard_normalize_courier_outcome( $outcome ) { $outcome = strtolower( trim( (string) $outcome ) ); return in_array( $outcome, array( 'kept', 'returned', 'not_collected' ), true ) ? $outcome : 'unknown'; } /** * Report a courier-verified outcome for an order. * * Idempotent + deduped: the last reported outcome per source lives in order * meta (_orderguard_courier_outcome), and an event is only sent when it CHANGES -- * re-running the poller with the same result is a no-op. A changed verdict * (e.g. delivered, then a linked return shipment appears) IS re-sent and * re-arbitrated by the server's replay engine. * * Delivery guarantees: orderguard_send_event() retries transient failures on its own * backoff chain, and orderguard_reconcile_order() re-emits recorded courier outcomes * on every sweep (the server dedupes identical payloads), so a lost send is * recovered within the hour. * * @param WC_Order $order * @param string $outcome kept|returned|not_collected|unknown (unknown = no-op) * @param string $source courier id from the registry (e.g. 'econt') * @param array $meta optional: raw_status (short, non-PII), mode * @return bool whether a terminal outcome is recorded for this source */ function orderguard_report_courier_outcome( $order, $outcome, $source, $meta = array() ) { if ( ! $order instanceof WC_Order || ! orderguard_is_activated() ) { return false; } $outcome = orderguard_normalize_courier_outcome( $outcome ); if ( 'unknown' === $outcome ) { return false; // no confident signal -- do nothing, poll again later } $source = sanitize_key( (string) $source ); if ( '' === $source ) { return false; } // Dedupe: same source, same outcome -> already reported, nothing to do. $reported = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' ); if ( ! is_array( $reported ) ) { $reported = array(); } if ( isset( $reported[ $source ] ) && $reported[ $source ] === $outcome ) { return true; } // Trust tier (M5): a registered adapter id is a VERIFIED courier-API read // ('carrier'); anything else (merchant status map, TrackShip, the // orderguard_report_outcome dev hook) is the distinct lower-trust 'status_map' // tier. Derived from the registry -- never caller-asserted -- so the exact // same value is reproduced by the reconciliation sweep's re-emits. $kind = orderguard_courier_source_kind( $source ); // Same wire contract as every other event: order_ref + facts. The order's // hashed identity is already attached server-side via its /v1/check // sightings -- no identity data travels here (and the sweep re-checks + // re-sends if the server doesn't know the order yet). source_kind is only // added for the status_map tier: verified-carrier events stay byte- // identical to the proven M0-M3 contract (absent = 'carrier' server-side). $event = array( 'order_ref' => (string) $order->get_id(), 'event' => 'carrier_reported', 'outcome' => $outcome, 'courier' => $source, ); if ( 'carrier' !== $kind ) { $event['source_kind'] = $kind; } // WAS THIS A CASH-ON-DELIVERY ORDER? // // OrderGuard predicts one thing: will this customer collect a COD order. // A prepaid outcome is NOT the same evidence: // - prepaid 'kept' -> they collected something already paid for. // Says little about a COD parcel, where // walking away costs them nothing. // - prepaid 'not_collected' -> they paid AND still didn't collect. Rare, // and arguably a STRONGER signal than a COD // no-show. // Flattening the two lets a wall of prepaid collections make someone look // COD-reliable when they have never once been tested on COD. This matters // concretely: BOX NOW Bulgaria does not offer COD at all, so every BOX NOW // outcome is prepaid. // // Read from the ORDER, not the courier payload: it is the merchant's own // record of what the customer actually committed to, it is authoritative, // and it works for every courier without parsing four different API shapes. // // Sent as a fact for the server to WEIGH LATER -- scoring is unchanged for // now. The point is to stop discarding the distinction; a signal not // recorded today cannot be reasoned about tomorrow. // // Uses the plugin's existing orderguard_is_cod_order() so this agrees with the COD // gate, the review queue and the Econt adapter. If that definition is ever // wrong (a store using a custom COD gateway whose id isn't 'cod'), it is // wrong in one place, consistently, and fixing it there fixes this too. $event['cod'] = function_exists( 'orderguard_is_cod_order' ) ? (bool) orderguard_is_cod_order( $order ) : null; $data = orderguard_send_event( $event ); // Local record: dedupe map + provenance for the (later) UI chip + audit. $reported[ $source ] = $outcome; $order->update_meta_data( '_orderguard_courier_outcome', $reported ); // First-verdict timestamp per source: the poller keeps re-polling a // terminal order until this is ORDERGUARD_COURIER_REVISE_DAYS old, so a wrong or // superseded first verdict can still be corrected (H1). Only set once -- // a revised verdict must not restart the window. $first_seen = orderguard_get_order_meta_map( $order, '_orderguard_courier_first_seen', 'earliest' ); if ( ! is_array( $first_seen ) ) { $first_seen = array(); } if ( empty( $first_seen[ $source ] ) ) { $first_seen[ $source ] = time(); $order->update_meta_data( '_orderguard_courier_first_seen', $first_seen ); } // Provenance keyed BY SOURCE (like _orderguard_courier_outcome), so one courier's // record never overwrites another's. Migrate a legacy flat entry in place. $provenance = orderguard_get_order_meta_map( $order, '_orderguard_courier_provenance' ); if ( ! is_array( $provenance ) ) { $provenance = array(); } elseif ( isset( $provenance['courier'] ) && is_string( $provenance['courier'] ) ) { $provenance = array( $provenance['courier'] => $provenance ); // legacy flat shape -> keyed } $provenance[ $source ] = array( 'courier' => $source, 'raw_status' => isset( $meta['raw_status'] ) ? sanitize_text_field( (string) $meta['raw_status'] ) : '', 'outcome' => $outcome, 'ts' => time(), 'mode' => isset( $meta['mode'] ) ? sanitize_key( (string) $meta['mode'] ) : 'auto', 'kind' => $kind, // 'carrier' = verified adapter read; 'status_map' = mapped/asserted (M4 chip: verified tick only for 'carrier') ); $order->update_meta_data( '_orderguard_courier_provenance', $provenance ); $order->save(); // Lightweight tallies for the Features-tab card (M4). Local only, small // options, no identities -- just counts. orderguard_courier_bump_synced_today(); orderguard_courier_health_note_recorded( $source ); orderguard_courier_history_note( $source ); // Human-readable trace on the order (translated like all OG admin strings). $labels = array( 'kept' => __( 'delivered and accepted (kept)', 'orderguard' ), 'returned' => __( 'returned after delivery', 'orderguard' ), 'not_collected' => __( 'not collected', 'orderguard' ), ); $courier_label = orderguard_courier_source_label( $source ); $order->add_order_note( sprintf( /* translators: 1: courier name, 2: delivery outcome */ __( 'OrderGuard: %1$s reported this delivery as %2$s.', 'orderguard' ), $courier_label, $labels[ $outcome ] ) ); if ( is_array( $data ) && isset( $data['scores'] ) ) { orderguard_save_scores( $order, $data['scores'] ); } orderguard_log( 'OrderGuard: courier outcome recorded -- order #' . $order->get_id() . ' ' . $source . ' -> ' . $outcome ); return true; } /** * =========================================================================== * M1 -- ADAPTER REGISTRY + DETECTION + AWB LOCATOR + LOGO RESOLVER. * =========================================================================== * Each adapter is an associative entry; adding a courier later is just a new * entry (or a filter). Callbacks: * detect() -- is this courier's plugin active here? * order_uses( $order ) -- did this courier handle this order? * get_awb( $order ) -- the waybill/shipment number ('' if none) * poll( $order, $awb ) -- raw status array | null (not pollable: * missing creds/AWB -- skip silently) | * WP_Error (transient failure -- back off) * classify( $raw, $order ) -- kept|returned|not_collected|unknown * summary( $raw ) -- short NON-PII status string for audit * logo() -- URL of a locally-installed logo asset, or '' */ function orderguard_courier_adapters() { $adapters = array( 'econt' => array( 'id' => 'econt', 'label' => 'Econt', 'detect' => 'orderguard_econt_detect', 'order_uses' => 'orderguard_econt_order_uses', 'get_awb' => 'orderguard_econt_get_awb', 'poll' => 'orderguard_econt_poll', 'classify' => 'orderguard_econt_classify', 'summary' => 'orderguard_econt_status_summary', 'logo' => 'orderguard_econt_logo', ), // M6 -- thin adapters on the same framework (see the M6 section for // the orderguard_speedy_* / orderguard_boxnow_* / orderguard_pigeon_* callbacks). Detection // and the poller iterate this registry, so a new courier is ONLY a // new entry here + its callbacks -- no engine change. 'speedy' => array( 'id' => 'speedy', 'label' => 'Speedy', 'detect' => 'orderguard_speedy_detect', 'order_uses' => 'orderguard_speedy_order_uses', 'get_awb' => 'orderguard_speedy_get_awb', 'poll' => 'orderguard_speedy_poll', 'classify' => 'orderguard_speedy_classify', 'summary' => 'orderguard_speedy_status_summary', 'logo' => 'orderguard_speedy_logo', ), 'boxnow' => array( 'id' => 'boxnow', 'label' => 'BOX NOW', 'detect' => 'orderguard_boxnow_detect', 'order_uses' => 'orderguard_boxnow_order_uses', 'get_awb' => 'orderguard_boxnow_get_awb', 'poll' => 'orderguard_boxnow_poll', 'classify' => 'orderguard_boxnow_classify', 'summary' => 'orderguard_boxnow_status_summary', 'logo' => 'orderguard_boxnow_logo', ), 'pigeon' => array( 'id' => 'pigeon', 'label' => 'Pigeon Express', 'detect' => 'orderguard_pigeon_detect', 'order_uses' => 'orderguard_pigeon_order_uses', 'get_awb' => 'orderguard_pigeon_get_awb', 'poll' => 'orderguard_pigeon_poll', 'classify' => 'orderguard_pigeon_classify', 'summary' => 'orderguard_pigeon_status_summary', 'logo' => 'orderguard_pigeon_logo', ), ); $filtered = apply_filters( 'orderguard_courier_adapters', $adapters ); if ( ! is_array( $filtered ) ) { return array(); } // The registry key is the canonical courier id everywhere else: option-map // rows, cursor keys, scheduled-action arguments and outcome provenance. A // filtered id such as "DHL" must not write health under "dhl" and then read // it back under "DHL"; ids that sanitize to empty never enter the poller. $normalized = array(); foreach ( $filtered as $id => $adapter ) { $id = sanitize_key( (string) $id ); if ( '' === $id || ! is_array( $adapter ) ) { continue; } $adapter['id'] = $id; $normalized[ $id ] = $adapter; } return $normalized; } /** * One adapter by courier id, or null. */ function orderguard_courier_adapter( $courier ) { $adapters = orderguard_courier_adapters(); $courier = sanitize_key( (string) $courier ); return isset( $adapters[ $courier ] ) ? $adapters[ $courier ] : null; } /** * Which couriers are active on this store (by plugin presence). * Returns adapter arrays keyed by courier id. */ function orderguard_detect_couriers() { $active = array(); foreach ( orderguard_courier_adapters() as $id => $adapter ) { if ( isset( $adapter['detect'] ) && is_callable( $adapter['detect'] ) && call_user_func( $adapter['detect'] ) ) { $active[ $id ] = $adapter; } } return $active; } /** * Which courier handled an order ('' when none matches). */ function orderguard_order_courier( $order ) { if ( ! $order instanceof WC_Order ) { return ''; } foreach ( orderguard_courier_adapters() as $id => $adapter ) { if ( isset( $adapter['order_uses'] ) && is_callable( $adapter['order_uses'] ) && call_user_func( $adapter['order_uses'], $order ) ) { return $id; } } return ''; } /** * The shipping method_ids on an order (HPOS-safe -- reads order items). */ function orderguard_order_shipping_method_ids( $order ) { $ids = array(); foreach ( $order->get_items( 'shipping' ) as $item ) { $ids[] = $item->get_method_id(); } return $ids; } /** * The waybill/AWB number for an order under a given courier ('' if none). */ function orderguard_order_awb( $order, $courier ) { $adapter = orderguard_courier_adapter( $courier ); if ( ! $adapter || ! $order instanceof WC_Order || ! is_callable( $adapter['get_awb'] ) ) { return ''; } return (string) call_user_func( $adapter['get_awb'], $order ); } /** * Logo resolver. Tier 1 is an asset the MERCHANT's installed courier plugin * ships locally; tier 2 an OrderGuard-bundled neutral/brand-colored * placeholder wordmark (see orderguard_courier_bundled_logo() -- the merchant can * drop an official brand-kit SVG into assets/couriers/ to override). Remote * third-party logos are NEVER hotlinked. When neither tier resolves: 'img' * is '' and the caller renders the courier name next to the neutral parcel * icon instead. */ function orderguard_courier_logo( $courier ) { $adapter = orderguard_courier_adapter( $courier ); $img = ''; if ( $adapter && isset( $adapter['logo'] ) && is_callable( $adapter['logo'] ) ) { $img = (string) call_user_func( $adapter['logo'] ); } return array( 'label' => $adapter ? $adapter['label'] : ucfirst( (string) $courier ), 'img' => $img, // '' => no local asset; use icon + label 'icon' => 'dashicons-archive', // neutral parcel fallback ); } /** * Logo resolution, tier 2: an OrderGuard-bundled asset at * assets/couriers/{id}.svg. Each adapter's logo() tries the courier's OWN * installed-plugin asset first (tier 1), then falls back here, then to '' * (tier 3 = name + neutral icon in the UI). * * To show a courier's real logo, drop an official brand-kit SVG named * {id}.svg (econt|speedy|boxnow|pigeon) into assets/couriers/ -- it is picked * up automatically with no code change. OrderGuard bundles neutral branded * placeholders by default and NEVER hotlinks remote third-party logos. */ function orderguard_courier_bundled_logo( $id ) { $id = sanitize_key( (string) $id ); if ( '' === $id ) { return ''; } $file = plugin_dir_path( __FILE__ ) . 'assets/couriers/' . $id . '.svg'; if ( file_exists( $file ) ) { return plugin_dir_url( __FILE__ ) . 'assets/couriers/' . $id . '.svg'; } return ''; } /** * =========================================================================== * M2 -- BACKGROUND POLLER (Action Scheduler, hourly, batched by courier). * =========================================================================== * Mirrors the reconciliation-sweep + history-import patterns: a recurring * dispatcher fans out one async batch chain per detected courier; each batch * action polls a bounded page of recent orders and chains the next offset. * Nothing here ever runs on a page load, and every guard re-checks consent. * * NOTE: bump ORDERGUARD_COURIER_SYNC_SCHED_VER whenever ORDERGUARD_COURIER_SYNC_INTERVAL * changes (same migration pattern as ORDERGUARD_SWEEP_SCHED_VER). */ if ( ! defined( 'ORDERGUARD_COURIER_SYNC_INTERVAL' ) ) { define( 'ORDERGUARD_COURIER_SYNC_INTERVAL', HOUR_IN_SECONDS ); } if ( ! defined( 'ORDERGUARD_COURIER_SYNC_SCHED_VER' ) ) { define( 'ORDERGUARD_COURIER_SYNC_SCHED_VER', '1' ); } if ( ! defined( 'ORDERGUARD_COURIER_BATCH' ) ) { define( 'ORDERGUARD_COURIER_BATCH', 10 ); } // orders queried per action -- keeps each action well under AS runtime limits if ( ! defined( 'ORDERGUARD_COURIER_LOOKBACK_DAYS' ) ) { define( 'ORDERGUARD_COURIER_LOOKBACK_DAYS', 60 ); } if ( ! defined( 'ORDERGUARD_COURIER_REVISE_DAYS' ) ) { define( 'ORDERGUARD_COURIER_REVISE_DAYS', 14 ); } // grace window: a recorded verdict keeps being re-polled this long so it can be REVISED (H1) if ( ! defined( 'ORDERGUARD_COURIER_BATCH_BUDGET' ) ) { define( 'ORDERGUARD_COURIER_BATCH_BUDGET', 30 ); } // seconds -- elapsed-time bail-out per batch action (L2) if ( ! defined( 'ORDERGUARD_COURIER_RESOURCE_MISMATCH_LIMIT' ) ) { define( 'ORDERGUARD_COURIER_RESOURCE_MISMATCH_LIMIT', 3 ); } // consecutive authenticated parcel/account mismatches before this hourly chain backs off add_action( 'init', 'orderguard_maybe_schedule_courier_sync' ); function orderguard_maybe_schedule_courier_sync() { if ( ! orderguard_is_activated() || ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return; } $enabled_ok = false; $enabled = orderguard_courier_sync_enabled( $enabled_ok ); if ( ! $enabled_ok ) { return; } // Consent OFF -> keep the job unscheduled. The option flag makes the OFF // path a single cached get_option() -- a store that never opts in never // pays an Action Scheduler lookup on init. if ( ! $enabled ) { if ( orderguard_get_option( 'orderguard_courier_sync_sched_ver' ) ) { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( 'orderguard_courier_sync', array(), 'orderguard' ); } orderguard_delete_option( 'orderguard_courier_sync_sched_ver' ); orderguard_log( 'OrderGuard: courier sync disabled -- unscheduled' ); } return; } $interval = (int) apply_filters( 'orderguard_courier_sync_interval', ORDERGUARD_COURIER_SYNC_INTERVAL ); if ( $interval < 15 * MINUTE_IN_SECONDS ) { $interval = 15 * MINUTE_IN_SECONDS; // floor -- be polite to courier APIs } // Migration: cadence changed -> drop the old recurring action and re-register. $schedule_ok = false; $stored_sched_ver = orderguard_get_option( 'orderguard_courier_sync_sched_ver', false, $schedule_ok ); if ( ! $schedule_ok ) { return; } if ( $stored_sched_ver !== ORDERGUARD_COURIER_SYNC_SCHED_VER ) { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( 'orderguard_courier_sync', array(), 'orderguard' ); } orderguard_update_option( 'orderguard_courier_sync_sched_ver', ORDERGUARD_COURIER_SYNC_SCHED_VER, false ); } if ( ! as_has_scheduled_action( 'orderguard_courier_sync', array(), 'orderguard' ) ) { as_schedule_recurring_action( time() + 5 * MINUTE_IN_SECONDS, $interval, 'orderguard_courier_sync', array(), 'orderguard' ); orderguard_log( 'OrderGuard: scheduled courier sync every ' . $interval . 's' ); } } add_action( 'orderguard_courier_sync', 'orderguard_run_courier_sync' ); /** * Dispatcher: one batch chain per detected courier (batched BY courier so a * slow courier API never starves another). */ function orderguard_run_courier_sync() { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } // 0.1.8: one-time seeding of the per-courier counters from history already // on the orders (see the COUNTER BACKFILL section). Piggybacked on this // hourly dispatcher on purpose: it runs in Action Scheduler context (never // on a page load), only while courier sync is consented ON, and its guard // is one option read once the backfill is done. orderguard_courier_backfill_maybe_schedule(); // Don't stack a fresh chain on top of one still queued/running from the // previous run (a slow courier API + hourly cadence can overlap): if ANY // batch action is still pending, skip this dispatch -- the next tick // retries once the old chain has drained. if ( function_exists( 'as_has_scheduled_action' ) && orderguard_has_scheduled_action( 'orderguard_courier_sync_batch', null, 'orderguard' ) ) { orderguard_log( 'OrderGuard: courier sync -- previous batch chain still pending; skipping this dispatch' ); return; } $detected = orderguard_detect_couriers(); foreach ( $detected as $courier_id => $adapter ) { // H4: resume from the persisted cursor, not from 0. Cursor 0 (or an // expired one) starts a fresh pass over the window; a mid-window // cursor continues the pass a previous run's bail-out or per-run // order cap left unfinished -- so orders past the first pages, and // past the cap, are eventually polled instead of never. orderguard_schedule_courier_batch( $courier_id, orderguard_courier_sync_cursor( $courier_id ) ); } orderguard_log( 'OrderGuard: courier sync -- dispatched batch chain(s) for ' . count( $detected ) . ' courier(s)' ); } /** * Queue one poll batch (async, or after $delay seconds). * * $chain_start is the offset this CHAIN began at (H4). The per-run order cap * in orderguard_run_courier_batch() bounds ($next - $chain_start) -- orders polled BY * THIS CHAIN -- not the absolute offset. Bounding the absolute offset would * cap a chain resumed from cursor 500 at zero work forever, which is the H4 * bug in a new coat. * * $auth_failures carries one unconfirmed authentication-shaped response * across pages in this chain. It is never carried into a later hourly chain, * where the same resource alone would otherwise become its own confirmation. * * $resource_mismatches similarly carries parcel/account mismatches without * an intervening valid response across mixed-courier pages. It is separate * from authentication: a fresh token was accepted, so accusing the * merchant's credentials would be false. A valid parcel response resets it. */ function orderguard_schedule_courier_batch( $courier, $offset, $delay = 0, $chain_start = null, $auth_failures = 0, $resource_mismatches = 0 ) { if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_enqueue_async_action' ) ) { return; } $chain_start = ( null === $chain_start ) ? (int) $offset : (int) $chain_start; $auth_failures = max( 0, min( 1, (int) $auth_failures ) ); $resource_mismatches = max( 0, min( ORDERGUARD_COURIER_RESOURCE_MISMATCH_LIMIT - 1, (int) $resource_mismatches ) ); $args = array( (string) $courier, (int) $offset, $chain_start, $auth_failures, $resource_mismatches ); // positional -> orderguard_run_courier_batch( $courier, $offset, $chain_start, $auth_failures, $resource_mismatches ) if ( $delay > 0 ) { as_schedule_single_action( time() + (int) $delay, 'orderguard_courier_sync_batch', $args, 'orderguard' ); } else { as_enqueue_async_action( 'orderguard_courier_sync_batch', $args, 'orderguard' ); } } add_action( 'orderguard_courier_sync_batch', 'orderguard_run_courier_batch', 10, 5 ); /** * --------------------------------------------------------------------------- * OFFSET CURSOR (H4) -- where the next batch chain for a courier resumes. * --------------------------------------------------------------------------- * Before this existed, every bail-out (time budget, repeated failures, the * per-run order cap) killed the chain WITHOUT rescheduling, and the next * hourly dispatch always restarted at offset 0. Two silent consequences: * a slow courier API re-polled the same first page every hour forever and * never reached offset 10, and on a store with more window orders than the * per-run cap (500 for API adapters), everything past the cap was NEVER * polled -- no error, no log, outcomes just missing. * * CURSOR LIFECYCLE: * - ADVANCE written at every chain step and every bail-out (the exact * resume position, not the page start); * - RESET a short/empty page = the end of the window = the PASS is * complete -> cursor back to 0; the next run starts a new pass; * - EXPIRE a cursor untouched for ORDERGUARD_COURIER_CURSOR_TTL reads as 0. A * live chain rewrites it every few seconds; a stale one means * the chain died without finishing (plugin deactivated * mid-pass, a fatal inside an adapter, Action Scheduler * wedged). Expiring to 0 restarts from the freshest orders -- * degrading to exactly the OLD behaviour at worst -- instead of * resuming a week-old mid-window position or wedging forever on * one poisonous offset. * * KNOWN, ACCEPTED IMPRECISION: the offset indexes a DESC-ordered, * status-filtered list that can shift underneath the cursor. An order * leaving the set (e.g. completed -> refunded) behind the cursor shifts * later orders one position up, so one order can slip past and be missed * FOR THE REST OF THAT PASS -- the next pass, from 0, covers it. New orders * shift positions the other way, which only re-polls a few already-seen * orders (orderguard_report_courier_outcome dedupes; 'unknown' re-classifies are * free). Bounded staleness, no permanent loss. */ if ( ! defined( 'ORDERGUARD_COURIER_CURSOR_TTL' ) ) { define( 'ORDERGUARD_COURIER_CURSOR_TTL', 6 * HOUR_IN_SECONDS ); } /** * The resume offset for one courier (0 = start of the window / fresh pass). */ function orderguard_courier_sync_cursor( $courier ) { $courier = sanitize_key( (string) $courier ); if ( '' === $courier ) { return 0; } $all = orderguard_get_option( 'orderguard_courier_sync_cursor', array() ); if ( ! is_array( $all ) || ! isset( $all[ $courier ] ) || ! is_array( $all[ $courier ] ) ) { return 0; } $offset = isset( $all[ $courier ]['offset'] ) ? (int) $all[ $courier ]['offset'] : 0; $ts = isset( $all[ $courier ]['ts'] ) ? (int) $all[ $courier ]['ts'] : 0; if ( $offset <= 0 || $ts <= 0 || ( time() - $ts ) > ORDERGUARD_COURIER_CURSOR_TTL ) { return 0; // nothing to resume, or the chain that wrote this is long dead } return $offset; } /** * Persist the resume offset (0 clears the entry -- pass complete). * * This is a compare-and-swap rather than an Options API read/modify/write. * One hourly dispatcher can have a batch for each courier running in parallel; * an absolute update_option() of the shared map lets the last writer erase the * other couriers' newer cursors. Each retry rebuilds from fresh database bytes. * * @return bool True only when the requested cursor state is durable. */ function orderguard_courier_sync_set_cursor( $courier, $offset ) { global $wpdb; $option = 'orderguard_courier_sync_cursor'; $courier = sanitize_key( (string) $courier ); $offset = max( 0, (int) $offset ); $now = time(); if ( '' === $courier ) { return false; } for ( $attempt = 1; $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 $stored = $wpdb->get_row( $wpdb->prepare( "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- cursor CAS must bypass Options API caches. if ( null === $stored && '' !== $wpdb->last_error ) { return false; } if ( null === $stored ) { if ( 0 === $offset ) { return true; } $stamp_found = false; $stamp_ok = false; $stamp = orderguard_migration_control_read( 'orderguard_data_migration_version', $stamp_found, $stamp_ok ); if ( ! $stamp_ok || ! $stamp_found || ( defined( 'ORDERGUARD_DATA_MIGRATION_VERSION' ) && ORDERGUARD_DATA_MIGRATION_VERSION !== (string) $stamp ) ) { return false; } $all = array( $courier => array( 'offset' => $offset, 'ts' => $now, ), ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these $created = $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, maybe_serialize( $all ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic first cursor writer after migration. if ( 1 === (int) $created ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return true; } if ( false === $created ) { return false; } continue; } $all = maybe_unserialize( $stored['option_value'] ); if ( ! is_array( $all ) ) { orderguard_courier_health_log_once( 'malformed_cursor_map', 'OrderGuard: courier cursor map was malformed; cursor update was blocked to preserve the stored value.' ); return false; } if ( 0 === $offset ) { unset( $all[ $courier ] ); } else { $all[ $courier ] = array( 'offset' => $offset, 'ts' => $now, ); } $merged = maybe_serialize( $all ); $autoload = (string) $stored['autoload']; if ( $merged === $stored['option_value'] && 'off' === $autoload ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s, autoload = 'off' WHERE option_name = %s AND BINARY option_value = BINARY %s AND BINARY autoload = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $merged, $option, $stored['option_value'], $autoload ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- byte-exact cursor compare-and-swap. if ( 1 === (int) $updated ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return true; } if ( false === $updated ) { return false; } // affected_rows=0 can mean either another courier won or wpdb retried // our already-committed absolute write after a lost acknowledgement. // A fresh loop distinguishes them without reusing stale map bytes. } return false; } /** * The UNIX ts of the FIRST terminal verdict recorded for this courier on this * order (0 = none/unknowable). Reads _orderguard_courier_first_seen, falling back to * the provenance ts for records written before the first-seen meta existed * (both the source-keyed and the legacy flat provenance shape). */ function orderguard_courier_first_verdict_ts( $order, $courier ) { $first_seen = orderguard_get_order_meta_map( $order, '_orderguard_courier_first_seen', 'earliest' ); if ( is_array( $first_seen ) && ! empty( $first_seen[ $courier ] ) ) { return (int) $first_seen[ $courier ]; } $provenance = orderguard_get_order_meta_map( $order, '_orderguard_courier_provenance' ); if ( is_array( $provenance ) ) { if ( isset( $provenance[ $courier ]['ts'] ) ) { return (int) $provenance[ $courier ]['ts']; } if ( isset( $provenance['ts'] ) && isset( $provenance['courier'] ) && $courier === $provenance['courier'] ) { return (int) $provenance['ts']; // legacy flat shape } } return 0; } /** * Poll ONE page of recent orders for one courier, then chain the next page. * * In-flight selection: recent orders (lookback window) in a shipped-ish * status, handled by this courier, with no terminal courier outcome recorded * yet -- PLUS terminal orders still inside the ORDERGUARD_COURIER_REVISE_DAYS grace * window, so a first verdict is never permanent: a wrong call, or a genuine * later change (delivered, then the return leg shows up in nextShipments, * flipping kept -> returned), is corrected by re-polling. The correction path * RELIES on this window: orderguard_report_courier_outcome() re-sends only on change, * and the server's replay engine lets the latest carrier verdict supersede * the earlier one -- no new event type needed. Orders whose verdict has aged * past the window (or that leave the 60-day lookback) stop being polled. * Unknown/in-transit results are left for a later run. * * H4 -- EVERY exit is resumable. The offset cursor (see the cursor section) * records exactly where this action stopped: a time-budget bail-out chains a * continuation immediately, a repeated-failure back-off resumes on the next * hourly run, the per-run order cap resumes on the next hourly run, and only * a SHORT page (the end of the window) resets the cursor to 0 -- that is the * one moment a pass is genuinely finished. * * H3/M1 -- while walking its page, the batch also counts this courier's * orders (eligible) and, of those, the DETECTOR'S EVIDENCE: orders old * enough that a terminal outcome should exist, created after the last * successful read, and still without one. The counts are summed per PASS * (offset 0 starts the pass, the short page ends it), so on a mixed-courier * store the total is the courier's real order count across the whole window * -- not the per-page fragment that could mathematically never reach the * alarm threshold. * * 0.1.9 -- the same walk feeds the OUTCOME COVERAGE METER: of the claimed * orders created inside the coverage band (see the coverage constants in the * health section), how many have a terminal outcome read by this source. * Counted here because the orders are already loaded; the Features card only * ever reads the snapshot (orderguard_courier_coverage), never scans orders itself. */ function orderguard_run_courier_batch( $courier = '', $offset = 0, $chain_start = null, $auth_failures = 0, $resource_mismatches = 0 ) { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } $started = time(); // elapsed-time budget for this action (see below) $courier = (string) $courier; $offset = max( 0, (int) $offset ); // The offset this CHAIN began at (bounds per-chain work -- see the tail). // Batches queued by a pre-cursor build carry two args -> null -> treat // this action's own offset as the chain start (worst case: one // transitional chain gets a fresh cap). Pre-auth-confirmation actions // carry three args; the optional fourth and fifth safely default to zero. $chain_start = ( null === $chain_start ) ? $offset : max( 0, (int) $chain_start ); // A single auth-shaped result can be resource-scoped. Carry that candidate // across this chain's pages so mixed-courier pages can still confirm a // genuinely account-wide failure. Pre-change queued actions omit it. $auth_failures = max( 0, min( 1, (int) $auth_failures ) ); // BOX NOW can accept a fresh token while rejecting a parcel owned by a // different partner account. Carry consecutive mismatches across pages, // but never let a stored or malformed action start at the stop boundary. $resource_mismatches = max( 0, min( ORDERGUARD_COURIER_RESOURCE_MISMATCH_LIMIT - 1, (int) $resource_mismatches ) ); $adapter = orderguard_courier_adapter( $courier ); if ( ! $adapter || ! is_callable( $adapter['detect'] ) || ! call_user_func( $adapter['detect'] ) ) { return; // courier plugin gone -- stop the chain quietly } // A legacy queued action may still carry a pre-normalization filter key. // Once the adapter resolves, its registry id is authoritative everywhere: // health, cursor, outcome metadata, filters and any chained action. $courier = (string) $adapter['id']; // Health bookkeeping (H3/M1). Offset 0 = a new pass over the window: the // pass accumulators restart. A mid-window offset is a continuation of the // pass already in flight and keeps accumulating. The seed call dates // pre-upgrade history (see orderguard_courier_health_seed_last_recorded) so the // stopped-working detector never judges outcomes it cannot date. $health_ok = false; $health = orderguard_courier_health_seed_last_recorded( $courier, $health_ok ); if ( ! $health_ok ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- health state could not be read; batch stopped safely' ); return; } if ( 0 === $offset && ! orderguard_courier_health_pass_begin( $courier ) ) { return; } // Watershed for the detector's evidence: only orders CREATED after the // last successful read can testify that reading has stopped. Read once, // here -- an outcome recorded mid-page moves the live value, but the // fast-clear in orderguard_courier_looks_broken() makes that moot. $watershed = (int) $health['last_recorded_at']; // Statuses that mean "this order has (probably) shipped". Custom-status // stores can widen this via the filter. $statuses = (array) apply_filters( 'orderguard_courier_sync_statuses', array( 'wc-processing', 'wc-completed' ) ); // How far back to look -- PER COURIER, not one number for everybody. // // The 60-day default exists to bound COURIER API TRAFFIC: a store with // thousands of historical orders must never fire thousands of requests at // Econt or Speedy. That is a real constraint and it stays for API adapters. // // But it is the wrong constraint for an adapter that makes NO API CALLS. // The mreja Econt adapter reads the merchant's own database; there is no // remote party to be polite to and no rate limit to respect. On a live // store this default was hiding 123 of 129 confirmed COD deliveries -- // courier-verified outcomes, money genuinely collected, sitting on the // merchant's own disk, unreachable purely because of a budget for traffic // that adapter never generates. At cold start that is the most valuable // data available, and it costs a SELECT. // // Adapters opt in via this filter (see orderguard_econt_lookback_days). $lookback = (int) apply_filters( 'orderguard_courier_lookback_days', ORDERGUARD_COURIER_LOOKBACK_DAYS, $courier ); $lookback = max( 1, min( 3650, $lookback ) ); // sanity clamp: 1 day .. 10 years // Orders per action, and the pause between polls. Both exist to be gentle // to a COURIER'S API. An adapter that only reads local tables is talking to // nobody, so it gets a bigger page and no pause -- otherwise a long lookback // would crawl (10 orders an hour) and the 250ms pause alone would burn most // of the time budget doing nothing. $batch_size = (int) apply_filters( 'orderguard_courier_batch_size', ORDERGUARD_COURIER_BATCH, $courier ); $batch_size = max( 1, min( 200, $batch_size ) ); $pace_us = (int) apply_filters( 'orderguard_courier_pace_us', 250000, $courier ); $pace_us = max( 0, min( 1000000, $pace_us ) ); // The per-CHAIN order cap (H4). Computed up front because EVERY path that // wants to chain another action must respect it -- including the budget // bail-out's continuation, or a slow API crawling in sub-page steps would // never hit the full-page check and could stream far past the hourly // traffic bound in one unbroken chain. $max_orders = (int) apply_filters( 'orderguard_courier_sync_max_orders_per_run', 500, $courier ); $max_orders = max( $batch_size, min( 100000, $max_orders ) ); $orders = wc_get_orders( array( 'limit' => $batch_size, 'offset' => (int) $offset, 'orderby' => 'date', 'order' => 'DESC', 'status' => $statuses, 'date_created' => '>' . ( time() - $lookback * DAY_IN_SECONDS ), 'return' => 'objects', ) ); $orders = is_array( $orders ) ? $orders : array(); $failures = 0; $eligible = 0; // this courier's orders seen in THIS page -- summed into the pass accumulator (H3) $evidence = 0; // of those: old enough to have an outcome, created after the last read, still without one (H3/M1) $cov_denom = 0; // coverage meter (0.1.9): claimed orders old enough that a terminal outcome should exist $cov_num = 0; // coverage meter (0.1.9): of those, outcomes actually read by THIS source $processed = 0; // orders of this page fully consumed -- the cursor's resume point (H4) $resolve_cutoff = time() - ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS * DAY_IN_SECONDS; // Coverage band (0.1.9): orders created between these two are the meter's // denominator -- old enough to have finished (AGE_DAYS), recent enough to // reflect the integration's CURRENT behaviour (WINDOW_DAYS, clamped to the // adapter lookback so the band never exceeds what this pass actually // walks -- mreja's 730-day lookback must not become a 730-day denominator). $cov_oldest = time() - min( ORDERGUARD_COURIER_COVERAGE_WINDOW_DAYS, $lookback ) * DAY_IN_SECONDS; $cov_newest = time() - ORDERGUARD_COURIER_COVERAGE_AGE_DAYS * DAY_IN_SECONDS; foreach ( $orders as $order ) { // Bail out when this action has used up its time budget: a slow // courier API must not push one action past the AS runtime limit. // H4: the bail-out is RESUMABLE. The cursor records exactly where // this page stopped and, when any progress was made, the chain // continues from there immediately (each continuation is its own // budgeted action, so AS limits are still respected; hourly volume // is still bounded by the per-run order cap below). Zero progress // means the order QUERY itself ate the budget -- an immediate retry // would spin on it, so that case waits for the next hourly run. if ( ( time() - $started ) > ORDERGUARD_COURIER_BATCH_BUDGET ) { // A zero-progress query has counted no page at all. Do not publish // its offset as a dedupe watermark: the next genuine walk must be // allowed to contribute this page's health counters. $counted_offset = $processed > 0 ? $offset : null; if ( ! orderguard_courier_health_note_run( $courier, $eligible, $evidence, $cov_denom, $cov_num, $counted_offset ) ) { return; } $resume = $offset + $processed; // With zero progress, leave the existing cursor bytes untouched. // Rewriting the same offset with ts=now would defeat the six-hour // stale-cursor rescue and could wedge one poisonous query forever. if ( $processed > 0 && ! orderguard_courier_sync_set_cursor( $courier, $resume ) ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- cursor update failed after a time-budget exit; chain stopped with the existing cursor unchanged' ); return; } if ( $processed > 0 && ( $resume - $chain_start ) < $max_orders ) { orderguard_schedule_courier_batch( $courier, $resume, 0, $chain_start, $auth_failures, $resource_mismatches ); orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- batch time budget exhausted; chaining continuation at offset ' . $resume ); } elseif ( $processed > 0 ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- time budget and per-run order cap exhausted; resuming at offset ' . $resume . ' next run' ); } else { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- time budget exhausted before any order was processed; resuming at offset ' . $resume . ' next run' ); } return; } $processed++; if ( ! $order instanceof WC_Order ) { continue; } // Only this courier's orders (the same order set is paged once per // courier chain; each chain picks out its own). if ( ! call_user_func( $adapter['order_uses'], $order ) ) { continue; } $eligible++; $reported = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' ); $has_verdict = is_array( $reported ) && ! empty( $reported[ $courier ] ); $created = $order->get_date_created(); $created_ts = $created ? (int) $created->getTimestamp() : 0; // Coverage meter (0.1.9): inside the band = part of the denominator; // read by this source = part of the numerator. Counted for EVERY // claimed order (verdict or not) -- the meter is a ratio of the two, // so both sides must see the same set. An order with no readable // creation date is counted in neither (same stance as the evidence // rule below: never judge what cannot be dated). if ( $created_ts >= $cov_oldest && $created_ts <= $cov_newest ) { $cov_denom++; if ( $has_verdict ) { $cov_num++; } } // Detector evidence (H3/M1): this order testifies that the adapter // has stopped (or never started) reading when a terminal outcome // could reasonably exist and none was ever read -- created at least // ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS ago (not merely in transit), created // AFTER the last successful read (older unresolved orders are the // permanent-unknown residue every healthy store accumulates: // cancelled parcels, Speedy 195s, merchant recalls -- they must not // arm the alarm forever), and no terminal outcome recorded by THIS // courier. Deliberately NOT gated on an AWB being present: a broken // get_awb (a courier plugin renaming its meta on update) is exactly // the silent failure this must catch. if ( ! $has_verdict && $created_ts > 0 && $created_ts <= $resolve_cutoff && $created_ts > $watershed ) { $evidence++; } // Terminal outcome recorded for this courier: keep RE-polling while // the first verdict is younger than the grace window (so it can be // revised -- see the function docblock); only a verdict older than // ORDERGUARD_COURIER_REVISE_DAYS is final and stops being polled. if ( $has_verdict ) { $first_ts = orderguard_courier_first_verdict_ts( $order, $courier ); if ( $first_ts <= 0 || ( time() - $first_ts ) > ORDERGUARD_COURIER_REVISE_DAYS * DAY_IN_SECONDS ) { continue; // aged out (or no timestamp to judge by) -- verdict stands } } $awb = orderguard_order_awb( $order, $courier ); if ( $pace_us > 0 ) { // Pace before the adapter call so every attempted remote poll is // spaced even when the adapter returns an error or a non-array. // Local-only adapters opt out through the pace filter. usleep( $pace_us ); } $raw = call_user_func( $adapter['poll'], $order, $awb ); if ( is_wp_error( $raw ) ) { $error_code = (string) $raw->get_error_code(); $auth_error = 'orderguard_courier_auth_failed' === $error_code; $rate_limit = 'orderguard_courier_rate_limited' === $error_code; $resource_mismatch = 'orderguard_courier_resource_mismatch' === $error_code; if ( $resource_mismatch ) { // A fresh BOX NOW token was accepted, so this is not evidence // that the credentials are bad. Keep it out of both the auth // diagnostic and the transient strike counter. Three // consecutive mismatches bound wrong-partner traffic without // letting one historical foreign parcel starve the chain. $auth_failures = 0; if ( (int) $health['auth_failed_at'] > 0 ) { if ( orderguard_courier_health_clear_auth_failed( $courier ) ) { $health['auth_failed_at'] = 0; } else { orderguard_courier_health_log_once( 'auth_clear_' . $courier, 'OrderGuard: recovered courier authentication state could not be cleared for ' . $courier . '.' ); } } $resource_mismatches++; orderguard_log( 'OrderGuard: courier parcel/account mismatch (' . $courier . ') order #' . $order->get_id() . ' -- ' . $raw->get_error_message() ); if ( $resource_mismatches >= ORDERGUARD_COURIER_RESOURCE_MISMATCH_LIMIT ) { // Preserve the durable page boundary and unpublished pass // counters, just like other terminal backoffs. The next // hourly run may recover after account access is fixed. orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- repeated parcel/account mismatches; backing off until next run with the existing cursor unchanged' ); return; } continue; } if ( $auth_error ) { $auth_failures++; if ( $auth_failures < 2 ) { // A resource belonging to another courier account can be // forbidden while the saved credentials remain valid. // Skip one ambiguous response; a second order confirms // that this is account-wide before stopping the chain. orderguard_log( 'OrderGuard: courier poll returned one authentication-shaped failure (' . $courier . ') order #' . $order->get_id() . ' -- awaiting confirmation from another order' ); continue; } } // A confirmed authentication failure and an explicit rate limit // apply to the account, not one shipment. Stop and let the // ordinary hourly dispatcher retry; never route either through // the transient-error strike counter. if ( $auth_error || $rate_limit ) { if ( $auth_error && ! orderguard_courier_health_note_auth_failed( $courier ) ) { orderguard_courier_health_log_once( 'auth_failed_' . $courier, 'OrderGuard: courier authentication failure could not be recorded for ' . $courier . '.' ); } orderguard_log( 'OrderGuard: courier poll stopped (' . $courier . ') order #' . $order->get_id() . ' -- ' . $raw->get_error_message() ); // Do not publish a partial page aggregate or advance its cursor. // The next hourly run retries the same page from its durable // boundary without double-counting or poisoning last_offset. orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- terminal courier error; backing off until next run with the existing cursor unchanged' ); return; } // Transient failure (network / courier 5xx). A bad hour shouldn't // hammer anyone: three strikes and this run backs off entirely. // H4: backing off means stopping for the rest of this hour, NOT // forgetting where we were -- the cursor stays ON the failing // order (it failed transiently, so the next hourly run retries // it), and no continuation is chained. $failures++; orderguard_log( 'OrderGuard: courier poll failed (' . $courier . ') order #' . $order->get_id() . ' -- ' . $raw->get_error_message() ); if ( $failures >= 3 ) { if ( ! orderguard_courier_health_note_run( $courier, $eligible, $evidence, $cov_denom, $cov_num, $offset ) ) { return; } $resume = max( 0, $offset + $processed - 1 ); // $processed already counts this order if ( ! orderguard_courier_sync_set_cursor( $courier, $resume ) ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- cursor update failed after repeated courier errors; chain stopped with the existing cursor unchanged' ); return; } orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- repeated failures; backing off until next run (will resume at offset ' . $resume . ')' ); return; } continue; } if ( ! is_array( $raw ) ) { continue; // not pollable (no creds / no AWB / order unknown) -- skip gracefully } $failures = 0; $auth_failures = 0; $resource_mismatches = 0; if ( (int) $health['auth_failed_at'] > 0 ) { if ( orderguard_courier_health_clear_auth_failed( $courier ) ) { $health['auth_failed_at'] = 0; } else { orderguard_courier_health_log_once( 'auth_clear_' . $courier, 'OrderGuard: recovered courier authentication state could not be cleared for ' . $courier . '.' ); } } $outcome = orderguard_normalize_courier_outcome( call_user_func( $adapter['classify'], $raw, $order ) ); if ( 'unknown' !== $outcome ) { $summary = ( isset( $adapter['summary'] ) && is_callable( $adapter['summary'] ) ) ? (string) call_user_func( $adapter['summary'], $raw ) : ''; orderguard_report_courier_outcome( $order, $outcome, $courier, array( 'raw_status' => $summary ) ); } // 'unknown' = still in transit / ambiguous -- a later run re-checks. } // Chain the next page while full pages keep coming -- bounded PER CHAIN so // a huge store never spawns a runaway chain. The cap bounds courier API // traffic per hourly run; it no longer bounds COVERAGE (H4): when it is // reached, the cursor persists and the next hourly dispatch resumes right // there, so a window deeper than the cap is fully covered across // successive runs instead of everything past position 500 being // unreachable forever. Most recent orders are still covered first each // pass thanks to the DESC ordering. ($max_orders is computed before the // loop -- the budget bail-out's continuation shares the same cap.) $next = (int) $offset + $batch_size; if ( count( $orders ) === $batch_size ) { // Full page: the window continues past it. if ( ! orderguard_courier_health_note_run( $courier, $eligible, $evidence, $cov_denom, $cov_num, $offset ) ) { return; } if ( ! orderguard_courier_sync_set_cursor( $courier, $next ) ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- cursor advance to offset ' . $next . ' failed; continuation was not scheduled' ); return; } if ( ( $next - $chain_start ) < $max_orders ) { orderguard_schedule_courier_batch( $courier, $next, 0, $chain_start, $auth_failures, $resource_mismatches ); } else { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- per-run order cap reached; resuming at offset ' . $next . ' next run' ); } return; } // Short (or empty) page: the end of the window -- this PASS is complete. // Add this final page and snapshot the pass in ONE mutation. Splitting the // two writes can either fuse two passes after a contended finalization, or // double-count this page when a retry keeps the cursor in place. if ( ! orderguard_courier_health_complete_pass( $courier, $eligible, $evidence, $cov_denom, $cov_num, $offset ) ) { return; } if ( ! orderguard_courier_sync_set_cursor( $courier, 0 ) ) { orderguard_log( 'OrderGuard: courier sync (' . $courier . ') -- completed pass was saved but its cursor could not be cleared; detector evaluation was deferred' ); return; } // Say it in the log too, not only in the admin. A merchant may never open // the Features tab; a host's error log is where a silent failure should // still be findable months later. Logged once per COMPLETED pass -- the // moment the counters are freshest -- instead of once per page, so a // genuinely broken courier is loud (hourly on a typical store) without // burying the rest of the log. if ( orderguard_courier_looks_broken( $courier ) ) { $h = orderguard_courier_health( $courier ); $since = $h['last_recorded_at'] > 0 ? $h['last_recorded_at'] : $h['first_seen']; orderguard_log( sprintf( 'OrderGuard: courier sync (%s) looks BROKEN -- %d orders shipped since %s are older than %d days and not one delivery outcome could be read for them (last successful read: %s). ' . 'This is almost certainly an adapter or configuration failure (wrong field name, changed option key, rotated credentials, a frozen local mirror) or waybills OrderGuard cannot see -- not parcels in transit.', $courier, orderguard_courier_health_evidence( $h ), gmdate( 'Y-m-d', $since ), ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS, $h['last_recorded_at'] > 0 ? gmdate( 'Y-m-d', $h['last_recorded_at'] ) : 'never' ) ); } elseif ( orderguard_courier_coverage_alarm( $courier ) ) { // The notice's arm B (0.1.9), same once-per-completed-pass cadence as // the BROKEN line -- the card shows "Problem", so the log must hold // the numbers support will ask for. elseif: when both arms fire, the // BROKEN line already carries the sharper diagnosis. $h = orderguard_courier_health( $courier ); orderguard_log( sprintf( 'OrderGuard: courier sync (%s) coverage LOW -- an outcome was read for only %d of %d shipments older than %d days (window: last %d days). ' . 'Reads are still landing, so this looks like a PARTIAL failure: one outcome branch broken by a courier-plugin update, or waybills created outside the plugin -- not a dead adapter.', $courier, max( 0, min( (int) $h['last_pass']['cov_num'], (int) $h['last_pass']['cov_denom'] ) ), (int) $h['last_pass']['cov_denom'], ORDERGUARD_COURIER_COVERAGE_AGE_DAYS, ORDERGUARD_COURIER_COVERAGE_WINDOW_DAYS ) ); } } /** * =========================================================================== * M3 -- ECONT ADAPTER (first real adapter, Bulgaria). * =========================================================================== * Three merchant setups are supported. The first two are polled with the * merchant's stored credentials; the third reads local plugin tables only: * * 1. Official "Deliver with Econt" (Econt Delivery OneCheckout): * - creds: option delivery_with_econt_settings (private_key + demo flag); * - waybill meta: _order_waybill_id (may be absent -- not needed to poll); * - poll: OrdersService.getTrace.json on delivery(-demo).econt.com, keyed * by the SHOP'S OWN order number, Authorization: -- the * exact call the official plugin itself makes (helpers.php). * * 2. Bulgarisation for WooCommerce (woo_bg), Econt module: * - creds: options woo_bg_settings_econt_user / _password / _env * (e-Econt account, HTTP Basic); * - shipment number: meta woo_bg_econt_label / woo_bg_econt_shipment_status * (['label']['shipmentNumber']); * - poll: ShipmentService.getShipmentStatuses.json on ee.econt.com. * * 3. mreja.net "WooCommerce Econt": * - waybill and tracking data: the plugin's own econt_loading and * econt_loading_tracking tables; * - no credentials and no Econt request from OrderGuard. The courier * plugin refreshes those tables only when an unsettled order is opened * in the WordPress admin, so the local data can be incomplete or stale. * * CLASSIFICATION IS STRUCTURAL (Econt has no single status enum): * deliveryTime + COD collected (cdCollectedTime/cdCollectedAmount) => kept; * no deliveryTime + no COD collected + a DEFINITIVE return-to-sender * signal ("връща се към подател" / explicit return-to-sender * instruction) => not_collected; * deliveryTime + a linked return shipment (nextShipments) => returned; * anything else => unknown (in transit) -- be conservative. 'unknown' keeps * the order in-flight, so it is simply re-polled on a later run. */ /** * Which Econt integration is active on this store ('' = none). */ function orderguard_econt_variant() { // Official "Deliver with Econt" plugin (class is autoloaded when active). if ( class_exists( 'Delivery_With_Econt_Options' ) || function_exists( 'DWEH' ) ) { return 'official'; } // Bulgarisation with its Econt module switched on. if ( function_exists( 'woo_bg_get_option' ) && 'yes' === woo_bg_get_option( 'apis', 'enable_econt' ) ) { return 'woo_bg'; } // mreja.net's "WooCommerce Econt" (woocommerce-econt/woocommerce-econt.php). // VERIFIED on a live store: it declares Econt_Express, Econt_Order, // Econt_Payment, Econt_mySQL, Econt_Ajax, Econt_OnUpdate, Econt_Admin_Order. // A class check, never an option probe: econt_* options survive the plugin // being deactivated or deleted, so an option probe would keep "detecting" a // courier that is gone (same reasoning as orderguard_boxnow_detect). if ( class_exists( 'Econt_Express' ) || class_exists( 'Econt_Order' ) ) { return 'mreja'; } return ''; } function orderguard_econt_detect() { return '' !== orderguard_econt_variant(); } /** * Which Econt integration shipped a specific order ('' = not an Econt order). * Shipping-item method_id first, meta presence as fallback. */ function orderguard_econt_order_variant( $order ) { $methods = orderguard_order_shipping_method_ids( $order ); if ( in_array( 'delivery_with_econt', $methods, true ) ) { return 'official'; } if ( in_array( 'woo_bg_econt', $methods, true ) ) { return 'woo_bg'; } // mreja.net -- VERIFIED live: its shipping method id is // 'econt_shipping_method' (14 of the last 15 orders on the test store). if ( in_array( 'econt_shipping_method', $methods, true ) ) { return 'mreja'; } if ( '' !== (string) $order->get_meta( '_order_waybill_id' ) ) { return 'official'; } if ( is_array( $order->get_meta( 'woo_bg_econt_label' ) ) || is_array( $order->get_meta( 'woo_bg_econt_shipment_status' ) ) ) { return 'woo_bg'; } // Fallback for mreja: it keeps NOTHING on the order -- the waybill lives in // its own table. So an order with a loading row is an Econt order even when // the shipping line has been edited away. if ( 'mreja' === orderguard_econt_variant() && '' !== orderguard_econt_mreja_awb( $order ) ) { return 'mreja'; } return ''; } function orderguard_econt_order_uses( $order ) { return '' !== orderguard_econt_order_variant( $order ); } /** * The Econt waybill/shipment number for an order ('' when none stored). */ function orderguard_econt_get_awb( $order ) { // Official plugin. $awb = (string) $order->get_meta( '_order_waybill_id' ); if ( '' !== $awb ) { return $awb; } // Bulgarisation: the createLabel response (ShipmentStatus incl. // shipmentNumber) is stored under ['label'] in these metas. foreach ( array( 'woo_bg_econt_label', 'woo_bg_econt_shipment_status' ) as $key ) { $number = orderguard_econt_find_shipment_number( $order->get_meta( $key ) ); if ( '' !== $number ) { return $number; } } // mreja.net stores NOTHING on the order -- the waybill is a row in its own // table, keyed by order_id. Checked last, and only when the mreja plugin is // actually the active variant: without that guard every official/woo_bg // store would fire a guaranteed-useless query for each order in the batch. if ( 'mreja' === orderguard_econt_variant() ) { $number = orderguard_econt_mreja_awb( $order ); if ( '' !== $number ) { return $number; } } return ''; } /* * --------------------------------------------------------------------------- * ECONT via mreja.net's "WooCommerce Econt" -- READ ITS LOCAL TABLES, NO API. * --------------------------------------------------------------------------- * The courier plugin stores tracking in its own tables, but does not refresh * them in the background. It refreshes an unsettled shipment only when that * order is opened in the WordPress admin. OrderGuard reads whatever is already * present there: no credentials, no Econt request and no copied response. * * VERIFIED against a live store (1230 loadings, 1223 order-linked, 2950 * tracking rows). Every fact below was read off real data -- none is assumed. * * {prefix}econt_loading one row per order (no duplicates seen) * order_id the WooCommerce order id * loading_num the waybill (товарителница) * receiver_time WHEN IT WAS RECEIVED -- the delivery marker * cd_get_sum / cd_get_time COD money collected, and when * next_parcel_reason 'RETURN' on a return leg * prev_parcel_num a return leg's link back to the original * is_returned SEE THE WARNING BELOW * delivery_attempt_count mostly empty; '1' x6, '2' x1 * * {prefix}econt_loading_tracking the event journal * loading_num, time, event, name, name_en * * *** DO NOT USE is_returned. *** * It is 0 on ALL 1230 rows. It is the obvious column, it is named exactly * right, and it is never set. Trusting it would detect zero returns forever, * with no error -- the same silent-failure shape that made Speedy discard 847 * parcels behind a wrong field name. Returns are found via the RETURN leg. * * HOW A RETURN IS REALLY REPRESENTED: a SEPARATE loading row with * next_parcel_reason='RETURN', order_id=0, is_imported=1, and prev_parcel_num * pointing at the ORIGINAL waybill. So we look for another loading that cites * ours -- never for a flag on our own row. * * VERIFIED TRACKING ENUM (counts from live data): * prepared 1303, office 640, courier_direction 573, client 151, * arrival_departure_from_hub 111, in_pickup_office 63, in_delivery_office 38, * instruction 27, courier 23, in_delivery_courier 7, failed_delivery 5, * canceled 3, return 2, is_returning_to_sender 1, redirect 1, * returned_to_sender 1 */ /** * --------------------------------------------------------------------------- * LOCAL-ADAPTER TUNING -- the mreja variant reads the merchant's own database, * so the limits that protect courier APIs do not apply to it. * --------------------------------------------------------------------------- * These filters are deliberately conditional on orderguard_econt_variant() === 'mreja'. * The SAME 'econt' courier id is served by the official and Bulgarisation * plugins, which DO call Econt's API -- turning these up for them would fire * thousands of HTTP requests at Econt and get the merchant rate-limited or * blocked. The distinction is not the courier; it is whether the adapter talks * to anyone. * * Measured on a live store: with the 60-day default, 123 of 129 confirmed COD * deliveries were unreachable -- verified outcomes with the money actually * collected, sitting in the merchant's own tables, excluded by a budget for * traffic this adapter never generates. * * 730 days matches the history import's 24-month window, so courier truth and * imported history cover the same span rather than disagreeing at the edges. */ add_filter( 'orderguard_courier_lookback_days', 'orderguard_econt_lookback_days', 10, 2 ); function orderguard_econt_lookback_days( $days, $courier ) { if ( 'econt' === $courier && 'mreja' === orderguard_econt_variant() ) { return 730; // 24 months -- local SELECTs, no remote party to be polite to } return $days; } add_filter( 'orderguard_courier_batch_size', 'orderguard_econt_batch_size', 10, 2 ); function orderguard_econt_batch_size( $size, $courier ) { if ( 'econt' === $courier && 'mreja' === orderguard_econt_variant() ) { return 100; // local queries: ~ms each, so a bigger page costs nothing } return $size; } add_filter( 'orderguard_courier_pace_us', 'orderguard_econt_pace_us', 10, 2 ); function orderguard_econt_pace_us( $us, $courier ) { if ( 'econt' === $courier && 'mreja' === orderguard_econt_variant() ) { return 0; // nothing to pace -- there is no API call to space out } return $us; } add_filter( 'orderguard_courier_sync_max_orders_per_run', 'orderguard_econt_max_orders', 10, 2 ); function orderguard_econt_max_orders( $max, $courier = '' ) { if ( 'econt' === $courier && 'mreja' === orderguard_econt_variant() ) { return 20000; // enough to walk a 24-month history; the time budget still bounds each action } return $max; } /** * '1970-01-01 00:00:00' is this plugin's "empty" datetime, not a real time. */ function orderguard_econt_mreja_has_time( $value ) { $value = trim( (string) $value ); if ( '' === $value || '0000-00-00 00:00:00' === $value ) { return false; } return strtotime( $value ) > strtotime( '1971-01-01' ); } /** * Does one of mreja's tables exist? Cached per process -- the check used to run * on every call, costing ~5 SHOW TABLES per polled order. * * esc_like() matters: 'wp_econt_loading' is full of underscores, and '_' is a * LIKE wildcard. prepare() quotes but does NOT neutralise wildcards, so an * unescaped probe can match a DIFFERENT table and then fail the !== comparison * -- i.e. fail closed, silently, and never poll. */ function orderguard_econt_mreja_table( $suffix ) { static $known = array(); if ( isset( $known[ $suffix ] ) ) { return $known[ $suffix ]; } global $wpdb; $table = $wpdb->prefix . $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 $exists = ( $table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ); return $known[ $suffix ] = ( $exists ? $table : '' ); } /** * This order's loading row, or null. Static-cached per request: classify() and * the summary both want it. * * SELECT an EXPLICIT COLUMN LIST, never *. The row also holds receiver_person, * receiver_person_phone, receiver_courier_phone and the amounts due. None of * that is needed to decide an outcome, and pulling it in would put a customer's * name and phone into any stack trace that touches this array -- which under * WP_DEBUG, Query Monitor or an APM agent means writing them to debug.log or * shipping them to a third party. The plugin's whole premise is that raw * customer data never leaves the store; not SELECTing it closes that off for * free. */ function orderguard_econt_mreja_loading( $order ) { static $cache = array(); if ( ! $order instanceof WC_Order ) { return null; } $id = (int) $order->get_id(); if ( array_key_exists( $id, $cache ) ) { return $cache[ $id ]; } $table = orderguard_econt_mreja_table( 'econt_loading' ); if ( '' === $table ) { return $cache[ $id ] = null; // plugin gone -- fail quiet, never fatal } global $wpdb; // phpcs:disable 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; read-only lookup in the Econt shipping plugin's own table; no WordPress API exists for it; must read the courier's current data; caching would be wrong $row = $wpdb->get_row( $wpdb->prepare( "SELECT econt_loading_id, order_id, loading_num, receiver_time, cd_get_sum, cd_get_time, next_parcel_reason, prev_parcel_num, delivery_attempt_count FROM `{$table}` WHERE order_id = %d ORDER BY econt_loading_id DESC LIMIT 1", $id ), ARRAY_A ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $cache[ $id ] = ( is_array( $row ) ? $row : null ); } /** * The waybill for an order ('' = none yet, i.e. not shipped). */ function orderguard_econt_mreja_awb( $order ) { $row = orderguard_econt_mreja_loading( $order ); return ( $row && ! empty( $row['loading_num'] ) ) ? (string) $row['loading_num'] : ''; } /** * The RETURN leg citing this waybill (the row itself, not a bool), or null. * * Returns its loading_num so the caller can read the RETURN LEG'S OWN tracking * events -- 'returned_to_sender' is an event about the return shipment, so it * hangs off the return leg's waybill, not the original's. Checking only the * original's events would be dead code that quietly never matches. * * Deliberately does NOT read is_returned (always 0 -- see the section docblock). */ function orderguard_econt_mreja_return_leg( $awb ) { $awb = trim( (string) $awb ); if ( '' === $awb ) { return null; } $table = orderguard_econt_mreja_table( 'econt_loading' ); if ( '' === $table ) { return null; } global $wpdb; // phpcs:disable 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; read-only lookup in the Econt shipping plugin's own table; no WordPress API exists for it; must read the courier's current data; caching would be wrong $row = $wpdb->get_row( $wpdb->prepare( "SELECT econt_loading_id, loading_num, prev_parcel_num, next_parcel_reason, receiver_time FROM `{$table}` WHERE prev_parcel_num = %s AND next_parcel_reason = 'RETURN' ORDER BY econt_loading_id DESC LIMIT 1", $awb ), ARRAY_A ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return is_array( $row ) ? $row : null; } /** * The tracking events for one or more waybills, oldest first. [] when none. */ function orderguard_econt_mreja_events( $awbs ) { $awbs = array_values( array_filter( array_map( 'trim', (array) $awbs ) ) ); if ( empty( $awbs ) ) { return array(); } $table = orderguard_econt_mreja_table( 'econt_loading_tracking' ); if ( '' === $table ) { return array(); } global $wpdb; $in = implode( ',', array_fill( 0, count( $awbs ), '%s' ) ); $sql = "SELECT loading_num, event, time FROM `{$table}` WHERE loading_num IN ({$in}) ORDER BY time ASC, econt_loading_tracking_id ASC"; $rows = $wpdb->get_results( $wpdb->prepare( $sql, $awbs ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- placeholders built from a counted array, values bound; 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 is_array( $rows ) ? $rows : array(); } /** * Read the whole picture for one order. Shaped like the other adapters' poll() * return: an array the classifier reads. NOTHING leaves the store. */ function orderguard_econt_poll_mreja( $order ) { $row = orderguard_econt_mreja_loading( $order ); if ( ! $row ) { return null; // no waybill yet -- not pollable, re-checked later } $awb = (string) ( $row['loading_num'] ?? '' ); $return_leg = orderguard_econt_mreja_return_leg( $awb ); // Events from BOTH waybills -- ours and the return leg's (see // orderguard_econt_mreja_return_leg for why the return's own events matter). $awbs = array( $awb ); if ( $return_leg && ! empty( $return_leg['loading_num'] ) ) { $awbs[] = (string) $return_leg['loading_num']; } return array( 'loading' => $row, 'events' => orderguard_econt_mreja_events( $awbs ), 'return_leg' => $return_leg, ); } /** * Classify a mreja Econt shipment. STRUCTURAL -- mirrors the official/woo_bg * adapters: a real delivery time is the evidence, not a status name. * * delivered + no RETURN leg => kept (COD gate below) * delivered + a RETURN leg => returned (had it, sent it back) * not delivered + returned-to-sender + not the * merchant's doing => not_collected * anything else => unknown * * TWO RULES THIS FUNCTION EXISTS TO HONOUR: * * 1. THE COD GATE. On a COD order, "Econt says received" is NOT enough for * 'kept' -- the money has to have been collected (cd_get_time / cd_get_sum). * orderguard_econt_classify() applies exactly this gate on the API path * ($cd_collected || ! $is_cod), and this variant must not be more generous * just because the merchant installed a different Econt plugin. OrderGuard's * one prediction is "will this customer collect a COD order"; recording * 'kept' for a COD parcel nobody paid for corrupts the only signal that * matters. Prepaid parcels have no cd_get_time and are no less kept -- hence * the gate is on COD orders only. THIS IS WHY $order IS REQUIRED. * * 2. NEVER BLAME THE CUSTOMER FOR THE MERCHANT'S DECISION. A parcel can come * back for reasons that have nothing to do with the customer: the merchant * cancelled and recalled it, the address was wrong, the courier damaged it. * A bare RETURN leg is therefore NOT evidence of non-collection. An earlier * draft returned 'not_collected' for any return leg -- which contradicted * this very docblock and would have marked people for orders the merchant * cancelled. 'not_collected' now requires an explicit returned-to-sender * signal AND an order the merchant did not cancel. */ function orderguard_econt_classify_mreja( $raw, $order = null ) { if ( ! is_array( $raw ) || empty( $raw['loading'] ) || ! is_array( $raw['loading'] ) ) { return 'unknown'; } $row = $raw['loading']; $delivered = orderguard_econt_mreja_has_time( $row['receiver_time'] ?? '' ); $events = is_array( $raw['events'] ?? null ) ? $raw['events'] : array(); $event_names = array(); foreach ( $events as $e ) { $event_names[] = strtolower( trim( (string) ( $e['event'] ?? '' ) ) ); } if ( $delivered ) { if ( ! empty( $raw['return_leg'] ) ) { return 'returned'; // received, then sent back } // COD gate -- see rule 1. $is_cod = ( $order instanceof WC_Order ) ? orderguard_is_cod_order( $order ) : true; $cd_collected = orderguard_econt_mreja_has_time( $row['cd_get_time'] ?? '' ) || ( '' !== trim( (string) ( $row['cd_get_sum'] ?? '' ) ) && (float) $row['cd_get_sum'] > 0 ); if ( $cd_collected || ! $is_cod ) { return 'kept'; } return 'unknown'; // delivered COD, no money yet -- wait, don't guess } // Never delivered. Did the courier send it back to the sender? $returned_to_sender = ( in_array( 'returned_to_sender', $event_names, true ) || in_array( 'is_returning_to_sender', $event_names, true ) ); if ( $returned_to_sender ) { // Rule 2: was this the merchant's doing rather than the customer's? if ( in_array( 'canceled', $event_names, true ) ) { return 'unknown'; } if ( $order instanceof WC_Order && in_array( $order->get_status(), array( 'cancelled', 'refunded' ), true ) ) { return 'unknown'; } return 'not_collected'; } // A return leg with no returned-to-sender signal is ambiguous -- it could be // a merchant recall just as easily as a non-collection. Say nothing. return 'unknown'; } /** * Short NON-PII summary: waybill + the facts that decided it. The loading row * carries receiver_person / phones -- none of that is ever included. */ function orderguard_econt_status_summary_mreja( $raw ) { if ( ! is_array( $raw ) || empty( $raw['loading'] ) ) { return ''; } $row = $raw['loading']; $parts = array(); if ( ! empty( $row['loading_num'] ) ) { $parts[] = 'awb=' . $row['loading_num']; } $parts[] = 'delivered=' . ( orderguard_econt_mreja_has_time( $row['receiver_time'] ?? '' ) ? 'yes' : 'no' ); if ( orderguard_econt_mreja_has_time( $row['cd_get_time'] ?? '' ) ) { $parts[] = 'cod_collected=yes'; } // return_leg is the leg's ROW (or null), not a bool -- report its waybill. if ( ! empty( $raw['return_leg']['loading_num'] ) ) { $parts[] = 'return_leg=' . $raw['return_leg']['loading_num']; } $events = is_array( $raw['events'] ?? null ) ? $raw['events'] : array(); if ( $events ) { $last = end( $events ); $parts[] = 'last_event=' . (string) ( $last['event'] ?? '?' ); } // Whitelist only -- the loading row also holds receiver_person and phones, // and this string is stored on the order and travels as raw_status. return implode( '; ', $parts ); } /** * Dig a shipmentNumber out of a Bulgarisation meta blob (defensive). */ function orderguard_econt_find_shipment_number( $meta ) { if ( ! is_array( $meta ) ) { return ''; } if ( ! empty( $meta['shipmentNumber'] ) && is_scalar( $meta['shipmentNumber'] ) ) { return (string) $meta['shipmentNumber']; } if ( isset( $meta['label'] ) && is_array( $meta['label'] ) && ! empty( $meta['label']['shipmentNumber'] ) && is_scalar( $meta['label']['shipmentNumber'] ) ) { return (string) $meta['label']['shipmentNumber']; } return ''; } /** * Official-plugin credentials (read at call time, never stored by OrderGuard). */ function orderguard_econt_official_creds() { $opts = get_option( 'delivery_with_econt_settings' ); if ( ! is_array( $opts ) || empty( $opts['private_key'] ) ) { return null; } return array( 'private_key' => (string) $opts['private_key'], 'demo' => ! empty( $opts['demo_service'] ), ); } /** * Bulgarisation e-Econt credentials (read at call time). */ function orderguard_econt_woobg_creds() { if ( ! function_exists( 'woo_bg_get_option' ) ) { return null; } $user = (string) woo_bg_get_option( 'econt', 'user' ); $pass = (string) woo_bg_get_option( 'econt', 'password' ); if ( '' === $user || '' === $pass ) { return null; } return array( 'user' => $user, 'password' => $pass, 'demo' => ( 'live' !== woo_bg_get_option( 'econt', 'env' ) ), ); } /** * Poll Econt for one order. * * @return array|null|WP_Error raw status data; null = can't poll this order * (no creds / no AWB / unknown to Econt); WP_Error = transient or * account-wide terminal failure. */ function orderguard_econt_poll( $order, $awb ) { $variant = orderguard_econt_order_variant( $order ); if ( 'official' === $variant ) { return orderguard_econt_poll_official( $order ); } if ( 'woo_bg' === $variant ) { return orderguard_econt_poll_woobg( $awb, $order ); } // mreja.net: a local table read -- no network, so it can never return a // WP_Error and never needs backing off. if ( 'mreja' === $variant ) { return orderguard_econt_poll_mreja( $order ); } return null; } /** * Official plugin: OrdersService.getTrace -- keyed by the shop's own order * number, no AWB needed. Response: a ShipmentStatus-shaped object; API errors * come back with a non-empty 'type' (e.g. ExAccessDenied). */ function orderguard_econt_poll_official( $order ) { $creds = orderguard_econt_official_creds(); if ( null === $creds ) { return null; // merchant hasn't configured Econt yet -- skip gracefully } // getTrace is keyed by the SHOP'S ORDER NUMBER -- what the official plugin // sent to Econt when the order was created. On stores with a sequential- // order-number plugin that differs from the internal post ID, so the post // ID would silently trace the wrong (or no) order. // TODO(verify-first): confirm the exact field the installed deliver-with- // econt build sends (helpers.php) -- no plugin copy was on disk to grep; // numeric order numbers are sent as int to match its known encoding. $order_number = (string) $order->get_order_number(); $base = $creds['demo'] ? 'https://delivery-demo.econt.com/' : 'https://delivery.econt.com/'; $response = wp_remote_post( $base . 'services/OrdersService.getTrace.json', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => $creds['private_key'], ), 'body' => wp_json_encode( array( 'orderNumber' => is_numeric( $order_number ) ? (int) $order_number : $order_number ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $code ) { return new WP_Error( 'orderguard_courier_rate_limited', 'Econt getTrace rate limited the courier sync' ); } if ( $code >= 500 ) { return new WP_Error( 'orderguard_econt_http', 'Econt getTrace HTTP ' . $code ); } if ( 401 === $code || 403 === $code ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Econt rejected the Deliver with Econt private key' ); } if ( $code < 200 || $code >= 300 ) { return null; // permanent (not found etc.) -- skip, don't hammer } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return null; } if ( isset( $data['type'] ) && '' !== $data['type'] ) { // Econt-style application error (ExAccessDenied, ExInvalidParam, ...). if ( 0 === strcasecmp( 'ExAccessDenied', trim( (string) $data['type'] ) ) ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Econt denied access to the Deliver with Econt account' ); } orderguard_log( 'OrderGuard: Econt getTrace error for order #' . $order->get_id() . ' -- ' . $data['type'] ); return null; } return $data; } /** * Bulgarisation: e-Econt ShipmentService.getShipmentStatuses with the stored * shipment number (HTTP Basic with the merchant's e-Econt account). */ function orderguard_econt_poll_woobg( $awb, $order = null ) { $awb = (string) $awb; if ( '' === $awb ) { return null; // no label created yet -- nothing to poll } $creds = orderguard_econt_woobg_creds(); if ( null === $creds ) { return null; } $base = $creds['demo'] ? 'https://demo.econt.com/ee/services/' : 'https://ee.econt.com/services/'; $response = wp_remote_post( $base . 'Shipments/ShipmentService.getShipmentStatuses.json', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Basic ' . base64_encode( $creds['user'] . ':' . $creds['password'] ), ), 'body' => wp_json_encode( array( 'shipmentNumbers' => array( $awb ) ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $code ) { return new WP_Error( 'orderguard_courier_rate_limited', 'Econt getShipmentStatuses rate limited the courier sync' ); } if ( $code >= 500 ) { return new WP_Error( 'orderguard_econt_http', 'Econt getShipmentStatuses HTTP ' . $code ); } if ( 401 === $code || 403 === $code ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Econt rejected the Bulgarisation e-Econt credentials' ); } if ( $code < 200 || $code >= 300 ) { return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return null; } if ( isset( $data['type'] ) && '' !== $data['type'] ) { if ( 0 === strcasecmp( 'ExAccessDenied', trim( (string) $data['type'] ) ) ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Econt denied access to the Bulgarisation e-Econt account' ); } orderguard_log( 'OrderGuard: Econt getShipmentStatuses error -- ' . $data['type'] ); return null; } // Per-shipment error (bad number etc.) -> not pollable. if ( isset( $data['shipmentStatuses'][0]['error'] ) && ! empty( $data['shipmentStatuses'][0]['error'] ) ) { return null; } return $data; } /** * Find the ShipmentStatus object inside whichever wrapper Econt used * (getTrace returns it directly / nested; getShipmentStatuses wraps it in * shipmentStatuses[0].status). Defensive by design. */ function orderguard_econt_extract_status( $raw ) { if ( ! is_array( $raw ) ) { return null; } if ( isset( $raw['shipmentStatuses'][0]['status'] ) && is_array( $raw['shipmentStatuses'][0]['status'] ) ) { return $raw['shipmentStatuses'][0]['status']; } if ( isset( $raw['shipmentStatus'] ) && is_array( $raw['shipmentStatus'] ) ) { return $raw['shipmentStatus']; } if ( isset( $raw['status'] ) && is_array( $raw['status'] ) ) { return $raw['status']; } // Looks like a bare ShipmentStatus already? if ( isset( $raw['shipmentNumber'] ) || isset( $raw['deliveryTime'] ) || isset( $raw['trackingEvents'] ) || isset( $raw['expectedDeliveryDate'] ) ) { return $raw; } return null; } /** * Structural classification (see the header block). Only returns a terminal * outcome when the evidence is unambiguous; everything else is 'unknown'. * * @param array $raw raw poll response * @param WC_Order|null $order for COD-awareness (kept needs COD-collected * evidence on COD orders; plain delivery suffices * on non-COD orders) */ function orderguard_econt_classify( $raw, $order = null ) { // mreja.net: a different shape entirely (local rows, not an API response). // Recognised by its own keys so the two can never be confused. $order is // passed on -- classify_mreja NEEDS it for the COD gate. if ( is_array( $raw ) && isset( $raw['loading'] ) && is_array( $raw['loading'] ) ) { return orderguard_econt_classify_mreja( $raw, $order ); } $s = orderguard_econt_extract_status( $raw ); if ( ! is_array( $s ) ) { return 'unknown'; } $delivered = ! empty( $s['deliveryTime'] ); $cd_collected = ! empty( $s['cdCollectedTime'] ) || ! empty( $s['cdCollectedAmount'] ); // All text matching below is mb_* on UTF-8: strtolower()/strpos() are // byte-wise and never case-fold Cyrillic, so "Връща се към подател" vs // "връща се към подател" (or any other casing) would silently not match. // A linked follow-up shipment (Econt models return legs as next shipments). // TODO(verify-first): confirm the exact nextShipments entry shape against a // live Econt account; until then only entries that literally mention a // return count. $return_leg = false; if ( ! empty( $s['nextShipments'] ) && is_array( $s['nextShipments'] ) ) { $legs_blob = mb_strtolower( (string) wp_json_encode( $s['nextShipments'], JSON_UNESCAPED_UNICODE ), 'UTF-8' ); $return_leg = ( false !== mb_strpos( $legs_blob, 'return', 0, 'UTF-8' ) || false !== mb_strpos( $legs_blob, 'връщ', 0, 'UTF-8' ) ); } // DEFINITIVE return-to-sender evidence only: the Bulgarian "returning to // sender" status text anywhere in the status blob, or an instruction that // explicitly says return-to-sender. A bare 'return' substring in // lastProcessedInstruction is NOT enough -- it also matches benign values // (e.g. return-receipt/return-documents services) and used to produce // false not_collected verdicts. // TODO(verify-first): the exact Econt field values for a returned-to- // sender / refused parcel must be confirmed against a live account. $instruction = ( isset( $s['lastProcessedInstruction'] ) && is_scalar( $s['lastProcessedInstruction'] ) ) ? mb_strtolower( (string) $s['lastProcessedInstruction'], 'UTF-8' ) : ''; $blob = mb_strtolower( (string) wp_json_encode( $s, JSON_UNESCAPED_UNICODE ), 'UTF-8' ); $return_to_sender = ( false !== mb_strpos( $blob, 'връща се към подател', 0, 'UTF-8' ) || false !== mb_strpos( $instruction, 'return_to_sender', 0, 'UTF-8' ) || false !== mb_strpos( $instruction, 'return to sender', 0, 'UTF-8' ) ); if ( $delivered ) { if ( $return_leg ) { // Delivered, then a linked return shipment appeared. NOTE: an // earlier 'kept' verdict is corrected by exactly this branch on a // RE-poll inside the ORDERGUARD_COURIER_REVISE_DAYS window -- the changed // verdict is re-sent by orderguard_report_courier_outcome() and the // server's latest-carrier-verdict-wins replay supersedes 'kept'. return 'returned'; } // orderguard_is_cod_order(), not a raw payment_method check: the COD // definition must be the one place the whole plugin shares (the gate, // the review queue, the mreja classifier) -- see orderguard_report_courier_outcome(). $is_cod = ( $order instanceof WC_Order ) ? orderguard_is_cod_order( $order ) : true; if ( $cd_collected || ! $is_cod ) { return 'kept'; } return 'unknown'; // delivered COD but no collection evidence yet -- wait } // Never delivered: not_collected needs STRONG structural evidence -- no // deliveryTime, no COD collected, AND a definitive return-to-sender // signal. Anything ambiguous (a lone return-looking leg, a soft 'return' // mention) stays 'unknown': the order remains in-flight and is re-polled. if ( ! $cd_collected && $return_to_sender ) { return 'not_collected'; } return 'unknown'; // still in transit / ambiguous } /** * Short, whitelisted, NON-PII status summary for the provenance meta. Never * include receiver names/phones/addresses from the tracking response. */ function orderguard_econt_status_summary( $raw ) { // mreja.net's shape -- see orderguard_econt_classify(). if ( is_array( $raw ) && isset( $raw['loading'] ) && is_array( $raw['loading'] ) ) { return orderguard_econt_status_summary_mreja( $raw ); } $s = orderguard_econt_extract_status( $raw ); if ( ! is_array( $s ) ) { return ''; } $parts = array(); foreach ( array( 'shipmentNumber', 'deliveryTime', 'cdCollectedTime', 'cdCollectedAmount', 'lastProcessedInstruction', 'expectedDeliveryDate' ) as $key ) { if ( isset( $s[ $key ] ) && is_scalar( $s[ $key ] ) && '' !== (string) $s[ $key ] ) { $parts[] = $key . '=' . $s[ $key ]; } } if ( ! empty( $s['nextShipments'] ) && is_array( $s['nextShipments'] ) ) { $parts[] = 'nextShipments=' . count( $s['nextShipments'] ); } return implode( '; ', $parts ); } /** * Local logo asset from the merchant's installed official Econt plugin * ('' when not installed -- caller falls back to name + parcel icon). */ function orderguard_econt_logo() { if ( defined( 'ECONT_PLUGIN_DIR' ) && defined( 'ECONT_PLUGIN_URL' ) && file_exists( ECONT_PLUGIN_DIR . 'public/images/econt-logo.svg' ) ) { return ECONT_PLUGIN_URL . 'public/images/econt-logo.svg'; } return orderguard_courier_bundled_logo( 'econt' ); } /** * =========================================================================== * M6 -- SPEEDY / BOX NOW / PIGEON EXPRESS ADAPTERS (thin, same framework). * =========================================================================== * Each mirrors the Econt adapter (M3) exactly: detect from the merchant's OWN * installed courier plugin, read the AWB from that plugin's order meta, poll * the courier's API AT CALL TIME with the credentials that plugin already * stores (credentials go ONLY to the courier's own endpoint -- never to the * OrderGuard server, never logged), classify CONSERVATIVELY (a terminal * outcome only on strong documented evidence, everything else 'unknown' = * the order stays in-flight and is simply re-polled), and log order id + * short status slug only. Where a plugin option key / API field / status * enum could not be verified against a live install, the read is defensive, * marked TODO(verify-first), overridable via a filter, and fails toward * null/'unknown' (skip -- never a false verdict). */ /** * Shared M6 helper: the first non-empty SCALAR wp option among $keys ('' when * none) -- a malformed/array option must never fatal or warn. */ function orderguard_courier_scalar_option( $keys ) { foreach ( (array) $keys as $key ) { $value = get_option( $key, '' ); if ( is_scalar( $value ) && '' !== trim( (string) $value ) ) { return trim( (string) $value ); } } return ''; } /* * --------------------------------------------------------------------------- * SPEEDY (Bulgaria) -- Bulgarisation for WooCommerce module, or the official * Speedy plugin. Polls api.speedy.bg/v1/track with the merchant's own Speedy * account; classification is by Speedy's DOCUMENTED final operation codes. * --------------------------------------------------------------------------- */ function orderguard_speedy_detect() { // Bulgarisation with its Speedy module switched on. // TODO(verify-first): confirm the exact woo_bg option key ('apis', // 'enable_speedy') against a live Bulgarisation install (mirrors the // verified enable_econt key). $detected = ( function_exists( 'woo_bg_get_option' ) && 'yes' === woo_bg_get_option( 'apis', 'enable_speedy' ) ); // Official Speedy plugin. // TODO(verify-first): confirm the official plugin's main class/constant; // no copy was on disk to grep -- these are its historical signatures. if ( ! $detected && ( class_exists( 'SpeedyShippingMethod' ) || defined( 'SPEEDY_PLUGIN_DIR' ) ) ) { $detected = true; } return (bool) apply_filters( 'orderguard_speedy_detect', $detected ); } function orderguard_speedy_order_uses( $order ) { $methods = orderguard_order_shipping_method_ids( $order ); if ( in_array( 'woo_bg_speedy', $methods, true ) ) { return true; } // TODO(verify-first): the official Speedy plugin's shipping method id. if ( in_array( 'speedy_shipping_method', $methods, true ) ) { return true; } return '' !== orderguard_speedy_get_awb( $order ); } /** * The Speedy shipment/parcel id for an order ('' when none stored). * * Two known shapes, both the raw Speedy createShipment response: * * mreja.net / Extensa "woocommerce-speedy" -> meta 'speedy_parcel' * (VERIFIED on a live store; keys: id, parcels, pickupDate, price, * deliveryDeadline -- 'id' is the AWB). Only present once the waybill has * actually been generated; a fresh order carries only checkout-time * speedy_* meta and legitimately has no AWB yet. * * Bulgarisation for WooCommerce -> meta 'woo_bg_speedy_label'. */ function orderguard_speedy_get_awb( $order ) { $id = orderguard_speedy_find_parcel_id( $order->get_meta( 'speedy_parcel' ) ); if ( '' !== $id ) { return $id; } return orderguard_speedy_find_parcel_id( $order->get_meta( 'woo_bg_speedy_label' ) ); } /** * Dig the shipment id out of a Bulgarisation Speedy meta blob (defensive -- * the exact nesting differs across woo_bg versions). * TODO(verify-first): confirm the stored shape on a live Bulgarisation store. */ function orderguard_speedy_find_parcel_id( $meta ) { if ( ! is_array( $meta ) ) { return ''; } foreach ( array( 'id', 'shipmentId', 'parcelId', 'shipmentNumber' ) as $key ) { if ( ! empty( $meta[ $key ] ) && is_scalar( $meta[ $key ] ) ) { return (string) $meta[ $key ]; } } if ( isset( $meta['label'] ) && is_array( $meta['label'] ) ) { return orderguard_speedy_find_parcel_id( $meta['label'] ); } if ( isset( $meta['parcels'][0]['id'] ) && is_scalar( $meta['parcels'][0]['id'] ) && '' !== (string) $meta['parcels'][0]['id'] ) { return (string) $meta['parcels'][0]['id']; } return ''; } /** * Speedy API credentials from the merchant's own courier plugin (read at call * time, never stored by OrderGuard). Null = not configured -> skip silently. */ function orderguard_speedy_creds() { $creds = null; // 1. Extensa's "woocommerce-speedy" -- VERIFIED on a live store. It keeps the // Speedy Web API credentials as api_username/api_password inside the standard // WooCommerce shipping-method settings array (and mirrors them into its own // options array). NOTE: 'username'/'password' in the same array are the // MySpeedy login, NOT the API credentials -- do not use those. foreach ( array( 'woocommerce_speedy_shipping_method_settings', 'speedy_shipping_method_options' ) as $opt ) { $s = get_option( $opt ); if ( ! is_array( $s ) ) { continue; } $user = isset( $s['api_username'] ) && is_scalar( $s['api_username'] ) ? trim( (string) $s['api_username'] ) : ''; $pass = isset( $s['api_password'] ) && is_scalar( $s['api_password'] ) ? trim( (string) $s['api_password'] ) : ''; if ( '' !== $user && '' !== $pass ) { $creds = array( 'user' => $user, 'password' => $pass ); break; } } // 2. Bulgarisation for WooCommerce, for stores running that instead. if ( null === $creds && function_exists( 'woo_bg_get_option' ) ) { // TODO(verify-first): confirm the woo_bg section/keys ('speedy', // 'user'/'password') -- mirrors the verified econt user/password keys. $user = (string) woo_bg_get_option( 'speedy', 'user' ); $pass = (string) woo_bg_get_option( 'speedy', 'password' ); if ( '' !== $user && '' !== $pass ) { $creds = array( 'user' => $user, 'password' => $pass ); } } // Escape hatch for stores whose Speedy plugin stores creds elsewhere. $creds = apply_filters( 'orderguard_speedy_credentials', $creds ); if ( ! is_array( $creds ) || empty( $creds['user'] ) || empty( $creds['password'] ) ) { return null; } return $creds; } /** * Whether Speedy's HTTP-200 Error object describes account authentication. * The API requests English messages, but prefer its numeric code whenever it * is present. Reading the message is only a compatibility fallback; it is * never logged because it may echo request values. */ function orderguard_speedy_application_error_is_auth( $error ) { if ( is_array( $error ) ) { if ( isset( $error['code'] ) && in_array( (int) $error['code'], array( 401, 403 ), true ) ) { return true; } $message = isset( $error['message'] ) && is_scalar( $error['message'] ) ? (string) $error['message'] : ''; } elseif ( is_scalar( $error ) ) { $message = (string) $error; } else { return false; } $message = strtolower( trim( $message ) ); foreach ( array( 'access denied', 'authentication failed', 'authorization failed', 'invalid credentials', 'invalid username', 'invalid password', 'wrong username', 'wrong password' ) as $needle ) { if ( false !== strpos( $message, $needle ) ) { return true; } } return false; } /** * Poll Speedy for one parcel: POST /v1/track, lastOperationOnly. * * @return array|null|WP_Error raw track data; null = can't poll (no creds / * no id / unknown parcel); WP_Error = transient or account-wide * terminal failure. */ function orderguard_speedy_poll( $order, $awb ) { $awb = (string) $awb; if ( '' === $awb ) { return null; // no shipment created yet -- nothing to poll } $creds = orderguard_speedy_creds(); if ( null === $creds ) { return null; // Speedy not configured -- skip gracefully } // Credentials travel ONLY to Speedy's own endpoint (see header invariants). // TODO(verify-first): the documented TrackingRequest sends parcels as // objects ({id: ...}); confirm against the merchant's live account. $response = wp_remote_post( 'https://api.speedy.bg/v1/track', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'userName' => $creds['user'], 'password' => $creds['password'], 'language' => 'EN', 'parcels' => array( array( 'id' => $awb ) ), 'lastOperationOnly' => true, ) ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $code ) { return new WP_Error( 'orderguard_courier_rate_limited', 'Speedy rate limited the courier sync' ); } if ( $code >= 500 ) { return new WP_Error( 'orderguard_speedy_http', 'Speedy track HTTP ' . $code ); } if ( 401 === $code || 403 === $code ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Speedy rejected the courier-plugin credentials' ); } if ( $code < 200 || $code >= 300 ) { return null; // permanent (not found etc.) -- skip, don't hammer } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return null; } // Speedy application error (bad credentials, malformed request, ...). if ( ! empty( $data['error'] ) ) { if ( orderguard_speedy_application_error_is_auth( $data['error'] ) ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Speedy rejected the courier-plugin credentials' ); } // Log a short code/id only, like the Econt adapter's error type -- // Speedy's free-text 'message' can echo request values (username). $err = is_array( $data['error'] ) ? $data['error'] : array(); $slug = 'application error'; foreach ( array( 'code', 'id', 'context' ) as $field ) { if ( isset( $err[ $field ] ) && is_scalar( $err[ $field ] ) && '' !== (string) $err[ $field ] ) { $slug = $field . '=' . $err[ $field ]; break; } } orderguard_log( 'OrderGuard: Speedy track error for order #' . $order->get_id() . ' -- ' . $slug ); return null; } // Per-parcel error (unknown number etc.) -> not pollable. if ( ! empty( $data['parcels'][0]['error'] ) ) { return null; } return $data; } /** * The operations list for the first tracked parcel (defensive). */ function orderguard_speedy_extract_operations( $raw ) { if ( ! is_array( $raw ) ) { return array(); } if ( isset( $raw['parcels'][0]['operations'] ) && is_array( $raw['parcels'][0]['operations'] ) ) { return $raw['parcels'][0]['operations']; } if ( isset( $raw['operations'] ) && is_array( $raw['operations'] ) ) { return $raw['operations']; } return array(); } /** * Read the operation code off one Speedy trace entry. * * THE FIELD IS `operationCode`. It is not `code`. * * This function exists because that one wrong key name silently cost a live * store 847 parcels: orderguard_speedy_classify() checked isset($last['code']), which * is never set, so EVERY parcel fell out at the guard and returned 'unknown'. * The poll worked, the API answered, the code map was correct -- and not one * outcome was ever recorded. There was no error to see: "unknown" is also what * a healthy in-transit parcel returns, so the failure was indistinguishable * from the system working. * * VERIFIED against 40 live parcels: every entry carries * dateTime, operationCode, place, description, exceptionCodes, consignee, * podImageURL, additionalInfo * 'code' is kept as a fallback only in case a future API revision renames it. * * @return int|null the code, or null when the entry has none. */ function orderguard_speedy_operation_code( $op ) { if ( ! is_array( $op ) ) { return null; } foreach ( array( 'operationCode', 'code' ) as $field ) { if ( isset( $op[ $field ] ) && is_scalar( $op[ $field ] ) && is_numeric( (string) $op[ $field ] ) ) { return (int) $op[ $field ]; } } return null; } /** * Classify by Speedy's FINAL operation codes only -- a non-final code means * "still moving" and stays 'unknown' (re-polled later). * * VERIFIED on a live store (40 parcels, 90-day window), codes + Speedy's own * descriptions: * -14 "Delivered" => kept (34/40) * 124 "Delivered back to sender" => not_collected (2/40) * 195 "Refuse contents check/test" => unknown (4/40) * * 195 is deliberately NOT mapped. It reads as terminal (it was the last entry * on those parcels) but it is genuinely ambiguous -- refusing the contents-check * service does not say whether the parcel was then taken or not. Guessing here * would put a "never collected" mark on a customer who may well have accepted * their order. Silence is the honest answer. * * Likewise 125/127/128/129 (destroyed / theft / canceled / administrative * closure): not the customer's doing, so not a customer signal. * * NOTE: a post-delivery customer return travels on a SEPARATE Speedy return * voucher, invisible on this parcel's trace -- 'returned' is deliberately * NEVER inferred by this adapter (no guessing). * * M6, DECIDED: -14 => 'kept' IS UNCONDITIONAL ON COD ORDERS, ON PURPOSE. * orderguard_econt_classify() refuses to call a COD order 'kept' on delivery alone * -- it demands collection evidence (cdCollectedTime / cd_get_time), which * Econt's data PROVIDES. The asymmetry here is a decision, not an oversight: * * 1. Speedy collects the COD amount AT HANDOVER -- that is what наложен * платеж is. A parcel is not handed over unpaid; a customer who will not * pay refuses the parcel, and that trace ends in 124 ("Delivered back to * sender"), never -14. On this API, -14 itself IS the collection * evidence. * 2. There is nothing to gate ON. The verified live entries (40 parcels) * carry dateTime, operationCode, place, description, exceptionCodes, * consignee, podImageURL, additionalInfo -- no COD/payment field -- and * Speedy's tracking contract documents none on the tracked parcel * either. An Econt-style gate here would wait forever for evidence that * CANNOT ARRIVE: every COD 'kept' would silently become 'unknown' -- the * exact silent-discard failure (real outcomes dropped, log quiet, card * green) that already cost this adapter 847 parcels once. * * RESIDUAL RISK, stated plainly: if a Speedy courier ever hands over a COD * parcel against a failed/deferred payment (an exceptional station-side * arrangement), this records 'kept' although the money may not have moved. * No field in this response can detect that case. If Speedy's API ever grows * a codCollected/amount field on the trace, the Econt-style gate belongs * right here, using $order. * * $order is deliberately accepted and UNUSED: the poller calls every * adapter's classify() as classify( $raw, $order ), and the COD gate above * is the documented reason a Speedy classifier would ever need it. The * parameter is the seam where that gate goes the day Speedy exposes the * evidence -- removing it would erase the seam and break the uniform * callback contract. */ function orderguard_speedy_classify( $raw, $order = null ) { $ops = orderguard_speedy_extract_operations( $raw ); if ( empty( $ops ) ) { return 'unknown'; } $last = end( $ops ); // lastOperationOnly => single entry; end() covers both if ( ! is_array( $last ) ) { return 'unknown'; } $code = orderguard_speedy_operation_code( $last ); if ( null === $code ) { return 'unknown'; } $map = apply_filters( 'orderguard_speedy_final_code_map', array( -14 => 'kept', 124 => 'not_collected', ) ); return ( is_array( $map ) && isset( $map[ $code ] ) ) ? orderguard_normalize_courier_outcome( $map[ $code ] ) : 'unknown'; } /** * Short NON-PII summary: code + timestamp only. Speedy operation DESCRIPTIONS * can embed the receiver's name ("Delivered to ...") -- never stored. */ function orderguard_speedy_status_summary( $raw ) { $parts = array(); if ( is_array( $raw ) && isset( $raw['parcels'][0]['id'] ) && is_scalar( $raw['parcels'][0]['id'] ) ) { $parts[] = 'id=' . $raw['parcels'][0]['id']; } $ops = orderguard_speedy_extract_operations( $raw ); $last = empty( $ops ) ? null : end( $ops ); if ( is_array( $last ) ) { // operationCode, not code -- see orderguard_speedy_operation_code(). $code = orderguard_speedy_operation_code( $last ); if ( null !== $code ) { $parts[] = 'code=' . $code; } if ( isset( $last['dateTime'] ) && is_scalar( $last['dateTime'] ) && '' !== (string) $last['dateTime'] ) { $parts[] = 'dateTime=' . $last['dateTime']; } } return implode( '; ', $parts ); } /** * TODO(verify-first): neither Bulgarisation nor the official Speedy plugin is * known to bundle a Speedy logo asset. If the merchant's install DOES ship * one, resolve it here via file_exists + plugins_url like orderguard_econt_logo(). * Until then: the OrderGuard-bundled placeholder wordmark (overridable with * an official brand-kit SVG in assets/couriers/ -- see * orderguard_courier_bundled_logo()). Remote third-party logos are NEVER hotlinked. */ function orderguard_speedy_logo() { return orderguard_courier_bundled_logo( 'speedy' ); } /* * --------------------------------------------------------------------------- * BOX NOW (lockers) -- official "BOX NOW Delivery" plugin. Partner API with * OAuth2 client-credentials: POST /api/v1/auth-sessions -> Bearer token -> * GET /api/v1/parcels?orderNumber=... -> item with state + events[]. * GUARDRAIL: the BOX NOW plugin marks the WooCommerce order 'completed' just * to CREATE the shipment, so the ORDER STATUS means nothing here -- only the * API state ever decides an outcome. * --------------------------------------------------------------------------- */ function orderguard_boxnow_detect() { // Source-verified against box-now-delivery 3.2.1 (WordPress.org SVN trunk): // the plugin is procedural (no class) and always defines the version // constant + the boxnow_order_completed() function on load, so either is a // reliable "plugin active" signal even before the merchant has entered API // credentials. Deliberately NO option probe here: boxnow_* options persist // after the plugin is deactivated/deleted, so they would keep "detecting" // a courier that is gone (options are consulted for CREDENTIALS only, in // orderguard_boxnow_creds()). $detected = defined( 'BOX_NOW_DELIVERY_VERSION' ) || function_exists( 'boxnow_order_completed' ); return (bool) apply_filters( 'orderguard_boxnow_detect', $detected ); } function orderguard_boxnow_order_uses( $order ) { // TODO(verify-first): the plugin's shipping method id (likely candidates). $methods = orderguard_order_shipping_method_ids( $order ); foreach ( array( 'boxnow', 'box_now_delivery', 'boxnow_delivery' ) as $method ) { if ( in_array( $method, $methods, true ) ) { return true; } } return '' !== orderguard_boxnow_get_awb( $order ); } /** * The BOX NOW parcel id for an order ('' when none): _boxnow_parcel_id * (single) or the first of _boxnow_parcel_ids (array, or CSV string). */ function orderguard_boxnow_get_awb( $order ) { $single = $order->get_meta( '_boxnow_parcel_id' ); if ( is_scalar( $single ) && '' !== trim( (string) $single ) ) { return trim( (string) $single ); } $many = $order->get_meta( '_boxnow_parcel_ids' ); if ( is_array( $many ) ) { foreach ( $many as $id ) { if ( is_scalar( $id ) && '' !== trim( (string) $id ) ) { return trim( (string) $id ); } } } elseif ( is_scalar( $many ) && '' !== trim( (string) $many ) ) { $first = explode( ',', (string) $many ); return trim( (string) $first[0] ); } return ''; } /** * BOX NOW Partner API host + client credentials from the plugin's options * (read at call time, never stored). Null = unresolved -> skip gracefully. * * TODO(verify-first): THE key open item for BOX NOW -- the exact option names * its settings page stores. Likely names are probed in order; if the live * store uses different ones, wire them via the orderguard_boxnow_credentials filter. */ function orderguard_boxnow_creds() { $creds = array( 'host' => orderguard_courier_scalar_option( array( 'boxnow_api_url', 'boxnow_api_base_url', 'boxnow_base_url' ) ), 'client_id' => orderguard_courier_scalar_option( array( 'boxnow_client_id', 'boxnow_api_client_id' ) ), 'client_secret' => orderguard_courier_scalar_option( array( 'boxnow_client_secret', 'boxnow_api_client_secret', 'boxnow_api_secret' ) ), ); $creds = apply_filters( 'orderguard_boxnow_credentials', $creds ); // Validate the SHAPE after the filter (it runs last): every field must be // a non-empty scalar, or a misbehaving filter could hand us an array // (=> "Array to string conversion" and a request to https://Array). $valid = is_array( $creds ); if ( $valid ) { foreach ( array( 'host', 'client_id', 'client_secret' ) as $field ) { if ( empty( $creds[ $field ] ) || ! is_scalar( $creds[ $field ] ) ) { $valid = false; break; } } } if ( ! $valid ) { // Log ONCE per run, not per order -- unresolved option names must be // diagnosable without spamming the log. Never logs values. static $logged = false; if ( ! $logged ) { $logged = true; orderguard_log( 'OrderGuard: BOX NOW detected but API host/credentials not found in the expected options -- courier sync skipped (override via the orderguard_boxnow_credentials filter)' ); } return null; } // VERIFIED ON A LIVE STORE: boxnow_api_url is NOT consistent between plugin // versions -- 3.2.1 stores a bare host ("api-production.boxnow.bg") while // 3.0.0 stores the full URL ("https://api-production.boxnow.bg"). Callers // build "https://{host}/api/v1/...", so a stored scheme produced // "https://https://..." and cURL error 6 ("Could not resolve host: https"). // Normalise to a bare host so both shapes work. $creds['host'] = rtrim( preg_replace( '#^[a-z][a-z0-9+.-]*://#i', '', trim( (string) $creds['host'] ) ), '/' ); if ( '' === $creds['host'] ) { return null; } // Normalize: the plugin may store the host bare or with a trailing slash. $host = (string) $creds['host']; if ( 0 !== strpos( $host, 'http://' ) && 0 !== strpos( $host, 'https://' ) ) { $host = 'https://' . $host; } $creds['host'] = untrailingslashit( $host ); $creds['client_id'] = (string) $creds['client_id']; $creds['client_secret'] = (string) $creds['client_secret']; return $creds; } /** * OAuth2 client-credentials Bearer token, cached PER RUN (static): one * auth-sessions call covers the whole poll batch. A cached token is honored * only inside its expiry window (the response's expires_in when present, * else ~3000s, refreshed a little early), so a long batch never keeps * presenting a stale token. Transient failures are cached for at most 60 * seconds to avoid repeated requests inside one action without poisoning a * long-lived Action Scheduler runner. Credential rejections are not cached. * * @param array $creds orderguard_boxnow_creds() array. * @param bool $force evict any cached token for this credential set and * re-authenticate (used after a 401/403 on /parcels). * @return string|null|WP_Error token; null = unusable response; WP_Error = * transient or account-wide terminal failure. */ function orderguard_boxnow_token( $creds, $force = false ) { static $cache = array(); $key = md5( $creds['host'] . '|' . $creds['client_id'] ); if ( $force ) { unset( $cache[ $key ] ); } if ( array_key_exists( $key, $cache ) ) { $entry = $cache[ $key ]; if ( is_array( $entry ) && array_key_exists( 'failure', $entry ) ) { if ( time() < $entry['expires'] ) { return $entry['failure']; } unset( $cache[ $key ] ); } elseif ( is_array( $entry ) && time() < $entry['expires'] ) { return $entry['token']; } else { unset( $cache[ $key ] ); } } $response = wp_remote_post( $creds['host'] . '/api/v1/auth-sessions', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json' ), // VERIFIED ON A LIVE STORE: /auth-sessions is OAuth2 // client-credentials and REQUIRES grant_type. Without it the API // answers HTTP 400 InvalidJsonSchema ("must have required property // 'grant_type'"), which the code below (correctly) treated as // "credentials rejected" -- so the real cause was invisible. 'body' => wp_json_encode( array( 'grant_type' => 'client_credentials', 'client_id' => $creds['client_id'], 'client_secret' => $creds['client_secret'], ) ), ) ); if ( is_wp_error( $response ) ) { $cache[ $key ] = array( 'failure' => $response, 'expires' => time() + 60 ); return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $code ) { $failure = new WP_Error( 'orderguard_courier_rate_limited', 'BOX NOW rate limited the authentication request' ); $cache[ $key ] = array( 'failure' => $failure, 'expires' => time() + 60 ); return $failure; } if ( $code >= 500 ) { $failure = new WP_Error( 'orderguard_boxnow_http', 'BOX NOW auth HTTP ' . $code ); $cache[ $key ] = array( 'failure' => $failure, 'expires' => time() + 60 ); return $failure; } if ( 401 === $code || 403 === $code ) { return new WP_Error( 'orderguard_courier_auth_failed', 'BOX NOW rejected the courier-plugin credentials' ); } if ( $code < 200 || $code >= 300 ) { // Log the API's own message: a bare "check your credentials" hid a // schema error (missing grant_type) for four debugging rounds. orderguard_log( 'OrderGuard: BOX NOW auth failed (HTTP ' . $code . ') -- ' . substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ) ); $cache[ $key ] = array( 'failure' => null, 'expires' => time() + 60 ); return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $token = ''; if ( is_array( $data ) ) { // TODO(verify-first): the Partner API's token field (access_token per // OAuth2); alternates probed defensively. foreach ( array( 'access_token', 'accessToken', 'token' ) as $field ) { if ( ! empty( $data[ $field ] ) && is_scalar( $data[ $field ] ) ) { $token = (string) $data[ $field ]; break; } } } if ( '' === $token ) { orderguard_log( 'OrderGuard: BOX NOW auth response carried no token field -- courier sync skipped' ); $cache[ $key ] = array( 'failure' => null, 'expires' => time() + 60 ); return null; } // Cache with a bounded lifetime: expires_in when the response carries one // (else ~3000s), refreshed 60s before the wire expiry; floored so a tiny // or bogus expires_in can never make the cache thrash. $expires_in = ( isset( $data['expires_in'] ) && is_numeric( $data['expires_in'] ) ) ? (int) $data['expires_in'] : 3000; $cache[ $key ] = array( 'token' => $token, 'expires' => time() + max( 60, $expires_in - 60 ), ); return $token; } /** * Poll BOX NOW for one order, looked up by PARCEL ID (the AWB stored by the * BOX NOW plugin). * * VERIFIED ON A LIVE STORE (0.1.4): this used to query ?orderNumber={shop * order number}, which was wrong twice over. BOX NOW does not index parcels by * the shop's order number at all -- it keeps the WooCommerce order id in * `itemRefId` -- and, worse, an unmatched ?orderNumber= returns HTTP 200 with * an EMPTY data array rather than an error. So every poll "succeeded" and * quietly found nothing: no error, no log line, outcome `unknown`, counter * stuck at 0. ?parcelId={awb} returns exactly the one parcel. */ function orderguard_boxnow_poll( $order, $awb ) { $creds = orderguard_boxnow_creds(); if ( null === $creds ) { return null; } $token = orderguard_boxnow_token( $creds ); if ( is_wp_error( $token ) ) { return $token; // transient -- let the batch back off } if ( ! is_string( $token ) || '' === $token ) { return null; } $awb = trim( (string) $awb ); if ( '' === $awb ) { return null; // no parcel id -> nothing to look up (label not made yet) } // On 401/403 the cached token is evicted and re-authed ONCE (it may have // expired between the auth call and this request). If /auth-sessions // accepts a fresh token but this parcel still rejects it, the credentials // are valid and the batch treats the result as a parcel/account mismatch. for ( $attempt = 0; $attempt < 2; $attempt++ ) { $response = wp_remote_get( $creds['host'] . '/api/v1/parcels?parcelId=' . rawurlencode( $awb ), array( 'timeout' => 8, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 401 !== $code && 403 !== $code ) { break; } if ( 0 === $attempt ) { $token = orderguard_boxnow_token( $creds, true ); // evict + re-auth once if ( is_wp_error( $token ) ) { return $token; } if ( ! is_string( $token ) || '' === $token ) { return null; } continue; } orderguard_log( 'OrderGuard: BOX NOW rejected order #' . $order->get_id() . ' after accepting a fresh access token -- treating this as a parcel/account mismatch' ); return new WP_Error( 'orderguard_courier_resource_mismatch', 'BOX NOW accepted the credentials but rejected this parcel for the partner account' ); } if ( 429 === $code ) { return new WP_Error( 'orderguard_courier_rate_limited', 'BOX NOW rate limited the courier sync' ); } if ( $code >= 500 ) { return new WP_Error( 'orderguard_boxnow_http', 'BOX NOW parcels HTTP ' . $code ); } if ( $code < 200 || $code >= 300 ) { return null; // not found etc. -- skip, don't hammer } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return null; } if ( null === orderguard_boxnow_extract_parcel( $data, (string) $awb ) ) { // Log it: a lookup that returns HTTP 200 + zero parcels is exactly the // failure that stayed invisible before 0.1.4. Silence here reads as // "polled fine, still in transit", which is indistinguishable from // working -- and it isn't. orderguard_log( 'OrderGuard: BOX NOW returned no matching parcel for id ' . $awb . ' (order #' . $order->get_id() . ') -- lookup found nothing' ); return null; // no unambiguous parcel for this order -- not pollable } return $data; } /** * Pick THIS order's parcel out of the parcels response (defensive). With a * known parcel id only an id match counts; without one, only a single-item * response is unambiguous. Anything else -> null (fail toward unknown). */ function orderguard_boxnow_extract_parcel( $raw, $awb = '' ) { if ( ! is_array( $raw ) ) { return null; } $items = null; if ( isset( $raw['items'] ) && is_array( $raw['items'] ) ) { $items = $raw['items']; } elseif ( isset( $raw['data'] ) && is_array( $raw['data'] ) ) { $items = $raw['data']; } elseif ( isset( $raw['state'] ) ) { return $raw; // already a bare parcel object } elseif ( array() !== $raw && array_values( $raw ) === $raw ) { $items = $raw; // bare list } if ( ! is_array( $items ) || empty( $items ) ) { return null; } $awb = trim( (string) $awb ); if ( '' !== $awb ) { foreach ( $items as $item ) { if ( ! is_array( $item ) ) { continue; } foreach ( array( 'id', 'parcelId' ) as $field ) { if ( isset( $item[ $field ] ) && is_scalar( $item[ $field ] ) && trim( (string) $item[ $field ] ) === $awb ) { return $item; } } } return null; // stored parcel id matches nothing -- ambiguous, skip } if ( 1 === count( $items ) ) { $only = reset( $items ); return is_array( $only ) ? $only : null; } return null; // several parcels, no id to pick by -- ambiguous, skip } /** * Classify by the DOCUMENTED BOX NOW parcel states ONLY (never the WC order * status -- see the section guardrail): * delivered => kept (collected from the locker) * expired-return => not_collected (sat uncollected, went back) * returned => returned ONLY when the parcel's events[] show a prior * delivery/collection (customer return after collection); * otherwise not_collected -- an uncollected parcel also * travels expired-return -> returned (back at sender), * so the bare state must never overwrite a correct * not_collected with 'returned' on a re-poll. * cancelled / cancelled-return / lost => no customer signal -- 'unknown'. * Everything else (new / in-transit / in-depot / at-locker) => 'unknown'. */ function orderguard_boxnow_classify( $raw, $order = null ) { $awb = ( $order instanceof WC_Order ) ? orderguard_boxnow_get_awb( $order ) : ''; $item = orderguard_boxnow_extract_parcel( $raw, $awb ); if ( ! is_array( $item ) || ! isset( $item['state'] ) || ! is_scalar( $item['state'] ) ) { return 'unknown'; } $state = strtolower( str_replace( '_', '-', trim( (string) $item['state'] ) ) ); // VERIFIED against BOX NOW's Partner API manual v1.65 (Bulgaria) AND live // parcel data. The documented state vocabulary is: // // new -- registered in the system // in-transit -- being transferred to a location // in-depot -- sitting in a BOX NOW warehouse // final-destination -- at the locker, WAITING FOR PICKUP // delivered -- delivered (customer collected it) // returned -- returned to the sender // expired-return -- not collected in time -> returned // lost / canceled / missing // // CAUTION -- the manual is WRONG about one value: it documents // 'final-destination', but the live API sends 'in-final-destination'. // Both are absent from this map on purpose, so both read as unknown, which // is the correct answer either way -- a parcel awaiting pickup has no // outcome yet. Do NOT "fix" that by mapping it to anything. // // Everything not listed here deliberately falls through to 'unknown'. // That matters most for 'lost', 'canceled' and 'missing': none of those is // the customer's doing, and none may ever count against them. $map = apply_filters( 'orderguard_boxnow_state_map', array( 'delivered' => 'kept', 'expired-return' => 'not_collected', 'returned' => 'returned', ) ); if ( ! is_array( $map ) || ! isset( $map[ $state ] ) ) { return 'unknown'; } $outcome = orderguard_normalize_courier_outcome( $map[ $state ] ); // A bare 'returned' state says only "this parcel went back to the sender". // It does NOT say whose doing that was, and the difference decides whether a // real person gets the harshest mark in the system. // // VERIFIED against live parcels on a merchant account (see // orderguard_boxnow_reached_customer), the three real event sequences are: // // delivered new -> accepted-to-locker -> final-destination -> delivered // expired-return new -> in-depot -> final-destination -> expired -> in-depot -> returned // returned new -> accepted-to-locker -> in-depot -> returned // // Note the last one: NO final-destination. That parcel never arrived at the // customer's locker at all -- it went from the sender's locker to the depot // and straight back. The customer was never given the chance to collect it. // Marking that 'not_collected' would punish someone for a journey that never // reached them (cancelled, undeliverable, a courier-side problem -- we can't // tell, and it isn't ours to guess). // // So the deciding fact is not "was it delivered" but "did it ever reach the // customer": // reached + delivered -> they got it and sent it back => returned // reached + not taken -> it waited for them, they didn't come => not_collected // never reached -> nothing to judge them on => unknown if ( 'returned' === $state ) { if ( orderguard_boxnow_had_delivery( $item ) ) { $outcome = 'returned'; // collected first, then sent back } elseif ( orderguard_boxnow_reached_customer( $item ) ) { $outcome = 'not_collected'; // sat in their locker, never taken } else { $outcome = 'unknown'; // never got to them -- say nothing } } return $outcome; } /** * TRUE when the parcel actually reached the customer's locker -- i.e. they had a * real opportunity to collect it. * * VERIFIED on live parcels: the arrival event is `type = final-destination` * (note: BOX NOW's manual documents the parcel STATE as 'final-destination' but * sends 'in-final-destination' on the parcel itself -- the EVENT type, however, * really is 'final-destination'. Both spellings are accepted here so a rename on * either side cannot silently turn this off). * * `expired` is also accepted: a parcel can only expire after arriving, so its * presence proves arrival even if the arrival event itself were missing. * * Fails to FALSE when events are absent or unreadable. That is the merciful * direction here: no proof of arrival means we record nothing rather than mark * someone for a parcel that may never have reached them. */ function orderguard_boxnow_reached_customer( $item ) { if ( ! is_array( $item ) || empty( $item['events'] ) || ! is_array( $item['events'] ) ) { return false; } foreach ( $item['events'] as $event ) { if ( ! is_array( $event ) ) { continue; } foreach ( array( 'type', 'state', 'status', 'eventType', 'name' ) as $field ) { if ( ! isset( $event[ $field ] ) || ! is_scalar( $event[ $field ] ) ) { continue; } $value = strtolower( str_replace( '_', '-', trim( (string) $event[ $field ] ) ) ); if ( 'final-destination' === $value || 'in-final-destination' === $value ) { return true; } if ( 'expired' === $value ) { return true; // can only expire after it arrived } } } return false; } /** * TRUE only when the parcel's events[] carry evidence the customer actually * took it -- a delivered/collected event. * * VERIFIED on a live BOX NOW account: a delivered parcel's final event is * `type = delivered`, and this function returns true for it. The field is * `type`; the others are kept as defensive alternates. * * Returns false when events are absent or unreadable. Paired with * orderguard_boxnow_reached_customer(), that now means "unknown" rather than * "not_collected" -- no proof of collection is no longer read as proof of * non-collection. */ function orderguard_boxnow_had_delivery( $item ) { if ( ! is_array( $item ) || empty( $item['events'] ) || ! is_array( $item['events'] ) ) { return false; } foreach ( $item['events'] as $event ) { if ( ! is_array( $event ) ) { continue; } foreach ( array( 'state', 'type', 'status', 'eventType', 'name' ) as $field ) { if ( ! isset( $event[ $field ] ) || ! is_scalar( $event[ $field ] ) ) { continue; } $value = strtolower( str_replace( '_', '-', trim( (string) $event[ $field ] ) ) ); if ( false !== strpos( $value, 'delivered' ) ) { return true; } // 'collected' counts, but never via its own negations. if ( false !== strpos( $value, 'collected' ) && false === strpos( $value, 'not-collected' ) && false === strpos( $value, 'uncollected' ) ) { return true; } } } return false; } /** * Short NON-PII summary: parcel id + state + event count only (events can * carry locker addresses/names -- never stored). */ function orderguard_boxnow_status_summary( $raw ) { $item = orderguard_boxnow_extract_parcel( $raw, '' ); if ( ! is_array( $item ) && is_array( $raw ) ) { // Multi-parcel response: summarize the count only. $items = isset( $raw['items'] ) && is_array( $raw['items'] ) ? $raw['items'] : array(); return empty( $items ) ? '' : 'parcels=' . count( $items ); } if ( ! is_array( $item ) ) { return ''; } $parts = array(); foreach ( array( 'id', 'state' ) as $key ) { if ( isset( $item[ $key ] ) && is_scalar( $item[ $key ] ) && '' !== (string) $item[ $key ] ) { $parts[] = $key . '=' . $item[ $key ]; } } if ( isset( $item['events'] ) && is_array( $item['events'] ) ) { $parts[] = 'events=' . count( $item['events'] ); } return implode( '; ', $parts ); } /** * TODO(verify-first): the official box-now-delivery plugin ships no local * logo image asset. If a checkout asset turns up in the installed copy, * resolve it via file_exists + plugins_url like orderguard_econt_logo(). Until then: * the OrderGuard-bundled placeholder wordmark (overridable via an official * brand-kit SVG in assets/couriers/ -- see orderguard_courier_bundled_logo()). * Remote third-party logos are NEVER hotlinked. */ function orderguard_boxnow_logo() { return orderguard_courier_bundled_logo( 'boxnow' ); } /* * --------------------------------------------------------------------------- * PIGEON EXPRESS -- its WooCommerce plugin. Polls the Pigeon Express API with * the merchant's stored API key/secret. The status_code enum is unconfirmed, * so classification is STRUCTURAL (delivery_date / chain_after), exactly like * the Econt adapter's structural approach. * --------------------------------------------------------------------------- */ function orderguard_pigeon_detect() { // TODO(verify-first): confirm the plugin's main constant/class. $detected = defined( 'PIGEON_EXPRESS_WC_PATH' ) || class_exists( 'Pigeon_Express_WC' ) || class_exists( 'PigeonExpressWC' ); return (bool) apply_filters( 'orderguard_pigeon_detect', $detected ); } function orderguard_pigeon_order_uses( $order ) { if ( '' !== orderguard_pigeon_get_awb( $order ) ) { return true; } // TODO(verify-first): the plugin's shipping method id (likely candidates). $methods = orderguard_order_shipping_method_ids( $order ); return in_array( 'pigeon_express', $methods, true ) || in_array( 'pe_wc_shipping', $methods, true ); } /** * The Pigeon Express shipment reference for an order ('' when none stored). */ function orderguard_pigeon_get_awb( $order ) { $ref = $order->get_meta( '_pe_wc_shipment_reference' ); return is_scalar( $ref ) ? trim( (string) $ref ) : ''; } /** * Pigeon Express API credentials + sandbox flag from the plugin's options * (read at call time, never stored). Null = not configured -> skip silently. * TODO(verify-first): option names + sandbox flag values on a live install. */ function orderguard_pigeon_creds() { $sandbox = get_option( 'pigeon_express_wc_sandbox', 'no' ); $creds = array( 'key' => orderguard_courier_scalar_option( array( 'pigeon_express_wc_api_key' ) ), 'secret' => orderguard_courier_scalar_option( array( 'pigeon_express_wc_api_secret' ) ), 'sandbox' => in_array( $sandbox, array( 'yes', '1', 1, true ), true ), ); $creds = apply_filters( 'orderguard_pigeon_credentials', $creds ); if ( ! is_array( $creds ) || empty( $creds['key'] ) || empty( $creds['secret'] ) ) { return null; } return $creds; } /** * Poll Pigeon Express for one shipment: GET /v1/shipments/{ref}/track. */ function orderguard_pigeon_poll( $order, $awb ) { $awb = (string) $awb; if ( '' === $awb ) { return null; // no shipment created yet } $creds = orderguard_pigeon_creds(); if ( null === $creds ) { return null; } $base = empty( $creds['sandbox'] ) ? 'https://api.pigeonexpress.com/v1' : 'https://api-demo.pigeonexpress.com/v1'; $response = wp_remote_get( $base . '/shipments/' . rawurlencode( $awb ) . '/track', array( 'timeout' => 8, 'headers' => array( 'X-API-Key' => $creds['key'], 'X-API-Secret' => $creds['secret'], 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $code ) { return new WP_Error( 'orderguard_courier_rate_limited', 'Pigeon Express rate limited the courier sync' ); } if ( $code >= 500 ) { return new WP_Error( 'orderguard_pigeon_http', 'Pigeon Express track HTTP ' . $code ); } if ( 401 === $code || 403 === $code ) { return new WP_Error( 'orderguard_courier_auth_failed', 'Pigeon Express rejected the courier-plugin credentials' ); } if ( $code < 200 || $code >= 300 ) { return null; // not found etc. -- skip } $data = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $data ) ? $data : null; } /** * The shipment object out of whichever wrapper the API used (defensive). */ function orderguard_pigeon_extract_shipment( $raw ) { if ( ! is_array( $raw ) ) { return null; } foreach ( array( 'data', 'shipment', 'result' ) as $wrap ) { if ( isset( $raw[ $wrap ] ) && is_array( $raw[ $wrap ] ) ) { $raw = $raw[ $wrap ]; break; } } // Looks like a shipment object? if ( isset( $raw['delivery_date'] ) || isset( $raw['chain_after'] ) || isset( $raw['status'] ) || isset( $raw['status_code'] ) || isset( $raw['reference'] ) ) { return $raw; } return null; } /** * STRUCTURAL classification (the status_code enum is unconfirmed): * delivery_date set + an EXPLICIT return-to-sender chain_after => returned; * delivery_date set, no explicit return chain, NON-COD order => kept; * delivery_date set, no explicit return chain, COD order => unknown * (the COD gate -- see below); * NO delivery + an EXPLICIT return-to-sender chain_after => not_collected; * anything else => unknown (re-polled). * "Explicit" means the definitive Bulgarian "връща се към подател" phrase in * the chained shipment(s), or a structured direction/type field on a chained * leg equal to a return value. A bare 'return' substring in the chain_after * blob is deliberately NOT enough -- it also matches benign legs * (return-receipt/return-documents services, redirects), exactly the * false-positive class the Econt adapter documents fixing. This adapter is * unverified/inert, so anything short of explicit evidence stays 'unknown'. * * THE COD GATE (M6 family), DECIDED THE OPPOSITE WAY FROM SPEEDY -- because * the evidence is opposite. Speedy's -14 maps to 'kept' unconditionally on * the strength of a verified response shape (no COD field exists to gate * on) and a known handover-against-payment procedure. For Pigeon Express * NOTHING is verified: this adapter has never run against a live account, * the status enum is unconfirmed, and the file's own TODO above concedes * delivery_date may be a SCHEDULED date rather than an actual delivery. A * 'kept' verdict is a claim about money on a COD order; issuing it from an * unverified field would be guessing. So this classifier mirrors * orderguard_econt_classify(_mreja): on COD orders it says 'kept' only with * collection evidence -- and since no collection field is even KNOWN for * this API, COD orders currently stay 'unknown' altogether. That is the * honest cost of an unverified adapter, and it is LOUD by design: on a * mostly-COD Pigeon store the courier-health detector will flag the silence * ("looks BROKEN"), which is precisely the prompt to do the verify-first * work below and tighten this classifier against real data. THIS IS WHY * $order IS REQUIRED here (same reason as the Econt classifiers). * * TODO(verify-first): call GET /shipment-statuses on the live account to * confirm the terminal status codes, then tighten this to the real enum. * TODO(verify-first): delivery_date must be confirmed to be an ACTUAL * delivery timestamp (not a scheduled/expected delivery date) before 'kept' * is trusted; if the live shipment carries a delivered status/status_code, * require it ALONGSIDE delivery_date here. * TODO(verify-first): find the COD-collected field (amount/date) in the live * response and use it to open the COD gate, like cd_get_time does for mreja. */ function orderguard_pigeon_classify( $raw, $order = null ) { $s = orderguard_pigeon_extract_shipment( $raw ); if ( ! is_array( $s ) ) { return 'unknown'; } $delivered = ! empty( $s['delivery_date'] ); // EXPLICIT return-to-sender evidence only -- see the docblock. mb_* on // UTF-8, like the Econt matcher: Cyrillic never case-folds under plain // strtolower(). $return_chain = false; if ( ! empty( $s['chain_after'] ) ) { $blob = mb_strtolower( (string) wp_json_encode( $s['chain_after'], JSON_UNESCAPED_UNICODE ), 'UTF-8' ); if ( false !== mb_strpos( $blob, 'връща се към подател', 0, 'UTF-8' ) ) { $return_chain = true; } elseif ( is_array( $s['chain_after'] ) ) { // Structured signal: a direction/type field on a chained leg that // EQUALS a return value (never a substring match). $legs = ( array_values( $s['chain_after'] ) === $s['chain_after'] ) ? $s['chain_after'] : array( $s['chain_after'] ); // single leg object, not a list foreach ( $legs as $leg ) { if ( ! is_array( $leg ) ) { continue; } foreach ( array( 'direction', 'type', 'shipment_type', 'kind' ) as $field ) { if ( isset( $leg[ $field ] ) && is_scalar( $leg[ $field ] ) && in_array( mb_strtolower( trim( (string) $leg[ $field ] ), 'UTF-8' ), array( 'return', 'return_to_sender', 'return-to-sender' ), true ) ) { $return_chain = true; break 2; } } } } } if ( $delivered ) { if ( $return_chain ) { return 'returned'; // had it, sent it back -- structural, no money claim } // COD gate -- see the docblock. No collection field is known for this // unverified API, so a COD 'kept' cannot be evidenced yet: say // nothing rather than guess about money. Unknown payment context // (no $order) is treated as COD -- the conservative direction, same // as orderguard_econt_classify_mreja(). $is_cod = ( $order instanceof WC_Order ) ? orderguard_is_cod_order( $order ) : true; if ( ! $is_cod ) { return 'kept'; } return 'unknown'; // delivered COD, collection unprovable on this API -- wait for the verify-first work } if ( $return_chain ) { return 'not_collected'; // explicitly going back to sender, never delivered } return 'unknown'; // in transit / ambiguous } /** * Short NON-PII summary (reference + status/dates only -- never receiver data). */ function orderguard_pigeon_status_summary( $raw ) { $s = orderguard_pigeon_extract_shipment( $raw ); if ( ! is_array( $s ) ) { return ''; } $parts = array(); foreach ( array( 'reference', 'status_code', 'status', 'delivery_date' ) as $key ) { if ( isset( $s[ $key ] ) && is_scalar( $s[ $key ] ) && '' !== (string) $s[ $key ] ) { $parts[] = $key . '=' . $s[ $key ]; } } if ( ! empty( $s['chain_after'] ) ) { $parts[] = 'chain_after=' . ( is_array( $s['chain_after'] ) ? count( $s['chain_after'] ) : 1 ); } return implode( '; ', $parts ); } /** * No known local logo asset in the Pigeon Express plugin -- the OrderGuard- * bundled placeholder wordmark (overridable via an official brand-kit SVG in * assets/couriers/ -- see orderguard_courier_bundled_logo()). Remote third-party * logos are NEVER hotlinked. */ function orderguard_pigeon_logo() { return orderguard_courier_bundled_logo( 'pigeon' ); } /** * =========================================================================== * M4 -- UI SUPPORT: trust tier, source labels, daily tally, provenance chips. * =========================================================================== */ /** * The trust tier of an outcome source. A REGISTERED adapter id means the * verdict was read from the courier's own tracking API => 'carrier' (verified). * Anything else (merchant status map, TrackShip, the orderguard_report_outcome dev * hook) => 'status_map', the distinct LOWER-trust tier. Derived from the * registry, never caller-asserted, so orderguard_report_courier_outcome() and the * reconciliation sweep's re-emits always agree on the same value -- and no * caller can promote itself to the verified tier. */ function orderguard_courier_source_kind( $source ) { return orderguard_courier_adapter( $source ) ? 'carrier' : 'status_map'; } /** * Human label for an outcome source (adapter label, or a friendly name for * the status-map tier sources). */ function orderguard_courier_source_label( $source ) { $adapter = orderguard_courier_adapter( $source ); if ( $adapter ) { return $adapter['label']; } if ( 'status_map' === $source ) { return __( 'Status mapping', 'orderguard' ); } if ( 'trackship' === $source ) { return 'TrackShip'; } return ucfirst( (string) $source ); } /** * =========================================================================== * COURIER HEALTH -- make silent failure loud. * =========================================================================== * WHY THIS EXISTS. Every courier bug found so far failed the same way: the * adapter polled, the API answered, and every parcel classified 'unknown'. Not * one error. Not one log line. And 'unknown' is ALSO the correct answer for a * healthy parcel still in transit -- so total failure is indistinguishable from * normal operation, from the outside and from the inside. * * The scoreboard, all found only because a merchant happened to ask: * Speedy -- read $last['code']; the field is 'operationCode'. * 847 waybills, 0 outcomes, for as long as it shipped. * BOX NOW -- queried ?orderNumber=; the API wants ?parcelId=, and answers an * unmatched orderNumber with HTTP 200 and an empty list. * BOX NOW -- /auth-sessions needs grant_type; without it a 400 that read as * "your credentials are wrong". * mreja -- is_returned is 0 on every row ever written. * * Four adapter paths (Econt official, Econt Bulgarisation, Speedy * Bulgarisation, Pigeon) have NEVER been run against a live store. Every single * assumption made about the three that have was wrong. There is no reason to * believe the untested four are better, and a merchant installing one will see * a green "Reading" pill and get nothing, forever, in silence. * * So: notice it ourselves. If a courier has had real orders to look at for * long enough and has produced ZERO outcomes for them, that is not * "everything is in transit" -- that is a broken adapter (or unreadable * waybills), and we say so instead of waiting to be asked. * * TWO FAILURE SHAPES, ONE DETECTOR (H3 + M1). The original detector could * not fire on either of them: * * H3 -- it compared a PER-PAGE count against the alarm threshold. Page * size is 10 for every API adapter, so on a mixed-courier store (both live * pilots) one courier's share of a page could mathematically never reach * 10: the detector built to catch the Speedy failure could not have caught * the Speedy failure. Counts are now summed across a whole PASS over the * window (offset 0 starts the pass, the short final page ends it), so each * order counts once per pass and the total is the courier's real volume. * * M1 -- 'recorded > 0' disarmed it forever: one lifetime success meant a * later death (frozen mreja mirror, rotated e-Econt password, renamed * option key on upgrade) stayed green permanently. The detector now judges * a RECENT window anchored to the LAST successful read, so "worked in May, * dead since June" fires just like "never worked". * * THE EVIDENCE RULE. An order testifies against a courier when ALL hold: * - it is that courier's order, seen by the current/last pass; * - it was created >= ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS ago -- old enough * that a terminal outcome should exist (in-transit parcels are NOT * evidence: all-'unknown' on fresh orders is healthy); * - it was created AFTER the last successful read (the watershed) -- the * permanent-unknown residue every healthy store accumulates (cancelled * parcels, Speedy 195s, merchant recalls) must not arm the alarm, and a * store that simply STOPPED USING a courier produces no post-watershed * orders and stays quiet; * - no terminal outcome was ever recorded for it by this courier. * The alarm needs ORDERGUARD_COURIER_HEALTH_MIN_ORDERS such orders in a fresh pass, * a courier first seen >= ORDERGUARD_COURIER_HEALTH_MIN_DAYS ago, and no outcome * recorded in the last ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS (one real read clears * it instantly). Exact rule: orderguard_courier_looks_broken(). * * Deliberately counts only. No identities, no order refs, nothing sent anywhere. */ /** Orders must have been seen, and time must have passed, before we cry wolf. */ if ( ! defined( 'ORDERGUARD_COURIER_HEALTH_MIN_ORDERS' ) ) { define( 'ORDERGUARD_COURIER_HEALTH_MIN_ORDERS', 10 ); } if ( ! defined( 'ORDERGUARD_COURIER_HEALTH_MIN_DAYS' ) ) { define( 'ORDERGUARD_COURIER_HEALTH_MIN_DAYS', 7 ); } /** An order younger than this is "maybe still in transit" and never counts as evidence; one outcome read within this window clears the alarm. */ if ( ! defined( 'ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS' ) ) { define( 'ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS', 10 ); } /** Pass counters older than this describe a poller that stopped running (dead cron, disabled sync) -- stale numbers must not fire a fresh alarm. */ if ( ! defined( 'ORDERGUARD_COURIER_HEALTH_FRESH_DAYS' ) ) { define( 'ORDERGUARD_COURIER_HEALTH_FRESH_DAYS', 7 ); } /* * OUTCOME COVERAGE (0.1.9) -- the meter's constants. Coverage answers the * question a raw lifetime count cannot: "of the shipped orders that have had * time to finish, how many did we actually read an outcome for?" A merchant * seeing "706" against 26,631 lifetime orders concludes the feature barely * works; "96% -- 395 of 412 shipments" is self-explanatory, and so is 4%. * * The DENOMINATOR, exactly: this courier's orders (order_uses), in the * shipped status set the poller already walks, created between * COVERAGE_WINDOW_DAYS and COVERAGE_AGE_DAYS ago. Both bounds carry weight: * - older than AGE_DAYS (14): a parcel younger than that may legitimately * still be on the road / in a locker -- in-flight orders must not drag a * healthy store's number down; * - within WINDOW_DAYS (60), CLAMPED to the adapter's lookback if that is * shorter: the meter judges the integration's RECENT behaviour. This is * deliberately NOT the adapter lookback itself -- mreja looks back 730 * days, and two years of pre-OrderGuard history would read as "5% * coverage" on a perfectly healthy store forever. * The NUMERATOR: the subset with a terminal outcome recorded by THIS source * (another source's verdict does not excuse this adapter -- same rule as the * detector's evidence). * * Counted during the poller's existing pass walk (the orders are already * loaded there) and snapshotted with the pass -- NEVER computed on a page * load: this renders on a live settings page, and a per-render scan of a * 60-day window is exactly the kind of load bomb 0.1.8 just removed. */ /** An order younger than this may still be in transit and is never part of the coverage denominator. */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_AGE_DAYS' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_AGE_DAYS', 14 ); } /** Coverage judges orders created within this many days (clamped to the adapter lookback when shorter). */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_WINDOW_DAYS' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_WINDOW_DAYS', 60 ); } /** The card shows a percentage only from this many measured orders -- "0 of 2" is noise, not a rate. */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_SHOW_MIN' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_SHOW_MIN', 5 ); } /** The courier-problem notice needs at least this many measured orders before a low rate means anything. */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_MIN_ORDERS' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_MIN_ORDERS', 20 ); } /** The courier-problem notice fires below this percentage (strictly below; exactly at it stays quiet). */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_BROKEN_PCT' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_BROKEN_PCT', 20 ); } /** * A courier must have been seen this long before low coverage is called a * problem. NOT a round number: 24 > AGE_DAYS + BROKEN_PCT% of the 46-day band * (14 + 9.2 = 23.2). That inequality means a fresh install clears the * threshold on post-install resolutions ALONE before the alarm may speak -- * even in the worst case where not one pre-install outcome is readable * (every real courier here does serve history; this covers one that doesn't). */ if ( ! defined( 'ORDERGUARD_COURIER_COVERAGE_MIN_DAYS' ) ) { define( 'ORDERGUARD_COURIER_COVERAGE_MIN_DAYS', 24 ); } function orderguard_courier_health_all( &$ok = null ) { $data = orderguard_get_option( 'orderguard_courier_health', array(), $ok ); return is_array( $data ) ? $data : array(); } /** * One courier's health row, normalized (every key present, ints). Rows * written by older builds lack the pass/last_pass/last_recorded_at fields * and read back as zeros -- orderguard_courier_looks_broken() and the upgrade seed * treat those explicitly. */ function orderguard_courier_health_normalize_row( $row ) { $row = is_array( $row ) ? $row : array(); $pass = isset( $row['pass'] ) && is_array( $row['pass'] ) ? $row['pass'] : array(); $last_pass = isset( $row['last_pass'] ) && is_array( $row['last_pass'] ) ? $row['last_pass'] : array(); return array( 'first_seen' => (int) ( $row['first_seen'] ?? 0 ), // first time we had an order for this courier 'eligible' => (int) ( $row['eligible'] ?? 0 ), // legacy: most orders seen in one PAGE -- diagnostic only, the detector no longer reads it (H3) 'recorded' => (int) ( $row['recorded'] ?? 0 ), // outcomes ever recorded (lifetime) 'last_run' => (int) ( $row['last_run'] ?? 0 ), 'last_recorded_at' => (int) ( $row['last_recorded_at'] ?? 0 ), // when the LAST outcome was recorded (M1's clock) 'auth_failed_at' => (int) ( $row['auth_failed_at'] ?? 0 ), // account-wide courier auth failure; cleared by the first successful poll 'mutation_token' => isset( $row['mutation_token'] ) && is_string( $row['mutation_token'] ) ? $row['mutation_token'] : '', // idempotency marker for wpdb's reconnect/retry path 'pass' => array( // the pass currently walking the window 'started_at' => (int) ( $pass['started_at'] ?? 0 ), 'updated_at' => (int) ( $pass['updated_at'] ?? 0 ), 'eligible' => (int) ( $pass['eligible'] ?? 0 ), 'evidence' => (int) ( $pass['evidence'] ?? 0 ), 'cov_denom' => (int) ( $pass['cov_denom'] ?? 0 ), // coverage denominator: claimed orders old enough to have finished (0.1.9) 'cov_num' => (int) ( $pass['cov_num'] ?? 0 ), // coverage numerator: of those, outcomes read by THIS source (0.1.9) 'last_offset' => isset( $pass['last_offset'] ) ? (int) $pass['last_offset'] : -1, ), 'last_pass' => array( // snapshot of the last COMPLETED pass 'completed_at' => (int) ( $last_pass['completed_at'] ?? 0 ), 'eligible' => (int) ( $last_pass['eligible'] ?? 0 ), 'evidence' => (int) ( $last_pass['evidence'] ?? 0 ), 'cov_denom' => (int) ( $last_pass['cov_denom'] ?? 0 ), // pre-0.1.9 snapshots read back 0 -> the meter stays hidden until one full 0.1.9 pass completes 'cov_num' => (int) ( $last_pass['cov_num'] ?? 0 ), 'last_offset' => isset( $last_pass['last_offset'] ) ? (int) $last_pass['last_offset'] : -1, ), ); } /** * Confirm that a health write whose acknowledgement was lost actually landed. * * wpdb retries the same statement after reconnecting from errno 2006. When the * first UPDATE committed but its OK packet was lost, the retry affects zero * rows because the old-value predicate is no longer true. The operation token * distinguishes that success from ordinary contention without applying a * non-idempotent mutator twice. */ function orderguard_courier_health_operation_landed( $courier, $token, &$row = null ) { 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 $stored = $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. 'orderguard_courier_health' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- lost-ack verification must bypass Options API caches. if ( null === $stored || '' !== $wpdb->last_error ) { return false; } $map = maybe_unserialize( $stored ); if ( ! is_array( $map ) || ! isset( $map[ $courier ] ) || ! is_array( $map[ $courier ] ) ) { return false; } $row = orderguard_courier_health_normalize_row( $map[ $courier ] ); if ( ! hash_equals( (string) $row['mutation_token'], (string) $token ) ) { return false; } orderguard_invalidate_option_caches( 'orderguard_courier_health', false ); wp_cache_delete( 'alloptions', 'options' ); return true; } /** * One courier's normalized health row. * * @param string $courier Courier identifier. * @param bool $ok Set false when the backing option could not be read. * @return array */ function orderguard_courier_health( $courier, &$ok = null ) { $all = orderguard_courier_health_all( $ok ); $courier = sanitize_key( (string) $courier ); return orderguard_courier_health_normalize_row( isset( $all[ $courier ] ) ? $all[ $courier ] : array() ); } function orderguard_courier_health_log_once( $key, $message ) { static $logged = array(); if ( isset( $logged[ $key ] ) ) { return; } $logged[ $key ] = true; orderguard_log( $message ); } /** * Atomically apply one pure mutation to a courier row in the shared map. * * @return array{status:string,before:?array,row:?array,reason:string} */ function orderguard_courier_health_mutate( $courier, $mutator ) { global $wpdb; $option = 'orderguard_courier_health'; $courier = sanitize_key( (string) $courier ); $operation_token = wp_generate_uuid4(); $before = null; $after = null; if ( '' === $courier || ! is_callable( $mutator ) ) { return array( 'status' => 'invalid_mutator', 'before' => null, 'row' => null, 'reason' => 'invalid courier or mutator' ); } if ( ! is_string( $operation_token ) || '' === $operation_token ) { return array( 'status' => 'invalid_token', 'before' => null, 'row' => null, 'reason' => 'could not create mutation token' ); } // Three total attempts: enough for the live writer, batch writer and // backfill writer to collide without ever claiming a false success. One // operation token spans every attempt: a wpdb reconnect may commit once, // retry the same conditional SQL and report zero affected rows. for ( $attempt = 1; $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 $stored = $wpdb->get_row( $wpdb->prepare( "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- compare-and-swap must bypass Options API caches. if ( null === $stored && '' !== $wpdb->last_error ) { return array( 'status' => 'read_error', 'before' => null, 'row' => null, 'reason' => $wpdb->last_error ); } if ( null === $stored ) { $stamp_found = false; $stamp_ok = false; $stamp = orderguard_migration_control_read( 'orderguard_data_migration_version', $stamp_found, $stamp_ok ); if ( ! $stamp_ok || ! $stamp_found || ( defined( 'ORDERGUARD_DATA_MIGRATION_VERSION' ) && ORDERGUARD_DATA_MIGRATION_VERSION !== (string) $stamp ) ) { return array( 'status' => 'not_ready', 'before' => null, 'row' => null, 'reason' => 'migration stamp not current' ); } // A current stamp makes the new-name row the only authoritative // generation. A preserved legacy fossil may still exist, but the // post-stamp lifecycle rule forbids consulting or resurrecting it. $before = orderguard_courier_health_normalize_row( array() ); $after = call_user_func( $mutator, $before ); if ( ! is_array( $after ) ) { return array( 'status' => 'invalid_mutator', 'before' => $before, 'row' => null, 'reason' => 'mutator returned a non-array' ); } $after = orderguard_courier_health_normalize_row( $after ); $after['mutation_token'] = $operation_token; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these $created = $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, maybe_serialize( array( $courier => $after ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic first-writer creation after migration. if ( false === $created ) { $create_error = $wpdb->last_error; $landed = null; if ( orderguard_courier_health_operation_landed( $courier, $operation_token, $landed ) ) { return array( 'status' => 'committed', 'before' => $before, 'row' => $landed, 'reason' => 'insert acknowledgement lost; token confirmed' ); } return array( 'status' => 'create_error', 'before' => $before, 'row' => $after, 'reason' => $create_error ); } if ( 1 === (int) $created ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return array( 'status' => 'committed', 'before' => $before, 'row' => $after, 'reason' => '' ); } $landed = null; if ( orderguard_courier_health_operation_landed( $courier, $operation_token, $landed ) ) { return array( 'status' => 'committed', 'before' => $before, 'row' => $landed, 'reason' => 'insert acknowledgement lost; token confirmed' ); } continue; } $map = maybe_unserialize( $stored['option_value'] ); if ( ! is_array( $map ) ) { orderguard_courier_health_log_once( 'malformed_map', 'OrderGuard: courier health map was malformed; mutation was blocked to preserve the stored value.' ); return array( 'status' => 'malformed', 'before' => null, 'row' => null, 'reason' => 'stored courier health map is not an array' ); } $before = orderguard_courier_health_normalize_row( isset( $map[ $courier ] ) ? $map[ $courier ] : array() ); if ( hash_equals( (string) $before['mutation_token'], $operation_token ) ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return array( 'status' => 'committed', 'before' => $before, 'row' => $before, 'reason' => 'prior attempt token confirmed' ); } $after = call_user_func( $mutator, $before ); if ( ! is_array( $after ) ) { return array( 'status' => 'invalid_mutator', 'before' => $before, 'row' => null, 'reason' => 'mutator returned a non-array' ); } $after = orderguard_courier_health_normalize_row( $after ); $semantic_map = $map; $semantic_map[ $courier ] = $after; $semantic_merged = maybe_serialize( $semantic_map ); $autoload = (string) $stored['autoload']; if ( $semantic_merged === $stored['option_value'] && 'off' === $autoload ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return array( 'status' => 'unchanged', 'before' => $before, 'row' => $after, 'reason' => '' ); } $after['mutation_token'] = $operation_token; $map[ $courier ] = $after; $merged = maybe_serialize( $map ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s, autoload = 'off' WHERE option_name = %s AND BINARY option_value = BINARY %s AND BINARY autoload = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $merged, $option, $stored['option_value'], $autoload ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact byte-level compare-and-swap. if ( false === $updated ) { $write_error = $wpdb->last_error; $landed = null; if ( orderguard_courier_health_operation_landed( $courier, $operation_token, $landed ) ) { return array( 'status' => 'committed', 'before' => $before, 'row' => $landed, 'reason' => 'update acknowledgement lost; token confirmed' ); } return array( 'status' => 'write_error', 'before' => $before, 'row' => $after, 'reason' => $write_error ); } if ( 1 === (int) $updated ) { orderguard_invalidate_option_caches( $option, false ); wp_cache_delete( 'alloptions', 'options' ); return array( 'status' => 'committed', 'before' => $before, 'row' => $after, 'reason' => '' ); } $landed = null; if ( orderguard_courier_health_operation_landed( $courier, $operation_token, $landed ) ) { return array( 'status' => 'committed', 'before' => $before, 'row' => $landed, 'reason' => 'update acknowledgement lost; token confirmed' ); } } return array( 'status' => 'contended', 'before' => $before, 'row' => $after, 'reason' => 'three compare-and-swap attempts were exhausted' ); } function orderguard_courier_health_mutation_succeeded( $result ) { return is_array( $result ) && in_array( $result['status'], array( 'committed', 'unchanged' ), true ); } /** * Offset 0 = a new pass over the window begins: restart the pass accumulators. */ function orderguard_courier_health_pass_begin( $courier ) { $now = time(); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now ) { $row['pass'] = array( 'started_at' => $now, 'updated_at' => $now, 'eligible' => 0, 'evidence' => 0, 'cov_denom' => 0, 'cov_num' => 0, 'last_offset' => -1, ); return $row; } ); if ( ! orderguard_courier_health_mutation_succeeded( $result ) ) { orderguard_courier_health_log_once( 'pass_begin_' . $courier, 'OrderGuard: courier health pass could not start for ' . $courier . '; batch stopped.' ); return false; } return true; } /** * Upgrade seed, called at the start of every batch (writes at most once per * courier, ever): rows written before last_recorded_at existed hold recorded * outcomes that cannot be dated. Seeding "now" starts the stopped-working * clock at upgrade time -- the conservative direction: it can only DELAY a * real alarm (by <= ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS), never invent one, and * it keeps the pre-upgrade permanent-unknown residue out of the evidence * count (evidence only counts orders CREATED after this watershed). * * @return array the (possibly seeded) normalized health row. */ function orderguard_courier_health_seed_last_recorded( $courier, &$ok = null ) { $now = time(); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now ) { if ( $row['recorded'] > 0 && $row['last_recorded_at'] <= 0 ) { $row['last_recorded_at'] = $now; } return $row; } ); if ( orderguard_courier_health_mutation_succeeded( $result ) ) { $ok = true; return $result['row']; } if ( in_array( $result['status'], array( 'write_error', 'contended' ), true ) && is_array( $result['before'] ) && is_array( $result['row'] ) ) { $ok = true; orderguard_courier_health_log_once( 'seed_write_' . $courier, 'OrderGuard: courier health seed could not be saved for ' . $courier . '; using a conservative in-memory watershed for this batch.' ); return $result['row']; } $ok = false; return orderguard_courier_health_normalize_row( array() ); } /** * Called at the end of a batch action: how many of this courier's orders did * this PAGE see, and how many of them are detector evidence (see the section * header)? Sums into the running pass -- each page holds different orders, * so the pass total is a distinct-order count (H3: the old per-page * high-water could never exceed the page size, so on a mixed store it could * never reach the alarm threshold). */ function orderguard_courier_health_note_run( $courier, $eligible, $evidence = 0, $cov_denom = 0, $cov_num = 0, $offset = null ) { $now = time(); $eligible = max( 0, (int) $eligible ); $evidence = max( 0, (int) $evidence ); $cov_denom = max( 0, (int) $cov_denom ); $cov_num = max( 0, (int) $cov_num ); $offset = null === $offset ? null : max( 0, (int) $offset ); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now, $eligible, $evidence, $cov_denom, $cov_num, $offset ) { // If the exact same page is retried after its aggregate was durably // applied, adding the same evidence twice is not harmless. This is an // exact-page replay guard, not a claim that partially overlapping page // walks can be split after their counters have already been aggregated. if ( null !== $offset && $offset <= (int) $row['pass']['last_offset'] ) { return $row; } if ( $eligible > 0 && 0 === $row['first_seen'] ) { $row['first_seen'] = $now; } $row['eligible'] = max( $row['eligible'], $eligible ); $row['last_run'] = $now; $row['pass']['eligible'] += $eligible; $row['pass']['evidence'] += $evidence; $row['pass']['cov_denom'] += $cov_denom; $row['pass']['cov_num'] += $cov_num; $row['pass']['updated_at'] = $now; if ( null !== $offset ) { $row['pass']['last_offset'] = $offset; } return $row; } ); if ( ! orderguard_courier_health_mutation_succeeded( $result ) ) { orderguard_courier_health_log_once( 'note_run_' . $courier, 'OrderGuard: courier health counters could not be saved for ' . $courier . '.' ); return false; } return true; } /** * Record the short final page and snapshot the completed pass atomically. * * The cursor may rewind only after this mutation commits. That keeps an * exhausted CAS retry from discarding the final page or letting the detector * judge a stale last_pass while a newer pass is already starting. */ function orderguard_courier_health_complete_pass( $courier, $eligible, $evidence = 0, $cov_denom = 0, $cov_num = 0, $offset = null ) { $now = time(); $eligible = max( 0, (int) $eligible ); $evidence = max( 0, (int) $evidence ); $cov_denom = max( 0, (int) $cov_denom ); $cov_num = max( 0, (int) $cov_num ); $offset = null === $offset ? null : max( 0, (int) $offset ); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now, $eligible, $evidence, $cov_denom, $cov_num, $offset ) { // The final-page snapshot may have committed even though the separate // cursor reset did not. Preserve that snapshot and let the caller retry // only the idempotent cursor clear. if ( null !== $offset && 0 === (int) $row['pass']['started_at'] && $offset <= (int) $row['last_pass']['last_offset'] ) { return $row; } $already_counted = null !== $offset && $offset <= (int) $row['pass']['last_offset']; if ( $eligible > 0 && 0 === $row['first_seen'] ) { $row['first_seen'] = $now; } if ( ! $already_counted ) { $row['eligible'] = max( $row['eligible'], $eligible ); $row['last_run'] = $now; $row['pass']['eligible'] += $eligible; $row['pass']['evidence'] += $evidence; $row['pass']['cov_denom'] += $cov_denom; $row['pass']['cov_num'] += $cov_num; $row['pass']['updated_at'] = $now; if ( null !== $offset ) { $row['pass']['last_offset'] = $offset; } } $row['last_pass'] = array( 'completed_at' => $now, 'eligible' => (int) $row['pass']['eligible'], 'evidence' => (int) $row['pass']['evidence'], 'cov_denom' => (int) $row['pass']['cov_denom'], 'cov_num' => (int) $row['pass']['cov_num'], 'last_offset' => (int) $row['pass']['last_offset'], ); $row['pass'] = array( 'started_at' => 0, 'updated_at' => 0, 'eligible' => 0, 'evidence' => 0, 'cov_denom' => 0, 'cov_num' => 0, 'last_offset' => -1, ); return $row; } ); if ( ! orderguard_courier_health_mutation_succeeded( $result ) ) { orderguard_courier_health_log_once( 'complete_pass_' . $courier, 'OrderGuard: courier health final page and pass snapshot could not be saved for ' . $courier . '; cursor was left in place.' ); return false; } return true; } function orderguard_courier_health_note_recorded( $courier ) { $now = time(); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now ) { $row['recorded']++; $row['last_recorded_at'] = $now; if ( 0 === $row['first_seen'] ) { $row['first_seen'] = $now; } return $row; } ); if ( ! orderguard_courier_health_mutation_succeeded( $result ) ) { orderguard_courier_health_log_once( 'note_recorded_' . $courier, 'OrderGuard: courier outcome counter could not be saved for ' . $courier . '.' ); return false; } return true; } /** * Persist an account-wide courier authentication failure for the existing * Problem badge. This cannot rely on the pass-based detectors: a chain that * stops after confirmation early in a page never completes a pass or * accumulates enough evidence. */ function orderguard_courier_health_note_auth_failed( $courier ) { $now = time(); $result = orderguard_courier_health_mutate( $courier, static function ( $row ) use ( $now ) { $row['auth_failed_at'] = $now; return $row; } ); return orderguard_courier_health_mutation_succeeded( $result ); } /** * Clear a prior authentication failure after the adapter returns a valid * array again. Unknown shipment outcomes still prove the account can talk to * the courier, so classification is deliberately not part of this test. */ function orderguard_courier_health_clear_auth_failed( $courier ) { $result = orderguard_courier_health_mutate( $courier, static function ( $row ) { $row['auth_failed_at'] = 0; return $row; } ); return orderguard_courier_health_mutation_succeeded( $result ); } /** * Whether a recently confirmed authentication failure should be shown. * Reuse the health freshness window: continued failures refresh the marker, * while a fixed store with no remaining pollable orders cannot stay warned * forever merely because no success response exists to clear it. */ function orderguard_courier_auth_failure_active( $courier ) { $health_ok = false; $health = orderguard_courier_health( $courier, $health_ok ); $failed_at = $health_ok ? (int) $health['auth_failed_at'] : 0; return $failed_at > 0 && ( time() - $failed_at ) <= ORDERGUARD_COURIER_HEALTH_FRESH_DAYS * DAY_IN_SECONDS; } /** * The detector's evidence count: the last COMPLETED pass, or the running * pass when it has already seen more -- whichever is larger -- and only when * fresh. Stale counters (poller stopped: dead cron, sync disabled mid-pass, * courier plugin removed) describe a world that no longer exists and never * fire a fresh alarm. A poller that is not running at all is a different * failure, and one this in-process detector genuinely cannot see -- stated * honestly here rather than papered over. */ function orderguard_courier_health_evidence( $h ) { $fresh = time() - ORDERGUARD_COURIER_HEALTH_FRESH_DAYS * DAY_IN_SECONDS; $evidence = 0; if ( $h['last_pass']['completed_at'] >= $fresh ) { $evidence = (int) $h['last_pass']['evidence']; } if ( $h['pass']['updated_at'] >= $fresh && (int) $h['pass']['evidence'] > $evidence ) { $evidence = (int) $h['pass']['evidence']; } return $evidence; } /** * TRUE when a courier looks broken: it has had orders to read for long * enough, enough of them are old enough that a terminal outcome should * exist, none of those ever produced one, and nothing at all has been read * recently. Catches BOTH "never worked" and "stopped working" (H3 + M1) -- * see the section header for the full rule and its reasoning. * * Conservative on purpose -- every clause below exists to keep a specific * healthy store quiet, because a detector that cries wolf gets ignored and * then the next real Speedy failure is invisible again: * - MIN_DAYS floor -> a brand-new courier/install gets a quiet week; * - fast-clear -> one outcome read in the last RESOLVE_DAYS means * the pipe works; the card turns green the moment * a real read lands, stale counters or not; * - undated-legacy -> pre-upgrade rows with recorded outcomes but no * last_recorded_at stay quiet until post-upgrade * data exists (the batch seed dates them); * - evidence >= floor -> in-transit parcels and the permanent-unknown * residue never count (see the evidence rule); * low-volume stores never reach the floor on * noise; a paused courier produces no * post-watershed orders at all; * - freshness -> a stopped poller's stale counters never fire. */ function orderguard_courier_looks_broken( $courier ) { $h = orderguard_courier_health( $courier ); $now = time(); if ( $h['first_seen'] <= 0 || ( $now - $h['first_seen'] ) < ORDERGUARD_COURIER_HEALTH_MIN_DAYS * DAY_IN_SECONDS ) { return false; // too soon -- parcels really can take a week } if ( $h['last_recorded_at'] > 0 && ( $now - $h['last_recorded_at'] ) < ORDERGUARD_COURIER_HEALTH_RESOLVE_DAYS * DAY_IN_SECONDS ) { return false; // it read an outcome recently -- alive (fast-clear) } if ( $h['recorded'] > 0 && $h['last_recorded_at'] <= 0 ) { return false; // legacy row: outcomes exist but cannot be dated -- quiet until the seed + fresh passes provide judgeable data } return orderguard_courier_health_evidence( $h ) >= ORDERGUARD_COURIER_HEALTH_MIN_ORDERS; } /** * The coverage meter's numbers for one courier (0.1.9). * * Read from the last COMPLETED pass only -- never the running one. A partial * pass has only walked a prefix of the window (newest orders first), so its * ratio systematically understates coverage: the newest band orders have had * the least time to resolve. The detector reads the running pass because MORE * evidence mid-pass is already meaningful; a RATIO mid-pass is not. * * Freshness uses the same rule as the detector (ORDERGUARD_COURIER_HEALTH_FRESH_DAYS): * a pass snapshot older than that describes a poller that stopped running, * and a stale percentage presented as current would be a lie in either * direction. Callers hide the meter when 'fresh' is false. * * 'pct' floors (never rounds up): 199 of 200 reads 99%, not 100% -- the meter * must not claim completeness while orders are missing. * * @return array{denom:int, num:int, pct:int, fresh:bool, completed_at:int} */ function orderguard_courier_coverage( $courier ) { $h = orderguard_courier_health( $courier ); $lp = $h['last_pass']; $denom = max( 0, (int) $lp['cov_denom'] ); $num = max( 0, min( (int) $lp['cov_num'], $denom ) ); // clamp: the numerator is structurally a subset, keep it one even against corrupt rows return array( 'denom' => $denom, 'num' => $num, 'pct' => ( $denom > 0 ) ? (int) floor( ( $num * 100 ) / $denom ) : 0, 'fresh' => ( $lp['completed_at'] > 0 && ( time() - $lp['completed_at'] ) <= ORDERGUARD_COURIER_HEALTH_FRESH_DAYS * DAY_IN_SECONDS ), 'completed_at' => (int) $lp['completed_at'], ); } /** * TRUE when coverage is bad enough to call a courier-integration problem * (0.1.9) -- the notice's SECOND arm. orderguard_courier_looks_broken() cannot see * one failure shape: an adapter that still reads a TRICKLE of outcomes (say, * only the returns after an update broke the delivered branch). Every trickle * read resets the detector's fast-clear, so the card stays green while 9 of * 10 outcomes are silently lost -- the exact disease this file exists to * catch, in its partial form. Coverage sees it: the ratio sits at the trickle * rate no matter how recent the last read was. Deliberately NO fast-clear * here, for exactly that reason. * * Every no-fire clause protects a specific healthy store: * - established (COVERAGE_MIN_DAYS, 24d) -> a fresh install's window is full * of pre-OrderGuard orders; real couriers serve historical outcomes (the * H-4 cursor read 267 old ones on a live store in a day), so honest * coverage needs time to fill in before a low number means anything. The * 24 is derived, not round: by day AGE_DAYS + 20% of the band (23.2), * post-install resolutions alone clear the threshold even if NO * pre-install outcome is ever readable (see the constant's docblock); * - fresh pass (HEALTH_FRESH_DAYS) -> a stopped poller's stale snapshot * never fires a fresh alarm (same rule, same reason as the detector); * - denominator floor (COVERAGE_MIN_ORDERS, 20) -> "1 of 3" is noise; at 20 * the alarm needs 17+ unread, finished-age orders -- substance; * - strictly below COVERAGE_BROKEN_PCT (20%) -> the threshold separates * "reads almost nothing" from mixed plugin/portal label workflows and * ordinary resolution delay. A store must lose more than 4 of every 5 * readable outcomes before this speaks. * * Residual, disclosed rather than hidden: a merchant who ships >80% of * claimed orders outside the courier plugin (portal labels -- unresolvable by * construction), or a courier that routinely takes ~50+ days to a terminal * event (spec-pinned boundary at defaults), can trip this on a working * integration. Both are support * conversations the notice's soft wording invites ("we'll find out what's * causing it"); neither is a red herring the merchant can't act on. Knobs: * the four defines above, overridable in wp-config.php. */ function orderguard_courier_coverage_alarm( $courier ) { $h = orderguard_courier_health( $courier ); $now = time(); if ( $h['first_seen'] <= 0 || ( $now - $h['first_seen'] ) < ORDERGUARD_COURIER_COVERAGE_MIN_DAYS * DAY_IN_SECONDS ) { return false; // too new -- historical outcomes are still landing } $lp = $h['last_pass']; if ( $lp['completed_at'] <= 0 || ( $now - $lp['completed_at'] ) > ORDERGUARD_COURIER_HEALTH_FRESH_DAYS * DAY_IN_SECONDS ) { return false; // no fresh completed pass -- the poller stopped, or 0.1.9 hasn't measured yet; never judge a dead world } $denom = (int) $lp['cov_denom']; $num = max( 0, min( (int) $lp['cov_num'], $denom ) ); if ( $denom < ORDERGUARD_COURIER_COVERAGE_MIN_ORDERS ) { return false; // too few measured orders for a rate to mean anything } // Integer cross-multiplication (no float edges): fires strictly below the // threshold -- exactly 20% stays quiet. return ( $num * 100 ) < ( ORDERGUARD_COURIER_COVERAGE_BROKEN_PCT * $denom ); } /** * The courier-problem notice's single question (0.1.9): does this courier's * integration need the merchant to contact support? Three independent arms, * each covering a structural blind spot in the others: * * A. orderguard_courier_looks_broken() -- total silence: never worked, worked-then- * died, broken get_awb. Evidence-based, fast-clearing, fires in ~12-13 * days at pilot volume. * B. orderguard_courier_coverage_alarm() -- partial silence: a trickle of reads * keeps arm A's fast-clear resetting forever while most outcomes are * lost. Ratio-based, immune to the trickle by design. * C. auth_failed_at -- a confirmed account-wide credential failure stops * early in the page, so no pass completes and neither evidence arm can * fire. The first successful array response clears this flag; the marker * also expires with the shared health freshness window on an idle store. * * Arm A alone misses partial failure; arm B alone misses a broken get_awb * only slowly (coverage decays over weeks; evidence accumulates in days) and * needs three quiet weeks on a new install where arm A needs one. Both miss * authentication because an early confirmed stop completes no pass. The * three arms preserve their separate failure directions. */ function orderguard_courier_problem_detected( $courier ) { return orderguard_courier_auth_failure_active( $courier ) || orderguard_courier_looks_broken( $courier ) || orderguard_courier_coverage_alarm( $courier ); } /** * =========================================================================== * PER-COURIER COUNTER BACKFILL (0.1.8) -- one-time, batched, run-once. * =========================================================================== * WHY: orderguard_courier_health_note_recorded() only bumps when a poll RESOLVES an * outcome, so the per-courier counters date from whenever per-courier * tracking reached this store -- while the card's lifetime total dates from * 0.1.4. A merchant reads "BOX NOW: 0" next to "Total since start: 43" as a * dead courier (it alarmed a live pilot twice in one day) when every one of * those outcomes is sitting right there on the orders, written at record * time by orderguard_report_courier_outcome() into _orderguard_courier_outcome / * _orderguard_courier_provenance meta. This seeds the counters from that meta. * * HOW, and why this exact shape: * * - SNAPSHOT, THEN WALK. Batch 0 queries the IDs of orders carrying * _orderguard_courier_outcome meta ONCE (store default order = newest first, * capped) into an option; every batch after that walks the frozen list * by index, ORDERGUARD_COURIER_BACKFILL_BATCH orders per Action Scheduler action * (group 'orderguard', like the import and the poller -- never on a page * load). A frozen ID list cannot drift, cannot re-serve a page, and does * not depend on either order store honouring an orderby -- live offset * paging can do all three, and a re-served page would inflate the * counters silently. * * - BOUND. The meta filter keeps the snapshot proportional to orders that * actually HAVE recorded courier outcomes (dozens-to-hundreds on the * pilots -- not bambuki's 26,631). If that filter is ever ignored (the * legacy CPT store can ignore meta_query; see the review-queue's own * caveat), the per-order PHP check below keeps the COUNTS honest and * ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS caps the WORK; newest-first means a * capped scan drops only the oldest, provenance-free end of history. * * - WHAT IS COUNTED: the _orderguard_courier_outcome entries (per order, per * source, terminal outcomes only) -- exactly the set the reconciliation * sweep re-emits, so the counter keeps meaning what the card claims: * "outcomes this store has on record". Timestamps come from * _orderguard_courier_provenance, read with the same legacy-flat-shape rule * orderguard_report_courier_outcome() migrates by; an entry with no readable ts * counts toward 'recorded' but never invents a 'last_recorded_at'. * * - APPLY BY max(), NEVER +=. recorded = max(live, backfill total) and * last_recorded_at = max(live, newest backfilled ts). Recordings the * live counter already saw -- including any that land WHILE this walk * runs -- are inside the backfill's own total too, so max() absorbs the * overlap: running the whole thing twice cannot double-count, and a * counter that is already correct/newer is never lowered. Detector * safety: last_recorded_at only ever moves FORWARD to a real past * recording (future-dated ts are discarded at collect), which can only * make orderguard_courier_looks_broken()'s fast-clear MORE truthful; first_seen * is deliberately untouched, so a row created here (courier with history * but no live health row) keeps first_seen=0 and stays out of the * detector's jurisdiction until the poller itself meets that courier. * * - RUN-ONCE + CRASH-SAFE. The orderguard_courier_backfill option is the guard: * status 'done' short-circuits forever. Each batch action carries its * position and no-ops unless it matches the stored position, so a * replayed or double-queued action cannot re-count its slice. State * (position + running tally) advances in ONE update_option BEFORE the * next batch is queued: a chain killed anywhere resumes from the stored * position when the hourly dispatcher notices the stale heartbeat. * * - NOT TOUCHED, on purpose: orderguard_courier_synced_total (it already counted * these recordings when they happened -- bumping it here would double * it), the daily history strip (a rolling record of LIVE activity), and * first_seen (above). Nothing is sent anywhere: this reads order meta * and writes two local options, full stop. It is still only SCHEDULED * from the consented-on courier dispatcher, because the counters it * feeds only render there. */ if ( ! defined( 'ORDERGUARD_COURIER_BACKFILL_BATCH' ) ) { define( 'ORDERGUARD_COURIER_BACKFILL_BATCH', 50 ); } if ( ! defined( 'ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS' ) ) { define( 'ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS', 20000 ); } if ( ! defined( 'ORDERGUARD_COURIER_BACKFILL_STALL' ) ) { define( 'ORDERGUARD_COURIER_BACKFILL_STALL', 6 * HOUR_IN_SECONDS ); } /** * Read one option row directly for backfill ownership and confirmation. */ function orderguard_courier_backfill_direct_row( $name, &$ok ) { 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 $row = $wpdb->get_row( $wpdb->prepare( "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $name ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- ownership checks must bypass Options API caches. $ok = ( '' === $wpdb->last_error ); return $ok ? $row : null; } /** * Read the authoritative snapshot generation directly and distinguish an * absent row from a present row whose value unserializes to null/malformed. */ function orderguard_courier_backfill_snapshot_direct( &$found, &$ok ) { $found = false; $ok = false; $stamp_found = false; $stamp_ok = false; $stamp = orderguard_migration_control_read( 'orderguard_data_migration_version', $stamp_found, $stamp_ok ); if ( ! $stamp_ok || ( $stamp_found && defined( 'ORDERGUARD_DATA_MIGRATION_VERSION' ) && ORDERGUARD_DATA_MIGRATION_VERSION !== (string) $stamp ) ) { return null; } $names = array( 'orderguard_courier_backfill_ids' ); if ( ! $stamp_found ) { $legacy_name = orderguard_legacy_option_name( 'orderguard_courier_backfill_ids' ); if ( '' !== $legacy_name ) { $names[] = $legacy_name; } } foreach ( $names as $name ) { $row = orderguard_courier_backfill_direct_row( $name, $ok ); if ( ! $ok ) { return null; } if ( is_array( $row ) ) { $found = true; return maybe_unserialize( $row['option_value'] ); } } $ok = true; return null; } /** * Exact state transition. A zero-row result means another action won. */ function orderguard_courier_backfill_state_cas( $expected_raw, $next_state ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these $updated = $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. maybe_serialize( $next_state ), 'orderguard_courier_backfill', $expected_raw ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact state ownership transition. if ( 1 === (int) $updated ) { orderguard_invalidate_option_caches( 'orderguard_courier_backfill', false ); // The migration preserves the legacy row's autoload value. This CAS // normalizes it to off, so an old alloptions entry must be evicted too. wp_cache_delete( 'alloptions', 'options' ); return true; } return false; } /** * Remove both snapshot generations and verify physical absence. */ function orderguard_courier_backfill_delete_snapshot_confirmed() { // delete_option() cache/existence behaviour is WordPress-version-dependent // (the tested WP 7.0.2 path resolves existence with a direct DB query). The // direct reads below remain authoritative and verify BOTH generations even // if an older core release or persistent cache behaves differently. orderguard_delete_option( 'orderguard_courier_backfill_ids' ); foreach ( array( 'orderguard_courier_backfill_ids', orderguard_legacy_option_name( 'orderguard_courier_backfill_ids' ) ) as $name ) { if ( '' === $name ) { continue; } $ok = false; $row = orderguard_courier_backfill_direct_row( $name, $ok ); if ( ! $ok || null !== $row ) { return false; } } return true; } function orderguard_courier_backfill_notice_malformed() { static $reported = false; if ( $reported ) { return; } $reported = true; orderguard_log( 'OrderGuard: courier counter backfill is blocked by malformed state; no restart was attempted.' ); if ( function_exists( 'orderguard_set_notice' ) ) { orderguard_set_notice( 'error', __( 'OrderGuard could not read the saved courier backfill state. Nothing was restarted; please contact support.', 'orderguard' ) ); } } /** * Finish a claimed preparing state: clear debris, transition, then enqueue. */ function orderguard_courier_backfill_finish_preparing( $state ) { if ( ! is_array( $state ) || 'preparing' !== ( $state['status'] ?? '' ) || empty( $state['run_id'] ) ) { return false; } // Claim cleanup BEFORE deleting anything. Two stale-rescue requests can // reach this function together; without this exact-state claim, the loser // can delete the winner's newly-created live snapshot after batch 0 starts. $ok = false; $row = orderguard_courier_backfill_direct_row( 'orderguard_courier_backfill', $ok ); if ( ! $ok || ! is_array( $row ) ) { return false; } $fresh = maybe_unserialize( $row['option_value'] ); if ( ! is_array( $fresh ) || 'preparing' !== ( $fresh['status'] ?? '' ) || (string) $state['run_id'] !== (string) ( $fresh['run_id'] ?? '' ) ) { return false; } if ( ! empty( $fresh['prepare_owner'] ) && ( time() - (int) ( $fresh['updated_at'] ?? 0 ) ) < ORDERGUARD_COURIER_BACKFILL_STALL ) { return false; } $owner = wp_generate_uuid4(); $claimed = $fresh; $claimed['prepare_owner'] = $owner; $claimed['updated_at'] = time(); if ( '' === $owner || ! orderguard_courier_backfill_state_cas( $row['option_value'], $claimed ) ) { return false; } if ( ! orderguard_courier_backfill_delete_snapshot_confirmed() ) { orderguard_log( 'OrderGuard: courier counter backfill -- snapshot cleanup could not be confirmed; preparation will retry.' ); return false; } $ok = false; $row = orderguard_courier_backfill_direct_row( 'orderguard_courier_backfill', $ok ); if ( ! $ok || ! is_array( $row ) ) { return false; } $fresh = maybe_unserialize( $row['option_value'] ); if ( ! is_array( $fresh ) || 'preparing' !== ( $fresh['status'] ?? '' ) || (string) $state['run_id'] !== (string) ( $fresh['run_id'] ?? '' ) || $owner !== (string) ( $fresh['prepare_owner'] ?? '' ) ) { return false; } unset( $fresh['prepare_owner'] ); $fresh['status'] = 'running'; $fresh['updated_at'] = time(); if ( ! orderguard_courier_backfill_state_cas( $row['option_value'], $fresh ) ) { return false; } as_enqueue_async_action( 'orderguard_courier_backfill_batch', array( 'pos' => 0, 'run_id' => (string) $fresh['run_id'] ), 'orderguard', true ); orderguard_log( 'OrderGuard: courier counter backfill -- scheduled (one-time)' ); return true; } /** * Apply a tally, atomically mark the owned run done, then remove its snapshot. */ function orderguard_courier_backfill_finalize( $state_raw, $state, $tally, $note = '' ) { if ( ! orderguard_courier_backfill_apply( $tally ) ) { orderguard_log( 'OrderGuard: courier counter backfill -- tally apply failed; run remains retryable.' ); return false; } $done = $state; $done['status'] = 'done'; $done['updated_at'] = time(); if ( '' !== $note ) { $done['note'] = $note; } if ( ! orderguard_courier_backfill_state_cas( $state_raw, $done ) ) { return false; } $ok = false; $row = orderguard_courier_backfill_direct_row( 'orderguard_courier_backfill', $ok ); if ( ! $ok || ! is_array( $row ) || maybe_serialize( $done ) !== $row['option_value'] ) { return false; } if ( ! orderguard_courier_backfill_delete_snapshot_confirmed() ) { orderguard_log( 'OrderGuard: courier counter backfill -- completed; snapshot cleanup will retry from the done guard.' ); } return true; } /** * Called by the hourly courier-sync dispatcher. One option read once done; * starts the run exactly once; rescues a chain whose heartbeat went stale. */ function orderguard_courier_backfill_maybe_schedule() { if ( ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return; } $state_ok = false; $state = orderguard_get_option( 'orderguard_courier_backfill', null, $state_ok ); if ( ! $state_ok ) { return; } if ( is_array( $state ) && 'done' === ( $state['status'] ?? '' ) ) { foreach ( array( 'orderguard_courier_backfill_ids', orderguard_legacy_option_name( 'orderguard_courier_backfill_ids' ) ) as $snapshot_name ) { if ( '' === $snapshot_name ) { continue; } $snapshot_ok = false; $snapshot_row = orderguard_courier_backfill_direct_row( $snapshot_name, $snapshot_ok ); if ( $snapshot_ok && null !== $snapshot_row ) { orderguard_courier_backfill_delete_snapshot_confirmed(); break; } } return; // ran already -- this cheap read is the forever-guard } if ( null !== $state && ( ! is_array( $state ) || ! in_array( $state['status'] ?? '', array( 'preparing', 'running', 'done' ), true ) ) ) { orderguard_courier_backfill_notice_malformed(); return; } if ( orderguard_has_scheduled_action( 'orderguard_courier_backfill_batch', null, 'orderguard' ) ) { return; // a batch is queued or running -- the chain is alive } if ( is_array( $state ) && in_array( $state['status'] ?? '', array( 'preparing', 'running' ), true ) ) { // No queued action but status 'running': the chain died un-resumed // (fatal mid-batch, deactivation, AS purge). After a grace window -- // AS visibility can lag a moment -- resume from the STORED position, // never from 0 (the position guard in the batch makes a stray old // action harmless either way). if ( ( time() - (int) ( $state['updated_at'] ?? 0 ) ) < ORDERGUARD_COURIER_BACKFILL_STALL ) { return; } if ( 'preparing' === $state['status'] ) { orderguard_courier_backfill_finish_preparing( $state ); return; } $resume = (int) ( $state['pos'] ?? 0 ); $args = array( 'pos' => $resume ); if ( ! empty( $state['run_id'] ) ) { $args['run_id'] = (string) $state['run_id']; } as_enqueue_async_action( 'orderguard_courier_backfill_batch', $args, 'orderguard', true ); orderguard_log( 'OrderGuard: courier counter backfill -- resuming a stalled run at position ' . $resume ); return; } // Fresh start: positively confirm absence, then let exactly one request // claim ownership with INSERT IGNORE. A preparing owner is recoverable if // the request dies between cleanup and enqueue. $blocking = null; $blocking_name = ''; $confirm_ok = false; if ( ! orderguard_option_absent_for_fresh_start( 'orderguard_courier_backfill', $blocking, $blocking_name, $confirm_ok ) ) { if ( $confirm_ok && '' !== $blocking_name && ( ! is_array( $blocking ) || ! in_array( $blocking['status'] ?? '', array( 'preparing', 'running', 'done' ), true ) ) ) { orderguard_courier_backfill_notice_malformed(); } return; } global $wpdb; $now = time(); $state = array( 'status' => 'preparing', 'run_id' => wp_generate_uuid4(), 'pos' => 0, 'scanned' => 0, 'tally' => array(), 'started_at' => $now, 'updated_at' => $now, ); // 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( "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. 'orderguard_courier_backfill', maybe_serialize( $state ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic fresh-start ownership. if ( 1 !== (int) $claimed ) { return; } orderguard_invalidate_option_caches( 'orderguard_courier_backfill', false ); orderguard_courier_backfill_finish_preparing( $state ); } add_action( 'orderguard_courier_backfill_batch', 'orderguard_run_courier_backfill_batch', 10, 2 ); /** * One slice of the walk. $pos is an index into the frozen ID snapshot. */ function orderguard_run_courier_backfill_batch( $pos, $run_id = '' ) { if ( ! orderguard_is_activated() || ! function_exists( 'wc_get_orders' ) ) { return; } $state = orderguard_get_option( 'orderguard_courier_backfill', null ); if ( ! is_array( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { return; // finished (or dismantled) while this action sat in the queue } $direct_ok = false; $state_row = orderguard_courier_backfill_direct_row( 'orderguard_courier_backfill', $direct_ok ); if ( ! $direct_ok || ! is_array( $state_row ) ) { return; } $state = maybe_unserialize( $state_row['option_value'] ); if ( ! is_array( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { return; } $state_run_id = isset( $state['run_id'] ) ? (string) $state['run_id'] : ''; if ( ( '' !== $state_run_id && $state_run_id !== (string) $run_id ) || ( '' === $state_run_id && '' !== (string) $run_id ) ) { return; } if ( (int) $pos !== (int) ( $state['pos'] ?? -1 ) ) { return; // replayed/duplicate action for a slice already counted -- no-op } $pos = (int) $pos; // Batch 0 takes the snapshot; later batches walk it. $snapshot_ok = false; orderguard_get_option( 'orderguard_courier_backfill_ids', null, $snapshot_ok ); if ( ! $snapshot_ok ) { return; } $snapshot_found = false; $snapshot = orderguard_courier_backfill_snapshot_direct( $snapshot_found, $snapshot_ok ); if ( ! $snapshot_ok ) { return; } $ids = null; if ( $snapshot_found && is_array( $snapshot ) && isset( $snapshot['ids'], $snapshot['run_id'] ) ) { if ( (string) $snapshot['run_id'] !== $state_run_id || ! is_array( $snapshot['ids'] ) ) { orderguard_log( 'OrderGuard: courier counter backfill -- snapshot ownership mismatch; run left for recovery.' ); return; } $ids = array_values( array_map( 'intval', $snapshot['ids'] ) ); } elseif ( $snapshot_found && is_array( $snapshot ) && '' === $state_run_id ) { // Compatibility with an in-flight pre-hardening run. $ids = array_values( array_map( 'intval', $snapshot ) ); } elseif ( $snapshot_found ) { orderguard_log( 'OrderGuard: courier counter backfill -- ID snapshot is present but malformed; run left for recovery.' ); return; } if ( ! is_array( $ids ) ) { if ( $pos > 0 ) { // The list vanished mid-run (option surgery, aggressive cleaner // plugin). Do not rebuild and guess at positions in a DIFFERENT // list: apply what was already counted -- max() keeps a partial // seed strictly-better-than-nothing and never harmful -- and // stop, loudly. $tally = is_array( $state['tally'] ?? null ) ? $state['tally'] : array(); if ( orderguard_courier_backfill_finalize( $state_row['option_value'], $state, $tally, 'id snapshot lost mid-run; applied partial tally' ) ) { orderguard_log( 'OrderGuard: courier counter backfill -- ID snapshot missing at position ' . $pos . '; applied partial tally and stopped' ); } return; } $ids = array(); $leg_capped = false; foreach ( orderguard_order_meta_query_keys( '_orderguard_courier_outcome' ) as $meta_key ) { $leg = wc_get_orders( array( 'limit' => ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS, 'return' => 'ids', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => $meta_key, 'compare' => 'EXISTS', ), ), ) ); $leg = is_array( $leg ) ? array_map( 'intval', $leg ) : array(); $leg_capped = $leg_capped || count( $leg ) >= ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS; foreach ( $leg as $order_id ) { $ids[ $order_id ] = true; } } $union_capped = count( $ids ) > ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS; $ids = array_keys( $ids ); if ( $union_capped ) { $ids = array_slice( $ids, 0, ORDERGUARD_COURIER_BACKFILL_MAX_ORDERS ); } if ( $leg_capped || $union_capped ) { $state['capped'] = 1; // recorded for support: the oldest end of history was left out } $snapshot = ( '' !== $state_run_id ) ? array( 'run_id' => $state_run_id, 'ids' => $ids ) : $ids; 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 $created = $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. 'orderguard_courier_backfill_ids', maybe_serialize( $snapshot ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exactly one batch-zero snapshot owns the run. if ( false === $created || ! in_array( (int) $created, array( 0, 1 ), true ) ) { return; } orderguard_invalidate_option_caches( 'orderguard_courier_backfill_ids', false ); $snapshot_row = orderguard_courier_backfill_direct_row( 'orderguard_courier_backfill_ids', $snapshot_ok ); if ( ! $snapshot_ok || ! is_array( $snapshot_row ) ) { return; } $persisted = maybe_unserialize( $snapshot_row['option_value'] ); if ( 1 === (int) $created && maybe_serialize( $snapshot ) !== $snapshot_row['option_value'] ) { return; } if ( '' !== $state_run_id ) { if ( ! is_array( $persisted ) || (string) ( $persisted['run_id'] ?? '' ) !== $state_run_id || ! isset( $persisted['ids'] ) || ! is_array( $persisted['ids'] ) ) { return; } $ids = array_values( array_map( 'intval', $persisted['ids'] ) ); } elseif ( is_array( $persisted ) ) { $ids = array_values( array_map( 'intval', $persisted ) ); } else { return; } if ( maybe_serialize( $persisted ) !== $snapshot_row['option_value'] ) { return; } orderguard_log( 'OrderGuard: courier counter backfill -- snapshot of ' . count( $ids ) . ' candidate order(s) taken' ); } $slice = array_slice( $ids, $pos, ORDERGUARD_COURIER_BACKFILL_BATCH ); $tally = is_array( $state['tally'] ?? null ) ? $state['tally'] : array(); $now = time(); foreach ( $slice as $order_id ) { $order = wc_get_order( (int) $order_id ); if ( ! $order instanceof WC_Order ) { continue; // deleted since the snapshot, or not a real order } // THE TRUTH IS THIS CHECK, not the SQL filter above: on a store where // meta_query is ignored the snapshot is just "orders", and this line // is what keeps the tally honest. $outcomes = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' ); if ( ! is_array( $outcomes ) || empty( $outcomes ) ) { continue; } // Provenance for the timestamps -- same legacy-flat-shape rule as // orderguard_report_courier_outcome() (an early build stored ONE courier's // provenance flat; it is migrated in place there and read // compatibly here). $prov = orderguard_get_order_meta_map( $order, '_orderguard_courier_provenance' ); if ( ! is_array( $prov ) ) { $prov = array(); } elseif ( isset( $prov['courier'] ) && is_string( $prov['courier'] ) ) { $prov = array( $prov['courier'] => $prov ); } foreach ( $outcomes as $source => $outcome ) { if ( ! in_array( $outcome, array( 'kept', 'returned', 'not_collected' ), true ) ) { continue; // only terminal outcomes were ever counted by note_recorded() } $source = sanitize_key( (string) $source ); if ( '' === $source ) { continue; } if ( ! isset( $tally[ $source ] ) || ! is_array( $tally[ $source ] ) ) { $tally[ $source ] = array( 'n' => 0, 'last_ts' => 0 ); } $tally[ $source ]['n']++; $ts = isset( $prov[ $source ]['ts'] ) ? (int) $prov[ $source ]['ts'] : 0; if ( $ts > $now + HOUR_IN_SECONDS ) { // A future-dated ts would pin last_recorded_at ahead of the // clock and hold the health detector's fast-clear open on a // possibly-dead courier. A real recording is never in the // future; discard, count the outcome only. $ts = 0; } if ( $ts > (int) $tally[ $source ]['last_ts'] ) { $tally[ $source ]['last_ts'] = $ts; } } } $next = $pos + count( $slice ); $state['pos'] = $next; $state['scanned'] = (int) ( $state['scanned'] ?? 0 ) + count( $slice ); $state['tally'] = $tally; $state['updated_at'] = time(); if ( $next >= count( $ids ) ) { if ( ! orderguard_courier_backfill_finalize( $state_row['option_value'], $state, $tally ) ) { return; } $summary = array(); foreach ( $tally as $source => $t ) { $summary[] = $source . '=' . (int) ( $t['n'] ?? 0 ); } orderguard_log( 'OrderGuard: courier counter backfill DONE -- ' . count( $ids ) . ' order(s) scanned; recorded outcomes found: ' . ( $summary ? implode( ', ', $summary ) : 'none' ) ); return; } // Advance the state FIRST, then queue the next slice: a crash between the // two leaves a consistent position for the dispatcher's stall-rescue to // resume from (and never re-counts this slice, thanks to the position // guard at the top). if ( ! orderguard_courier_backfill_state_cas( $state_row['option_value'], $state ) ) { return; } $next_args = array( 'pos' => $next ); if ( '' !== $state_run_id ) { $next_args['run_id'] = $state_run_id; } as_enqueue_async_action( 'orderguard_courier_backfill_batch', $next_args, 'orderguard', true ); } /** * Fold the finished tally into orderguard_courier_health. max(), never += -- see the * section header for why that is the whole idempotency story. */ function orderguard_courier_backfill_apply( $tally ) { if ( ! is_array( $tally ) ) { return false; } foreach ( $tally as $source => $t ) { $source = sanitize_key( (string) $source ); $n = isset( $t['n'] ) ? (int) $t['n'] : 0; $ts = isset( $t['last_ts'] ) ? (int) $t['last_ts'] : 0; if ( '' === $source || $n <= 0 ) { continue; } $result = orderguard_courier_health_mutate( $source, static function ( $row ) use ( $n, $ts ) { $row['recorded'] = max( (int) $row['recorded'], $n ); $row['last_recorded_at'] = max( (int) $row['last_recorded_at'], $ts ); return $row; } ); if ( ! orderguard_courier_health_mutation_succeeded( $result ) ) { orderguard_courier_health_log_once( 'backfill_' . $source, 'OrderGuard: courier counter backfill could not update health for ' . $source . '; the backfill remains retryable.' ); return false; } $old_recorded = (int) $result['before']['recorded']; $old_last = (int) $result['before']['last_recorded_at']; $recorded = (int) $result['row']['recorded']; $last_at = (int) $result['row']['last_recorded_at']; if ( $recorded === $old_recorded && $last_at === $old_last ) { continue; } orderguard_log( sprintf( 'OrderGuard: courier counter backfill -- %s: recorded %d -> %d, last_recorded_at %d -> %d', $source, $old_recorded, $recorded, $old_last, $last_at ) ); } return true; } /** * Per-courier outcome counts for the card. ['econt' => 25, 'boxnow' => 13] * * WHY THE CARD NEEDS THIS AND NOT JUST A TOTAL: a single blended number lets a * working courier hide a dead one. On a live store the card read "70 synced" * while Speedy had recorded 0 of 847 -- all 70 were BOX NOW. Two numbers side * by side would have shown it at a glance, months earlier, without anyone * having to ask. */ function orderguard_courier_synced_by_courier() { $out = array(); foreach ( orderguard_courier_health_all() as $courier => $row ) { $n = (int) ( $row['recorded'] ?? 0 ); if ( $n > 0 ) { $out[ $courier ] = $n; } } arsort( $out ); return $out; } /** * Courier-sync tallies for the Features-tab card. Two tiny local options -- * counts only, no order or customer data, never sent anywhere. * * WHY A LIFETIME TOTAL (added 0.1.4): the card used to show only "Synced * today", which is a bad health signal for this feature. A courier outcome is * recorded only when a parcel reaches a TERMINAL state (collected / returned / * not collected). Parcels routinely sit in a locker for days first, so a * perfectly healthy install reads 0 for most of any given day -- verified live * on a merchant store, where Speedy was polling correctly and every parcel was * simply still awaiting pickup. Merchants read that 0 as "broken". The * cumulative total only ever grows, so it answers "has this ever worked?" * -- which is the question the card is actually being asked. * * The daily tally is kept (cheap, and useful for support) but is no longer the * headline number. */ function orderguard_courier_bump_synced_today() { // ORDER MATTERS. The lifetime total is read FIRST, while today's tally is // still at its pre-increment value. // // orderguard_courier_synced_total() seeds itself from today's tally when the option // doesn't exist yet (so upgrading installs don't appear to lose outcomes // they already recorded). Reading it AFTER incrementing today therefore // counted the very first outcome twice, and every total after that read as // "today + 1" -- caught on a live store showing 69 total against 68 today. $total_ok = false; $total = orderguard_get_option( 'orderguard_courier_synced_total', null, $total_ok ); if ( ! $total_ok ) { return; } if ( null === $total ) { $seed_ok = false; $total = orderguard_courier_synced_today( $seed_ok ); // pre-increment: the seed value if ( ! $seed_ok ) { return; } } $today = current_time( 'Y-m-d' ); $today_ok = false; $data = orderguard_get_option( 'orderguard_courier_synced_today', array(), $today_ok ); if ( ! $today_ok ) { return; } if ( ! is_array( $data ) || ! isset( $data['day'] ) || $today !== $data['day'] ) { $data = array( 'day' => $today, 'count' => 0 ); // new day -> reset } $data['count'] = (int) $data['count'] + 1; orderguard_update_option( 'orderguard_courier_synced_today', $data, false ); // autoload=false: only read on the Features tab // Lifetime total -- never resets. orderguard_update_option( 'orderguard_courier_synced_total', max( 0, (int) $total ) + 1, false ); } function orderguard_courier_synced_today( &$ok = null ) { $data = orderguard_get_option( 'orderguard_courier_synced_today', array(), $ok ); if ( ! is_array( $data ) || ! isset( $data['day'] ) || current_time( 'Y-m-d' ) !== $data['day'] ) { return 0; } return max( 0, (int) $data['count'] ); } /** * Lifetime count of courier outcomes recorded on this store. Seeded from * today's tally on first read so existing installs don't appear to lose * outcomes they already recorded before the upgrade. */ function orderguard_courier_synced_total() { $total = orderguard_get_option( 'orderguard_courier_synced_total', null ); if ( null === $total ) { return max( 0, (int) orderguard_courier_synced_today() ); } return max( 0, (int) $total ); } /** * PER-COURIER DAILY HISTORY (0.1.7) -- the Features-tab activity strip. * * WHY: the card shows a lifetime total and today's count, and neither answers * the question a merchant is actually asking -- "is this STILL working?". * A courier can sit on a healthy-looking historical number while recording * nothing for weeks: Speedy did exactly that here, 0 of 847 parcels, card * green the whole time. A 14-day strip makes that flatline visible at a * glance, before anyone has to understand a threshold. * * Stored PER COURIER even though v1 renders a combined strip: a store-wide * flatline says "something died", a per-courier one says "Speedy died while * BOX NOW kept working". Keeping the shape now means a breakdown later needs * no migration. * * Counts only -- no order refs, no identities, never sent anywhere. One small * option, autoload off, pruned to ORDERGUARD_COURIER_HISTORY_DAYS so it cannot grow * without bound (couriers x days is the hard ceiling). */ if ( ! defined( 'ORDERGUARD_COURIER_HISTORY_DAYS' ) ) { define( 'ORDERGUARD_COURIER_HISTORY_DAYS', 30 ); } /** * Day-bucket helper. current_time('timestamp') + gmdate() is the standard WP * idiom for a SITE-LOCAL calendar day -- deliberately not strtotime() on a * local date string, which reads the server's timezone instead and drifts. */ function orderguard_courier_history_day( $offset_days = 0 ) { return gmdate( 'Y-m-d', current_time( 'timestamp' ) - ( (int) $offset_days * DAY_IN_SECONDS ) ); } function orderguard_courier_history_note( $courier ) { $courier = sanitize_key( (string) $courier ); if ( '' === $courier ) { return; } $history_ok = false; $hist = orderguard_get_option( 'orderguard_courier_daily_history', array(), $history_ok ); if ( ! $history_ok ) { return; } if ( ! is_array( $hist ) ) { $hist = array(); // corrupt/legacy value -> start clean rather than fatal } if ( ! isset( $hist[ $courier ] ) || ! is_array( $hist[ $courier ] ) ) { $hist[ $courier ] = array(); } $today = orderguard_courier_history_day( 0 ); $prev = isset( $hist[ $courier ][ $today ] ) ? (int) $hist[ $courier ][ $today ] : 0; $hist[ $courier ][ $today ] = $prev + 1; // Prune outside the retention window. String comparison is safe on Y-m-d. $cutoff = orderguard_courier_history_day( ORDERGUARD_COURIER_HISTORY_DAYS ); foreach ( $hist as $c => $days ) { if ( ! is_array( $days ) ) { unset( $hist[ $c ] ); continue; } foreach ( $days as $d => $n ) { if ( (string) $d < $cutoff ) { unset( $hist[ $c ][ $d ] ); } } if ( empty( $hist[ $c ] ) ) { unset( $hist[ $c ] ); } } orderguard_update_option( 'orderguard_courier_daily_history', $hist, false ); } /** * Combined daily counts for the last $days days -- OLDEST FIRST, zero-filled, * so the strip always renders a fixed number of bars whatever the data holds. * * @return array */ function orderguard_courier_history_series( $days = 14 ) { $days = max( 1, (int) $days ); $hist = orderguard_get_option( 'orderguard_courier_daily_history', array() ); if ( ! is_array( $hist ) ) { $hist = array(); } $totals = array(); foreach ( $hist as $per_day ) { if ( ! is_array( $per_day ) ) { continue; } foreach ( $per_day as $d => $n ) { $prev = isset( $totals[ $d ] ) ? (int) $totals[ $d ] : 0; $totals[ $d ] = $prev + (int) $n; } } $out = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $d = orderguard_courier_history_day( $i ); $out[] = array( 'day' => $d, 'count' => isset( $totals[ $d ] ) ? (int) $totals[ $d ] : 0, ); } return $out; } /** * "last: today / yesterday / 3 days ago" for one courier, or '' when nothing * has ever been recorded. This is the per-courier freshness signal -- a * courier reading "3 weeks ago" is the M-1 stopped-working case, visible * without reading a single threshold. * * last_recorded_at is stored as a UTC time(); shift it by the site offset * before comparing against site-local day strings. */ function orderguard_courier_last_recorded_label( $courier ) { $row = orderguard_courier_health( $courier ); $ts = isset( $row['last_recorded_at'] ) ? (int) $row['last_recorded_at'] : 0; if ( $ts <= 0 ) { return ''; } $offset = current_time( 'timestamp' ) - time(); // site offset in seconds $that_day = gmdate( 'Y-m-d', $ts + $offset ); if ( $that_day === orderguard_courier_history_day( 0 ) ) { return __( 'today', 'orderguard' ); } if ( $that_day === orderguard_courier_history_day( 1 ) ) { return __( 'yesterday', 'orderguard' ); } /* translators: %s: human-readable time difference, e.g. "3 days". */ return sprintf( __( '%s ago', 'orderguard' ), human_time_diff( $ts, time() ) ); } /** * The in-order provenance chips: one small chip per outcome SOURCE recorded in * _orderguard_courier_provenance -- courier logo/name, the outcome, the date, an * "auto" tag, and a verified tick for real courier-adapter ('carrier' tier) * sources. Deliberately secondary styling: this is the source of the signal, * shown under the score/snapshot, not a headline. Returns '' when the order * has no provenance (the caller renders nothing -- no empty box). */ function orderguard_render_courier_provenance_chips( $order ) { if ( ! $order instanceof WC_Order ) { return ''; } $provenance = orderguard_get_order_meta_map( $order, '_orderguard_courier_provenance' ); if ( ! is_array( $provenance ) || empty( $provenance ) ) { return ''; } if ( isset( $provenance['courier'] ) && is_string( $provenance['courier'] ) ) { $provenance = array( $provenance['courier'] => $provenance ); // legacy flat shape -> keyed } $outcome_labels = array( 'kept' => array( __( 'Kept', 'orderguard' ), '#44621F', '#EDF4E1' ), 'returned' => array( __( 'Returned', 'orderguard' ), '#7D6430', '#FBF1DE' ), 'not_collected' => array( __( 'Not collected', 'orderguard' ), '#8F4444', '#FBEAEA' ), ); $chips = ''; foreach ( $provenance as $source => $entry ) { if ( ! is_array( $entry ) ) { continue; } $source = sanitize_key( (string) $source ); $outcome = isset( $entry['outcome'] ) ? (string) $entry['outcome'] : ''; if ( '' === $source || ! isset( $outcome_labels[ $outcome ] ) ) { continue; } list( $o_label, $o_fg, $o_bg ) = $outcome_labels[ $outcome ]; $kind = isset( $entry['kind'] ) ? (string) $entry['kind'] : orderguard_courier_source_kind( $source ); // legacy entries: derive $mode = isset( $entry['mode'] ) ? (string) $entry['mode'] : 'auto'; $ts = isset( $entry['ts'] ) ? (int) $entry['ts'] : 0; $date = $ts > 0 ? ( function_exists( 'orderguard_i18n_date' ) ? orderguard_i18n_date( 'j M', $ts ) : date_i18n( 'j M', $ts ) ) : ''; // Source identity: local logo asset when the merchant's own courier // plugin ships one, otherwise neutral parcel icon + name. $logo = orderguard_courier_logo( $source ); if ( 'carrier' === $kind && '' !== $logo['img'] ) { $who = '' . esc_attr( $logo['label'] ) . ''; } else { $who = '' . '' . esc_html( orderguard_courier_source_label( $source ) ) . ''; } // Two lines: 1) who + outcome + auto tag, 2) date + verified/source tier. $chips .= '
' . '
' . $who . '' . esc_html( $o_label ) . '' . ( 'manual' !== $mode ? '' . esc_html__( 'auto', 'orderguard' ) . '' : '' ) . '
' . '
' . ( '' !== $date ? '' . esc_html( $date ) . '' : '' ) . ( 'carrier' === $kind ? '' . esc_html__( 'Verified', 'orderguard' ) . '' : '' . esc_html__( 'via status map', 'orderguard' ) . '' ) . '
' . '
'; } if ( '' === $chips ) { return ''; } return '
' . '
' . esc_html__( 'Delivery source', 'orderguard' ) . '
' . '
' . $chips . '
' . '
'; } /** * =========================================================================== * M5 -- UNIVERSAL STATUS MAPPING + DEVELOPER HOOK (the scale unlock). * =========================================================================== * Any courier or tracking plugin that moves orders into distinguishable order * statuses can feed OrderGuard with a one-time merchant mapping and zero new * code. This is LOWER-trust than a real courier-API read -- the merchant (or * their plugin) asserts the outcome, nobody verified it against the courier -- * so everything here reports with the DISTINCT 'status_map' tier (see * orderguard_courier_source_kind), never as the verified 'carrier' source. * * Flow: enumerate statuses -> merchant maps status -> outcome (one option) -> * woocommerce_order_status_changed listener debounces a settle window -> * orderguard_report_courier_outcome( ..., 'status_map' | 'trackship' ). */ // How long an order must SIT in a mapped status before the outcome is // recorded -- guards against status bounce (completed -> refunded moments // later) and fat-finger status changes. Filterable, floor 1 minute. if ( ! defined( 'ORDERGUARD_COURIER_MAP_SETTLE' ) ) { define( 'ORDERGUARD_COURIER_MAP_SETTLE', 10 * MINUTE_IN_SECONDS ); } // The outcome vocabulary a status can map to ('' in the UI = not mapped). function orderguard_courier_map_choices() { return array( 'kept', 'returned', 'not_collected', 'ignore' ); } /** * Pre-seeded SUGGESTIONS for the mapping UI. These only pre-fill the * dropdowns before the merchant first saves -- nothing is ever recorded from * defaults alone (the listener reads only the SAVED map, so the whole layer * is inert until the merchant explicitly saves a mapping). * * NOTE: core 'wc-failed' deliberately seeds 'ignore', NOT 'not_collected' -- * failed is a PAYMENT failure (pre-ship, no parcel involved); auto-counting * it as a non-collection would poison customer histories. Custom delivery * statuses (wc-not-collected etc., created by courier plugins) are the real * non-collection signals. 'wc-cancelled' is ambiguous (usually pre-ship), so * it also seeds 'ignore' -- a merchant who ships-then-cancels can override. */ function orderguard_courier_status_map_defaults() { $defaults = array( 'wc-completed' => 'kept', 'wc-delivered' => 'kept', 'wc-refunded' => 'returned', 'wc-returned' => 'returned', 'wc-not-collected' => 'not_collected', 'wc-failed-delivery' => 'not_collected', 'wc-failed' => 'ignore', 'wc-cancelled' => 'ignore', ); return apply_filters( 'orderguard_courier_status_map_defaults', $defaults ); } /** * The merchant's SAVED status map (status slug 'wc-xxx' => outcome), cleaned. * Empty array until the merchant saves one -- default-safe. */ function orderguard_courier_status_map() { $saved = orderguard_get_option( 'orderguard_courier_status_map', array() ); if ( ! is_array( $saved ) ) { $saved = array(); } $clean = array(); foreach ( $saved as $status => $outcome ) { $status = sanitize_key( (string) $status ); $outcome = sanitize_key( (string) $outcome ); if ( '' !== $status && in_array( $outcome, orderguard_courier_map_choices(), true ) ) { $clean[ $status ] = $outcome; } } return $clean; } /** * Every registered order status (slug 'wc-xxx' => array with label + how many * orders currently sit in it) -- the enumeration the mapping UI renders. */ function orderguard_order_statuses_with_counts() { if ( ! function_exists( 'wc_get_order_statuses' ) ) { return array(); } $out = array(); foreach ( wc_get_order_statuses() as $slug => $label ) { $bare = preg_replace( '/^wc-/', '', (string) $slug ); $count = function_exists( 'wc_orders_count' ) ? (int) wc_orders_count( $bare ) : 0; $out[ (string) $slug ] = array( 'label' => (string) $label, 'count' => $count, ); } return $out; } /** * Signature-detect known tracking/courier-status plugins, to pre-fill * suggestions and label the Features card. id => display name. */ function orderguard_detect_tracking_plugins() { $found = array(); if ( function_exists( 'trackship_for_woocommerce' ) || class_exists( 'Trackship_For_Woocommerce' ) ) { $found['trackship'] = 'TrackShip'; } if ( class_exists( 'WC_Advanced_Shipment_Tracking_Actions' ) || class_exists( 'WC_Advanced_Shipment_Tracking' ) ) { $found['ast'] = 'Advanced Shipment Tracking'; } if ( class_exists( 'WC_Shipment_Tracking' ) ) { $found['wc_shipment_tracking'] = 'Shipment Tracking'; } if ( defined( 'YITH_YWOT_VERSION' ) || class_exists( 'YITH_WooCommerce_Order_Tracking' ) ) { $found['yith_ywot'] = 'YITH Order Tracking'; } return apply_filters( 'orderguard_courier_tracking_plugins', $found ); } /** * Save handler for the Features-tab courier card + status-mapping table. * Nonce + manage_woocommerce. NO plan gate -- courier sync is free. */ add_action( 'admin_init', 'orderguard_handle_courier_settings_submit' ); function orderguard_handle_courier_settings_submit() { if ( ! isset( $_POST['orderguard_courier_sync_submit'] ) && ! isset( $_POST['orderguard_courier_map_submit'] ) ) { return; } if ( ! isset( $_POST['orderguard_courier_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['orderguard_courier_nonce'] ) ), 'orderguard_courier_features' ) ) { return; } if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } // Master consent toggle. FREE ON EVERY PLAN -- no orderguard_is_pro() check. // // This used to silently force 'no' for a Free store. Dropped for two // reasons, the second mattering more than the first: // 1. WordPress.org guideline 5 forbids shipping code that is locked until // payment. This feature runs on the merchant's own site using the // merchant's own courier credentials -- it is not our service to sell. // 2. Courier sync is how OrderGuard learns whether a parcel was ACTUALLY // collected. Gating it meant the merchants who would contribute the // best data were the ones excluded. Free sync => denser, better-verified // network => a more valuable Pro product. if ( isset( $_POST['orderguard_courier_sync_submit'] ) ) { $want = isset( $_POST['orderguard_courier_sync_enabled'] ) ? 'yes' : 'no'; orderguard_update_option( 'orderguard_courier_sync_enabled', $want ); if ( function_exists( 'orderguard_set_notice' ) ) { orderguard_set_notice( 'success', __( 'Feature settings saved.', 'orderguard' ) ); } } // Status-mapping table -> ONE option. Unmapped ('' ) entries are dropped; // only known statuses and known outcome values are accepted. // LAUNCH: the mapping table is hidden from the Features tab, so nothing // posts orderguard_courier_map_submit -- this branch is dormant, kept intact for // a future reintroduction of the mapping UI. if ( isset( $_POST['orderguard_courier_map_submit'] ) ) { // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- is_array()-checked and unslashed, then every key AND value is sanitize_key()'d and must survive an allowlist check against wc_get_order_statuses() and orderguard_courier_map_choices(); anything else is skipped. // No plan gate: the status map belongs to courier sync, which is free. $raw = ( isset( $_POST['orderguard_status_map'] ) && is_array( $_POST['orderguard_status_map'] ) ) ? wp_unslash( $_POST['orderguard_status_map'] ) : array(); $valid_statuses = function_exists( 'wc_get_order_statuses' ) ? array_keys( wc_get_order_statuses() ) : array(); $map = array(); foreach ( $raw as $status => $outcome ) { $status = sanitize_key( (string) $status ); $outcome = sanitize_key( (string) $outcome ); if ( ! in_array( $status, $valid_statuses, true ) ) { continue; } if ( ! in_array( $outcome, orderguard_courier_map_choices(), true ) ) { continue; // includes '' = "not mapped" } $map[ $status ] = $outcome; } orderguard_update_option( 'orderguard_courier_status_map', $map, false ); // Baseline for the new-unmapped-status notice: everything that exists // right now has been seen by the merchant. orderguard_update_option( 'orderguard_courier_known_statuses', array_map( 'sanitize_key', $valid_statuses ), false ); if ( function_exists( 'orderguard_set_notice' ) ) { orderguard_set_notice( 'success', __( 'Delivery status mapping saved.', 'orderguard' ) ); } // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } } /** * One admin notice when a NEW order status appears (e.g. a freshly installed * courier plugin registered wc-out-for-delivery) that has no mapping yet. * Shows once per new status, only to shop managers, and only after the * merchant has actually adopted the mapping (a saved non-empty map). * * LAUNCH: the merchant-facing mapping UI is hidden for now, so this notice * must never nag -- registration disabled. The function stays for a future * reintroduction of the mapping UI. */ // add_action( 'admin_notices', 'orderguard_courier_new_status_notice' ); function orderguard_courier_new_status_notice() { if ( ! function_exists( 'wc_get_order_statuses' ) || ! current_user_can( 'manage_woocommerce' ) ) { return; } if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } $map = orderguard_courier_status_map(); if ( empty( $map ) ) { return; // mapping not adopted -- never nag } $known = orderguard_get_option( 'orderguard_courier_known_statuses', array() ); if ( ! is_array( $known ) || empty( $known ) ) { return; // no baseline yet (pre-M5 install) -- the next map save sets one } $current = array_map( 'sanitize_key', array_keys( wc_get_order_statuses() ) ); $new_unmapped = array_diff( $current, $known, array_keys( $map ) ); if ( empty( $new_unmapped ) ) { return; } // Mark them known so this shows once per new status, not on every page. orderguard_update_option( 'orderguard_courier_known_statuses', array_values( array_unique( array_merge( $known, $current ) ) ), false ); echo '

OrderGuard: ' . esc_html( sprintf( /* translators: %s: comma-separated list of order-status slugs */ __( 'a new order status appeared with no delivery-outcome mapping yet: %s.', 'orderguard' ), implode( ', ', $new_unmapped ) ) ) . ' ' . esc_html__( 'Map it in Features', 'orderguard' ) . '

'; } /** * LISTENER: order status changed -> schedule a debounced settle check. The * outcome is only recorded if the order is STILL in that status after the * settle window (status-bounce guard: completed -> refunded five minutes * later must record 'returned' once, not 'kept' then 'returned'). Last write * wins by design: a stale scheduled check no-ops because the status moved on, * and the newest status schedules its own check. The manual not-collected * flag still overrides server-side (a confirmed COD non-collection outranks a * status-map 'kept' in replay arbitration). * * LAUNCH: with the mapping UI hidden there is no saved map, so this listener * is fully dormant by default -- the empty-map early return inside makes it * a no-op. Attached UNCONDITIONALLY on purpose: the previous conditional * attach ran orderguard_courier_status_map() (a get_option that is NOT autoloaded) * at file scope, i.e. a DB query on every request of every page load, * front-end included, to guard a hook that only ever fires on an admin/order * status change. The guard now lives where the event does. Side benefit: a * map saved mid-request takes effect immediately instead of one request * late. */ add_action( 'woocommerce_order_status_changed', 'orderguard_courier_status_changed', 20, 3 ); function orderguard_courier_status_changed( $order_id, $old_status, $new_status ) { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; // consent master switch -- inert by default } $map = orderguard_courier_status_map(); if ( empty( $map ) ) { return; // no mapping saved -- inert } $slug = 'wc-' . sanitize_key( (string) $new_status ); if ( ! isset( $map[ $slug ] ) || 'ignore' === $map[ $slug ] ) { return; // unmapped or explicitly ignored } $delay = (int) apply_filters( 'orderguard_courier_map_settle', ORDERGUARD_COURIER_MAP_SETTLE ); if ( $delay < MINUTE_IN_SECONDS ) { $delay = MINUTE_IN_SECONDS; } $args = array( (int) $order_id, sanitize_key( (string) $new_status ) ); if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + $delay, 'orderguard_courier_map_apply', $args, 'orderguard' ); } else { wp_schedule_single_event( time() + $delay, 'orderguard_courier_map_apply', $args ); } } add_action( 'orderguard_courier_map_apply', 'orderguard_courier_map_apply', 10, 2 ); /** * The settle check: runs ORDERGUARD_COURIER_MAP_SETTLE after a status change and * records the mapped outcome IF the order still sits in that status. * Precedence: (1) TrackShip's normalized shipment status when TrackShip is * active and has already produced a verdict for this order (its live * ts_status_change_trigger feed, below, is closer to the courier than any * order-status guess); (2) the merchant's status map. */ function orderguard_courier_map_apply( $order_id = 0, $status = '' ) { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } $order = wc_get_order( (int) $order_id ); if ( ! $order instanceof WC_Order ) { return; } // Bounce guard: the order must STILL be in the status we scheduled for. $status = sanitize_key( (string) $status ); if ( $order->get_status() !== $status ) { return; // status moved on -- the newer change scheduled its own check } // Precedence (1): defer to TrackShip's normalized verdict when it exists. $tracking = orderguard_detect_tracking_plugins(); if ( isset( $tracking['trackship'] ) ) { $reported = orderguard_get_order_meta_map( $order, '_orderguard_courier_outcome' ); if ( is_array( $reported ) && ! empty( $reported['trackship'] ) ) { return; // TrackShip already fed this order's outcome } } // Precedence (2): the merchant's status map. $map = orderguard_courier_status_map(); $slug = 'wc-' . $status; if ( ! isset( $map[ $slug ] ) || 'ignore' === $map[ $slug ] ) { return; // map changed since scheduling -- respect the current one } orderguard_report_courier_outcome( $order, $map[ $slug ], 'status_map', array( 'raw_status' => $slug, // the status slug only -- never customer data 'mode' => 'auto', ) ); } /** * TrackShip live feed: TrackShip fires ts_status_change_trigger whenever a * shipment's NORMALIZED tracking status changes. Higher-priority than the * order-status map (it comes from the courier's tracking data), but still the * 'status_map' trust tier -- TrackShip normalized it, OrderGuard didn't * verify it against the courier. * TODO(verify-first): confirm the argument order/status vocabulary against a * live TrackShip install before relying on it broadly; the conservative * normalizer below only acts on unambiguous terminal statuses either way. * * LAUNCH: gated like the status-map listener above -- dormant until the * merchant has a non-empty saved map (i.e. has adopted the mapping layer). * The gate is INSIDE the handler for the same reason as above: the old * conditional attach cost a file-scope get_option on every request; this * hook only ever fires when TrackShip reports a shipment change. */ add_action( 'ts_status_change_trigger', 'orderguard_courier_trackship_changed', 10, 4 ); function orderguard_courier_trackship_changed( $order_id, $old_status = '', $new_status = '', $tracking = null ) { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } if ( empty( orderguard_courier_status_map() ) ) { return; // mapping layer not adopted -- the TrackShip feed stays dormant (see the attach note) } $outcome = orderguard_trackship_normalize( $new_status ); if ( 'unknown' === $outcome ) { return; // in transit / ambiguous -- say nothing } $order = wc_get_order( (int) $order_id ); if ( ! $order instanceof WC_Order ) { return; } orderguard_report_courier_outcome( $order, $outcome, 'trackship', array( 'raw_status' => sanitize_key( (string) $new_status ), // normalized slug only, non-PII 'mode' => 'auto', ) ); } /** * TrackShip normalized status -> canonical outcome. Conservative: only * unambiguous terminal statuses map; everything else (in transit, exceptions, * failed attempts that may be retried) stays 'unknown' = no-op. */ function orderguard_trackship_normalize( $status ) { $status = strtolower( str_replace( '-', '_', trim( (string) $status ) ) ); $map = array( 'delivered' => 'kept', 'return_to_sender' => 'not_collected', // going back without delivery 'returned_to_sender' => 'not_collected', ); return isset( $map[ $status ] ) ? $map[ $status ] : 'unknown'; } /** * =========================================================================== * DEVELOPER HOOK -- orderguard_report_outcome * =========================================================================== * For courier-plugin authors and merchant developers: report a delivery * outcome for an order from your own code, without touching OrderGuard * internals: * * do_action( 'orderguard_report_outcome', $order_id, $outcome ); * do_action( 'orderguard_report_outcome', $order_id, $outcome, $source ); * * @param int $order_id WooCommerce order id. * @param string $outcome 'kept' | 'returned' | 'not_collected' * (anything else is ignored). * @param string $source optional short id for your integration * (a-z0-9_-, max 32 chars; default 'custom'). * * Notes: * - No-ops unless OrderGuard is activated AND the merchant has courier * sync switched on (Features tab) -- consent stays with the merchant. * - Outcomes reported here are recorded in the 'status_map' trust tier * (asserted, not courier-verified). To ship a VERIFIED integration, * register a full adapter via the orderguard_courier_adapters filter instead. * - Idempotent: repeating the same outcome for the same source is a no-op; * a changed outcome supersedes the earlier one (last write wins). */ add_action( 'orderguard_report_outcome', 'orderguard_handle_dev_reported_outcome', 10, 3 ); function orderguard_handle_dev_reported_outcome( $order_id, $outcome = '', $source = 'custom' ) { if ( ! orderguard_is_activated() || ! orderguard_courier_sync_enabled() ) { return; } $order = wc_get_order( (int) $order_id ); if ( ! $order instanceof WC_Order ) { return; } $source = substr( sanitize_key( (string) $source ), 0, 32 ); // mirror the server's 32-char courier cap if ( '' === $source ) { $source = 'custom'; } // A caller-supplied source must never collide with a registered adapter // id -- orderguard_courier_source_kind() would derive the verified 'carrier' tier // for it. Force collisions to 'custom' so hook-sourced outcomes always // land in the 'status_map' tier, as documented above. if ( orderguard_courier_adapter( $source ) ) { $source = 'custom'; } orderguard_report_courier_outcome( $order, $outcome, $source, array( 'mode' => 'auto' ) ); }