ledger = $ledger; $this->store = new ProgramStore(); } /** Register the resumable background action. */ public function register() { add_action( self::HOOK, array( $this, 'process' ) ); } /** * Create a dry run bounded by the highest existing order ID, not a mutable offset. * * @param int $from_id Inclusive first order ID. * @param int $to_id Inclusive last order ID; zero freezes the current maximum. * @return array */ public function create( $from_id = 1, $to_id = 0 ) { $last = wc_get_orders( array( 'type' => 'shop_order', 'limit' => 1, 'orderby' => 'ID', 'order' => 'DESC', 'return' => 'ids', ) ); $max = $last ? absint( reset( $last ) ) : 0; $end = $to_id ? min( $max, absint( $to_id ) ) : $max; $key = 'backfill:' . wp_generate_uuid4(); return $this->store->locked( $key, 'backfill_job', function ( $row ) use ( $from_id, $end ) { return $this->store->save( $row, array( 'cursor' => max( 0, absint( $from_id ) - 1 ), 'end' => $end, 'apply_cursor' => 0, 'eligible' => 0, 'preview_points' => 0, 'applied' => 0, 'skipped' => 0, 'failed' => 0, ), 'preview', array( 'user_id' => get_current_user_id() ) ); } ); } /** * Inspect at most 25 orders in a bounded ID window; never move points. * * @param string $key Job key. * @return array|\WP_Error * @throws \RuntimeException When the order query violates its bound. */ public function preview( $key ) { return $this->existing_job( $key, function ( $row ) { if ( 'preview' !== $row['status'] ) { return $row; } $data = json_decode( $row['payload'], true ); $end = min( $data['end'], $data['cursor'] + 200 ); $ids = $end > $data['cursor'] ? wc_get_orders( array( 'type' => 'shop_order', 'post__in' => range( $data['cursor'] + 1, $end ), 'limit' => 25, 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'ids', ) ) : array(); // Both WooCommerce order stores support post__in. Fail closed if a filter removes the bound. $previous = $data['cursor']; foreach ( $ids as $order_id ) { if ( $order_id <= $previous || $order_id > $end ) { throw new \RuntimeException( 'Order query exceeded its bounded checkpoint.' ); } $previous = (int) $order_id; } foreach ( $ids as $order_id ) { $snapshot = $this->snapshot( wc_get_order( $order_id ) ); if ( ! $snapshot ) { continue; } $this->store->locked( $row['event_key'] . ':' . $order_id, 'backfill_item', function ( $item ) use ( $row, $order_id, $snapshot ) { return $this->store->save( $item, $snapshot, 'preview', array( 'group_key' => $row['event_key'], 'order_id' => $order_id, 'user_id' => $snapshot['user_id'], ) ); } ); ++$data['eligible']; $data['preview_points'] += $snapshot['net_points']; } $data['cursor'] = count( $ids ) === 25 ? absint( end( $ids ) ) : $end; return $this->store->save( $row, $data, $data['cursor'] >= $data['end'] ? 'ready' : 'preview' ); } ); } /** * Approve a finished preview behind merchant authorization and a nonce. * * @param string $key Job key. * @return array|\WP_Error */ public function confirm( $key ) { $result = $this->existing_job( $key, function ( $row ) { if ( 'ready' !== $row['status'] ) { return new \WP_Error( 'devoynx_backfill_not_ready', __( 'Finish the dry run before confirming the backfill.', 'devoynx-rewards' ) ); } return $this->store->save( $row, json_decode( $row['payload'], true ), 'running' ); } ); if ( ! is_wp_error( $result ) ) { $this->schedule( $key ); } return $result; } /** * Commit a checkpoint and at most ten order awards together. * * @param string $key Job key. * @return array|\WP_Error */ public function process( $key ) { try { $result = $this->existing_job( $key, function ( $row ) { if ( 'running' !== $row['status'] ) { return $row; } $data = json_decode( $row['payload'], true ); $items = $this->store->page( 'backfill_item', $row['event_key'], $data['apply_cursor'], 10 ); foreach ( $items as $item ) { try { $status = $this->apply_item( $item ); $this->store->save( $item, json_decode( $item['payload'], true ), $status ); ++$data[ 'applied' === $status ? 'applied' : 'skipped' ]; } catch ( \Throwable $error ) { $this->store->save( $item, json_decode( $item['payload'], true ), 'failed' ); ++$data['failed']; } $data['apply_cursor'] = (int) $item['id']; } return $this->store->save( $row, $data, count( $items ) < 10 ? 'complete' : 'running' ); } ); if ( ! is_wp_error( $result ) && 'running' === $result['status'] ) { $this->schedule( $key ); } return $result; } catch ( \Throwable $error ) { return new \WP_Error( 'devoynx_backfill_retry', __( 'The batch did not commit. Resume to retry safely.', 'devoynx-rewards' ) ); } } /** * Qualify and fingerprint economic state without changing an order. * * @param \WC_Order|false $order Candidate order. * @return array|null */ private function snapshot( $order ) { if ( ! $order || ! $order->get_user_id() || ! $order->is_paid() || ! $order->get_date_paid() || $order->get_meta( '_devoynx_rewards_award_entry' ) || $order->get_meta( '_devoynx_guest_points_id' ) || $order->get_currency() !== get_woocommerce_currency() ) { return null; } $result = ( new RuleEngine() )->calculate_order( $order ); if ( $result['points'] < 1 ) { return null; } $total = (float) $order->get_total(); $ratio = $total > 0 ? min( 1, (float) $order->get_total_refunded() / $total ) : 0; $data = array( 'user_id' => $order->get_user_id(), 'points' => $result['points'], 'net_points' => $result['points'] - (int) round( $result['points'] * $ratio, 0, PHP_ROUND_HALF_UP ), 'total' => $total, 'refunded' => (float) $order->get_total_refunded(), 'status' => $order->get_status(), 'rules' => $result, ); $data['fingerprint'] = hash( 'sha256', wp_json_encode( $data ) ); return $data; } /** * Apply an unchanged preview under the customer wallet lock. * * @param array $item Preview record. * @return string * @throws \RuntimeException When award or reconciliation fails. */ private function apply_item( $item ) { return Transaction::wallet( $item['user_id'], function () use ( $item ) { $order = wc_get_order( $item['order_id'] ); if ( ! $order ) { return 'unavailable'; } $order->read_meta_data( true ); $current = $this->snapshot( $order ); $expected = json_decode( $item['payload'], true ); if ( ! $current || ! hash_equals( $expected['fingerprint'], $current['fingerprint'] ) ) { return 'changed'; } $settings = get_option( 'devoynx_rewards_settings', array() ); add_filter( 'devoynx_rewards_notification_allowed', '__return_false', PHP_INT_MAX ); try { ( new WooCommerce( $this->ledger, new RuleEngine() ) )->maybe_award_order( $order->get_id(), $order->get_status(), $settings['award_status'] ?? 'completed', $order ); $order->read_meta_data( true ); if ( ! $order->get_meta( '_devoynx_rewards_award_entry' ) ) { throw new \RuntimeException( 'Order award rejected.' ); } $refunds = $order->get_refunds(); if ( $refunds ) { ( new RefundProcessor( $this->ledger ) )->refund( $order->get_id(), reset( $refunds )->get_id() ); } if ( ! ( new LedgerRepository() )->reconcile( $item['user_id'] )['matches'] ) { throw new \RuntimeException( 'Backfill reconciliation failed.' ); } return 'applied'; } finally { remove_filter( 'devoynx_rewards_notification_allowed', '__return_false', PHP_INT_MAX ); } } ); } /** * Lock only an existing backfill job. * * @param string $key Job key. * @param callable $callback Operation. * @return mixed */ private function existing_job( $key, callable $callback ) { $row = $this->store->find( $key ); if ( ! $row || 'backfill_job' !== $row['kind'] ) { return new \WP_Error( 'devoynx_backfill_missing', __( 'Backfill job not found.', 'devoynx-rewards' ) ); } return $this->store->locked( $key, 'backfill_job', $callback ); } /** * Schedule the next checkpoint; duplicate actions remain harmless. * * @param string $key Job key. */ private function schedule( $key ) { if ( function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + 10, self::HOOK, array( $key ), 'devoynx-rewards', false ); } elseif ( ! wp_next_scheduled( self::HOOK, array( $key ) ) ) { wp_schedule_single_event( time() + 10, self::HOOK, array( $key ) ); } } }