Stripe.php 30.1 KB
Newer Older
drastik's avatar
drastik committed
1
<?php
2

drastik's avatar
drastik committed
3 4 5
/*
 * Payment Processor class for Stripe
 */
6

drastik's avatar
drastik committed
7 8 9 10 11 12 13 14
class CRM_Core_Payment_Stripe extends CRM_Core_Payment {

  /**
   * We only need one instance of this object. So we use the singleton
   * pattern and cache the instance in this variable
   *
   * @var object
   */
Matthew Wire's avatar
Matthew Wire committed
15
  private static $_singleton = NULL;
drastik's avatar
drastik committed
16 17

  /**
drastik's avatar
drastik committed
18
   * Mode of operation: live or test.
drastik's avatar
drastik committed
19 20 21
   *
   * @var object
   */
22
  protected $_mode = NULL;
drastik's avatar
drastik committed
23

24 25 26 27 28 29 30
  /**
   * TRUE if we are dealing with a live transaction
   *
   * @var boolean
   */
  private $_islive = FALSE;

drastik's avatar
drastik committed
31 32 33
  /**
   * Constructor
   *
Joshua Walker's avatar
Joshua Walker committed
34 35
   * @param string $mode
   *   The mode of operation: live or test.
drastik's avatar
drastik committed
36 37 38
   *
   * @return void
   */
39
  public function __construct($mode, &$paymentProcessor) {
40 41
    $this->_mode = $mode;
    $this->_islive = ($mode == 'live' ? 1 : 0);
drastik's avatar
drastik committed
42
    $this->_paymentProcessor = $paymentProcessor;
43
    $this->_processorName = ts('Stripe');
drastik's avatar
drastik committed
44 45 46
  }

  /**
Joshua Walker's avatar
Joshua Walker committed
47
   * This function checks to see if we have the right config values.
drastik's avatar
drastik committed
48
   *
Matthew Wire's avatar
Matthew Wire committed
49
   * @return null|string
Joshua Walker's avatar
Joshua Walker committed
50
   *   The error message if any.
drastik's avatar
drastik committed
51
   */
52
  public function checkConfig() {
drastik's avatar
drastik committed
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
    $error = array();

    if (empty($this->_paymentProcessor['user_name'])) {
      $error[] = ts('The "Secret Key" is not set in the Stripe Payment Processor settings.');
    }

    if (empty($this->_paymentProcessor['password'])) {
      $error[] = ts('The "Publishable Key" is not set in the Stripe Payment Processor settings.');
    }

    if (!empty($error)) {
      return implode('<p>', $error);
    }
    else {
      return NULL;
    }
  }

71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
  /**
   * Get the currency for the transaction.
   *
   * Handle any inconsistency about how it is passed in here.
   *
   * @param $params
   *
   * @return string
   */
  public function getAmount($params) {
    // Stripe amount required in cents.
    $amount = number_format($params['amount'], 2, '.', '');
    $amount = (int) preg_replace('/[^\d]/', '', strval($amount));
    return $amount;
  }

Joshua Walker's avatar
Joshua Walker committed
87 88
  /**
   * Helper log function.
drastik's avatar
drastik committed
89
   *
Joshua Walker's avatar
Joshua Walker committed
90 91 92 93 94
   * @param string $op
   *   The Stripe operation being performed.
   * @param Exception $exception
   *   The error!
   */
95
  public function logStripeException($op, $exception) {
peterh's avatar
peterh committed
96
    $body = print_r($exception->getJsonBody(), TRUE);
mattwire's avatar
mattwire committed
97
    Civi::log()->debug("Stripe_Error {$op}:  <pre> {$body} </pre>");
peterh's avatar
peterh committed
98 99
  }

100 101 102 103
  /**
   * Check if return from stripeCatchErrors was an error object
   * that should be passed back to original api caller.
   *
104
   * @param array $err
105
   *   The return from a call to stripeCatchErrors
Matthew Wire's avatar
Matthew Wire committed
106
   *
107 108
   * @return bool
   */
109 110
  public function isErrorReturn($err) {
    if (!empty($err['is_error'])) {
Matthew Wire's avatar
Matthew Wire committed
111 112
      return TRUE;
    }
113 114 115 116 117 118 119 120 121 122 123 124 125 126
    else {
      return FALSE;
    }
  }

  /**
   * Handle an error from Stripe API and notify the user
   *
   * @param array $err
   * @param string $bounceURL
   *
   * @return string errorMessage (or statusbounce if URL is specified)
   */
  public function handleErrorNotification($err, $bounceURL = NULL) {
mattwire's avatar
mattwire committed
127
    $errorMessage = 'Payment Response: <br />' .
128 129 130 131
      'Type: ' . $err['type'] . '<br />' .
      'Code: ' . $err['code'] . '<br />' .
      'Message: ' . $err['message'] . '<br />';

mattwire's avatar
mattwire committed
132 133
    Civi::log()->debug('Stripe Payment Error: ' . $errorMessage);

134
    if ($bounceURL) {
mattwire's avatar
mattwire committed
135
      CRM_Core_Error::statusBounce($errorMessage, $bounceURL, 'Payment Error');
136 137
    }
    return $errorMessage;
138 139
  }

140 141
  /**
   * Run Stripe calls through this to catch exceptions gracefully.
drastik's avatar
drastik committed
142
   *
Joshua Walker's avatar
Joshua Walker committed
143
   * @param string $op
144
   *   Determine which operation to perform.
Matthew Wire's avatar
Matthew Wire committed
145
   * @param $stripe_params
Joshua Walker's avatar
Joshua Walker committed
146
   * @param array $params
147
   *   Parameters to run Stripe calls on.
Matthew Wire's avatar
Matthew Wire committed
148
   * @param array $ignores
drastik's avatar
drastik committed
149
   *
Matthew Wire's avatar
Matthew Wire committed
150
   * @return bool|\CRM_Core_Error|\Stripe\Charge|\Stripe\Customer|\Stripe\Plan
151
   *   Response from gateway.
Matthew Wire's avatar
Matthew Wire committed
152 153
   *
   * @throws \CiviCRM_API3_Exception
154
   */
155
  public function stripeCatchErrors($op = 'create_customer', $stripe_params, $params, $ignores = array()) {
drastik's avatar
drastik committed
156
    $return = FALSE;
157
    // Check for errors before trying to submit.
158 159
    try {
      switch ($op) {
160 161 162 163 164 165
         case 'create_customer':
          $return = \Stripe\Customer::create($stripe_params);
          break;

        case 'update_customer':
          $return = \Stripe\Customer::update($stripe_params);
166
          break;
167 168

        case 'charge':
169
          $return = \Stripe\Charge::create($stripe_params);
170
          break;
171 172

        case 'save':
173
          $return = $stripe_params->save();
174
          break;
175 176

        case 'create_plan':
177
          $return = \Stripe\Plan::create($stripe_params);
178
          break;
179

180
        case 'retrieve_customer':
181
          $return = \Stripe\Customer::retrieve($stripe_params);
182
          break;
183 184

        case 'retrieve_balance_transaction':
185
          $return = \Stripe\BalanceTransaction::retrieve($stripe_params);
186
          break;
187

188
        default:
189
          $return = \Stripe\Customer::create($stripe_params);
190
          break;
191
      }
192 193
    }
    catch (Exception $e) {
194
      if (is_a($e, 'Stripe_Error')) {
195 196
        foreach ($ignores as $ignore) {
          if (is_a($e, $ignore['class'])) {
peterh's avatar
peterh committed
197 198 199
            $body = $e->getJsonBody();
            $error = $body['error'];
            if ($error['type'] == $ignore['type'] && $error['message'] == $ignore['message']) {
200 201 202 203
              return $return;
            }
          }
        }
peterh's avatar
peterh committed
204
      }
205 206

      $this->logStripeException($op, $e);
207 208
      // Since it's a decline, Stripe_CardError will be caught
      $body = $e->getJsonBody();
209
      $err = $body['error'];
Jamie McClelland's avatar
Jamie McClelland committed
210
      if (!isset($err['code'])) {
211 212
        // A "fake" error code
        $err['code'] = 9000;
Jamie McClelland's avatar
Jamie McClelland committed
213
      }
214 215

      if (is_a($e, 'Stripe_CardError')) {
216 217
        civicrm_api3('Note', 'create', array(
          'entity_id' => self::getContactId($params),
218 219 220 221 222 223
          'contact_id' => $params['contributionID'],
          'subject' => $err['type'],
          'note' => $err['code'],
          'entity_table' => "civicrm_contributions",
        ));
      }
224

225 226 227
      // Flag to detect error return
      $err['is_error'] = TRUE;
      return $err;
228 229 230 231
    }

    return $return;
  }
Peter Hartmann's avatar
Peter Hartmann committed
232

Matthew Wire's avatar
Matthew Wire committed
233 234
  /**
   * Override CRM_Core_Payment function
Matthew Wire's avatar
Matthew Wire committed
235 236
   *
   * @return array
Matthew Wire's avatar
Matthew Wire committed
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336
   */
  public function getPaymentFormFields() {
    return array(
      'credit_card_type',
      'credit_card_number',
      'cvv2',
      'credit_card_exp_date',
      'stripe_token',
      'stripe_pub_key',
      'stripe_id',
    );
  }

  /**
   * Return an array of all the details about the fields potentially required for payment fields.
   *
   * Only those determined by getPaymentFormFields will actually be assigned to the form
   *
   * @return array
   *   field metadata
   */
  public function getPaymentFormFieldsMetadata() {
    $creditCardType = array('' => ts('- select -')) + CRM_Contribute_PseudoConstant::creditCard();
    return array(
      'credit_card_number' => array(
        'htmlType' => 'text',
        'name' => 'credit_card_number',
        'title' => ts('Card Number'),
        'cc_field' => TRUE,
        'attributes' => array(
          'size' => 20,
          'maxlength' => 20,
          'autocomplete' => 'off',
        ),
        'is_required' => TRUE,
      ),
      'cvv2' => array(
        'htmlType' => 'text',
        'name' => 'cvv2',
        'title' => ts('Security Code'),
        'cc_field' => TRUE,
        'attributes' => array(
          'size' => 5,
          'maxlength' => 10,
          'autocomplete' => 'off',
        ),
        'is_required' => TRUE,
      ),
      'credit_card_exp_date' => array(
        'htmlType' => 'date',
        'name' => 'credit_card_exp_date',
        'title' => ts('Expiration Date'),
        'cc_field' => TRUE,
        'attributes' => CRM_Core_SelectValues::date('creditCard'),
        'is_required' => TRUE,
        'month_field' => 'credit_card_exp_date_M',
        'year_field' => 'credit_card_exp_date_Y',
      ),

      'credit_card_type' => array(
        'htmlType' => 'select',
        'name' => 'credit_card_type',
        'title' => ts('Card Type'),
        'cc_field' => TRUE,
        'attributes' => $creditCardType,
        'is_required' => FALSE,
      ),
      'stripe_token' => array(
        'htmlType' => 'hidden',
        'name' => 'stripe_token',
        'title' => 'Stripe Token',
        'attributes' => array(
          'id' => 'stripe-token',
        ),
        'cc_field' => TRUE,
        'is_required' => TRUE,
      ),
      'stripe_id' => array(
        'htmlType' => 'hidden',
        'name' => 'stripe_id',
        'title' => 'Stripe ID',
        'attributes' => array(
          'id' => 'stripe-id',
        ),
        'cc_field' => TRUE,
        'is_required' => TRUE,
      ),
      'stripe_pub_key' => array(
        'htmlType' => 'hidden',
        'name' => 'stripe_pub_key',
        'title' => 'Stripe Public Key',
        'attributes' => array(
          'id' => 'stripe-pub-key',
        ),
        'cc_field' => TRUE,
        'is_required' => TRUE,
      ),
    );
  }

337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
  /**
   * Get form metadata for billing address fields.
   *
   * @param int $billingLocationID
   *
   * @return array
   *    Array of metadata for address fields.
   */
  public function getBillingAddressFieldsMetadata($billingLocationID = NULL) {
    $metadata = parent::getBillingAddressFieldsMetadata($billingLocationID);
    if (!$billingLocationID) {
      // Note that although the billing id is passed around the forms the idea that it would be anything other than
      // the result of the function below doesn't seem to have eventuated.
      // So taking this as a param is possibly something to be removed in favour of the standard default.
      $billingLocationID = CRM_Core_BAO_LocationType::getBilling();
    }

    // Stripe does not require the state/county field
    if (!empty($metadata["billing_state_province_id-{$billingLocationID}"]['is_required'])) {
      $metadata["billing_state_province_id-{$billingLocationID}"]['is_required'] = FALSE;
    }

    return $metadata;
  }

Peter Hartmann's avatar
Peter Hartmann committed
362
  /**
363
   * Set default values when loading the (payment) form
364
   *
365
   * @param \CRM_Core_Form $form
Peter Hartmann's avatar
Peter Hartmann committed
366
   */
367
  public function buildForm(&$form) {
368
    // Set default values
369
    $paymentProcessorId = CRM_Utils_Array::value('id', $form->_paymentProcessor);
370
    $publishableKey = CRM_Core_Payment_Stripe::getPublishableKey($paymentProcessorId);
371 372 373 374
    $defaults = [
      'stripe_id' => $paymentProcessorId,
      'stripe_pub_key' => $publishableKey,
    ];
Matthew Wire's avatar
Matthew Wire committed
375
    $form->setDefaults($defaults);
Peter Hartmann's avatar
Peter Hartmann committed
376
  }
377

378
   /**
Matthew Wire's avatar
Matthew Wire committed
379 380 381 382 383
   * Given a payment processor id, return the publishable key (password field)
   *
   * @param $paymentProcessorId
   *
   * @return string
Peter Hartmann's avatar
Peter Hartmann committed
384
   */
385
  public static function getPublishableKey($paymentProcessorId) {
Peter Hartmann's avatar
Peter Hartmann committed
386
    try {
Matthew Wire's avatar
Matthew Wire committed
387
      $publishableKey = (string) civicrm_api3('PaymentProcessor', 'getvalue', array(
388
        'return' => "password",
Matthew Wire's avatar
Matthew Wire committed
389
        'id' => $paymentProcessorId,
390
      ));
Peter Hartmann's avatar
Peter Hartmann committed
391 392
    }
    catch (CiviCRM_API3_Exception $e) {
Matthew Wire's avatar
Matthew Wire committed
393
      return '';
Peter Hartmann's avatar
Peter Hartmann committed
394
    }
Matthew Wire's avatar
Matthew Wire committed
395
    return $publishableKey;
Peter Hartmann's avatar
Peter Hartmann committed
396 397
  }

drastik's avatar
drastik committed
398 399 400 401
  /**
   * Submit a payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
   *
Joshua Walker's avatar
Joshua Walker committed
402 403
   * @param array $params
   *   Assoc array of input parameters for this transaction.
drastik's avatar
drastik committed
404
   *
Matthew Wire's avatar
Matthew Wire committed
405
   * @return array|\CRM_Core_Error
Joshua Walker's avatar
Joshua Walker committed
406
   *   The result in a nice formatted array (or an error object).
drastik's avatar
drastik committed
407
   *
Matthew Wire's avatar
Matthew Wire committed
408
   * @throws \CiviCRM_API3_Exception
drastik's avatar
drastik committed
409
   */
410
  public function doDirectPayment(&$params) {
411 412 413
    if (array_key_exists('credit_card_number', $params)) {
      $cc = $params['credit_card_number'];
      if (!empty($cc) && substr($cc, 0, 8) != '00000000') {
mattwire's avatar
mattwire committed
414
        Civi::log()->debug(ts('ALERT! Unmasked credit card received in back end. Please report this error to the site administrator.'));
415 416 417
      }
    }

drastik's avatar
drastik committed
418
    // Let a $0 transaction pass.
419 420
    if (empty($params['amount']) || $params['amount'] == 0) {
      return $params;
drastik's avatar
drastik committed
421 422
    }

423
    // Get proper entry URL for returning on error.
424 425 426
    if (!(array_key_exists('qfKey', $params))) {
      // Probably not called from a civicrm form (e.g. webform) -
      // will return error object to original api caller.
427
      $params['stripe_error_url'] = NULL;
428 429 430 431 432
    }
    else {
      $qfKey = $params['qfKey'];
      $parsed_url = parse_url($params['entryURL']);
      $url_path = substr($parsed_url['path'], 1);
433
      $params['stripe_error_url'] = CRM_Utils_System::url($url_path,
drastik's avatar
drastik committed
434
      $parsed_url['query'] . "&_qf_Main_display=1&qfKey={$qfKey}", FALSE, NULL, FALSE);
435
    }
436

mattwire's avatar
mattwire committed
437
    // Set plugin info and API credentials.
438
    \Stripe\Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
439
    \Stripe\Stripe::setApiKey($this->_paymentProcessor['user_name']);
drastik's avatar
drastik committed
440

441
    $amount = self::getAmount($params);
drastik's avatar
drastik committed
442

443
    // Use Stripe.js instead of raw card details.
Matthew Wire's avatar
Matthew Wire committed
444 445 446 447 448
    if (!empty($params['stripe_token'])) {
      $card_token = $params['stripe_token'];
    }
    else if(!empty(CRM_Utils_Array::value('stripe_token', $_POST, NULL))) {
      $card_token = CRM_Utils_Array::value('stripe_token', $_POST, NULL);
449 450
    }
    else {
Matthew Wire's avatar
Matthew Wire committed
451 452
      CRM_Core_Error::statusBounce(ts('Unable to complete payment! Please this to the site administrator with a description of what you were trying to do.'));
      Civi::log()->debug('Stripe.js token was not passed!  Report this message to the site administrator. $params: ' . print_r($params, TRUE));
453 454
    }

455 456
    $contactId = self::getContactId($params);
    $email = self::getBillingEmail($params, $contactId);
drastik's avatar
drastik committed
457

458 459 460 461 462 463 464 465
    // See if we already have a stripe customer
    $customerParams = [
      'contact_id' => $contactId,
      'card_token' => $card_token,
      'is_live' => $this->_islive,
      'processor_id' => $this->_paymentProcessor['id'],
      'email' => $email,
    ];
466

467
    $stripeCustomerId = CRM_Stripe_Customer::find($customerParams);
drastik's avatar
drastik committed
468

469
    // Customer not in civicrm database.  Create a new Customer in Stripe.
470
    if (!isset($stripeCustomerId)) {
471
      $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
472 473
    }
    else {
474 475 476 477 478 479 480 481 482 483 484 485 486
      // Customer was found in civicrm database, fetch from Stripe.
      $stripeCustomer = $this->stripeCatchErrors('retrieve_customer', $stripeCustomerId, $params);
      if (!empty($stripeCustomer)) {
        if ($this->isErrorReturn($stripeCustomer)) {
          if (($stripeCustomer['type'] == 'invalid_request_error') && ($stripeCustomer['code'] == 'resource_missing')) {
            // Customer doesn't exist, create a new one
            $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
          }
          if ($this->isErrorReturn($stripeCustomer)) {
            // We still failed to create a customer
            self::handleErrorNotification($stripeCustomer, $params['stripe_error_url']);
            return $stripeCustomer;
          }
487
        }
mattwire's avatar
mattwire committed
488

489
        // Avoid the 'use same token twice' issue while still using latest card.
490 491 492 493
        if (!empty($params['is_secondary_financial_transaction'])) {
          // This is a Contribution page with "Separate Membership Payment".
          // Charge is coming through for the 2nd time.
          // Don't update customer again or we will get "token_already_used" error from Stripe.
494 495
        }
        else {
496
          $stripeCustomer->card = $card_token;
mattwire's avatar
mattwire committed
497 498 499 500
          $stripeCustomer = $this->stripeCatchErrors('save', $stripeCustomer, $params);
          if ($this->isErrorReturn($stripeCustomer)) {
            if (($stripeCustomer['type'] == 'invalid_request_error') && ($stripeCustomer['code'] == 'token_already_used')) {
              // This error is ok, we've already used the token during create_customer
501
            }
mattwire's avatar
mattwire committed
502 503 504 505 506
            else {
              self::handleErrorNotification($stripeCustomer, $params['stripe_error_url']);
              return $stripeCustomer;
            }
          }
507 508 509
        }
      }
      else {
510 511 512
        // Customer was found in civicrm_stripe database, but not in Stripe.
        // Delete existing customer record from CiviCRM and create a new customer
        CRM_Stripe_Customer::delete($customerParams);
513
        $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
drastik's avatar
drastik committed
514 515 516
      }
    }

drastik's avatar
drastik committed
517
    // Prepare the charge array, minus Customer/Card details.
518
    if (empty($params['description'])) {
519
      $params['description'] = ts('Backend Stripe contribution');
520
    }
521 522

    // Stripe charge.
drastik's avatar
drastik committed
523 524
    $stripe_charge = array(
      'amount' => $amount,
525
      'currency' => strtolower($params['currencyID']),
526
      'description' => $params['description'] . ' # Invoice ID: ' . CRM_Utils_Array::value('invoiceID', $params),
drastik's avatar
drastik committed
527 528
    );

drastik's avatar
drastik committed
529
    // Use Stripe Customer if we have a valid one.  Otherwise just use the card.
530 531
    if (!empty($stripeCustomer->id)) {
      $stripe_charge['customer'] = $stripeCustomer->id;
532 533
    }
    else {
Matthew Wire's avatar
Matthew Wire committed
534
      $stripe_charge['card'] = $card_token;
drastik's avatar
drastik committed
535 536
    }

537
    // Handle recurring payments in doRecurPayment().
drastik's avatar
drastik committed
538
    if (CRM_Utils_Array::value('is_recur', $params) && $params['contributionRecurID']) {
539
      return $this->doRecurPayment($params, $amount, $stripeCustomer);
drastik's avatar
drastik committed
540 541
    }

542
    // Fire away!  Check for errors before trying to submit.
543 544 545 546 547
    $stripeCharge = $this->stripeCatchErrors('charge', $stripe_charge, $params);
    if (!empty($stripeCharge)) {
      if ($this->isErrorReturn($stripeCharge)) {
        self::handleErrorNotification($stripeCharge, $params['stripe_error_url']);
        return $stripeCharge;
548
      }
drastik's avatar
drastik committed
549
      // Success!  Return some values for CiviCRM.
550
      $params['trxn_id'] = $stripeCharge->id;
551 552
      // Return fees & net amount for Civi reporting.
      // Uses new Balance Trasaction object.
553 554 555 556 557
      $balanceTransaction = $this->stripeCatchErrors('retrieve_balance_transaction', $stripeCharge->balance_transaction, $params);
      if (!empty($balanceTransaction)) {
        if ($this->isErrorReturn($balanceTransaction)) {
          self::handleErrorNotification($balanceTransaction, $params['stripe_error_url']);
          return $balanceTransaction;
558
        }
559 560
        $params['fee_amount'] = $balanceTransaction->fee / 100;
        $params['net_amount'] = $balanceTransaction->net / 100;
561
      }
drastik's avatar
drastik committed
562 563 564
    }
    else {
      // There was no response from Stripe on the create charge command.
565 566
      if (isset($params['stripe_error_url'])) {
        CRM_Core_Error::statusBounce('Stripe transaction response not received!  Check the Logs section of your stripe.com account.', $params['stripe_error_url']);
567 568 569 570 571 572 573
      }
      else {
        // Don't have return url - return error object to api
        $core_err = CRM_Core_Error::singleton();
        $core_err->push(9000, 0, NULL, 'Stripe transaction response not recieved!  Check the Logs section of your stripe.com account.');
        return $core_err;
      }
drastik's avatar
drastik committed
574
    }
drastik's avatar
drastik committed
575 576 577 578 579 580 581 582

    return $params;
  }

  /**
   * Submit a recurring payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
   *
Joshua Walker's avatar
Joshua Walker committed
583 584 585 586
   * @param array $params
   *   Assoc array of input parameters for this transaction.
   * @param int $amount
   *   Transaction amount in USD cents.
587
   * @param object $stripeCustomer
Joshua Walker's avatar
Joshua Walker committed
588
   *   Stripe customer object generated by Stripe API.
drastik's avatar
drastik committed
589
   *
drastik's avatar
drastik committed
590
   * @return array
Joshua Walker's avatar
Joshua Walker committed
591
   *   The result in a nice formatted array (or an error object).
drastik's avatar
drastik committed
592
   *
Matthew Wire's avatar
Matthew Wire committed
593
   * @throws \CiviCRM_API3_Exception
drastik's avatar
drastik committed
594
   */
595
  public function doRecurPayment(&$params, $amount, $stripeCustomer) {
drastik's avatar
drastik committed
596
    // Get recurring contrib properties.
drastik's avatar
drastik committed
597
    $frequency = $params['frequency_unit'];
598
    $frequency_interval = (empty($params['frequency_interval']) ? 1 : $params['frequency_interval']);
599
    $currency = strtolower($params['currencyID']);
600 601 602 603 604 605 606 607 608
    if (isset($params['installments'])) {
      $installments = $params['installments'];
    }

    // This adds some support for CiviDiscount on recurring contributions and changes the default behavior to discounting
    // only the first of a recurring contribution set instead of all. (Intro offer) The Stripe procedure for discounting the
    // first payment of subscription entails creating a negative invoice item or negative balance first,
    // then creating the subscription at 100% full price. The customers first Stripe invoice will reflect the
    // discount. Subsequent invoices will be at the full undiscounted amount.
609 610 611
    // NB: Civi currently won't send a $0 charge to a payproc extension, but it should in this case. If the discount is >
    // the cost of initial payment, we still send the whole discount (or giftcard) as a negative balance.
    // Consider not selling giftards greater than your least expensive auto-renew membership until we can override this.
612
    // TODO: add conditonals that look for $param['intro_offer'] (to give admins the choice of default behavior) and
613
    // $params['trial_period'].
614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656

    if (!empty($params['discountcode'])) {
      $discount_code = $params['discountcode'];
      $discount_object = civicrm_api3('DiscountCode', 'get', array(
         'sequential' => 1,
         'return' => "amount,amount_type",
         'code' => $discount_code,
          ));
       // amount_types: 1 = percentage, 2 = fixed, 3 = giftcard
       if ((!empty($discount_object['values'][0]['amount'])) && (!empty($discount_object['values'][0]['amount_type']))) {
         $discount_type = $discount_object['values'][0]['amount_type'];
         if ( $discount_type == 1 ) {
         // Discount is a percentage. Avoid ugly math and just get the full price using price_ param.
           foreach($params as $key=>$value){
             if("price_" == substr($key,0,6)){
               $price_param = $key;
               $price_field_id = substr($key,strrpos($key,'_') + 1);
             }
           }
           if (!empty($params[$price_param])) {
             $priceFieldValue = civicrm_api3('PriceFieldValue', 'get', array(
               'sequential' => 1,
               'return' => "amount",
               'id' => $params[$price_param],
               'price_field_id' => $price_field_id,
              ));
           }
           if (!empty($priceFieldValue['values'][0]['amount'])) {
              $priceset_amount = $priceFieldValue['values'][0]['amount'];
              $full_price = $priceset_amount * 100;
              $discount_in_cents = $full_price - $amount;
              // Set amount to full price.
              $amount = $full_price;
           }
        } else if ( $discount_type >= 2 ) {
        // discount is fixed or a giftcard. (may be > amount).
          $discount_amount = $discount_object['values'][0]['amount'];
          $discount_in_cents = $discount_amount * 100;
          // Set amount to full price.
          $amount =  $amount + $discount_in_cents;
        }
     }
        // Apply the disount through a negative balance.
657 658
       $stripeCustomer->account_balance = -$discount_in_cents;
       $stripeCustomer->save();
659 660
     }

661 662 663 664
    // Tying a plan to a membership (or priceset->membership) makes it possible
    // to automatically change the users membership level with subscription upgrade/downgrade.
    // An amount is not enough information to distinguish a membership related recurring
    // contribution from a non-membership related one.
665 666 667
    $membership_type_tag = '';
    $membership_name = '';
    if (isset($params['selectMembership'])) {
Peter Hartmann's avatar
Peter Hartmann committed
668
      $membership_type_id = $params['selectMembership'][0];
669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688
      $membership_type_tag = 'membertype_' . $membership_type_id . '-';
      $membershipType = civicrm_api3('MembershipType', 'get', array(
       'sequential' => 1,
       'return' => "name",
       'id' => $membership_type_id,
      ));
      $membership_name = $membershipType['values'][0]['name'];
    }

    // Currently plan_id is a unique db key. Therefore test plans of the
    // same name as a live plan fail to be added with a DB error Already exists,
    // which is a problem for testing.  This appends 'test' to a test
    // plan to avoid that error.
    $is_live = $this->_islive;
    $mode_tag = '';
    if ( $is_live == 0 ) {
      $mode_tag = '-test';
    }
    $plan_id = "{$membership_type_tag}every-{$frequency_interval}-{$frequency}-{$amount}-{$currency}{$mode_tag}";

689 690 691
    // Prepare escaped query params.
    $query_params = array(
      1 => array($plan_id, 'String'),
692
      2 => array($this->_paymentProcessor['id'], 'Integer'),
693 694 695 696
    );

    $stripe_plan_query = CRM_Core_DAO::singleValueQuery("SELECT plan_id
      FROM civicrm_stripe_plans
697
      WHERE plan_id = %1 AND is_live = '{$this->_islive}' AND processor_id = %2", $query_params);
drastik's avatar
drastik committed
698

699
    if (!isset($stripe_plan_query)) {
Rich's avatar
Rich committed
700
      $formatted_amount = number_format(($amount / 100), 2);
701 702 703 704
      $product = \Stripe\Product::create(array(
        "name" => "CiviCRM {$membership_name} every {$frequency_interval} {$frequency}(s) {$formatted_amount}{$currency}{$mode_tag}",
        "type" => "service"
      ));
drastik's avatar
drastik committed
705
      // Create a new Plan.
706
      $stripe_plan = array(
707 708
        'amount' => $amount,
        'interval' => $frequency,
709
        'product' => $product->id,
710
        'currency' => $currency,
711 712 713
        'id' => $plan_id,
        'interval_count' => $frequency_interval,
      );
714

peterh's avatar
peterh committed
715
      $ignores = array(
716
        array(
717
          'class' => 'Stripe_InvalidRequestError',
718 719 720
          'type' => 'invalid_request_error',
          'message' => 'Plan already exists.',
        ),
peterh's avatar
peterh committed
721
      );
722
      $this->stripeCatchErrors('create_plan', $stripe_plan, $params, $ignores);
723 724 725
      // Prepare escaped query params.
      $query_params = array(
        1 => array($plan_id, 'String'),
726
        2 => array($this->_paymentProcessor['id'], 'Integer'),
727
      );
728 729
      CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_plans (plan_id, is_live, processor_id)
        VALUES (%1, '{$this->_islive}', %2)", $query_params);
drastik's avatar
drastik committed
730 731
    }

732 733 734 735 736 737 738
    // As of Feb. 2014, Stripe handles multiple subscriptions per customer, even
    // ones of the exact same plan. To pave the way for that kind of support here,
    // were using subscription_id as the unique identifier in the
    // civicrm_stripe_subscription table, instead of using customer_id to derive
    // the invoice_id.  The proposed default behavor should be to always create a
    // new subscription. Upgrade/downgrades keep the same subscription id in Stripe
    // and we mirror this behavior by modifing our recurring contribution when this happens.
739 740 741
    // For now, updating happens in Webhook.php as a result of modifiying the subscription
    // in the UI at stripe.com. Eventually we'll initiating subscription changes
    // from within Civi and Stripe.php. The Webhook.php code should still be relevant.
742

drastik's avatar
drastik committed
743
    // Attach the Subscription to the Stripe Customer.
744 745 746 747
    $cust_sub_params = array(
      'prorate' => FALSE,
      'plan' => $plan_id,
    );
748 749
    $stripeSubscription = $stripeCustomer->subscriptions->create($cust_sub_params);
    $subscription_id = $stripeSubscription->id;
750
    $recuring_contribution_id = $params['contributionRecurID'];
751 752 753

    // Prepare escaped query params.
    $query_params = array(
754
      1 => array($subscription_id, 'String'),
755
      2 => array($stripeCustomer->id, 'String'),
756 757
      3 => array($recuring_contribution_id, 'String'),
      4 => array($this->_paymentProcessor['id'], 'Integer'),
758 759
    );

760 761
    // Insert the Stripe Subscription info.

762
    // Let end_time be NULL if installments are ongoing indefinitely
763 764
    if (empty($installments)) {
      CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_subscriptions
765 766 767 768 769
        (subscription_id, customer_id, contribution_recur_id, processor_id, is_live )
        VALUES (%1, %2, %3, %4,'{$this->_islive}')", $query_params);
    } else {
      // Calculate timestamp for the last installment.
      $end_time = strtotime("+{$installments} {$frequency}");
770
      // Add the end time to the query params.
771
      $query_params[5] = array($end_time, 'Integer');
772
      CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_subscriptions
773 774
        (subscription_id, customer_id, contribution_recur_id, processor_id, end_time, is_live)
        VALUES (%1, %2, %3, %4, %5, '{$this->_islive}')", $query_params);
775
    }
drastik's avatar
drastik committed
776

777
    //  Don't return a $params['trxn_id'] here or else recurring membership contribs will be set
778
    //  "Completed" prematurely.  Webhook.php does that.
Jamie McClelland's avatar
Jamie McClelland committed
779 780 781 782
    
    // Add subscription_id so tests can properly work with recurring
    // contributions. 
    $params['subscription_id'] = $subscription_id;
783

drastik's avatar
drastik committed
784
    return $params;
785

drastik's avatar
drastik committed
786 787 788
  }

  /**
Joshua Walker's avatar
Joshua Walker committed
789
   * Transfer method not in use.
drastik's avatar
drastik committed
790
   *
Joshua Walker's avatar
Joshua Walker committed
791 792
   * @param array $params
   *   Name value pair of contribution data.
drastik's avatar
drastik committed
793
   *
794
   * @throws \CiviCRM_API3_Exception
drastik's avatar
drastik committed
795
   */
796
  public function doTransferCheckout(&$params, $component) {
797
    self::doDirectPayment($params);
drastik's avatar
drastik committed
798
  }
mattwire's avatar
mattwire committed
799 800 801 802 803 804 805 806 807 808 809

  /**
   * Default payment instrument validation.
   *
   * Implement the usual Luhn algorithm via a static function in the CRM_Core_Payment_Form if it's a credit card
   * Not a static function, because I need to check for payment_type.
   *
   * @param array $values
   * @param array $errors
   */
  public function validatePaymentInstrument($values, &$errors) {
810 811
    // Use $_POST here and not $values - for webform fields are not set in $values, but are in $_POST
    CRM_Core_Form::validateMandatoryFields($this->getMandatoryFields(), $_POST, $errors);
mattwire's avatar
mattwire committed
812 813 814 815 816
    if ($this->_paymentProcessor['payment_type'] == 1) {
      // Don't validate credit card details as they are not passed (and stripe does this for us)
      //CRM_Core_Payment_Form::validateCreditCard($values, $errors, $this->_paymentProcessor['id']);
    }
  }
817

818 819
  /**
   * Process incoming notification.
Matthew Wire's avatar
Matthew Wire committed
820 821
   *
   * @throws \CRM_Core_Exception
822
   */
Matthew Wire's avatar
Matthew Wire committed
823
  public static function handlePaymentNotification() {
824 825 826 827 828
    $data_raw = file_get_contents("php://input");
    $data = json_decode($data_raw);
    $ipnClass = new CRM_Core_Payment_StripeIPN($data);
    $ipnClass->main();
  }
829

mattwire's avatar
mattwire committed
830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881

  /*******************************************************************
   * THE FOLLOWING FUNCTIONS SHOULD BE REMOVED ONCE THEY ARE IN CORE
   * getBillingEmail
   * getContactId
   ******************************************************************/

  /**
   * Get the billing email address
   *
   * @param array $params
   * @param int $contactId
   *
   * @return string|NULL
   */
  protected static function getBillingEmail($params, $contactId) {
    $billingLocationId = CRM_Core_BAO_LocationType::getBilling();

    $emailAddress = CRM_Utils_Array::value("email-{$billingLocationId}", $params,
      CRM_Utils_Array::value('email-Primary', $params,
        CRM_Utils_Array::value('email', $params, NULL)));

    if (empty($emailAddress) && !empty($contactId)) {
      // Try and retrieve an email address from Contact ID
      try {
        $emailAddress = civicrm_api3('Email', 'getvalue', array(
          'contact_id' => $contactId,
          'return' => ['email'],
        ));
      }
      catch (CiviCRM_API3_Exception $e) {
        return NULL;
      }
    }
    return $emailAddress;
  }

  /**
   * Get the contact id
   *
   * @param array $params
   *
   * @return int ContactID
   */
  protected static function getContactId($params) {
    return CRM_Utils_Array::value('contactID', $params,
      CRM_Utils_Array::value('contact_id', $params,
        CRM_Utils_Array::value('cms_contactID', $params,
          CRM_Utils_Array::value('cid', $params, NULL
          ))));
  }

882
}
883