ledger = $ledger; $this->store = new ProgramStore(); } /** Attach guest-only capture and reconciliation, leaving RC3 callbacks unchanged. */ public function register() { add_action( 'woocommerce_order_status_changed', array( $this, 'capture' ), 24, 4 ); add_action( 'woocommerce_order_refunded', array( $this, 'refund' ), 30, 2 ); add_action( self::HOOK, array( $this, 'cleanup' ) ); add_action( 'init', array( $this, 'schedule' ), 20 ); } /** * Conservative opt-in configuration. * * @return array */ public function settings() { $input = get_option( self::OPTION, array() ); $input = is_array( $input ) ? $input : array(); return array( 'enabled' => is_array( $input ) && ! empty( $input['enabled'] ), 'window_days' => min( 90, max( 1, absint( $input['window_days'] ?? 30 ) ) ), ); } /** * Keyed email identity; never store an email or verification code in plaintext. * * @param string $email Mailbox. * @return string */ public static function email_hash( $email ) { return hash_hmac( 'sha256', strtolower( trim( $email ) ), wp_salt( 'auth' ) ); } /** * Freeze first qualifying paid guest order economics, without awarding points. * * @param int $order_id Order. * @param string $from Previous status. * @param string $to New status. * @param \WC_Order $order Event order. */ public function capture( $order_id, $from, $to, $order ) { unset( $from ); $config = $this->settings(); $core = get_option( 'devoynx_rewards_settings', array() ); if ( ! $config['enabled'] || ( $core['award_status'] ?? 'completed' ) !== $to || ! $this->eligible_order( $order ) ) { return; } try { $this->store->locked( 'guest:order:' . absint( $order_id ), 'guest_order', function ( $row ) use ( $order_id, $config, $core ) { if ( 'new' !== $row['status'] ) { return $row; } $order = wc_get_order( $order_id ); if ( ! $this->eligible_order( $order ) ) { return $this->error( 'unavailable' ); } $award = ( new RuleEngine() )->calculate_order( $order ); if ( $award['points'] < 1 ) { return $this->error( 'unavailable' ); } return $this->store->save( $row, array( 'award' => $award, 'fingerprint' => $this->fingerprint( $order ), 'expires' => time() + $config['window_days'] * DAY_IN_SECONDS, 'expiry_days' => absint( $core['points_expiry_days'] ?? 0 ), ), 'pending', array( 'order_id' => $order_id, 'group_key' => self::email_hash( $order->get_billing_email() ), ) ); } ); } catch ( \Throwable $error ) { wc_get_logger()->error( 'Guest rewards snapshot could not be saved; no points were awarded.', array( 'source' => 'devoynx-rewards' ) ); } } /** * Reject existing/legacy awards and unpaid or fully refunded purchases. * * @param \WC_Order|false $order Candidate. * @return bool */ private function eligible_order( $order ) { return $order instanceof \WC_Order && ! $order->get_customer_id() && $order->is_paid() && $order->get_date_paid() && is_email( $order->get_billing_email() ) && $order->get_currency() === get_woocommerce_currency() && ! $order->get_meta( '_devoynx_rewards_award_entry' ) && ! $order->get_meta( '_devoynx_guest_points_id' ) && (float) $order->get_total() > (float) $order->get_total_refunded(); } /** * Detect edits to the purchased economics, without recalculating changing rules. * * @param \WC_Order $order Purchase. * @return string */ private function fingerprint( $order ) { $lines = array(); foreach ( $order->get_items() as $id => $item ) { $lines[ $id ] = array( $item->get_product_id(), $item->get_variation_id(), $item->get_quantity(), $item->get_subtotal(), $item->get_total(), $item->get_total_tax() ); } ksort( $lines ); return hash( 'sha256', wp_json_encode( array( $order->get_currency(), $order->get_total(), $order->get_coupon_codes(), $lines ) ) ); } /** * Request a short-lived proof. Mail is attempted only once per issued challenge. * * Nonexistent/mismatching orders have the same public result. Requests are * limited per billing mailbox across accounts and orders, never by IP tracking. * * @param int $order_id Supplied order ID. * @return array|\WP_Error */ public function challenge( $order_id ) { $user = wp_get_current_user(); if ( ! $user->ID || ! is_email( $user->user_email ) ) { return $this->error( 'account' ); } $generic = array( 'sent' => true ); if ( ! $this->settings()['enabled'] || ! $this->store->find( 'guest:order:' . absint( $order_id ) ) ) { return $generic; } $email_hash = self::email_hash( $user->user_email ); $delivery = null; try { $this->store->locked( 'guest:mail:' . $email_hash, 'guest_mail', function ( $rate_row ) use ( $order_id, $user, $email_hash, &$delivery ) { $rate = json_decode( $rate_row['payload'], true ); if ( ( $rate['expires'] ?? 0 ) <= time() ) { $rate = array( 'expires' => time() + HOUR_IN_SECONDS, 'count' => 0, ); } if ( $rate['count'] >= 3 ) { return; } $this->store->locked( 'guest:order:' . absint( $order_id ), 'guest_order', function ( $row ) use ( $user, $email_hash, &$delivery ) { $data = json_decode( $row['payload'], true ); $order = wc_get_order( $row['order_id'] ); if ( 'pending' !== $row['status'] || ( $data['expires'] ?? 0 ) <= time() || ! hash_equals( $row['group_key'], $email_hash ) || ! $this->eligible_order( $order ) || ! hash_equals( $row['group_key'], self::email_hash( $order->get_billing_email() ) ) || ! hash_equals( $data['fingerprint'], $this->fingerprint( $order ) ) || ( $data['challenge']['last_sent'] ?? 0 ) > time() - MINUTE_IN_SECONDS ) { return; } $token = bin2hex( random_bytes( 16 ) ); $data['challenge'] = array( 'hash' => hash( 'sha256', $token ), 'user_id' => $user->ID, 'expires' => time() + 20 * MINUTE_IN_SECONDS, 'last_sent' => time(), 'attempts' => 0, ); $this->store->save( $row, $data, 'pending' ); $delivery = array( 'email' => $order->get_billing_email(), 'token' => $token, ); } ); if ( $delivery ) { ++$rate['count']; } $this->store->save( $rate_row, $rate, 'active', array( 'group_key' => $email_hash ) ); } ); } catch ( \Throwable $error ) { return $this->error( 'failed' ); } if ( $delivery ) { // Proof is committed before transport: crashes may lose mail, never resend it automatically. $switched = switch_to_user_locale( $user->ID ); try { /* translators: 1: store name, 2: verification code, 3: wallet URL. */ $message = sprintf( __( "You requested a guest purchase points claim at %1\$s.\n\nVerification code: %2\$s\n\nSign in to the account that requested this code and enter it at %3\$s. It expires in 20 minutes. Do not share it. If you did not request this, ignore this message. No points have been claimed.", 'devoynx-rewards' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $delivery['token'], wc_get_account_endpoint_url( 'rewards' ) ); wp_mail( $delivery['email'], __( 'Verify your guest purchase points claim', 'devoynx-rewards' ), $message ); } catch ( \Throwable $error ) { // Do not expose mailbox validity or transport details; a later request can issue a new proof. return $generic; } finally { if ( $switched ) { restore_previous_locale(); } } } return $generic; } /** * Atomically consume proof, link the order, award and reconcile prior refunds. * * @param int $order_id Purchase. * @param string $token Private code from the billing mailbox. * @return array|\WP_Error */ public function claim( $order_id, $token ) { $user = wp_get_current_user(); if ( ! $user->ID || ! is_string( $token ) || strlen( $token ) > 64 || ! $this->store->find( 'guest:order:' . absint( $order_id ) ) ) { return $this->error( 'verification' ); } try { $result = $this->store->locked( 'guest:order:' . absint( $order_id ), 'guest_order', function ( $row ) use ( $user, $token ) { $data = json_decode( $row['payload'], true ); if ( 'claimed' === $row['status'] && (int) $row['user_id'] === (int) $user->ID ) { return $data['receipt']; } $proof = $data['challenge'] ?? array(); if ( ! $this->settings()['enabled'] || 'pending' !== $row['status'] || ( $data['expires'] ?? 0 ) <= time() || ! hash_equals( $row['group_key'], self::email_hash( $user->user_email ) ) || (int) ( $proof['user_id'] ?? 0 ) !== (int) $user->ID || ( $proof['expires'] ?? 0 ) <= time() || ( $proof['attempts'] ?? 5 ) >= 5 ) { return $this->error( 'verification' ); } if ( ! hash_equals( $proof['hash'], hash( 'sha256', $token ) ) ) { ++$data['challenge']['attempts']; $this->store->save( $row, $data, 'pending' ); // A normal return commits failed-attempt counters; WP_Error would roll them back. return array( 'error' => 'verification' ); } return Transaction::wallet( $user->ID, function () use ( $row, $data, $user ) { $this->forget_order_cache( $row['order_id'] ); $order = wc_get_order( $row['order_id'] ); if ( $order ) { $order->read_meta_data( true ); } if ( ! $this->eligible_order( $order ) || ! hash_equals( $row['group_key'], self::email_hash( $order->get_billing_email() ) ) || ! hash_equals( $data['fingerprint'], $this->fingerprint( $order ) ) ) { return $this->error( 'unavailable' ); } $award = $data['award']; $entry = $this->ledger->award( $user->ID, $award['points'], false, array( 'idempotency_key' => 'order-award:' . $order->get_id(), 'event_type' => 'guest_order_claim', 'source_type' => 'order', 'source_id' => (string) $order->get_id(), 'rule_id' => $award['rule_id'], 'rule_version' => $award['rule_version'], 'trace' => $award['trace'], 'constraints' => $award['constraints'], 'expires_at' => $data['expiry_days'] ? gmdate( 'Y-m-d H:i:s', time() + $data['expiry_days'] * DAY_IN_SECONDS ) : null, ) ); if ( is_wp_error( $entry ) ) { return $entry; } $order->set_customer_id( $user->ID ); $order->update_meta_data( '_devoynx_rewards_awarded_points', $award['points'] ); $order->update_meta_data( '_devoynx_rewards_award_entry', $entry ); $order->save(); $refunds = wc_get_orders( array( 'type' => 'shop_order_refund', 'parent' => $order->get_id(), 'limit' => 1, 'return' => 'ids', ) ); if ( $refunds ) { ( new RefundProcessor( $this->ledger ) )->refund( $order->get_id(), reset( $refunds ) ); } $data['receipt'] = array( 'entry_id' => $entry, 'points' => $award['points'], 'order_id' => $order->get_id(), ); unset( $data['challenge'] ); $this->store->save( $row, $data, 'claimed', array( 'user_id' => $user->ID ) ); return $data['receipt']; } ); } ); return is_array( $result ) && isset( $result['error'] ) ? $this->error( $result['error'] ) : $result; } catch ( \Throwable $error ) { return $this->error( 'failed' ); } finally { // CRUD objects written inside a rolled-back transaction must not outlive it. $this->forget_order_cache( $order_id ); } } /** * Close the race where a refund sees a guest just before a claim commits. * * Both operations acquire this order checkpoint before the wallet lock. The * unchanged RC3 callback remains idempotent if it already processed the refund. * * @param int $order_id Parent order. * @param int $refund_id Refund. */ public function refund( $order_id, $refund_id ) { $key = 'guest:order:' . absint( $order_id ); if ( ! $this->store->find( $key ) ) { return; } $this->store->locked( $key, 'guest_order', function ( $row ) use ( $order_id, $refund_id ) { if ( 'claimed' === $row['status'] ) { $this->forget_order_cache( $order_id ); ( new RefundProcessor( $this->ledger ) )->refund( $order_id, $refund_id ); } } ); } /** * Invalidate only this order after waiting for locks or rolling back CRUD writes. * * @param int $order_id Order whose cached state may predate the transaction. */ private function forget_order_cache( $order_id ) { $order_id = absint( $order_id ); clean_post_cache( $order_id ); if ( class_exists( '\Automattic\WooCommerce\Caches\OrderCache' ) ) { wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->remove( $order_id ); } // Optional HPOS datastore caching is separate from order object caching. // Guard the internal invalidation adapter for WooCommerce versions without it. $class = '\Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore'; if ( class_exists( $class ) && method_exists( $class, 'clear_cached_data' ) ) { wc_get_container()->get( $class )->clear_cached_data( array( $order_id ) ); } } /** Schedule a bounded rotating cleanup, including when new captures are disabled. */ public function schedule() { if ( function_exists( 'as_has_scheduled_action' ) ) { if ( ! as_has_scheduled_action( self::HOOK, array(), 'devoynx-rewards' ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::HOOK, array(), 'devoynx-rewards', true ); } } elseif ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::HOOK ); } } /** Sweep at most 100 pending claims per run, rotating a durable ID cursor. */ public function cleanup() { global $wpdb; $cursor = absint( get_option( 'devoynx_rewards_guest_cleanup_cursor', 0 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Indexed, bounded workflow sweep, no cached secrets. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT event_key,id FROM %i WHERE kind = 'guest_order' AND status = 'pending' AND id > %d ORDER BY id LIMIT 100", $wpdb->prefix . 'devoynx_rewards_program_records', $cursor ), ARRAY_A ); foreach ( $rows as $item ) { $this->store->locked( $item['event_key'], 'guest_order', function ( $row ) { if ( 'pending' !== $row['status'] ) { return; } $data = json_decode( $row['payload'], true ); if ( $data['expires'] <= time() ) { $this->store->save( $row, array(), 'expired', array( 'group_key' => '', 'user_id' => 0, ) ); } elseif ( ( $data['challenge']['expires'] ?? PHP_INT_MAX ) <= time() ) { unset( $data['challenge']['hash'] ); $this->store->save( $row, $data, 'pending' ); } } ); } update_option( 'devoynx_rewards_guest_cleanup_cursor', count( $rows ) === 100 ? (int) end( $rows )['id'] : 0, false ); $cursor = absint( get_option( 'devoynx_rewards_guest_mail_cursor', 0 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Indexed, bounded rate-record sweep with a rotating cursor. $rates = $wpdb->get_results( $wpdb->prepare( "SELECT event_key,id FROM %i WHERE kind = 'guest_mail' AND id > %d ORDER BY id LIMIT 100", $wpdb->prefix . 'devoynx_rewards_program_records', $cursor ), ARRAY_A ); foreach ( $rates as $item ) { $this->store->locked( $item['event_key'], 'guest_mail', static function ( $row ) use ( $wpdb ) { $data = json_decode( $row['payload'], true ); if ( ( $data['expires'] ?? 0 ) <= time() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Delete only the locked, expired one-hour abuse counter, not an economic receipt. $wpdb->delete( $wpdb->prefix . 'devoynx_rewards_program_records', array( 'id' => $row['id'] ), array( '%d' ) ); } } ); } update_option( 'devoynx_rewards_guest_mail_cursor', count( $rates ) === 100 ? (int) end( $rates )['id'] : 0, false ); } /** * Safe, localized public errors. * * @param string $code Allowlisted status. * @return \WP_Error */ public function error( $code ) { $messages = array( 'account' => __( 'Sign in to your own account before requesting a verification code.', 'devoynx-rewards' ), 'verification' => __( 'The claim could not be verified. Check the order ID and code, or request a new code after one minute. Use the account with the purchase billing email.', 'devoynx-rewards' ), 'unavailable' => __( 'This purchase is no longer eligible or its details changed. Please contact the store for help.', 'devoynx-rewards' ), 'failed' => __( 'The claim could not be completed safely. No partial reward was saved. Please try again.', 'devoynx-rewards' ), ); return new \WP_Error( isset( $messages[ $code ] ) ? $code : 'failed', $messages[ $code ] ?? $messages['failed'] ); } }