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 (1034)
Showing
with 4924 additions and 1702 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
* @var \Stripe\StripeClient
*/
private static $_singleton = NULL;
public $stripeClient;
/**
* Mode of operation: live or test.
*
* @var object
* @var \Civi\Stripe\Api;
*/
protected $_mode = NULL;
public \Civi\Stripe\Api $api;
/**
* TRUE if we are dealing with a live transaction
* Custom properties used by this payment processor
*
* @var boolean
* @var string[]
*/
private $_islive = FALSE;
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 null|string
* The error message if any.
* @return string
*/
public function checkConfig() {
$error = array();
public static function getSecretKey($paymentProcessor) {
return trim($paymentProcessor['password'] ?? '');
}
/**
* @param array $paymentProcessor
*
* @return string
*/
public static function getPublicKey($paymentProcessor) {
return trim($paymentProcessor['user_name'] ?? '');
}
if (empty($this->_paymentProcessor['user_name'])) {
$error[] = ts('The "Secret Key" is not set in the Stripe Payment Processor settings.');
/**
* @return string
*/
public function getWebhookSecret(): string {
return trim($this->_paymentProcessor['signature'] ?? '');
}
/**
* 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);
......@@ -69,164 +155,269 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* Get the currency for the transaction.
*
* Handle any inconsistency about how it is passed in here.
*
* @param $params
* Override CRM_Core_Payment function
*
* @return string
*/
public function getAmount($params) {
// Stripe amount required in cents.
$amount = number_format($params['amount'], 2, '.', '');
$amount = (int) preg_replace('/[^\d]/', '', strval($amount));
return $amount;
public function getPaymentTypeName() {
return 'credit_card';
}
/**
* 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) {
Civi::log()->debug("Stripe_Error {$op}: " . print_r($exception->getJsonBody(), TRUE));
public function getPaymentTypeLabel() {
return E::ts('Stripe');
}
/**
* Check if return from stripeCatchErrors was an error object
* that should be passed back to original api caller.
*
* @param array $err
* The return from a call to stripeCatchErrors
*
* We can use the stripe processor on the backend
* @return bool
*/
public function isErrorReturn($err) {
if (!empty($err['is_error'])) {
return TRUE;
}
else {
return FALSE;
}
public function supportsBackOffice() {
return TRUE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return TRUE;
}
/**
* Handle an error from Stripe API and notify the user
* Get an array of the fields that can be edited on the recurring contribution.
*
* 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.
*
* @param array $err
* @param string $bounceURL
* 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 string errorMessage (or statusbounce if URL is specified)
* 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 handleErrorNotification($err, $bounceURL = NULL) {
$errorMessage = 'Payment Response: <br />' .
'Type: ' . $err['type'] . '<br />' .
'Code: ' . $err['code'] . '<br />' .
'Message: ' . $err['message'] . '<br />';
public function getEditableRecurringScheduleFields() {
if ($this->supports('changeSubscriptionAmount')) {
return ['amount'];
}
return [];
}
Civi::log()->debug('Stripe Payment Error: ' . $errorMessage);
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
if ($bounceURL) {
CRM_Core_Error::statusBounce($errorMessage, $bounceURL, 'Payment Error');
}
return $errorMessage;
/**
* 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;
}
/**
* Run Stripe calls through this to catch exceptions gracefully.
* Is an authorize-capture flow supported.
*
* @param string $op
* Determine which operation to perform.
* @param $stripe_params
* @param array $params
* Parameters to run Stripe calls on.
* @param array $ignores
* @return bool
*/
protected function supportsPreApproval() {
return TRUE;
}
/**
* Does this processor support cancelling recurring contributions through code.
*
* @return bool|\CRM_Core_Error|\Stripe\Charge|\Stripe\Customer|\Stripe\Plan
* Response from gateway.
* If the processor returns true it must be possible to take action from within CiviCRM
* that will result in no further payments being processed.
*
* @throws \CiviCRM_API3_Exception
* @return bool
*/
public function stripeCatchErrors($op = 'create_customer', $stripe_params, $params, $ignores = array()) {
$return = FALSE;
// Check for errors before trying to submit.
try {
switch ($op) {
case 'create_customer':
$return = \Stripe\Customer::create($stripe_params);
break;
case 'update_customer':
$return = \Stripe\Customer::update($stripe_params);
break;
case 'charge':
$return = \Stripe\Charge::create($stripe_params);
break;
protected function supportsCancelRecurring() {
return TRUE;
}
case 'save':
$return = $stripe_params->save();
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 'create_plan':
$return = \Stripe\Plan::create($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;
}
case 'retrieve_customer':
$return = \Stripe\Customer::retrieve($stripe_params);
break;
/**
* @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();
}
case 'retrieve_balance_transaction':
$return = \Stripe\BalanceTransaction::retrieve($stripe_params);
break;
/**
* 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);
}
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;
}
}
/**
* 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;
}
}
$this->logStripeException($op, $e);
// Since it's a decline, Stripe_CardError will be caught
$body = $e->getJsonBody();
$err = $body['error'];
if (!isset($err['code'])) {
// A "fake" error code
$err['code'] = 9000;
}
/**
* 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 (is_a($e, 'Stripe_CardError')) {
civicrm_api3('Note', 'create', array(
'entity_id' => self::getContactId($params),
'contact_id' => $params['contributionID'],
'subject' => $err['type'],
'note' => $err['code'],
'entity_table' => "civicrm_contributions",
));
// 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);
}
// Flag to detect error return
$err['is_error'] = TRUE;
return $err;
}
return $return;
return $plan;
}
/**
......@@ -234,16 +425,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
*
* @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 [];
}
/**
......@@ -254,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);
}
}
/**
......@@ -341,21 +468,32 @@ 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;
}
}
/**
......@@ -364,429 +502,810 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @param \CRM_Core_Form $form
*/
public function buildForm(&$form) {
// Set default values
$paymentProcessorId = CRM_Utils_Array::value('id', $form->_paymentProcessor);
$publishableKey = CRM_Core_Payment_Stripe::getPublishableKey($paymentProcessorId);
$defaults = [
'stripe_id' => $paymentProcessorId,
'stripe_pub_key' => $publishableKey,
// 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);
}
$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'),
];
$form->setDefaults($defaults);
if (class_exists('\Civi\Firewall\Firewall')) {
$firewall = new \Civi\Firewall\Firewall();
$jsVars['csrfToken'] = $firewall->generateCSRFToken($context);
}
// Add CSS via region (it won't load on drupal webform if added via \Civi::resources()->addStyleFile)
CRM_Core_Region::instance('billing-block')->add([
'styleUrl' => \Civi::service('asset_builder')->getUrl(
'elements.css',
[
'path' => E::path('css/elements.css'),
'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 publishable key (password field)
/**
* Function to action pre-approval if supported
*
* @param $paymentProcessorId
* @param array $params
* Parameters from the form
*
* @return string
* 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 static function getPublishableKey($paymentProcessorId) {
try {
$publishableKey = (string) civicrm_api3('PaymentProcessor', 'getvalue', array(
'return' => "password",
'id' => $paymentProcessorId,
));
}
catch (CiviCRM_API3_Exception $e) {
return '';
public function doPreApproval(&$params) {
foreach ($this->customProperties as $property) {
$preApprovalParams[$property] = CRM_Utils_Request::retrieveValue($property, 'String', NULL, FALSE, 'POST');
}
return $publishableKey;
return ['pre_approval_parameters' => $preApprovalParams ?? []];
}
/**
* Get any details that may be available to the payment processor due to an approval process having happened.
*
* In some cases the browser is redirected to enter details on a processor site. Some details may be available as a
* result.
*
* @param array $storedDetails
*
* @return array
*/
public function getPreApprovalDetails($storedDetails) {
return $storedDetails ?? [];
}
/**
* 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|\CRM_Core_Error
* The result in a nice formatted array (or an error object).
* @return array
* Result array
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doDirectPayment(&$params) {
if (array_key_exists('credit_card_number', $params)) {
$cc = $params['credit_card_number'];
if (!empty($cc) && substr($cc, 0, 8) != '00000000') {
Civi::log()->debug(ts('ALERT! Unmasked credit card received in back end. Please report this error to the site administrator.'));
}
}
public function doPayment(&$paymentParams, $component = 'contribute') {
/* @var \Civi\Payment\PropertyBag $propertyBag */
$propertyBag = PropertyBag::cast($paymentParams);
// Let a $0 transaction pass.
if (empty($params['amount']) || $params['amount'] == 0) {
return $params;
$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'] = 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'] = 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);
$amount = self::getAmount($params);
$amountFormattedForStripe = $this->getAmountFormattedForStripeAPI($propertyBag);
// Use Stripe.js instead of raw card details.
if (!empty($params['stripe_token'])) {
$card_token = $params['stripe_token'];
$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 if(!empty(CRM_Utils_Array::value('stripe_token', $_POST, NULL))) {
$card_token = CRM_Utils_Array::value('stripe_token', $_POST, NULL);
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);
}
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));
$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));
}
}
$contactId = self::getContactId($params);
$email = self::getBillingEmail($params, $contactId);
// 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);
}
// @fixme FROM HERE we are using $params ONLY - SET things if required ($propertyBag is not used beyond here)
$params = $this->getPropertyBagAsArray($propertyBag);
$params = $this->processPaymentIntent($params, $intent);
// For a single charge there is no stripe invoice, we set OrderID to the ChargeID.
if (empty($this->getPaymentProcessorOrderID())) {
$this->setPaymentProcessorOrderID($this->getPaymentProcessorTrxnID());
}
// For contribution workflow we have a contributionId so we can set parameters directly.
// For events/membership workflow we have to return the parameters and they might get set...
return $this->endDoPayment($params);
}
/**
* @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);
}
// See if we already have a stripe customer
$customerParams = [
'contact_id' => $contactId,
'card_token' => $card_token,
'is_live' => $this->_islive,
'processor_id' => $this->_paymentProcessor['id'],
'email' => $email,
'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),
];
$stripeCustomerId = CRM_Stripe_Customer::find($customerParams);
// 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 (!isset($stripeCustomerId)) {
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
if (empty($stripeCustomer)) {
$stripeCustomerObject = CRM_Stripe_Customer::create($customerParams, $this);
}
else {
$shouldDeleteStripeCustomer = $shouldCreateNewStripeCustomer = FALSE;
// Customer was found in civicrm database, fetch from Stripe.
$stripeCustomer = $this->stripeCatchErrors('retrieve_customer', $stripeCustomerId, $params);
if (!empty($stripeCustomer)) {
if ($this->isErrorReturn($stripeCustomer)) {
if (($stripeCustomer['type'] == 'invalid_request_error') && ($stripeCustomer['code'] == 'resource_missing')) {
// Customer doesn't exist, create a new one
CRM_Stripe_Customer::delete($customerParams);
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
}
if ($this->isErrorReturn($stripeCustomer)) {
// We still failed to create a customer
self::handleErrorNotification($stripeCustomer, $params['stripe_error_url']);
return $stripeCustomer;
}
}
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;
}
$stripeCustomer->card = $card_token;
$updatedStripeCustomer = $this->stripeCatchErrors('save', $stripeCustomer, $params);
if ($this->isErrorReturn($updatedStripeCustomer)) {
if (($updatedStripeCustomer['type'] == 'invalid_request_error') && ($updatedStripeCustomer['code'] == 'token_already_used')) {
// This error is ok, we've already used the token during create_customer
}
else {
self::handleErrorNotification($updatedStripeCustomer, $params['stripe_error_url']);
return $updatedStripeCustomer;
}
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 {
// We need to create a new customer
$shouldCreateNewStripeCustomer = TRUE;
}
}
else {
// Customer was found in civicrm_stripe database, but not in Stripe.
// Delete existing customer record from CiviCRM and create a new customer
if ($shouldDeleteStripeCustomer) {
// Customer was deleted, delete it.
CRM_Stripe_Customer::delete($customerParams);
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
}
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;
}
// Prepare the charge array, minus Customer/Card details.
if (empty($params['description'])) {
$params['description'] = ts('Backend Stripe contribution');
/**
* @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;
}
// Stripe charge.
$stripe_charge = array(
'amount' => $amount,
'currency' => strtolower($params['currencyID']),
'description' => $params['description'] . ' # Invoice ID: ' . CRM_Utils_Array::value('invoiceID', $params),
);
/**
* 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);
// Use Stripe Customer if we have a valid one. Otherwise just use the card.
if (!empty($stripeCustomer->id)) {
$stripe_charge['customer'] = $stripeCustomer->id;
$required = NULL;
if (empty($this->getRecurringContributionId($propertyBag))) {
$required = 'contributionRecurID';
}
else {
$stripe_charge['card'] = $card_token;
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);
}
// Handle recurring payments in doRecurPayment().
if (CRM_Utils_Array::value('is_recur', $params) && $params['contributionRecurID']) {
return $this->doRecurPayment($params, $amount, $stripeCustomer);
}
// Fire away! Check for errors before trying to submit.
$stripeCharge = $this->stripeCatchErrors('charge', $stripe_charge, $params);
if (!empty($stripeCharge)) {
if ($this->isErrorReturn($stripeCharge)) {
self::handleErrorNotification($stripeCharge, $params['stripe_error_url']);
return $stripeCharge;
}
// Success! Return some values for CiviCRM.
$params['trxn_id'] = $stripeCharge->id;
// Return fees & net amount for Civi reporting.
// Uses new Balance Trasaction object.
$balanceTransaction = $this->stripeCatchErrors('retrieve_balance_transaction', $stripeCharge->balance_transaction, $params);
if (!empty($balanceTransaction)) {
if ($this->isErrorReturn($balanceTransaction)) {
self::handleErrorNotification($balanceTransaction, $params['stripe_error_url']);
return $balanceTransaction;
}
$params['fee_amount'] = $balanceTransaction->fee / 100;
$params['net_amount'] = $balanceTransaction->net / 100;
// 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());
}
if ($stripeSubscription->status === 'incomplete') {
$contributionRecur->addValue('contribution_status_id:name', 'Failed');
}
// 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');
}
// For a recurring (subscription) with future start date we might not have an invoice yet.
if (!empty($stripeSubscription->latest_invoice)) {
// Get the paymentIntent for the latest invoice
$intent = $stripeSubscription->latest_invoice['payment_intent'];
$params = $this->processPaymentIntent($params, $intent);
// Set the OrderID (invoice) and TrxnID (charge)
$this->setPaymentProcessorOrderID($stripeSubscription->latest_invoice['id']);
if (!empty($stripeSubscription->latest_invoice['charge'])) {
$this->setPaymentProcessorTrxnID($stripeSubscription->latest_invoice['charge']);
}
}
else {
// There was no response from Stripe on the create charge command.
if (isset($params['stripe_error_url'])) {
CRM_Core_Error::statusBounce('Stripe transaction response not received! Check the Logs section of your stripe.com account.', $params['stripe_error_url']);
// 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']);
}
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 received! Check the Logs section of your stripe.com account.');
return $core_err;
catch (Exception $e) {
// Do nothing, we should already have a StripePaymentintent record but we don't so we'll create one.
}
if (empty($intentParams['contribution_id'])) {
$intentParams['flags'][] = 'NC';
}
CRM_Stripe_BAO_StripePaymentintent::create($intentParams);
// Set the orderID (trxn_id) to the subscription ID because we don't yet have an invoice.
// The IPN will change it to the invoice_id and then the charge_id
$this->setPaymentProcessorOrderID($stripeSubscription->id);
}
return $this->endDoPayment($params);
}
/**
* Get the billing cycle day (timestamp)
* @param array $params
*
* @return int|null
*/
private function getRecurBillingCycleDay($params) {
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;
}
}
// 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();
}
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 $stripeCustomer
* Stripe customer object generated by Stripe API.
*
* @return array
* The result in a nice formatted array (or an error object).
* @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);
}
}
$propertyBag = PropertyBag::cast($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());
}
switch ($refund->status) {
case 'pending':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
$refundStatusName = 'Pending';
break;
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;
}
/**
* Get a description field
* @param array|PropertyBag $params
* @param string $type
* One of description, statement_descriptor, statement_descriptor_suffix
*
* @throws \CiviCRM_API3_Exception
*/
public function doRecurPayment(&$params, $amount, $stripeCustomer) {
// 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;
* @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'];
}
}
}
// Apply the disount through a negative balance.
$stripeCustomer->account_balance = -$discount_in_cents;
$stripeCustomer->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'),
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,
);
$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);
$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);
default:
// The (paymentIntent) full description has no restriction on characters that are allowed/disallowed.
return "{$propertyBag->getDescription()} " . $contactContributionID . " #" . ($propertyBag->has('invoiceID') ? $propertyBag->getInvoiceID() : '');
}
}
// 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.
/**
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
*
* @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);
}
}
// Attach the Subscription to the Stripe Customer.
$cust_sub_params = array(
'prorate' => FALSE,
'plan' => $plan_id,
);
$stripeSubscription = $stripeCustomer->subscriptions->create($cust_sub_params);
$subscription_id = $stripeSubscription->id;
$recuring_contribution_id = $params['contributionRecurID'];
// Prepare escaped query params.
$query_params = array(
1 => array($subscription_id, 'String'),
2 => array($stripeCustomer->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);
}
// Don't return a $params['trxn_id'] here or else recurring membership contribs will be set
// "Completed" prematurely. Webhook.php does that.
// Add subscription_id so tests can properly work with recurring
// contributions.
$params['subscription_id'] = $subscription_id;
switch ($params['recurFrequencyUnit']) {
case 'day':
$frequencyUnit = 'D';
break;
return $params;
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';
}
/**
* Transfer method not in use.
*
* @param array $params
* Name value pair of contribution data.
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
*
* @throws \CiviCRM_API3_Exception
* @return string
* @throws \CRM_Core_Exception
*/
public function doTransferCheckout(&$params, $component) {
self::doDirectPayment($params);
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');
}
/**
......@@ -801,75 +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']);
}
}
/**
* Process incoming notification.
* Attempt to cancel the subscription at Stripe.
*
* @throws \CRM_Core_Exception
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array|null[]
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function handlePaymentNotification() {
$data_raw = file_get_contents("php://input");
$data = json_decode($data_raw);
$ipnClass = new CRM_Core_Payment_StripeIPN($data);
$ipnClass->main();
}
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);
}
/*******************************************************************
* THE FOLLOWING FUNCTIONS SHOULD BE REMOVED ONCE THEY ARE IN CORE
* getBillingEmail
* getContactId
******************************************************************/
return ['message' => E::ts('Successfully cancelled the subscription at Stripe.')];
}
/**
* Get the billing email address
* Change the amount of the recurring payment.
*
* @param string $message
* @param array $params
* @param int $contactId
*
* @return string|NULL
* @return bool|object
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected static function getBillingEmail($params, $contactId) {
$billingLocationId = CRM_Core_BAO_LocationType::getBilling();
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!');
}
$emailAddress = CRM_Utils_Array::value("email-{$billingLocationId}", $params,
CRM_Utils_Array::value('email-Primary', $params,
CRM_Utils_Array::value('email', $params, NULL)));
// 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;
}
if (empty($emailAddress) && !empty($contactId)) {
// Try and retrieve an email address from Contact 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 {
$emailAddress = civicrm_api3('Email', 'getvalue', array(
'contact_id' => $contactId,
'return' => ['email'],
));
}
catch (CiviCRM_API3_Exception $e) {
return NULL;
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();
}
}
return $emailAddress;
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 the contact id
* 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 int ContactID
* @return string
*/
protected static function getContactId($params) {
return CRM_Utils_Array::value('contactID', $params,
CRM_Utils_Array::value('contact_id', $params,
CRM_Utils_Array::value('cms_contactID', $params,
CRM_Utils_Array::value('cid', $params, NULL
))));
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 ?? [];
}
}
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 |
+--------------------------------------------------------------------+
*/
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 {
/**
......@@ -8,95 +25,98 @@ class CRM_Stripe_Customer {
* @param $params
*
* @return null|string
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function find($params) {
$requiredParams = ['is_live', 'processor_id'];
$requiredParams = ['processor_id'];
foreach ($requiredParams as $required) {
if (empty($required)) {
throw new CRM_Core_Exception('Stripe Customer (find): Missing required parameter: ' . $required);
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (find): Missing required parameter: ' . $required);
}
}
if (empty($params['email']) && empty($params['contact_id'])) {
throw new CRM_Core_Exception('Stripe Customer (find): One of email or contact_id is required');
if (empty($params['contact_id'])) {
throw new PaymentProcessorException('Stripe Customer (find): contact_id is required');
}
$queryParams = [
1 => [$params['contact_id'], 'String'],
2 => [$params['is_live'], 'Boolean'],
3 => [$params['processor_id'], 'Positive'],
];
return CRM_Core_DAO::singleValueQuery("SELECT id
FROM civicrm_stripe_customers
WHERE contact_id = %1 AND is_live = %2 AND processor_id = %3", $queryParams);
$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;
}
/**
* Add a new Stripe customer to the CiviCRM database
* Find the details (contact_id, processor_id) for an existing Stripe customer in the CiviCRM database
*
* @param $params
* @param string $stripeCustomerId
*
* @throws \CRM_Core_Exception
* @return array|null
*/
public static function add($params) {
$requiredParams = ['contact_id', 'customer_id', 'is_live', 'processor_id'];
foreach ($requiredParams as $required) {
if (empty($required)) {
throw new CRM_Core_Exception('Stripe Customer (add): Missing required parameter: ' . $required);
}
}
public static function getParamsForCustomerId($stripeCustomerId) {
$result = StripeCustomer::get(FALSE)
->addWhere('customer_id', '=', $stripeCustomerId)
->addSelect('contact_id', 'processor_id')
->execute()
->first();
$queryParams = [
1 => [$params['contact_id'], 'String'],
2 => [$params['customer_id'], 'String'],
3 => [$params['is_live'], 'Boolean'],
4 => [$params['processor_id'], 'Integer'],
];
CRM_Core_DAO::executeQuery("INSERT INTO civicrm_stripe_customers
(contact_id, id, is_live, processor_id) VALUES (%1, %2, %3, %4)", $queryParams);
// 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];
}
public static function create($params, $paymentProcessor) {
$requiredParams = ['contact_id', 'card_token', 'is_live', 'processor_id'];
// $optionalParams = ['email'];
/**
* 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($required)) {
throw new CRM_Core_Exception('Stripe Customer (create): Missing required parameter: ' . $required);
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (create): Missing required parameter: ' . $required);
}
}
$contactDisplayName = civicrm_api3('Contact', 'getvalue', [
'return' => 'display_name',
'id' => $params['contact_id'],
]);
$sc_create_params = [
'description' => $contactDisplayName . ' (CiviCRM)',
'card' => $params['card_token'],
'email' => CRM_Utils_Array::value('email', $params),
'metadata' => ['civicrm_contact_id' => $params['contact_id']],
];
$stripeCustomerParams = CRM_Stripe_BAO_StripeCustomer::getStripeCustomerMetadata($params['contact_id'], $params['invoice_settings'] ?? []);
$stripeCustomer = $paymentProcessor->stripeCatchErrors('create_customer', $sc_create_params, $params);
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.
if (isset($stripeCustomer)) {
if ($paymentProcessor->isErrorReturn($stripeCustomer)) {
return $stripeCustomer;
}
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();
$params = [
'contact_id' => $params['contact_id'],
'customer_id' => $stripeCustomer->id,
'is_live' => $params['is_live'],
'processor_id' => $params['processor_id'],
];
self::add($params);
}
else {
Throw new CRM_Core_Exception(ts('There was an error saving new customer within Stripe.'));
}
return $stripeCustomer;
return $stripeCustomerObject;
}
/**
......@@ -104,24 +124,29 @@ class CRM_Stripe_Customer {
*
* @param array $params
*
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function delete($params) {
$requiredParams = ['contact_id', 'is_live', 'processor_id'];
public static function delete(array $params) {
$requiredParams = ['processor_id'];
foreach ($requiredParams as $required) {
if (empty($required)) {
throw new CRM_Core_Exception('Stripe Customer (delete): Missing required parameter: ' . $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');
}
$queryParams = [
1 => [$params['contact_id'], 'String'],
2 => [$params['is_live'], 'Boolean'],
3 => [$params['processor_id'], 'Integer'],
];
$sql = "DELETE FROM civicrm_stripe_customers
WHERE contact_id = %1 AND is_live = %2 AND processor_id = %3";
CRM_Core_DAO::executeQuery($sql, $queryParams);
$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.
<?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.
This diff is collapsed.