Skip to content

Instantly share code, notes, and snippets.

@psrpinto
Last active December 1, 2016 08:47
Show Gist options
  • Save psrpinto/4600319 to your computer and use it in GitHub Desktop.
Save psrpinto/4600319 to your computer and use it in GitHub Desktop.
A plug-and-play handler for Paypal's Instant Payment Notification (IPN) system to be used with JMSPaymentCoreBundle and JMSPaymentPaypalBundle.

IPN handler

A plug-and-play handler for Paypal's Instant Payment Notification system to be used with JMSPaymentCoreBundle and JMSPaymentPaypalBundle. Includes support for Refunds. It's assumed that you are using the JMSPayment bundles to handle the checkout workflow as described here.

It's as simple as:

<?php namespace Acme\SomeBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller

class OrdersController extends Controller
{ 
    public function paypalIpnAction ()
    {
        $notification = $this->getRequest()->request->all();
        $ipnHandler = $this->get('acme.paypal_ipn.handler');
        
        // This will trigger calls to the correct JMSPaymentCore methods 
        // according to the type of notification. After this call, the 
        // JMSPaymentCore DB tables will have been updated.
        $ipnHandler->process($notification);
    }
}

IPN notifications come with a txn_id that is used to retrieve the original PaymentInstruction that was created during your checkout worflow. Your models will then be updated according to the type of notification (Completed or Refunded):

  • Completed: the Payment associated with the PaymentInstruction will be set as Deposited and it's funds will be approved
  • Refunded: a new Credit will be created and it will be linked to the original Payment

Warning

Two ugly hacks are used in order to workaround the internal architecture of JMSPaymentCoreBundle. For information on why this is needed see issue #56 of JMSPaymentPaypalBundle.

  1. The value of payment_system_name in the PaymentInstruction is temporarily changed to paypal_ipn. The original value is re-set once the Notification has been handled.
  2. When handling a Refunded notification, the state of the original Payment is set to Approved. The original value is re-set once the Notification has been handled.

Use at your own risk.

Setup

This handler is meant to be used with JMSPaymentPaypalBundle and JMSPaymentCoreBundle and it requires those bundles to be installed and configured.

  1. Install SensioBuzzBundle
  2. Clone this Gist: git clone https://gist.github.com/4600319.git /src/Acme/SomeBundle/Services/IpnHandler
    OR
    Drop the files below directly into your application
  3. Set the correct namespace in the cloned files
  4. Configure services (remember to set the paths accordingly)
# Acme/SomeBundle/Resources/config/services.yml
services:
  ...
  
  acme.paypal_ipn.handler:
    class: Acme\SomeBundle\Services\IpnHandler\IpnHandler
    arguments:
      doctrine: "@doctrine"
      browser: "@buzz"
      ppc: "@payment.plugin_controller"
      is_sandbox: %payment.paypal.debug%

  acme.paypal_ipn.plugin:
    class: Acme\SomeBundle\Services\IpnHandler\IpnPlugin
    tags:
      - { name: payment.plugin }

Validation

If you need to perform some sort of validation, you can pass a calback to the process method. This callback takes as parameters the PaymentInstruction and the array of parameters that were POSTed by Paypal. Throwing an exception from this function will prevent the transaction from being "applied" and nothing will be changed in the DB.

<?php
$ipnHandler->process($notification, function ($instruction, $notification) {
    // perform validation
    $currency = $instruction->getCurrency();
    if ($notification['mc_currency'] !== $currency) {
        throw new \Exception("Currency mismatch: ".
            $notification['mc_currency']." instead of $currency");
    }
});
<?php
namespace Acme\SomeBundle\Services\IpnHandler;
use Buzz\Browser;
use JMS\Payment\CoreBundle\Model\PaymentInterface,
JMS\Payment\CoreBundle\PluginController\PluginControllerInterface,
JMS\Payment\CoreBundle\PluginController\Result;
/**
* A plug-and-play handler for Paypal's Instant Payment Notification system,
* to be used with JMSPaymentCoreBundle and JMSPaymentPaypalBundle. Includes
* support for Refunds.
*
* For requirements and a usage example see: https://gist.github.com/4600319
*
* @author Paulo Rodrigues Pinto <https://github.com/regularjack>
*/
class IpnHandler
{
private $doctrine;
private $ppc;
private $browser;
private $verifyUrl;
/**
* Constructor.
*
* @param Doctrine $doctrine Doctrine instance
* @param Browser $browser Buzz instance
* @param PluginControllerInterface $ppc Payment plugin controller instance
* @param boolean $is_sandbox True if request verification should
* be done with paypal's Sandbox, false
* if not.
*/
public function __construct ($doctrine, Browser $browser,
PluginControllerInterface $ppc, $is_sandbox = true)
{
$this->doctrine = $doctrine;
$this->browser = $browser;
$this->ppc = $ppc;
// comment the following line if you want cUrl to verify SSL certificates
$this->browser->getClient()->setVerifyPeer(false);
$this->verifyUrl = $is_sandbox
? "https://www.sandbox.paypal.com/cgi-bin/webscr"
: "https://www.paypal.com/cgi-bin/webscr";
}
/**
* Process an IPN notification.
*
* The $notification array should contain all the key-value parameters POSTed
* by Paypal. From a Symfony2 Controller this array can easily be obtained:
*
* $notification = $this->getRequest()->request->all();
*
*
* An optional callback may be passed to this method so that transaction
* validation is delegated to the caller. If set, this callback is called
* immediately before the transaction is "applied". The callabck takes two
* arguments: an instance of a PaymentInstruction Entity and the same array
* that is passed to this method ($notification). Throwing an Exception from
* the callback will prevent the transaction from being "applied" and the
* exception will be re-thrown from this method.
*
* @param array $notification Key-value parameters sent by paypal
* @param callable $validate_callback Function to which validation is delegated
* @throws Exception
*/
public function process (array $notification, $validate_callback = null)
{
$txn_id = $notification['txn_id'];
$repository = $this->doctrine->getEntityManager()
->getRepository('JMS\Payment\CoreBundle\Entity\FinancialTransaction');
if (!$this->verifyIpnRequest($notification)) {
throw new \Exception('IPN request validation failed');
}
if (isset($notification['parent_txn_id'])) {
// Refund or Reversal
$transaction = $repository->findOneBy(array(
'referenceNumber' => $notification['parent_txn_id']
));
} else {
$transaction = $repository->findOneBy(array('referenceNumber' => $txn_id));
}
if (!$transaction) {
throw new \Exception("Transaction not found: $txn_id");
}
$payment = $transaction->getPayment();
if (!$payment) {
throw new \Exception("No Payment is associated with the Transaction: $txn_id");
}
$instruction = $payment->getPaymentInstruction();
// HACK
// We temporarily change the value of "payment_system_name" in the
// PaymentInstruction to 'paypal_ipn'. The original value is re-set once
// the Notification has been handled. This is used in order to workaround
// the internal architecture of JMSPaymentCoreBundle. For information on
// why this is needed see
// https://github.com/schmittjoh/JMSPaymentPaypalBundle/issues/56
$oldName = $instruction->getPaymentSystemName();
$this->setPaymentSystemName($instruction, 'paypal_ipn');
try {
if ($validate_callback && is_callable($validate_callback)) {
$validate_callback($instruction, $notification);
}
$this->applyTransaction($notification, $payment);
} catch (\Exception $e) {
// Must reset the entity manager orelse an "entity manager is closed"
// exception is thrown.
$this->doctrine->resetEntityManager();
// Re-set the original value (see hack description above)
$this->setPaymentSystemName($instruction, $oldName);
throw $e;
}
// Re-set the original value (see hack description above)
$this->setPaymentSystemName($instruction, $oldName);
}
/**
* "Apply" a transaction by delegating to the PluginController. For the moment,
* the only supported transactions are 'Completed' and 'Refunded'.
*
* The PluginController will delegate external API calls to the IpnPlugin.
*
* @param array $notification Key-value parameters sent by paypal
* @param Payment $payment Payment Entity instance
* @throws Exception If the transaction type is not supported
* @throws Exception Generic error
*/
private function applyTransaction ($notification, $payment)
{
$result = null;
$amount = 0;
$em = $this->doctrine->getEntityManager();
$repository = $em->getRepository('JMS\Payment\CoreBundle\Entity\FinancialTransaction');
if (isset($notification['mc_gross'])) {
$amount = $notification['mc_gross'];
} else {
// mc_gross_x
foreach ($notification as $key => $value) {
if (strstr($key, 'mc_gross_') !== FALSE) {
$amount += $value;
}
}
}
if ($amount === null) {
throw new \Exception('Invalid amount');
}
switch ($notification['payment_status']) {
case 'Pending':
// The payment is pending. See pending_reason for more information.
break;
case 'Completed':
// The payment has been completed and the funds have been added
// to the seller's account balance
if ($payment->getState() === PaymentInterface::STATE_NEW ||
$payment->getState() === PaymentInterface::STATE_APPROVING) {
$result = $this->ppc->approveAndDeposit($payment->getId(), $amount);
}
break;
case 'Refunded':
// The seller refunded the payment
// HACK
// The Payment must have state APPROVED in order for JMSPaymentCore
// to accept a credit. Since at this point the payment has state
// DEPOSITED, we set it to APPROVED and re-set it back after the
// credit was created.
$oldState = $payment->getState();
$this->setPaymentState($payment, PaymentInterface::STATE_APPROVED);
// When a transaction is a Refund, Paypal sends a negative amount.
// However, we want the credited amount to be a positive number.
$amount = abs($amount);
try {
$credit = $this->ppc->createDependentCredit($payment->getId(), $amount);
$result = $this->ppc->credit($credit->getId(), $amount);
} catch (Exception $e) {
$this->setPaymentState($payment, $oldState);
throw $e;
}
// set the reference number in the newly created transaction
$new_transaction = $repository->findOneBy(array('credit' => $credit));
if ($new_transaction) {
$new_transaction->setReferenceNumber($notification['txn_id']);
$em->flush($new_transaction);
}
$this->setPaymentState($payment, $oldState);
break;
default:
throw new \Exception('Unsupported Transaction: '.$notification['payment_status']);
break;
}
if ($result && $result->getStatus() !== Result::STATUS_SUCCESS) {
throw new \Exception('Transaction was not successful: '.$result->getReasonCode());
}
}
/**
* Verify an IPN request with Paypal.
*
* @param array $notification Parameters received from paypal
* @return Boolean True if validation successful, false otherwise
*/
private function verifyIpnRequest ($notification)
{
$response = $this->browser->post($this->verifyUrl, array(), array_merge(
array('cmd' => '_notify-validate'),
$notification
));
return $response->getContent() === "VERIFIED";
}
/**
* Set payment_system_name on the payment instruction associated with a
* given Payment.
*
* @param PaymentInstruction $instruction PaymentInstruction entity
* @param string $name The new value for payment_system_name
*/
private function setPaymentSystemName ($instruction, $name)
{
$em = $this->doctrine->getEntityManager();
$em->getConnection()->beginTransaction();
// We're forced to set the new value directly in the DB because the
// PaymentInstruction Entity does not define a setter for
// paymentSystemName.
$em->createQuery("
UPDATE JMS\Payment\CoreBundle\Entity\PaymentInstruction pi
SET pi.paymentSystemName = :psm
WHERE pi = :pi")
->setParameter('psm', $name)
->setParameter('pi', $instruction)
->getResult();
$em->getConnection()->commit();
$em->clear();
}
/**
* Set state on a given Payment.
*
* @param Payment $payment Payment entity
* @param integer $state New state
*/
private function setPaymentState ($payment, $state)
{
$em = $this->doctrine->getEntityManager();
$em->getConnection()->beginTransaction();
$em->createQuery("
UPDATE JMS\Payment\CoreBundle\Entity\Payment p
SET p.state = :state
WHERE p = :p")
->setParameter('state', $state)
->setParameter('p', $payment)
->getResult();
$em->getConnection()->commit();
$em->clear();
}
}
<?php
namespace Acme\SomeBundle\Services\IpnHandler;
use JMS\Payment\CoreBundle\Plugin\AbstractPlugin,
JMS\Payment\CoreBundle\Model\FinancialTransactionInterface,
JMS\Payment\CoreBundle\Plugin\PluginInterface;
/**
* Dummy Plugin for JMSPaymentCoreBundle.
* This plugin performs no interaction with the payment backend system.
*/
class IpnPlugin extends AbstractPlugin
{
public function approveAndDeposit(FinancialTransactionInterface $transaction, $retry)
{
$this->process($transaction);
}
public function credit(FinancialTransactionInterface $transaction, $retry)
{
$this->process($transaction);
}
private function process(FinancialTransactionInterface $transaction)
{
$transaction->setProcessedAmount($transaction->getRequestedAmount());
$transaction->setResponseCode(PluginInterface::RESPONSE_CODE_SUCCESS);
$transaction->setReasonCode(PluginInterface::REASON_CODE_SUCCESS);
}
public function processes ($name)
{
return 'paypal_ipn' === $name;
}
}
@pdias
Copy link

pdias commented Mar 14, 2013

Can you post a full working exemple?

(Podes colocar um exemplo a funcionar?)

Thanks,

Paulo Dias

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment