Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • extensions/stripe
  • madhavi/stripe
  • wmortada/stripe
  • jamie/stripe
  • michaelmcandrew/stripe
  • bgm/stripe
  • noah/stripe
  • laryn/stripe
  • phani/stripe
  • JonGold/stripe
  • scardinius/stripe
  • varshith/stripe
  • naomi/stripe
  • jhoskins98/stripe
  • artfulrobot/stripe
  • jitendra/stripe
  • justinfreeman/stripe
  • revati_gawas/stripe
  • capo/stripe
  • pradeep/stripe
  • partners/ixiam/stripe
  • homotechsual/stripe
  • Edselopez/stripe
  • goron/stripe
  • tapash/stripe
  • petednz/stripe
  • kartik1000/stripe
  • ananelson/stripe
  • Samuele.Masetto/stripe
  • sluc23/stripe
  • aaron/stripe
  • DaveD/stripe
  • konadave/stripe
  • partners/coopsymbiotic/stripe
  • kurund/stripe
  • AllenShaw/stripe
  • awestbha/stripe
  • mathavan/stripe
  • BjoernE/stripe
  • alietz/stripe
  • seamuslee/stripe
  • damo-civi/stripe
  • dmunio/stripe
  • ufundo/stripe
44 results
Show changes
Commits on Source (1107)
Showing
with 4965 additions and 1674 deletions
<?php
/*
* Payment Processor class for Stripe
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use Brick\Money\Money;
use Brick\Math\RoundingMode;
use Civi\Api4\ContributionRecur;
use Civi\Api4\PaymentprocessorWebhook;
use Civi\Api4\StripeCustomer;
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
use Stripe\Stripe;
use Civi\Payment\Exception\PaymentProcessorException;
use Stripe\StripeObject;
use Stripe\Webhook;
/**
* Class CRM_Core_Payment_Stripe
*/
class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
use CRM_Core_Payment_MJWTrait;
/**
* We only need one instance of this object. So we use the singleton
* pattern and cache the instance in this variable
*
* @var object
* @static
* @var \Stripe\StripeClient
*/
static private $_singleton = NULL;
public $stripeClient;
/**
* Mode of operation: live or test.
* @var \Civi\Stripe\Api;
*/
public \Civi\Stripe\Api $api;
/**
* Custom properties used by this payment processor
*
* @var object
* @var string[]
*/
protected $_mode = NULL;
private $customProperties = ['paymentIntentID', 'paymentMethodID', 'setupIntentID'];
/**
* Constructor
*
* @param string $mode
* The mode of operation: live or test.
*
* @return void
* (deprecated) The mode of operation: live or test.
* @param array $paymentProcessor
*/
public function __construct($mode, &$paymentProcessor) {
$this->_mode = $mode;
$this->_islive = ($mode == 'live' ? 1 : 0);
public function __construct($mode, $paymentProcessor) {
$this->_paymentProcessor = $paymentProcessor;
$this->_processorName = ts('Stripe');
$this->api = new \Civi\Stripe\Api($this);
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);
}
}
}
/**
* This function checks to see if we have the right config values.
* @param array $paymentProcessor
*
* @return string
* The error message if any.
*/
public static function getSecretKey($paymentProcessor) {
return trim($paymentProcessor['password'] ?? '');
}
/**
* @param array $paymentProcessor
*
* @public
* @return string
*/
public function checkConfig() {
$config = CRM_Core_Config::singleton();
$error = array();
public static function getPublicKey($paymentProcessor) {
return trim($paymentProcessor['user_name'] ?? '');
}
/**
* @return string
*/
public function getWebhookSecret(): string {
return trim($this->_paymentProcessor['signature'] ?? '');
}
if (empty($this->_paymentProcessor['user_name'])) {
$error[] = ts('The "Secret Key" is not set in the Stripe Payment Processor settings.');
/**
* 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 (CRM_Core_Exception $e) {
return '';
}
return $key;
}
if (empty($this->_paymentProcessor['password'])) {
$error[] = ts('The "Publishable Key" is not set in the Stripe Payment Processor settings.');
/**
* 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 (CRM_Core_Exception $e) {
return '';
}
return $key;
}
/**
* This function checks to see if we have the right config values.
*
* @return null|string
* The error message if any.
*/
public function checkConfig() {
$error = [];
if (!empty($error)) {
return implode('<p>', $error);
......@@ -66,155 +155,278 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* Helper log function.
* Override CRM_Core_Payment function
*
* @param string $op
* The Stripe operation being performed.
* @param Exception $exception
* The error!
* @return string
*/
public function logStripeException($op, $exception) {
$body = print_r($exception->getJsonBody(), TRUE);
CRM_Core_Error::debug_log_message("Stripe_Error {$op}: <pre> {$body} </pre>");
public function getPaymentTypeName() {
return 'credit_card';
}
/**
* Check if return from stripeCatchErrors was an error object
* that should be passed back to original api caller.
* Override CRM_Core_Payment function
*
* @param $stripeReturn
* The return from a call to stripeCatchErrors
* @return string
*/
public function getPaymentTypeLabel() {
return E::ts('Stripe');
}
/**
* We can use the stripe processor on the backend
* @return bool
*
*/
public function isErrorReturn($stripeReturn) {
if (is_object($stripeReturn) && get_class($stripeReturn) == 'CRM_Core_Error') {
return true;
}
return false;
public function supportsBackOffice() {
return TRUE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* Run Stripe calls through this to catch exceptions gracefully.
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return TRUE;
}
/**
* Get an array of the fields that can be edited on the recurring contribution.
*
* @param string $op
* Determine which operation to perform.
* @param array $params
* Parameters to run Stripe calls on.
* Some payment processors support editing the amount and other scheduling details of recurring payments, especially
* those which use tokens. Others are fixed. This function allows the processor to return an array of the fields that
* can be updated from the contribution recur edit screen.
*
* The fields are likely to be a subset of these
* - 'amount',
* - 'installments',
* - 'frequency_interval',
* - 'frequency_unit',
* - 'cycle_day',
* - 'next_sched_contribution_date',
* - 'end_date',
* - 'failure_retry_day',
*
* @return varies
* Response from gateway.
* The form does not restrict which fields from the contribution_recur table can be added (although if the html_type
* metadata is not defined in the xml for the field it will cause an error.
*
* Open question - would it make sense to return membership_id in this - which is sometimes editable and is on that
* form (UpdateSubscription).
*
* @return array
*/
public function stripeCatchErrors($op = 'create_customer', $stripe_params, $params, $ignores = array()) {
$error_url = $params['stripe_error_url'];
$return = FALSE;
// Check for errors before trying to submit.
try {
switch ($op) {
case 'create_customer':
$return = \Stripe\Customer::create($stripe_params);
break;
public function getEditableRecurringScheduleFields() {
if ($this->supports('changeSubscriptionAmount')) {
return ['amount'];
}
return [];
}
case 'update_customer':
$return = \Stripe\Customer::update($stripe_params);
break;
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
case 'charge':
$return = \Stripe\Charge::create($stripe_params);
break;
/**
* Can we set a future recur start date? Stripe allows this but we don't (yet) support it.
* @return bool
*/
public function supportsFutureRecurStartDate() {
return TRUE;
}
case 'save':
$return = $stripe_params->save();
break;
/**
* Is an authorize-capture flow supported.
*
* @return bool
*/
protected function supportsPreApproval() {
return TRUE;
}
case 'create_plan':
$return = \Stripe\Plan::create($stripe_params);
break;
/**
* 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;
}
case 'retrieve_customer':
$return = \Stripe\Customer::retrieve($stripe_params);
break;
/**
* 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() {
return TRUE;
}
case 'retrieve_balance_transaction':
$return = \Stripe\BalanceTransaction::retrieve($stripe_params);
break;
/**
* Get the amount for the Stripe API formatted in lowest (ie. cents / pennies).
*
* @param array|PropertyBag $params
*
* @return string
*/
protected function getAmount($params = []) {
$amount = number_format((float) $params['amount'] ?? 0.0, CRM_Utils_Money::getCurrencyPrecision($this->getCurrency($params)), '.', '');
// Stripe amount required in cents.
$amount = preg_replace('/[^\d]/', '', strval($amount));
return $amount;
}
default:
$return = \Stripe\Customer::create($stripe_params);
break;
}
}
catch (Exception $e) {
if (is_a($e, 'Stripe_Error')) {
foreach ($ignores as $ignore) {
if (is_a($e, $ignore['class'])) {
$body = $e->getJsonBody();
$error = $body['error'];
if ($error['type'] == $ignore['type'] && $error['message'] == $ignore['message']) {
return $return;
}
}
}
}
/**
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
public function getAmountFormattedForStripeAPI(PropertyBag $propertyBag): string {
return Money::of($propertyBag->getAmount(), $propertyBag->getCurrency(), NULL, RoundingMode::HALF_UP)->getMinorAmount()->getIntegralPart();
}
$this->logStripeException($op, $e);
$error_message = '';
// Since it's a decline, Stripe_CardError will be caught
$body = $e->getJsonBody();
$err = $body['error'];
/**
* Set API parameters for Stripe (such as identifier, api version, api key)
*/
public function setAPIParams() {
// Use CiviCRM log file
Stripe::setLogger(\Civi::log());
// Attempt one retry (Stripe default is 0) if we can't connect to Stripe servers
Stripe::setMaxNetworkRetries(1);
// Set plugin info and API credentials.
Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
Stripe::setApiKey(self::getSecretKey($this->_paymentProcessor));
// With Stripe-php 12 we pin to latest Stripe API
// Stripe::setApiVersion(CRM_Stripe_Check::API_VERSION);
}
//$error_message .= 'Status is: ' . $e->getHttpStatus() . "<br />";
////$error_message .= 'Param is: ' . $err['param'] . "<br />";
$error_message .= 'Type: ' . $err['type'] . '<br />';
$error_message .= 'Code: ' . $err['code'] . '<br />';
$error_message .= 'Message: ' . $err['message'] . '<br />';
/**
* 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
*
* @return array $err
*/
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':
/** @var \Stripe\Exception\CardException $e */
// 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->getStripeCode();
$error['message'] = $e->getMessage();
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->getStripeCode()) {
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;
case 'Stripe\Exception\PermissionException':
// The client is probably setup with a restricted API key and does not have permission to do the requested action.
// We should not display the specific error to the end customer but we *do* want the details in the log.
// For example, if we have a readonly API key we won't be able to update Stripe customer metadata, but we may choose to continue!
\Civi::log('stripe')->warning($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage());
$genericError['code'] = $e->getStripeCode();
$genericError['message'] = $e->getMessage();
return $genericError;
default:
// Something else happened, completely unrelated to Stripe
\Civi::log('stripe')->error($this->getLogPrefix() . $op . ' (unknown error): ' . get_class($e) . ': ' . $e->getMessage());
return $genericError;
}
}
if (is_a($e, 'Stripe_CardError')) {
$newnote = civicrm_api3('Note', 'create', array(
'sequential' => 1,
'entity_id' => $params['contactID'],
'contact_id' => $params['contributionID'],
'subject' => $err['type'],
'note' => $err['code'],
'entity_table' => "civicrm_contributions",
));
}
/**
* Create or update a Stripe Plan
*
* @param \Civi\Payment\PropertyBag $propertyBag
* @param integer $amount
*
* @return \Stripe\Plan
*/
public function createPlan(\Civi\Payment\PropertyBag $propertyBag, int $amount): \Stripe\Plan {
$planID = "every-{$propertyBag->getRecurFrequencyInterval()}-{$propertyBag->getRecurFrequencyUnit()}-{$amount}-" . strtolower($propertyBag->getCurrency());
if (isset($error_url)) {
// Redirect to first page of form and present error.
CRM_Core_Error::statusBounce("Oops! Looks like there was an error. Payment Response:
<br /> {$error_message}", $error_url);
}
else {
// Don't have return url - return error object to api
$core_err = CRM_Core_Error::singleton();
$message = 'Oops! Looks like there was an error. Payment Response: <br />' . $error_message;
if ($err['code']) {
$core_err->push($err['code'], 0, NULL, $message);
}
else {
$core_err->push(9000, 0, NULL, 'Unknown Error: ' . $message);
}
return $core_err;
// 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) {
if ($e->getStripeCode() === 'resource_missing') {
$formattedAmount = CRM_Utils_Money::formatLocaleNumericRoundedByCurrency(($amount / 100), $propertyBag->getCurrency());
$productName = "{$propertyBag->getCurrency()}{$formattedAmount} "
. ($propertyBag->has('membership_name') ? $propertyBag->getCustomProperty('membership_name') . ' ' : '')
. "every {$propertyBag->getRecurFrequencyInterval()} {$propertyBag->getRecurFrequencyUnit()}(s)";
$product = $this->stripeClient->products->create([
'name' => $productName,
'type' => 'service'
]);
// Create a new Plan.
$stripePlan = [
'amount' => $amount,
'interval' => $propertyBag->getRecurFrequencyUnit(),
'product' => $product->id,
'currency' => $propertyBag->getCurrency(),
'id' => $planID,
'interval_count' => $propertyBag->getRecurFrequencyInterval(),
];
$plan = $this->stripeClient->plans->create($stripePlan);
}
}
return $return;
return $plan;
}
/**
* Override CRM_Core_Payment function
*
* @return array
*/
public function getPaymentFormFields() {
return array(
'credit_card_type',
'credit_card_number',
'cvv2',
'credit_card_exp_date',
'stripe_token',
'stripe_pub_key',
'stripe_id',
);
public function getPaymentFormFields(): array {
return [];
}
/**
......@@ -225,83 +437,27 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @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',
),
public function getPaymentFormFieldsMetadata(): array {
return [];
}
'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,
),
);
/**
* 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);
}
}
/**
......@@ -312,586 +468,844 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @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();
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 the state/county field
if (!empty($metadata["billing_state_province_id-{$billingLocationID}"]['is_required'])) {
$metadata["billing_state_province_id-{$billingLocationID}"]['is_required'] = FALSE;
}
// 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;
return $metadata;
}
}
/**
* Implementation of hook_civicrm_buildForm().
* Set default values when loading the (payment) form
*
* @param $form - reference to the form object
* @param \CRM_Core_Form $form
*/
public function buildForm(&$form) {
if ($form->isSubmitted()) return;
// Don't use \Civi::resources()->addScriptFile etc as they often don't work on AJAX loaded forms (eg. participant backend registration)
$context = [];
if (class_exists('Civi\Formprotection\Forms')) {
$context = \Civi\Formprotection\Forms::getContextFromQuickform($form);
}
$stripe_ppid = CRM_Utils_Array::value('id', $form->_paymentProcessor);
$stripe_key = self::stripe_get_key($stripe_ppid);
$motoEnabled = CRM_Core_Permission::check('allow stripe moto payments')
&& (
(in_array('backend', \Civi::settings()->get('stripe_moto')) && $form->isBackOffice)
|| (in_array('frontend', \Civi::settings()->get('stripe_moto')) && !$form->isBackOffice)
);
$jsVars = [
'id' => $form->_paymentProcessor['id'],
'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(),
'apiVersion' => CRM_Stripe_Check::API_VERSION,
'csrfToken' => NULL,
'country' => \Civi::settings()->get('stripe_country'),
'moto' => $motoEnabled,
'disablelink' => \Civi::settings()->get('stripe_cardelement_disablelink'),
];
if (class_exists('\Civi\Firewall\Firewall')) {
$firewall = new \Civi\Firewall\Firewall();
$jsVars['csrfToken'] = $firewall->generateCSRFToken($context);
}
// Set ddi_reference
$defaults = array();
$defaults['stripe_id'] = $stripe_ppid;
$defaults['stripe_pub_key'] = $stripe_key;
$form->setDefaults($defaults);
// 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'),
'mimetype' => 'text/css',
]
),
'weight' => -1,
]);
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);
}
/**
* Given a payment processor id, return the pub key.
* 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 stripe_get_key($stripe_ppid) {
try {
$result = civicrm_api3('PaymentProcessor', 'getvalue', array(
'return' => "password",
'id' => $stripe_ppid,
));
}
catch (CiviCRM_API3_Exception $e) {
return NULL;
public function doPreApproval(&$params) {
foreach ($this->customProperties as $property) {
$preApprovalParams[$property] = CRM_Utils_Request::retrieveValue($property, 'String', NULL, FALSE, 'POST');
}
return $result;
return ['pre_approval_parameters' => $preApprovalParams ?? []];
}
/**
* Return the CiviCRM version we're running.
* 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 get_civi_version() {
$version = civicrm_api3('Domain', 'getvalue', array(
'return' => "version",
'current_domain' => true,
));
return $version;
public function getPreApprovalDetails($storedDetails) {
return $storedDetails ?? [];
}
/**
* Process payment
* Submit a payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
* Payment processors should set payment_status_id/payment_status.
*
* @param array $params
* @param array|PropertyBag $paymentParams
* Assoc array of input parameters for this transaction.
* @param string $component
*
* @return array
* The result in a nice formatted array (or an error object).
* Result array
*
* @public
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doDirectPayment(&$params) {
// Let a $0 transaction pass.
if (empty($params['amount']) || $params['amount'] == 0) {
return $params;
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));
// Get proper entry URL for returning on error.
if (!(array_key_exists('qfKey', $params))) {
// Probably not called from a civicrm form (e.g. webform) -
// will return error object to original api caller.
$params['stripe_error_url'] = $error_url = null;
// 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;
}
else {
$qfKey = $params['qfKey'];
$parsed_url = parse_url($params['entryURL']);
$url_path = substr($parsed_url['path'], 1);
$params['stripe_error_url'] = $error_url = CRM_Utils_System::url($url_path,
$parsed_url['query'] . "&_qf_Main_display=1&qfKey={$qfKey}", FALSE, NULL, FALSE);
$propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, FALSE);
if ($propertyBag->has('paymentMethodID')) {
$paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
}
else {
$propertyBag = $this->getTokenParameter('paymentIntentID', $propertyBag, TRUE);
}
}
// Set plugin info and API credentials.
\Stripe\Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
\Stripe\Stripe::setApiKey($this->_paymentProcessor['user_name']);
// 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);
// Stripe amount required in cents.
$amount = number_format($params['amount'], 2, '.', '');
$amount = (int) preg_replace('/[^\d]/', '', strval($amount));
$amountFormattedForStripe = $this->getAmountFormattedForStripeAPI($propertyBag);
// Use Stripe.js instead of raw card details.
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);
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$customerParams = [
'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;
}
else {
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));
}
// Check for existing customer, create new otherwise.
// Possible email fields.
$email_fields = array(
'email',
'email-5',
'email-Primary',
);
// Possible contact ID fields.
$contact_id_fields = array(
'contact_id',
'contactID',
);
// Find out which email field has our yummy value.
foreach ($email_fields as $email_field) {
if (!empty($params[$email_field])) {
$email = $params[$email_field];
break;
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.
if (empty($paymentMethodID)) {
\Civi::log('stripe')->error($this->getLogPrefix() . 'recur payment but missing paymentmethod. Check form config');
throw new PaymentProcessorException('Payment form is not configured correctly!');
}
return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer);
}
// We didn't find an email, but never fear - this might be a backend contrib.
// We can look for a contact ID field and get the email address.
if (empty($email)) {
foreach ($contact_id_fields as $cid_field) {
if (!empty($params[$cid_field])) {
$email = civicrm_api3('Contact', 'getvalue', array(
'id' => $params[$cid_field],
'return' => 'email',
));
break;
}
$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));
}
}
// We still didn't get an email address?! /ragemode on
if (empty($email)) {
CRM_Core_Error::fatal(ts('No email address found. Please report this issue.'));
// This is where we actually charge the customer
try {
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);
}
catch (Exception $e) {
$parsedError = $this->parseStripeException('doPayment', $e);
$this->handleError($parsedError['code'], $parsedError['message'], $this->getErrorUrl($propertyBag), FALSE);
}
// Prepare escaped query params.
$query_params = array(
1 => array($email, 'String'),
2 => array($this->_paymentProcessor['id'], 'Integer'),
);
$customer_query = CRM_Core_DAO::singleValueQuery("SELECT id
FROM civicrm_stripe_customers
WHERE email = %1 AND is_live = '{$this->_islive}' AND processor_id = %2", $query_params);
/****
* If for some reason you cannot use Stripe.js and you are aware of PCI Compliance issues,
* here is the alternative to Stripe.js:
****/
/*
// Get Cardholder's full name.
$cc_name = $params['first_name'] . " ";
if (strlen($params['middle_name']) > 0) {
$cc_name .= $params['middle_name'] . " ";
}
$cc_name .= $params['last_name'];
// @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);
// Prepare Card details in advance to use for new Stripe Customer object if we need.
$card_details = array(
'number' => $params['credit_card_number'],
'exp_month' => $params['month'],
'exp_year' => $params['year'],
'cvc' => $params['cvv2'],
'name' => $cc_name,
'address_line1' => $params['street_address'],
'address_state' => $params['state_province'],
'address_zip' => $params['postal_code'],
);
*/
// drastik - Uncomment this for Drupal debugging to dblog.
/*
$zz = print_r(get_defined_vars(), TRUE);
$debug_code = '<pre>' . $zz . '</pre>';
watchdog('Stripe', $debug_code);
*/
// Customer not in civicrm_stripe database. Create a new Customer in Stripe.
if (!isset($customer_query)) {
$sc_create_params = array(
'description' => 'Donor from CiviCRM',
'card' => $card_token,
'email' => $email,
);
// For a single charge there is no stripe invoice, we set OrderID to the ChargeID.
if (empty($this->getPaymentProcessorOrderID())) {
$this->setPaymentProcessorOrderID($this->getPaymentProcessorTrxnID());
}
$stripe_customer = $this->stripeCatchErrors('create_customer', $sc_create_params, $params);
// 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);
}
// Store the relationship between CiviCRM's email address for the Contact & Stripe's Customer ID.
if (isset($stripe_customer)) {
if ($this->isErrorReturn($stripe_customer)) {
return $stripe_customer;
}
// Prepare escaped query params.
$query_params = array(
1 => array($email, 'String'),
2 => array($stripe_customer->id, 'String'),
3 => array($this->_paymentProcessor['id'], 'Integer'),
);
/**
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return \Stripe\Customer|PropertySpy
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected function getStripeCustomer(\Civi\Payment\PropertyBag $propertyBag) {
if (!$propertyBag->has('email')) {
// @fixme: Check if we still need to call the getBillingEmail function - eg. how does it handle "email-Primary".
$email = $this->getBillingEmail($propertyBag);
$propertyBag->setEmail($email);
}
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_customers
(email, id, is_live, processor_id) VALUES (%1, %2, '{$this->_islive}', %3)", $query_params);
}
else {
CRM_Core_Error::fatal(ts('There was an error saving new customer within Stripe. Is Stripe down?'));
}
// See if we already have a stripe customer
$customerParams = [
'contact_id' => $propertyBag->getContactID(),
'processor_id' => $this->getPaymentProcessor()['id'],
'email' => $propertyBag->getEmail(),
'currency' => mb_strtolower($propertyBag->getCurrency()),
// Include this to allow redirect within session on payment failure
'error_url' => $this->getErrorUrl($propertyBag),
];
// 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.
// Customers can only be billed for subscriptions in a single currency. currency field was added in 6.10
// So we look for a customer with matching currency and if not check for an empty currency (if customer was created before 6.10)
$stripeCustomer = StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $customerParams['contact_id'])
->addWhere('processor_id', '=', $customerParams['processor_id'])
->addClause('OR', ['currency', 'IS EMPTY'], ['currency', '=', $customerParams['currency']])
->addOrderBy('currency', 'DESC')
->execute()->first();
// Customer not in civicrm database. Create a new Customer in Stripe.
if (empty($stripeCustomer)) {
$stripeCustomerObject = CRM_Stripe_Customer::create($customerParams, $this);
}
else {
// Customer was found in civicrm_stripe database, fetch from Stripe.
$stripe_customer = $this->stripeCatchErrors('retrieve_customer', $customer_query, $params);
if (!empty($stripe_customer)) {
if ($this->isErrorReturn($stripe_customer)) {
return $stripe_customer;
}
// Avoid the 'use same token twice' issue while still using latest card.
if (!empty($params['selectMembership'])
&& $params['selectMembership']
&& empty($params['contributionPageID'])
) {
// This is a Contribution form w/ Membership option and charge is
// coming through for the 2nd time. Don't need to update customer again.
$shouldDeleteStripeCustomer = $shouldCreateNewStripeCustomer = FALSE;
// Customer was found in civicrm database, fetch from Stripe.
try {
$stripeCustomerObject = $this->stripeClient->customers->retrieve($stripeCustomer['customer_id']);
$shouldDeleteStripeCustomer = $stripeCustomerObject->isDeleted();
} catch (Exception $e) {
$err = $this->parseStripeException('retrieve_customer', $e);
\Civi::log('stripe')->error($this->getLogPrefix() . 'Failed to retrieve Stripe Customer: ' . $err['code']);
$shouldDeleteStripeCustomer = TRUE;
}
if (empty($stripeCustomer['currency'])) {
// We have no currency set for the customer in the CiviCRM database
if ($stripeCustomerObject->currency === $customerParams['currency']) {
// We can use this customer but we need to update the currency in the civicrm database
StripeCustomer::update(FALSE)
->addValue('currency', $stripeCustomerObject->currency)
->addWhere('id', '=', $stripeCustomer['id'])
->execute();
}
else {
$stripe_customer->card = $card_token;
$response = $this->stripeCatchErrors('save', $stripe_customer, $params);
if (isset($response) && $this->isErrorReturn($response)) {
return $response;
}
// We need to create a new customer
$shouldCreateNewStripeCustomer = TRUE;
}
}
else {
// Customer was found in civicrm_stripe database, but unable to be
// retrieved from Stripe. Was he deleted?
$sc_create_params = array(
'description' => 'Donor from CiviCRM',
'card' => $card_token,
'email' => $email,
);
$stripe_customer = $this->stripeCatchErrors('create_customer', $sc_create_params, $params);
// Somehow a customer ID saved in the system no longer pairs
// with a Customer within Stripe. (Perhaps deleted using Stripe interface?).
// Store the relationship between CiviCRM's email address for the Contact & Stripe's Customer ID.
if (isset($stripe_customer)) {
/*if ($this->isErrorReturn($stripe_customer)) {
return $stripe_customer;
}*/
// Delete whatever we have for this customer.
$query_params = array(
1 => array($email, 'String'),
2 => array($this->_paymentProcessor['id'], 'Integer'),
);
CRM_Core_DAO::executeQuery("DELETE FROM civicrm_stripe_customers
WHERE email = %1 AND is_live = '{$this->_islive}' AND processor_id = %2", $query_params);
// Create new record for this customer.
$query_params = array(
1 => array($email, 'String'),
2 => array($stripe_customer->id, 'String'),
3 => array($this->_paymentProcessor['id'], 'Integer'),
);
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_customers (email, id, is_live, processor_id)
VALUES (%1, %2, '{$this->_islive}, %3')", $query_params);
}
else {
// Customer was found in civicrm_stripe database, but unable to be
// retrieved from Stripe, and unable to be created in Stripe. What luck :(
CRM_Core_Error::fatal(ts('There was an error saving new customer within Stripe. Is Stripe down?'));
if ($shouldDeleteStripeCustomer) {
// Customer was deleted, delete it.
CRM_Stripe_Customer::delete($customerParams);
}
if ($shouldDeleteStripeCustomer || $shouldCreateNewStripeCustomer) {
try {
$stripeCustomerObject = 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 $stripeCustomerObject;
}
/**
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return bool
*/
private function isPaymentForEventAdditionalParticipants(\Civi\Payment\PropertyBag $propertyBag): bool {
if ($propertyBag->getter('additional_participants', TRUE)) {
return TRUE;
}
return FALSE;
}
/**
* 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
* Stripe customer object generated by Stripe API.
*
* @return array
* The result in a nice formatted array (or an error object).
*
* @throws \CRM_Core_Exception
*/
public function doRecurPayment(\Civi\Payment\PropertyBag $propertyBag, int $amountFormattedForStripe, $stripeCustomer): array {
// Make sure recurFrequencyInterval is set (default to 1 if not)
if (!$propertyBag->has('recurFrequencyInterval') || $propertyBag->getRecurFrequencyInterval() === 0) {
$propertyBag->setRecurFrequencyInterval(1);
}
$params = $this->getPropertyBagAsArray($propertyBag);
// @fixme FROM HERE we are using $params array (but some things are READING from $propertyBag)
// @fixme: Split this out into "$returnParams"
// 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 (!$propertyBag->has('recurFrequencyUnit')) {
$required = 'recurFrequencyUnit';
}
if ($required) {
Civi::log()->error($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
throw new CRM_Core_Exception($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
}
// Create the stripe plan
$plan = self::createPlan($propertyBag, $amountFormattedForStripe);
// Attach the Subscription to the Stripe Customer.
$subscriptionParams = [
'proration_behavior' => 'none',
'plan' => $plan->id,
'metadata' => ['Description' => $propertyBag->getDescription()],
'expand' => ['latest_invoice.payment_intent'],
'customer' => $stripeCustomer->id,
'off_session' => TRUE,
];
// 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 = 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 ($propertyBag->has('recurInstallments') && ($propertyBag->getRecurInstallments() > 0)) {
// 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', $propertyBag->getRecurInstallments());
}
// Prepare the charge array, minus Customer/Card details.
if (empty($params['description'])) {
$params['description'] = ts('CiviCRM backend contribution');
if ($stripeSubscription->status === 'incomplete') {
$contributionRecur->addValue('contribution_status_id:name', 'Failed');
}
else {
$params['description'] = ts('CiviCRM # ') . $params['description'];
// Update the recurring payment
$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('stripe')->warning($this->getLogPrefix() . 'subscription status=incomplete. ID:' . $stripeSubscription->id);
throw new PaymentProcessorException('Payment failed');
}
// Stripe charge.
$stripe_charge = array(
'amount' => $amount,
'currency' => strtolower($params['currencyID']),
'description' => $params['description'] . ' # Invoice ID: ' . CRM_Utils_Array::value('invoiceID', $params),
);
// 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);
// Use Stripe Customer if we have a valid one. Otherwise just use the card.
if (!empty($stripe_customer->id)) {
$stripe_charge['customer'] = $stripe_customer->id;
// 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']);
}
}
else {
$stripe_charge['card'] = $card_token;
// 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);
}
// Handle recurring payments in doRecurPayment().
if (CRM_Utils_Array::value('is_recur', $params) && $params['contributionRecurID']) {
return $this->doRecurPayment($params, $amount, $stripe_customer);
}
// Fire away! Check for errors before trying to submit.
$stripe_response = $this->stripeCatchErrors('charge', $stripe_charge, $params);
if (!empty($stripe_response)) {
if ($this->isErrorReturn($stripe_response)) {
return $stripe_response;
}
// Success! Return some values for CiviCRM.
$params['trxn_id'] = $stripe_response->id;
// Return fees & net amount for Civi reporting.
// Uses new Balance Trasaction object.
$balance_transaction = $this->stripeCatchErrors('retrieve_balance_transaction', $stripe_response->balance_transaction, $params);
if (!empty($balance_transaction)) {
if ($this->isErrorReturn($balance_transaction)) {
return $balance_transaction;
}
$params['fee_amount'] = $balance_transaction->fee / 100;
$params['net_amount'] = $balance_transaction->net / 100;
return $this->endDoPayment($params);
}
/**
* Get the billing cycle day (timestamp)
* @param array $params
*
* @return int|null
*/
private function getRecurBillingCycleDay($params) {
if (isset($params['receive_date'])) {
$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;
}
}
else {
// There was no response from Stripe on the create charge command.
if (isset($error_url)) {
CRM_Core_Error::statusBounce('Stripe transaction response not recieved! Check the Logs section of your stripe.com account.', $error_url);
// Either we had no receive_date or receive_date was in the past (or "now" when form was submitted).
return NULL;
}
/**
* 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();
}
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;
switch ($intent->status) {
case 'requires_capture':
$intent->capture();
case 'succeeded':
// Return fees & net amount for Civi reporting.
if (!empty($intent->charges)) {
// Stripe API version < 2022-11-15
$stripeCharge = $intent->charges->data[0];
}
elseif (!empty($intent->latest_charge)) {
// Stripe API version 2022-11-15
$stripeCharge = $this->stripeClient->charges->retrieve($intent->latest_charge);
}
try {
$stripeBalanceTransaction = $this->stripeClient->balanceTransactions->retrieve($stripeCharge->balance_transaction);
}
catch (Exception $e) {
$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']);
}
else {
// 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]);
}
break;
}
}
catch (Exception $e) {
$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);
}
return $params;
}
/**
* Submit a recurring payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
* Submit a refund payment
*
* @param array $params
* Assoc array of input parameters for this transaction.
* @param int $amount
* Transaction amount in USD cents.
* @param object $stripe_customer
* Stripe customer object generated by Stripe API.
*
* @return array
* The result in a nice formatted array (or an error object).
*
* @public
*/
public function doRecurPayment(&$params, $amount, $stripe_customer) {
// Get recurring contrib properties.
$frequency = $params['frequency_unit'];
$frequency_interval = (empty($params['frequency_interval']) ? 1 : $params['frequency_interval']);
$currency = strtolower($params['currencyID']);
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.
// 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.
// TODO: add conditonals that look for $param['intro_offer'] (to give admins the choice of default behavior) and
// $params['trial_period'].
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.
$stripe_customer->account_balance = -$discount_in_cents;
$stripe_customer->save();
}
// 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.
$membership_type_tag = '';
$membership_name = '';
if (isset($params['selectMembership'])) {
$membership_type_id = $params['selectMembership'][0];
$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}";
// Prepare escaped query params.
$query_params = array(
1 => array($plan_id, 'String'),
);
// Prepare escaped query params.
$query_params = array(
1 => array($plan_id, 'String'),
2 => array($this->_paymentProcessor['id'], 'Integer'),
);
$stripe_plan_query = CRM_Core_DAO::singleValueQuery("SELECT plan_id
FROM civicrm_stripe_plans
WHERE plan_id = %1 AND is_live = '{$this->_islive}' AND processor_id = %2", $query_params);
if (!isset($stripe_plan_query)) {
$formatted_amount = number_format(($amount / 100), 2);
$product = \Stripe\Product::create(array(
"name" => "CiviCRM {$membership_name} every {$frequency_interval} {$frequency}(s) {$formatted_amount}{$currency}{$mode_tag}",
"type" => "service"
));
// Create a new Plan.
$stripe_plan = array(
'amount' => $amount,
'interval' => $frequency,
'product' => $product->id,
'currency' => $currency,
'id' => $plan_id,
'interval_count' => $frequency_interval,
);
$ignores = array(
array(
'class' => 'Stripe_InvalidRequestError',
'type' => 'invalid_request_error',
'message' => 'Plan already exists.',
),
);
$this->stripeCatchErrors('create_plan', $stripe_plan, $params, $ignores);
// Prepare escaped query params.
$query_params = array(
1 => array($plan_id, 'String'),
2 => array($this->_paymentProcessor['id'], 'Integer'),
);
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_plans (plan_id, is_live, processor_id)
VALUES (%1, '{$this->_islive}', %2)", $query_params);
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
public function doRefund(&$params) {
$requiredParams = ['trxn_id', 'amount'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = $this->getLogPrefix() . 'doRefund: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new PaymentProcessorException($message);
}
}
// 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.
// 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.
$propertyBag = PropertyBag::cast($params);
// Attach the Subscription to the Stripe Customer.
$cust_sub_params = array(
'prorate' => FALSE,
'plan' => $plan_id,
);
$stripe_response = $stripe_customer->subscriptions->create($cust_sub_params);
$subscription_id = $stripe_response->id;
$recuring_contribution_id = $params['contributionRecurID'];
// Prepare escaped query params.
$query_params = array(
1 => array($subscription_id, 'String'),
2 => array($stripe_customer->id, 'String'),
3 => array($recuring_contribution_id, 'String'),
4 => array($this->_paymentProcessor['id'], 'Integer'),
);
// Insert the Stripe Subscription info.
// Let end_time be NULL if installments are ongoing indefinitely
if (empty($installments)) {
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_subscriptions
(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}");
// Add the end time to the query params.
$query_params[5] = array($end_time, 'Integer');
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_subscriptions
(subscription_id, customer_id, contribution_recur_id, processor_id, end_time, is_live)
VALUES (%1, %2, %3, %4, %5, '{$this->_islive}')", $query_params);
$refundParams = [
'charge' => $params['trxn_id'],
];
$refundParams['amount'] = $this->getAmountFormattedForStripeAPI($propertyBag);
try {
$refund = $this->stripeClient->refunds->create($refundParams);
// Stripe does not refund fees - see https://support.stripe.com/questions/understanding-fees-for-refunded-payments
// $fee = $this->getFeeFromBalanceTransaction($refund['balance_transaction'], $refund['currency']);
}
catch (Exception $e) {
$this->handleError($e->getCode(), $e->getMessage());
}
// Don't return a $params['trxn_id'] here or else recurring membership contribs will be set
// "Completed" prematurely. Webhook.php does that.
switch ($refund->status) {
case 'pending':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
$refundStatusName = 'Pending';
break;
return $params;
case 'succeeded':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
$refundStatusName = 'Completed';
break;
case 'failed':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');
$refundStatusName = 'Failed';
break;
case 'canceled':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
$refundStatusName = 'Cancelled';
break;
}
$refundParams = [
'refund_trxn_id' => $refund->id,
'refund_status_id' => $refundStatus,
'refund_status' => $refundStatusName,
'fee_amount' => 0,
];
return $refundParams;
}
/**
* Transfer method not in use.
*
* @param array $params
* Name value pair of contribution data.
* Get a description field
* @param array|PropertyBag $params
* @param string $type
* One of description, statement_descriptor, statement_descriptor_suffix
*
* @return void
* @return string
*/
protected function getDescription($params, string $type = 'description'): string {
/* @var \Civi\Payment\PropertyBag $propertyBag */
$propertyBag = PropertyBag::cast($params);
# See https://stripe.com/docs/statement-descriptors
# 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".
$disallowed_characters = ['<', '>', '\\', "'", '"', '*'];
$contactContributionID = $propertyBag->getContactID() . 'X' . ($propertyBag->has('contributionID') ? $propertyBag->getContributionID() : 'XX');
switch ($type) {
// 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 ".".
case 'statement_descriptor':
$description = trim(\Civi::settings()->get('stripe_statementdescriptor'));
if (empty($description)) {
$description = trim("{$contactContributionID} {$propertyBag->getDescription()}");
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)) {
$description = 'X';
}
return substr($description, 0, 22);
case 'statement_descriptor_suffix':
$description = trim(\Civi::settings()->get('stripe_statementdescriptorsuffix'));
if (empty($description)) {
$description = trim("{$contactContributionID} {$propertyBag->getDescription()}");
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)) {
$description = 'X';
}
return substr($description,0,12);
default:
// The (paymentIntent) full description has no restriction on characters that are allowed/disallowed.
return "{$propertyBag->getDescription()} " . $contactContributionID . " #" . ($propertyBag->has('invoiceID') ? $propertyBag->getInvoiceID() : '');
}
}
/**
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
*
* @access public
* @return string
* @throws \CRM_Core_Exception
*/
public function calculateEndDate($params) {
$requiredParams = ['receive_date', 'recurInstallments', 'recurFrequencyInterval', 'recurFrequencyUnit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = $this->getLogPrefix() . 'calculateEndDate: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new CRM_Core_Exception($message);
}
}
switch ($params['recurFrequencyUnit']) {
case 'day':
$frequencyUnit = 'D';
break;
case 'week':
$frequencyUnit = 'W';
break;
case 'month':
$frequencyUnit = 'M';
break;
case 'year':
$frequencyUnit = 'Y';
break;
}
$numberOfUnits = $params['recurInstallments'] * $params['recurFrequencyInterval'];
$endDate = new DateTime($params['receive_date']);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd') . '235959';
}
/**
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
*
* @return string
* @throws \CRM_Core_Exception
*/
public function doTransferCheckout(&$params, $component) {
CRM_Core_Error::fatal(ts('Use direct billing instead of Transfer method.'));
public function calculateNextScheduledDate($params) {
$requiredParams = ['recurFrequencyInterval', 'recurFrequencyUnit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = $this->getLogPrefix() . 'calculateNextScheduledDate: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new CRM_Core_Exception($message);
}
}
if (empty($params['receive_date']) && empty($params['next_sched_contribution_date'])) {
$startDate = date('YmdHis');
}
elseif (!empty($params['next_sched_contribution_date'])) {
if ($params['next_sched_contribution_date'] < date('YmdHis')) {
$startDate = $params['next_sched_contribution_date'];
}
}
else {
$startDate = $params['receive_date'];
}
switch ($params['recurFrequencyUnit']) {
case 'day':
$frequencyUnit = 'D';
break;
case 'week':
$frequencyUnit = 'W';
break;
case 'month':
$frequencyUnit = 'M';
break;
case 'year':
$frequencyUnit = 'Y';
break;
}
$numberOfUnits = $params['recurFrequencyInterval'];
$endDate = new DateTime($startDate);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd');
}
/**
......@@ -906,10 +1320,360 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
public function validatePaymentInstrument($values, &$errors) {
// 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);
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']);
}
/**
* Attempt to cancel the subscription at Stripe.
*
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array|null[]
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doCancelRecurring(PropertyBag $propertyBag) {
// By default we always notify the processor and we don't give the user the option
// because supportsCancelRecurringNotifyOptional() = FALSE
if (!$propertyBag->has('isNotifyProcessorOnCancelRecur')) {
// If isNotifyProcessorOnCancelRecur is NOT set then we set our default
$propertyBag->setIsNotifyProcessorOnCancelRecur(TRUE);
}
$notifyProcessor = $propertyBag->getIsNotifyProcessorOnCancelRecur();
if (!$notifyProcessor) {
return ['message' => E::ts('Successfully cancelled the subscription in CiviCRM ONLY.')];
}
if (!$propertyBag->has('recurProcessorID')) {
$errorMessage = E::ts('The recurring contribution cannot be cancelled (No reference (processor_id) found).');
\Civi::log('stripe')->error($errorMessage);
throw new PaymentProcessorException($errorMessage);
}
try {
$subscription = $this->stripeClient->subscriptions->retrieve($propertyBag->getRecurProcessorID());
if (!$subscription->isDeleted()) {
$subscription->cancel();
}
}
catch (Exception $e) {
$errorMessage = E::ts('Could not cancel Stripe subscription: %1', [1 => $e->getMessage()]);
\Civi::log('stripe')->error($errorMessage);
throw new PaymentProcessorException($errorMessage);
}
return ['message' => E::ts('Successfully cancelled the subscription at Stripe.')];
}
/**
* Change the amount of the recurring payment.
*
* @param string $message
* @param array $params
*
* @return bool|object
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function changeSubscriptionAmount(&$message = '', $params = []) {
// We only support the following params: amount
try {
$propertyBag = $this->beginChangeSubscriptionAmount($params);
// Get the Stripe subscription
$subscription = $this->stripeClient->subscriptions->retrieve($propertyBag->getRecurProcessorID());
$calculatedItems = $this->api->calculateItemsForSubscription($propertyBag->getRecurProcessorID(), $subscription->items->data);
$contributionRecurKey = mb_strtolower($propertyBag->getCurrency()) . "_{$propertyBag->getRecurFrequencyUnit()}_{$propertyBag->getRecurFrequencyInterval()}";
if (isset($calculatedItems[$contributionRecurKey])) {
$calculatedItem = $calculatedItems[$contributionRecurKey];
if (Money::of($calculatedItem['amount'], mb_strtoupper($calculatedItem['currency']))
->isAmountAndCurrencyEqualTo(Money::of($propertyBag->getAmount(), $propertyBag->getCurrency()))) {
throw new PaymentProcessorException('Amount is the same as before!');
}
}
else {
throw new PaymentProcessorException('Cannot find existing price/plan for this subscription with matching frequency!');
}
// Get the existing Price
$existingPrice = $subscription->items->data[0]->price;
// Check if the Stripe Product already has a Price configured for the new amount
$priceToMatch = [
'active' => TRUE,
'currency' => $subscription->currency,
'product' => $existingPrice->product,
'type' => 'recurring',
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
],
];
$existingPrices = $this->stripeClient->prices->all($priceToMatch);
foreach ($existingPrices as $price) {
if ($price->unit_amount === (int) $this->getAmountFormattedForStripeAPI($propertyBag)) {
// Yes, we already have a matching price option - use it!
$newPriceID = $price->id;
break;
}
}
if (empty($newPriceID)) {
// We didn't find an existing price that matched for the product. Create a new one.
$newPriceParameters = [
'currency' => $subscription->currency,
'unit_amount' => $this->getAmountFormattedForStripeAPI($propertyBag),
'product' => $existingPrice->product,
'metadata' => $existingPrice->metadata->toArray(),
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
'interval_count' => $existingPrice->recurring['interval_count'],
],
];
$newPriceID = $this->stripeClient->prices->create($newPriceParameters)->id;
}
// Update the Stripe subscription, replacing the existing price with the new one.
$this->stripeClient->subscriptions->update($propertyBag->getRecurProcessorID(), [
'items' => [
[
'id' => $subscription->items->data[0]->id,
'price' => $newPriceID,
],
],
// See https://stripe.com/docs/billing/subscriptions/prorations - we disable this to keep it simple for now.
'proration_behavior' => 'none',
]);
}
catch (Exception $e) {
// On ANY failure, throw an exception which will be reported back to the user.
\Civi::log()->error('Update Subscription failed for RecurID: ' . $propertyBag->getContributionRecurID() . ' Error: ' . $e->getMessage());
throw new PaymentProcessorException('Update Subscription Failed: ' . $e->getMessage(), $e->getCode(), $params);
}
return TRUE;
}
/**
* Process incoming payment notification (IPN).
*
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public function handlePaymentNotification() {
// Set default http response to 200
http_response_code(200);
$rawData = file_get_contents("php://input");
$event = json_decode($rawData, TRUE);
$ipnClass = new CRM_Core_Payment_StripeIPN($this);
$ipnClass->setEventID($event['id']);
if (!$ipnClass->setEventType($event['type'])) {
// We don't handle this event
return;
}
$webhookSecret = $this->getWebhookSecret();
if (!empty($webhookSecret)) {
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];
try {
Webhook::constructEvent($rawData, $sigHeader, $webhookSecret);
$ipnClass->setVerifyData(FALSE);
$data = StripeObject::constructFrom($event['data']);
$ipnClass->setData($data);
} catch (\UnexpectedValueException $e) {
// Invalid payload
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
} catch (\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
}
}
else {
$ipnClass->setVerifyData(TRUE);
}
$ipnClass->onReceiveWebhook();
}
/**
* @param int $paymentProcessorID
* The actual payment processor ID that should be used.
* @param $rawData
* The "raw" data, eg. a JSON string that is saved in the civicrm_system_log.context table
* @param bool $verifyRequest
* Should we verify the request data with the payment processor (eg. retrieve it again?).
* @param null|int $emailReceipt
* Override setting of email receipt if set to 0, 1
*
* @return bool
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @deprecated
*/
public static function processPaymentNotification($paymentProcessorID, $rawData, $verifyRequest = TRUE, $emailReceipt = NULL) {
// Set default http response to 200
http_response_code(200);
$event = json_decode($rawData);
$paymentProcessorObject = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
if (!($paymentProcessorObject instanceof CRM_Core_Payment_Stripe)) {
throw new PaymentProcessorException('Failed to get payment processor');
}
$ipnClass = new CRM_Core_Payment_StripeIPN($paymentProcessorObject);
$ipnClass->setEventID($event->id);
if (!$ipnClass->setEventType($event->type)) {
// We don't handle this event
return FALSE;
};
$ipnClass->setVerifyData($verifyRequest);
if (!$verifyRequest) {
$ipnClass->setData($event->data);
}
$ipnClass->setExceptionMode(FALSE);
if (isset($emailReceipt)) {
$ipnClass->setSendEmailReceipt($emailReceipt);
}
return $ipnClass->processWebhookEvent()->ok;
}
/**
* Called by mjwshared extension's queue processor api3 Job.process_paymentprocessor_webhooks
*
* The array parameter contains a row of PaymentprocessorWebhook data, which represents a single PaymentprocessorWebhook event
*
* Return TRUE for success, FALSE if there's a problem
*/
public function processWebhookEvent(array $webhookEvent) :bool {
// If there is another copy of this event in the table with a lower ID, then
// this is a duplicate that should be ignored. We do not worry if there is one with a higher ID
// because that means that while there are duplicates, we'll only process the one with the lowest ID.
$duplicates = PaymentprocessorWebhook::get(FALSE)
->selectRowCount()
->addWhere('event_id', '=', $webhookEvent['event_id'])
->addWhere('id', '<', $webhookEvent['id'])
->execute()->count();
if ($duplicates) {
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', '=', $webhookEvent['id'])
->addValue('status', 'error')
->addValue('message', 'Refusing to process this event as it is a duplicate.')
->execute();
return FALSE;
}
$handler = new CRM_Core_Payment_StripeIPN($this);
$handler->setEventID($webhookEvent['event_id']);
if (!$handler->setEventType($webhookEvent['trigger'])) {
// We don't handle this event
return FALSE;
}
// Populate the data from this webhook.
$rawEventData = str_replace('Stripe\StripeObject JSON: ', '', $webhookEvent['data']);
$eventData = json_decode($rawEventData, TRUE);
$data = StripeObject::constructFrom($eventData);
$handler->setData($data);
// We retrieve/validate/store the webhook data when it is received.
$handler->setVerifyData(FALSE);
$handler->setExceptionMode(FALSE);
return $handler->processQueuedWebhookEvent($webhookEvent);
}
/**
* Get help text information (help, description, etc.) about this payment,
* to display to the user.
*
* @param string $context
* Context of the text.
* Only explicitly supported contexts are handled without error.
* Currently supported:
* - contributionPageRecurringHelp (params: is_recur_installments, is_email_receipt)
* - contributionPageContinueText (params: amount, is_payment_to_existing)
* - cancelRecurDetailText:
* params:
* mode, amount, currency, frequency_interval, frequency_unit,
* installments, {membershipType|only if mode=auto_renew},
* selfService (bool) - TRUE if user doesn't have "edit contributions" permission.
* ie. they are accessing via a "self-service" link from an email receipt or similar.
* - cancelRecurNotSupportedText
*
* @param array $params
* Parameters for the field, context specific.
*
* @return string
*/
public function getText($context, $params) {
$text = parent::getText($context, $params);
switch ($context) {
case 'cancelRecurDetailText':
// $params['selfService'] added via https://github.com/civicrm/civicrm-core/pull/17687
$params['selfService'] = $params['selfService'] ?? TRUE;
if ($params['selfService']) {
$text .= ' <br/><strong>' . E::ts('Stripe will be automatically notified and the subscription will be cancelled.') . '</strong>';
}
else {
$text .= ' <br/><strong>' . E::ts("If you select 'Send cancellation request..' then Stripe will be automatically notified and the subscription will be cancelled.") . '</strong>';
}
}
return $text;
}
/**
* Get the help text to present on the recurring update page.
*
* This should reflect what can or cannot be edited.
*
* @return string
*/
public function getRecurringScheduleUpdateHelpText() {
return E::ts('Use this form to change the amount for this recurring contribution. The Stripe subscription will be updated with the new amount.');
}
/*
* Sets a mock stripe client object for this object and all future created
* instances. This should only be called by phpunit tests.
*
* Nb. cannot change other already-existing instances.
*/
public function setMockStripeClient($stripeClient) {
if (!defined('STRIPE_PHPUNIT_TEST')) {
throw new \RuntimeException("setMockStripeClient was called while not in a STRIPE_PHPUNIT_TEST");
}
$GLOBALS['mockStripeClient'] = $this->stripeClient = $stripeClient;
}
/**
* Get the Fee charged by Stripe from the "balance transaction".
* If the transaction is declined, there won't be a balance_transaction_id.
* We also have to do currency conversion here in case Stripe has converted it internally.
*
* @param \Stripe\BalanceTransaction|PropertySpy $balanceTransaction
* @param
*
* @return float
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function getFeeFromBalanceTransaction($balanceTransaction, string $currency): float {
if ($currency !== $balanceTransaction->currency && !empty($balanceTransaction->exchange_rate)) {
$fee = CRM_Stripe_Api::currencyConversion($balanceTransaction->fee, $balanceTransaction->exchange_rate, $currency);
} else {
// 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.
$fee = round($balanceTransaction->fee / 100, CRM_Utils_Money::getCurrencyPrecision($currency));
}
return $fee;
}
/**
* @return string
*/
public function getLogPrefix(): string {
return 'Stripe(' . $this->getID() . '): ';
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
use Civi\Payment\Exception\PaymentProcessorException;
/**
* Class CRM_Core_Payment_Stripe
*/
class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
use CRM_Core_Payment_MJWTrait;
/**
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeName() {
return 'stripe-checkout';
}
/**
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeLabel() {
return E::ts('Stripe Checkout');
}
/**
* We can use the stripe processor on the backend
*
* @return bool
*/
public function supportsBackOffice() {
return FALSE;
}
/**
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return FALSE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
/**
* Can we set a future recur start date?
*
* @return bool
*/
public function supportsFutureRecurStartDate() {
return FALSE;
}
/**
* Is an authorize-capture flow supported.
*
* @return bool
*/
protected function supportsPreApproval() {
return FALSE;
}
/**
* 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() {
return TRUE;
}
/**
* Set default values when loading the (payment) form
*
* @param \CRM_Core_Form $form
*/
public function buildForm(&$form) {}
/**
* Process payment
* Submit a payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
* Payment processors should set payment_status_id/payment_status.
*
* @param array|PropertyBag $paymentParams
* Assoc array of input parameters for this transaction.
* @param string $component
*
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doPayment(&$paymentParams, $component = 'contribute') {
/* @var \Civi\Payment\PropertyBag $propertyBag */
$propertyBag = \Civi\Payment\PropertyBag::cast($paymentParams);
$zeroAmountPayment = $this->processZeroAmountPayment($propertyBag);
if ($zeroAmountPayment) {
return $zeroAmountPayment;
}
$propertyBag = $this->beginDoPayment($propertyBag);
// Not sure what the point of this next line is.
$this->_component = $component;
$successUrl = $this->getReturnSuccessUrl($paymentParams['qfKey']);
$failUrl = $this->getCancelUrl($paymentParams['qfKey'], NULL);
$lineItems = $this->calculateLineItems($paymentParams);
$checkoutSession = $this->createCheckoutSession($successUrl, $failUrl, $propertyBag, $lineItems);
// Allow each CMS to do a pre-flight check before redirecting to Stripe.
CRM_Core_Config::singleton()->userSystem->prePostRedirect();
CRM_Utils_System::setHttpHeader("HTTP/1.1 303 See Other", '');
CRM_Utils_System::redirect($checkoutSession->url);
}
/**
* This gathers the line items which are then used in buildCheckoutLineItems()
*
* @param array|PropertyBag $paymentParams
*/
public function calculateLineItems($paymentParams): array {
$lineItems = [];
if (!empty($paymentParams['skipLineItem']) || empty($paymentParams['line_item'])) {
if (!empty($paymentParams['participants_info'])) {
// Events: Multiple participants. Lineitem for each participant is in participantDetail.
foreach ($paymentParams['participants_info'] as $participantID => $participantDetail) {
$lineItems = array_merge($lineItems, $participantDetail['lineItem']);
}
}
else {
// Fallback if no lineitems (some contribution pages)
$lineItems = [
'priceset' => [
'pricesetline' => [
'unit_price' => $paymentParams['amount'],
// source is available on contribution pages, description on event registration
'field_title' => $paymentParams['source'] ?? $paymentParams['description'],
'label' => $paymentParams['source'] ?? $paymentParams['description'],
'qty' => 1,
],
],
];
}
}
else {
$lineItems = $paymentParams['line_item'] ?? [];
}
return $lineItems;
}
/**
* Create a Stripe Checkout Session
*
* @return \Stripe\Checkout\Session
*/
public function createCheckoutSession(string $successUrl, string $failUrl, PropertyBag $propertyBag, array $lineItems) {
// Get existing/saved Stripe customer or create a new one
$existingStripeCustomer = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $propertyBag->getContactID())
->addWhere('processor_id', '=', $this->getPaymentProcessor()['id'])
->execute()
->first();
if (empty($existingStripeCustomer)) {
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$stripeCustomerID = $stripeCustomer->id;
}
else {
$stripeCustomerID = $existingStripeCustomer['customer_id'];
}
// Build the checkout session parameters
$checkoutSessionParams = [
'line_items' => $this->buildCheckoutLineItems($lineItems, $propertyBag),
'mode' => $propertyBag->getIsRecur() ? 'subscription' : 'payment',
'success_url' => $successUrl,
'cancel_url' => $failUrl,
// Nb. We can only specify customer_email|customer, not both.
// 'customer_email' => $propertyBag->getEmail(),
'customer' => $stripeCustomerID,
// 'submit_type' => one of 'auto', pay, book, donate
'client_reference_id' => $propertyBag->getInvoiceID(),
'payment_method_types' => $this->getSupportedPaymentMethods($propertyBag),
];
// Stripe Error:
// Stripe\Exception\InvalidRequestException: You can not pass `payment_intent_data` in `subscription` mode.
if ($propertyBag->getIsRecur()) {
$checkoutSessionParams['subscription_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
}
else {
$checkoutSessionParams['payment_intent_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
}
// Allows you to alter the params passed to StripeCheckout (eg. payment_method_types)
CRM_Utils_Hook::alterPaymentProcessorParams($this, $propertyBag, $checkoutSessionParams);
try {
$checkoutSession = $this->stripeClient->checkout->sessions->create($checkoutSessionParams);
}
catch (Exception $e) {
$parsedError = $this->parseStripeException('doPayment', $e);
throw new PaymentProcessorException($parsedError['message']);
}
CRM_Stripe_BAO_StripeCustomer::updateMetadata(['contact_id' => $propertyBag->getContactID()], $this, $checkoutSession['customer']);
return $checkoutSession;
}
/**
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array
*/
private function getSupportedPaymentMethods(\Civi\Payment\PropertyBag $propertyBag): array {
$paymentMethods = \Civi::settings()->get('stripe_checkout_supported_payment_methods');
$result = [];
foreach ($paymentMethods as $paymentMethod) {
switch ($paymentMethod) {
case 'sepa_debit':
case 'bancontact':
if ($propertyBag->getCurrency() === 'EUR') {
$result[] = $paymentMethod;
}
break;
case 'us_bank_account':
if ($propertyBag->getCurrency() === 'USD') {
$result[] = $paymentMethod;
}
break;
case 'bacs_debit':
if ($propertyBag->getCurrency() === 'GBP') {
$result[] = $paymentMethod;
}
break;
default:
$result[] = $paymentMethod;
}
}
if (empty($result)) {
throw new PaymentProcessorException('There are no valid Stripe payment methods enabled for this configuration. Check currency etc.');
}
return $result;
}
/**
* Takes the lineitems passed into doPayment and converts them into an array suitable for passing to Stripe Checkout
*
* @param array $civicrmLineItems
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
private function buildCheckoutLineItems(array $civicrmLineItems, PropertyBag $propertyBag) {
foreach ($civicrmLineItems as $priceSetLines) {
foreach ($priceSetLines as $lineItem) {
$checkoutLineItem = [
'price_data' => [
'currency' => $propertyBag->getCurrency(),
'unit_amount' => $this->getAmountFormattedForStripeAPI(PropertyBag::cast(['amount' => $lineItem['unit_price'], 'currency' => $propertyBag->getCurrency()])),
'product_data' => [
'name' => $lineItem['field_title'],
// An empty label on a contribution page amounts configuration gives an empty $lineItem['label']. StripeCheckout needs it set.
'description' => $lineItem['label'] ?: $lineItem['field_title'],
//'images' => ['https://example.com/t-shirt.png'],
],
],
'quantity' => $lineItem['qty'],
];
if ($propertyBag->getIsRecur()) {
$checkoutLineItem['price_data']['recurring'] = [
'interval' => $propertyBag->getRecurFrequencyUnit(),
'interval_count' => $propertyBag->getRecurFrequencyInterval(),
];
}
$checkoutLineItems[] = $checkoutLineItem;
}
}
return $checkoutLineItems ?? [];
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use Civi\Api4\ContributionRecur;
use Civi\Api4\PaymentprocessorWebhook;
use Civi\Payment\Exception\PaymentProcessorException;
/**
* Class CRM_Core_Payment_StripeIPN
*/
class CRM_Core_Payment_StripeIPN {
use CRM_Core_Payment_MJWIPNTrait;
/**
* @var \CRM_Core_Payment_Stripe Payment processor
*/
protected $_paymentProcessor;
/**
* The data provided by the IPN
* Redeclared here to tighten up the var type
*
* @var \Stripe\StripeObject
*/
protected $data;
/**
* The CiviCRM contact ID that maps to the Stripe customer
*
* @var int
*/
protected $contactID = NULL;
// Properties of the event.
/**
* @var string The Stripe Subscription ID
*/
protected $subscription_id = NULL;
/**
* @var string The Stripe Customer ID
*/
protected $customer_id = NULL;
/**
* @var string The Stripe PaymentIntent ID
*/
protected $payment_intent_id = NULL;
/**
* @var string The Stripe Charge ID
*/
protected $charge_id = NULL;
/**
* @var string The stripe Invoice ID (mapped to trxn_id on a contribution for recurring contributions)
*/
protected $invoice_id = NULL;
/**
* @var string The date/time the charge was made
*/
protected $receive_date = NULL;
/**
* @var float The amount paid
*/
protected $amount = 0.0;
/**
* @var float The fee charged by Stripe
*/
protected $fee = 0.0;
/**
* @var array The current contribution (linked to Stripe charge(single)/invoice(subscription)
*/
protected $contribution = NULL;
/**
* @var bool
*/
protected $setInputParametersHasRun = FALSE;
/**
* Normally if any exception is thrown in processing a webhook it is
* caught and a simple error logged.
*
* In a test environment it is often helpful for it to throw the exception instead.
*
* @var bool.
*/
public $exceptionOnFailure = FALSE;
/**
* Redeclared here to tighten up the var type
* Can't define return because unit tests return PropertySpy
* Should be \Stripe\StripeObject|PropertySpy with PHP8.0+
*
* @return \Stripe\StripeObject
*/
public function getData() {
return $this->data;
}
public function __construct(?CRM_Core_Payment_Stripe $paymentObject = NULL) {
if ($paymentObject !== NULL && !($paymentObject instanceof CRM_Core_Payment_Stripe)) {
// This would be a coding error.
throw new Exception(__CLASS__ . " constructor requires CRM_Core_Payment_Stripe object (or NULL for legacy use).");
}
$this->_paymentProcessor = $paymentObject;
}
/**
* Returns TRUE if we handle this event type, FALSE otherwise
* @param string $eventType
*
* @return bool
*/
public function setEventType($eventType) {
$this->eventType = $eventType;
if (!in_array($this->eventType, CRM_Stripe_Webhook::getDefaultEnabledEvents())) {
return FALSE;
}
return TRUE;
}
/**
* Set and initialise the paymentProcessor object
* @param int $paymentProcessorID
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function setPaymentProcessor($paymentProcessorID) {
try {
$this->_paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
}
catch (Exception $e) {
$this->exception('Failed to get payment processor');
}
}
/**
* @return string|null
*/
public function getStripeCustomerID() {
return $this->customer_id;
}
/**
* @return string|null
*/
public function getStripeSubscriptionID() {
return $this->subscription_id;
}
/**
* @return string|null
*/
public function getStripeInvoiceID() {
return $this->invoice_id;
}
/**
* @return string|null
*/
public function getStripeChargeID() {
return $this->charge_id;
}
/**
* Check, decode, validate webhook data and extract some parameters to the class.
*/
public function setInputParameters() {
if ($this->setInputParametersHasRun) {
return;
}
// If we don't have a webhook signing secret we need to retrieve the event again
// to make sure that it is "real" and was not faked.
if ($this->getVerifyData()) {
/** @var \Stripe\Event $event */
$event = $this->getPaymentProcessor()->stripeClient->events->retrieve($this->eventID);
$this->setData($event->data);
}
// If we have a webhook signing secret data was already set on the class.
$data = $this->getData();
if (!is_object($data)) {
$this->exception('Invalid input data (not an object)');
}
// When we receive a charge.X webhook event and it has an invoice ID we expand the invoice object
// so that we have the subscription ID.
// We'll receive both invoice.payment_succeeded/failed and charge.succeeded/failed at the same time
// and we need to make sure we don't process them at the same time or we can get deadlocks/race conditions
// that cause processing to fail.
if (($data->object instanceof \Stripe\Charge) && !empty($data->object->invoice)) {
$data->object = $this->getPaymentProcessor()->stripeClient->charges->retrieve(
$this->getData()->object->id,
['expand' => ['invoice']]
);
$this->setData($data);
$this->subscription_id = CRM_Stripe_Api::getObjectParam('subscription_id', $this->getData()->object->invoice);
$this->invoice_id = CRM_Stripe_Api::getObjectParam('invoice_id', $this->getData()->object->invoice);
}
else {
$this->subscription_id = $this->retrieve('subscription_id', 'String', FALSE);
$this->invoice_id = $this->retrieve('invoice_id', 'String', FALSE);
}
$this->charge_id = $this->retrieve('charge_id', 'String', FALSE);
$this->payment_intent_id = $this->retrieve('payment_intent_id', 'String', FALSE);
$this->customer_id = $this->retrieve('customer_id', 'String', FALSE);
$this->setInputParametersHasRun = TRUE;
}
/**
* Get a parameter from the Stripe data object
*
* @param string $name
* @param string $type
* @param bool $abort
*
* @return int|mixed|null
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function retrieve($name, $type, $abort = TRUE) {
$value = CRM_Stripe_Api::getObjectParam($name, $this->getData()->object);
$value = CRM_Utils_Type::validate($value, $type, FALSE);
if ($abort && $value === NULL) {
echo "Failure: Missing or invalid parameter " . CRM_Utils_Type::escape($name, 'String');
$this->exception("Missing or invalid parameter {$name}");
}
return $value;
}
/**
* Get a unique identifier string based on webhook data.
*
* @return string
*/
private function getWebhookUniqueIdentifier() {
return "{$this->payment_intent_id}:{$this->charge_id}:{$this->invoice_id}:{$this->subscription_id}";
}
/**
* When CiviCRM receives a Stripe webhook call this method (via handlePaymentNotification()).
* This checks the webhook and either queues or triggers processing (depending on existing webhooks in queue)
*
* Set default to "process immediately". This will get changed to FALSE if we already
* have a pending webhook in the queue or the webhook is flagged for delayed processing.
* @param bool $processWebhook
*
* @return bool
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public function onReceiveWebhook($processWebhook = TRUE): bool {
if (!in_array($this->eventType, CRM_Stripe_Webhook::getDefaultEnabledEvents())) {
// We don't handle this event, return 200 OK so Stripe does not retry.
return TRUE;
}
$test = $this->getPaymentProcessor()->getPaymentProcessor()['is_test'] ? '(Test)' : '(Live)';
$name = $this->getPaymentProcessor()->getPaymentProcessor()['name'];
// Special case if this is the test webhook
if (substr($this->getEventID(), -15, 15) === '_00000000000000') {
echo "Test webhook from Stripe ({$this->getEventID()}) received successfully by CiviCRM: {$name} {$test}.";
exit();
}
// Check, decode, validate webhook data and extract some parameters to the class
$this->setInputParameters();
// If we have both Stripe (elements) and Stripe Checkout setup it is quite likely that
// we have two payment processors with the same private/public key and we'll receive "duplicate" webhooks.
// So if we have a Stripe Customer ID with the event check that it matches our payment processor ID as recorded
// in the civicrm_stripe_customers table.
if (!empty($this->getStripeCustomerID())) {
$stripeCustomers = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('customer_id', '=', $this->getStripeCustomerID())
->execute();
$eventForThisPaymentProcessor = FALSE;
foreach ($stripeCustomers as $stripeCustomer) {
if ($stripeCustomer['processor_id'] === $this->getPaymentProcessor()->getID()) {
// We have a customer in the database for this processor - continue processing
$eventForThisPaymentProcessor = TRUE;
break;
}
}
if (!$eventForThisPaymentProcessor) {
echo "Event ({$this->getEventID()}) is not for this payment processor - ignoring. CiviCRM: {$name} {$test}.";
exit();
}
}
// Get a "unique" identifier for this webhook that allows us to match "duplicate/similar" webhooks.
$uniqueIdentifier = $this->getWebhookUniqueIdentifier();
// Get all received webhooks with matching identifier which have not been processed
// This returns all webhooks that match the uniqueIdentifier above and have not been processed.
// For example this would match both invoice.finalized and invoice.payment_succeeded events which must be
// processed sequentially and not simultaneously.
$paymentProcessorWebhooks = PaymentprocessorWebhook::get(FALSE)
->addWhere('payment_processor_id', '=', $this->getPaymentProcessor()->getID())
->addWhere('identifier', '=', $uniqueIdentifier)
->addWhere('processed_date', 'IS NULL')
->execute();
if (empty($paymentProcessorWebhooks->rowCount)) {
// We have not received this webhook before.
// Some webhooks we always add to the queue and do not process immediately (eg. invoice.finalized)
if (in_array($this->eventType, CRM_Stripe_Webhook::getDelayProcessingEvents())) {
// Never process the webhook immediately.
$processWebhook = FALSE;
}
}
else {
// We already have one or more webhooks with matching identifier
foreach ($paymentProcessorWebhooks as $paymentProcessorWebhook) {
// Does the eventType match our webhook?
if ($paymentProcessorWebhook['trigger'] === $this->eventType) {
// We have already recorded a webhook with a matching event type and it is awaiting processing.
// Exit
return TRUE;
}
if (!in_array($paymentProcessorWebhook['trigger'], CRM_Stripe_Webhook::getDelayProcessingEvents())) {
// There is a webhook that is already in the queue not flagged for delayed processing.
// So we cannot process the current webhook immediately and must add it to the queue instead.
$processWebhook = FALSE;
}
}
// We have recorded another webhook with matching identifier but different eventType.
// There is already a recorded webhook with matching identifier that has not yet been processed.
// So we will record this webhook but will not process now (it will be processed later by the scheduled job).
}
$newWebhookEvent = PaymentprocessorWebhook::create(FALSE)
->addValue('payment_processor_id', $this->getPaymentProcessor()->getID())
->addValue('trigger', $this->getEventType())
->addValue('identifier', $uniqueIdentifier)
->addValue('event_id', $this->getEventID())
->addValue('data', $this->getData())
->execute()
->first();
// Check the number of webhooks to be processed does not exceed connection-limit
$toBeProcessedWebhook = PaymentprocessorWebhook::get(FALSE)
->addWhere('payment_processor_id', '=', $this->getPaymentProcessor()->getID())
->addWhere('processed_date', 'IS NULL')
->execute();
// Limit on webhooks that will be processed immediately. Otherwise we delay execution.
$webhookProcessingLimit = (int)\Civi::settings()->get('stripe_webhook_processing_limit');
if (!$processWebhook || ($toBeProcessedWebhook->rowCount > $webhookProcessingLimit)) {
return TRUE;
}
return $this->processQueuedWebhookEvent($newWebhookEvent);
}
/**
* Process a single queued event and update it.
*
* @param array $webhookEvent
*
* @return bool TRUE on success.
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function processQueuedWebhookEvent(array $webhookEvent) :bool {
$this->setEventID($webhookEvent['event_id']);
if (!$this->setEventType($webhookEvent['trigger'])) {
// We don't handle this event
return FALSE;
}
$this->setExceptionMode(FALSE);
$processingResult = $this->processWebhookEvent();
// Update the stored webhook event.
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', '=', $webhookEvent['id'])
->addValue('status', $processingResult->ok ? 'success' : 'error')
->addValue('message', preg_replace('/^(.{250}).*/su', '$1 ...', $processingResult->message))
->addValue('processed_date', 'now')
->execute();
return $processingResult->ok;
}
/**
* Process the given webhook
*
* @return stdClass
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function processWebhookEvent() :stdClass {
$return = (object) ['message' => '', 'ok' => FALSE, 'exception' => NULL];
try {
$this->setInputParameters();
$webhookEventProcessor = new \Civi\Stripe\Webhook\Events($this->getPaymentProcessor()->getID());
$webhookEventProcessor->setEventType($this->getEventType());
$webhookEventProcessor->setEventID($this->getEventID());
$webhookEventProcessor->setData($this->getData());
switch ($this->eventType) {
case 'checkout.session.completed':
$return = $webhookEventProcessor->doCheckoutSessionCompleted();
break;
case 'charge.succeeded':
case 'charge.captured':
$return = $webhookEventProcessor->doChargeSucceeded();
break;
case 'charge.refunded':
$return = $webhookEventProcessor->doChargeRefunded();
break;
case 'charge.failed':
$return = $webhookEventProcessor->doChargeFailed();
break;
case 'invoice.payment_failed':
$return = $webhookEventProcessor->doInvoicePaymentFailed();
break;
case 'invoice.finalized':
$return = $webhookEventProcessor->doInvoiceFinalized();
break;
case 'invoice.paid':
case 'invoice.payment_succeeded':
$return = $webhookEventProcessor->doInvoicePaid();
break;
case 'customer.subscription.updated':
$return = $webhookEventProcessor->doCustomerSubscriptionUpdated();
break;
case 'customer.subscription.deleted':
$return = $webhookEventProcessor->doCustomerSubscriptionDeleted();
break;
default:
$return->message = $this->eventType . ' - not implemented';
$return->ok = TRUE;
}
}
catch (Exception $e) {
if ($this->exceptionOnFailure) {
// Re-throw a modified exception. (Special case for phpunit testing).
$return->message = get_class($e) . ": " . $e->getMessage();
throw new PaymentProcessorException($return->message, $e->getCode());
}
else {
// Normal use.
$return->ok = FALSE;
if (($e instanceof \Stripe\Exception\InvalidRequestException) && ($e->getHttpStatus() === 404)) {
/** @var \Stripe\Exception\InvalidRequestException $e */
// Probably "is no longer available because it's aged out of our retention policy"
// We don't need a backtrace
$return->message = $e->getMessage();
}
else {
$return->message = $e->getMessage() . "\n" . $e->getTraceAsString();
}
$return->exception = $e;
\Civi::log('stripe')->error("StripeIPN: processWebhookEvent failed. EventID: {$this->eventID} : " . $return->message);
}
}
$this->setEventID('');
return $return;
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
class CRM_Stripe_Api {
/**
* @param string $name
* @param \Stripe\StripeObject $stripeObject
*
* @return bool|float|int|string|null
* @throws \Stripe\Exception\ApiErrorException
*/
public static function getObjectParam($name, $stripeObject) {
// object is a string containing the Stripe object name
switch ($stripeObject->object) {
case 'charge':
/** @var \Stripe\Charge $stripeObject */
switch ($name) {
case 'charge_id':
return (string) $stripeObject->id;
case 'failure_code':
return (string) $stripeObject->failure_code;
case 'failure_message':
return (string) $stripeObject->failure_message;
case 'amount':
return (float) $stripeObject->amount / 100;
case 'refunded':
return (bool) $stripeObject->refunded;
case 'amount_refunded':
return (float) $stripeObject->amount_refunded / 100;
case 'customer_id':
return (string) $stripeObject->customer;
case 'balance_transaction':
return (string) $stripeObject->balance_transaction;
case 'receive_date':
case 'created_date':
return self::formatDate($stripeObject->created);
case 'invoice_id':
if (!isset($stripeObject->invoice)) {
return '';
}
// Handle both "expanded" and "collapsed" response
elseif (is_object($stripeObject->invoice)) {
return (string) $stripeObject->invoice->id;
}
else {
return (string) $stripeObject->invoice;
}
case 'captured':
return (bool) $stripeObject->captured;
case 'currency':
return self::formatCurrency($stripeObject->currency);
case 'payment_intent_id':
return (string) $stripeObject->payment_intent;
case 'description':
return (string) $stripeObject->description;
case 'status':
// This might be "succeeded", "pending", "failed" (https://stripe.com/docs/api/charges/object#charge_object-status)
return (string) $stripeObject->status;
}
break;
case 'invoice':
/** @var \Stripe\Invoice $stripeObject */
switch ($name) {
case 'charge_id':
return (string) $stripeObject->charge;
case 'invoice_id':
return (string) $stripeObject->id;
case 'receive_date':
/*
* The "created" date of the invoice does not equal the paid date but it *might* be the same.
* We should use the paid_at below or lookup via the charge or paymentintent.
* "status_transitions": {
* "finalized_at": 1676295806,
* "marked_uncollectible_at": null,
* "paid_at": 1677591861,
* "voided_at": null
* },
*/
if (!empty($stripeObject->status_transitions->paid_at)) {
return self::formatDate($stripeObject->status_transitions->paid_at);
}
// Intentionally falls through to invoice_date
case 'invoice_date':
if (!empty($stripeObject->status_transitions->finalized_at)) {
return self::formatDate($stripeObject->status_transitions->finalized_at);
}
// Intentionally falls through to created_date
case 'created_date':
return self::formatDate($stripeObject->created);
case 'subscription_id':
return (string) $stripeObject->subscription;
case 'amount':
return (float) $stripeObject->amount_due / 100;
case 'amount_paid':
return (float) $stripeObject->amount_paid / 100;
case 'amount_remaining':
return (float) $stripeObject->amount_remaining / 100;
case 'currency':
return self::formatCurrency($stripeObject->currency);
case 'description':
return (string) $stripeObject->description;
case 'customer_id':
return (string) $stripeObject->customer;
case 'failure_message':
// This is a coding error, but it looks like the general policy here is to return something. Could otherwise consider throwing an exception.
Civi::log()->error("Coding error: CRM_Stripe_Api::getObjectParam failure_message is not a property on a Stripe Invoice object. Please alter your code to fetch the Charge and obtain the failure_message from that.");
return '';
case 'status':
return self::mapInvoiceStatusToContributionStatus($stripeObject);
}
break;
case 'subscription':
/** @var \Stripe\Subscription $stripeObject */
switch ($name) {
case 'frequency_interval':
case 'frequency_unit':
case 'amount':
$plan = [
'amount' => 0,
'interval' => '',
'interval_count' => 0,
];
foreach ($stripeObject->items as $item) {
if ($item->price->active && ($item->quantity > 0)) {
$plan['amount'] += $item->price->unit_amount * $item->quantity;
$plan['interval'] = $item->plan->interval;
$plan['interval_count'] = $item->plan->interval_count;
}
}
switch($name) {
case 'frequency_interval':
return (int) $plan['interval_count'];
case 'frequency_unit':
return (string) $plan['interval'];
case 'amount':
return (float) $plan['amount'] / 100;
}
break;
case 'currency':
return self::formatCurrency($stripeObject->currency);
case 'plan_start':
return self::formatDate($stripeObject->start_date);
case 'cancel_date':
return self::formatDate($stripeObject->canceled_at);
case 'next_sched_contribution_date':
return self::formatDate($stripeObject->current_period_end);
case 'current_period_start':
return self::formatDate($stripeObject->current_period_start);
case 'cycle_day':
return date("d", $stripeObject->billing_cycle_anchor);
case 'subscription_id':
return (string) $stripeObject->id;
case 'status_id':
switch ($stripeObject->status) {
case \Stripe\Subscription::STATUS_INCOMPLETE:
return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
case \Stripe\Subscription::STATUS_ACTIVE:
case \Stripe\Subscription::STATUS_TRIALING:
return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'In Progress');
case \Stripe\Subscription::STATUS_PAST_DUE:
return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Overdue');
case \Stripe\Subscription::STATUS_CANCELED:
case \Stripe\Subscription::STATUS_UNPAID:
case \Stripe\Subscription::STATUS_INCOMPLETE_EXPIRED:
default:
return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
}
case 'status':
return self::mapSubscriptionStatusToRecurStatus($stripeObject->status);
case 'customer_id':
return (string) $stripeObject->customer;
}
break;
case 'checkout.session':
/** @var \Stripe\Checkout\Session $stripeObject */
switch ($name) {
case 'checkout_session_id':
return (string) $stripeObject->id;
case 'client_reference_id':
return (string) $stripeObject->client_reference_id;
case 'customer_id':
return (string) $stripeObject->customer;
case 'invoice_id':
return (string) $stripeObject->invoice;
case 'payment_intent_id':
return (string) $stripeObject->payment_intent;
case 'subscription_id':
return (string) $stripeObject->subscription;
}
break;
case 'subscription_item':
/** @var \Stripe\SubscriptionItem $stripeObject */
switch ($name) {
default:
if (isset($stripeObject->$name)) {
return $stripeObject->$name;
}
\Civi::log('stripe')->error('getObjectParam: Tried to get param "' . $name . '" from "' . $stripeObject->object . '" but it is not set');
return NULL;
// unit_amount
}
break;
case 'price':
/** @var \Stripe\Price $stripeObject */
switch ($name) {
case 'unit_amount':
return (float) $stripeObject->unit_amount / 100;
case 'recurring_interval':
// eg. "year"
return (string) $stripeObject->recurring->interval ?? '';
case 'recurring_interval_count':
// eg 1
return (int) $stripeObject->recurring->interval_count ?? 0;
default:
if (isset($stripeObject->$name)) {
return $stripeObject->$name;
}
\Civi::log('stripe')->error('getObjectParam: Tried to get param "' . $name . '" from "' . $stripeObject->object . '" but it is not set');
return NULL;
// unit_amount
}
break;
}
return NULL;
}
/**
* Return a formatted date from a stripe timestamp or NULL if not set
* @param int $stripeTimestamp
*
* @return string|null
*/
public static function formatDate($stripeTimestamp) {
return $stripeTimestamp ? date('YmdHis', $stripeTimestamp) : NULL;
}
/**
* @param string $stripeCurrency
*
* @return string
*/
public static function formatCurrency(string $stripeCurrency): string {
return (string) mb_strtoupper($stripeCurrency);
}
/**
* Convert amount to a new currency
*
* @param float $amount
* @param float $exchangeRate
* @param string $currency
*
* @return float
*/
public static function currencyConversion($amount, $exchangeRate, $currency) {
$amount = ($amount / $exchangeRate) / 100;
// 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.
$amount = round($amount, CRM_Utils_Money::getCurrencyPrecision($currency));
return $amount;
}
/**
* We have to map CiviCRM locales to a specific set of Stripe locales for elements to set the user language correctly.
* Reference: https://stripe.com/docs/js/appendix/supported_locales
* @param string $civiCRMLocale (eg. en_GB).
*
* @return string
*/
public static function mapCiviCRMLocaleToStripeLocale($civiCRMLocale = '') {
if (empty($civiCRMLocale)) {
$civiCRMLocale = CRM_Core_I18n::getLocale();
}
$localeMap = [
'en_AU' => 'en',
'en_CA' => 'en',
'en_GB' => 'en-GB',
'en_US' => 'en',
'es_ES' => 'es',
'es_MX' => 'es-419',
'es_PR' => 'es-419',
'fr_FR' => 'fr',
'fr_CA' => 'fr-CA',
'pt_BR' => 'pt-BR',
'pt_PT' => 'pt',
'zh_CN' => 'zh',
'zh_HK' => 'zh-HK',
'zh_TW' => 'zh-TW'
];
if (array_key_exists($civiCRMLocale, $localeMap)) {
return $localeMap[$civiCRMLocale];
}
// Most stripe locale codes are two characters which match the first two chars
// of the CiviCRM locale. If it doesn't match the Stripe element will fallback
// to "auto"
return substr($civiCRMLocale,0, 2);
}
public static function getListOfSupportedPaymentMethodsCheckout() {
return [
'card' => E::ts('Card'),
// 'acss_debit',
// 'affirm',
// 'afterpay_clearpay',
// 'alipay',
'au_becs_debit' => E::ts('BECS Direct Debit payments in Australia'),
'bacs_debit' => E::ts('BACS Direct Debit'),
'bancontact' => E::ts('Bancontact'),
// 'blik',
// 'boleto',
// 'cashapp',
// 'customer_balance',
// 'eps',
// 'fpx',
// 'giropay',
// 'grabpay',
// 'ideal',
// 'klarna',
// 'konbini',
// 'oxxo',
// 'p24',
// 'paynow',
// 'pix',
// 'promptpay',
'sepa_debit' => E::ts('SEPA Direct Debit'),
// 'sofort',
'us_bank_account' => E::ts('ACH Direct Debit'),
// 'wechat_pay',
];
}
/**
* Map the Stripe Subscription Status to the CiviCRM ContributionRecur status.
*
* @param string $subscriptionStatus
*
* @return string
*/
public static function mapSubscriptionStatusToRecurStatus(string $subscriptionStatus): string {
$statusMap = [
'incomplete' => 'Failed',
'incomplete_expired' => 'Failed',
'trialing' => 'In Progress',
'active' => 'In Progress',
'past_due' => 'Overdue',
'canceled' => 'Cancelled',
'unpaid' => 'Failed',
'paused' => 'Pending',
];
return $statusMap[$subscriptionStatus] ?? '';
}
/**
* Map the Stripe Invoice Status to the CiviCRM Contribution status.
* https://stripe.com/docs/invoicing/overview#invoice-statuses
*
* @param \Stripe\Invoice $invoice
*
* @return string
*/
public static function mapInvoiceStatusToContributionStatus(\Stripe\Invoice $invoice): string {
$statusMap = [
'draft' => 'Pending',
'open' => 'Pending',
'paid' => 'Completed',
'void' => 'Cancelled',
'uncollectible' => 'Failed',
];
if ($invoice->status === 'open' && $invoice->attempted && empty($invoice->next_payment_attempt)) {
// An invoice will automatically be retried. If that fails the status will remain "open" but it has effectively failed.
// We use attempted + next_payment_attempt to check if it will NOT be retried and then record it as Failed in CiviCRM.
return 'Failed';
}
return $statusMap[$invoice->status] ?? '';
}
}
<?php
use Civi\Api4\Contact;
use Civi\Api4\Email;
use Civi\Api4\Extension;
use Civi\Api4\StripeCustomer;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Stripe_ExtensionUtil as E;
class CRM_Stripe_BAO_StripeCustomer extends CRM_Stripe_DAO_StripeCustomer {
/**
* @param int $contactID
* @param array $invoiceSettings
* @param string|null $description
*
* @return array
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public static function getStripeCustomerMetadata(int $contactID, array $invoiceSettings = [], ?string $description = NULL) {
$contact = Contact::get(FALSE)
->addSelect('display_name', 'email_primary.email', 'email_billing.email')
->addWhere('id', '=', $contactID)
->execute()
->first();
if (version_compare(\CRM_Utils_System::version(), '5.53.0', '<')) {
// @todo: Remove when we drop support for CiviCRM < 5.53
// APIv4 - Read & write contact primary and billing locations as implicit joins
// https://github.com/civicrm/civicrm-core/pull/23972 was added in 5.53
$emailResult = Email::get(FALSE)
->addWhere('contact_id', '=', $contactID)
->addOrderBy('is_primary', 'DESC')
->addOrderBy('is_billing', 'DESC')
->execute()
->first();
if (!empty($emailResult['email'])) {
$contact['email_primary.email'] = $emailResult['email'];
}
}
$extVersion = Extension::get(FALSE)
->addWhere('file', '=', E::SHORT_NAME)
->execute()
->first()['version'];
$stripeCustomerParams = [
'name' => $contact['display_name'],
// Stripe does not include the Customer Name when exporting payments, just the customer
// description, so we stick the name in the description.
'description' => $description ?? $contact['display_name'] . ' (CiviCRM)',
'metadata' => [
'CiviCRM Contact ID' => $contactID,
'CiviCRM URL' => CRM_Utils_System::url('civicrm/contact/view', "reset=1&cid={$contactID}", TRUE, NULL, FALSE, FALSE, TRUE),
'CiviCRM Version' => CRM_Utils_System::version() . ' ' . $extVersion,
],
];
$email = $contact['email_primary.email'] ?? $contact['email_billing.email'] ?? NULL;
if ($email) {
$stripeCustomerParams['email'] = $email;
}
// This is used for new subscriptions/invoices as the default payment method
if (!empty($invoiceSettings)) {
$stripeCustomerParams['invoice_settings'] = $invoiceSettings;
}
return $stripeCustomerParams;
}
/**
* @param array $params
* @param \CRM_Core_Payment_Stripe $stripe
* @param string $stripeCustomerID
*
* @return string
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function updateMetadata(array $params, \CRM_Core_Payment_Stripe $stripe, string $stripeCustomerID): string {
$requiredParams = ['contact_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (updateMetadata): Missing required parameter: ' . $required);
}
}
$stripeCustomerParams = CRM_Stripe_BAO_StripeCustomer::getStripeCustomerMetadata($params['contact_id'], $params['invoice_settings'] ?? [], $params['description'] ?? NULL);
try {
$stripeCustomer = $stripe->stripeClient->customers->update($stripeCustomerID, $stripeCustomerParams);
}
catch (Exception $e) {
$err = $stripe->parseStripeException('create_customer', $e);
if ($e instanceof \Stripe\Exception\PermissionException) {
\Civi::log('stripe')->warning($stripe->getLogPrefix() . 'Could not update Stripe Customer metadata for StripeCustomerID: ' . $stripeCustomerID . '; contactID: ' . $params['contact_id']);
}
else {
\Civi::log('stripe')->error($stripe->getLogPrefix() . 'Failed to create Stripe Customer: ' . $err['message'] . '; ' . print_r($err, TRUE));
throw new PaymentProcessorException('Failed to update Stripe Customer: ' . $err['code']);
}
}
return $stripeCustomer ?? '';
}
/**
* Update the metadata at Stripe for a given contactID
*
* @param int $contactID
*
* @return void
*/
public static function updateMetadataForContact(int $contactID): void {
$customers = StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $contactID)
->execute();
// Could be multiple customer_id's and/or stripe processors
foreach ($customers as $customer) {
/** @var CRM_Core_Payment_Stripe $stripe */
StripeCustomer::updateStripe(FALSE)
->setPaymentProcessorID($customer['processor_id'])
->setContactID($contactID)
->setCustomerID($customer['customer_id'])
->execute()
->first();
}
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
class CRM_Stripe_BAO_StripePaymentintent extends CRM_Stripe_DAO_StripePaymentintent {
public static function getEntityName() {
return 'StripePaymentintent';
}
/**
* Create a new StripePaymentintent based on array-data
*
* @param array $params key-value pairs
*
* @return \CRM_Stripe_BAO_StripePaymentintent
*/
public static function create($params) {
$instance = new self;
try {
if (!empty($params['id'])) {
$instance->id = $params['id'];
}
elseif ($params['stripe_intent_id']) {
$instance->id = civicrm_api3('StripePaymentintent', 'getvalue', [
'return' => "id",
'stripe_intent_id' => $params['stripe_intent_id'],
]);
}
if ($instance->id) {
if ($instance->find()) {
$instance->fetch();
}
}
}
catch (Exception $e) {
// do nothing, we're creating a new one
}
$flags = empty($instance->flags) ? [] : unserialize($instance->flags);
if (!empty($params['flags']) && is_array($params['flags'])) {
foreach ($params['flags'] as $flag) {
if (!in_array($flag, $flags)) {
$flags[] = 'NC';
}
}
unset($params['flags']);
}
$instance->flags = serialize($flags);
if (!empty($_SERVER['HTTP_REFERER']) && empty($instance->referrer)) {
$instance->referrer = $_SERVER['HTTP_REFERER'];
}
$hook = empty($instance->id) ? 'create' : 'edit';
CRM_Utils_Hook::pre($hook, self::getEntityName(), $params['id'] ?? NULL, $params);
$instance->copyValues($params);
$instance->save();
CRM_Utils_Hook::post($hook, self::getEntityName(), $instance->id, $instance);
return $instance;
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
/**
* Class CRM_Stripe_Check
*/
class CRM_Stripe_Check {
/**
* @var string
*/
const API_VERSION = \Stripe\Util\ApiVersion::CURRENT;
const API_MIN_VERSION = \Stripe\Util\ApiVersion::CURRENT;
/**
* @var string
*/
const MIN_VERSION_MJWSHARED = '1.3';
const MIN_VERSION_FIREWALL = '1.5.9';
/**
* @var array
*/
private array $messages;
/**
* constructor.
*
* @param $messages
*/
public function __construct($messages) {
$this->messages = $messages;
}
/**
* @return array
* @throws \CRM_Core_Exception
*/
public function checkRequirements() {
$this->checkExtensionMjwshared();
$this->checkExtensionFirewall();
$this->checkWebhooks();
$this->checkFailedPaymentIntents();
return $this->messages;
}
/**
* @param string $extensionName
* @param string $minVersion
* @param string $actualVersion
*/
private function requireExtensionMinVersion(string $extensionName, string $minVersion, string $actualVersion) {
$actualVersionModified = $actualVersion;
if (substr($actualVersion, -4) === '-dev') {
$actualVersionModified = substr($actualVersion, 0, -4);
$devMessageAlreadyDefined = FALSE;
foreach ($this->messages as $message) {
if ($message->getName() === __FUNCTION__ . $extensionName . '_requirements_dev') {
// Another extension already generated the "Development version" message for this extension
$devMessageAlreadyDefined = TRUE;
}
}
if (!$devMessageAlreadyDefined) {
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . '_requirements_dev',
E::ts('You are using a development version of %1 extension.',
[1 => $extensionName]),
E::ts('%1: Development version', [1 => $extensionName]),
\Psr\Log\LogLevel::WARNING,
'fa-code'
);
$this->messages[] = $message;
}
}
if (version_compare($actualVersionModified, $minVersion) === -1) {
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . E::SHORT_NAME . '_requirements',
E::ts('The %1 extension requires the %2 extension version %3 or greater but your system has version %4.',
[
1 => ucfirst(E::SHORT_NAME),
2 => $extensionName,
3 => $minVersion,
4 => $actualVersion
]),
E::ts('%1: Missing Requirements', [1 => ucfirst(E::SHORT_NAME)]),
\Psr\Log\LogLevel::ERROR,
'fa-exclamation-triangle'
);
$message->addAction(
E::ts('Upgrade now'),
NULL,
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$this->messages[] = $message;
}
}
/**
* @throws \CRM_Core_Exception
*/
private function checkExtensionMjwshared() {
// mjwshared: required. Requires min version
$extensionName = 'mjwshared';
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => $extensionName,
]);
if (empty($extensions['count']) || ($extensions['values'][$extensions['id']]['status'] !== 'installed')) {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . E::SHORT_NAME . '_requirements',
E::ts('The <em>%1</em> extension requires the <em>Payment Shared</em> extension which is not installed. See <a href="%2" target="_blank">details</a> for more information.',
[
1 => ucfirst(E::SHORT_NAME),
2 => 'https://civicrm.org/extensions/mjwshared',
]
),
E::ts('%1: Missing Requirements', [1 => ucfirst(E::SHORT_NAME)]),
\Psr\Log\LogLevel::ERROR,
'fa-money'
);
$message->addAction(
E::ts('Install now'),
NULL,
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$this->messages[] = $message;
return;
}
if (isset($extensions['id']) && $extensions['values'][$extensions['id']]['status'] === 'installed') {
$this->requireExtensionMinVersion($extensionName, self::MIN_VERSION_MJWSHARED, $extensions['values'][$extensions['id']]['version']);
}
}
/**
* @throws \CRM_Core_Exception
*/
private function checkExtensionFirewall() {
$extensionName = 'firewall';
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => $extensionName,
]);
if (empty($extensions['count']) || ($extensions['values'][$extensions['id']]['status'] !== 'installed')) {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'stripe_recommended',
E::ts('If you are using Stripe to accept payments on public forms (eg. contribution/event registration forms) it is required that you install the <strong><a href="https://lab.civicrm.org/extensions/firewall">firewall</a></strong> extension.
Some sites have become targets for spammers who use the payment endpoint to try and test credit cards by submitting invalid payments to your Stripe account.'),
E::ts('Required Extension: firewall'),
\Psr\Log\LogLevel::ERROR,
'fa-lightbulb-o'
);
$message->addAction(
E::ts('Install now'),
NULL,
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$this->messages[] = $message;
}
if (isset($extensions['id']) && $extensions['values'][$extensions['id']]['status'] === 'installed') {
$this->requireExtensionMinVersion($extensionName, CRM_Stripe_Check::MIN_VERSION_FIREWALL, $extensions['values'][$extensions['id']]['version']);
}
}
private function checkWebhooks() {
// If we didn't install mjwshared yet check requirements but don't crash when checking webhooks
if (trait_exists('CRM_Mjwshared_WebhookTrait')) {
$webhooks = new CRM_Stripe_Webhook();
$webhooks->check($this->messages);
}
}
/**
* Try to detect if a client is being spammed / credit card fraud.
*/
private function checkFailedPaymentIntents() {
// Check for a high volume of failed/pending contributions
$count = CRM_Core_DAO::singleValueQuery('SELECT count(*)
FROM civicrm_stripe_paymentintent
WHERE status = "failed"
AND TIMESTAMPDIFF(minute, created_date, NOW()) < 60
ORDER BY id DESC
LIMIT 1000');
if ($count > 20) {
$message = new CRM_Utils_Check_Message(
'stripe_paymentintentspam',
E::ts('%1 failed Stripe Payment Intents in the past hour. Please check the logs. They are problably hitting the CiviCRM REST API.', [1 => $count]),
E::ts('Stripe - High rate of failed contributions'),
\Psr\Log\LogLevel::CRITICAL,
'fa-check'
);
$this->messages[] = $message;
}
else {
$message = new CRM_Utils_Check_Message(
'stripe_paymentintentspam',
E::ts('%1 failed Stripe Payment Intents in the past hour.', [1 => $count]) . ' ' . E::ts('We monitor this in case someone malicious is testing stolen credit cards on public contribution forms.'),
E::ts('Stripe - Failed Stripe Payment Intents'),
\Psr\Log\LogLevel::INFO,
'fa-check'
);
$this->messages[] = $message;
}
}
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use Civi\Api4\Contact;
use Civi\Api4\StripeCustomer;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Stripe_ExtensionUtil as E;
/**
* Class CRM_Stripe_Customer
*/
class CRM_Stripe_Customer {
/**
* Find an existing Stripe customer in the CiviCRM database
*
* @param $params
*
* @return null|string
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function find($params) {
$requiredParams = ['processor_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (find): Missing required parameter: ' . $required);
}
}
if (empty($params['contact_id'])) {
throw new PaymentProcessorException('Stripe Customer (find): contact_id is required');
}
$result = StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $params['contact_id'])
->addWhere('processor_id', '=', $params['processor_id'])
->addClause('OR', ['currency', 'IS EMPTY'], ['currency', '=', $params['currency']])
->addSelect('customer_id')
->execute();
return $result->count() ? $result->first()['customer_id'] : NULL;
}
/**
* Find the details (contact_id, processor_id) for an existing Stripe customer in the CiviCRM database
*
* @param string $stripeCustomerId
*
* @return array|null
*/
public static function getParamsForCustomerId($stripeCustomerId) {
$result = StripeCustomer::get(FALSE)
->addWhere('customer_id', '=', $stripeCustomerId)
->addSelect('contact_id', 'processor_id')
->execute()
->first();
// Not sure whether this return for no match is needed, but that's what was being returned previously
return $result ? $result : ['contact_id' => NULL, 'processor_id' => NULL];
}
/**
* Find all the Stripe customers in the CiviCRM database for a given processorId
*
* @param string $processorId
*
* @return array|null
*/
public static function getAll($processorId, $options = []) {
return civicrm_api4('StripeCustomer', 'get', [
'select' => ['customer_id'],
'where' => [['processor_id', '=', $processorId]],
'checkPermissions' => FALSE,
] + $options, ['customer_id']);
}
/**
* @param array $params
* @param \CRM_Core_Payment_Stripe $stripe
*
* @return \Stripe\Customer|\PropertySpy
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function create(array $params, \CRM_Core_Payment_Stripe $stripe) {
$requiredParams = ['contact_id', 'processor_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (create): Missing required parameter: ' . $required);
}
}
$stripeCustomerParams = CRM_Stripe_BAO_StripeCustomer::getStripeCustomerMetadata($params['contact_id'], $params['invoice_settings'] ?? []);
try {
$stripeCustomerObject = $stripe->stripeClient->customers->create($stripeCustomerParams);
}
catch (Exception $e) {
$err = $stripe->parseStripeException('create_customer', $e);
\Civi::log('stripe')->error('Failed to create Stripe Customer: ' . $err['message'] . '; ' . print_r($err, TRUE));
throw new PaymentProcessorException('Failed to create Stripe Customer: ' . $err['code']);
}
// Store the relationship between CiviCRM's email address for the Contact & Stripe's Customer ID.
StripeCustomer::create(FALSE)
->addValue('contact_id', $params['contact_id'])
->addValue('customer_id', $stripeCustomerObject->id)
->addValue('processor_id', $params['processor_id'])
->addValue('currency', $params['currency'])
->execute();
return $stripeCustomerObject;
}
/**
* Delete a Stripe customer from the CiviCRM database
*
* @param array $params
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function delete(array $params) {
$requiredParams = ['processor_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (delete): Missing required parameter: ' . $required);
}
}
if (empty($params['contact_id']) && empty($params['customer_id'])) {
throw new PaymentProcessorException('Stripe Customer (delete): Missing required parameter: contact_id or customer_id');
}
$delete = StripeCustomer::delete(FALSE)
->addWhere('processor_id', '=', $params['processor_id']);
if (!empty($params['customer_id'])) {
$delete = $delete->addWhere('customer_id', '=', $params['customer_id']);
}
else {
$delete = $delete->addWhere('contact_id', '=', $params['contact_id']);
}
$delete->execute();
}
}
<?php
/**
* DAOs provide an OOP-style facade for reading and writing database records.
*
* DAOs are a primary source for metadata in older versions of CiviCRM (<5.74)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required.
* @property string $id
* @property string $customer_id
* @property string $contact_id
* @property string $processor_id
* @property string $currency
*/
class CRM_Stripe_DAO_StripeCustomer extends CRM_Stripe_DAO_Base {
/**
* Required by older versions of CiviCRM (<5.74).
* @var string
*/
public static $_tableName = 'civicrm_stripe_customers';
}
<?php
/**
* DAOs provide an OOP-style facade for reading and writing database records.
*
* DAOs are a primary source for metadata in older versions of CiviCRM (<5.74)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required.
* @property string $id
* @property string $stripe_intent_id
* @property string $contribution_id
* @property string $payment_processor_id
* @property string $description
* @property string $status
* @property string $identifier
* @property string $contact_id
* @property string $created_date
* @property string $flags
* @property string $referrer
* @property string $extra_data
*/
class CRM_Stripe_DAO_StripePaymentintent extends CRM_Stripe_DAO_Base {
/**
* Required by older versions of CiviCRM (<5.74).
* @var string
*/
public static $_tableName = 'civicrm_stripe_paymentintent';
}
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
/**
* Form controller class
*
* @see https://wiki.civicrm.org/confluence/display/CRMDOC/QuickForm+Reference
*/
class CRM_Stripe_Form_UpdateWebhook extends CRM_Core_Form {
public function buildQuickForm() {
// Defaults.
$this->assign('shouldOfferToFix', 0);
$this->assign('isStillBad', 0);
$this->assign('isAllOk', 0);
// Run check.
$messages = [];
$webhooks = new CRM_Stripe_Webhook();
$webhooks->check($messages);
if (!$messages) {
$this->assign('isAllOk', 1);
}
else {
$this->assign('shouldOfferToFix', 1);
$this->assignMessages($messages);
$this->addButtons(array(
array(
'type' => 'submit',
'name' => E::ts('Update / Create webhook'),
'isDefault' => TRUE,
),
));
}
// export form elements
$this->assign('elementNames', $this->getRenderableElementNames());
parent::buildQuickForm();
}
public function postProcess() {
$messages = [];
$attemptFix = TRUE;
$webhooks = new CRM_Stripe_Webhook();
$webhooks->check($messages, $attemptFix);
if ($messages) {
$this->assign('isStillBad', 1);
$this->assign('shouldOfferToFix', 0);
$this->assignMessages($messages);
}
else {
$this->assign('isAllOk', 1);
$this->assign('shouldOfferToFix', 0);
$this->assign('isStillBad', 0);
$this->assign('intro', E::ts('All webhooks update successfully.'));
}
parent::postProcess();
}
/**
* @param array $messages
*/
private function assignMessages($messages) {
$messagesArray = [];
foreach ($messages as $message) {
$messagesArray[] = [
'title' => $message->getTitle(),
'message' => $message->getMessage(),
];
}
$this->assign('messages', $messagesArray);
}
/**
* Get the fields/elements defined in this form.
*
* @return array (string)
*/
public function getRenderableElementNames() {
// The _elements list includes some items which should not be
// auto-rendered in the loop -- such as "qfKey" and "buttons". These
// items don't have labels. We'll identify renderable by filtering on
// the 'label'.
$elementNames = array();
foreach ($this->_elements as $element) {
/** @var HTML_QuickForm_Element $element */
$label = $element->getLabel();
if (!empty($label)) {
$elementNames[] = $element->getName();
}
}
return $elementNames;
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
/*
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
namespace Civi\Api4\Action\StripeCharge;
use Stripe\Event;
/**
* @inheritDoc
*/
class GetBalanceTransactionDetails extends \Civi\Api4\Generic\AbstractAction {
/**
* Stripe Charge ID
*
* @var string
*/
protected $chargeID = '';
/**
* The CiviCRM Payment Processor ID
*
* @var int
*/
protected $paymentProcessorID;
/**
* @param \Civi\Api4\Generic\Result $result
*
* @return void
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\ApiErrorException
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (empty($this->chargeID)) {
throw new \CRM_Core_Exception('Missing chargeID');
}
if (empty($this->paymentProcessorID)) {
throw new \CRM_Core_Exception('Missing paymentProcessorID');
}
$stripeApi = new \Civi\Stripe\Api(\Civi\Payment\System::singleton()->getById($this->paymentProcessorID));
$charge = $stripeApi->getPaymentProcessor()->stripeClient->charges->retrieve($this->chargeID);
$stripeEvent = new \Stripe\Event();
$stripeEvent->object = $charge;
$stripeApi->setData($stripeEvent);
$balanceTransactionDetails = $stripeApi->getDetailsFromBalanceTransactionByChargeObject($stripeEvent->object);
$result->exchangeArray($balanceTransactionDetails);
}
}
This diff is collapsed.
This diff is collapsed.