Skip to content
Snippets Groups Projects
StripeIPN.php 18.2 KiB
Newer Older
  • Learn to ignore specific revisions
  • /**
     * https://civicrm.org/licensing
    
    /**
     * Class CRM_Core_Payment_StripeIPN
     */
    
    class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
    
      /**
       * @var \CRM_Core_Payment_Stripe Payment processor
       */
      protected $_paymentProcessor;
    
    
      /**
       * 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;
    
      // Derived properties.
    
    
      /**
       * @var int The recurring contribution ID (linked to Stripe Subscription) (if available)
       */
    
      protected $contribution_recur_id = NULL;
    
    
      /**
       * @var string The stripe Invoice ID (mapped to trxn_id on a contribution for recurring contributions)
       */
    
    
      /**
       * @var string The date/time the charge was made
       */
    
    mattwire's avatar
    mattwire committed
      protected $amount = 0.0;
    
    mattwire's avatar
    mattwire committed
      protected $fee = 0.0;
    
    
      /**
       * @var array The current contribution (linked to Stripe charge(single)/invoice(subscription)
       */
    
      protected $contribution = NULL;
    
      /**
       * CRM_Core_Payment_StripeIPN constructor.
       *
    
       * @param bool $verify
       */
    
      public function __construct($ipnData, $verify = TRUE) {
    
        $this->verify_event = $verify;
    
        $this->setInputParameters($ipnData);
    
      /**
       * Store input array on the class.
       * We override base because our input parameter is an object
       *
       * @param array $parameters
    
      public function setInputParameters($parameters) {
        // Determine the proper Stripe Processor ID so we can get the secret key
        // and initialize Stripe.
    
        $this->getPaymentProcessor();
    
        $this->_paymentProcessor->setAPIParams();
    
        if (!is_object($parameters)) {
          $this->exception('Invalid input parameters');
        }
    
    
        // Now re-retrieve the data from Stripe to ensure it's legit.
    
        // Special case if this is the test webhook
        if (substr($parameters->id, -15, 15) === '_00000000000000') {
          http_response_code(200);
    
          $test = (boolean) $this->_paymentProcessor->getPaymentProcessor()['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) {
    
        $value = CRM_Stripe_Api::getObjectParam($name, $this->_inputParameters->data->object);
    
        $value = CRM_Utils_Type::validate($value, $type, FALSE);
        if ($abort && $value === NULL) {
    
          echo "Failure: Missing or invalid parameter<p>" . CRM_Utils_Type::escape($name, 'String');
          $this->exception("Missing or invalid parameter {$name}");
    
       * @throws \CRM_Core_Exception
       * @throws \CiviCRM_API3_Exception
    
       */
      public function main() {
    
        // Collect and determine all data about this event.
    
        $this->event_type = CRM_Stripe_Api::getParam('event_type', $this->_inputParameters);
    
        $pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
    
    
    mattwire's avatar
    mattwire committed
        // NOTE: If you add an event here make sure you add it to the webhook or it will never be received!
    
        switch($this->event_type) {
          case 'invoice.payment_succeeded':
    
            // Successful recurring payment. Either we are completing an existing contribution or it's the next one in a subscription
    
            if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
    
              $params = [
                'id' => $this->contribution['id'],
                'trxn_date' => $this->receive_date,
                'contribution_trxn_id' => $this->invoice_id,
                'payment_trxn_id' => $this->charge_id,
                'total_amount' => $this->amount,
                'fee_amount' => $this->fee,
              ];
              $this->updateContributionCompleted($params);
              // Don't touch the contributionRecur as it's updated automatically by Contribution.completetransaction
    
            elseif ($this->contribution['trxn_id'] != $this->invoice_id) {
              // Stripe has generated a new invoice (next payment in a subscription) so we
              //   create a new contribution in CiviCRM
              $params = [
    
                'contribution_recur_id' => $this->contribution_recur_id,
                'contribution_status_id' => 'Completed',
    
                'receive_date' => $this->receive_date,
    
                'contribution_trxn_id' => $this->invoice_id,
                'payment_trxn_id' => $this->charge_id,
    
                'total_amount' => $this->amount,
                'fee_amount' => $this->fee,
    
                'original_contribution_id' => $this->contribution['id'],
    
              $this->repeatContribution($params);
              // Don't touch the contributionRecur as it's updated automatically by Contribution.repeattransaction
    
            $this->handleInstallmentsForSubscription();
    
    
          case 'invoice.payment_failed':
    
            // Failed recurring payment. Either we are failing an existing contribution or it's the next one in a subscription
    
            if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
    
              // If this contribution is Pending, set it to Failed.
    
                'receive_date' => $this->receive_date,
                'cancel_reason' => $this->retrieve('failure_message', 'String'),
                'payment_trxn_id' => $this->charge_id,
              ];
              $this->updateContributionFailed($params);
    
            elseif ($this->contribution['trxn_id'] != $this->invoice_id) {
              $params = [
    
                'contribution_recur_id' => $this->contribution_recur_id,
    
                'contribution_status_id' => 'Failed',
    
                'receive_date' => $this->receive_date,
                'contribution_trxn_id' => $this->invoice_id,
                'payment_trxn_id' => $this->charge_id,
    
                'total_amount' => $this->amount,
    
                'original_contribution_id' => $this->contribution['id'],
    
              $this->repeatContribution($params);
              // Don't touch the contributionRecur as it's updated automatically by Contribution.completetransaction
    
    
          case 'customer.subscription.deleted':
    
            // Cancel the recurring contribution
    
            $this->updateRecurCancelled(['id' => $this->contribution_recur_id, 'cancel_date' => $this->retrieve('cancel_date', 'String', FALSE)]);
    
          // One-time donation and per invoice payment.
          case 'charge.failed':
    
            // If we don't have a customer_id we can't do anything with it!
            // It's quite likely to be a fraudulent/spam so we ignore.
            if (empty(CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object))) {
              return TRUE;
            }
    
    
              'id' => $this->contribution['id'],
              'receive_date' => $this->receive_date,
              'cancel_reason' => $this->retrieve('failure_message', 'String'),
              'payment_trxn_id' => $this->charge_id,
    
            $this->updateContributionFailed($params);
    
            // Cancelling an uncaptured paymentIntent triggers charge.refunded but we don't want to process that
            if (empty(CRM_Stripe_Api::getObjectParam('captured', $this->_inputParameters->data->object))) {
              return TRUE;
            };
            // This charge was actually captured, so record the refund in CiviCRM
    
    mattwire's avatar
    mattwire committed
            // This gives us the actual amount refunded
            $amountRefunded = CRM_Stripe_Api::getObjectParam('amount_refunded', $this->_inputParameters->data->object);
            // This gives us the refund date + reason code
    
            $refunds = \Stripe\Refund::all(['charge' => $this->charge_id, 'limit' => 1]);
    
    mattwire's avatar
    mattwire committed
            // This gets the fee refunded
            $this->setBalanceTransactionDetails($refunds->data[0]->balance_transaction);
    
    
    mattwire's avatar
    mattwire committed
              'contribution_id' => $this->contribution['id'],
              'total_amount' => 0 - abs($amountRefunded),
              'trxn_date' => date('YmdHis', $refunds->data[0]->created),
              'trxn_result_code' => $refunds->data[0]->reason,
              'fee_amount' => 0 - abs($this->fee),
              'trxn_id' => $this->charge_id,
              'order_reference' => $this->invoice_id ?? NULL,
    
            $this->updateContributionRefund($params);
    
            // For a recurring contribution we can process charge.succeeded once we receive the event with an invoice ID.
            // For a single contribution we can't process charge.succeeded because it only triggers BEFORE the charge is captured
            if (empty(CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object))) {
              return TRUE;
            };
          case 'charge.captured':
            // For a single contribution we have to use charge.captured because it has the customer_id.
    
            if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
    
              $params = [
                'id' => $this->contribution['id'],
                'trxn_date' => $this->receive_date,
    
                'contribution_trxn_id' => $this->invoice_id ?: $this->charge_id,
    
                'payment_trxn_id' => $this->charge_id,
                'total_amount' => $this->amount,
                'fee_amount' => $this->fee,
              ];
              $this->updateContributionCompleted($params);
    
          case 'customer.subscription.updated':
    
            $this->setInfo();
            if (empty($this->previous_plan_id)) {
              // Not a plan change...don't care.
              return TRUE;
            }
    
            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,
    
        }
        // Unhandled event type.
    
       * 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() {
    
        $stripeObjectName = get_class($this->_inputParameters->data->object);
    
        $this->customer_id = CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object);
    
        if (empty($this->customer_id)) {
          $this->exception('Missing customer_id!');
        }
    
        $this->previous_plan_id = CRM_Stripe_Api::getParam('previous_plan_id', $this->_inputParameters);
    
        $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->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);
    
    
        if (($stripeObjectName !== 'Stripe\Charge') && ($this->charge_id !== NULL)) {
          $charge = \Stripe\Charge::retrieve($this->charge_id);
          $balanceTransactionID = CRM_Stripe_Api::getObjectParam('balance_transaction', $charge);
        }
        else {
          $balanceTransactionID = CRM_Stripe_Api::getObjectParam('balance_transaction', $this->_inputParameters->data->object);
        }
    
    mattwire's avatar
    mattwire committed
        $this->setBalanceTransactionDetails($balanceTransactionID);
    
        // 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());
          }
        }
    
        $contributionParamsToReturn = [
          'id',
          'trxn_id',
          'contribution_status_id',
          'total_amount',
          'fee_amount',
          'net_amount',
          'tax_amount',
        ];
    
    
        if ($this->charge_id) {
          try {
            $this->contribution = civicrm_api3('Contribution', 'getsingle', [
              'trxn_id' => $this->charge_id,
              'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
    
              'return' => $contributionParamsToReturn,
    
            ]);
          }
          catch (Exception $e) {
    
            // Contribution not found - that's ok
          }
        }
        if (!$this->contribution && $this->invoice_id) {
          try {
            $this->contribution = civicrm_api3('Contribution', 'getsingle', [
              'trxn_id' => $this->invoice_id,
              'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
    
              'return' => $contributionParamsToReturn,
    
            ]);
          }
          catch (Exception $e) {
            // Contribution not found - that's ok
    
        if (!$this->contribution && $this->contribution_recur_id) {
    
          // If a recurring contribution has been found, get the most recent contribution belonging to it.
    
          try {
            // Same approach as api repeattransaction.
    
            $this->contribution = civicrm_api3('contribution', 'getsingle', [
    
              'return' => ['id', 'contribution_status_id', 'total_amount', 'trxn_id'],
    
              'contribution_recur_id' => $this->contribution_recur_id,
    
              'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
    
              'return' => $contributionParamsToReturn,
    
              'options' => ['limit' => 1, 'sort' => 'id DESC'],
            ]);
    
            $this->exception('Cannot find any contributions with recurring contribution ID: ' . $this->contribution_recur_id . '. ' . $e->getMessage());
    
        if (!$this->contribution) {
          $this->exception('No matching contributions for event ' . CRM_Stripe_Api::getParam('id', $this->_inputParameters));
        }
      }
    
    
    mattwire's avatar
    mattwire committed
      private function setBalanceTransactionDetails($balanceTransactionID) {
        // Gather info about the amount and fee.
        // Get the Stripe charge object if one exists. Null charge still needs processing.
        // If the transaction is declined, there won't be a balance_transaction_id.
        $this->amount = 0.0;
        $this->fee = 0.0;
        if ($balanceTransactionID) {
          try {
            $balanceTransaction = \Stripe\BalanceTransaction::retrieve($balanceTransactionID);
            $this->amount = $balanceTransaction->amount / 100;
            $this->fee = $balanceTransaction->fee / 100;
          }
          catch(Exception $e) {
            $this->exception('Error retrieving balance transaction. ' . $e->getMessage());
          }
        }
      }
    
    
      /**
       * This allows us to end a subscription once:
       *   a) We've reached the end date / number of installments
       *   b) The recurring contribution is marked as completed
       *
       * @throws \CiviCRM_API3_Exception
       */
      private function handleInstallmentsForSubscription() {
        if ((!$this->contribution_recur_id) || (!$this->subscription_id)) {
          return;
        }
    
        $contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', [
          'id' => $this->contribution_recur_id,
        ]);
    
        if (empty($contributionRecur['installments']) && empty($contributionRecur['end_date'])) {
          return;
        }
    
        $stripeSubscription = \Stripe\Subscription::retrieve($this->subscription_id);
        // If we've passed the end date cancel the subscription
        if (($stripeSubscription->current_period_end >= strtotime($contributionRecur['end_date']))
          || ($contributionRecur['contribution_status_id']
            == CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_ContributionRecur', 'contribution_status_id', 'Completed'))) {
          \Stripe\Subscription::update($this->subscription_id, ['cancel_at_period_end' => TRUE]);
          $this->updateRecurCompleted(['id' => $this->contribution_recur_id]);
        }
        // There is no easy way of retrieving a count of all invoices for a subscription so we ignore the "installments"
        //   parameter for now and rely on checking end_date (which was calculated based on number of installments...)
        // $stripeInvoices = \Stripe\Invoice::all(['subscription' => $this->subscription_id, 'limit' => 100]);