ledger = $ledger; $this->store = new ProgramStore(); } /** Register status-independent paid-order reconciliation for classic and Blocks. */ public function register() { add_action( 'woocommerce_order_status_changed', array( $this, 'observe' ), 35 ); add_action( 'woocommerce_payment_complete', array( $this, 'observe' ), 35 ); add_action( 'woocommerce_order_refunded', array( $this, 'observe' ), 35 ); add_action( self::HOOK, array( $this, 'observe' ), 10, 2 ); } /** * Bounded opt-in economics. Accepted invitations retain their original terms. * * @return array */ public function settings() { $value = get_option( self::OPTION, array() ); $value = is_array( $value ) ? $value : array(); return array( 'enabled' => ! empty( $value['enabled'] ), 'referrer_points' => min( 100000, absint( $value['referrer_points'] ?? 100 ) ), 'friend_points' => min( 100000, absint( $value['friend_points'] ?? 50 ) ), 'minimum_order' => min( 1000000, max( 0, (float) ( $value['minimum_order'] ?? 10 ) ) ), ); } /** * Read an owner's signed share code without writing user metadata on GET. * * @param int $user_id Owner. * @return string */ public function code( $user_id ) { return $user_id && get_current_user_id() === (int) $user_id ? $this->signed_code( $user_id ) : ''; } /** * Sign a public identifier, not an authentication credential. * * @param int $user_id Inviter. * @return string */ private function signed_code( $user_id ) { $id = (string) absint( $user_id ); return 'D' . $id . '-' . substr( hash_hmac( 'sha256', 'basic-referral:' . $id, wp_salt( 'auth' ) ), 0, 24 ); } /** * Whether Free already owns this customer's explicit attribution (including tombstones). * * @param int $user_id Friend. * @return bool */ public function owns_customer( $user_id ) { return $user_id && (bool) $this->store->find( 'referral:friend:' . absint( $user_id ) ); } /** * Preserve ownership after a merchant reassigns an already-qualified order. * * @param int $order_id Order. * @return bool */ public function owns_order( $order_id ) { return (bool) $this->order_key( $order_id ); } /** * Find the immutable referral receipt for an order. * * @param int $order_id Order. * @return string|null */ private function order_key( $order_id ) { if ( ! absint( $order_id ) ) { return null; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Indexed uncached receipt lookup survives Woo order reassignment. return $wpdb->get_var( $wpdb->prepare( "SELECT event_key FROM %i WHERE order_id = %d AND kind = 'basic_referral' LIMIT 1", $wpdb->prefix . 'devoynx_rewards_program_records', absint( $order_id ) ) ); } /** * Consent is a nonce-protected POST at the UI; identity always comes from the session. * * @param string $code Signed invitation. * @return array|\WP_Error */ public function accept( $code ) { $user = wp_get_current_user(); $config = $this->settings(); if ( ! $user->ID || get_user_meta( $user->ID, '_devoynx_rewards_referral_optout', true ) || ! $config['enabled'] || ! is_string( $code ) || ! preg_match( '/^D([1-9][0-9]{0,18})-[a-f0-9]{24}$/D', $code, $match ) ) { return $this->error(); } $inviter = get_userdata( (int) $match[1] ); if ( ! $inviter || get_user_meta( $inviter->ID, '_devoynx_rewards_referral_optout', true ) || $user->ID === $inviter->ID || ! hash_equals( $this->signed_code( $inviter->ID ), $code ) || ! is_email( $user->user_email ) || strcasecmp( $user->user_email, $inviter->user_email ) === 0 ) { return $this->error(); } $hash = GuestClaims::email_hash( $user->user_email ); try { // One mailbox cannot be recycled through another account for a second invitation. return $this->store->locked( 'referral:mail:' . $hash, 'referral_mail', function ( $mail ) use ( $user, $inviter, $config, $hash ) { if ( 'new' !== $mail['status'] && (int) $mail['user_id'] !== $user->ID ) { return $this->error(); } return $this->store->locked( 'referral:friend:' . $user->ID, 'basic_referral', function ( $row ) use ( $mail, $user, $inviter, $config, $hash ) { $data = json_decode( $row['payload'], true ); if ( 'new' !== $row['status'] ) { return (int) ( $data['inviter'] ?? 0 ) === $inviter->ID && 'erased' !== $row['status'] ? $row : $this->error(); } foreach ( array( array( 'customer_id' => $user->ID ), array( 'billing_email' => $user->user_email ) ) as $identity ) { if ( wc_get_orders( array_merge( $identity, array( 'type' => 'shop_order', 'date_paid' => '>0', 'limit' => 1, 'return' => 'ids', ) ) ) ) { return $this->error(); } } $data = array_merge( $config, array( 'inviter' => $inviter->ID, 'email_hash' => $hash, 'accepted_at' => time(), 'currency' => get_woocommerce_currency(), 'referrer_reversed' => 0, 'friend_reversed' => 0, ) ); unset( $data['enabled'] ); $this->store->save( $mail, array(), 'claimed', array( 'user_id' => $user->ID ) ); return $this->store->save( $row, $data, 'pending', array( 'user_id' => $user->ID, 'group_key' => 'inviter:' . $inviter->ID, ) ); } ); } ); } catch ( \Throwable $error ) { return $this->error(); } } /** * Observe committed WooCommerce events; retry at most three times after a safe rollback. * * @param int $order_id Order. * @param int $attempt Retry count (only supplied by the private scheduled hook). */ public function observe( $order_id, $attempt = 0 ) { $result = $this->process( $order_id ); if ( ! is_wp_error( $result ) ) { return; } $this->record_failure( $order_id, $attempt ); if ( $attempt >= 3 ) { return; } $next = absint( $attempt ) + 1; $args = array( absint( $order_id ), $next ); if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + 300, self::HOOK, $args, self::RETRY_GROUP . absint( $order_id ) . '-' . $next, true ); } elseif ( ! wp_next_scheduled( self::HOOK, $args ) ) { wp_schedule_single_event( time() + 300, self::HOOK, $args ); } } /** * Surface failed reconciliation without retaining an exception or sensitive payment data. * * @param int $order_id Order. * @param int $attempt Retry count. */ private function record_failure( $order_id, $attempt ) { try { $order = $this->fresh_order( $order_id ); $key = $this->order_key( $order_id ); $key = $key ? $key : ( $order ? 'referral:friend:' . $order->get_customer_id() : '' ); if ( ! $key || ! $this->store->find( $key ) ) { return; } $this->store->locked( $key, 'basic_referral', function ( $row ) use ( $order_id, $attempt ) { $data = json_decode( $row['payload'], true ); $data['needs_review'] = true; $data['retry_attempt'] = min( 3, absint( $attempt ) ); return $this->store->save( $row, $data, $row['status'], array( 'order_id' => $order_id ) ); } ); } catch ( \Throwable $error ) { // A database outage may prevent the diagnostic checkpoint too; do not break a payment. return; } } /** * Reconcile one order. Ledger observers and both wallets commit with the same receipt. * * @param int $order_id Paid or refunded order. * @return array|\WP_Error|null */ public function process( $order_id ) { $order = $this->fresh_order( $order_id ); if ( ! $order ) { return null; } $owned = $this->order_key( $order_id ); $key = $owned ? $owned : 'referral:friend:' . $order->get_customer_id(); if ( ! $this->store->find( $key ) ) { return null; } try { return $this->store->locked( $key, 'basic_referral', function ( $row ) use ( $order_id ) { $data = json_decode( $row['payload'], true ); if ( ! in_array( $row['status'], array( 'pending', 'awarded', 'reversed' ), true ) || empty( $data['inviter'] ) || ( $row['order_id'] && (int) $row['order_id'] !== (int) $order_id ) ) { return $row; } $owners = array( (int) $row['user_id'], (int) $data['inviter'] ); sort( $owners, SORT_NUMERIC ); return Transaction::wallet( $owners[0], function () use ( $owners, $row, $data, $order_id ) { return Transaction::wallet( $owners[1], function () use ( $row, $data, $order_id ) { $order = $this->fresh_order( $order_id ); if ( ! $order ) { return $this->error(); } if ( 'pending' === $row['status'] ) { if ( ! $this->settings()['enabled'] || ! $this->qualifies( $order, $row, $data ) ) { return $row; } $data['order_total'] = (float) $order->get_total(); foreach ( array( 'referrer' => $data['inviter'], 'friend' => $row['user_id'], ) as $role => $owner ) { $points = (int) $data[ $role . '_points' ]; if ( $points ) { $result = $this->ledger->award( $owner, $points, false, array( 'idempotency_key' => $row['event_key'] . ':' . $role, 'event_type' => 'friend' === $role ? 'referral_friend_award' : 'referral_award', 'source_type' => 'basic_referral', 'source_id' => (string) $row['id'], 'trace' => array( 'order_id' => $order_id, 'role' => $role, ), ) ); if ( is_wp_error( $result ) ) { return $result; } $data[ $role . '_entry' ] = $result; } } } $ratio = min( 1, max( 0, (float) $order->get_total_refunded() / max( 0.01, $data['order_total'] ) ) ); foreach ( array( 'referrer' => $data['inviter'], 'friend' => $row['user_id'], ) as $role => $owner ) { $target = (int) round( $data[ $role . '_points' ] * $ratio, 0, PHP_ROUND_HALF_UP ); $needed = max( 0, $target - $data[ $role . '_reversed' ] ); if ( ! $needed ) { continue; } $now = min( $needed, $this->ledger->balance( $owner )['available'] ); if ( $now ) { $result = $this->ledger->reverse( $owner, $now, 'available', array( 'idempotency_key' => $row['event_key'] . ':' . $role . ':reverse:' . $target, 'event_type' => 'referral_reversal', 'source_type' => 'basic_referral', 'source_id' => (string) $row['id'], 'trace' => array( 'order_id' => $order_id, 'target_reversal' => $target, ), ) ); if ( is_wp_error( $result ) ) { return $result; } } $debt = absint( get_user_meta( $owner, '_devoynx_rewards_reversal_due', true ) ); if ( $needed > $now && false === update_user_meta( $owner, '_devoynx_rewards_reversal_due', $debt + $needed - $now ) ) { return $this->error(); } $data[ $role . '_reversed' ] = $target; } unset( $data['needs_review'], $data['retry_attempt'] ); return $this->store->save( $row, $data, $ratio >= 1 ? 'reversed' : 'awarded', array( 'order_id' => $order_id ) ); } ); } ); } ); } catch ( \Throwable $error ) { return $this->error(); } } /** * Verify current billing identity, first qualifying payment and immutable invitation terms. * * @param \WC_Order $order Candidate. * @param array $row Attribution. * @param array $data Frozen terms. * @return bool */ private function qualifies( $order, $row, $data ) { $friend = get_userdata( $row['user_id'] ); $inviter = get_userdata( $data['inviter'] ); if ( get_user_meta( $row['user_id'], '_devoynx_rewards_referral_optout', true ) || get_user_meta( $data['inviter'], '_devoynx_rewards_referral_optout', true ) ) { return false; } if ( ! $friend || ! $inviter || (int) $order->get_customer_id() !== $friend->ID || ! $order->is_paid() || ! $order->get_date_paid() || $order->get_date_paid()->getTimestamp() < $data['accepted_at'] || $data['currency'] !== $order->get_currency() || (float) $order->get_total() < max( 0.01, $data['minimum_order'] ) || ! hash_equals( $data['email_hash'], GuestClaims::email_hash( $friend->user_email ) ) || strcasecmp( $order->get_billing_email(), $friend->user_email ) || ! strcasecmp( $order->get_billing_email(), $inviter->user_email ) ) { return false; } // Native Woo queries work with HPOS and posts storage. Bounded history fails closed. foreach ( array( array( 'customer_id' => $friend->ID ), array( 'billing_email' => $friend->user_email ) ) as $identity ) { $history = wc_get_orders( array_merge( $identity, array( 'type' => 'shop_order', 'date_paid' => '>0', 'limit' => 101, 'orderby' => 'ID', 'order' => 'ASC', ) ) ); if ( count( $history ) > 100 ) { return false; } foreach ( $history as $prior ) { $paid = $prior->get_date_paid(); if ( ! $paid ) { continue; } if ( $paid->getTimestamp() < $data['accepted_at'] ) { return false; } $earlier = $paid->getTimestamp() < $order->get_date_paid()->getTimestamp() || ( $paid->getTimestamp() === $order->get_date_paid()->getTimestamp() && $prior->get_id() < $order->get_id() ); if ( $earlier && $prior->get_currency() === $data['currency'] && (float) $prior->get_total() >= max( 0.01, $data['minimum_order'] ) ) { return false; } } } return true; } /** * Drop potentially stale Woo caches after waiting for a workflow/wallet lock. * * @param int $order_id Order. * @return \WC_Order|false */ private function fresh_order( $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 ); } $store = '\Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore'; if ( class_exists( $store ) && method_exists( $store, 'clear_cached_data' ) ) { wc_get_container()->get( $store )->clear_cached_data( array( $order_id ) ); } return wc_get_order( $order_id ); } /** * Non-disclosing error for invalid invitations or transactional failures. * * @return \WP_Error */ public function error() { return new \WP_Error( 'referral_unavailable', __( 'This referral could not be completed. Check eligibility or contact the store for help.', 'devoynx-rewards' ) ); } }