Magento – Paypal orders being cancelled randomly

ipnmagento-1.9payment-methodspaypalsales-order

I have noticed Paypal orders on my site at random get cancelled even though the customer paid and completed the transaction. Payment information gets populated and I can see the ipn registered message too.

  • 2 Apr 2017 09:24:21|Canceled
  • Customer Notification Not Applicable
  • 2 Apr 2017 08:44:17|Pending
  • Customer Notification Not Applicable
  • IPN "Completed". Registered notification about captured amount of £30.95. Transaction ID: ############
    2 Apr 2017 08:43:23|Pending Payment
  • Customer Not Notified

This excerpt of the comments history shows 08:43 the order was in pending payment status. Then 8.44 ipn confirmed capture of payment. Making the order pending. I have written code to stop auto-invoice.

But, 40 Minutes only after was cancelled. Customer hasn't cancelled anything payment is there in paypal. Neither have we.
Why would the system be doing this ?

Update:

Here is another example, whereby it got cancelled 2 minutes and 52 seconds later.

8 Dec 2017 00:00:51|Canceled Customer Notification Not Applicable
7 Dec 2017 23:57:59|Pending Customer Notification Not Applicable IPN "Completed". Registered notification about captured amount of £xx.xx. Transaction ID: "xxxxxxxxxxx".
7 Dec 2017 23:57:59|Pending Customer Notified Notified Customer 7 Dec 2017 23:56:56|Pending Payment Customer Not Notified

My code to stop the auto invoicing is as follows:

config.xml:

    <?xml version="1.0"?>
<config>
<modules>
    <Wfh_DisableAutoInvoice>
        <version>0.1.0</version>
    </Wfh_DisableAutoInvoice>
</modules>
<global>
    <models>
        <disableautoinvoice>
            <class>Wfh_DisableAutoInvoice_Model</class>
            <resourceModel>disableautoinvoice_mysql4</resourceModel>
        </disableautoinvoice>
        <sales>
            <rewrite>
        <order_payment>Wfh_DisableAutoInvoice_Model_Sales_Order_Payment</order_payment>
            </rewrite>
        </sales>
    </models>
</global>
</config> 

My payment.php

<?php
    class Wfh_DisableAutoInvoice_Model_Sales_Order_Payment extends Mage_Sales_Model_Order_Payment
{   

/**
 * Authorize or authorize and capture payment on gateway, if applicable
 * This method is supposed to be called only when order is placed
 *
 * @return Mage_Sales_Model_Order_Payment
 */
public function place()
{
    Mage::dispatchEvent('sales_order_payment_place_start', array('payment' => $this));
    $order = $this->getOrder();

    $this->setAmountOrdered($order->getTotalDue());
    $this->setBaseAmountOrdered($order->getBaseTotalDue());
    $this->setShippingAmount($order->getShippingAmount());
    $this->setBaseShippingAmount($order->getBaseShippingAmount());

    $methodInstance = $this->getMethodInstance();
    $methodInstance->setStore($order->getStoreId());
    $orderState = Mage_Sales_Model_Order::STATE_NEW;
    $stateObject = new Varien_Object();

    /**
     * Do order payment validation on payment method level
     */
    $methodInstance->validate();
    $action = $methodInstance->getConfigPaymentAction();
    if ($action) {
        if ($methodInstance->isInitializeNeeded()) {
            /**
             * For method initialization we have to use original config value for payment action
             */
            $methodInstance->initialize($methodInstance->getConfigData('payment_action'), $stateObject);
        } else {
            $orderState = Mage_Sales_Model_Order::STATE_NEW;
            switch ($action) {
                case Mage_Payment_Model_Method_Abstract::ACTION_ORDER:
                    $this->_order($order->getBaseTotalDue());
                    break;
                case Mage_Payment_Model_Method_Abstract::ACTION_AUTHORIZE:
                    $this->_authorize(true, $order->getBaseTotalDue()); // base amount will be set inside
                    $this->setAmountAuthorized($order->getTotalDue());
                    break;
                case Mage_Payment_Model_Method_Abstract::ACTION_AUTHORIZE_CAPTURE:
                    $this->setAmountAuthorized($order->getTotalDue());
                    $this->setBaseAmountAuthorized($order->getBaseTotalDue());
                    $this->capture(null);
                    break;
                default:
                    break;
            }
        }
    }

    $this->_createBillingAgreement();

    $orderIsNotified = null;
    if ($stateObject->getState() && $stateObject->getStatus()) {
        $orderState      = $stateObject->getState();
        $orderStatus     = $stateObject->getStatus();
        $orderIsNotified = $stateObject->getIsNotified();
    } else {
        $orderStatus = $methodInstance->getConfigData('order_status');
        if (!$orderStatus) {
            $orderStatus = $order->getConfig()->getStateDefaultStatus($orderState);
        } else {
            // check if $orderStatus has assigned a state
            $states = $order->getConfig()->getStatusStates($orderStatus);
            if (count($states) == 0) {
                $orderStatus = $order->getConfig()->getStateDefaultStatus($orderState);
            }
        }
    }
    $isCustomerNotified = (null !== $orderIsNotified) ? $orderIsNotified : $order->getCustomerNoteNotify();
    $message = $order->getCustomerNote();

    // add message if order was put into review during authorization or capture
    if ($order->getState() == Mage_Sales_Model_Order::STATE_PAYMENT_REVIEW) {
        if ($message) {
            $order->addStatusToHistory($order->getStatus(), $message, $isCustomerNotified);
        }
    } elseif ($order->getState() && ($orderStatus !== $order->getStatus() || $message)) {
        // add message to history if order state already declared
        $order->setState($orderState, $orderStatus, $message, $isCustomerNotified);
    } elseif (($order->getState() != $orderState) || ($order->getStatus() != $orderStatus) || $message) {
        // set order state
        $order->setState($orderState, $orderStatus, $message, $isCustomerNotified);
    }

    Mage::dispatchEvent('sales_order_payment_place_end', array('payment' => $this));

    return $this;
}


/**
 * Capture the payment online
 * Does not create invoice automatically for order
 * Updates transactions hierarchy, if required
 * Updates payment totals, updates order status and adds proper comments
 *
 * TODO: eliminate logic duplication with registerCaptureNotification()
 *
 * @return Mage_Sales_Model_Order_Payment
 * @throws Mage_Core_Exception
 */
public function capture($invoice)
{

    $invoice = null;
    $order = $this->getOrder();

    $amountToCapture = $this->_formatAmount($order->getBaseGrandTotal());

    $paidWorkaround = (float)$amountToCapture;
    $this->_isCaptureFinal($paidWorkaround);

    $this->_generateTransactionId(
        Mage_Sales_Model_Order_Payment_Transaction::TYPE_CAPTURE,
        $this->getAuthorizationTransaction()
    );

    Mage::dispatchEvent('sales_order_payment_capture', array('payment' => $this, 'invoice' => $invoice));

    $status = true;
    if (!$this->getIsTransactionPending()) {
        // attempt to capture: this can trigger "is_transaction_pending"
        $this->getMethodInstance()->setStore($order->getStoreId())->capture($this, $amountToCapture);

        $transaction = $this->_addTransaction(
            Mage_Sales_Model_Order_Payment_Transaction::TYPE_CAPTURE,
            $invoice,
            true
        );

        if ($this->getIsTransactionPending()) {
            $message = Mage::helper('sales')->__('Capturing amount of %s is pending approval on gateway.', $this->_formatPrice($amountToCapture));
            $state = Mage_Sales_Model_Order::STATE_PAYMENT_REVIEW;
            if ($this->getIsFraudDetected()) {
                $status = Mage_Sales_Model_Order::STATUS_FRAUD;
            }
        } else { // normal online capture: invoice is marked as "paid"
            $message = Mage::helper('sales')->__('Captured amount of %s online.', $this->_formatPrice($amountToCapture));
            $state = Mage_Sales_Model_Order::STATE_NEW;
            $this->_updateTotals(array('base_amount_paid_online' => $amountToCapture));
        }
        if ($order->isNominal()) {
            $message = $this->_prependMessage(Mage::helper('sales')->__('Nominal order registered.'));
        } else {
            $message = $this->_prependMessage($message);
            $message = $this->_appendTransactionToMessage($transaction, $message);
        }
        $order->setState($state, $status, $message);

        return $this;
    }
    Mage::throwException(
        Mage::helper('sales')->__('The transaction "%s" cannot be captured yet.', $amountToCapture)
    );
}

/**
 * Process a capture notification from a payment gateway for specified amount
 * Does not create an invoice automatically if the amount if the amount covers the order base grand total completely
 * Updates transactions hierarchy, if required
 * Prevents transaction double processing
 * Updates payment totals, updates order status and adds proper comments
 *
 * TODO: eliminate logic duplication with capture()
 *
 * @param float $amount
 * @param bool $skipFraudDetection
 * @return Mage_Sales_Model_Order_Payment
 */
public function registerCaptureNotification($amount, $skipFraudDetection = false)
{

    $this->_generateTransactionId(Mage_Sales_Model_Order_Payment_Transaction::TYPE_CAPTURE,
        $this->getAuthorizationTransaction()
    );

    $order   = $this->getOrder();
    $amount  = (float)$amount;
    $invoice = null;

    // register new capture
    if (!$invoice) {
        $isSameCurrency = $this->_isSameCurrency();
        if (!$isSameCurrency && !$this->_isCaptureFinal($amount)) {
            if (!$skipFraudDetection || !$isSameCurrency) {
                $this->setIsFraudDetected(true);
            }
            $this->_updateTotals(array('base_amount_paid_online' => $amount));
        }
    }

    $status = true;
    $letsEmail = false;
    if ($this->getIsTransactionPending()) {
        $message = Mage::helper('sales')->__('Capturing amount of %s is pending approval on gateway.', $this->_formatPrice($amount));
        $state = Mage_Sales_Model_Order::STATE_PAYMENT_REVIEW;
        $letsEmail = true;
        if ($this->getIsFraudDetected()) {
            $message = Mage::helper('sales')->__('Order is suspended as its capture amount %s is suspected to be fraudulent.', $this->_formatPrice($amount, $this->getCurrencyCode()));
            $status = Mage_Sales_Model_Order::STATUS_FRAUD;
            $letsEmail = false;
        }
    } else {
        $message = Mage::helper('sales')->__('Registered notification about captured amount of %s.', $this->_formatPrice($amount));
        $state = Mage_Sales_Model_Order::STATE_NEW;
        $letsEmail = true;
        if ($this->getIsFraudDetected()) {
            $state = Mage_Sales_Model_Order::STATE_PAYMENT_REVIEW;
            $message = Mage::helper('sales')->__('Order is suspended as its capture amount %s is suspected to be fraudulent.', $this->_formatPrice($amount, $this->getCurrencyCode()));
            $status = Mage_Sales_Model_Order::STATUS_FRAUD;
            $letsEmail = false;
        }
    }

    $transaction = $this->_addTransaction(Mage_Sales_Model_Order_Payment_Transaction::TYPE_CAPTURE, $invoice, true);
    $message = $this->_prependMessage($message);
    $message = $this->_appendTransactionToMessage($transaction, $message);
    $order->setState($state, $status, $message);
    if ($letsEmail === true && !$order->getEmailSent()){
         $order->queueNewOrderEmail()->addStatusHistoryComment(
                Mage::helper('paypal')->__('Notified Customer')
            )
            ->setIsCustomerNotified(true)
            ->save();
    }

    return $this;
}
}
?>

Best Answer

We have this issue appear occasionally

Having placed debug trace on the paypal cancel action, I got the following information:

enter image description here

which clearly shows the cancel action was called via the Mage_Paypal_StandardController

so, this is an external call, to cancel the order.

Locating the actual webserver logs, I can then see the call did indeed come from external (paypal)

enter image description here

The interesting bit there is that it was a 302 (redirect)

If I load that referrer URL, I get a paypal display that the session has timed out.

Also, I could determine this was from an iPad. I don't have enough of these to determine other devices, so that could mean squat.

So, given from what I see, I am making a calculated assumption that this cancel action is initiated from paypal, when/if teh user session on teh paypal side has times out, or had issues keeping session during the transaction period.

Paypal then actions a cancel request ?

enter image description here

UPDATE: Setting the trace:

As per comments request, here is how I placed the trace. When the order is cancelled, a comment is placed in the order history/comments concerning that cancellation. This is where I injected my trace code, thus tracing back the code path to having reached that comment insertion.

It can just as well be pushed to a log file.

I created a small module for this:

I simply did a rewrite of the core sales order module (be aware of creating a rewrite conflict if some other 3rd party module also rewrites this class. In my case this was not an issue:

In module config.xml

<global>
        <models>
            <enjo_sales>
                <class>Enjo_Sales_Model</class>
            </enjo_sales>
            <sales>
                <rewrite>
                    <order>Enjo_Sales_Model_Order</order>
                </rewrite>
            </sales>
        </models>
    </global>

and then in the model class:

<?php

class Enjo_Sales_Model_Order extends Mage_Sales_Model_Order
{
    protected function _setState($state, $status = false, $comment = '',
                                 $isCustomerNotified = null, $shouldProtectState = false)
    {
        if ($this->getState() != $state && $state == self::STATE_CANCELED) {
            $comment .= Varien_Debug::backtrace(true,true, true);
        }
        return parent::_setState($state, $status, $comment, $isCustomerNotified, $shouldProtectState);

    }
}
Related Topic