Stripe.php 48.6 KB
Newer Older
drastik's avatar
drastik committed
1
<?php
mattwire's avatar
mattwire committed
2
3
4
5
6
7
8
9
/*
 +--------------------------------------------------------------------+
 | Copyright CiviCRM LLC. All rights reserved.                        |
 |                                                                    |
 | This work is published under the GNU AGPLv3 license with some      |
 | permitted exceptions and without any warranty. For full license    |
 | and copyright information, see https://civicrm.org/licensing       |
 +--------------------------------------------------------------------+
drastik's avatar
drastik committed
10
 */
11

12
use Civi\Api4\PaymentprocessorWebhook;
13
use CRM_Stripe_ExtensionUtil as E;
14
use Civi\Payment\PropertyBag;
mattwire's avatar
mattwire committed
15
use Stripe\Stripe;
16
use Civi\Payment\Exception\PaymentProcessorException;
mattwire's avatar
mattwire committed
17
use Stripe\Webhook;
18

mattwire's avatar
mattwire committed
19
20
21
/**
 * Class CRM_Core_Payment_Stripe
 */
drastik's avatar
drastik committed
22
class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
23

24
  use CRM_Core_Payment_MJWTrait;
drastik's avatar
drastik committed
25

26
27
28
29
30
  /**
   * @var \Stripe\StripeClient
   */
  public $stripeClient;

drastik's avatar
drastik committed
31
32
33
  /**
   * Constructor
   *
Joshua Walker's avatar
Joshua Walker committed
34
   * @param string $mode
35
   *   (deprecated) The mode of operation: live or test.
36
   * @param array $paymentProcessor
drastik's avatar
drastik committed
37
   */
38
  public function __construct($mode, $paymentProcessor) {
drastik's avatar
drastik committed
39
    $this->_paymentProcessor = $paymentProcessor;
Rich's avatar
Rich committed
40
41
42
43
44
45
46
47

    if (defined('STRIPE_PHPUNIT_TEST') && isset($GLOBALS['mockStripeClient'])) {
      // When under test, prefer the mock.
      $this->stripeClient = $GLOBALS['mockStripeClient'];

    }
    else {
      // Normally we create a new stripe client.
48
49
50
      $secretKey = self::getSecretKey($this->_paymentProcessor);
      // You can configure only one of live/test so don't initialize StripeClient if keys are blank
      if (!empty($secretKey)) {
51
        $this->setAPIParams();
52
53
        $this->stripeClient = new \Stripe\StripeClient($secretKey);
      }
Rich's avatar
Rich committed
54
    }
drastik's avatar
drastik committed
55
56
  }

mattwire's avatar
mattwire committed
57
58
59
60
61
62
  /**
   * @param array $paymentProcessor
   *
   * @return string
   */
  public static function getSecretKey($paymentProcessor) {
63
    return trim($paymentProcessor['password'] ?? '');
mattwire's avatar
mattwire committed
64
65
66
67
68
69
70
71
  }

  /**
   * @param array $paymentProcessor
   *
   * @return string
   */
  public static function getPublicKey($paymentProcessor) {
72
    return trim($paymentProcessor['user_name'] ?? '');
mattwire's avatar
mattwire committed
73
74
  }

mattwire's avatar
mattwire committed
75
76
77
78
79
80
81
  /**
   * @return string
   */
  public function getWebhookSecret(): string {
    return trim($this->_paymentProcessor['signature']);
  }

mattwire's avatar
mattwire committed
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
  /**
   * Given a payment processor id, return the public key
   *
   * @param $paymentProcessorId
   *
   * @return string
   */
  public static function getPublicKeyById($paymentProcessorId) {
    try {
      $paymentProcessor = civicrm_api3('PaymentProcessor', 'getsingle', [
        'id' => $paymentProcessorId,
      ]);
      $key = self::getPublicKey($paymentProcessor);
    }
    catch (CiviCRM_API3_Exception $e) {
      return '';
    }
    return $key;
  }

  /**
   * Given a payment processor id, return the secret key
   *
   * @param $paymentProcessorId
   *
   * @return string
   */
  public static function getSecretKeyById($paymentProcessorId) {
    try {
      $paymentProcessor = civicrm_api3('PaymentProcessor', 'getsingle', [
        'id' => $paymentProcessorId,
      ]);
      $key = self::getSecretKey($paymentProcessor);
    }
    catch (CiviCRM_API3_Exception $e) {
      return '';
    }
    return $key;
  }

drastik's avatar
drastik committed
122
  /**
Joshua Walker's avatar
Joshua Walker committed
123
   * This function checks to see if we have the right config values.
drastik's avatar
drastik committed
124
   *
mattwire's avatar
mattwire committed
125
   * @return null|string
Joshua Walker's avatar
Joshua Walker committed
126
   *   The error message if any.
drastik's avatar
drastik committed
127
   */
128
  public function checkConfig() {
129
    $error = [];
drastik's avatar
drastik committed
130
131
132
133
134
135
136
137
138

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

mattwire's avatar
mattwire committed
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
  /**
   * Override CRM_Core_Payment function
   *
   * @return string
   */
  public function getPaymentTypeName() {
    return 'credit_card';
  }

  /**
   * Override CRM_Core_Payment function
   *
   * @return string
   */
  public function getPaymentTypeLabel() {
    return E::ts('Stripe');
  }

157
  /**
158
   * We can use the stripe processor on the backend
159
160
161
   * @return bool
   */
  public function supportsBackOffice() {
mattwire's avatar
mattwire committed
162
    return TRUE;
163
164
165
  }

  /**
166
   * We can edit stripe recurring contributions
167
168
169
170
171
172
   * @return bool
   */
  public function supportsEditRecurringContribution() {
    return FALSE;
  }

173
  public function supportsRecurring() {
174
    return TRUE;
175
176
  }

177
178
179
180
181
182
183
184
185
  /**
   * Does this payment processor support refund?
   *
   * @return bool
   */
  public function supportsRefund() {
    return TRUE;
  }

186
  /**
187
   * Can we set a future recur start date?  Stripe allows this but we don't (yet) support it.
188
189
190
   * @return bool
   */
  public function supportsFutureRecurStartDate() {
191
    return TRUE;
192
193
  }

194
195
196
197
198
199
200
201
202
  /**
   * Is an authorize-capture flow supported.
   *
   * @return bool
   */
  protected function supportsPreApproval() {
    return TRUE;
  }

mattwire's avatar
mattwire committed
203
204
205
206
207
208
209
210
211
212
213
214
  /**
   * Does this processor support cancelling recurring contributions through code.
   *
   * If the processor returns true it must be possible to take action from within CiviCRM
   * that will result in no further payments being processed.
   *
   * @return bool
   */
  protected function supportsCancelRecurring() {
    return TRUE;
  }

215
216
217
218
219
220
221
222
223
224
  /**
   * Does the processor support the user having a choice as to whether to cancel the recurring with the processor?
   *
   * If this returns TRUE then there will be an option to send a cancellation request in the cancellation form.
   *
   * This would normally be false for processors where CiviCRM maintains the schedule.
   *
   * @return bool
   */
  protected function supportsCancelRecurringNotifyOptional() {
225
    return TRUE;
226
227
  }

228
229
230
231
232
  /**
   * Get the currency for the transaction.
   *
   * Handle any inconsistency about how it is passed in here.
   *
233
   * @param array|PropertyBag $params
234
   *
235
   * @return string
236
   */
237
  public function getAmount($params = []): string {
238
    $amount = number_format((float) $params['amount'] ?? 0.0, CRM_Utils_Money::getCurrencyPrecision($this->getCurrency($params)), '.', '');
239
    // Stripe amount required in cents.
240
    $amount = preg_replace('/[^\d]/', '', strval($amount));
241
    return $amount;
242
243
  }

Joshua Walker's avatar
Joshua Walker committed
244
  /**
245
   * Set API parameters for Stripe (such as identifier, api version, api key)
246
   */
247
  public function setAPIParams() {
248
    // Use CiviCRM log file
mattwire's avatar
mattwire committed
249
    Stripe::setLogger(\Civi::log());
250
    // Attempt one retry (Stripe default is 0) if we can't connect to Stripe servers
mattwire's avatar
mattwire committed
251
    Stripe::setMaxNetworkRetries(1);
252
    // Set plugin info and API credentials.
mattwire's avatar
mattwire committed
253
254
255
    Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
    Stripe::setApiKey(self::getSecretKey($this->_paymentProcessor));
    Stripe::setApiVersion(CRM_Stripe_Check::API_VERSION);
256
257
258
259
260
261
262
263
264
265
  }

  /**
   * 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)
   */
266
267
  public function handleErrorNotification($err, $bounceURL = NULL) {
    return self::handleError("{$err['type']} {$err['code']}", $err['message'], $bounceURL);
268
269
  }

270
271
272
273
274
275
276
277
  /**
   * Stripe exceptions contain a json object in the body "error". This function extracts and returns that as an array.
   * @param String $op
   * @param Exception $e
   * @param Boolean $log
   *
   * @return array $err
   */
278
279
  public static function parseStripeException($op, $e, $log = FALSE) {
    $body = $e->getJsonBody();
280
    if ($log) {
281
      Civi::log()->error("Stripe_Error {$op}: " . print_r($body, TRUE));
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
    }
    $err = $body['error'];
    if (!isset($err['code'])) {
      // A "fake" error code
      $err['code'] = 9000;
    }
    return $err;
  }

  /**
   * Create or update a Stripe Plan
   *
   * @param array $params
   * @param integer $amount
   *
   * @return \Stripe\Plan
   */
  public function createPlan($params, $amount) {
300
    $currency = $this->getCurrency($params);
301
    $planId = "every-{$params['recurFrequencyInterval']}-{$params['recurFrequencyUnit']}-{$amount}-" . strtolower($currency);
302

303
    if ($this->_paymentProcessor['is_test']) {
304
305
306
307
308
309
      $planId .= '-test';
    }

    // Try and retrieve existing plan from Stripe
    // If this fails, we'll create a new one
    try {
310
      $plan = $this->stripeClient->plans->retrieve($planId);
311
    }
312
    catch (Stripe\Exception\InvalidRequestException $e) {
313
      $err = self::parseStripeException('plan_retrieve', $e, FALSE);
314
315
      if ($err['code'] === 'resource_missing') {
        $formatted_amount = CRM_Utils_Money::formatLocaleNumericRoundedByCurrency(($amount / 100), $currency);
316
        $productName = "CiviCRM " . (isset($params['membership_name']) ? $params['membership_name'] . ' ' : '') . "every {$params['recurFrequencyInterval']} {$params['recurFrequencyUnit']}(s) {$currency}{$formatted_amount}";
317
        if ($this->_paymentProcessor['is_test']) {
318
319
          $productName .= '-test';
        }
320
        $product = $this->stripeClient->products->create([
321
322
          "name" => $productName,
          "type" => "service"
323
        ]);
324
        // Create a new Plan.
325
        $stripePlan = [
326
          'amount' => $amount,
327
          'interval' => $params['recurFrequencyUnit'],
328
329
330
          'product' => $product->id,
          'currency' => $currency,
          'id' => $planId,
331
          'interval_count' => $params['recurFrequencyInterval'],
332
        ];
333
        $plan = $this->stripeClient->plans->create($stripePlan);
334
335
336
337
338
      }
    }

    return $plan;
  }
mattwire's avatar
mattwire committed
339
340
  /**
   * Override CRM_Core_Payment function
mattwire's avatar
mattwire committed
341
342
   *
   * @return array
mattwire's avatar
mattwire committed
343
344
   */
  public function getPaymentFormFields() {
345
    return [];
mattwire's avatar
mattwire committed
346
347
348
349
350
351
352
353
354
355
356
  }

  /**
   * 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() {
357
    return [];
mattwire's avatar
mattwire committed
358
359
  }

360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
  /**
   * Get billing fields required for this processor.
   *
   * We apply the existing default of returning fields only for payment processor type 1. Processors can override to
   * alter.
   *
   * @param int $billingLocationID
   *
   * @return array
   */
  public function getBillingAddressFields($billingLocationID = NULL) {
    if ((boolean) \Civi::settings()->get('stripe_nobillingaddress')) {
      return [];
    }
    else {
      return parent::getBillingAddressFields($billingLocationID);
    }
  }

379
380
381
382
383
384
385
386
387
  /**
   * Get form metadata for billing address fields.
   *
   * @param int $billingLocationID
   *
   * @return array
   *    Array of metadata for address fields.
   */
  public function getBillingAddressFieldsMetadata($billingLocationID = NULL) {
388
389
    if ((boolean) \Civi::settings()->get('stripe_nobillingaddress')) {
      return [];
390
    }
391
392
393
394
395
396
397
398
    else {
      $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();
      }
399

400
401
402
403
404
405
406
407
408
      // Stripe does not require some of the billing fields but users may still choose to fill them in.
      $nonRequiredBillingFields = [
        "billing_state_province_id-{$billingLocationID}",
        "billing_postal_code-{$billingLocationID}"
      ];
      foreach ($nonRequiredBillingFields as $fieldName) {
        if (!empty($metadata[$fieldName]['is_required'])) {
          $metadata[$fieldName]['is_required'] = FALSE;
        }
409
      }
410

411
412
      return $metadata;
    }
413
414
  }

Peter Hartmann's avatar
Peter Hartmann committed
415
  /**
416
   * Set default values when loading the (payment) form
417
   *
418
   * @param \CRM_Core_Form $form
Peter Hartmann's avatar
Peter Hartmann committed
419
   */
420
  public function buildForm(&$form) {
421
    // Don't use \Civi::resources()->addScriptFile etc as they often don't work on AJAX loaded forms (eg. participant backend registration)
422
423
    $jsVars = [
      'id' => $form->_paymentProcessor['id'],
424
      'currency' => $this->getDefaultCurrencyForForm($form),
425
426
      'billingAddressID' => CRM_Core_BAO_LocationType::getBilling(),
      'publishableKey' => CRM_Core_Payment_Stripe::getPublicKeyById($form->_paymentProcessor['id']),
427
      'paymentProcessorTypeID' => $form->_paymentProcessor['payment_processor_type_id'],
428
      'locale' => CRM_Stripe_Api::mapCiviCRMLocaleToStripeLocale(),
429
      'apiVersion' => CRM_Stripe_Check::API_VERSION,
430
      'csrfToken' => class_exists('\Civi\Firewall\Firewall') ? \Civi\Firewall\Firewall::getCSRFToken() : NULL,
431
      'country' => \Civi::settings()->get('stripe_country'),
432
    ];
mattwire's avatar
mattwire committed
433

434
435
436
    // Add help and javascript
    CRM_Core_Region::instance('billing-block')->add(
      ['template' => 'CRM/Core/Payment/Stripe/Card.tpl', 'weight' => -1]);
437
    // Add CSS via region (it won't load on drupal webform if added via \Civi::resources()->addStyleFile)
mattwire's avatar
mattwire committed
438

439
    CRM_Core_Region::instance('billing-block')->add([
440
441
      'styleUrl' => \Civi::service('asset_builder')->getUrl(
        'elements.css',
mattwire's avatar
mattwire committed
442
443
444
445
        [
          'path' => \Civi::resources()->getPath(E::LONG_NAME, 'css/elements.css'),
          'mimetype' => 'text/css',
        ]
446
      ),
447
448
      'weight' => -1,
    ]);
449
    CRM_Core_Region::instance('billing-block')->add([
450
451
      'scriptUrl' => \Civi::service('asset_builder')->getUrl(
        'civicrmStripe.js',
mattwire's avatar
mattwire committed
452
453
454
455
        [
          'path' => \Civi::resources()->getPath(E::LONG_NAME, 'js/civicrm_stripe.js'),
          'mimetype' => 'application/javascript',
        ]
mattwire's avatar
mattwire committed
456
457
458
      ),
      // Load after other scripts on form (default = 1)
      'weight' => 100,
459
    ]);
460

461
462
463
464
465
466
467
468
469
    // Add the future recur start date functionality
    CRM_Stripe_Recur::buildFormFutureRecurStartDate($form, $this, $jsVars);

    \Civi::resources()->addVars(E::SHORT_NAME, $jsVars);
    // Assign to smarty so we can add via Card.tpl for drupal webform because addVars doesn't work in that context
    $form->assign('stripeJSVars', $jsVars);

    // Enable JS validation for forms so we only (submit) create a paymentIntent when the form has all fields validated.
    $form->assign('isJsValidate', TRUE);
Peter Hartmann's avatar
Peter Hartmann committed
470
  }
471

472
473
474
475
476
477
478
479
480
481
482
483
484
  /**
   * Function to action pre-approval if supported
   *
   * @param array $params
   *   Parameters from the form
   *
   * This function returns an array which should contain
   *   - pre_approval_parameters (this will be stored on the calling form & available later)
   *   - redirect_url (if set the browser will be redirected to this.
   *
   * @return array
   */
  public function doPreApproval(&$params) {
485
486
487
    $preApprovalParams['paymentIntentID'] = CRM_Utils_Request::retrieve('paymentIntentID', 'String');
    $preApprovalParams['paymentMethodID'] = CRM_Utils_Request::retrieve('paymentMethodID', 'String');
    return ['pre_approval_parameters' => $preApprovalParams];
488
489
490
491
492
493
494
495
496
497
498
499
500
  }

  /**
   * Get any details that may be available to the payment processor due to an approval process having happened.
   *
   * In some cases the browser is redirected to enter details on a processor site. Some details may be available as a
   * result.
   *
   * @param array $storedDetails
   *
   * @return array
   */
  public function getPreApprovalDetails($storedDetails) {
501
    return $storedDetails ?? [];
502
503
  }

drastik's avatar
drastik committed
504
  /**
505
   * Process payment
drastik's avatar
drastik committed
506
507
   * Submit a payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
508
   * Payment processors should set payment_status_id/payment_status.
drastik's avatar
drastik committed
509
   *
510
   * @param array|PropertyBag $propertyBag
Joshua Walker's avatar
Joshua Walker committed
511
   *   Assoc array of input parameters for this transaction.
512
   * @param string $component
drastik's avatar
drastik committed
513
   *
514
515
516
   * @return array
   *   Result array
   *
517
518
   * @throws \CRM_Core_Exception
   * @throws \CiviCRM_API3_Exception
519
   * @throws \Civi\Payment\Exception\PaymentProcessorException
drastik's avatar
drastik committed
520
   */
521
522
523
  public function doPayment(&$propertyBag, $component = 'contribute') {
    /* @var \Civi\Payment\PropertyBag $propertyBag */
    $propertyBag = \Civi\Payment\PropertyBag::cast($propertyBag);
524
525
526
527
528

    $zeroAmountPayment = $this->processZeroAmountPayment($propertyBag);
    if ($zeroAmountPayment) {
      return $zeroAmountPayment;
    }
529
530
531
532
533
    $propertyBag = $this->beginDoPayment($propertyBag);

    $isRecur = ($propertyBag->getIsRecur() && $this->getRecurringContributionId($propertyBag));
    if ($isRecur || $this->isPaymentForEventAdditionalParticipants($propertyBag)) {
      $propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, TRUE);
534
535
    }
    else {
536
      $propertyBag = $this->getTokenParameter('paymentIntentID', $propertyBag, TRUE);
537
    }
538

539
540
    // @fixme DO NOT SET ANYTHING ON $propertyBag or $params BELOW THIS LINE (we are reading from both)
    $params = $this->getPropertyBagAsArray($propertyBag);
541
542
543
544

    // We don't actually use this hook with Stripe, but useful to trigger so listeners can see raw params
    $newParams = [];
    CRM_Utils_Hook::alterPaymentProcessorParams($this, $params, $newParams);
drastik's avatar
drastik committed
545

546
547
    $amountFormattedForStripe = self::getAmount($params);
    $email = $this->getBillingEmail($params, $propertyBag->getContactID());
drastik's avatar
drastik committed
548

549
550
    // See if we already have a stripe customer
    $customerParams = [
551
      'contact_id' => $propertyBag->getContactID(),
552
553
      'processor_id' => $this->_paymentProcessor['id'],
      'email' => $email,
554
      // Include this to allow redirect within session on payment failure
555
      'error_url' => $propertyBag->getCustomProperty('error_url'),
556
    ];
557

558
559
560
561
    // Get the Stripe Customer:
    //   1. Look for an existing customer.
    //   2. If no customer (or a deleted customer found), create a new one.
    //   3. If existing customer found, update the metadata that Stripe holds for this customer.
562
    $stripeCustomerId = CRM_Stripe_Customer::find($customerParams);
563
    // Customer not in civicrm database.  Create a new Customer in Stripe.
564
    if (!isset($stripeCustomerId)) {
565
      $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
566
567
    }
    else {
568
      // Customer was found in civicrm database, fetch from Stripe.
569
      try {
570
        $stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomerId);
571
      } catch (Exception $e) {
572
        $err = self::parseStripeException('retrieve_customer', $e, FALSE);
573
        $errorMessage = $this->handleErrorNotification($err, $propertyBag->getCustomProperty('error_url'));
574
        throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to retrieve Stripe Customer: ' . $errorMessage);
575
      }
mattwire's avatar
mattwire committed
576

577
      if ($stripeCustomer->isDeleted()) {
578
579
580
        // Customer doesn't exist, create a new one
        CRM_Stripe_Customer::delete($customerParams);
        try {
581
582
          $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
        } catch (Exception $e) {
583
          // We still failed to create a customer
584
585
          $err = self::parseStripeException('create_customer', $e, FALSE);
          $errorMessage = $this->handleErrorNotification($err, $propertyBag->getCustomProperty('error_url'));
586
          throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Customer: ' . $errorMessage);
587
588
        }
      }
589
590
591
      else {
        CRM_Stripe_Customer::updateMetadata($customerParams, $this, $stripeCustomer->id);
      }
drastik's avatar
drastik committed
592
593
    }

594
    // Handle recurring payments in doRecurPayment().
595
    if ($isRecur) {
596
597
      // We're processing a recurring payment - for recurring payments we first saved a paymentMethod via the browser js.
      // Now we use that paymentMethod to setup a stripe subscription and take the first payment.
598
599
600
      // This is where we save the customer card
      // @todo For a recurring payment we have to save the card. For a single payment we'd like to develop the
      //   save card functionality but should not save by default as the customer has not agreed.
Rich's avatar
Rich committed
601
602
      $paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
      $paymentMethod = $this->stripeClient->paymentMethods->retrieve($paymentMethodID);
603
      $paymentMethod->attach(['customer' => $stripeCustomer->id]);
604
      $stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomer->id);
605

606
      return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer, $paymentMethod);
607
    }
608
    elseif ($this->isPaymentForEventAdditionalParticipants($propertyBag)) {
609
610
      // We're processing an event registration for multiple participants - because we did not know
      //   the amount until now we process via a saved paymentMethod.
611
      $paymentMethod = $this->stripeClient->paymentMethods->retrieve($propertyBag->getCustomProperty('paymentMethodID'));
612
      $paymentMethod->attach(['customer' => $stripeCustomer->id]);
613
614
615
      $stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomer->id);
      $intent = $this->stripeClient->paymentIntents->create([
        'payment_method' => $propertyBag->getCustomProperty('paymentMethodID'),
616
        'customer' => $stripeCustomer->id,
617
        'amount' => $amountFormattedForStripe,
618
619
620
621
622
623
624
625
        'currency' => $this->getCurrency($params),
        'confirmation_method' => 'automatic',
        'capture_method' => 'manual',
        // authorize the amount but don't take from card yet
        'setup_future_usage' => 'off_session',
        // Setup the card to be saved and used later
        'confirm' => true,
      ]);
626
627

      $propertyBag->setCustomProperty('paymentIntentID', $intent->id);
628
629
      $params['paymentIntentID'] = $intent->id;
    }
630
    // @fixme FROM HERE we are using $params ONLY - SET things if required ($propertyBag is not used beyond here)
631
    //   Note that we set both $propertyBag and $params paymentIntentID in the case of participants above
632

633
634
    $intentParams = [
      'customer' => $stripeCustomer->id,
635
      'description' => $this->getDescription($params, 'description'),
636
    ];
637
638
    $intentParams['statement_descriptor_suffix'] = $this->getDescription($params, 'statement_descriptor_suffix');
    $intentParams['statement_descriptor'] = $this->getDescription($params, 'statement_descriptor');
639

640
    // This is where we actually charge the customer
641
    try {
642
      $intent = $this->stripeClient->paymentIntents->retrieve($propertyBag->getCustomProperty('paymentIntentID'));
643
      if ($intent->amount != $this->getAmount($params)) {
644
        $intentParams['amount'] = $this->getAmount($params);
645
      }
646
      $intent = $this->stripeClient->paymentIntents->update($intent->id, $intentParams);
drastik's avatar
drastik committed
647
    }
648
    catch (Exception $e) {
649
      $this->handleError($e->getCode(), $e->getMessage(), $params['error_url']);
drastik's avatar
drastik committed
650
    }
651

652
    $params = $this->processPaymentIntent($params, $intent);
653
654
655
656

    // For a single charge there is no stripe invoice, we set OrderID to the ChargeID.
    if (empty($this->getPaymentProcessorOrderID())) {
      $this->setPaymentProcessorOrderID($this->getPaymentProcessorTrxnID());
657
658
    }

659
660
    // For contribution workflow we have a contributionId so we can set parameters directly.
    // For events/membership workflow we have to return the parameters and they might get set...
661
    return $this->endDoPayment($params);
drastik's avatar
drastik committed
662
663
  }

664
  /**
665
666
   * @param \Civi\Payment\PropertyBag $params
   *
667
668
   * @return bool
   */
669
  private function isPaymentForEventAdditionalParticipants($params) {
mattwire's avatar
mattwire committed
670
671
672
673
    if ($params->getter('additional_participants', TRUE)) {
      return TRUE;
    }
    return FALSE;
674
675
  }

drastik's avatar
drastik committed
676
677
678
679
  /**
   * Submit a recurring payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
   *
680
681
682
683
   * @param \Civi\Payment\PropertyBag $propertyBag
   *   PropertyBag for this transaction.
   * @param int $amountFormattedForStripe
   *   Transaction amount in cents.
684
   * @param \Stripe\Customer $stripeCustomer
Joshua Walker's avatar
Joshua Walker committed
685
   *   Stripe customer object generated by Stripe API.
686
   * @param \Stripe\PaymentMethod $stripePaymentMethod
drastik's avatar
drastik committed
687
   *
drastik's avatar
drastik committed
688
   * @return array
Joshua Walker's avatar
Joshua Walker committed
689
   *   The result in a nice formatted array (or an error object).
drastik's avatar
drastik committed
690
   *
mattwire's avatar
mattwire committed
691
   * @throws \CiviCRM_API3_Exception
692
   * @throws \CRM_Core_Exception
drastik's avatar
drastik committed
693
   */
694
695
696
697
698
699
  public function doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer, $stripePaymentMethod) {
    $params = $this->getPropertyBagAsArray($propertyBag);

    // @fixme FROM HERE we are using $params array (but some things are READING from $propertyBag)

    // We set payment status as pending because the IPN will set it as completed / failed
700
    $params = $this->setStatusPaymentPending($params);
701

702
    $required = NULL;
703
    if (empty($this->getRecurringContributionId($propertyBag))) {
704
705
      $required = 'contributionRecurID';
    }
706
707
    if (!isset($params['recurFrequencyUnit'])) {
      $required = 'recurFrequencyUnit';
708
709
710
711
    }
    if ($required) {
      Civi::log()->error('Stripe doRecurPayment: Missing mandatory parameter: ' . $required);
      throw new CRM_Core_Exception('Stripe doRecurPayment: Missing mandatory parameter: ' . $required);
712
713
    }

714
715
    // Make sure recurFrequencyInterval is set (default to 1 if not)
    empty($params['recurFrequencyInterval']) ? $params['recurFrequencyInterval'] = 1 : NULL;
716

717
    // Create the stripe plan
718
    $planId = self::createPlan($params, $amountFormattedForStripe);
drastik's avatar
drastik committed
719

drastik's avatar
drastik committed
720
    // Attach the Subscription to the Stripe Customer.
721
    $subscriptionParams = [
722
      'proration_behavior' => 'none',
723
      'plan' => $planId,
724
      'default_payment_method' => $stripePaymentMethod,
725
726
      'metadata' => ['Description' => $params['description']],
      'expand' => ['latest_invoice.payment_intent'],
727
      'customer' => $stripeCustomer->id,
728
    ];
729
730
731
732
733
734
    // This is the parameter that specifies the start date for the subscription.
    // If omitted the subscription will start immediately.
    $billingCycleAnchor = $this->getRecurBillingCycleDay($params);
    if ($billingCycleAnchor) {
      $subscriptionParams['billing_cycle_anchor'] = $billingCycleAnchor;
    }
735

736
    // Create the stripe subscription for the customer
737
    $stripeSubscription = $this->stripeClient->subscriptions->create($subscriptionParams);
738
    $this->setPaymentProcessorSubscriptionID($stripeSubscription->id);
739
740

    $recurParams = [
741
      'id' =>     $this->getRecurringContributionId($propertyBag),
mattwire's avatar
mattwire committed
742
743
744
      // @fixme trxn_id/processor_id - see https://lab.civicrm.org/dev/financial/-/issues/57#note_19168
      //   We need to set them both but one should be removed. doCancelRecurring()/updateSubscriptionBillingInfo() both
      //   get processor_id
745
746
      'trxn_id' => $this->getPaymentProcessorSubscriptionID(),
      'processor_id' => $this->getPaymentProcessorSubscriptionID(),
747
748
749
      'auto_renew' => 1,
      'next_sched_contribution_date' => $this->calculateNextScheduledDate($params),
    ];
750
    $recurParams['cycle_day'] = date('d', strtotime($recurParams['next_sched_contribution_date']));
751
752
    if (!empty($params['installments'])) {
      // We set an end date if installments > 0
753
754
      if (empty($params['receive_date'])) {
        $params['receive_date'] = date('YmdHis');
755
756
757
      }
      if ($params['installments']) {
        $recurParams['end_date'] = $this->calculateEndDate($params);
758
        $recurParams['installments'] = $params['installments'];
759
760
      }
    }
761

762
763
764
765
    // Hook to allow modifying recurring contribution params
    CRM_Stripe_Hook::updateRecurringContribution($recurParams);
    // Update the recurring payment
    civicrm_api3('ContributionRecur', 'create', $recurParams);
766

Rich's avatar
Rich committed
767
    // artfulrobot: Q. what do we normally get here?
768
769
770
    if ($stripeSubscription->latest_invoice) {
      // Get the paymentIntent for the latest invoice
      $intent = $stripeSubscription->latest_invoice['payment_intent'];
771
      $params = $this->processPaymentIntent($params, $intent);
772

773
774
775
776
777
      // Set the orderID (trxn_id) to the invoice ID
      // The IPN will change it to the charge_id
      $this->setPaymentProcessorOrderID($stripeSubscription->latest_invoice['id']);
    }
    else {
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
      // Update the paymentIntent in the CiviCRM database for later tracking
      // If we are not starting the recurring series immediately we probably have a "setupIntent" which needs confirming
      $intentParams = [
        'stripe_intent_id' => $intent->id ?? $stripeSubscription->pending_setup_intent ?? $propertyBag->getCustomProperty('paymentMethodID'),
        'payment_processor_id' => $this->_paymentProcessor['id'],
        'contribution_id' =>  $params['contributionID'] ?? NULL,
        'identifier' => $params['qfKey'] ?? NULL,
        'contact_id' => $params['contactID'],
      ];
      try {
        $intentParams['id'] = civicrm_api3('StripePaymentintent', 'getvalue', ['stripe_intent_id' => $propertyBag->getCustomProperty('paymentMethodID'), 'return' => 'id']);
      }
      catch (Exception $e) {
        // Do nothing, we should already have a StripePaymentintent record but we don't so we'll create one.
      }

      if (empty($intentParams['contribution_id'])) {
        $intentParams['flags'][] = 'NC';
      }
      CRM_Stripe_BAO_StripePaymentintent::create($intentParams);
798
799
800
801
      // Set the orderID (trxn_id) to the subscription ID because we don't yet have an invoice.
      // The IPN will change it to the invoice_id and then the charge_id
      $this->setPaymentProcessorOrderID($stripeSubscription->id);
    }
802

803
    return $this->endDoPayment($params);
804
805
  }

806
807
808
809
810
811
  /**
   * Get the billing cycle day (timestamp)
   * @param array $params
   *
   * @return int|null
   */
812
  private function getRecurBillingCycleDay($params) {
813
    if (isset($params['receive_date'])) {
814
815
816
817
818
819
      $receiveDateTimestamp = strtotime($params['receive_date']);
      // If `receive_date` was set to "now" it will be in the past (by a few seconds) by the time we actually send it to Stripe.
      if ($receiveDateTimestamp > strtotime('now')) {
        // We've specified a receive_date in the future, use it!
        return $receiveDateTimestamp;
      }
820
    }
821
    // Either we had no receive_date or receive_date was in the past (or "now" when form was submitted).
822
    return NULL;
823
824
  }

825
826
827
828
829
  /**
   * This performs the processing and recording of the paymentIntent for both recurring and non-recurring payments
   * @param array $params
   * @param \Stripe\PaymentIntent $intent
   *
830
   * @return array $params
831
832
   */
  private function processPaymentIntent($params, $intent) {
833
    $contactId = $params['contactID'];
834
835
836
837
838
839
840
841
842
843
    $email = $this->getBillingEmail($params, $contactId);

    try {
      if ($intent->status === 'requires_confirmation') {
        $intent->confirm();
      }

      switch ($intent->status) {
        case 'requires_capture':
          $intent->capture();
mattwire's avatar
mattwire committed
844
        case 'succeeded':
845
846
847
          // Return fees & net amount for Civi reporting.
          $stripeCharge = $intent->charges->data[0];
          try {
848
            $stripeBalanceTransaction = $this->stripeClient->balanceTransactions->retrieve($stripeCharge->balance_transaction);
849
850
851
          }
          catch (Exception $e) {
            $err = self::parseStripeException('retrieve_balance_transaction', $e, FALSE);
852
            $errorMessage = $this->handleErrorNotification($err, $params['error_url']);
853
854
            throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to retrieve Stripe Balance Transaction: ' . $errorMessage);
          }
855
856
          if (($stripeCharge['currency'] !== $stripeBalanceTransaction->currency)
              && (!empty($stripeBalanceTransaction->exchange_rate))) {
857
            $params['fee_amount'] = CRM_Stripe_Api::currencyConversion($stripeBalanceTransaction->fee, $stripeBalanceTransaction['exchange_rate'], $stripeCharge['currency']);
858
859
          }
          else {
860
861
            // We must round to currency precision otherwise payments may fail because Contribute BAO saves but then
            // can't retrieve because it tries to use the full unrounded number when it only got saved with 2dp.
862
            $params['fee_amount'] = round($stripeBalanceTransaction->fee / 100, CRM_Utils_Money::getCurrencyPrecision($stripeCharge['currency']));
863
          }
864
865
          // Success!
          // Set the desired contribution status which will be set later (do not set on the contribution here!)
866
          $params = $this->setStatusPaymentCompleted($params);
867
868
869
870
871
872
873
874
          // Transaction ID is always stripe Charge ID.
          $this->setPaymentProcessorTrxnID($stripeCharge->id);

        case 'requires_action':
          // We fall through to this in requires_capture / requires_action so we always set a receipt_email
          if ((boolean) \Civi::settings()->get('stripe_oneoffreceipt')) {
            // Send a receipt from Stripe - we have to set the receipt_email after the charge has been captured,
            //   as the customer receives an email as soon as receipt_email is updated and would receive two if we updated before capture.
875
            $this->stripeClient->paymentIntents->update($intent->id, ['receipt_email' => $email]);
876
877
878
879
880
          }
          break;
      }
    }
    catch (Exception $e) {
881
      $this->handleError($e->getCode(), $e->getMessage(), $params['error_url']);
882
883
884
885
    }

    // Update the paymentIntent in the CiviCRM database for later tracking
    $intentParams = [
886
      'stripe_intent_id' => $intent->id,
887
888
      'payment_processor_id' => $this->_paymentProcessor['id'],
      'status' => $intent->status,
889
      'contribution_id' =>  $params['contributionID'] ?? NULL,
890
      'description' => $this->getDescription($params, 'description'),
891
      'identifier' => $params['qfKey'] ?? NULL,
892
      'contact_id' => $params['contactID'],
893
894
895
896
897
898
    ];
    if (empty($intentParams['contribution_id'])) {
      $intentParams['flags'][] = 'NC';
    }
    CRM_Stripe_BAO_StripePaymentintent::create($intentParams);

899
    return $params;
900
901
  }

902
903
904
905
906
907
  /**
   * Submit a refund payment
   *
   * @param array $params
   *   Assoc array of input parameters for this transaction.
   *
908
   * @return array
909
910
   * @throws \Civi\Payment\Exception\PaymentProcessorException
   */
911
  public function doRefund(&$params) {
912
    $requiredParams = ['trxn_id', 'amount'];
913
914
915
916
    foreach ($requiredParams as $required) {
      if (!isset($params[$required])) {
        $message = 'Stripe doRefund: Missing mandatory parameter: ' . $required;
        Civi::log()->error($message);
917
        throw new \Civi\Payment\Exception\PaymentProcessorException($message);
918
919
      }
    }
920

921
    $refundParams = [
922
      'charge' => $params['trxn_id'],
923
    ];
924
    $refundParams['amount'] = $this->getAmount($params);
925
    try {
926
      $refund = $this->stripeClient->refunds->create($refundParams);
927
928
929
930
931
    }
    catch (Exception $e) {
      $this->handleError($e->getCode(), $e->getMessage());
      Throw new \Civi\Payment\Exception\PaymentProcessorException($e->getMessage());
    }
932
933
934
935

    switch ($refund->status) {
      case 'pending':
        $refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
936
        $refundStatusName = 'Pending';
937
938
939
940
        break;

      case 'succeeded':
        $refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
941
        $refundStatusName = 'Completed';
942
943
944
945
        break;

      case 'failed':
        $refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');
946
        $refundStatusName = 'Failed';
947
948
949
950
        break;

      case 'canceled':
        $refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
951
        $refundStatusName = 'Cancelled';
952
953
954
955
956
957
        break;
    }

    $refundParams = [
      'refund_trxn_id' => $refund->id,
      'refund_status_id' => $refundStatus,
958
      'refund_status_name' => $refundStatusName,
959
960
961
      'processor_result' => $refund->jsonSerialize(),
    ];
    return $refundParams;
962
963
  }

964
965
966
967
968
969
970
971
972
  /**
   * Get a description field
   * @param array $params
   * @param string $type
   *   One of description, statement_descriptor, statement_descriptor_suffix
   *
   * @return string
   */
  private function getDescription($params, $type = 'description') {
973
    # See https://stripe.com/docs/statement-descriptors
974
975
976
    # And note: both the descriptor and the descriptor suffix must have at
    # least one alphabetical character - so we ensure that all returned
    # statement descriptors minimally have an "X".
mattwire's avatar
mattwire committed
977
    $disallowed_characters = ['<', '>', '\\', "'", '"', '*'];
978

979
    $contactContributionID = $params['contactID'] . 'X' . ($params['contributionID'] ?? 'XX');
980
    switch ($type) {
981
982
983
984
985
986
      // For statement_descriptor / statement_descriptor_suffix:
      // 1. Get it from the setting if defined.
      // 2. Generate it from the contact/contribution ID + description (event/contribution title).
      // 3. Set it to the current "domain" name in CiviCRM.
      // 4. If we end up with a blank descriptor Stripe will reject it - https://lab.civicrm.org/extensions/stripe/-/issues/293
      //   so we set it to ".".
987
      case 'statement_descriptor':
988
        $description = trim(\Civi::settings()->get('stripe_statementdescriptor'));
989
990
991
992
993
994
995
996
997
998
999
1000
        if (empty($description)) {
          $description = trim("{$contactContributionID} {$params['description']}");
          if (empty($description)) {
            $description = \Civi\Api4\Domain::get(FALSE)
              ->setCurrentDomain(TRUE)
              ->addSelect('name')
              ->execute()
              ->first()['name'];
          }
        }
        $description = str_replace($disallowed_characters, '', $description);
        if (empty($description)) {
For faster browsing, not all history is shown. View entire blame