diff --git a/core/Tracker/GoalManager.php b/core/Tracker/GoalManager.php
index ce1532d8608b724b3b394c5a4963b6fe69332d42..8b3c86734fa76d61f1733e75f86d35a60085e33d 100644
--- a/core/Tracker/GoalManager.php
+++ b/core/Tracker/GoalManager.php
@@ -55,54 +55,24 @@ class GoalManager
     const INTERNAL_ITEM_PRICE = 7;
     const INTERNAL_ITEM_QUANTITY = 8;
 
-    public $idGoal;
-    public $requestIsEcommerce;
-    private $isGoalAnOrder;
-
     /**
-     * @var Action
+     * TODO: should remove this, but it is used by getGoalColumn which is used by dimensions. should replace w/ value object.
+     *
+     * @var array
      */
-    protected $action = null;
-    protected $convertedGoals = array();
-
     private $currentGoal = array();
 
-    /**
-     * @var Request
-     */
-    protected $request;
-    protected $orderId;
-
-    protected $isThereExistingCartInVisit = false;
-
-    /**
-     * Constructor
-     * @param Request $request
-     */
-    public function __construct(Request $request)
-    {
-        $this->request = $request;
-        $this->orderId = $request->getParam('ec_id');
-        $this->idGoal  = $request->getParam('idgoal');
-
-        $this->isGoalAnOrder = !empty($this->orderId);
-        $this->requestIsEcommerce = (0 == $this->idGoal);
-    }
-
-    public function isGoalAnOrder()
-    {
-        return $this->isGoalAnOrder;
-    }
-
     public function detectIsThereExistingCartInVisit($visitInformation)
     {
-        if (!empty($visitInformation['visit_goal_buyer'])) {
-            $goalBuyer = $visitInformation['visit_goal_buyer'];
-            $types     = array(GoalManager::TYPE_BUYER_OPEN_CART, GoalManager::TYPE_BUYER_ORDERED_AND_OPEN_CART);
-
-            // Was there a Cart for this visit prior to the order?
-            $this->isThereExistingCartInVisit = in_array($goalBuyer, $types);
+        if (empty($visitInformation['visit_goal_buyer'])) {
+            return false;
         }
+
+        $goalBuyer = $visitInformation['visit_goal_buyer'];
+        $types     = array(GoalManager::TYPE_BUYER_OPEN_CART, GoalManager::TYPE_BUYER_ORDERED_AND_OPEN_CART);
+
+        // Was there a Cart for this visit prior to the order?
+        return in_array($goalBuyer, $types);
     }
 
     public static function getGoalDefinitions($idSite)
@@ -147,17 +117,18 @@ class GoalManager
      * @param int $idSite
      * @param Action $action
      * @throws Exception
-     * @return int Number of goals matched
+     * @return array[] Goals matched
      */
     public function detectGoalsMatchingUrl($idSite, $action)
     {
         if (!Common::isGoalPluginEnabled()) {
-            return false;
+            return array();
         }
 
         $actionType = $action->getActionType();
         $goals = $this->getGoalDefinitions($idSite);
 
+        $convertedGoals = array();
         foreach ($goals as $goal) {
             $attribute = $goal['match_attribute'];
             // if the attribute to match is not the type of the current action
@@ -196,37 +167,32 @@ class GoalManager
             $match = $this->isUrlMatchingGoal($goal, $pattern_type, $url);
             if ($match) {
                 $goal['url'] = $action->getActionUrl();
-                $this->convertedGoals[] = $goal;
+                $convertedGoals[] = $goal;
             }
         }
 
-        return count($this->convertedGoals) > 0;
+        return $convertedGoals;
     }
 
-    public function isManualGoalConversion()
-    {
-        return $this->idGoal > 0;
-    }
-
-    public function detectGoalId($idSite)
+    public function detectGoalId($idSite, Request $request)
     {
         if (!Common::isGoalPluginEnabled()) {
-            return false;
+            return null;
         }
 
+        $idGoal = $request->getParam('idgoal');
+
         $goals = $this->getGoalDefinitions($idSite);
 
-        if (!isset($goals[$this->idGoal])) {
-            return false;
+        if (!isset($goals[$idGoal])) {
+            return null;
         }
 
-        $goal = $goals[$this->idGoal];
+        $goal = $goals[$idGoal];
 
-        $url         = $this->request->getParam('url');
+        $url         = $request->getParam('url');
         $goal['url'] = PageUrl::excludeQueryParametersFromUrl($url, $idSite);
-        $this->convertedGoals[] = $goal;
-
-        return true;
+        return $goal;
     }
 
     /**
@@ -237,7 +203,7 @@ class GoalManager
      * @param array $visitCustomVariables
      * @param Action $action
      */
-    public function recordGoals(VisitProperties $visitProperties)
+    public function recordGoals(VisitProperties $visitProperties, Request $request)
     {
         $visitorInformation = $visitProperties->visitorInfo;
         $visitCustomVariables = $visitProperties->getRequestMetadata('CustomVariables', 'visitCustomVariables');
@@ -245,7 +211,7 @@ class GoalManager
         /** @var Action $action */
         $action = $visitProperties->getRequestMetadata('Actions', 'action');
 
-        $goal = $this->getGoalFromVisitor($visitProperties, $action);
+        $goal = $this->getGoalFromVisitor($visitProperties, $request, $action);
 
         // Copy Custom Variables from Visit row to the Goal conversion
         // Otherwise, set the Custom Variables found in the cookie sent with this request
@@ -266,10 +232,11 @@ class GoalManager
         }
 
         // some goals are converted, so must be ecommerce Order or Cart Update
-        if ($this->requestIsEcommerce) {
-            $this->recordEcommerceGoal($visitProperties, $goal, $action);
+        $isRequestEcommerce = $visitProperties->getRequestMetadata('Ecommerce', 'isRequestEcommerce');
+        if ($isRequestEcommerce) {
+            $this->recordEcommerceGoal($visitProperties, $request, $goal, $action);
         } else {
-            $this->recordStandardGoals($visitProperties, $goal, $action);
+            $this->recordStandardGoals($visitProperties, $request, $goal, $action);
         }
     }
 
@@ -299,23 +266,27 @@ class GoalManager
      * @param Action $action
      * @param array $visitInformation
      */
-    protected function recordEcommerceGoal(VisitProperties $visitProperties, $conversion, $action)
+    protected function recordEcommerceGoal(VisitProperties $visitProperties, Request $request, $conversion, $action)
     {
-        if ($this->isThereExistingCartInVisit) {
+        $isThereExistingCartInVisit = $visitProperties->getRequestMetadata('Goals', 'isThereExistingCartInVisit');
+        if ($isThereExistingCartInVisit) {
             Common::printDebug("There is an existing cart for this visit");
         }
 
         $visitor = Visitor::makeFromVisitProperties($visitProperties);
 
-        if ($this->isGoalAnOrder) {
+        $isGoalAnOrder = $visitProperties->getRequestMetadata('Ecommerce', 'isGoalAnOrder');
+        if ($isGoalAnOrder) {
             $debugMessage = 'The conversion is an Ecommerce order';
 
-            $conversion['idorder'] = $this->orderId;
+            $orderId = $request->getParam('ec_id');
+
+            $conversion['idorder'] = $orderId;
             $conversion['idgoal']  = self::IDGOAL_ORDER;
-            $conversion['buster']  = Common::hashStringToInt($this->orderId);
+            $conversion['buster']  = Common::hashStringToInt($orderId);
 
             $conversionDimensions = ConversionDimension::getAllDimensions();
-            $conversion = $this->triggerHookOnDimensions($conversionDimensions, 'onEcommerceOrderConversion', $visitor, $action, $conversion);
+            $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onEcommerceOrderConversion', $visitor, $action, $conversion);
         } // If Cart update, select current items in the previous Cart
         else {
             $debugMessage = 'The conversion is an Ecommerce Cart Update';
@@ -324,13 +295,13 @@ class GoalManager
             $conversion['idgoal'] = self::IDGOAL_CART;
 
             $conversionDimensions = ConversionDimension::getAllDimensions();
-            $conversion = $this->triggerHookOnDimensions($conversionDimensions, 'onEcommerceCartUpdateConversion', $visitor, $action, $conversion);
+            $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onEcommerceCartUpdateConversion', $visitor, $action, $conversion);
         }
 
         Common::printDebug($debugMessage . ':' . var_export($conversion, true));
 
         // INSERT or Sync items in the Cart / Order for this visit & order
-        $items = $this->getEcommerceItemsFromRequest();
+        $items = $this->getEcommerceItemsFromRequest($request);
 
         if (false === $items) {
             return;
@@ -343,10 +314,10 @@ class GoalManager
 
         $conversion['items'] = $itemsCount;
 
-        if ($this->isThereExistingCartInVisit) {
+        if ($isThereExistingCartInVisit) {
             $recorded = $this->getModel()->updateConversion($visitProperties->visitorInfo['idvisit'], self::IDGOAL_CART, $conversion);
         } else {
-            $recorded = $this->insertNewConversion($conversion, $visitProperties->visitorInfo);
+            $recorded = $this->insertNewConversion($conversion, $visitProperties->visitorInfo, $request);
         }
 
         if ($recorded) {
@@ -371,9 +342,9 @@ class GoalManager
      * Returns Items read from the request string
      * @return array|bool
      */
-    private function getEcommerceItemsFromRequest()
+    private function getEcommerceItemsFromRequest(Request $request)
     {
-        $items = $this->request->getParam('ec_items');
+        $items = $request->getParam('ec_items');
 
         if (empty($items)) {
             Common::printDebug("There are no Ecommerce items in the request");
@@ -674,11 +645,12 @@ class GoalManager
      * @param Action $action
      * @param $visitorInformation
      */
-    protected function recordStandardGoals(VisitProperties $visitProperties, $goal, $action)
+    protected function recordStandardGoals(VisitProperties $visitProperties, Request $request, $goal, $action)
     {
         $visitor = Visitor::makeFromVisitProperties($visitProperties);
 
-        foreach ($this->convertedGoals as $convertedGoal) {
+        $convertedGoals = $visitProperties->getRequestMetadata('Goals', 'goalsConverted') ?: array();
+        foreach ($convertedGoals as $convertedGoal) {
             $this->currentGoal = $convertedGoal;
             Common::printDebug("- Goal " . $convertedGoal['idgoal'] . " matched. Recording...");
             $conversion = $goal;
@@ -696,9 +668,9 @@ class GoalManager
                 : $visitProperties->visitorInfo['visit_last_action_time'];
 
             $conversionDimensions = ConversionDimension::getAllDimensions();
-            $conversion = $this->triggerHookOnDimensions($conversionDimensions, 'onGoalConversion', $visitor, $action, $conversion);
+            $conversion = $this->triggerHookOnDimensions($request, $conversionDimensions, 'onGoalConversion', $visitor, $action, $conversion);
 
-            $this->insertNewConversion($conversion, $visitProperties->visitorInfo);
+            $this->insertNewConversion($conversion, $visitProperties->visitorInfo, $request);
 
             /**
              * Triggered after successfully recording a non-ecommerce conversion.
@@ -720,7 +692,7 @@ class GoalManager
      * @param array $visitInformation
      * @return bool
      */
-    protected function insertNewConversion($conversion, $visitInformation)
+    protected function insertNewConversion($conversion, $visitInformation, Request $request)
     {
         /**
          * Triggered before persisting a new [conversion entity](/guides/persistence-and-the-mysql-backend#conversions).
@@ -733,7 +705,7 @@ class GoalManager
          *                                information it contains [here](/guides/persistence-and-the-mysql-backend#visits).
          * @param \Piwik\Tracker\Request $request An object describing the tracking request being processed.
          */
-        Piwik::postEvent('Tracker.newConversionInformation', array(&$conversion, $visitInformation, $this->request));
+        Piwik::postEvent('Tracker.newConversionInformation', array(&$conversion, $visitInformation, $request));
 
         $newGoalDebug = $conversion;
         $newGoalDebug['idvisitor'] = bin2hex($newGoalDebug['idvisitor']);
@@ -796,10 +768,10 @@ class GoalManager
      *
      * @return array|null The updated $valuesToUpdate or null if no $valuesToUpdate given
      */
-    private function triggerHookOnDimensions($dimensions, $hook, $visitor, $action, $valuesToUpdate)
+    private function triggerHookOnDimensions(Request $request, $dimensions, $hook, $visitor, $action, $valuesToUpdate)
     {
         foreach ($dimensions as $dimension) {
-            $value = $dimension->$hook($this->request, $visitor, $action, $this);
+            $value = $dimension->$hook($request, $visitor, $action, $this);
 
             if (false !== $value) {
                 if (is_float($value)) {
@@ -816,7 +788,7 @@ class GoalManager
         return $valuesToUpdate;
     }
 
-    private function getGoalFromVisitor(VisitProperties $visitProperties, $action)
+    private function getGoalFromVisitor(VisitProperties $visitProperties, Request $request, $action)
     {
         $goal = array(
             'idvisit'     => $visitProperties->visitorInfo['idvisit'],
@@ -828,7 +800,7 @@ class GoalManager
 
         $visit = Visitor::makeFromVisitProperties($visitProperties);
         foreach ($visitDimensions as $dimension) {
-            $value = $dimension->onAnyGoalConversion($this->request, $visit, $action);
+            $value = $dimension->onAnyGoalConversion($request, $visit, $action);
             if (false !== $value) {
                 $goal[$dimension->getColumnName()] = $value;
             }
diff --git a/core/Tracker/RequestProcessor.php b/core/Tracker/RequestProcessor.php
index d386cc33e7020c5f2bfb49c2cbcc878e2c69f078..072e9d76b52fff817d52e80c8a0b6c71e87f762b 100644
--- a/core/Tracker/RequestProcessor.php
+++ b/core/Tracker/RequestProcessor.php
@@ -156,8 +156,9 @@ abstract class RequestProcessor
      * other words, the values in the array were persisted to the DB before this method was called).
      *
      * @param VisitProperties $visitProperties
+     * @param Request $request
      */
-    public function recordLogs(VisitProperties $visitProperties)
+    public function recordLogs(VisitProperties $visitProperties, Request $request)
     {
         // empty
     }
diff --git a/core/Tracker/Visit.php b/core/Tracker/Visit.php
index f8787b691430ce9665e4d9bb8727a438b18bda7e..6b2883bf2ba84eec08480f572f1b54ce58d2e135 100644
--- a/core/Tracker/Visit.php
+++ b/core/Tracker/Visit.php
@@ -166,7 +166,7 @@ class Visit implements VisitInterface
         foreach ($this->requestProcessors as $processor) {
             Common::printDebug("Executing " . get_class($processor) . "::recordLogs()...");
 
-            $processor->recordLogs($this->visitProperties);
+            $processor->recordLogs($this->visitProperties, $this->request);
         }
 
         $this->markArchivedReportsAsInvalidIfArchiveAlreadyFinished();
diff --git a/core/Tracker/Visitor.php b/core/Tracker/Visitor.php
index 61345dd9e5a2c72dd115c67bce60921b88a3dbc1..cdd4ce769349da1b7548757e6b615b2c2a7d09a5 100644
--- a/core/Tracker/Visitor.php
+++ b/core/Tracker/Visitor.php
@@ -16,7 +16,7 @@ use Piwik\Tracker\Visit\VisitProperties;
 class Visitor
 {
     private $visitorKnown = false;
-    private $visitProperties;
+    public $visitProperties;
 
     public function __construct(VisitProperties $visitProperties, $isVisitorKnown = false)
     {
diff --git a/plugins/Actions/Tracker/ActionsRequestProcessor.php b/plugins/Actions/Tracker/ActionsRequestProcessor.php
index b4c7bf9e89b1206315da62c42ed1da1666045d49..cfc9bb57934cc2a9540d170dcfc9a4604bd98e56 100644
--- a/plugins/Actions/Tracker/ActionsRequestProcessor.php
+++ b/plugins/Actions/Tracker/ActionsRequestProcessor.php
@@ -69,7 +69,7 @@ class ActionsRequestProcessor extends RequestProcessor
         }
     }
 
-    public function recordLogs(VisitProperties $visitProperties)
+    public function recordLogs(VisitProperties $visitProperties, Request $request)
     {
         /** @var Action $action */
         $action = $visitProperties->getRequestMetadata('Actions', 'action');
diff --git a/plugins/CoreHome/Columns/VisitGoalBuyer.php b/plugins/CoreHome/Columns/VisitGoalBuyer.php
index 8867efd70848a27d5dfad7a7c73dc46a4880cc8d..006c93a179a1d7f2b767f9e8cca3e730aa338da1 100644
--- a/plugins/CoreHome/Columns/VisitGoalBuyer.php
+++ b/plugins/CoreHome/Columns/VisitGoalBuyer.php
@@ -56,7 +56,7 @@ class VisitGoalBuyer extends VisitDimension
      */
     public function onNewVisit(Request $request, Visitor $visitor, $action)
     {
-        return $this->getBuyerType($request);
+        return $this->getBuyerType($visitor);
     }
 
     /**
@@ -70,7 +70,7 @@ class VisitGoalBuyer extends VisitDimension
         $goalBuyer = $visitor->getVisitorColumn($this->columnName);
 
         // Ecommerce buyer status
-        $visitEcommerceStatus = $this->getBuyerType($request, $goalBuyer);
+        $visitEcommerceStatus = $this->getBuyerType($visitor, $goalBuyer);
 
         if ($visitEcommerceStatus != self::TYPE_BUYER_NONE
             // only update if the value has changed (prevents overwriting the value in case a request has
@@ -106,15 +106,15 @@ class VisitGoalBuyer extends VisitDimension
         return self::$visitEcommerceStatus[$id];
     }
 
-    private function getBuyerType(Request $request, $existingType = self::TYPE_BUYER_NONE)
+    private function getBuyerType(Visitor $visitor, $existingType = self::TYPE_BUYER_NONE)
     {
-        $goalManager = new GoalManager($request);
-
-        if (!$goalManager->requestIsEcommerce) {
+        $isRequestEcommerce = $visitor->visitProperties->getRequestMetadata('Ecommerce', 'isRequestEcommerce');
+        if (!$isRequestEcommerce) {
             return $existingType;
         }
 
-        if ($goalManager->isGoalAnOrder()) {
+        $isGoalAnOrder = $visitor->visitProperties->getRequestMetadata('Ecommerce', 'isGoalAnOrder');
+        if ($isGoalAnOrder) {
             return self::TYPE_BUYER_ORDERED;
         }
 
diff --git a/plugins/CoreHome/Columns/VisitTotalTime.php b/plugins/CoreHome/Columns/VisitTotalTime.php
index 5975d6c6f9a86c17bb760508095bbbbdb01932d2..22afceb6a59de61efc25027dc9fdeadc33c24305 100644
--- a/plugins/CoreHome/Columns/VisitTotalTime.php
+++ b/plugins/CoreHome/Columns/VisitTotalTime.php
@@ -72,14 +72,12 @@ class VisitTotalTime extends VisitDimension
             return false;
         }
 
-        $goalManager = new GoalManager($request);
-
         $totalTime = $visitor->getVisitorColumn('visit_total_time');
 
         // If a pageview and goal conversion in the same second, with previously a goal conversion recorded
         // the request would not "update" the row since all values are the same as previous
         // therefore the request below throws exception, instead we make sure the UPDATE will affect the row
-        $totalTime = $totalTime + $goalManager->idGoal;
+        $totalTime = $totalTime + $request->getParam('idgoal');
         // +2 to offset idgoal=-1 and idgoal=0
         $totalTime = $totalTime + 2;
 
diff --git a/plugins/Ecommerce/Tracker/EcommerceRequestProcessor.php b/plugins/Ecommerce/Tracker/EcommerceRequestProcessor.php
index a072a46cf285dc9246a2e7d6d47c4a32d8d027d1..fd701207d913417ae44064cb434e25691dc57b0d 100644
--- a/plugins/Ecommerce/Tracker/EcommerceRequestProcessor.php
+++ b/plugins/Ecommerce/Tracker/EcommerceRequestProcessor.php
@@ -16,23 +16,71 @@ use Piwik\Tracker\Visit\VisitProperties;
 /**
  * Handles ecommerce tracking requests.
  *
- * Defines no new request metadata.
+ * ## Request Metadata
+ *
+ * This processor defines the following request metadata under the **Ecommerce**
+ * plugin:
+ *
+ * * **isRequestEcommerce**: If `true`, the request is for an ecommerce goal conversion.
+ *
+ *                           Set in `processRequestParams()`.
+ *
+ * * **isGoalAnOrder**: If `true` the request is tracking an ecommerce order.
+ *
+ *                      Set in `processRequestParams()`.
  */
 class EcommerceRequestProcessor extends RequestProcessor
 {
+    /**
+     * @var GoalManager
+     */
+    public $goalManager = null;
+
+    public function __construct(GoalManager $goalManager)
+    {
+        $this->goalManager = $goalManager;
+    }
+
     public function processRequestParams(VisitProperties $visitProperties, Request $request)
     {
-        $goalManager = new GoalManager($request); // TODO: GoalManager should be stateless and stored in DI.
+        $isGoalAnOrder = $this->isRequestForAnOrder($request);
+        $visitProperties->setRequestMetadata('Ecommerce', 'isGoalAnOrder', $isGoalAnOrder);
 
-        if ($goalManager->requestIsEcommerce) {
-            $visitProperties->setRequestMetadata('Goals', 'someGoalsConverted', true);
+        $isRequestEcommerce = $this->isRequestEcommerce($request);
+        $visitProperties->setRequestMetadata('Ecommerce', 'isRequestEcommerce', $isRequestEcommerce);
 
+        if ($isRequestEcommerce) {
             // Mark the visit as Converted only if it is an order (not for a Cart update)
-            if ($goalManager->isGoalAnOrder()) {
+            $idGoal = GoalManager::IDGOAL_CART;
+            if ($isGoalAnOrder) {
+                $idGoal = GoalManager::IDGOAL_ORDER;
                 $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', true);
             }
 
+            $visitProperties->setRequestMetadata('Goals', 'goalsConverted', array(array('idgoal' => $idGoal)));
+
             $visitProperties->setRequestMetadata('Actions', 'action', null); // don't track actions when tracking ecommerce orders
         }
     }
-}
\ No newline at end of file
+
+    public function afterRequestProcessed(VisitProperties $visitProperties, Request $request)
+    {
+        $goalsConverted = $visitProperties->getRequestMetadata('Goals', 'goalsConverted');
+        if (!empty($goalsConverted)) {
+            $isThereExistingCartInVisit = $this->goalManager->detectIsThereExistingCartInVisit($visitProperties->visitorInfo);
+            $visitProperties->setRequestMetadata('Goals', 'isThereExistingCartInVisit', $isThereExistingCartInVisit);
+        }
+    }
+
+    private function isRequestForAnOrder(Request $request)
+    {
+        $orderId = $request->getParam('ec_id');
+        return !empty($orderId);
+    }
+
+    private function isRequestEcommerce(Request $request)
+    {
+        $idGoal = $request->getParam('idgoal');
+        return 0 == $idGoal;
+    }
+}
diff --git a/plugins/Goals/Tracker/GoalsRequestProcessor.php b/plugins/Goals/Tracker/GoalsRequestProcessor.php
index 30439e02a5f3f863abe7f935e4584e93188a807e..e4ab93b1e9be84c41d05626a692c8904dc1e4c79 100644
--- a/plugins/Goals/Tracker/GoalsRequestProcessor.php
+++ b/plugins/Goals/Tracker/GoalsRequestProcessor.php
@@ -23,16 +23,17 @@ use Piwik\Tracker\Visit\VisitProperties;
  * This processor defines the following request metadata under the **Goals**
  * plugin:
  *
- * * **someGoalsConverted**: If `true`, the request triggers one or more conversions that will
- *                           be recorded.
+ * * **goalsConverted**: The array of goals that were converted by this request. Each element
+ *                       will be an array of goal column value pairs. The ecommerce goal will
+ *                       only have the idgoal column set.
  *
- *                           Set in `processRequestParams()`.
+ *                       Set in `processRequestParams()`.
  *
- *                           Plugins can set this to false to skip conversion recording.
+ *                       Plugins can set this to empty to skip conversion recording.
  *
  * * **visitIsConverted**: If `true`, the current visit should be marked as "converted". Note:
  *                         some goal conversions (ie, ecommerce) do not mark the visit as
- *                         "converted", so it is possible for someGoalsConverted to be `true`
+ *                         "converted", so it is possible for goalsConverted to be non-empty
  *                         while visitIsConverted is `false`.
  *
  *                         Set in `processRequestParams()`.
@@ -40,28 +41,35 @@ use Piwik\Tracker\Visit\VisitProperties;
 class GoalsRequestProcessor extends RequestProcessor
 {
     /**
-     * TODO: GoalManager should be stateless and stored in DI.
-     *
      * @var GoalManager
      */
-    public static $goalManager = null;
+    public $goalManager = null;
+
+    public function __construct(GoalManager $goalManager)
+    {
+        $this->goalManager = $goalManager;
+    }
 
     public function processRequestParams(VisitProperties $visitProperties, Request $request)
     {
-        self::$goalManager = new GoalManager($request);
+        $this->goalManager = new GoalManager();
 
-        if (self::$goalManager->isManualGoalConversion()) {
+        if ($this->isManualGoalConversion($request)) {
             // this request is from the JS call to piwikTracker.trackGoal()
-            $someGoalsConverted = self::$goalManager->detectGoalId($request->getIdSite());
+            $goal = $this->goalManager->detectGoalId($request->getIdSite(), $request);
+
+            $visitIsConverted = !empty($goal);
+            $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', $visitIsConverted);
 
-            $visitProperties->setRequestMetadata('Goals', 'someGoalsConverted', $someGoalsConverted);
-            $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', $someGoalsConverted);
+            $existingConvertedGoals = $visitProperties->getRequestMetadata('Goals', 'goalsConverted') ?: array();
+            $visitProperties->setRequestMetadata('Goals', 'goalsConverted', array_merge($existingConvertedGoals, array($goal)));
 
             $visitProperties->setRequestMetadata('Actions', 'action', null); // don't track actions when doing manual goal conversions
 
             // if we find a idgoal in the URL, but then the goal is not valid, this is most likely a fake request
-            if (!$someGoalsConverted) {
-                Common::printDebug('Invalid goal tracking request for goal id = ' . self::$goalManager->idGoal);
+            if (!$visitIsConverted) {
+                $idGoal = $request->getParam('idgoal');
+                Common::printDebug('Invalid goal tracking request for goal id = ' . $idGoal);
                 return true;
             }
         }
@@ -71,24 +79,22 @@ class GoalsRequestProcessor extends RequestProcessor
 
     public function afterRequestProcessed(VisitProperties $visitProperties, Request $request)
     {
-        $someGoalsConverted = $visitProperties->getRequestMetadata('Goals', 'someGoalsConverted');
+        $goalsConverted = $visitProperties->getRequestMetadata('Goals', 'goalsConverted');
 
         /** @var Action $action */
         $action = $visitProperties->getRequestMetadata('Actions', 'action');
 
         // if the visit hasn't already been converted another way (ie, manual goal conversion or ecommerce conversion,
         // try to convert based on the action)
-        if (!$someGoalsConverted
+        if (empty($goalsConverted)
             && $action
         ) {
-            $someGoalsConverted = self::$goalManager->detectGoalsMatchingUrl($request->getIdSite(), $action);
+            $goalsConverted = $this->goalManager->detectGoalsMatchingUrl($request->getIdSite(), $action);
 
-            $visitProperties->setRequestMetadata('Goals', 'someGoalsConverted', $someGoalsConverted);
-            $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', $someGoalsConverted);
-        }
+            $existingGoalsConverted = $visitProperties->getRequestMetadata('Goals', 'goalsConverted') ?: array();
+            $visitProperties->setRequestMetadata('Goals', 'goalsConverted', array_merge($existingGoalsConverted, $goalsConverted));
 
-        if ($someGoalsConverted) {
-            self::$goalManager->detectIsThereExistingCartInVisit($visitProperties->visitorInfo);
+            $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', !empty($goalsConverted));
         }
 
         // There is an edge case when:
@@ -97,25 +103,32 @@ class GoalsRequestProcessor extends RequestProcessor
         //   because the UPDATE didn't affect any rows (one row was found, but not updated since no field changed)
         // - the exception is caught here and will result in a new visit incorrectly
         // In this case, we cancel the current conversion to be recorded:
-        $isManualGoalConversion = self::$goalManager->isManualGoalConversion();
-        $requestIsEcommerce = self::$goalManager->requestIsEcommerce;
+        $isManualGoalConversion = $this->isManualGoalConversion($request);
+        $requestIsEcommerce = $visitProperties->getRequestMetadata('Goals', 'isRequestEcommerce');
         $visitorNotFoundInDb = $visitProperties->getRequestMetadata('CoreHome', 'visitorNotFoundInDb');
 
         if ($visitorNotFoundInDb
             && ($isManualGoalConversion
                 || $requestIsEcommerce)
         ) {
-            $visitProperties->setRequestMetadata('Goals', 'someGoalsConverted', false);
+            $visitProperties->setRequestMetadata('Goals', 'goalsConverted', array());
             $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', false);
         }
 
     }
 
-    public function recordLogs(VisitProperties $visitProperties)
+    public function recordLogs(VisitProperties $visitProperties, Request $request)
     {
         // record the goals if there were conversions in this request (even if the visit itself was not converted)
-        if ($visitProperties->getRequestMetadata('Goals', 'someGoalsConverted')) {
-            self::$goalManager->recordGoals($visitProperties);
+        $goalsConverted = $visitProperties->getRequestMetadata('Goals', 'goalsConverted');
+        if (!empty($goalsConverted)) {
+            $this->goalManager->recordGoals($visitProperties, $request);
         }
     }
+
+    private function isManualGoalConversion(Request $request)
+    {
+        $idGoal = $request->getParam('idgoal');
+        return $idGoal > 0;
+    }
 }
diff --git a/plugins/Heartbeat/Tracker/PingRequestProcessor.php b/plugins/Heartbeat/Tracker/PingRequestProcessor.php
index 80e9759df851f99d631bef0c6e513ecab815212f..99c22bfc77756101962c34ea91a290c092838dc2 100644
--- a/plugins/Heartbeat/Tracker/PingRequestProcessor.php
+++ b/plugins/Heartbeat/Tracker/PingRequestProcessor.php
@@ -25,7 +25,7 @@ class PingRequestProcessor extends RequestProcessor
             // on a ping request that is received before the standard visit length, we just update the visit time w/o adding a new action
             Common::printDebug("-> ping=1 request: we do not track a new action nor a new visit nor any goal.");
             $visitProperties->setRequestMetadata('Actions', 'action', null);
-            $visitProperties->setRequestMetadata('Goals', 'someGoalsConverted', false);
+            $visitProperties->setRequestMetadata('Goals', 'goalsConverted', array());
             $visitProperties->setRequestMetadata('Goals', 'visitIsConverted', false);
 
             // When a ping request is received more than 30 min after the last request/ping,