prefix . 'reachzone_demand'; } /** * Create the outbox table and schedule the hourly send (activation, and once after an update from the old local * log). Rows the old version collected for a local-only log are deleted: they were collected under a different * consent than sending them to the ReachZone service. */ public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $collate = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, ts datetime NOT NULL, lat3 decimal(6,3) NOT NULL, lon3 decimal(7,3) NOT NULL, source varchar(12) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY ts (ts) ) {$collate};" ); if ( get_option( self::DB_OPTION ) !== self::DB_VERSION ) { self::delete_all(); wp_clear_scheduled_hook( self::OLD_CRON ); update_option( self::DB_OPTION, self::DB_VERSION, true ); } if ( ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON ); } } /** * Remove the cron events (deactivation). */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON ); wp_clear_scheduled_hook( self::OLD_CRON ); } /** * Is collecting switched on, allowed by the plan, and possible (a licence key is saved)? The plan only decides * whether the ReachZone service takes the points; nothing else in the plugin depends on it. * * @return bool */ public static function enabled() { $settings = Settings::get(); return ! empty( $settings['demand_map'] ) && '' !== (string) $settings['license_key'] && ! empty( License::current_limits()['demand_map'] ); } /** * Round a coordinate to 3 decimals. * * @param float $v Value. * @return float */ public static function round3( $v ) { return round( (float) $v, 3 ); } /** * Queue an out-of-area lookup for the next hourly send. Takes coordinates only. * * @param float $lat Latitude. * @param float $lon Longitude. * @param string $source checker|checkout. * @return bool Whether a row was written. */ public static function record( $lat, $lon, $source ) { if ( ! self::enabled() || ! is_numeric( $lat ) || ! is_numeric( $lon ) ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin's own table. $ok = $wpdb->insert( self::table(), array( 'ts' => gmdate( 'Y-m-d H:i:s' ), 'lat3' => self::round3( $lat ), 'lon3' => self::round3( $lon ), 'source' => 'checkout' === $source ? 'checkout' : 'checker', ), array( '%s', '%f', '%f', '%s' ) ); return false !== $ok; } /** * Transient key that identifies one shopper's out-of-area attempt at one rounded spot. * * Pure. The shopper part is only hashed together with the cell, so the key says nothing about who it was. * * @param string $shopper Opaque shopper id (WooCommerce session customer id). * @param float $lat Latitude. * @param float $lon Longitude. * @param string $salt Site secret. * @return string */ public static function dedupe_key( $shopper, $lat, $lon, $salt = '' ) { return 'rz_dl_' . substr( hash_hmac( 'sha256', (string) $shopper . '|' . self::round3( $lat ) . '|' . self::round3( $lon ), (string) $salt ), 0, 32 ); } /** * Record an out-of-area attempt at most once per hour per shopper and rounded spot. * * For the Block checkout, where the Store API validates the cart on every refresh and the same attempt would be * counted again and again. * * @param float $lat Latitude. * @param float $lon Longitude. * @param string $source checker|checkout. * @param string $shopper Opaque shopper id. * @return bool Whether a row was written. */ public static function record_once( $lat, $lon, $source, $shopper ) { if ( ! self::enabled() || ! is_numeric( $lat ) || ! is_numeric( $lon ) || '' === (string) $shopper ) { return false; } $key = self::dedupe_key( $shopper, $lat, $lon, wp_salt( 'auth' ) ); if ( false !== get_transient( $key ) ) { return false; } set_transient( $key, 1, HOUR_IN_SECONDS ); return self::record( $lat, $lon, $source ); } /** * The events to send for a batch of outbox rows. * * @param array[] $rows Rows with ts (UTC), lat3, lon3 and source. * @return array[] Each array( lat, lon, source, day ), rounded again to 3 decimals. */ public static function events_from_rows( array $rows ) { $events = array(); foreach ( $rows as $row ) { $time = strtotime( $row['ts'] . ' UTC' ); $events[] = array( 'lat' => self::round3( $row['lat3'] ), 'lon' => self::round3( $row['lon3'] ), 'source' => 'checkout' === $row['source'] ? 'checkout' : 'checker', 'day' => gmdate( 'Y-m-d', false === $time ? time() : $time ), ); } return $events; } /** * Hourly job: send up to 500 of the oldest outbox rows to the ReachZone API. * * - 200: the sent rows are deleted. * - 402 or 403 (licence expired, or the plan has no demand map): the whole outbox is deleted. * - Network error, 5xx, 429 (monthly quota used up) or anything else: the rows stay for the next run. * - Switched off or no plan any more: the outbox is emptied without any call. * - Rows older than 7 days are always deleted. * * @return string off|empty|sent|wiped|kept (for tests and logs). */ public static function flush() { global $wpdb; self::delete_older_than( time() - self::KEEP_DAYS * DAY_IN_SECONDS ); if ( ! self::enabled() ) { self::delete_all(); return 'off'; } $table = esc_sql( self::table() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin's own table; table name is not user input. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, ts, lat3, lon3, source FROM {$table} ORDER BY id ASC LIMIT %d", self::BATCH ), ARRAY_A ); if ( empty( $rows ) ) { return 'empty'; } $result = Api_Client::demand_events( self::events_from_rows( $rows ) ); if ( ! is_wp_error( $result ) ) { self::delete_ids( wp_list_pluck( $rows, 'id' ) ); return 'sent'; } if ( in_array( $result->get_error_code(), array( 'reachzone_payment_required', 'reachzone_forbidden' ), true ) ) { self::delete_all(); return 'wiped'; } return 'kept'; } /** * Delete outbox rows by id. * * @param array $ids Row ids. */ private static function delete_ids( array $ids ) { global $wpdb; $ids = array_filter( array_map( 'intval', $ids ) ); if ( ! $ids ) { return; } $table = esc_sql( self::table() ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Plugin's own table; the placeholders are generated here and the ids are bound. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", array_values( $ids ) ) ); } /** * Empty the outbox. */ private static function delete_all() { global $wpdb; $table = esc_sql( self::table() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin's own table; table name is not user input. $wpdb->query( "DELETE FROM {$table}" ); } /** * Delete rows older than a moment. * * @param int $before Unix time. */ private static function delete_older_than( $before ) { global $wpdb; $table = esc_sql( self::table() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin's own table; table name is not user input. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE ts < %s", gmdate( 'Y-m-d H:i:s', $before ) ) ); } /** * The map data from the ReachZone API, cached for 10 minutes (the admin page only). * * @param int $days Look-back window, 1 to 180. * @return array|\WP_Error array( days, total, cells ) as returned by Api_Client::demand_cells(). */ public static function remote_cells( $days = 90 ) { $key = 'reachzone_demand_cells_' . substr( License::key_hash( Settings::get()['license_key'] ), 0, 16 ) . '_' . (int) $days; $cached = get_transient( $key ); if ( is_array( $cached ) && isset( $cached['cells'], $cached['total'] ) ) { return $cached; } $result = Api_Client::demand_cells( $days ); if ( ! is_wp_error( $result ) ) { set_transient( $key, $result, self::CELLS_TTL ); } return $result; } }