Skip to content
Snippets Groups Projects
StripeIPN.php 18.7 KiB
Newer Older
  • Learn to ignore specific revisions
  • <?php
    /*
     * @file
     * Handle Stripe Webhooks for recurring payments.
     */
    
    class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
    
      use CRM_Core_Payment_StripeIPNTrait;
    
    
      /**
       * Transaction ID is the contribution in the redirect flow and a random number in the on-site->POST flow
       * Ideally the contribution id would always be created at this point in either flow for greater consistency
       * @var
       */
      protected $transaction_id;
    
    
      // By default, always retrieve the event from stripe to ensure we are
      // not being fed garbage. However, allow an override so when we are 
      // testing, we can properly test a failed recurring contribution.
    
      /**
       * Do we send an email receipt for each contribution?
       *
       * @var int
       */
      protected $is_email_receipt = NULL;
    
    
      // Properties of the event.
    
      protected $event_type = NULL;
      protected $subscription_id = NULL;
      protected $customer_id = NULL;
      protected $charge_id = NULL;
      protected $previous_plan_id = NULL;
      protected $plan_id = NULL;
      protected $plan_amount = NULL;
      protected $frequency_interval = NULL;
      protected $frequency_unit = NULL;
      protected $plan_name = NULL;
      protected $plan_start = NULL;
    
      protected $contribution_recur_id = NULL;
      protected $event_id = NULL;
      protected $invoice_id = NULL;
      protected $receive_date = NULL;
      protected $amount = NULL;
      protected $fee = NULL;
      protected $net_amount = NULL;
    
      protected $previous_contribution = [];
    
      /**
       * CRM_Core_Payment_StripeIPN constructor.
       *
    
       * @param $ipnData
    
       * @param bool $verify
       *
       * @throws \CRM_Core_Exception
       */
    
      public function __construct($ipnData, $verify = TRUE) {
    
        $this->verify_event = $verify;
    
        $this->setInputParameters($ipnData);
    
      /**
       * Set the value of is_email_receipt to use when a new contribution is received for a recurring contribution
       * This is used for the API Stripe.Ipn function.  If not set, we respect the value set on the ContributionRecur entity.
       *
       * @param int $sendReceipt The value of is_email_receipt
       */
      public function setSendEmailReceipt($sendReceipt) {
        switch ($sendReceipt) {
          case 0:
            $this->is_email_receipt = 0;
            break;
    
          case 1:
            $this->is_email_receipt = 1;
            break;
    
          default:
            $this->is_email_receipt = 0;
        }
      }
    
      /**
       * Get the value of is_email_receipt to use when a new contribution is received for a recurring contribution
       * This is used for the API Stripe.Ipn function.  If not set, we respect the value set on the ContributionRecur entity.
       *
       * @return int
       * @throws \CiviCRM_API3_Exception
       */
      public function getSendEmailReceipt() {
        if (isset($this->is_email_receipt)) {
          return (int) $this->is_email_receipt;
        }
        if (!empty($this->contribution_recur_id)) {
          $this->is_email_receipt = civicrm_api3('ContributionRecur', 'getvalue', [
            'return' => ["is_email_receipt"],
            'id' => $this->contribution_recur_id,
          ]);
        }
        return (int) $this->is_email_receipt;
      }
    
    
      /**
       * Store input array on the class.
       * We override base because our input parameter is an object
       *
       * @param array $parameters
      */
      public function setInputParameters($parameters) {
    
        if (!is_object($parameters)) {
          $this->exception('Invalid input parameters');
    
        }
    
        // Determine the proper Stripe Processor ID so we can get the secret key
        // and initialize Stripe.
    
        $this->getPaymentProcessor();
    
    
        // Now re-retrieve the data from Stripe to ensure it's legit.
    
        \Stripe\Stripe::setApiKey($this->_paymentProcessor['user_name']);
    
    
        // Special case if this is the test webhook
        if (substr($parameters->id, -15, 15) === '_00000000000000') {
          http_response_code(200);
          $test = (boolean) $this->_paymentProcessor['is_test'] ? '(Test processor)' : '(Live processor)';
          echo "Test webhook from Stripe ({$parameters->id}) received successfully by CiviCRM {$test}.";
          exit();
        }
    
    
        if ($this->verify_event) {
          $this->_inputParameters = \Stripe\Event::retrieve($parameters->id);
        }
        else {
          $this->_inputParameters = $parameters;
        }
    
       * Get a parameter given to us by Stripe.
       *
       * @param string $name
       * @param $type
       * @param bool $abort
       *
       * @return false|int|null|string
       * @throws \CRM_Core_Exception
    
       */
      public function retrieve($name, $type, $abort = TRUE) {
    
        $className = get_class($this->_inputParameters->data->object);
    
        switch ($className) {
          case 'Stripe\Charge':
            switch ($name) {
              case 'charge_id':
                $value = $this->_inputParameters->data->object->id;
                break;
    
              case 'failure_code':
                $value = $this->_inputParameters->data->object->failure_code;
                break;
    
              case 'failure_message':
                $value = $this->_inputParameters->data->object->failure_message;
                break;
    
              case 'refunded':
                $value = $this->_inputParameters->data->object->refunded;
                break;
    
              case 'amount_refunded':
                $value = $this->_inputParameters->data->object->amount_refunded;
                break;
    
    
          case 'Stripe\Invoice':
            switch ($name) {
              case 'charge_id':
                $value = $this->_inputParameters->data->object->charge;
                break;
    
              case 'invoice_id':
                $value = $this->_inputParameters->data->object->id;
                break;
    
              case 'receive_date':
                $value = date("Y-m-d H:i:s", $this->_inputParameters->data->object->date);
                break;
    
              case 'subscription_id':
                $value = $this->_inputParameters->data->object->subscription;
                break;
    
    
          case 'Stripe\Subscription':
            switch ($name) {
              case 'frequency_interval':
                $value = $this->_inputParameters->data->object->plan->interval_count;
                break;
    
              case 'frequency_unit':
                $value = $this->_inputParameters->data->object->plan->interval;
                break;
    
              case 'plan_amount':
                $value = $this->_inputParameters->data->object->plan->amount / 100;
                break;
    
              case 'plan_id':
                $value = $this->_inputParameters->data->object->plan->id;
                break;
    
              case 'plan_name':
                $value = $this->_inputParameters->data->object->plan->name;
                break;
    
              case 'plan_start':
                $value = date("Y-m-d H:i:s", $this->_inputParameters->data->object->start);
                break;
    
              case 'subscription_id':
                $value = $this->_inputParameters->data->object->id;
                break;
    
        }
    
        // Common parameters
        switch ($name) {
          case 'customer_id':
            $value = $this->_inputParameters->data->object->customer;
    
          case 'event_type':
            $value = $this->_inputParameters->type;
            break;
    
          case 'previous_plan_id':
            if (preg_match('/\.updated$/', $this->_inputParameters->type)) {
              $value = $this->_inputParameters->data->previous_attributes->plan->id;
            }
            break;
        }
    
        $value = CRM_Utils_Type::validate($value, $type, FALSE);
        if ($abort && $value === NULL) {
    
    mattwire's avatar
    mattwire committed
          echo "Failure: Missing Parameter<p>" . CRM_Utils_Type::escape($name, 'String');
          $this->exception("Could not find an entry for $name");
    
       * @throws \CRM_Core_Exception
       * @throws \CiviCRM_API3_Exception
       */
      public function main() {
    
        // Collect and determine all data about this event.
    
        $this->event_type = $this->retrieve('event_type', 'String');
    
        $pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
    
    
        switch($this->event_type) {
          // Successful recurring payment.
          case 'invoice.payment_succeeded':
    
            if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
    
            elseif ($this->previous_contribution['trxn_id'] != $this->charge_id) {
    
              // The first contribution was completed, so create a new one.
              // api contribution repeattransaction repeats the appropriate contribution if it is given
    
              // simply the recurring contribution id. It also updates the membership for us.
              civicrm_api3('Contribution', 'repeattransaction', array(
    
                'contribution_recur_id' => $this->contribution_recur_id,
                'contribution_status_id' => 'Completed',
    
                'receive_date' => $this->receive_date,
                'trxn_id' => $this->charge_id,
                'total_amount' => $this->amount,
                'fee_amount' => $this->fee,
    
                'is_email_receipt' => $this->getSendEmailReceipt(),
    
              ));
            }
    
            // Successful charge & more to come. 
    
            civicrm_api3('ContributionRecur', 'create', array(
    
              'id' => $this->contribution_recur_id,
              'failure_count' => 0,
    
              'contribution_status_id' => 'In Progress'
    
    
          // Failed recurring payment.
          case 'invoice.payment_failed':
    
            if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
    
              // If this contribution is Pending, set it to Failed.
    
              civicrm_api3('Contribution', 'create', array(
    
                'id' => $this->previous_contribution['id'],
    
                'contribution_status_id' => "Failed",
    
                'is_email_receipt' => 0,
    
                'contribution_recur_id' => $this->contribution_recur_id,
    
                'contribution_status_id' => 'Failed',
                'receive_date' => $failDate,
    
                'total_amount' => $this->amount,
    
                'is_email_receipt' => 0,
    
              ];
              civicrm_api3('Contribution', 'repeattransaction', $contributionParams);
    
            $failureCount = civicrm_api3('ContributionRecur', 'getvalue', array(
    
             'id' => $this->contribution_recur_id,
             'return' => 'failure_count',
            ));
    
            // Change the status of the Recurring and update failed attempts.
            civicrm_api3('ContributionRecur', 'create', array(
    
              'id' => $this->contribution_recur_id,
              'contribution_status_id' => "Failed",
    
              'failure_count' => $failureCount,
              'modified_date' => $failDate,
    
          // Subscription is cancelled
    
          case 'customer.subscription.deleted':
    
            // Cancel the recurring contribution
            civicrm_api3('ContributionRecur', 'cancel', array(
    
              'id' => $this->contribution_recur_id,
            ));
    
          // One-time donation and per invoice payment.
          case 'charge.failed':
            $chargeId = $this->retrieve('charge_id', 'String');
            $failureCode = $this->retrieve('failure_code', 'String');
            $failureMessage = $this->retrieve('failure_message', 'String');
            $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $chargeId]);
            $failedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');
            if ($contribution['contribution_status_id'] != $failedStatusId) {
              $note = $failureCode . ' : ' . $failureMessage;
              civicrm_api3('Contribution', 'create', ['id' => $contribution['id'], 'contribution_status_id' => $failedStatusId, 'note' => $note]);
            }
    
          case 'charge.refunded':
            $chargeId = $this->retrieve('charge_id', 'String');
            $refunded = $this->retrieve('refunded', 'Boolean');
            $refundAmount = $this->retrieve('amount_refunded', 'Integer');
            $contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $chargeId]);
            if ($refunded) {
              $refundedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Refunded');
              if ($contribution['contribution_status_id'] != $refundedStatusId) {
                civicrm_api3('Contribution', 'create', [
                  'id' => $contribution['id'],
                  'contribution_status_id' => $refundedStatusId
                ]);
              }
              elseif ($refundAmount > 0) {
                $partiallyRefundedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Partially Refunded');
                if ($contribution['contribution_status_id'] != $partiallyRefundedStatusId) {
                  civicrm_api3('Contribution', 'create', [
                    'id' => $contribution['id'],
                    'contribution_status_id' => $refundedStatusId
                  ]);
                }
              }
            }
    
            if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
    
          case 'customer.subscription.updated':
    
           if (empty($this->previous_plan_id)) {
             // Not a plan change...don't care.
    
           civicrm_api3('ContributionRecur', 'create', [
             'id' => $this->contribution_recur_id,
    
              'amount' => $this->plan_amount,
              'auto_renew' => 1,
              'created_date' => $this->plan_start,
              'frequency_unit' => $this->frequency_unit,
              'frequency_interval' => $this->frequency_interval,
    
           civicrm_api3('Contribution', 'create', [
    
              'id' => $this->previous_contribution['id'],
    
              'total_amount' => $this->plan_amount,
              'contribution_recur_id' => $this->contribution_recur_id,
           ]);
    
        }
        // Unhandled event type.
    
       * Complete a pending contribution and update associated entities (recur/membership)
       *
       * @throws \CiviCRM_API3_Exception
       */
      public function completeContribution() {
        // Update the contribution to include the fee.
        civicrm_api3('Contribution', 'create', array(
    
          'id' => $this->previous_contribution['id'],
    
          'total_amount' => $this->amount,
          'fee_amount' => $this->fee,
          'net_amount' => $this->net_amount,
        ));
        // The last one was not completed, so complete it.
        civicrm_api3('Contribution', 'completetransaction', array(
    
          'id' => $this->previous_contribution['id'],
    
          'trxn_date' => $this->receive_date,
          'trxn_id' => $this->charge_id,
          'total_amount' => $this->amount,
          'net_amount' => $this->net_amount,
          'fee_amount' => $this->fee,
          'payment_processor_id' => $this->_paymentProcessor['id'],
    
          'is_email_receipt' => $this->getSendEmailReceipt(),
    
       * Gather and set info as class properties.
       *
       * Given the data passed to us via the Stripe Event, try to determine
       * as much as we can about this event and set that information as 
       * properties to be used later.
    
       *
       * @throws \CRM_Core_Exception
    
      public function setInfo() {
    
        $abort = FALSE;
        $this->customer_id = $this->retrieve('customer_id', 'String');
        $this->subscription_id = $this->retrieve('subscription_id', 'String', $abort);
        $this->invoice_id = $this->retrieve('invoice_id', 'String', $abort);
        $this->receive_date = $this->retrieve('receive_date', 'String', $abort);
        $this->charge_id = $this->retrieve('charge_id', 'String', $abort);
        $this->plan_id = $this->retrieve('plan_id', 'String', $abort);
        $this->previous_plan_id = $this->retrieve('previous_plan_id', 'String', $abort);
        $this->plan_amount = $this->retrieve('plan_amount', 'String', $abort);
        $this->frequency_interval = $this->retrieve('frequency_interval', 'String', $abort);
        $this->frequency_unit = $this->retrieve('frequency_unit', 'String', $abort);
        $this->plan_name = $this->retrieve('plan_name', 'String', $abort);
        $this->plan_start = $this->retrieve('plan_start', 'String', $abort);
    
        // Gather info about the amount and fee.
        // Get the Stripe charge object if one exists. Null charge still needs processing.
    
          try {
            $charge = \Stripe\Charge::retrieve($this->charge_id);
            $balance_transaction_id = $charge->balance_transaction;
            // If the transaction is declined, there won't be a balance_transaction_id.
            if ($balance_transaction_id) {
              $balance_transaction = \Stripe\BalanceTransaction::retrieve($balance_transaction_id);
              $this->amount = $charge->amount / 100;
              $this->fee = $balance_transaction->fee / 100;
            }
            else {
              $this->amount = 0;
              $this->fee = 0;
            }
          }
          catch(Exception $e) {
    
    mattwire's avatar
    mattwire committed
            $this->exception('Cannot get contribution amounts');
    
          }
        } else {
          // The customer had a credit on their subscription from a downgrade or gift card.
          $this->amount = 0;
          $this->fee = 0;
        }
    
        $this->net_amount = $this->amount - $this->fee;
    
    
        // Additional processing of values is only relevant if there is a subscription id.
    
        if ($this->subscription_id) {
    
          // Get the recurring contribution record associated with the Stripe subscription.
    
          try {
            $contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $this->subscription_id]);
            $this->contribution_recur_id = $contributionRecur['id'];
    
          }
          catch (Exception $e) {
            $this->exception('Cannot find recurring contribution for subscription ID: ' . $this->subscription_id . '. ' . $e->getMessage());
          }
        }
        // If a recurring contribution has been found, get the most recent contribution belonging to it.
        if ($this->contribution_recur_id) {
          try {
            // Same approach as api repeattransaction.
    
            $contribution = civicrm_api3('contribution', 'getsingle', array(
    
              'return' => array('id', 'contribution_status_id', 'total_amount', 'trxn_id'),
    
              'contribution_recur_id' => $this->contribution_recur_id,
    
              'contribution_test' => isset($this->_paymentProcessor['is_test']) && $this->_paymentProcessor['is_test'] ? 1 : 0,
    
              'options' => array('limit' => 1, 'sort' => 'id DESC'),
    
            $this->previous_contribution = $contribution;
    
            $this->exception('Cannot find any contributions with recurring contribution ID: ' . $this->contribution_recur_id . '. ' . $e->getMessage());
    
    mattwire's avatar
    mattwire committed
      public function exception($message) {
    
        $errorMessage = 'StripeIPN Exception: Event: ' . $this->event_type . ' Error: ' . $message;
        Civi::log()->debug($errorMessage);
    
    mattwire's avatar
    mattwire committed
        http_response_code(400);
        exit(1);
    
    mattwire's avatar
    mattwire committed
      }