scheduler = $scheduler; $this->repository = $repository instanceof OrderRiskRepository ? $repository : new OrderRiskRepository(); $this->detector = $detector instanceof PromotionSignalDetector ? $detector : new PromotionSignalDetector(); } /** * Register WooCommerce hooks. * * @return void */ public function register() { // Unpaid-eligible: may evaluate as preliminary before payment confirmation. add_action( 'woocommerce_order_status_pending', array( $this, 'maybe_enqueue' ), 20, 1 ); add_action( 'woocommerce_order_status_on-hold', array( $this, 'maybe_enqueue' ), 20, 1 ); add_action( 'woocommerce_order_status_processing', array( $this, 'maybe_enqueue' ), 20, 1 ); add_action( 'woocommerce_payment_complete', array( $this, 'maybe_enqueue' ), 20, 1 ); add_action( 'woocommerce_order_status_completed', array( $this, 'maybe_enqueue' ), 20, 1 ); add_action( 'woocommerce_update_order', array( $this, 'maybe_enqueue_on_edit' ), 20, 1 ); // Refund: keep original snapshot; never compute refund-adjusted margin (PRD 13.6 / AC-010). add_action( 'woocommerce_order_refunded', array( $this, 'on_order_refunded' ), 20, 1 ); add_action( 'woocommerce_order_partially_refunded', array( $this, 'on_order_refunded' ), 20, 1 ); add_action( 'woocommerce_order_fully_refunded', array( $this, 'on_order_refunded' ), 20, 1 ); } /** * Label refunded order; do not re-evaluate profitability. * * @param int $order_id Order ID. * @return void */ public function on_order_refunded( $order_id ) { $order_id = (int) $order_id; if ( $order_id <= 0 ) { return; } $this->repository->mark_refunded( $order_id ); } /** * Enqueue evaluation for eligible order. * * @param int $order_id Order ID. * @return void */ public function maybe_enqueue( $order_id ) { $order_id = (int) $order_id; if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Cancelled / Failed: not evaluated (PRD 13.6). $status = $order->get_status(); if ( in_array( $status, array( 'cancelled', 'failed' ), true ) ) { $this->repository->mark_ineligible_status( $order_id ); return; } // Refunded: preserve original snapshot only. if ( OrderRefundLifecycle::order_is_refunded( $order ) ) { $this->repository->mark_refunded( $order_id ); return; } $signal = $this->detector->detect( $order ); // Free: only verified/suspected promotion. Pro: also baseline (signal none). if ( ! OrderRiskEligibility::should_evaluate( $signal ) ) { $this->repository->upsert_pending_no_promotion( $order_id, $signal ); return; } $revision = $this->build_order_revision( $order ); $payment_state = PaymentResultState::for_order( $order ); // Include payment state so unpaid→paid transitions re-enqueue even when material hash is unchanged. $dedupe = 'order_' . $order_id . '_' . $revision . '_' . $payment_state; $action_id = $this->scheduler->enqueue( Constants::ACTION_EVALUATE_ORDER, array( 'order_id' => $order_id, 'revision' => $revision, ), Constants::SCHEDULER_GROUP, null, $dedupe ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { return; } $this->repository->ensure_queued( $order_id, $revision, $signal ); } /** * Manual retry / Free basic refresh (admin-initiated). * * @param int $order_id Order ID. * @param string $origin Queue origin label: realtime|recalculate. * @return array{success:bool,error?:string} */ public function enqueue_manual_retry( $order_id, $origin = 'realtime' ) { $order_id = (int) $order_id; if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) ) { return array( 'success' => false, 'error' => __( 'Order not found.', 'marginbeacon' ), ); } $order = wc_get_order( $order_id ); if ( ! $order ) { return array( 'success' => false, 'error' => __( 'Order not found.', 'marginbeacon' ), ); } if ( OrderRefundLifecycle::order_is_refunded( $order ) ) { $this->repository->mark_refunded( $order_id ); return array( 'success' => false, 'error' => __( 'Refunded orders keep the original margin snapshot. V1 does not recalculate refund-adjusted profitability.', 'marginbeacon' ), ); } $status = $order->get_status(); if ( in_array( $status, array( 'cancelled', 'failed' ), true ) ) { $this->repository->mark_ineligible_status( $order_id ); return array( 'success' => false, 'error' => __( 'Cancelled or failed orders are not evaluated.', 'marginbeacon' ), ); } $signal = $this->detector->detect( $order ); if ( ! OrderRiskEligibility::should_evaluate( $signal ) ) { $this->repository->upsert_pending_no_promotion( $order_id, $signal ); return array( 'success' => true, ); } $revision = $this->build_order_revision( $order ); $payment_state = PaymentResultState::for_order( $order ); $origin = in_array( $origin, array( 'realtime', 'recalculate', 'recent_preview' ), true ) ? $origin : 'realtime'; $action_id = $this->scheduler->enqueue( Constants::ACTION_EVALUATE_ORDER, array( 'order_id' => $order_id, 'revision' => $revision, 'origin' => $origin, ), Constants::SCHEDULER_GROUP, null, 'retry_' . $order_id . '_' . $payment_state . '_' . time() ); if ( ! SchedulerResult::is_enqueued( $action_id ) ) { return array( 'success' => false, 'error' => __( 'Could not enqueue evaluation (Action Scheduler unavailable).', 'marginbeacon' ), ); } $this->repository->mark_queued_for_retry( $order_id, $revision, $signal ); ( new \Kayvandra\MarginBeacon\Infrastructure\Persistence\OperationalEventsRepository() )->record( 'order_risk', (string) $order_id, null, 'recalculate' === $origin ? 'recalculate_queued' : 'retry_queued', array( 'origin' => $origin, 'revision' => $revision, 'payment_result_state' => $payment_state, ) ); return array( 'success' => true, ); } /** * On material order update, enqueue recalculation only when material hash changes (PRD 9.10.1). * * @param int $order_id Order ID. * @return void */ public function maybe_enqueue_on_edit( $order_id ) { $order_id = (int) $order_id; if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $new_revision = OrderMaterialHasher::hash_order( $order ); $current = $this->repository->get_current( $order_id ); if ( is_array( $current ) && ! empty( $current['order_revision'] ) && hash_equals( (string) $current['order_revision'], $new_revision ) ) { return; } $this->maybe_enqueue( $order_id ); } /** * Material field hash for order revision (PRD 9.10.1). * * @param \WC_Order $order Order. * @return string */ private function build_order_revision( $order ) { return OrderMaterialHasher::hash_order( $order ); } }