Skip to content
Snippets Groups Projects
Stripe.php 55.2 KiB
Newer Older
drastik's avatar
drastik committed
<?php
mattwire's avatar
mattwire committed
/*
 +--------------------------------------------------------------------+
 | 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
 */
use Brick\Money\Money;
use Civi\Api4\PaymentprocessorWebhook;
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
mattwire's avatar
mattwire committed
use Stripe\Stripe;
use Civi\Payment\Exception\PaymentProcessorException;
mattwire's avatar
mattwire committed
use Stripe\StripeObject;
use Stripe\Webhook;
/**
 * Class CRM_Core_Payment_Stripe
 */
drastik's avatar
drastik committed
class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
  use CRM_Core_Payment_MJWTrait;
drastik's avatar
drastik committed

  /**
   * Custom properties used by this payment processor
   *
   * @var string[]
   */
  private $customProperties = ['paymentIntentID', 'paymentMethodID', 'setupIntentID'];

drastik's avatar
drastik committed
  /**
   * Constructor
   *
Joshua Walker's avatar
Joshua Walker committed
   * @param string $mode
   *   (deprecated) The mode of operation: live or test.
drastik's avatar
drastik committed
   */
  public function __construct($mode, $paymentProcessor) {
drastik's avatar
drastik committed
    $this->_paymentProcessor = $paymentProcessor;
Rich's avatar
Rich committed

    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.
      $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)) {
        $this->setAPIParams();
        $this->stripeClient = new \Stripe\StripeClient($secretKey);
      }
Rich's avatar
Rich committed
    }
drastik's avatar
drastik committed
  }

mattwire's avatar
mattwire committed
  /**
   * @param array $paymentProcessor
   *
   * @return string
   */
  public static function getSecretKey($paymentProcessor) {
    return trim($paymentProcessor['password'] ?? '');
mattwire's avatar
mattwire committed
  }

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

  /**
   * @return string
   */
  public function getWebhookSecret(): string {
    return trim($this->_paymentProcessor['signature'] ?? '');
mattwire's avatar
mattwire committed
  /**
   * 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
  /**
Joshua Walker's avatar
Joshua Walker committed
   * This function checks to see if we have the right config values.
drastik's avatar
drastik committed
   *
mattwire's avatar
mattwire committed
   * @return null|string
Joshua Walker's avatar
Joshua Walker committed
   *   The error message if any.
drastik's avatar
drastik committed
   */
drastik's avatar
drastik committed

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

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

   * We can use the stripe processor on the backend
   * @return bool
   */
  public function supportsBackOffice() {
mattwire's avatar
mattwire committed
    return TRUE;
   * We can edit stripe recurring contributions
   * @return bool
   */
  public function supportsEditRecurringContribution() {
    return FALSE;
  }

  public function supportsRecurring() {
  /**
   * Does this payment processor support refund?
   *
   * @return bool
   */
  public function supportsRefund() {
    return TRUE;
  }

   * Can we set a future recur start date?  Stripe allows this but we don't (yet) support it.
   * @return bool
   */
  public function supportsFutureRecurStartDate() {
  /**
   * Is an authorize-capture flow supported.
   *
   * @return bool
   */
  protected function supportsPreApproval() {
    return TRUE;
  }

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

  /**
   * 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() {
   * Get the amount for the Stripe API formatted in lowest (ie. cents / pennies).
   * @param array|PropertyBag $params
  protected function getAmount($params = []) {
    $amount = number_format((float) $params['amount'] ?? 0.0, CRM_Utils_Money::getCurrencyPrecision($this->getCurrency($params)), '.', '');
    $amount = preg_replace('/[^\d]/', '', strval($amount));
  /**
   * @param \Civi\Payment\PropertyBag $propertyBag
   *
   * @throws \Brick\Money\Exception\UnknownCurrencyException
   */
  public function getAmountFormattedForStripeAPI(PropertyBag $propertyBag): string {
    return Money::of($propertyBag->getAmount(), $propertyBag->getCurrency())->getMinorAmount()->getIntegralPart();
  }

Joshua Walker's avatar
Joshua Walker committed
  /**
   * Set API parameters for Stripe (such as identifier, api version, api key)
mattwire's avatar
mattwire committed
    Stripe::setLogger(\Civi::log());
    // Attempt one retry (Stripe default is 0) if we can't connect to Stripe servers
mattwire's avatar
mattwire committed
    Stripe::setMaxNetworkRetries(1);
    // Set plugin info and API credentials.
mattwire's avatar
mattwire committed
    Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
    Stripe::setApiKey(self::getSecretKey($this->_paymentProcessor));
    Stripe::setApiVersion(CRM_Stripe_Check::API_VERSION);
   * This function parses, sanitizes and extracts useful information from the exception that was thrown.
   * The goal is that it only returns information that is safe to show to the end-user.
   *
   * @see https://stripe.com/docs/api/errors/handling?lang=php
   *
   * @param string $op
   * @param \Exception $e
  public function parseStripeException(string $op, \Exception $e): array {
    $genericError = ['code' => 9000, 'message' => E::ts('An error occurred')];

    switch (get_class($e)) {
      case 'Stripe\Exception\CardException':
        // Since it's a decline, \Stripe\Exception\CardException will be caught
        \Civi::log('stripe')->error($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage() . print_r($e->getJsonBody(),TRUE));
        $error['code'] = $e->getError()->code;
        $error['message'] = $e->getError()->message;
        return $error;

      case 'Stripe\Exception\RateLimitException':
        // Too many requests made to the API too quickly
      case 'Stripe\Exception\InvalidRequestException':
        // Invalid parameters were supplied to Stripe's API
        switch ($e->getError()->code) {
          case 'payment_intent_unexpected_state':
            $genericError['message'] = E::ts('An error occurred while processing the payment');
            break;
        }
        // Don't show the actual error code to the end user - we log it so sysadmin can fix it if required.
        $genericError['code'] = '';

      case 'Stripe\Exception\AuthenticationException':
        // Authentication with Stripe's API failed
        // (maybe you changed API keys recently)
      case 'Stripe\Exception\ApiConnectionException':
        // Network communication with Stripe failed
        \Civi::log('stripe')->error($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage());
        return $genericError;

      case 'Stripe\Exception\ApiErrorException':
        // Display a very generic error to the user, and maybe send yourself an email
        // Get the error array. Creat a "fake" error code if error is not set.
        // The calling code will parse this further.
        \Civi::log('stripe')->error($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage() . print_r($e->getJsonBody(),TRUE));
        return $e->getJsonBody()['error'] ?? $genericError;

      default:
        // Something else happened, completely unrelated to Stripe
        return $genericError;
    }
  }

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

    // Try and retrieve existing plan from Stripe
    // If this fails, we'll create a new one
    try {
      $plan = $this->stripeClient->plans->retrieve($planId);
    catch (\Stripe\Exception\InvalidRequestException $e) {
      $err = $this->parseStripeException('plan_retrieve', $e);
      if ($err['code'] === 'resource_missing') {
        $formatted_amount = CRM_Utils_Money::formatLocaleNumericRoundedByCurrency(($amount / 100), $currency);
        $productName = "CiviCRM " . (isset($params['membership_name']) ? $params['membership_name'] . ' ' : '') . "every {$params['recurFrequencyInterval']} {$params['recurFrequencyUnit']}(s) {$currency}{$formatted_amount}";
        if ($this->_paymentProcessor['is_test']) {
        $product = $this->stripeClient->products->create([
          "name" => $productName,
          "type" => "service"
          'interval' => $params['recurFrequencyUnit'],
          'product' => $product->id,
          'currency' => $currency,
          'id' => $planId,
          'interval_count' => $params['recurFrequencyInterval'],
        $plan = $this->stripeClient->plans->create($stripePlan);
  /**
   * Override CRM_Core_Payment function
mattwire's avatar
mattwire committed
   *
   * @return array
  public function getPaymentFormFields(): array {
  }

  /**
   * 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(): array {
  /**
   * 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): array {
    if ((boolean) \Civi::settings()->get('stripe_nobillingaddress')) {
      return [];
    }
    else {
      return parent::getBillingAddressFields($billingLocationID);
    }
  }

  /**
   * Get form metadata for billing address fields.
   *
   * @param int $billingLocationID
   *
   * @return array
   *    Array of metadata for address fields.
   */
  public function getBillingAddressFieldsMetadata($billingLocationID = NULL): array {
    if ((boolean) \Civi::settings()->get('stripe_nobillingaddress')) {
      return [];
    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();
      }
      // 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;
        }
      return $metadata;
    }
   * Set default values when loading the (payment) form
   * @param \CRM_Core_Form $form
    // Don't use \Civi::resources()->addScriptFile etc as they often don't work on AJAX loaded forms (eg. participant backend registration)
mattwire's avatar
mattwire committed
    $context = [];
    if (class_exists('Civi\Formprotection\Forms')) {
      $context = \Civi\Formprotection\Forms::getContextFromQuickform($form);
    }

      'currency' => $this->getDefaultCurrencyForForm($form),
      'billingAddressID' => CRM_Core_BAO_LocationType::getBilling(),
      'publishableKey' => CRM_Core_Payment_Stripe::getPublicKeyById($form->_paymentProcessor['id']),
      'paymentProcessorTypeID' => $form->_paymentProcessor['payment_processor_type_id'],
      'locale' => CRM_Stripe_Api::mapCiviCRMLocaleToStripeLocale(),
      'csrfToken' => NULL,
      'country' => \Civi::settings()->get('stripe_country'),
      'moto' => \Civi::settings()->get('stripe_moto') && ($form->isBackOffice ?? FALSE) && CRM_Core_Permission::check('allow stripe moto payments'),
    if (class_exists('\Civi\Firewall\Firewall')) {
      $firewall = new \Civi\Firewall\Firewall();
      $jsVars['csrfToken'] = $firewall->generateCSRFToken($context);
    // Add CSS via region (it won't load on drupal webform if added via \Civi::resources()->addStyleFile)
    CRM_Core_Region::instance('billing-block')->add([
      'styleUrl' => \Civi::service('asset_builder')->getUrl(
        'elements.css',
          'path' => E::path('css/elements.css'),
mattwire's avatar
mattwire committed
          'mimetype' => 'text/css',
        ]
    CRM_Core_Region::instance('billing-block')->add([
      'scriptFile' => [
        E::LONG_NAME,
        'js/civicrmStripe.js',
      ],
      // Load after other scripts on form (default = 1)
      'weight' => 100,
    // 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 and other situations where jsVars don't get loaded on the form.
    // This applies to some contribution page configurations as well.
    $form->assign('stripeJSVars', $jsVars);
    CRM_Core_Region::instance('billing-block')->add([
      'template' => E::path('templates/CRM/Core/Payment/Stripe/Card.tpl'),
      'weight' => -1,
    ]);

    // Enable JS validation for forms so we only (submit) create a paymentIntent when the form has all fields validated.
    $form->assign('isJsValidate', TRUE);
  /**
   * 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) {
    foreach ($this->customProperties as $property) {
      $preApprovalParams[$property] = CRM_Utils_Request::retrieveValue($property, 'String', NULL, FALSE, 'POST');
    }
    return ['pre_approval_parameters' => $preApprovalParams ?? []];
  }

  /**
   * 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) {
    return $storedDetails ?? [];
drastik's avatar
drastik committed
  /**
drastik's avatar
drastik committed
   * Submit a payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
   * Payment processors should set payment_status_id/payment_status.
drastik's avatar
drastik committed
   *
   * @param array|PropertyBag $paymentParams
Joshua Walker's avatar
Joshua Walker committed
   *   Assoc array of input parameters for this transaction.
drastik's avatar
drastik committed
   *
   * @throws \CRM_Core_Exception
   * @throws \CiviCRM_API3_Exception
   * @throws \Civi\Payment\Exception\PaymentProcessorException
drastik's avatar
drastik committed
   */
  public function doPayment(&$paymentParams, $component = 'contribute') {
    /* @var \Civi\Payment\PropertyBag $propertyBag */
    $propertyBag = PropertyBag::cast($paymentParams);

    $zeroAmountPayment = $this->processZeroAmountPayment($propertyBag);
    if ($zeroAmountPayment) {
      return $zeroAmountPayment;
    }
    $propertyBag = $this->beginDoPayment($propertyBag);

    $isRecur = ($propertyBag->getIsRecur() && $this->getRecurringContributionId($propertyBag));

    // Now try to retrieve the payment "token". One of setupIntentID, paymentMethodID, paymentIntentID is required (in that order)
    $paymentMethodID = NULL;
    $propertyBag = $this->getTokenParameter('setupIntentID', $propertyBag, FALSE);
    if ($propertyBag->has('setupIntentID')) {
      $setupIntentID = $propertyBag->getCustomProperty('setupIntentID');
      $setupIntent = $this->stripeClient->setupIntents->retrieve($setupIntentID);
      $paymentMethodID = $setupIntent->payment_method;
      $propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, FALSE);
      if ($propertyBag->has('paymentMethodID')) {
        $paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
      }
      else {
        $propertyBag = $this->getTokenParameter('paymentIntentID', $propertyBag, TRUE);
      }
    // We didn't actually use this hook with Stripe, but it was useful to trigger so listeners could see raw params
    // passing $propertyBag instead of $params now allows some things to be altered
    $newParams = [];
    CRM_Utils_Hook::alterPaymentProcessorParams($this, $propertyBag, $newParams);

    $amountFormattedForStripe = $this->getAmountFormattedForStripeAPI($propertyBag);

    // @fixme: Check if we still need to call the getBillingEmail function - eg. how does it handle "email-Primary".
    $email = $this->getBillingEmail($propertyBag, $propertyBag->getContactID());
    $propertyBag->setEmail($email);

    $stripeCustomer = $this->getStripeCustomer($propertyBag);
drastik's avatar
drastik committed

      'contact_id' => $propertyBag->getContactID(),
    // Attach the paymentMethod to the customer and set as default for new invoices
    if (isset($paymentMethodID)) {
      $paymentMethod = $this->stripeClient->paymentMethods->retrieve($paymentMethodID);
      $paymentMethod->attach(['customer' => $stripeCustomer->id]);
      $customerParams['invoice_settings']['default_payment_method'] = $paymentMethodID;
    }

    CRM_Stripe_BAO_StripeCustomer::updateMetadata($customerParams, $this, $stripeCustomer->id);
    // Handle recurring payments in doRecurPayment().
    if ($isRecur) {
      // 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.
      // 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.
      return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer);
    $intentParams = [
      'customer' => $stripeCustomer->id,
      'description' => $this->getDescription($propertyBag, 'description'),
    $intentParams['statement_descriptor_suffix'] = $this->getDescription($propertyBag, 'statement_descriptor_suffix');
    $intentParams['statement_descriptor'] = $this->getDescription($propertyBag, 'statement_descriptor');
    if (!$propertyBag->has('paymentIntentID') && !empty($paymentMethodID)) {
      // We came in via a flow that did not know the amount before submit (eg. multiple event participants)
      // We need to create a paymentIntent
      $stripePaymentIntent = new CRM_Stripe_PaymentIntent($this);
      $stripePaymentIntent->setDescription($this->getDescription($propertyBag));
      $stripePaymentIntent->setReferrer($_SERVER['HTTP_REFERER'] ?? '');
      $stripePaymentIntent->setExtraData($propertyBag->has('extra_data') ? $propertyBag->getCustomProperty('extra_data') : '');

      $paymentIntentParams = [
        'paymentMethodID' => $paymentMethodID,
        'customer' => $stripeCustomer->id,
        'capture' => FALSE,
        'amount' => $propertyBag->getAmount(),
        'currency' => $propertyBag->getCurrency(),
      ];
      $processIntentResult = $stripePaymentIntent->processPaymentIntent($paymentIntentParams);
      if ($processIntentResult->ok) {
        $paymentIntentID = $processIntentResult->data['paymentIntent']['id'];
      }
      else {
        \Civi::log('stripe')->error('Attempted to create paymentIntent from paymentMethod during doPayment failed: ' . print_r($processIntentResult, TRUE));
      }
    }

    // This is where we actually charge the customer
      if (empty($paymentIntentID)) {
        $paymentIntentID = $propertyBag->getCustomProperty('paymentIntentID');
      }
      $intent = $this->stripeClient->paymentIntents->retrieve($paymentIntentID);
      if ($intent->amount != $this->getAmountFormattedForStripeAPI($propertyBag)) {
        $intentParams['amount'] = $this->getAmountFormattedForStripeAPI($propertyBag);
      $intent = $this->stripeClient->paymentIntents->update($intent->id, $intentParams);
drastik's avatar
drastik committed
    }
      $parsedError = $this->parseStripeException('doPayment', $e);
      $this->handleError($parsedError['code'], $parsedError['message'], ($propertyBag->has('error_url') ? $propertyBag->getCustomProperty('error_url') : ''), FALSE);
drastik's avatar
drastik committed
    }
    // @fixme FROM HERE we are using $params ONLY - SET things if required ($propertyBag is not used beyond here)
    $params = $this->getPropertyBagAsArray($propertyBag);
    $params = $this->processPaymentIntent($params, $intent);

    // For a single charge there is no stripe invoice, we set OrderID to the ChargeID.
    if (empty($this->getPaymentProcessorOrderID())) {
      $this->setPaymentProcessorOrderID($this->getPaymentProcessorTrxnID());
    // 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...
    return $this->endDoPayment($params);
drastik's avatar
drastik committed
  }

  /**
   * @param \Civi\Payment\PropertyBag $propertyBag
   *
   * @return \Stripe\Customer|PropertySpy
   * @throws \CiviCRM_API3_Exception
   * @throws \Civi\Payment\Exception\PaymentProcessorException
   */
  protected function getStripeCustomer(\Civi\Payment\PropertyBag $propertyBag) {
    // See if we already have a stripe customer
    $customerParams = [
      'contact_id' => $propertyBag->getContactID(),
      'processor_id' => $this->getPaymentProcessor()['id'],
      'email' => $propertyBag->getEmail(),
      // Include this to allow redirect within session on payment failure
      'error_url' => $propertyBag->getCustomProperty('error_url'),
    ];

    // 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.
    $stripeCustomerID = CRM_Stripe_Customer::find($customerParams);
    // Customer not in civicrm database.  Create a new Customer in Stripe.
    if (!isset($stripeCustomerID)) {
      $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
    }
    else {
      // Customer was found in civicrm database, fetch from Stripe.
      try {
        $stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomerID);
        $shouldDeleteStripeCustomer = $stripeCustomer->isDeleted();
      } catch (Exception $e) {
        $err = $this->parseStripeException('retrieve_customer', $e);
        \Civi::log()->error($this->getLogPrefix() . 'Failed to retrieve Stripe Customer: ' . $err['code']);
      if ($shouldDeleteStripeCustomer) {
        // Customer doesn't exist or was deleted, create a new one
        CRM_Stripe_Customer::delete($customerParams);
        try {
          $stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
        } catch (Exception $e) {
          // We still failed to create a customer
          $err = $this->parseStripeException('create_customer', $e);
          throw new PaymentProcessorException('Failed to create Stripe Customer: ' . $err['code']);
        }
      }
    }
    return $stripeCustomer;
  }

   * @param \Civi\Payment\PropertyBag $propertyBag
   * @return bool
   */
  private function isPaymentForEventAdditionalParticipants(\Civi\Payment\PropertyBag $propertyBag): bool {
    if ($propertyBag->getter('additional_participants', TRUE)) {
mattwire's avatar
mattwire committed
      return TRUE;
    }
    return FALSE;
drastik's avatar
drastik committed
  /**
   * Submit a recurring payment using Stripe's PHP API:
   * https://stripe.com/docs/api?lang=php
   *
   * @param \Civi\Payment\PropertyBag $propertyBag
   *   PropertyBag for this transaction.
   * @param int $amountFormattedForStripe
   *   Transaction amount in cents.
   * @param \Stripe\Customer $stripeCustomer
Joshua Walker's avatar
Joshua Walker committed
   *   Stripe customer object generated by Stripe API.
drastik's avatar
drastik committed
   *
drastik's avatar
drastik committed
   * @return array
Joshua Walker's avatar
Joshua Walker committed
   *   The result in a nice formatted array (or an error object).
drastik's avatar
drastik committed
   *
mattwire's avatar
mattwire committed
   * @throws \CiviCRM_API3_Exception
drastik's avatar
drastik committed
   */
  public function doRecurPayment(\Civi\Payment\PropertyBag $propertyBag, int $amountFormattedForStripe, $stripeCustomer): array {
    $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
    $params = $this->setStatusPaymentPending($params);
    $required = NULL;
    if (empty($this->getRecurringContributionId($propertyBag))) {
      $required = 'contributionRecurID';
    }
    if (!isset($params['recurFrequencyUnit'])) {
      $required = 'recurFrequencyUnit';
      Civi::log()->error($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
      throw new CRM_Core_Exception($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
    // Make sure recurFrequencyInterval is set (default to 1 if not)
    empty($params['recurFrequencyInterval']) ? $params['recurFrequencyInterval'] = 1 : NULL;
    $planId = self::createPlan($params, $amountFormattedForStripe);
drastik's avatar
drastik committed

drastik's avatar
drastik committed
    // Attach the Subscription to the Stripe Customer.
      'metadata' => ['Description' => $params['description']],
      'expand' => ['latest_invoice.payment_intent'],
    // 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;
    }
    // Create the stripe subscription for the customer
    $stripeSubscription = $this->stripeClient->subscriptions->create($subscriptionParams);
    $this->setPaymentProcessorSubscriptionID($stripeSubscription->id);
    $nextScheduledContributionDate = $this->calculateNextScheduledDate($params);
    $contributionRecur = \Civi\Api4\ContributionRecur::update(FALSE)
      ->addWhere('id', '=', $this->getRecurringContributionId($propertyBag))
      ->addValue('processor_id', $this->getPaymentProcessorSubscriptionID())
      ->addValue('auto_renew', 1)
      ->addValue('next_sched_contribution_date', $nextScheduledContributionDate)
      ->addValue('cycle_day', date('d', strtotime($nextScheduledContributionDate)));

    if (!empty($params['installments'])) {
      // We set an end date if installments > 0
      if (empty($params['receive_date'])) {
        $params['receive_date'] = date('YmdHis');
        $contributionRecur
          ->addValue('end_date', $this->calculateEndDate($params))
          ->addValue('installments', $params['installments']);
    if ($stripeSubscription->status === 'incomplete') {
      $contributionRecur->addValue('contribution_status_id:name', 'Failed');
    }
    $contributionRecur->execute();

    if ($stripeSubscription->status === 'incomplete') {
      // For example with test card 4000000000000341 (Attaching this card to a Customer object succeeds, but attempts to charge the customer fail)
      \Civi::log()->warning($this->getLogPrefix() . 'subscription status=incomplete. ID:' . $stripeSubscription->id);
      throw new PaymentProcessorException('Payment failed');
    // For a recurring (subscription) with future start date we might not have an invoice yet.
    if (!empty($stripeSubscription->latest_invoice)) {
      // Get the paymentIntent for the latest invoice
      $intent = $stripeSubscription->latest_invoice['payment_intent'];
      $params = $this->processPaymentIntent($params, $intent);
      // Set the OrderID (invoice) and TrxnID (charge)
      $this->setPaymentProcessorOrderID($stripeSubscription->latest_invoice['id']);
      if (!empty($stripeSubscription->latest_invoice['charge'])) {
        $this->setPaymentProcessorTrxnID($stripeSubscription->latest_invoice['charge']);
      }
      // 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);
      // 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);
    }
    return $this->endDoPayment($params);
  /**
   * Get the billing cycle day (timestamp)
   * @param array $params
   *
   * @return int|null
   */
  private function getRecurBillingCycleDay($params) {
      $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;
      }
    // Either we had no receive_date or receive_date was in the past (or "now" when form was submitted).
  /**
   * This performs the processing and recording of the paymentIntent for both recurring and non-recurring payments
   * @param array $params
   * @param \Stripe\PaymentIntent $intent
   *
   * @return array $params
   */
  private function processPaymentIntent($params, $intent) {
    $email = $this->getBillingEmail($params, $params['contactID']);

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

      switch ($intent->status) {
        case 'requires_capture':
          $intent->capture();
mattwire's avatar
mattwire committed
        case 'succeeded':
          // Return fees & net amount for Civi reporting.
          if (!empty($intent->charges)) {
mattwire's avatar
mattwire committed
            // Stripe API version < 2022-11-15
            $stripeCharge = $intent->charges->data[0];
          }
          elseif (!empty($intent->latest_charge)) {
mattwire's avatar
mattwire committed
            // Stripe API version 2022-11-15
            $stripeCharge = $this->stripeClient->charges->retrieve($intent->latest_charge);
            $stripeBalanceTransaction = $this->stripeClient->balanceTransactions->retrieve($stripeCharge->balance_transaction);
            $err = $this->parseStripeException('retrieve_balance_transaction', $e);
            throw new PaymentProcessorException('Failed to retrieve Stripe Balance Transaction: ' . $err['code']);
          if (($stripeCharge['currency'] !== $stripeBalanceTransaction->currency)
              && (!empty($stripeBalanceTransaction->exchange_rate))) {
            $params['fee_amount'] = CRM_Stripe_Api::currencyConversion($stripeBalanceTransaction->fee, $stripeBalanceTransaction['exchange_rate'], $stripeCharge['currency']);
            // 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.
            $params['fee_amount'] = round($stripeBalanceTransaction->fee / 100, CRM_Utils_Money::getCurrencyPrecision($stripeCharge['currency']));
          // Success!
          // Set the desired contribution status which will be set later (do not set on the contribution here!)
          $params = $this->setStatusPaymentCompleted($params);
          // 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.
            $this->stripeClient->paymentIntents->update($intent->id, ['receipt_email' => $email]);
      $this->handleError($e->getCode(), $e->getMessage(), $params['error_url']);
    finally {
      // Always update the paymentIntent in the CiviCRM database for later tracking
      $intentParams = [
        'stripe_intent_id' => $intent->id,
        'payment_processor_id' => $this->_paymentProcessor['id'],
        'status' => $intent->status,
        'contribution_id' =>  $params['contributionID'] ?? NULL,
        'description' => $this->getDescription($params, 'description'),
        'identifier' => $params['qfKey'] ?? NULL,
        'contact_id' => $params['contactID'],
        'extra_data' => ($errorMessage ?? '') . ';' . ($email ?? ''),
      ];
      if (empty($intentParams['contribution_id'])) {
        $intentParams['flags'][] = 'NC';
      }
      CRM_Stripe_BAO_StripePaymentintent::create($intentParams);
  /**
   * Submit a refund payment
   *