getInputs(); $details = $this->approval_details( $inputs ); $points = null === $details['estimated_points'] ? __( 'unknown', 'aisentic' ) : (string) $details['estimated_points']; $after = null === $details['estimated_after'] ? __( 'unknown', 'aisentic' ) : (string) $details['estimated_after']; return sprintf( /* translators: 1: channel name, 2: customer name, 3: customer email, 4: points, 5: amount, 6: current balance, 7: estimated balance. */ __( 'Trigger %1$s for %2$s <%3$s>: %4$s points on amount %5$s; balance %6$s -> %7$s. For a timeout retry, reuse the same idempotency_key.', 'aisentic' ), $details['channel_name'], $details['customer_name'], $details['customer_email'], $points, (string) ( $inputs['amount'] ?? '?' ), (string) $details['balance_before'], $after ); } /** * Approval card inputs with resolved names and balance impact. * * @return array */ public function get_approval_inputs(): array { $inputs = $this->getInputs(); $details = $this->approval_details( $inputs ); /* translators: 1: channel name, 2: channel ID. */ $inputs['channel'] = sprintf( __( '%1$s (ID: %2$d)', 'aisentic' ), $details['channel_name'], (int) ( $inputs['channel_id'] ?? 0 ) ); /* translators: 1: customer name, 2: customer email, 3: user ID. */ $inputs['customer'] = sprintf( __( '%1$s <%2$s> (ID: %3$d)', 'aisentic' ), $details['customer_name'], $details['customer_email'], (int) ( $inputs['user_id'] ?? 0 ) ); $inputs['points_estimated'] = $details['estimated_points']; $inputs['balance_before'] = $details['balance_before']; $inputs['balance_after'] = $details['estimated_after']; $inputs['retry_note'] = __( 'If the request times out, reuse the same idempotency_key; do not create a new key.', 'aisentic' ); return $inputs; } /** * Execute the tool after approval. * * @param int $channel_id Channel ID. * @param int $user_id Verified WordPress user ID. * @param float $amount Event amount. * @param string $idempotency_key Stable award key. * @param string|null $customer_email Expected email. * @param string|null $customer_login Expected login. * @return array */ public function __invoke( int $channel_id, int $user_id, float $amount, string $idempotency_key, ?string $customer_email = null, ?string $customer_login = null ): array { $check = $this->guard( 'manage_pointics', 'manage_options' ); if ( $check ) { return $check; } $ready = $this->pointics_tables_ready(); if ( $ready ) { return $this->steer( $ready ); } if ( $channel_id <= 0 || $user_id <= 0 ) { return $this->steer( [ 'error' => __( 'channel_id and user_id must be positive IDs. Resolve them with pointics-list-channels and pointics-find-member first.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ] ); } if ( $amount <= 0 || ! is_finite( $amount ) ) { return $this->steer( [ 'error' => __( 'amount must be greater than 0.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ] ); } $user = $this->verified_user( $user_id, $customer_email, $customer_login ); if ( $this->is_error( $user ) ) { return $this->steer( $user ); } $idempotency_key = substr( sanitize_text_field( $idempotency_key ), 0, 120 ); if ( '' === trim( $idempotency_key ) ) { return $this->steer( [ 'error' => __( 'idempotency_key is required. Reuse the same key for a timeout retry.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ] ); } try { $controller = pointics_container()->make( \Pointics\Api\Controllers\Channels_Controller::class ); $channel_request = new \WP_REST_Request( 'GET' ); $channel_request->set_param( 'id', $channel_id ); $channel = $this->extract_response( $controller->get_channel( $channel_request ), __( 'Failed to retrieve the Pointics channel before triggering it.', 'aisentic' ), 'pointics-get-channel' ); if ( $this->is_error( $channel ) ) { return $channel; } $points_service = pointics_container()->make( \Pointics\Points\Points_Service::class ); $balance_before = $this->safe_balance( $points_service, $user_id ); $request = new \WP_REST_Request( 'POST' ); $request->set_param( 'id', $channel_id ); $request->set_header( 'content-type', 'application/json' ); $request->set_body( wp_json_encode( [ 'user_id' => $user_id, 'amount' => $amount, 'idempotency_key' => $idempotency_key, ] ) ); $entry = $this->extract_response( $controller->trigger_channel( $request ), __( 'Failed to trigger the Pointics channel.', 'aisentic' ), 'pointics-trigger-channel' ); if ( $this->is_error( $entry ) ) { return $this->steer_trigger_failure( $entry ); } if ( ! isset( $entry['id'], $entry['points'] ) ) { return $this->steer( [ 'error' => __( 'Pointics returned no award entry ID or points. Verify the channel state before retrying.', 'aisentic' ), 'code' => 'internal', 'status' => null, ] ); } $balance_after = $this->safe_balance( $points_service, $user_id ); return [ 'success' => true, 'channel_id' => $channel_id, 'channel_name' => (string) ( $channel['name'] ?? '' ), 'customer' => [ 'user_id' => $user_id, 'name' => $user['name'], 'email' => $user['email'], ], 'amount' => $amount, 'points' => (int) $entry['points'], 'balance_before' => $balance_before, 'balance_after' => $balance_after, 'entry_id' => (int) $entry['id'], 'idempotency_key' => $idempotency_key, 'message' => __( 'Pointics channel triggered successfully.', 'aisentic' ), ]; } catch ( \Throwable $e ) { Log::error( 'Pointics_Trigger_Channel_Tool error', [ 'error' => $e->getMessage() ] ); return $this->steer( [ 'error' => __( 'Failed to trigger the Pointics channel.', 'aisentic' ), 'code' => 'internal', 'status' => null, ] ); } } /** * Add trigger-specific recovery guidance to a failed award. * * `award_failed` means the channel produced no points (a zero or blocked * award), so the model must check the amount and calculation before a * retry. Other logical failures keep the idempotency-note steering so a * duplicate award is never dispatched twice. * * @param array $entry Normalised trigger failure. * @return array */ private function steer_trigger_failure( array $entry ): array { if ( 'award_failed' === (string) ( $entry['pointics_code'] ?? '' ) ) { $guidance = __( 'Confirm the amount is greater than 0; the channel calculation may block zero-point awards. For a timeout retry, reuse the same idempotency_key.', 'aisentic' ); if ( false === strpos( (string) ( $entry['error'] ?? '' ), 'idempotency_key' ) ) { $entry['error'] = trim( (string) ( $entry['error'] ?? '' ) . ' ' . $guidance ); } return $entry; } if ( 'logical_failure' === ( $entry['code'] ?? '' ) && false === strpos( (string) ( $entry['error'] ?? '' ), 'already exist' ) ) { $entry['error'] = trim( (string) $entry['error'] . ' ' . __( 'An award may already exist for this idempotency key; verify the member balance/history before retrying.', 'aisentic' ) ); } return $entry; } /** * Resolve a real WordPress user and verify optional identity fields. * * @param int $user_id User ID. * @param string|null $customer_email Expected email. * @param string|null $customer_login Expected login. * @return array */ private function verified_user( int $user_id, ?string $customer_email, ?string $customer_login ): array { $user = get_userdata( $user_id ); if ( ! is_object( $user ) || (int) ( $user->ID ?? 0 ) !== $user_id ) { return [ 'error' => __( 'The supplied user_id is not a resolvable WordPress member. Call pointics-find-member first; do not dispatch to a phantom user ID.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ]; } $email = (string) ( $user->user_email ?? '' ); $login = (string) ( $user->user_login ?? '' ); if ( null !== $customer_email && '' !== trim( $customer_email ) && strtolower( trim( $customer_email ) ) !== strtolower( $email ) ) { return [ 'error' => __( 'customer_email does not match the verified user_id. Re-run pointics-find-member and use its matching identity.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ]; } if ( null !== $customer_login && '' !== trim( $customer_login ) && trim( $customer_login ) !== $login ) { return [ 'error' => __( 'customer_login does not match the verified user_id. Re-run pointics-find-member and use its matching identity.', 'aisentic' ), 'code' => 'validation', 'status' => 422, ]; } return [ 'name' => (string) ( $user->display_name ?? $login ), 'email' => $email, 'login' => $login, ]; } /** * Build approval-card details. * * @param array $inputs Trigger inputs. * @return array */ private function approval_details( array $inputs ): array { $channel_name = __( 'unknown channel', 'aisentic' ); $customer_name = __( 'unknown customer', 'aisentic' ); $customer_email = ''; $balance_before = __( 'unknown', 'aisentic' ); $estimated_points = null; $estimated_after = null; try { $channel = $this->entity_lookup( \Pointics\Api\Controllers\Channels_Controller::class, 'get_channel', (int) ( $inputs['channel_id'] ?? 0 ), 'pointics-get-channel', __( 'Unable to resolve the channel for approval.', 'aisentic' ) ); if ( [] !== $channel ) { $channel_name = (string) ( $channel['name'] ?? $channel_name ); $estimated_points = $this->estimate_points( $channel, (float) ( $inputs['amount'] ?? 0 ) ); } $user = get_userdata( (int) ( $inputs['user_id'] ?? 0 ) ); if ( is_object( $user ) ) { $customer_name = (string) ( $user->display_name ?? $customer_name ); $customer_email = (string) ( $user->user_email ?? '' ); } $points_service = pointics_container()->make( \Pointics\Points\Points_Service::class ); $balance_before = $this->balance_before( $points_service, (int) ( $inputs['user_id'] ?? 0 ) ); $estimated_after = null === $estimated_points || ! is_numeric( $balance_before ) ? null : (int) $balance_before + $estimated_points; } catch ( \Throwable $e ) { // Approval UI still describes supplied inputs when a lookup is unavailable. unset( $e ); } return compact( 'channel_name', 'customer_name', 'customer_email', 'balance_before', 'estimated_points', 'estimated_after' ); } /** * Read the member's spendable balance. * * @param object $points_service Pointics points facade. * @param int $user_id User ID. * @return int */ private function balance_before( object $points_service, int $user_id ) { $balance = $points_service->balance( $user_id ); return (int) ( $balance['spendable'] ?? 0 ); } /** * Read the member's spendable balance without aborting the award. * * A balance lookup failure must never block an already-approved award, so * the read degrades to null and the dispatch continues. * * @param object $points_service Pointics points facade. * @param int $user_id User ID. * @return int|null Null when the balance cannot be read. */ private function safe_balance( object $points_service, int $user_id ): ?int { try { return $this->balance_before( $points_service, $user_id ); } catch ( \Throwable $e ) { return null; } } /** * Estimate points for the known core strategies. * * @param array $channel Channel detail. * @param float $amount Event amount. * @return int|null */ private function estimate_points( array $channel, float $amount ): ?int { $params = is_array( $channel['calc_params'] ?? null ) ? $channel['calc_params'] : []; return match ( $channel['calc_type'] ?? '' ) { 'fixed' => max( 0, (int) ( $params['points'] ?? 0 ) ), 'per_currency' => (int) floor( max( 0.0, $amount ) * (float) ( $params['rate'] ?? 0 ) ), 'percentage' => (int) floor( max( 0.0, $amount ) * (float) ( $params['percentage'] ?? 0 ) / 100 ), 'tiered' => (int) floor( max( 0, (int) ( $params['base_points'] ?? 0 ) ) * max( 0.0, (float) ( $params['multiplier'] ?? 1 ) ) ), default => null, }; } }