StripeIPN.php 18.7 KB
Newer Older
1 2 3 4 5 6 7
<?php
/*
 * @file
 * Handle Stripe Webhooks for recurring payments.
 */

class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
8

9
  use CRM_Core_Payment_StripeIPNTrait;
10 11 12 13 14 15 16 17

  /**
   * 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;

18 19 20
  // 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.
21
  protected $verify_event = TRUE;
22

23 24 25 26 27 28 29
  /**
   * Do we send an email receipt for each contribution?
   *
   * @var int
   */
  protected $is_email_receipt = NULL;

30
  // Properties of the event.
31 32 33 34 35 36 37 38 39 40 41
  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;
42 43
       
  // Derived properties.
44 45 46 47 48 49 50
  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;
51
  protected $previous_contribution = [];
52

53 54 55
  /**
   * CRM_Core_Payment_StripeIPN constructor.
   *
56
   * @param $ipnData
57 58 59 60
   * @param bool $verify
   *
   * @throws \CRM_Core_Exception
   */
61
  public function __construct($ipnData, $verify = TRUE) {
62
    $this->verify_event = $verify;
63
    $this->setInputParameters($ipnData);
64 65
    parent::__construct();
  }
66

67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
  /**
   * 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', [
101
        'return' => "is_email_receipt",
102 103 104 105 106 107
        'id' => $this->contribution_recur_id,
      ]);
    }
    return (int) $this->is_email_receipt;
  }

108 109 110 111 112 113 114
  /**
   * Store input array on the class.
   * We override base because our input parameter is an object
   *
   * @param array $parameters
  */
  public function setInputParameters($parameters) {
115 116
    if (!is_object($parameters)) {
      $this->exception('Invalid input parameters');
117 118 119 120
    }

    // Determine the proper Stripe Processor ID so we can get the secret key
    // and initialize Stripe.
121
    $this->getPaymentProcessor();
122 123

    // Now re-retrieve the data from Stripe to ensure it's legit.
124
    \Stripe\Stripe::setApiKey($this->_paymentProcessor['user_name']);
125 126 127 128 129 130 131 132 133

    // 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();
    }

134 135 136 137 138 139
    if ($this->verify_event) {
      $this->_inputParameters = \Stripe\Event::retrieve($parameters->id);
    }
    else {
      $this->_inputParameters = $parameters;
    }
140
    http_response_code(200);
141
  }
142

143
  /**
144 145 146 147 148 149 150 151
   * 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
152 153
   */
  public function retrieve($name, $type, $abort = TRUE) {
154
    $className = get_class($this->_inputParameters->data->object);
155
    $value = NULL;
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
    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;
178 179
        }
        break;
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197

      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;
198 199
        }
        break;
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229

      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;
230 231
        }
        break;
232 233 234 235 236 237
    }

    // Common parameters
    switch ($name) {
      case 'customer_id':
        $value = $this->_inputParameters->data->object->customer;
238
        break;
239

240 241 242
      case 'event_type':
        $value = $this->_inputParameters->type;
        break;
243

244 245 246 247 248 249
      case 'previous_plan_id':
        if (preg_match('/\.updated$/', $this->_inputParameters->type)) {
          $value = $this->_inputParameters->data->previous_attributes->plan->id;
        }
        break;
    }
250

251 252
    $value = CRM_Utils_Type::validate($value, $type, FALSE);
    if ($abort && $value === NULL) {
mattwire's avatar
mattwire committed
253 254
      echo "Failure: Missing Parameter<p>" . CRM_Utils_Type::escape($name, 'String');
      $this->exception("Could not find an entry for $name");
255 256 257 258
    }
    return $value;
  }

259
  /**
260 261 262 263
   * @throws \CRM_Core_Exception
   * @throws \CiviCRM_API3_Exception
   */
  public function main() {
264
    // Collect and determine all data about this event.
265
    $this->event_type = $this->retrieve('event_type', 'String');
266

267 268
    $pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');

269 270 271
    switch($this->event_type) {
      // Successful recurring payment.
      case 'invoice.payment_succeeded':
272
        $this->setInfo();
273
        if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
274
          $this->completeContribution();
275
        }
276
        elseif ($this->previous_contribution['trxn_id'] != $this->charge_id) {
277 278
          // The first contribution was completed, so create a new one.
          // api contribution repeattransaction repeats the appropriate contribution if it is given
279 280
          // simply the recurring contribution id. It also updates the membership for us.
          civicrm_api3('Contribution', 'repeattransaction', array(
281 282
            'contribution_recur_id' => $this->contribution_recur_id,
            'contribution_status_id' => 'Completed',
283 284 285 286
            'receive_date' => $this->receive_date,
            'trxn_id' => $this->charge_id,
            'total_amount' => $this->amount,
            'fee_amount' => $this->fee,
287
            'is_email_receipt' => $this->getSendEmailReceipt(),
288 289 290 291
          ));
        }

        // Successful charge & more to come. 
292
        civicrm_api3('ContributionRecur', 'create', array(
293 294
          'id' => $this->contribution_recur_id,
          'failure_count' => 0,
295
          'contribution_status_id' => 'In Progress'
296
        ));
297
        return TRUE;
298 299 300

      // Failed recurring payment.
      case 'invoice.payment_failed':
301
        $this->setInfo();
302
        $failDate = date('YmdHis');
303

304
        if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
305
          // If this contribution is Pending, set it to Failed.
306
          civicrm_api3('Contribution', 'create', array(
307
            'id' => $this->previous_contribution['id'],
308
            'contribution_status_id' => "Failed",
309
            'receive_date' => $failDate,
310
            'is_email_receipt' => 0,
311 312 313
          ));
        }
        else {
314
          $contributionParams = [
315
            'contribution_recur_id' => $this->contribution_recur_id,
316 317
            'contribution_status_id' => 'Failed',
            'receive_date' => $failDate,
318
            'total_amount' => $this->amount,
319
            'is_email_receipt' => 0,
320 321
          ];
          civicrm_api3('Contribution', 'repeattransaction', $contributionParams);
322 323
        }

324
        $failureCount = civicrm_api3('ContributionRecur', 'getvalue', array(
325 326 327
         'id' => $this->contribution_recur_id,
         'return' => 'failure_count',
        ));
328
        $failureCount++;
329

330 331
        // Change the status of the Recurring and update failed attempts.
        civicrm_api3('ContributionRecur', 'create', array(
332 333
          'id' => $this->contribution_recur_id,
          'contribution_status_id' => "Failed",
334 335
          'failure_count' => $failureCount,
          'modified_date' => $failDate,
336
        ));
337
        return TRUE;
338

339
      // Subscription is cancelled
340
      case 'customer.subscription.deleted':
341
        $this->setInfo();
342 343
        // Cancel the recurring contribution
        civicrm_api3('ContributionRecur', 'cancel', array(
344 345
          'id' => $this->contribution_recur_id,
        ));
346
        return TRUE;
347

348 349 350 351 352 353 354 355 356 357 358
      // 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]);
        }
359
        return TRUE;
360

361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383
      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
              ]);
            }
          }
        }
384
        return TRUE;
385 386

      case 'charge.succeeded':
387
        $this->setInfo();
388
        if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
389 390
          $this->completeContribution();
        }
391
        return TRUE;
392

393
      case 'customer.subscription.updated':
394
       $this->setInfo();
395 396
       if (empty($this->previous_plan_id)) {
         // Not a plan change...don't care.
397
         return TRUE;
398 399
       }

400 401
       civicrm_api3('ContributionRecur', 'create', [
         'id' => $this->contribution_recur_id,
402 403 404 405 406
          'amount' => $this->plan_amount,
          'auto_renew' => 1,
          'created_date' => $this->plan_start,
          'frequency_unit' => $this->frequency_unit,
          'frequency_interval' => $this->frequency_interval,
407
       ]);
408

409
       civicrm_api3('Contribution', 'create', [
410
          'id' => $this->previous_contribution['id'],
411 412 413
          'total_amount' => $this->plan_amount,
          'contribution_recur_id' => $this->contribution_recur_id,
       ]);
414
        return TRUE;
415 416
    }
    // Unhandled event type.
417
    return TRUE;
418 419 420
  }

  /**
421 422 423 424 425 426 427
   * 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(
428
      'id' => $this->previous_contribution['id'],
429 430 431 432 433 434
      '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(
435
      'id' => $this->previous_contribution['id'],
436 437 438 439 440 441
      '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'],
442
      'is_email_receipt' => $this->getSendEmailReceipt(),
443 444 445 446
    ));
  }

    /**
447 448 449 450 451
   * 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.
452 453
   *
   * @throws \CRM_Core_Exception
454
   */
455
  public function setInfo() {
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471
    $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.
472
    if ($this->charge_id !== null) {
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487
      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
488
        $this->exception('Cannot get contribution amounts');
489 490 491 492 493 494 495 496 497
      }
    } 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;

498
    // Additional processing of values is only relevant if there is a subscription id.
499
    if ($this->subscription_id) {
500
      // Get the recurring contribution record associated with the Stripe subscription.
501 502 503
      try {
        $contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $this->subscription_id]);
        $this->contribution_recur_id = $contributionRecur['id'];
504 505 506 507 508 509 510 511 512
      }
      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.
513
        $contribution = civicrm_api3('contribution', 'getsingle', array(
514
          'return' => array('id', 'contribution_status_id', 'total_amount', 'trxn_id'),
515
          'contribution_recur_id' => $this->contribution_recur_id,
516
          'contribution_test' => isset($this->_paymentProcessor['is_test']) && $this->_paymentProcessor['is_test'] ? 1 : 0,
517
          'options' => array('limit' => 1, 'sort' => 'id DESC'),
518
        ));
519
        $this->previous_contribution = $contribution;
520 521
      }
      catch (Exception $e) {
522
        $this->exception('Cannot find any contributions with recurring contribution ID: ' . $this->contribution_recur_id . '. ' . $e->getMessage());
523 524 525
      }
    }
  }
526

mattwire's avatar
mattwire committed
527
  public function exception($message) {
528 529
    $errorMessage = 'StripeIPN Exception: Event: ' . $this->event_type . ' Error: ' . $message;
    Civi::log()->debug($errorMessage);
mattwire's avatar
mattwire committed
530 531
    http_response_code(400);
    exit(1);
mattwire's avatar
mattwire committed
532
  }
533
}