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 (991)
Showing
with 4476 additions and 1756 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_StripeTrait;
use CRM_Core_Payment_MJWTrait;
/**
*
* @var string
* @var \Stripe\StripeClient
*/
public $stripeClient;
/**
* @var \Civi\Stripe\Api;
*/
protected $_stripeAPIVersion = '2019-02-19';
public \Civi\Stripe\Api $api;
/**
* Mode of operation: live or test.
* Custom properties used by this payment processor
*
* @var object
* @var string[]
*/
protected $_mode = NULL;
private $customProperties = ['paymentIntentID', 'paymentMethodID', 'setupIntentID'];
/**
* Constructor
*
* @param string $mode
* The mode of operation: live or test.
*
* @return void
* (deprecated) The mode of operation: live or test.
* @param array $paymentProcessor
*/
public function __construct($mode, &$paymentProcessor) {
$this->_mode = $mode;
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'] ?? '');
}
if (empty($this->_paymentProcessor['user_name'])) {
$error[] = ts('The "Secret Key" is not set in the Stripe Payment Processor settings.');
/**
* @param array $paymentProcessor
*
* @return string
*/
public static function getPublicKey($paymentProcessor) {
return trim($paymentProcessor['user_name'] ?? '');
}
/**
* @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);
......@@ -61,164 +155,278 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* We can use the smartdebit processor on the backend
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeName() {
return 'credit_card';
}
/**
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeLabel() {
return E::ts('Stripe');
}
/**
* We can use the stripe processor on the backend
* @return bool
*/
public function supportsBackOffice() {
return TRUE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* We can edit smartdebit recurring contributions
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return FALSE;
return TRUE;
}
/**
* 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.
*
* 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',
*
* 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 getEditableRecurringScheduleFields() {
if ($this->supports('changeSubscriptionAmount')) {
return ['amount'];
}
return [];
}
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
/**
* We can configure a start date for a smartdebit mandate
* Can we set a future recur start date? Stripe allows this but we don't (yet) support it.
* @return bool
*/
public function supportsFutureRecurStartDate() {
return FALSE;
return TRUE;
}
/**
* Get the currency for the transaction.
* Is an authorize-capture flow supported.
*
* Handle any inconsistency about how it is passed in here.
* @return bool
*/
protected function supportsPreApproval() {
return TRUE;
}
/**
* Does this processor support cancelling recurring contributions through code.
*
* @param $params
* 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 string
* @return bool
*/
public function getAmount($params) {
// Stripe amount required in cents.
$amount = number_format($params['amount'], 2, '.', '');
$amount = (int) preg_replace('/[^\d]/', '', strval($amount));
return $amount;
protected function supportsCancelRecurring() {
return TRUE;
}
/**
* Set API parameters for Stripe (such as identifier, api version, api key)
* 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
*/
public function setAPIParams() {
// 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']);
\Stripe\Stripe::setApiVersion($this->_stripeAPIVersion);
protected function supportsCancelRecurringNotifyOptional() {
return TRUE;
}
/**
* Handle an error from Stripe API and notify the user
* Get the amount for the Stripe API formatted in lowest (ie. cents / pennies).
*
* @param array $err
* @param string $bounceURL
* @param array|PropertyBag $params
*
* @return string errorMessage (or statusbounce if URL is specified)
* @return string
*/
public static function handleErrorNotification($err, $bounceURL = NULL) {
$errorMessage = 'Payment Response: <br />' .
'Type: ' . $err['type'] . '<br />' .
'Code: ' . $err['code'] . '<br />' .
'Message: ' . $err['message'] . '<br />';
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;
}
Civi::log()->debug('Stripe Payment Error: ' . $errorMessage);
/**
* @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();
}
if ($bounceURL) {
CRM_Core_Error::statusBounce($errorMessage, $bounceURL, 'Payment Error');
}
return $errorMessage;
/**
* 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);
}
/**
* Stripe exceptions contain a json object in the body "error". This function extracts and returns that as an array.
* @param String $op
* @param Exception $e
* @param Boolean $log
* 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 static function parseStripeException($op, $e, $log = FALSE) {
$body = $e->getJsonBody();
if ($log) {
Civi::log()->debug("Stripe_Error {$op}: " . print_r($body, TRUE));
}
$err = $body['error'];
if (!isset($err['code'])) {
// A "fake" error code
$err['code'] = 9000;
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;
}
return $err;
}
/**
* Create or update a Stripe Plan
*
* @param array $params
* @param \Civi\Payment\PropertyBag $propertyBag
* @param integer $amount
*
* @return \Stripe\Plan
*/
public function createPlan($params, $amount) {
$currency = strtolower($params['currencyID']);
$planId = "every-{$params['frequency_interval']}-{$params['frequency_unit']}-{$amount}-" . $currency;
if (isset($params['membership_type_tag'])) {
$planId = $params['membership_type_tag'] . $planId;
}
if ($this->_paymentProcessor['is_test']) {
$planId .= '-test';
}
public function createPlan(\Civi\Payment\PropertyBag $propertyBag, int $amount): \Stripe\Plan {
$planID = "every-{$propertyBag->getRecurFrequencyInterval()}-{$propertyBag->getRecurFrequencyUnit()}-{$amount}-" . strtolower($propertyBag->getCurrency());
// Try and retrieve existing plan from Stripe
// If this fails, we'll create a new one
try {
$plan = \Stripe\Plan::retrieve($planId);
}
catch (Stripe\Error\InvalidRequest $e) {
$err = self::parseStripeException('plan_retrieve', $e, FALSE);
if ($err['code'] == 'resource_missing') {
$formatted_amount = number_format(($amount / 100), 2);
$productName = "CiviCRM " . (isset($params['membership_name']) ? $params['membership_name'] . ' ' : '') . "every {$params['frequency_interval']} {$params['frequency_unit']}(s) {$formatted_amount}{$currency}";
if ($this->_paymentProcessor['is_test']) {
$productName .= '-test';
}
$product = \Stripe\Product::create(array(
"name" => $productName,
"type" => "service"
));
$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 = array(
$stripePlan = [
'amount' => $amount,
'interval' => $params['frequency_unit'],
'interval' => $propertyBag->getRecurFrequencyUnit(),
'product' => $product->id,
'currency' => $currency,
'id' => $planId,
'interval_count' => $params['frequency_interval'],
);
$plan = \Stripe\Plan::create($stripePlan);
'currency' => $propertyBag->getCurrency(),
'id' => $planID,
'interval_count' => $propertyBag->getRecurFrequencyInterval(),
];
$plan = $this->stripeClient->plans->create($stripePlan);
}
}
return $plan;
}
/**
* Override CRM_Core_Payment function
*
* @return array
*/
public function getPaymentFormFields() {
return array(
'credit_card_type',
'credit_card_number',
'cvv2',
'credit_card_exp_date',
'stripe_token',
'stripe_pub_key',
'stripe_id',
);
public function getPaymentFormFields(): array {
return [];
}
/**
......@@ -229,87 +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',
'extra' => ['class' => 'crm-form-select'],
),
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',
'class' => 'payproc-metadata',
),
'cc_field' => TRUE,
'is_required' => TRUE,
),
'stripe_id' => array(
'htmlType' => 'hidden',
'name' => 'stripe_id',
'title' => 'Stripe ID',
'attributes' => array(
'id' => 'stripe-id',
'class' => 'payproc-metadata',
),
'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',
'class' => 'payproc-metadata',
),
'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);
}
}
/**
......@@ -320,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;
}
}
/**
......@@ -343,298 +502,717 @@ 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 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
* Result array
*
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doPayment(&$params, $component = 'contribute') {
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);
$zeroAmountPayment = $this->processZeroAmountPayment($propertyBag);
if ($zeroAmountPayment) {
return $zeroAmountPayment;
}
$propertyBag = $this->beginDoPayment($propertyBag);
$isRecur = ($propertyBag->getIsRecur() && $this->getRecurringContributionId($propertyBag));
// Now try to retrieve the payment "token". One of setupIntentID, paymentMethodID, paymentIntentID is required (in that order)
$paymentMethodID = NULL;
$propertyBag = $this->getTokenParameter('setupIntentID', $propertyBag, FALSE);
if ($propertyBag->has('setupIntentID')) {
$setupIntentID = $propertyBag->getCustomProperty('setupIntentID');
$setupIntent = $this->stripeClient->setupIntents->retrieve($setupIntentID);
$paymentMethodID = $setupIntent->payment_method;
}
else {
$propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, FALSE);
if ($propertyBag->has('paymentMethodID')) {
$paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
}
else {
$propertyBag = $this->getTokenParameter('paymentIntentID', $propertyBag, TRUE);
}
}
$completedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
$pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
// We didn't actually use this hook with Stripe, but it was useful to trigger so listeners could see raw params
// passing $propertyBag instead of $params now allows some things to be altered
$newParams = [];
CRM_Utils_Hook::alterPaymentProcessorParams($this, $propertyBag, $newParams);
$amountFormattedForStripe = $this->getAmountFormattedForStripeAPI($propertyBag);
$stripeCustomer = $this->getStripeCustomer($propertyBag);
// If we have a $0 amount, skip call to processor and set payment_status to Completed.
if (empty($params['amount'])) {
$params['payment_status_id'] = $completedStatusId;
return $params;
$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;
}
$this->setAPIParams();
CRM_Stripe_BAO_StripeCustomer::updateMetadata($customerParams, $this, $stripeCustomer->id);
// 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;
// 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 {
$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);
$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));
}
}
$amount = self::getAmount($params);
// Use Stripe.js instead of raw card details.
if (!empty($params['stripe_token'])) {
$card_token = $params['stripe_token'];
// 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);
}
else if(!empty(CRM_Utils_Array::value('stripe_token', $_POST, NULL))) {
$card_token = CRM_Utils_Array::value('stripe_token', $_POST, NULL);
catch (Exception $e) {
$parsedError = $this->parseStripeException('doPayment', $e);
$this->handleError($parsedError['code'], $parsedError['message'], $this->getErrorUrl($propertyBag), FALSE);
}
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));
// @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());
}
$contactId = $this->getContactId($params);
$email = $this->getBillingEmail($params, $contactId);
// 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->_paymentProcessor['is_test'],
'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.
$deleteCustomer = FALSE;
try {
$stripeCustomer = \Stripe\Customer::retrieve($stripeCustomerId);
$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;
}
catch (Exception $e) {
$err = self::parseStripeException('retrieve_customer', $e, FALSE);
if (($err['type'] == 'invalid_request_error') && ($err['code'] == 'resource_missing')) {
$deleteCustomer = TRUE;
if (empty($stripeCustomer['currency'])) {
// We have no currency set for the customer in the CiviCRM database
if ($stripeCustomerObject->currency === $customerParams['currency']) {
// We can use this customer but we need to update the currency in the civicrm database
StripeCustomer::update(FALSE)
->addValue('currency', $stripeCustomerObject->currency)
->addWhere('id', '=', $stripeCustomer['id'])
->execute();
}
else {
// We need to create a new customer
$shouldCreateNewStripeCustomer = TRUE;
}
$errorMessage = self::handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Charge: ' . $errorMessage);
}
if ($deleteCustomer || $stripeCustomer->isDeleted()) {
// Customer doesn't exist, create a new one
if ($shouldDeleteStripeCustomer) {
// Customer was deleted, delete it.
CRM_Stripe_Customer::delete($customerParams);
}
if ($shouldDeleteStripeCustomer || $shouldCreateNewStripeCustomer) {
try {
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
}
catch (Exception $e) {
$stripeCustomerObject = CRM_Stripe_Customer::create($customerParams, $this);
} catch (Exception $e) {
// We still failed to create a customer
$errorMessage = self::handleErrorNotification($stripeCustomer, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Customer: ' . $errorMessage);
$err = $this->parseStripeException('create_customer', $e);
throw new PaymentProcessorException('Failed to create Stripe Customer: ' . $err['code']);
}
}
}
return $stripeCustomerObject;
}
$stripeCustomer->card = $card_token;
try {
$stripeCustomer->save();
}
catch (Exception $e) {
$err = self::parseStripeException('update_customer', $e, TRUE);
if (($err['type'] == 'invalid_request_error') && ($err['code'] == 'token_already_used')) {
// This error is ok, we've already used the token during create_customer
}
else {
$errorMessage = self::handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to update Stripe Customer: ' . $errorMessage);
}
}
/**
* @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;
}
// Prepare the charge array, minus Customer/Card details.
if (empty($params['description'])) {
$params['description'] = ts('Backend Stripe contribution');
/**
* 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);
}
// Handle recurring payments in doRecurPayment().
if (CRM_Utils_Array::value('is_recur', $params) && $params['contributionRecurID']) {
// We set payment status as pending because the IPN will set it as completed / failed
$params['payment_status_id'] = $pendingStatusId;
return $this->doRecurPayment($params, $amount, $stripeCustomer);
$params = $this->getPropertyBagAsArray($propertyBag);
// @fixme FROM HERE we are using $params array (but some things are READING from $propertyBag)
// @fixme: Split this out into "$returnParams"
// We set payment status as pending because the IPN will set it as completed / failed
$params = $this->setStatusPaymentPending($params);
$required = NULL;
if (empty($this->getRecurringContributionId($propertyBag))) {
$required = 'contributionRecurID';
}
if (!$propertyBag->has('recurFrequencyUnit')) {
$required = 'recurFrequencyUnit';
}
if ($required) {
Civi::log()->error($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
throw new CRM_Core_Exception($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
}
// Stripe charge.
$stripeChargeParams = [
'amount' => $amount,
'currency' => strtolower($params['currencyID']),
'description' => $params['description'] . ' # Invoice ID: ' . CRM_Utils_Array::value('invoiceID', $params),
// 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;
}
// Use Stripe Customer if we have a valid one. Otherwise just use the card.
if (!empty($stripeCustomer->id)) {
$stripeChargeParams['customer'] = $stripeCustomer->id;
// 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());
}
else {
$stripeChargeParams['card'] = $card_token;
if ($stripeSubscription->status === 'incomplete') {
$contributionRecur->addValue('contribution_status_id:name', 'Failed');
}
// Update the recurring payment
$contributionRecur->execute();
try {
$stripeCharge = \Stripe\Charge::create($stripeChargeParams);
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');
}
catch (Exception $e) {
$err = self::parseStripeException('charge_create', $e, FALSE);
if ($e instanceof \Stripe\Error\Card) {
civicrm_api3('Note', 'create', [
'entity_id' => $params['contributionID'],
'contact_id' => $this->getContactId($params),
'subject' => $err['type'],
'note' => $err['code'],
'entity_table' => 'civicrm_contribution',
]);
// 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 {
// Update the paymentIntent in the CiviCRM database for later tracking
// If we are not starting the recurring series immediately we probably have a "setupIntent" which needs confirming
$intentParams = [
'stripe_intent_id' => $intent->id ?? $stripeSubscription->pending_setup_intent ?? $propertyBag->getCustomProperty('paymentMethodID'),
'payment_processor_id' => $this->_paymentProcessor['id'],
'contribution_id' => $params['contributionID'] ?? NULL,
'identifier' => $params['qfKey'] ?? NULL,
'contact_id' => $params['contactID'],
];
try {
$intentParams['id'] = civicrm_api3('StripePaymentintent', 'getvalue', ['stripe_intent_id' => $propertyBag->getCustomProperty('paymentMethodID'), 'return' => 'id']);
}
catch (Exception $e) {
// Do nothing, we should already have a StripePaymentintent record but we don't so we'll create one.
}
$errorMessage = self::handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Charge: ' . $errorMessage);
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);
}
// Success! Return some values for CiviCRM.
$newParams['id'] = $this->getContributionId($params);
$newParams['trxn_id'] = $stripeCharge->id;
$newParams['payment_status_id'] = $completedStatusId;
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']);
// Return fees & net amount for Civi reporting.
try {
$stripeBalanceTransaction = \Stripe\BalanceTransaction::retrieve($stripeCharge->balance_transaction);
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) {
$err = self::parseStripeException('retrieve_balance_transaction', $e, FALSE);
$errorMessage = self::handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to retrieve Stripe Balance Transaction: ' . $errorMessage);
$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);
}
$newParams['fee_amount'] = $stripeBalanceTransaction->fee / 100;
$newParams['net_amount'] = $stripeBalanceTransaction->net / 100;
civicrm_api3('Contribution', 'create', $newParams);
unset($newParams['id']);
$params = array_merge($params, $newParams);
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 \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
public function doRecurPayment(&$params, $amount, $stripeCustomer) {
$requiredParams = ['contributionRecurID', 'frequency_unit'];
public function doRefund(&$params) {
$requiredParams = ['trxn_id', 'amount'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
Civi::log()->error('Stripe doRecurPayment: Missing mandatory parameter: ' . $required);
throw new CRM_Core_Exception('Stripe doRecurPayment: Missing mandatory parameter: ' . $required);
$message = $this->getLogPrefix() . 'doRefund: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new PaymentProcessorException($message);
}
}
// Make sure frequency_interval is set (default to 1 if not)
empty($params['frequency_interval']) ? $params['frequency_interval'] = 1 : NULL;
$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;
$amount = $this->deprecatedHandleCiviDiscount($params, $amount, $stripeCustomer);
case 'succeeded':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
$refundStatusName = 'Completed';
break;
// Create the stripe plan
$planId = self::createPlan($params, $amount);
case 'failed':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');
$refundStatusName = 'Failed';
break;
// Attach the Subscription to the Stripe Customer.
$subscriptionParams = [
'prorate' => FALSE,
'plan' => $planId,
];
// Create the stripe subscription for the customer
$stripeSubscription = $stripeCustomer->subscriptions->create($subscriptionParams);
$recurParams = [
'id' => $params['contributionRecurID'],
'trxn_id' => $stripeSubscription->id,
// FIXME processor_id is deprecated as it is not guaranteed to be unique, but currently (CiviCRM 5.9)
// it is required by cancelSubscription (where it is called subscription_id)
'processor_id' => $stripeSubscription->id,
'auto_renew' => 1,
'cycle_day' => date('d'),
'next_sched_contribution_date' => $this->calculateNextScheduledDate($params),
];
if (!empty($params['installments'])) {
// We set an end date if installments > 0
if (empty($params['start_date'])) {
$params['start_date'] = date('YmdHis');
}
if ($params['installments']) {
$recurParams['end_date'] = $this->calculateEndDate($params);
}
case 'canceled':
$refundStatus = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
$refundStatusName = 'Cancelled';
break;
}
// Hook to allow modifying recurring contribution params
CRM_Stripe_Hook::updateRecurringContribution($recurParams);
// Update the recurring payment
civicrm_api3('ContributionRecur', 'create', $recurParams);
// Update the contribution status
$refundParams = [
'refund_trxn_id' => $refund->id,
'refund_status_id' => $refundStatus,
'refund_status' => $refundStatusName,
'fee_amount' => 0,
];
return $refundParams;
}
return $params;
/**
* Get a description field
* @param array|PropertyBag $params
* @param string $type
* One of description, statement_descriptor, statement_descriptor_suffix
*
* @return string
*/
protected function getDescription($params, string $type = 'description'): string {
/* @var \Civi\Payment\PropertyBag $propertyBag */
$propertyBag = PropertyBag::cast($params);
# See https://stripe.com/docs/statement-descriptors
# And note: both the descriptor and the descriptor suffix must have at
# least one alphabetical character - so we ensure that all returned
# statement descriptors minimally have an "X".
$disallowed_characters = ['<', '>', '\\', "'", '"', '*'];
$contactContributionID = $propertyBag->getContactID() . 'X' . ($propertyBag->has('contributionID') ? $propertyBag->getContributionID() : 'XX');
switch ($type) {
// For statement_descriptor / statement_descriptor_suffix:
// 1. Get it from the setting if defined.
// 2. Generate it from the contact/contribution ID + description (event/contribution title).
// 3. Set it to the current "domain" name in CiviCRM.
// 4. If we end up with a blank descriptor Stripe will reject it - https://lab.civicrm.org/extensions/stripe/-/issues/293
// so we set it to ".".
case 'statement_descriptor':
$description = trim(\Civi::settings()->get('stripe_statementdescriptor'));
if (empty($description)) {
$description = trim("{$contactContributionID} {$propertyBag->getDescription()}");
if (empty($description)) {
$description = \Civi\Api4\Domain::get(FALSE)
->setCurrentDomain(TRUE)
->addSelect('name')
->execute()
->first()['name'];
}
}
$description = str_replace($disallowed_characters, '', $description);
if (empty($description)) {
$description = 'X';
}
return substr($description, 0, 22);
case 'statement_descriptor_suffix':
$description = trim(\Civi::settings()->get('stripe_statementdescriptorsuffix'));
if (empty($description)) {
$description = trim("{$contactContributionID} {$propertyBag->getDescription()}");
if (empty($description)) {
$description = \Civi\Api4\Domain::get(FALSE)
->setCurrentDomain(TRUE)
->addSelect('name')
->execute()
->first()['name'];
}
}
$description = str_replace($disallowed_characters, '', $description);
if (empty($description)) {
$description = 'X';
}
return substr($description,0,12);
default:
// The (paymentIntent) full description has no restriction on characters that are allowed/disallowed.
return "{$propertyBag->getDescription()} " . $contactContributionID . " #" . ($propertyBag->has('invoiceID') ? $propertyBag->getInvoiceID() : '');
}
}
/**
......@@ -645,16 +1223,16 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @throws \CRM_Core_Exception
*/
public function calculateEndDate($params) {
$requiredParams = ['start_date', 'installments', 'frequency_interval', 'frequency_unit'];
$requiredParams = ['receive_date', 'recurInstallments', 'recurFrequencyInterval', 'recurFrequencyUnit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = 'Stripe calculateEndDate: Missing mandatory parameter: ' . $required;
$message = $this->getLogPrefix() . 'calculateEndDate: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new CRM_Core_Exception($message);
}
}
switch ($params['frequency_unit']) {
switch ($params['recurFrequencyUnit']) {
case 'day':
$frequencyUnit = 'D';
break;
......@@ -672,8 +1250,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
break;
}
$numberOfUnits = $params['installments'] * $params['frequency_interval'];
$endDate = new DateTime($params['start_date']);
$numberOfUnits = $params['recurInstallments'] * $params['recurFrequencyInterval'];
$endDate = new DateTime($params['receive_date']);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd') . '235959';
}
......@@ -686,15 +1264,15 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @throws \CRM_Core_Exception
*/
public function calculateNextScheduledDate($params) {
$requiredParams = ['frequency_interval', 'frequency_unit'];
$requiredParams = ['recurFrequencyInterval', 'recurFrequencyUnit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = 'Stripe calculateNextScheduledDate: Missing mandatory parameter: ' . $required;
$message = $this->getLogPrefix() . 'calculateNextScheduledDate: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new CRM_Core_Exception($message);
}
}
if (empty($params['start_date']) && empty($params['next_sched_contribution_date'])) {
if (empty($params['receive_date']) && empty($params['next_sched_contribution_date'])) {
$startDate = date('YmdHis');
}
elseif (!empty($params['next_sched_contribution_date'])) {
......@@ -703,10 +1281,10 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
}
else {
$startDate = $params['start_date'];
$startDate = $params['receive_date'];
}
switch ($params['frequency_unit']) {
switch ($params['recurFrequencyUnit']) {
case 'day':
$frequencyUnit = 'D';
break;
......@@ -724,80 +1302,12 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
break;
}
$numberOfUnits = $params['frequency_interval'];
$numberOfUnits = $params['recurFrequencyInterval'];
$endDate = new DateTime($startDate);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd');
}
/**
* @deprecated This belongs in a separate extension / hook as it's non-standard CiviCRM behaviour
*
* 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.
*
* @param $params
* @param $amount
* @param $stripeCustomer
*
* @return float|int
* @throws \CiviCRM_API3_Exception
*/
public function deprecatedHandleCiviDiscount(&$params, $amount, $stripeCustomer) {
if (!empty($params['discountcode'])) {
$discount_code = $params['discountcode'];
$discount_object = civicrm_api3('DiscountCode', 'get', array(
'sequential' => 1,
'return' => "amount,amount_type",
'code' => $discount_code,
));
// amount_types: 1 = percentage, 2 = fixed, 3 = giftcard
if ((!empty($discount_object['values'][0]['amount'])) && (!empty($discount_object['values'][0]['amount_type']))) {
$discount_type = $discount_object['values'][0]['amount_type'];
if ( $discount_type == 1 ) {
// Discount is a percentage. Avoid ugly math and just get the full price using price_ param.
foreach($params as $key=>$value){
if("price_" == substr($key,0,6)){
$price_param = $key;
$price_field_id = substr($key,strrpos($key,'_') + 1);
}
}
if (!empty($params[$price_param])) {
$priceFieldValue = civicrm_api3('PriceFieldValue', 'get', array(
'sequential' => 1,
'return' => "amount",
'id' => $params[$price_param],
'price_field_id' => $price_field_id,
));
}
if (!empty($priceFieldValue['values'][0]['amount'])) {
$priceset_amount = $priceFieldValue['values'][0]['amount'];
$full_price = $priceset_amount * 100;
$discount_in_cents = $full_price - $amount;
// Set amount to full price.
$amount = $full_price;
}
} else if ( $discount_type >= 2 ) {
// discount is fixed or a giftcard. (may be > amount).
$discount_amount = $discount_object['values'][0]['amount'];
$discount_in_cents = $discount_amount * 100;
// Set amount to full price.
$amount = $amount + $discount_in_cents;
}
}
// Apply the disount through a negative balance.
$stripeCustomer->account_balance = -$discount_in_cents;
$stripeCustomer->save();
}
return $amount;
}
/**
* Default payment instrument validation.
*
......@@ -813,39 +1323,130 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* @param string $message
* @param array $params
* Attempt to cancel the subscription at Stripe.
*
* @return bool|object
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array|null[]
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function cancelSubscription(&$message = '', $params = []) {
$this->setAPIParams();
$contributionRecurId = $this->getRecurringContributionId($params);
try {
$contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', array(
'id' => $contributionRecurId,
));
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);
}
catch (Exception $e) {
return FALSE;
$notifyProcessor = $propertyBag->getIsNotifyProcessorOnCancelRecur();
if (!$notifyProcessor) {
return ['message' => E::ts('Successfully cancelled the subscription in CiviCRM ONLY.')];
}
if (empty($contributionRecur['trxn_id'])) {
CRM_Core_Session::setStatus(ts('The recurring contribution cannot be cancelled (No reference (trxn_id) found).'), 'Smart Debit', 'error');
return FALSE;
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 = \Stripe\Subscription::retrieve($contributionRecur['trxn_id']);
$subscription = $this->stripeClient->subscriptions->retrieve($propertyBag->getRecurProcessorID());
if (!$subscription->isDeleted()) {
$subscription->cancel();
}
}
catch (Exception $e) {
$errorMessage = 'Could not delete Stripe subscription: ' . $e->getMessage();
CRM_Core_Session::setStatus($errorMessage, 'Stripe', 'error');
Civi::log()->debug($errorMessage);
return FALSE;
$errorMessage = E::ts('Could not cancel Stripe subscription: %1', [1 => $e->getMessage()]);
\Civi::log('stripe')->error($errorMessage);
throw new PaymentProcessorException($errorMessage);
}
return ['message' => E::ts('Successfully cancelled the subscription at Stripe.')];
}
/**
* Change the amount of the recurring payment.
*
* @param string $message
* @param array $params
*
* @return bool|object
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function changeSubscriptionAmount(&$message = '', $params = []) {
// We only support the following params: amount
try {
$propertyBag = $this->beginChangeSubscriptionAmount($params);
// Get the Stripe subscription
$subscription = $this->stripeClient->subscriptions->retrieve($propertyBag->getRecurProcessorID());
$calculatedItems = $this->api->calculateItemsForSubscription($propertyBag->getRecurProcessorID(), $subscription->items->data);
$contributionRecurKey = mb_strtolower($propertyBag->getCurrency()) . "_{$propertyBag->getRecurFrequencyUnit()}_{$propertyBag->getRecurFrequencyInterval()}";
if (isset($calculatedItems[$contributionRecurKey])) {
$calculatedItem = $calculatedItems[$contributionRecurKey];
if (Money::of($calculatedItem['amount'], mb_strtoupper($calculatedItem['currency']))
->isAmountAndCurrencyEqualTo(Money::of($propertyBag->getAmount(), $propertyBag->getCurrency()))) {
throw new PaymentProcessorException('Amount is the same as before!');
}
}
else {
throw new PaymentProcessorException('Cannot find existing price/plan for this subscription with matching frequency!');
}
// Get the existing Price
$existingPrice = $subscription->items->data[0]->price;
// Check if the Stripe Product already has a Price configured for the new amount
$priceToMatch = [
'active' => TRUE,
'currency' => $subscription->currency,
'product' => $existingPrice->product,
'type' => 'recurring',
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
],
];
$existingPrices = $this->stripeClient->prices->all($priceToMatch);
foreach ($existingPrices as $price) {
if ($price->unit_amount === (int) $this->getAmountFormattedForStripeAPI($propertyBag)) {
// Yes, we already have a matching price option - use it!
$newPriceID = $price->id;
break;
}
}
if (empty($newPriceID)) {
// We didn't find an existing price that matched for the product. Create a new one.
$newPriceParameters = [
'currency' => $subscription->currency,
'unit_amount' => $this->getAmountFormattedForStripeAPI($propertyBag),
'product' => $existingPrice->product,
'metadata' => $existingPrice->metadata->toArray(),
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
'interval_count' => $existingPrice->recurring['interval_count'],
],
];
$newPriceID = $this->stripeClient->prices->create($newPriceParameters)->id;
}
// Update the Stripe subscription, replacing the existing price with the new one.
$this->stripeClient->subscriptions->update($propertyBag->getRecurProcessorID(), [
'items' => [
[
'id' => $subscription->items->data[0]->id,
'price' => $newPriceID,
],
],
// See https://stripe.com/docs/billing/subscriptions/prorations - we disable this to keep it simple for now.
'proration_behavior' => 'none',
]);
}
catch (Exception $e) {
// On ANY failure, throw an exception which will be reported back to the user.
\Civi::log()->error('Update Subscription failed for RecurID: ' . $propertyBag->getContributionRecurID() . ' Error: ' . $e->getMessage());
throw new PaymentProcessorException('Update Subscription Failed: ' . $e->getMessage(), $e->getCode(), $params);
}
return TRUE;
......@@ -855,15 +1456,224 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* Process incoming payment notification (IPN).
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public static function handlePaymentNotification() {
$data_raw = file_get_contents("php://input");
$data = json_decode($data_raw);
$ipnClass = new CRM_Core_Payment_StripeIPN($data);
if ($ipnClass->main()) {
http_response_code(200);
public function handlePaymentNotification() {
// Set default http response to 200
http_response_code(200);
$rawData = file_get_contents("php://input");
$event = json_decode($rawData, TRUE);
$ipnClass = new CRM_Core_Payment_StripeIPN($this);
$ipnClass->setEventID($event['id']);
if (!$ipnClass->setEventType($event['type'])) {
// We don't handle this event
return;
}
$webhookSecret = $this->getWebhookSecret();
if (!empty($webhookSecret)) {
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];
try {
Webhook::constructEvent($rawData, $sigHeader, $webhookSecret);
$ipnClass->setVerifyData(FALSE);
$data = StripeObject::constructFrom($event['data']);
$ipnClass->setData($data);
} catch (\UnexpectedValueException $e) {
// Invalid payload
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
} catch (\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
}
}
else {
$ipnClass->setVerifyData(TRUE);
}
$ipnClass->onReceiveWebhook();
}
/**
* @param int $paymentProcessorID
* The actual payment processor ID that should be used.
* @param $rawData
* The "raw" data, eg. a JSON string that is saved in the civicrm_system_log.context table
* @param bool $verifyRequest
* Should we verify the request data with the payment processor (eg. retrieve it again?).
* @param null|int $emailReceipt
* Override setting of email receipt if set to 0, 1
*
* @return bool
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @deprecated
*/
public static function processPaymentNotification($paymentProcessorID, $rawData, $verifyRequest = TRUE, $emailReceipt = NULL) {
// Set default http response to 200
http_response_code(200);
$event = json_decode($rawData);
$paymentProcessorObject = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
if (!($paymentProcessorObject instanceof CRM_Core_Payment_Stripe)) {
throw new PaymentProcessorException('Failed to get payment processor');
}
$ipnClass = new CRM_Core_Payment_StripeIPN($paymentProcessorObject);
$ipnClass->setEventID($event->id);
if (!$ipnClass->setEventType($event->type)) {
// We don't handle this event
return FALSE;
};
$ipnClass->setVerifyData($verifyRequest);
if (!$verifyRequest) {
$ipnClass->setData($event->data);
}
$ipnClass->setExceptionMode(FALSE);
if (isset($emailReceipt)) {
$ipnClass->setSendEmailReceipt($emailReceipt);
}
return $ipnClass->processWebhookEvent()->ok;
}
/**
* Called by mjwshared extension's queue processor api3 Job.process_paymentprocessor_webhooks
*
* The array parameter contains a row of PaymentprocessorWebhook data, which represents a single PaymentprocessorWebhook event
*
* Return TRUE for success, FALSE if there's a problem
*/
public function processWebhookEvent(array $webhookEvent) :bool {
// If there is another copy of this event in the table with a lower ID, then
// this is a duplicate that should be ignored. We do not worry if there is one with a higher ID
// because that means that while there are duplicates, we'll only process the one with the lowest ID.
$duplicates = PaymentprocessorWebhook::get(FALSE)
->selectRowCount()
->addWhere('event_id', '=', $webhookEvent['event_id'])
->addWhere('id', '<', $webhookEvent['id'])
->execute()->count();
if ($duplicates) {
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', '=', $webhookEvent['id'])
->addValue('status', 'error')
->addValue('message', 'Refusing to process this event as it is a duplicate.')
->execute();
return FALSE;
}
$handler = new CRM_Core_Payment_StripeIPN($this);
$handler->setEventID($webhookEvent['event_id']);
if (!$handler->setEventType($webhookEvent['trigger'])) {
// We don't handle this event
return FALSE;
}
// Populate the data from this webhook.
$rawEventData = str_replace('Stripe\StripeObject JSON: ', '', $webhookEvent['data']);
$eventData = json_decode($rawEventData, TRUE);
$data = StripeObject::constructFrom($eventData);
$handler->setData($data);
// We retrieve/validate/store the webhook data when it is received.
$handler->setVerifyData(FALSE);
$handler->setExceptionMode(FALSE);
return $handler->processQueuedWebhookEvent($webhookEvent);
}
/**
* Get help text information (help, description, etc.) about this payment,
* to display to the user.
*
* @param string $context
* Context of the text.
* Only explicitly supported contexts are handled without error.
* Currently supported:
* - contributionPageRecurringHelp (params: is_recur_installments, is_email_receipt)
* - contributionPageContinueText (params: amount, is_payment_to_existing)
* - cancelRecurDetailText:
* params:
* mode, amount, currency, frequency_interval, frequency_unit,
* installments, {membershipType|only if mode=auto_renew},
* selfService (bool) - TRUE if user doesn't have "edit contributions" permission.
* ie. they are accessing via a "self-service" link from an email receipt or similar.
* - cancelRecurNotSupportedText
*
* @param array $params
* Parameters for the field, context specific.
*
* @return string
*/
public function getText($context, $params) {
$text = parent::getText($context, $params);
switch ($context) {
case 'cancelRecurDetailText':
// $params['selfService'] added via https://github.com/civicrm/civicrm-core/pull/17687
$params['selfService'] = $params['selfService'] ?? TRUE;
if ($params['selfService']) {
$text .= ' <br/><strong>' . E::ts('Stripe will be automatically notified and the subscription will be cancelled.') . '</strong>';
}
else {
$text .= ' <br/><strong>' . E::ts("If you select 'Send cancellation request..' then Stripe will be automatically notified and the subscription will be cancelled.") . '</strong>';
}
}
return $text;
}
/**
* Get the help text to present on the recurring update page.
*
* This should reflect what can or cannot be edited.
*
* @return string
*/
public function getRecurringScheduleUpdateHelpText() {
return E::ts('Use this form to change the amount for this recurring contribution. The Stripe subscription will be updated with the new amount.');
}
/*
* Sets a mock stripe client object for this object and all future created
* instances. This should only be called by phpunit tests.
*
* Nb. cannot change other already-existing instances.
*/
public function setMockStripeClient($stripeClient) {
if (!defined('STRIPE_PHPUNIT_TEST')) {
throw new \RuntimeException("setMockStripeClient was called while not in a STRIPE_PHPUNIT_TEST");
}
$GLOBALS['mockStripeClient'] = $this->stripeClient = $stripeClient;
}
/**
* Get the Fee charged by Stripe from the "balance transaction".
* If the transaction is declined, there won't be a balance_transaction_id.
* We also have to do currency conversion here in case Stripe has converted it internally.
*
* @param \Stripe\BalanceTransaction|PropertySpy $balanceTransaction
* @param
*
* @return float
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function getFeeFromBalanceTransaction($balanceTransaction, string $currency): float {
if ($currency !== $balanceTransaction->currency && !empty($balanceTransaction->exchange_rate)) {
$fee = CRM_Stripe_Api::currencyConversion($balanceTransaction->fee, $balanceTransaction->exchange_rate, $currency);
} else {
// We must round to currency precision otherwise payments may fail because Contribute BAO saves but then
// can't retrieve because it tries to use the full unrounded number when it only got saved with 2dp.
$fee = round($balanceTransaction->fee / 100, CRM_Utils_Money::getCurrencyPrecision($currency));
}
return $fee;
}
/**
* @return string
*/
public function getLogPrefix(): string {
return 'Stripe(' . $this->getID() . '): ';
}
}
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
use Civi\Payment\Exception\PaymentProcessorException;
/**
* Class CRM_Core_Payment_Stripe
*/
class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
use CRM_Core_Payment_MJWTrait;
/**
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeName() {
return 'stripe-checkout';
}
/**
* Override CRM_Core_Payment function
*
* @return string
*/
public function getPaymentTypeLabel() {
return E::ts('Stripe Checkout');
}
/**
* We can use the stripe processor on the backend
*
* @return bool
*/
public function supportsBackOffice() {
return FALSE;
}
/**
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return FALSE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
/**
* Can we set a future recur start date?
*
* @return bool
*/
public function supportsFutureRecurStartDate() {
return FALSE;
}
/**
* Is an authorize-capture flow supported.
*
* @return bool
*/
protected function supportsPreApproval() {
return FALSE;
}
/**
* Does this processor support cancelling recurring contributions through code.
*
* If the processor returns true it must be possible to take action from within CiviCRM
* that will result in no further payments being processed.
*
* @return bool
*/
protected function supportsCancelRecurring() {
return TRUE;
}
/**
* Does the processor support the user having a choice as to whether to cancel the recurring with the processor?
*
* If this returns TRUE then there will be an option to send a cancellation request in the cancellation form.
*
* This would normally be false for processors where CiviCRM maintains the schedule.
*
* @return bool
*/
protected function supportsCancelRecurringNotifyOptional() {
return TRUE;
}
/**
* Set default values when loading the (payment) form
*
* @param \CRM_Core_Form $form
*/
public function buildForm(&$form) {}
/**
* Process payment
* Submit a payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
* Payment processors should set payment_status_id/payment_status.
*
* @param array|PropertyBag $paymentParams
* Assoc array of input parameters for this transaction.
* @param string $component
*
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doPayment(&$paymentParams, $component = 'contribute') {
/* @var \Civi\Payment\PropertyBag $propertyBag */
$propertyBag = \Civi\Payment\PropertyBag::cast($paymentParams);
$zeroAmountPayment = $this->processZeroAmountPayment($propertyBag);
if ($zeroAmountPayment) {
return $zeroAmountPayment;
}
$propertyBag = $this->beginDoPayment($propertyBag);
// Not sure what the point of this next line is.
$this->_component = $component;
$successUrl = $this->getReturnSuccessUrl($paymentParams['qfKey']);
$failUrl = $this->getCancelUrl($paymentParams['qfKey'], NULL);
$lineItems = $this->calculateLineItems($paymentParams);
$checkoutSession = $this->createCheckoutSession($successUrl, $failUrl, $propertyBag, $lineItems);
// Allow each CMS to do a pre-flight check before redirecting to Stripe.
CRM_Core_Config::singleton()->userSystem->prePostRedirect();
CRM_Utils_System::setHttpHeader("HTTP/1.1 303 See Other", '');
CRM_Utils_System::redirect($checkoutSession->url);
}
/**
* This gathers the line items which are then used in buildCheckoutLineItems()
*
* @param array|PropertyBag $paymentParams
*/
public function calculateLineItems($paymentParams): array {
$lineItems = [];
if (!empty($paymentParams['skipLineItem']) || empty($paymentParams['line_item'])) {
if (!empty($paymentParams['participants_info'])) {
// Events: Multiple participants. Lineitem for each participant is in participantDetail.
foreach ($paymentParams['participants_info'] as $participantID => $participantDetail) {
$lineItems = array_merge($lineItems, $participantDetail['lineItem']);
}
}
else {
// Fallback if no lineitems (some contribution pages)
$lineItems = [
'priceset' => [
'pricesetline' => [
'unit_price' => $paymentParams['amount'],
// source is available on contribution pages, description on event registration
'field_title' => $paymentParams['source'] ?? $paymentParams['description'],
'label' => $paymentParams['source'] ?? $paymentParams['description'],
'qty' => 1,
],
],
];
}
}
else {
$lineItems = $paymentParams['line_item'] ?? [];
}
return $lineItems;
}
/**
* Create a Stripe Checkout Session
*
* @return \Stripe\Checkout\Session
*/
public function createCheckoutSession(string $successUrl, string $failUrl, PropertyBag $propertyBag, array $lineItems) {
// Get existing/saved Stripe customer or create a new one
$existingStripeCustomer = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $propertyBag->getContactID())
->addWhere('processor_id', '=', $this->getPaymentProcessor()['id'])
->execute()
->first();
if (empty($existingStripeCustomer)) {
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$stripeCustomerID = $stripeCustomer->id;
}
else {
$stripeCustomerID = $existingStripeCustomer['customer_id'];
}
// Build the checkout session parameters
$checkoutSessionParams = [
'line_items' => $this->buildCheckoutLineItems($lineItems, $propertyBag),
'mode' => $propertyBag->getIsRecur() ? 'subscription' : 'payment',
'success_url' => $successUrl,
'cancel_url' => $failUrl,
// Nb. We can only specify customer_email|customer, not both.
// 'customer_email' => $propertyBag->getEmail(),
'customer' => $stripeCustomerID,
// 'submit_type' => one of 'auto', pay, book, donate
'client_reference_id' => $propertyBag->getInvoiceID(),
'payment_method_types' => $this->getSupportedPaymentMethods($propertyBag),
];
// Stripe Error:
// Stripe\Exception\InvalidRequestException: You can not pass `payment_intent_data` in `subscription` mode.
if ($propertyBag->getIsRecur()) {
$checkoutSessionParams['subscription_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
}
else {
$checkoutSessionParams['payment_intent_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
}
// Allows you to alter the params passed to StripeCheckout (eg. payment_method_types)
CRM_Utils_Hook::alterPaymentProcessorParams($this, $propertyBag, $checkoutSessionParams);
try {
$checkoutSession = $this->stripeClient->checkout->sessions->create($checkoutSessionParams);
}
catch (Exception $e) {
$parsedError = $this->parseStripeException('doPayment', $e);
throw new PaymentProcessorException($parsedError['message']);
}
CRM_Stripe_BAO_StripeCustomer::updateMetadata(['contact_id' => $propertyBag->getContactID()], $this, $checkoutSession['customer']);
return $checkoutSession;
}
/**
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array
*/
private function getSupportedPaymentMethods(\Civi\Payment\PropertyBag $propertyBag): array {
$paymentMethods = \Civi::settings()->get('stripe_checkout_supported_payment_methods');
$result = [];
foreach ($paymentMethods as $paymentMethod) {
switch ($paymentMethod) {
case 'sepa_debit':
case 'bancontact':
if ($propertyBag->getCurrency() === 'EUR') {
$result[] = $paymentMethod;
}
break;
case 'us_bank_account':
if ($propertyBag->getCurrency() === 'USD') {
$result[] = $paymentMethod;
}
break;
case 'bacs_debit':
if ($propertyBag->getCurrency() === 'GBP') {
$result[] = $paymentMethod;
}
break;
default:
$result[] = $paymentMethod;
}
}
if (empty($result)) {
throw new PaymentProcessorException('There are no valid Stripe payment methods enabled for this configuration. Check currency etc.');
}
return $result;
}
/**
* Takes the lineitems passed into doPayment and converts them into an array suitable for passing to Stripe Checkout
*
* @param array $civicrmLineItems
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return array
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
private function buildCheckoutLineItems(array $civicrmLineItems, PropertyBag $propertyBag) {
foreach ($civicrmLineItems as $priceSetLines) {
foreach ($priceSetLines as $lineItem) {
$checkoutLineItem = [
'price_data' => [
'currency' => $propertyBag->getCurrency(),
'unit_amount' => $this->getAmountFormattedForStripeAPI(PropertyBag::cast(['amount' => $lineItem['unit_price'], 'currency' => $propertyBag->getCurrency()])),
'product_data' => [
'name' => $lineItem['field_title'],
// An empty label on a contribution page amounts configuration gives an empty $lineItem['label']. StripeCheckout needs it set.
'description' => $lineItem['label'] ?: $lineItem['field_title'],
//'images' => ['https://example.com/t-shirt.png'],
],
],
'quantity' => $lineItem['qty'],
];
if ($propertyBag->getIsRecur()) {
$checkoutLineItem['price_data']['recurring'] = [
'interval' => $propertyBag->getRecurFrequencyUnit(),
'interval_count' => $propertyBag->getRecurFrequencyInterval(),
];
}
$checkoutLineItems[] = $checkoutLineItem;
}
}
return $checkoutLineItems ?? [];
}
}
<?php
/*
* @file
* Handle Stripe Webhooks for recurring payments.
+--------------------------------------------------------------------+
| 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 |
+--------------------------------------------------------------------+
*/
class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
use Civi\Api4\ContributionRecur;
use Civi\Api4\PaymentprocessorWebhook;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Core_Payment_StripeIPNTrait;
/**
* Class CRM_Core_Payment_StripeIPN
*/
class CRM_Core_Payment_StripeIPN {
use CRM_Core_Payment_MJWIPNTrait;
/**
* @var \CRM_Core_Payment_Stripe Payment processor
*/
protected $_paymentProcessor;
/**
* Transaction ID is the contribution in the redirect flow and a random number in the on-site->POST flow
* Ideally the contribution id would always be created at this point in either flow for greater consistency
* @var
* The data provided by the IPN
* Redeclared here to tighten up the var type
*
* @var \Stripe\StripeObject
*/
protected $transaction_id;
protected $data;
// By default, always retrieve the event from stripe to ensure we are
// not being fed garbage. However, allow an override so when we are
// testing, we can properly test a failed recurring contribution.
protected $verify_event = TRUE;
/**
* The CiviCRM contact ID that maps to the Stripe customer
*
* @var int
*/
protected $contactID = NULL;
// Properties of the event.
protected $event_type = NULL;
/**
* @var string The Stripe Subscription ID
*/
protected $subscription_id = NULL;
/**
* @var string The Stripe Customer ID
*/
protected $customer_id = NULL;
/**
* @var string The Stripe PaymentIntent ID
*/
protected $payment_intent_id = NULL;
/**
* @var string The Stripe Charge ID
*/
protected $charge_id = NULL;
protected $previous_plan_id = NULL;
protected $plan_id = NULL;
protected $plan_amount = NULL;
protected $frequency_interval = NULL;
protected $frequency_unit = NULL;
protected $plan_name = NULL;
protected $plan_start = NULL;
// Derived properties.
protected $contribution_recur_id = NULL;
protected $event_id = NULL;
/**
* @var string The stripe Invoice ID (mapped to trxn_id on a contribution for recurring contributions)
*/
protected $invoice_id = NULL;
/**
* @var string The date/time the charge was made
*/
protected $receive_date = NULL;
protected $amount = NULL;
protected $fee = NULL;
protected $net_amount = NULL;
protected $previous_contribution = [];
/**
* CRM_Core_Payment_StripeIPN constructor.
* @var float The amount paid
*/
protected $amount = 0.0;
/**
* @var float The fee charged by Stripe
*/
protected $fee = 0.0;
/**
* @var array The current contribution (linked to Stripe charge(single)/invoice(subscription)
*/
protected $contribution = NULL;
/**
* @var bool
*/
protected $setInputParametersHasRun = FALSE;
/**
* Normally if any exception is thrown in processing a webhook it is
* caught and a simple error logged.
*
* @param $ipnData
* @param bool $verify
* In a test environment it is often helpful for it to throw the exception instead.
*
* @throws \CRM_Core_Exception
* @var bool.
*/
public function __construct($ipnData, $verify = TRUE) {
$this->verify_event = $verify;
$this->setInputParameters($ipnData);
parent::__construct();
}
public $exceptionOnFailure = FALSE;
/**
* Store input array on the class.
* We override base because our input parameter is an object
* Redeclared here to tighten up the var type
* Can't define return because unit tests return PropertySpy
* Should be \Stripe\StripeObject|PropertySpy with PHP8.0+
*
* @param array $parameters
*/
public function setInputParameters($parameters) {
if (!is_object($parameters)) {
$this->exception('Invalid input parameters');
}
// Determine the proper Stripe Processor ID so we can get the secret key
// and initialize Stripe.
$this->getPaymentProcessor();
// Now re-retrieve the data from Stripe to ensure it's legit.
\Stripe\Stripe::setApiKey($this->_paymentProcessor['user_name']);
* @return \Stripe\StripeObject
*/
public function getData() {
return $this->data;
}
// Special case if this is the test webhook
if (substr($parameters->id, -15, 15) === '_00000000000000') {
http_response_code(200);
$test = (boolean) $this->_paymentProcessor['is_test'] ? '(Test processor)' : '(Live processor)';
echo "Test webhook from Stripe ({$parameters->id}) received successfully by CiviCRM {$test}.";
exit();
public function __construct(?CRM_Core_Payment_Stripe $paymentObject = NULL) {
if ($paymentObject !== NULL && !($paymentObject instanceof CRM_Core_Payment_Stripe)) {
// This would be a coding error.
throw new Exception(__CLASS__ . " constructor requires CRM_Core_Payment_Stripe object (or NULL for legacy use).");
}
$this->_paymentProcessor = $paymentObject;
}
if ($this->verify_event) {
$this->_inputParameters = \Stripe\Event::retrieve($parameters->id);
}
else {
$this->_inputParameters = $parameters;
/**
* Returns TRUE if we handle this event type, FALSE otherwise
* @param string $eventType
*
* @return bool
*/
public function setEventType($eventType) {
$this->eventType = $eventType;
if (!in_array($this->eventType, CRM_Stripe_Webhook::getDefaultEnabledEvents())) {
return FALSE;
}
http_response_code(200);
return TRUE;
}
/**
* Get a parameter given to us by Stripe.
*
* @param string $name
* @param $type
* @param bool $abort
* Set and initialise the paymentProcessor object
* @param int $paymentProcessorID
*
* @return false|int|null|string
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function retrieve($name, $type, $abort = TRUE) {
$className = get_class($this->_inputParameters->data->object);
$value = NULL;
switch ($className) {
case 'Stripe\Charge':
switch ($name) {
case 'charge_id':
$value = $this->_inputParameters->data->object->id;
break;
case 'failure_code':
$value = $this->_inputParameters->data->object->failure_code;
break;
case 'failure_message':
$value = $this->_inputParameters->data->object->failure_message;
break;
case 'refunded':
$value = $this->_inputParameters->data->object->refunded;
break;
case 'amount_refunded':
$value = $this->_inputParameters->data->object->amount_refunded;
break;
}
break;
case 'Stripe\Invoice':
switch ($name) {
case 'charge_id':
$value = $this->_inputParameters->data->object->charge;
break;
case 'invoice_id':
$value = $this->_inputParameters->data->object->id;
break;
case 'receive_date':
$value = date("Y-m-d H:i:s", $this->_inputParameters->data->object->date);
break;
case 'subscription_id':
$value = $this->_inputParameters->data->object->subscription;
break;
}
break;
public function setPaymentProcessor($paymentProcessorID) {
try {
$this->_paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
}
catch (Exception $e) {
$this->exception('Failed to get payment processor');
}
}
case 'Stripe\Subscription':
switch ($name) {
case 'frequency_interval':
$value = $this->_inputParameters->data->object->plan->interval_count;
break;
/**
* @return string|null
*/
public function getStripeCustomerID() {
return $this->customer_id;
}
case 'frequency_unit':
$value = $this->_inputParameters->data->object->plan->interval;
break;
/**
* @return string|null
*/
public function getStripeSubscriptionID() {
return $this->subscription_id;
}
case 'plan_amount':
$value = $this->_inputParameters->data->object->plan->amount / 100;
break;
/**
* @return string|null
*/
public function getStripeInvoiceID() {
return $this->invoice_id;
}
case 'plan_id':
$value = $this->_inputParameters->data->object->plan->id;
break;
/**
* @return string|null
*/
public function getStripeChargeID() {
return $this->charge_id;
}
case 'plan_name':
$value = $this->_inputParameters->data->object->plan->name;
break;
/**
* Check, decode, validate webhook data and extract some parameters to the class.
*/
public function setInputParameters() {
if ($this->setInputParametersHasRun) {
return;
}
case 'plan_start':
$value = date("Y-m-d H:i:s", $this->_inputParameters->data->object->start);
break;
// If we don't have a webhook signing secret we need to retrieve the event again
// to make sure that it is "real" and was not faked.
if ($this->getVerifyData()) {
/** @var \Stripe\Event $event */
$event = $this->getPaymentProcessor()->stripeClient->events->retrieve($this->eventID);
$this->setData($event->data);
}
case 'subscription_id':
$value = $this->_inputParameters->data->object->id;
break;
}
break;
// If we have a webhook signing secret data was already set on the class.
$data = $this->getData();
if (!is_object($data)) {
$this->exception('Invalid input data (not an object)');
}
// Common parameters
switch ($name) {
case 'customer_id':
$value = $this->_inputParameters->data->object->customer;
break;
// When we receive a charge.X webhook event and it has an invoice ID we expand the invoice object
// so that we have the subscription ID.
// We'll receive both invoice.payment_succeeded/failed and charge.succeeded/failed at the same time
// and we need to make sure we don't process them at the same time or we can get deadlocks/race conditions
// that cause processing to fail.
if (($data->object instanceof \Stripe\Charge) && !empty($data->object->invoice)) {
$data->object = $this->getPaymentProcessor()->stripeClient->charges->retrieve(
$this->getData()->object->id,
['expand' => ['invoice']]
);
$this->setData($data);
$this->subscription_id = CRM_Stripe_Api::getObjectParam('subscription_id', $this->getData()->object->invoice);
$this->invoice_id = CRM_Stripe_Api::getObjectParam('invoice_id', $this->getData()->object->invoice);
}
else {
$this->subscription_id = $this->retrieve('subscription_id', 'String', FALSE);
$this->invoice_id = $this->retrieve('invoice_id', 'String', FALSE);
}
case 'event_type':
$value = $this->_inputParameters->type;
break;
$this->charge_id = $this->retrieve('charge_id', 'String', FALSE);
$this->payment_intent_id = $this->retrieve('payment_intent_id', 'String', FALSE);
$this->customer_id = $this->retrieve('customer_id', 'String', FALSE);
case 'previous_plan_id':
if (preg_match('/\.updated$/', $this->_inputParameters->type)) {
$value = $this->_inputParameters->data->previous_attributes->plan->id;
}
break;
}
$this->setInputParametersHasRun = TRUE;
}
/**
* Get a parameter from the Stripe data object
*
* @param string $name
* @param string $type
* @param bool $abort
*
* @return int|mixed|null
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function retrieve($name, $type, $abort = TRUE) {
$value = CRM_Stripe_Api::getObjectParam($name, $this->getData()->object);
$value = CRM_Utils_Type::validate($value, $type, FALSE);
if ($abort && $value === NULL) {
echo "Failure: Missing Parameter<p>" . CRM_Utils_Type::escape($name, 'String');
$this->exception("Could not find an entry for $name");
echo "Failure: Missing or invalid parameter " . CRM_Utils_Type::escape($name, 'String');
$this->exception("Missing or invalid parameter {$name}");
}
return $value;
}
/**
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* Get a unique identifier string based on webhook data.
*
* @return string
*/
public function main() {
// Collect and determine all data about this event.
$this->event_type = $this->retrieve('event_type', 'String');
$pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
switch($this->event_type) {
// Successful recurring payment.
case 'invoice.payment_succeeded':
$this->setInfo();
if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
$this->completeContribution();
}
elseif ($this->previous_contribution['trxn_id'] != $this->charge_id) {
// The first contribution was completed, so create a new one.
// api contribution repeattransaction repeats the appropriate contribution if it is given
// simply the recurring contribution id. It also updates the membership for us.
civicrm_api3('Contribution', 'repeattransaction', array(
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_status_id' => 'Completed',
'receive_date' => $this->receive_date,
'trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'is_email_receipt' => 0,
));
}
// Successful charge & more to come.
civicrm_api3('ContributionRecur', 'create', array(
'id' => $this->contribution_recur_id,
'failure_count' => 0,
'contribution_status_id' => 'In Progress'
));
return TRUE;
// Failed recurring payment.
case 'invoice.payment_failed':
$this->setInfo();
$failDate = date('YmdHis');
if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
// If this contribution is Pending, set it to Failed.
civicrm_api3('Contribution', 'create', array(
'id' => $this->previous_contribution['id'],
'contribution_status_id' => "Failed",
'receive_date' => $failDate,
'is_email_receipt' => 0,
));
}
else {
$contributionParams = [
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_status_id' => 'Failed',
'receive_date' => $failDate,
'total_amount' => $this->amount,
'is_email_receipt' => 0,
];
civicrm_api3('Contribution', 'repeattransaction', $contributionParams);
}
private function getWebhookUniqueIdentifier() {
return "{$this->payment_intent_id}:{$this->charge_id}:{$this->invoice_id}:{$this->subscription_id}";
}
$failureCount = civicrm_api3('ContributionRecur', 'getvalue', array(
'id' => $this->contribution_recur_id,
'return' => 'failure_count',
));
$failureCount++;
// Change the status of the Recurring and update failed attempts.
civicrm_api3('ContributionRecur', 'create', array(
'id' => $this->contribution_recur_id,
'contribution_status_id' => "Failed",
'failure_count' => $failureCount,
'modified_date' => $failDate,
));
return TRUE;
/**
* When CiviCRM receives a Stripe webhook call this method (via handlePaymentNotification()).
* This checks the webhook and either queues or triggers processing (depending on existing webhooks in queue)
*
* Set default to "process immediately". This will get changed to FALSE if we already
* have a pending webhook in the queue or the webhook is flagged for delayed processing.
* @param bool $processWebhook
*
* @return bool
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public function onReceiveWebhook($processWebhook = TRUE): bool {
if (!in_array($this->eventType, CRM_Stripe_Webhook::getDefaultEnabledEvents())) {
// We don't handle this event, return 200 OK so Stripe does not retry.
return TRUE;
}
// Subscription is cancelled
case 'customer.subscription.deleted':
$this->setInfo();
// Cancel the recurring contribution
civicrm_api3('ContributionRecur', 'cancel', array(
'id' => $this->contribution_recur_id,
));
return TRUE;
$test = $this->getPaymentProcessor()->getPaymentProcessor()['is_test'] ? '(Test)' : '(Live)';
$name = $this->getPaymentProcessor()->getPaymentProcessor()['name'];
// Special case if this is the test webhook
if (substr($this->getEventID(), -15, 15) === '_00000000000000') {
echo "Test webhook from Stripe ({$this->getEventID()}) received successfully by CiviCRM: {$name} {$test}.";
exit();
}
// One-time donation and per invoice payment.
case 'charge.failed':
$chargeId = $this->retrieve('charge_id', 'String');
$failureCode = $this->retrieve('failure_code', 'String');
$failureMessage = $this->retrieve('failure_message', 'String');
$contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $chargeId]);
$failedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');
if ($contribution['contribution_status_id'] != $failedStatusId) {
$note = $failureCode . ' : ' . $failureMessage;
civicrm_api3('Contribution', 'create', ['id' => $contribution['id'], 'contribution_status_id' => $failedStatusId, 'note' => $note]);
// Check, decode, validate webhook data and extract some parameters to the class
$this->setInputParameters();
// If we have both Stripe (elements) and Stripe Checkout setup it is quite likely that
// we have two payment processors with the same private/public key and we'll receive "duplicate" webhooks.
// So if we have a Stripe Customer ID with the event check that it matches our payment processor ID as recorded
// in the civicrm_stripe_customers table.
if (!empty($this->getStripeCustomerID())) {
$stripeCustomers = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('customer_id', '=', $this->getStripeCustomerID())
->execute();
$eventForThisPaymentProcessor = FALSE;
foreach ($stripeCustomers as $stripeCustomer) {
if ($stripeCustomer['processor_id'] === $this->getPaymentProcessor()->getID()) {
// We have a customer in the database for this processor - continue processing
$eventForThisPaymentProcessor = TRUE;
break;
}
return TRUE;
}
if (!$eventForThisPaymentProcessor) {
echo "Event ({$this->getEventID()}) is not for this payment processor - ignoring. CiviCRM: {$name} {$test}.";
exit();
}
}
case 'charge.refunded':
$chargeId = $this->retrieve('charge_id', 'String');
$refunded = $this->retrieve('refunded', 'Boolean');
$refundAmount = $this->retrieve('amount_refunded', 'Integer');
$contribution = civicrm_api3('Contribution', 'getsingle', ['trxn_id' => $chargeId]);
if ($refunded) {
$refundedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Refunded');
if ($contribution['contribution_status_id'] != $refundedStatusId) {
civicrm_api3('Contribution', 'create', [
'id' => $contribution['id'],
'contribution_status_id' => $refundedStatusId
]);
}
elseif ($refundAmount > 0) {
$partiallyRefundedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Partially Refunded');
if ($contribution['contribution_status_id'] != $partiallyRefundedStatusId) {
civicrm_api3('Contribution', 'create', [
'id' => $contribution['id'],
'contribution_status_id' => $refundedStatusId
]);
}
}
// Get a "unique" identifier for this webhook that allows us to match "duplicate/similar" webhooks.
$uniqueIdentifier = $this->getWebhookUniqueIdentifier();
// Get all received webhooks with matching identifier which have not been processed
// This returns all webhooks that match the uniqueIdentifier above and have not been processed.
// For example this would match both invoice.finalized and invoice.payment_succeeded events which must be
// processed sequentially and not simultaneously.
$paymentProcessorWebhooks = PaymentprocessorWebhook::get(FALSE)
->addWhere('payment_processor_id', '=', $this->getPaymentProcessor()->getID())
->addWhere('identifier', '=', $uniqueIdentifier)
->addWhere('processed_date', 'IS NULL')
->execute();
if (empty($paymentProcessorWebhooks->rowCount)) {
// We have not received this webhook before.
// Some webhooks we always add to the queue and do not process immediately (eg. invoice.finalized)
if (in_array($this->eventType, CRM_Stripe_Webhook::getDelayProcessingEvents())) {
// Never process the webhook immediately.
$processWebhook = FALSE;
}
}
else {
// We already have one or more webhooks with matching identifier
foreach ($paymentProcessorWebhooks as $paymentProcessorWebhook) {
// Does the eventType match our webhook?
if ($paymentProcessorWebhook['trigger'] === $this->eventType) {
// We have already recorded a webhook with a matching event type and it is awaiting processing.
// Exit
return TRUE;
}
return TRUE;
case 'charge.succeeded':
$this->setInfo();
if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) {
$this->completeContribution();
if (!in_array($paymentProcessorWebhook['trigger'], CRM_Stripe_Webhook::getDelayProcessingEvents())) {
// There is a webhook that is already in the queue not flagged for delayed processing.
// So we cannot process the current webhook immediately and must add it to the queue instead.
$processWebhook = FALSE;
}
return TRUE;
}
// We have recorded another webhook with matching identifier but different eventType.
// There is already a recorded webhook with matching identifier that has not yet been processed.
// So we will record this webhook but will not process now (it will be processed later by the scheduled job).
}
case 'customer.subscription.updated':
$this->setInfo();
if (empty($this->previous_plan_id)) {
// Not a plan change...don't care.
return TRUE;
}
civicrm_api3('ContributionRecur', 'create', [
'id' => $this->contribution_recur_id,
'amount' => $this->plan_amount,
'auto_renew' => 1,
'created_date' => $this->plan_start,
'frequency_unit' => $this->frequency_unit,
'frequency_interval' => $this->frequency_interval,
]);
civicrm_api3('Contribution', 'create', [
'id' => $this->previous_contribution['id'],
'total_amount' => $this->plan_amount,
'contribution_recur_id' => $this->contribution_recur_id,
]);
$newWebhookEvent = PaymentprocessorWebhook::create(FALSE)
->addValue('payment_processor_id', $this->getPaymentProcessor()->getID())
->addValue('trigger', $this->getEventType())
->addValue('identifier', $uniqueIdentifier)
->addValue('event_id', $this->getEventID())
->addValue('data', $this->getData())
->execute()
->first();
// Check the number of webhooks to be processed does not exceed connection-limit
$toBeProcessedWebhook = PaymentprocessorWebhook::get(FALSE)
->addWhere('payment_processor_id', '=', $this->getPaymentProcessor()->getID())
->addWhere('processed_date', 'IS NULL')
->execute();
// Limit on webhooks that will be processed immediately. Otherwise we delay execution.
$webhookProcessingLimit = (int)\Civi::settings()->get('stripe_webhook_processing_limit');
if (!$processWebhook || ($toBeProcessedWebhook->rowCount > $webhookProcessingLimit)) {
return TRUE;
}
// Unhandled event type.
return TRUE;
return $this->processQueuedWebhookEvent($newWebhookEvent);
}
/**
* Complete a pending contribution and update associated entities (recur/membership)
* Process a single queued event and update it.
*
* @param array $webhookEvent
*
* @throws \CiviCRM_API3_Exception
* @return bool TRUE on success.
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function completeContribution() {
// Update the contribution to include the fee.
civicrm_api3('Contribution', 'create', array(
'id' => $this->previous_contribution['id'],
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'net_amount' => $this->net_amount,
));
// The last one was not completed, so complete it.
civicrm_api3('Contribution', 'completetransaction', array(
'id' => $this->previous_contribution['id'],
'trxn_date' => $this->receive_date,
'trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'net_amount' => $this->net_amount,
'fee_amount' => $this->fee,
'payment_processor_id' => $this->_paymentProcessor['id'],
'is_email_receipt' => 0,
));
public function processQueuedWebhookEvent(array $webhookEvent) :bool {
$this->setEventID($webhookEvent['event_id']);
if (!$this->setEventType($webhookEvent['trigger'])) {
// We don't handle this event
return FALSE;
}
$this->setExceptionMode(FALSE);
$processingResult = $this->processWebhookEvent();
// Update the stored webhook event.
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', '=', $webhookEvent['id'])
->addValue('status', $processingResult->ok ? 'success' : 'error')
->addValue('message', preg_replace('/^(.{250}).*/su', '$1 ...', $processingResult->message))
->addValue('processed_date', 'now')
->execute();
return $processingResult->ok;
}
/**
* Gather and set info as class properties.
*
* Given the data passed to us via the Stripe Event, try to determine
* as much as we can about this event and set that information as
* properties to be used later.
/**
* Process the given webhook
*
* @throws \CRM_Core_Exception
* @return stdClass
* @throws \API_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function setInfo() {
$abort = FALSE;
$this->customer_id = $this->retrieve('customer_id', 'String');
$this->subscription_id = $this->retrieve('subscription_id', 'String', $abort);
$this->invoice_id = $this->retrieve('invoice_id', 'String', $abort);
$this->receive_date = $this->retrieve('receive_date', 'String', $abort);
$this->charge_id = $this->retrieve('charge_id', 'String', $abort);
$this->plan_id = $this->retrieve('plan_id', 'String', $abort);
$this->previous_plan_id = $this->retrieve('previous_plan_id', 'String', $abort);
$this->plan_amount = $this->retrieve('plan_amount', 'String', $abort);
$this->frequency_interval = $this->retrieve('frequency_interval', 'String', $abort);
$this->frequency_unit = $this->retrieve('frequency_unit', 'String', $abort);
$this->plan_name = $this->retrieve('plan_name', 'String', $abort);
$this->plan_start = $this->retrieve('plan_start', 'String', $abort);
// Gather info about the amount and fee.
// Get the Stripe charge object if one exists. Null charge still needs processing.
if ($this->charge_id !== null) {
try {
$charge = \Stripe\Charge::retrieve($this->charge_id);
$balance_transaction_id = $charge->balance_transaction;
// If the transaction is declined, there won't be a balance_transaction_id.
if ($balance_transaction_id) {
$balance_transaction = \Stripe\BalanceTransaction::retrieve($balance_transaction_id);
$this->amount = $charge->amount / 100;
$this->fee = $balance_transaction->fee / 100;
public function processWebhookEvent() :stdClass {
$return = (object) ['message' => '', 'ok' => FALSE, 'exception' => NULL];
try {
$this->setInputParameters();
$webhookEventProcessor = new \Civi\Stripe\Webhook\Events($this->getPaymentProcessor()->getID());
$webhookEventProcessor->setEventType($this->getEventType());
$webhookEventProcessor->setEventID($this->getEventID());
$webhookEventProcessor->setData($this->getData());
switch ($this->eventType) {
case 'checkout.session.completed':
$return = $webhookEventProcessor->doCheckoutSessionCompleted();
break;
case 'charge.succeeded':
case 'charge.captured':
$return = $webhookEventProcessor->doChargeSucceeded();
break;
case 'charge.refunded':
$return = $webhookEventProcessor->doChargeRefunded();
break;
case 'charge.failed':
$return = $webhookEventProcessor->doChargeFailed();
break;
case 'invoice.payment_failed':
$return = $webhookEventProcessor->doInvoicePaymentFailed();
break;
case 'invoice.finalized':
$return = $webhookEventProcessor->doInvoiceFinalized();
break;
case 'invoice.paid':
case 'invoice.payment_succeeded':
$return = $webhookEventProcessor->doInvoicePaid();
break;
case 'customer.subscription.updated':
$return = $webhookEventProcessor->doCustomerSubscriptionUpdated();
break;
case 'customer.subscription.deleted':
$return = $webhookEventProcessor->doCustomerSubscriptionDeleted();
break;
default:
$return->message = $this->eventType . ' - not implemented';
$return->ok = TRUE;
}
}
catch (Exception $e) {
if ($this->exceptionOnFailure) {
// Re-throw a modified exception. (Special case for phpunit testing).
$return->message = get_class($e) . ": " . $e->getMessage();
throw new PaymentProcessorException($return->message, $e->getCode());
}
else {
// Normal use.
$return->ok = FALSE;
if (($e instanceof \Stripe\Exception\InvalidRequestException) && ($e->getHttpStatus() === 404)) {
/** @var \Stripe\Exception\InvalidRequestException $e */
// Probably "is no longer available because it's aged out of our retention policy"
// We don't need a backtrace
$return->message = $e->getMessage();
}
else {
$this->amount = 0;
$this->fee = 0;
$return->message = $e->getMessage() . "\n" . $e->getTraceAsString();
}
$return->exception = $e;
\Civi::log('stripe')->error("StripeIPN: processWebhookEvent failed. EventID: {$this->eventID} : " . $return->message);
}
catch(Exception $e) {
$this->exception('Cannot get contribution amounts');
}
} else {
// The customer had a credit on their subscription from a downgrade or gift card.
$this->amount = 0;
$this->fee = 0;
}
$this->net_amount = $this->amount - $this->fee;
// Additional processing of values is only relevant if there is a subscription id.
if ($this->subscription_id) {
// Get info related to recurring contributions.
try {
$contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $this->subscription_id]);
$this->contribution_recur_id = $contributionRecur['id'];
// Same approach as api repeattransaction. Find last contribution associated
// with our recurring contribution.
$contribution = civicrm_api3('contribution', 'getsingle', array(
'return' => array('id', 'contribution_status_id', 'total_amount', 'trxn_id'),
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_test' => $this->_paymentProcessor['is_test'],
'options' => array('limit' => 1, 'sort' => 'id DESC'),
));
$this->previous_contribution = $contribution;
}
catch (Exception $e) {
$this->exception('Cannot find recurring contribution for subscription ID: ' . $this->subscription_id . '. ' . $e->getMessage());
}
}
$this->setEventID('');
return $return;
}
public function exception($message) {
$errorMessage = 'StripeIPN Exception: Event: ' . $this->event_type . ' Error: ' . $message;
Civi::log()->debug($errorMessage);
http_response_code(400);
exit(1);
}
}
<?php
/**
* Shared payment IPN functions that should one day be migrated to CiviCRM core
* Version: 20190304
*/
trait CRM_Core_Payment_StripeIPNTrait {
/**
* @var array Payment processor
*/
private $_paymentProcessor;
/**
* Get the payment processor
* The $_GET['processor_id'] value is set by CRM_Core_Payment::handlePaymentMethod.
*/
protected function getPaymentProcessor() {
$paymentProcessorId = (int) CRM_Utils_Array::value('processor_id', $_GET);
if (empty($paymentProcessorId)) {
$this->exception('Failed to get payment processor id');
}
try {
$this->_paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorId)->getPaymentProcessor();
}
catch(Exception $e) {
$this->exception('Failed to get payment processor');
}
}
/**
* Mark a contribution as cancelled and update related entities
*
* @param array $params [ 'id' -> contribution_id, 'payment_processor_id' -> payment_processor_id]
*
* @return bool
* @throws \CiviCRM_API3_Exception
*/
protected function canceltransaction($params) {
return $this->incompletetransaction($params, 'cancel');
}
/**
* Mark a contribution as failed and update related entities
*
* @param array $params [ 'id' -> contribution_id, 'payment_processor_id' -> payment_processor_id]
*
* @return bool
* @throws \CiviCRM_API3_Exception
*/
protected function failtransaction($params) {
return $this->incompletetransaction($params, 'fail');
}
/**
* Handler for failtransaction and canceltransaction - do not call directly
*
* @param array $params
* @param string $mode
*
* @return bool
* @throws \CiviCRM_API3_Exception
*/
protected function incompletetransaction($params, $mode) {
$requiredParams = ['id', 'payment_processor_id'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$this->exception('canceltransaction: Missing mandatory parameter: ' . $required);
}
}
if (isset($params['payment_processor_id'])) {
$input['payment_processor_id'] = $params['payment_processor_id'];
}
$contribution = new CRM_Contribute_BAO_Contribution();
$contribution->id = $params['id'];
if (!$contribution->find(TRUE)) {
throw new CiviCRM_API3_Exception('A valid contribution ID is required', 'invalid_data');
}
if (!$contribution->loadRelatedObjects($input, $ids, TRUE)) {
throw new CiviCRM_API3_Exception('failed to load related objects');
}
$input['trxn_id'] = !empty($params['trxn_id']) ? $params['trxn_id'] : $contribution->trxn_id;
if (!empty($params['fee_amount'])) {
$input['fee_amount'] = $params['fee_amount'];
}
$objects['contribution'] = &$contribution;
$objects = array_merge($objects, $contribution->_relatedObjects);
$transaction = new CRM_Core_Transaction();
switch ($mode) {
case 'cancel':
return $this->cancelled($objects, $transaction);
case 'fail':
return $this->failed($objects, $transaction);
default:
throw new CiviCRM_API3_Exception('Unknown incomplete transaction type: ' . $mode);
}
}
}
<?php
/**
* Shared payment functions that should one day be migrated to CiviCRM core
* Version 1.0
*/
trait CRM_Core_Payment_StripeTrait {
/**
* Get the billing email address
*
* @param array $params
* @param int $contactId
*
* @return string|NULL
*/
protected function getBillingEmail($params, $contactId) {
$billingLocationId = CRM_Core_BAO_LocationType::getBilling();
$emailAddress = CRM_Utils_Array::value("email-{$billingLocationId}", $params,
CRM_Utils_Array::value('email-Primary', $params,
CRM_Utils_Array::value('email', $params, NULL)));
if (empty($emailAddress) && !empty($contactId)) {
// Try and retrieve an email address from Contact ID
try {
$emailAddress = civicrm_api3('Email', 'getvalue', array(
'contact_id' => $contactId,
'return' => ['email'],
));
}
catch (CiviCRM_API3_Exception $e) {
return NULL;
}
}
return $emailAddress;
}
/**
* Get the contact id
*
* @param array $params
*
* @return int ContactID
*/
protected function getContactId($params) {
$contactId = 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
))));
if (!empty($contactId)) {
return $contactId;
}
// FIXME: Ref: https://lab.civicrm.org/extensions/stripe/issues/16
// The problem is that when registering for a paid event, civicrm does not pass in the
// contact id to the payment processor (civicrm version 5.3). So, I had to patch your
// getContactId to check the session for a contact id. It's a hack and probably should be fixed in core.
// The code below is exactly what CiviEvent does, but does not pass it through to the next function.
$session = CRM_Core_Session::singleton();
return $session->get('transaction.userID', NULL);
}
/**
* Get the contribution ID
*
* @param $params
*
* @return mixed
*/
protected function getContributionId($params) {
return $params['contributionID'];
}
/**
* Get the recurring contribution ID from parameters passed in to cancelSubscription
* Historical the data passed to cancelSubscription is pretty poor and doesn't include much!
*
* @param array $params
*
* @return int|null
*/
protected function getRecurringContributionId($params) {
// Not yet passed, but could be added via core PR
$contributionRecurId = CRM_Utils_Array::value('contribution_recur_id', $params);
if (!empty($contributionRecurId)) {
return $contributionRecurId;
}
// Not yet passed, but could be added via core PR
$contributionId = CRM_Utils_Array::value('contribution_id', $params);
try {
return civicrm_api3('Contribution', 'getvalue', ['id' => $contributionId, 'return' => 'contribution_recur_id']);
}
catch (Exception $e) {
$subscriptionId = CRM_Utils_Array::value('subscriptionId', $params);
if (!empty($subscriptionId)) {
try {
return civicrm_api3('ContributionRecur', 'getvalue', ['processor_id' => $subscriptionId, 'return' => 'id']);
}
catch (Exception $e) {
return NULL;
}
}
return NULL;
}
}
/**
*
* @param array $params ['name' => payment instrument name]
*
* @return int|null
* @throws \CiviCRM_API3_Exception
*/
public static function createPaymentInstrument($params) {
$mandatoryParams = ['name'];
foreach ($mandatoryParams as $value) {
if (empty($params[$value])) {
Civi::log()->error('createPaymentInstrument: Missing mandatory parameter: ' . $value);
return NULL;
}
}
// Create a Payment Instrument
// See if we already have this type
$paymentInstrument = civicrm_api3('OptionValue', 'get', array(
'option_group_id' => "payment_instrument",
'name' => $params['name'],
));
if (empty($paymentInstrument['count'])) {
// Otherwise create it
try {
$financialAccount = civicrm_api3('FinancialAccount', 'getsingle', [
'financial_account_type_id' => "Asset",
'name' => "Payment Processor Account",
]);
}
catch (Exception $e) {
$financialAccount = civicrm_api3('FinancialAccount', 'getsingle', [
'financial_account_type_id' => "Asset",
'name' => "Payment Processor Account",
'options' => ['limit' => 1, 'sort' => "id ASC"],
]);
}
$paymentParams = [
'option_group_id' => "payment_instrument",
'name' => $params['name'],
'description' => $params['name'],
'financial_account_id' => $financialAccount['id'],
];
$paymentInstrument = civicrm_api3('OptionValue', 'create', $paymentParams);
$paymentInstrumentId = $paymentInstrument['values'][$paymentInstrument['id']]['value'];
}
else {
$paymentInstrumentId = $paymentInstrument['id'];
}
return $paymentInstrumentId;
}
}
<?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 {
/**
......@@ -11,99 +28,95 @@ class CRM_Stripe_Customer {
* @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 \Civi\Payment\Exception\PaymentProcessorException('Stripe Customer (find): Missing required parameter: ' . $required);
if (empty($params[$required])) {
throw new PaymentProcessorException('Stripe Customer (find): Missing required parameter: ' . $required);
}
}
if (empty($params['contact_id'])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe Customer (find): contact_id is required');
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 \Civi\Payment\Exception\PaymentProcessorException
* @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 \Civi\Payment\Exception\PaymentProcessorException('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];
}
/**
* @param $params
* @param $paymentProcessor
* 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\ApiResource
* @throws \CiviCRM_API3_Exception
* @return \Stripe\Customer|\PropertySpy
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function create($params, $paymentProcessor) {
$requiredParams = ['contact_id', 'card_token', 'is_live', 'processor_id'];
// $optionalParams = ['email'];
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 \Civi\Payment\Exception\PaymentProcessorException('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'],
]);
$stripeCustomerParams = [
'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'] ?? []);
try {
$stripeCustomer = \Stripe\Customer::create($stripeCustomerParams);
$stripeCustomerObject = $stripe->stripeClient->customers->create($stripeCustomerParams);
}
catch (Exception $e) {
$err = CRM_Core_Payment_Stripe::parseStripeException('create_customer', $e, FALSE);
$errorMessage = CRM_Core_Payment_Stripe::handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Customer: ' . $errorMessage);
$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.
$params = [
'contact_id' => $params['contact_id'],
'customer_id' => $stripeCustomer->id,
'is_live' => $params['is_live'],
'processor_id' => $params['processor_id'],
];
self::add($params);
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();
return $stripeCustomerObject;
}
/**
......@@ -113,22 +126,27 @@ class CRM_Stripe_Customer {
*
* @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 \Civi\Payment\Exception\PaymentProcessorException('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');
}
$delete = StripeCustomer::delete(FALSE)
->addWhere('processor_id', '=', $params['processor_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);
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;
}
}
<?php
/*--------------------------------------------------------------------+
| CiviCRM version 5.0 |
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC (c) 2004-2017 |
+--------------------------------------------------------------------+
| This file is a part of CiviCRM. |
| |
| CiviCRM is free software; you can copy, modify, and distribute it |
| under the terms of the GNU Affero General Public License |
| Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
| |
| CiviCRM is distributed in the hope that it will be useful, but |
| WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
| See the GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public |
| License and the CiviCRM Licensing Exception along |
| with this program; if not, contact CiviCRM LLC |
| at info[AT]civicrm[DOT]org. If you have questions about the |
| GNU Affero General Public License or the licensing of CiviCRM, |
| see the CiviCRM license FAQ at http://civicrm.org/licensing |
+-------------------------------------------------------------------*/
/**
* This class implements hooks for Stripe
*/
class CRM_Stripe_Hook {
/**
* This hook allows modifying recurring contribution parameters
*
* @param array $recurContributionParams Recurring contribution params (ContributionRecur.create API parameters)
*
* @return mixed
*/
public static function updateRecurringContribution(&$recurContributionParams) {
return CRM_Utils_Hook::singleton()
->invoke(1, $recurContributionParams, CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject,
CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject, 'civicrm_stripe_updateRecurringContribution');
}
}
<?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\Payment\PropertyBag;
use CRM_Stripe_ExtensionUtil as E;
/**
* Manage the civicrm_stripe_paymentintent database table which records all created paymentintents
* Class CRM_Stripe_PaymentIntent
*/
class CRM_Stripe_PaymentIntent {
/**
* @var CRM_Core_Payment_Stripe
*/
protected $paymentProcessor;
/**
* @var string
*/
protected $description = '';
/**
* @var string
*/
protected $referrer = '';
/**
* @var string
*/
protected $extraData = '';
/**
* @param \CRM_Core_Payment_Stripe $paymentProcessor
*/
public function __construct(\CRM_Core_Payment_Stripe $paymentProcessor) {
$this->paymentProcessor = $paymentProcessor;
}
/**
* @param string $description
*
* @return void
*/
public function setDescription($description) {
$this->description = $description;
}
/**
* @param string $referrer
*
* @return void
*/
public function setReferrer($referrer) {
$this->referrer = $referrer;
}
/**
* @param string $extraData
*
* @return void
*/
public function setExtraData(string $extraData) {
$this->extraData = $extraData;
}
/**
* Add a paymentIntent to the database
*
* @param $params
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function add($params) {
$requiredParams = ['id', 'payment_processor_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (add): Missing required parameter: ' . $required);
}
}
$count = 0;
foreach ($params as $key => $value) {
switch ($key) {
case 'id':
case 'description':
case 'status':
case 'identifier':
$queryParams[] = [$value, 'String'];
break;
case 'payment_processor_id':
$queryParams[] = [$value, 'Integer'];
break;
case 'contribution_id':
if (empty($value)) {
continue 2;
}
$queryParams[] = [$value, 'Integer'];
break;
}
$keys[] = $key;
$update[] = "{$key} = '{$value}'";
$values[] = "%{$count}";
$count++;
}
$query = "INSERT INTO civicrm_stripe_paymentintent
(" . implode(',', $keys) . ") VALUES (" . implode(',', $values) . ")";
$query .= " ON DUPLICATE KEY UPDATE " . implode(',', $update);
CRM_Core_DAO::executeQuery($query, $queryParams);
}
/**
* @param array $params
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function create($params) {
self::add($params);
}
/**
* Delete a Stripe paymentintent from the CiviCRM database
*
* @param array $params
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function delete($params) {
$requiredParams = ['id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (delete): Missing required parameter: ' . $required);
}
}
$queryParams = [
1 => [$params['id'], 'String'],
];
$sql = "DELETE FROM civicrm_stripe_paymentintent WHERE id = %1";
CRM_Core_DAO::executeQuery($sql, $queryParams);
}
/**
* @param array $params
* @param \CRM_Core_Payment_Stripe $stripe
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*
* @deprecated not used anywhere?
*/
public static function stripeCancel($params, $stripe) {
$requiredParams = ['id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (getFromStripe): Missing required parameter: ' . $required);
}
}
$intent = $stripe->stripeClient->paymentIntents->retrieve($params['id']);
$intent->cancel();
}
/**
* @param array $params
* @param \CRM_Core_Payment_Stripe $stripe
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*
* @deprecated not used anywhere?
*/
public static function stripeGet($params, $stripe) {
$requiredParams = ['id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (getFromStripe): Missing required parameter: ' . $required);
}
}
$intent = $stripe->stripeClient->paymentIntents->retrieve($params['id']);
$params['status'] = $intent->status;
self::add($params);
}
/**
* Get an existing Stripe paymentIntent from the CiviCRM database
*
* @param $params
*
* @return array
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function get($params) {
$requiredParams = ['id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (get): Missing required parameter: ' . $required);
}
}
if (empty($params['contact_id'])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe PaymentIntent (get): contact_id is required');
}
$queryParams = [
1 => [$params['id'], 'String'],
];
$dao = CRM_Core_DAO::executeQuery("SELECT *
FROM civicrm_stripe_paymentintent
WHERE id = %1", $queryParams);
return $dao->toArray();
}
/**
* @param $params
*
* @return object
*/
public function processSetupIntent(array $params) {
/*
$params = [
// Optional paymentMethodID
'paymentMethodID' => 'pm_xx',
'customer => 'cus_xx',
];
*/
$resultObject = (object) ['ok' => FALSE, 'message' => '', 'data' => []];
$intentParams['confirm'] = TRUE;
if (!empty($this->description)) {
$intentParams['description'] = $this->description;
}
$intentParams['payment_method_types'] = ['card'];
if (!empty($params['paymentMethodID'])) {
$intentParams['payment_method'] = $params['paymentMethodID'];
}
if (!empty($params['customer'])) {
$intentParams['customer'] = $params['customer'];
}
$intentParams['usage'] = 'off_session';
// Get the client IP address
$ipAddress = (class_exists('\Civi\Firewall\Firewall')) ? (new \Civi\Firewall\Firewall())->getIPAddress() : $ipAddress = CRM_Utils_System::ipAddress();
try {
$intent = $this->paymentProcessor->stripeClient->setupIntents->create($intentParams);
} catch (Exception $e) {
// Save the "error" in the paymentIntent table in case investigation is required.
$stripePaymentintentParams = [
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => 'failed',
'description' => "{$e->getRequestId()};{$e->getMessage()};{$this->description}",
'referrer' => $this->referrer,
];
$extraData = (!empty($this->extraData)) ? explode(';', $this->extraData) : [];
$extraData[] = $ipAddress;
$extraData[] = $e->getMessage();
$stripePaymentintentParams['extra_data'] = implode(';', $extraData);
CRM_Stripe_BAO_StripePaymentintent::create($stripePaymentintentParams);
$resultObject->ok = FALSE;
$resultObject->message = $e->getMessage();
return $resultObject;
}
// Save the generated setupIntent in the CiviCRM database for later tracking
$stripePaymentintentParams = [
'stripe_intent_id' => $intent->id,
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => $intent->status,
'description' => $this->description,
'referrer' => $this->referrer,
];
$extraData = (!empty($this->extraData)) ? explode(';', $this->extraData) : [];
$extraData[] = $ipAddress;
$stripePaymentintentParams['extra_data'] = implode(';', $extraData);
CRM_Stripe_BAO_StripePaymentintent::create($stripePaymentintentParams);
switch ($intent->status) {
case 'requires_payment_method':
case 'requires_confirmation':
case 'requires_action':
case 'processing':
case 'canceled':
case 'succeeded':
$resultObject->ok = TRUE;
$resultObject->data = [
'status' => $intent->status,
'next_action' => $intent->next_action,
'client_secret' => $intent->client_secret,
];
break;
}
// Invalid status
if (isset($intent->last_setup_error)) {
if (isset($intent->last_payment_error->message)) {
$message = E::ts('Payment failed: %1', [1 => $intent->last_payment_error->message]);
}
else {
$message = E::ts('Payment failed.');
}
$resultObject->ok = FALSE;
$resultObject->message = $message;
}
return $resultObject;
}
/**
* Handle the processing of a Stripe "intent" from an endpoint eg. API3/API4.
* This function does not implement any "security" checks - it is expected that
* the calling code will do necessary security/permissions checks.
* WARNING: This function is NOT supported outside of Stripe extension and may change without notice.
*
* @param array $params
*
* @return object
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\ApiErrorException
*/
public function processIntent(array $params) {
// Params that may or may not be set by calling code:
// 'currency' should really be set but we'll default if not set.
$currency = \CRM_Utils_Type::validate($params['currency'], 'String', \CRM_Core_Config::singleton()->defaultCurrency);
// If a payment using MOTO (mail order telephone order) was requested.
// This parameter has security implications and great care should be taken when setting it to TRUE.
$params['moto'] = $params['moto'] ?? FALSE;
if ($this->paymentProcessor->getPaymentProcessor()['class_name'] !== 'Payment_Stripe') {
\Civi::log('stripe')->error(__CLASS__ . " payment processor {$params['paymentProcessorID']} is not Stripe");
return (object) ['ok' => FALSE, 'message' => 'Payment processor is not Stripe', 'data' => []];
}
if ($params['setup']) {
$processSetupIntentParams = [
'paymentMethodID' => $params['paymentMethodID'],
];
$processIntentResult = $this->processSetupIntent($processSetupIntentParams);
return $processIntentResult;
}
else {
$processPaymentIntentParams = [
'paymentIntentID' => $params['intentID'],
'paymentMethodID' => $params['paymentMethodID'],
'capture' => FALSE,
'amount' => $params['amount'],
'currency' => $currency,
'payment_method_options' => $params['payment_method_options'] ?? [],
];
if (!empty($params['moto'])) {
$processPaymentIntentParams['moto'] = TRUE;
}
$processIntentResult = $this->processPaymentIntent($processPaymentIntentParams);
return $processIntentResult;
}
}
/**
* @param array $params
*
* @return object
* @throws \Stripe\Exception\ApiErrorException
*/
public function processPaymentIntent(array $params) {
/*
$params = [
// Either paymentIntentID or paymentMethodID must be set
'paymentIntentID' => 'pi_xx',
'paymentMethodID' => 'pm_xx',
'customer' => 'cus_xx', // required if paymentMethodID is set
'capture' => TRUE/FALSE,
'amount' => '12.05',
'currency' => 'USD',
];
*/
$resultObject = (object) ['ok' => FALSE, 'message' => '', 'data' => []];
if (class_exists('\Civi\Firewall\Event\FraudEvent')) {
if (!empty($this->extraData)) {
// The firewall will block IP addresses when it detects fraud.
// This additionally checks if the same details are being used on a different IP address.
$ipAddress = \Civi\Firewall\Firewall::getIPAddress();
// Where a payment is declined as likely fraud, log it as a more serious exception
$numberOfFailedAttempts = \Civi\Api4\StripePaymentintent::get(FALSE)
->selectRowCount()
->addWhere('extra_data', '=', $this->extraData)
->addWhere('status', '=', 'failed')
->addWhere('created_date', '>', '-2 hours')
->execute()
->count();
if ($numberOfFailedAttempts > 5) {
\Civi\Firewall\Event\FraudEvent::trigger($ipAddress, CRM_Utils_String::ellipsify('StripeProcessPaymentIntent: ' . $this->extraData, 255));
}
}
}
$intentParams = [];
$intentParams['confirm'] = TRUE;
$intentParams['confirmation_method'] = 'manual';
$intentParams['payment_method_types'] = ['card'];
if (!empty($params['paymentIntentID'])) {
try {
// We already have a PaymentIntent, retrieve and attempt to confirm.
$intent = $this->paymentProcessor->stripeClient->paymentIntents->retrieve($params['paymentIntentID']);
if ($intent->status === 'requires_confirmation') {
$intent->confirm();
}
}
catch (Exception $e) {
\Civi::log('stripe')->debug($this->paymentProcessor->getLogPrefix() . get_class($e) . $e->getMessage());
}
}
else {
// We don't yet have a PaymentIntent, create one using the
// Payment Method ID and attempt to confirm it too.
try {
if (!empty($params['moto'])) {
$intentParams['payment_method_options']['card']['moto'] = TRUE;
}
$intentParams['amount'] = $this->paymentProcessor->getAmountFormattedForStripeAPI(PropertyBag::cast(['amount' => $params['amount'], 'currency' => $params['currency']]));
$intentParams['currency'] = $params['currency'];
// authorize the amount but don't take from card yet
$intentParams['capture_method'] = 'manual';
// Setup the card to be saved and used later
$intentParams['setup_future_usage'] = 'off_session';
if (isset($params['paymentMethodID'])) {
$intentParams['payment_method'] = $params['paymentMethodID'];
}
if (isset($params['customer'])) {
$intentParams['customer'] = $params['customer'];
}
$intent = $this->paymentProcessor->stripeClient->paymentIntents->create($intentParams);
}
catch (Exception $e) {
$parsedError = $this->paymentProcessor->parseStripeException('process_paymentintent', $e);
// Save the "error" in the paymentIntent table in case investigation is required.
$stripePaymentintentParams = [
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => 'failed',
'description' => "{$e->getRequestId()};{$e->getMessage()};{$this->description}",
'referrer' => $this->referrer,
];
// Get the client IP address
$ipAddress = (class_exists('\Civi\Firewall\Firewall')) ? (new \Civi\Firewall\Firewall())->getIPAddress() : $ipAddress = CRM_Utils_System::ipAddress();
$extraData = (!empty($this->extraData)) ? explode(';', $this->extraData) : [];
$extraData[] = $ipAddress;
$extraData[] = $e->getMessage();
$stripePaymentintentParams['extra_data'] = implode(';', $extraData);
CRM_Stripe_BAO_StripePaymentintent::create($stripePaymentintentParams);
if ($e instanceof \Stripe\Exception\CardException) {
$fraud = FALSE;
if (method_exists('\Civi\Firewall\Firewall', 'getIPAddress')) {
$ipAddress = \Civi\Firewall\Firewall::getIPAddress();
}
else {
$ipAddress = \CRM_Utils_System::ipAddress();
}
// Where a payment is declined as likely fraud, log it as a more serious exception
if (class_exists('\Civi\Firewall\Event\FraudEvent')) {
// Fraud response from issuer
if ($e->getDeclineCode() === 'fraudulent') {
$fraud = TRUE;
}
// Look for fraud detected by Stripe Radar
else {
$jsonBody = $e->getJsonBody();
if (!empty($jsonBody['error']['payment_intent']['charges']['data'])) {
foreach ($jsonBody['error']['payment_intent']['charges']['data'] as $charge) {
if ($charge['outcome']['type'] === 'blocked') {
$fraud = TRUE;
break;
}
}
}
}
if ($fraud) {
\Civi\Firewall\Event\FraudEvent::trigger($ipAddress, 'CRM_Stripe_PaymentIntent::processPaymentIntent');
}
}
// Multiple declined card attempts is an indicator of card testing
if (!$fraud && class_exists('\Civi\Firewall\Event\DeclinedCardEvent')) {
\Civi\Firewall\Event\DeclinedCardEvent::trigger($ipAddress, 'CRM_Stripe_PaymentIntent::processPaymentIntent');
}
// Returned message should not indicate whether fraud was detected
$message = $parsedError['message'];
}
elseif ($e instanceof \Stripe\Exception\InvalidRequestException) {
$message = $parsedError['message'];
}
$resultObject->ok = FALSE;
$resultObject->message = $message ?? 'Unknown error';
return $resultObject;
}
}
// Save the generated paymentIntent in the CiviCRM database for later tracking
$stripePaymentintentParams = [
'stripe_intent_id' => $intent->id,
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => $intent->status,
'description' => $this->description,
'referrer' => $this->referrer,
];
if (!empty($this->extraData)) {
$stripePaymentintentParams['extra_data'] = $this->extraData;
}
CRM_Stripe_BAO_StripePaymentintent::create($stripePaymentintentParams);
$resultObject->data = [
'requires_payment_method' => false,
'requires_action' => false,
'success' => false,
'paymentIntent' => null,
];
// generatePaymentResponse()
if ($intent->status === 'requires_action' &&
$intent->next_action->type === 'use_stripe_sdk') {
// Tell the client to handle the action
$resultObject->ok = TRUE;
$resultObject->data['requires_action'] = true;
$resultObject->data['paymentIntentClientSecret'] = $intent->client_secret;
$resultObject->data['paymentIntent'] = ['id' => $intent->id];
}
elseif (($intent->status === 'requires_capture') || ($intent->status === 'requires_confirmation')) {
// paymentIntent = requires_capture / requires_confirmation
// The payment intent has been confirmed, we just need to capture the payment
// Handle post-payment fulfillment
$resultObject->ok = TRUE;
$resultObject->data['success'] = true;
$resultObject->data['paymentIntent'] = ['id' => $intent->id];
}
elseif ($intent->status === 'succeeded') {
$resultObject->ok = TRUE;
$resultObject->data['success'] = true;
$resultObject->data['paymentIntent'] = ['id' => $intent->id];
}
elseif ($intent->status === 'requires_payment_method') {
$resultObject->ok = TRUE;
$resultObject->data['requires_payment_method'] = true;
$resultObject->data['paymentIntentClientSecret'] = $intent->client_secret;
}
else {
// Invalid status
if (isset($intent->last_payment_error->message)) {
$message = E::ts('Payment failed: %1', [1 => $intent->last_payment_error->message]);
}
else {
$message = E::ts('Payment failed.');
}
$resultObject->ok = FALSE;
$resultObject->message = $message;
}
return $resultObject;
}
}
<?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_Recur
*/
class CRM_Stripe_Recur {
/**
* Get a list of [dayOfMonth => dayOfMonth] for selecting
* allowed future recur start dates in settings
*
* @return string[]
*/
public static function getRecurStartDays() {
// 0 = Special case - now = as soon as transaction is processed
$days = [0 => 'now'];
for ($i = 1; $i <= 28; $i++) {
// Add days 1 to 28 (29-31 are excluded because don't exist for some months)
$days["$i"] = "$i";
}
return $days;
}
/**
* Get list of future start dates formatted to display to user
*
* @return array|null
*/
public static function getFutureMonthlyStartDates() {
$allowDays = \Civi::settings()->get('stripe_future_recur_start_days');
// Future date options.
$startDates = [];
// If only "now" (default) is specified don't give start date options
if (count($allowDays) === 1 && ((int) $allowDays[0] === 0)) {
return NULL;
}
$todayDay = (int) date('d');
$now = date('YmdHis');
foreach ($allowDays as $dayOfMonth) {
$dayOfMonth = (int) $dayOfMonth;
if (($dayOfMonth === 0) || ($dayOfMonth === $todayDay)) {
// Today or now
$startDates[$now] = E::ts('Now');
}
else {
// Add all days permitted in settings
// We add for this month or next month depending on todays date.
$month = ($dayOfMonth < $todayDay) ? 'next' : 'this';
$date = new DateTime();
$date->setTime(3,0,0)
->modify("first day of {$month} month")
->modify("+" . ($dayOfMonth - 1) . " days");
$startDates[$date->format('YmdHis')] = CRM_Utils_Date::customFormat($date->format('YmdHis'), \Civi::settings()->get('dateformatFull'));
}
}
ksort($startDates);
return $startDates;
}
/**
* Build the form functionality for future recurring start date
*
* @param \CRM_Core_Form|\CRM_Contribute_Form_Contribution_Main $form
* The payment form
* @param \CRM_Core_Payment_Stripe $paymentProcessor
* The payment object
* @param array $jsVars
* (reference) Array of variables to be assigned to CRM.vars.stripe in the javascript domain
*/
public static function buildFormFutureRecurStartDate($form, $paymentProcessor, &$jsVars) {
// We can choose which frequency_intervals to enable future recurring start date for.
// If none are enabled (or the contribution page does not have any that are enabled in Stripe settings)
// then don't load the futurerecur elements on the form.
$enableFutureRecur = FALSE;
$startDateFrequencyIntervals = \Civi::settings()->get('stripe_enable_public_future_recur_start');
if (!empty($form->_values['recur_frequency_unit'])) {
$formFrequencyIntervals = explode(CRM_Core_DAO::VALUE_SEPARATOR, $form->_values['recur_frequency_unit']);
$enableFutureRecur = FALSE;
foreach ($formFrequencyIntervals as $interval) {
if (in_array($interval, $startDateFrequencyIntervals)) {
$enableFutureRecur = TRUE;
break;
}
}
}
elseif (!empty($form->_membershipBlock)) {
if (isset($form->_membershipBlock['auto_renew'])) {
foreach ($form->_membershipBlock['auto_renew'] as $membershipType => $autoRenew) {
if (!empty($autoRenew)) {
$interval = civicrm_api3('MembershipType', 'getvalue', ['id' => $membershipType, 'return' => 'duration_unit']);
if (in_array($interval, $startDateFrequencyIntervals)) {
$enableFutureRecur = TRUE;
}
break;
}
}
}
}
// Add form element and js to select future recurring start date
if ($enableFutureRecur && !$paymentProcessor->isBackOffice() && $paymentProcessor->supportsFutureRecurStartDate()) {
$jsVars['startDateFrequencyIntervals'] = $startDateFrequencyIntervals;
$startDates = CRM_Stripe_Recur::getFutureMonthlyStartDates();
if ($startDates) {
$form->addElement('select', 'receive_date', ts('Start date'), $startDates);
CRM_Core_Region::instance('billing-block')->add([
'template' => 'CRM/Core/Payment/Stripe/BillingBlockRecurringExtra.tpl',
]);
CRM_Core_Region::instance('billing-block')->add([
'scriptUrl' => \Civi::service('asset_builder')->getUrl(
'recurStart.js',
[
'path' => \Civi::resources()
->getPath(E::LONG_NAME, 'js/recur_start.js'),
'mimetype' => 'application/javascript',
]
),
// Load after civicrm_stripe.js (weight 100)
'weight' => 120,
]);
}
}
}
}
<?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;
/**
* Collection of upgrade steps.
* DO NOT USE a naming scheme other than upgrade_N, where N is an integer.
* Naming scheme upgrade_X_Y_Z is offically wrong!
* DO NOT USE a naming scheme other than upgrade_N, where N is an integer.
* Naming scheme upgrade_X_Y_Z is offically wrong!
* https://chat.civicrm.org/civicrm/pl/usx3pfjzjbrhzpewuggu1e6ftw
*/
class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
class CRM_Stripe_Upgrader extends CRM_Extension_Upgrader_Base {
// By convention, functions that look like "function upgrade_NNNN()" are
// upgrade tasks. They are executed in order (like Drupal's hook_update_N).
......@@ -22,7 +34,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
// Add is_live column to civicrm_stripe_plans and civicrm_stripe_customers tables.
$sql = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %1 AND TABLE_NAME = 'civicrm_stripe_customers' AND COLUMN_NAME = 'is_live'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
$live_column_exists = $dao->N == 0 ? FALSE : TRUE;
if (!$live_column_exists) {
$this->ctx->log->info('Applying civicrm_stripe update 1903. Adding is_live to civicrm_stripe_plans and civicrm_stripe_customers tables.');
......@@ -46,7 +58,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$dbName = DB::connect($config->dsn)->_db;
$sql = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %1 AND TABLE_NAME = 'civicrm_stripe_customers' AND COLUMN_NAME = 'processor_id'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
if ($dao->N) {
$this->ctx->log->info('Skipped civicrm_stripe update 5001. Column processor_id already present on our customers, plans and subscriptions tables.');
}
......@@ -56,7 +68,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_plans ADD COLUMN `processor_id` int(10) DEFAULT NULL COMMENT "ID from civicrm_payment_processor"');
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_subscriptions ADD COLUMN `processor_id` int(10) DEFAULT NULL COMMENT "ID from civicrm_payment_processor"');
}
return TRUE;
return TRUE;
}
......@@ -70,28 +82,28 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$config = CRM_Core_Config::singleton();
$dbName = DB::connect($config->dsn)->_db;
$null_count = CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_customers where processor_id IS NULL') +
$null_count = CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_customers where processor_id IS NULL') +
CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_plans where processor_id IS NULL') +
CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_subscriptions where processor_id IS NULL');
if ( $null_count == 0 ) {
if ($null_count == 0) {
$this->ctx->log->info('Skipped civicrm_stripe update 5002. No nulls found in column processor_id in our tables.');
return TRUE;
}
else {
}
else {
try {
// Set processor ID if there's only one.
$processorCount = civicrm_api3('PaymentProcessorType', 'get', array(
$processorCount = civicrm_api3('PaymentProcessorType', 'get', [
'name' => "Stripe",
'api.PaymentProcessor.get' => array('is_test' => 0),
));
'api.PaymentProcessor.get' => ['is_test' => 0],
]);
foreach ($processorCount['values'] as $processorType) {
if (!empty($processorType['api.PaymentProcessor.get']['id'])) {
$stripe_live =$processorType['api.PaymentProcessor.get']['id'];
$stripe_live = $processorType['api.PaymentProcessor.get']['id'];
$stripe_test = $stripe_live + 1;
$p = array(
1 => array($stripe_live, 'Integer'),
2 => array($stripe_test, 'Integer'),
);
$p = [
1 => [$stripe_live, 'Integer'],
2 => [$stripe_test, 'Integer'],
];
CRM_Core_DAO::executeQuery('UPDATE `civicrm_stripe_customers` SET processor_id = %1 where processor_id IS NULL and is_live = 1', $p);
CRM_Core_DAO::executeQuery('UPDATE `civicrm_stripe_customers` SET processor_id = %2 where processor_id IS NULL and is_live = 0', $p);
CRM_Core_DAO::executeQuery('UPDATE `civicrm_stripe_plans` SET processor_id = %1 where processor_id IS NULL and is_live = 1', $p);
......@@ -100,8 +112,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
CRM_Core_DAO::executeQuery('UPDATE `civicrm_stripe_subscriptions` SET processor_id = %2 where processor_id IS NULL and is_live = 0', $p);
}
}
}
catch (CiviCRM_API3_Exception $e) {
} catch (CRM_Core_Exception $e) {
Civi::log()->debug("Cannot find a PaymentProcessorType named Stripe.");
return;
}
......@@ -109,8 +120,8 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
return TRUE;
}
/**
/**
* Add subscription_id column to civicrm_stripe_subscriptions table.
*
* @return TRUE on success
......@@ -121,7 +132,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$dbName = DB::connect($config->dsn)->_db;
$sql = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %1 AND TABLE_NAME = 'civicrm_stripe_subscriptions' AND COLUMN_NAME = 'subscription_id'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
if ($dao->N) {
$this->ctx->log->info('Skipped civicrm_stripe update 5003. Column subscription_id already present in civicrm_stripe_subscriptions table.');
......@@ -131,11 +142,11 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_subscriptions ADD COLUMN `subscription_id` varchar(255) DEFAULT NULL COMMENT "Subscription ID from Stripe" FIRST');
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_subscriptions` ADD UNIQUE KEY(`subscription_id`)');
}
return TRUE;
}
/**
return TRUE;
}
/**
* Populates the subscription_id column in table civicrm_stripe_subscriptions.
*
* @return TRUE on success
......@@ -145,51 +156,42 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$config = CRM_Core_Config::singleton();
$dbName = DB::connect($config->dsn)->_db;
$null_count = CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_subscriptions where subscription_id IS NULL');
if ( $null_count == 0 ) {
$null_count = CRM_Core_DAO::executeQuery('SELECT COUNT(*) FROM civicrm_stripe_subscriptions where subscription_id IS NULL');
if ($null_count == 0) {
$this->ctx->log->info('Skipped civicrm_stripe update 5004. No nulls found in column subscription_id in our civicrm_stripe_subscriptions table.');
}
else {
$customer_infos = CRM_Core_DAO::executeQuery("SELECT customer_id,processor_id
}
else {
$customer_infos = CRM_Core_DAO::executeQuery("SELECT customer_id,processor_id
FROM `civicrm_stripe_subscriptions`;");
while ( $customer_infos->fetch() ) {
while ($customer_infos->fetch()) {
$processor_id = $customer_infos->processor_id;
$customer_id = $customer_infos->customer_id;
try {
$stripe_key = civicrm_api3('PaymentProcessor', 'getvalue', array(
'return' => 'user_name',
'id' => $processor_id,
));
}
catch (Exception $e) {
Civi::log()->debug('Update 5004 failed. Has Stripe been removed as a payment processor?', $out = false);
return;
}
try {
\Stripe\Stripe::setApiKey($stripe_key);
$subscription = \Stripe\Subscription::all(array(
'customer'=> $customer_id,
'limit'=>1,
));
}
catch (Exception $e) {
// Don't quit here. A missing customer in Stipe is OK. They don't exist, so they can't have a subscription.
Civi::log()->debug('Cannot find Stripe API key: ' . $e->getMessage());
}
if (!empty($subscription['data'][0]['id'])) {
$query_params = array(
1 => array($subscription['data'][0]['id'], 'String'),
2 => array($customer_id, 'String'),
);
CRM_Core_DAO::executeQuery('UPDATE civicrm_stripe_subscriptions SET subscription_id = %1 where customer_id = %2;', $query_params);
unset($subscription);
}
try {
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($processor_id);
$subscription = $paymentProcessor->stripeClient->subscriptions->all([
'customer' => $customer_id,
'limit' => 1,
]);
} catch (Exception $e) {
// Don't quit here. A missing customer in Stipe is OK. They don't exist, so they can't have a subscription.
Civi::log()->debug('Cannot find Stripe API key: ' . $e->getMessage());
}
if (!empty($subscription['data'][0]['id'])) {
$query_params = [
1 => [$subscription['data'][0]['id'], 'String'],
2 => [$customer_id, 'String'],
];
CRM_Core_DAO::executeQuery('UPDATE civicrm_stripe_subscriptions SET subscription_id = %1 where customer_id = %2;', $query_params);
unset($subscription);
}
}
}
return TRUE;
return TRUE;
}
/**
/**
* Add contribution_recur_id column to civicrm_stripe_subscriptions table.
*
* @return TRUE on success
......@@ -200,26 +202,26 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$dbName = DB::connect($config->dsn)->_db;
$sql = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %1 AND TABLE_NAME = 'civicrm_stripe_subscriptions' AND COLUMN_NAME = 'contribution_recur_id'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
if ($dao->N) {
$this->ctx->log->info('Skipped civicrm_stripe update 5005. Column contribution_recur_id already present in civicrm_stripe_subscriptions table.');
}
else {
$this->ctx->log->info('Applying civicrm_stripe update 5005. Adding contribution_recur_id to civicrm_stripe_subscriptions table.');
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_subscriptions
ADD COLUMN `contribution_recur_id` int(10) UNSIGNED DEFAULT NULL
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_subscriptions
ADD COLUMN `contribution_recur_id` int(10) UNSIGNED DEFAULT NULL
COMMENT "FK ID from civicrm_contribution_recur" AFTER `customer_id`');
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_subscriptions` ADD INDEX(`contribution_recur_id`);');
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_subscriptions` ADD CONSTRAINT `FK_civicrm_stripe_contribution_recur_id` FOREIGN KEY (`contribution_recur_id`) REFERENCES `civicrm_contribution_recur`(`id`) ON DELETE SET NULL ON UPDATE RESTRICT;');
}
return TRUE;
return TRUE;
}
/**
/**
* Method 1 for populating the contribution_recur_id column in the civicrm_stripe_subscriptions table.
* ( A simple approach if that works if there have never been any susbcription edits in the Stripe UI. )
*
* @return TRUE on success
* @throws Exception
*/
......@@ -228,95 +230,34 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$config = CRM_Core_Config::singleton();
$dbName = DB::connect($config->dsn)->_db;
$subscriptions = CRM_Core_DAO::executeQuery("SELECT invoice_id,is_live
$subscriptions = CRM_Core_DAO::executeQuery("SELECT invoice_id,is_live
FROM `civicrm_stripe_subscriptions`;");
while ( $subscriptions->fetch() ) {
$test_mode = (int)!$subscriptions->is_live;
try {
// Fetch the recurring contribution Id.
$recur_id = civicrm_api3('Contribution', 'getvalue', array(
'sequential' => 1,
'return' => "contribution_recur_id",
'invoice_id' => $subscriptions->invoice_id,
'contribution_test' => $test_mode,
));
}
catch (CiviCRM_API3_Exception $e) {
// Don't quit here. If we can't find the recurring ID for a single customer, make a note in the error log and carry on.
Civi::log()->debug('Recurring contribution search: ' . $e->getMessage());
}
if (!empty($recur_id)) {
$p = array(
1 => array($recur_id, 'Integer'),
2 => array($subscriptions->invoice_id, 'String'),
);
CRM_Core_DAO::executeQuery('UPDATE civicrm_stripe_subscriptions SET contribution_recur_id = %1 WHERE invoice_id = %2;', $p);
}
while ($subscriptions->fetch()) {
$test_mode = (int) !$subscriptions->is_live;
try {
// Fetch the recurring contribution Id.
$recur_id = civicrm_api3('Contribution', 'getvalue', [
'sequential' => 1,
'return' => "contribution_recur_id",
'invoice_id' => $subscriptions->invoice_id,
'contribution_test' => $test_mode,
]);
} catch (CRM_Core_Exception $e) {
// Don't quit here. If we can't find the recurring ID for a single customer, make a note in the error log and carry on.
Civi::log()->debug('Recurring contribution search: ' . $e->getMessage());
}
return TRUE;
}
/**
* Method 2 for populating the contribution_recur_id column in the civicrm_stripe_subscriptions table. Uncomment this and comment 5006.
* ( A more convoluted approach that works if there HAVE been susbcription edits in the Stripe UI. )
* @return TRUE on success. Please let users uncomment this as needed and increment past 5007 for the next upgrade.
* @throws Exception
*/
/*
public function upgrade_5007() {
$config = CRM_Core_Config::singleton();
$dbName = DB::connect($config->dsn)->_db;
$subscriptions = CRM_Core_DAO::executeQuery("SELECT customer_id,is_live,processor_id
FROM `civicrm_stripe_subscriptions`;");
while ( $subscriptions->fetch() ) {
$test_mode = (int)!$subscriptions->is_live;
$p = array(
1 => array($subscriptions->customer_id, 'String'),
2 => array($subscriptions->is_live, 'Integer'),
);
$customer = CRM_Core_DAO::executeQuery("SELECT email
FROM `civicrm_stripe_customers` WHERE id = %1 AND is_live = %2;", $p);
$customer->fetch();
// Try the billing email first, since that's what we send to Stripe.
try {
$contact = civicrm_api3('Email', 'get', array(
'sequential' => 1,
'return' => "contact_id",
'is_billing' => 1,
'email' => $customer->email,
'api.ContributionRecur.get' => array('return' => "id", 'contact_id' => "\$value.contact_id", 'contribution_status_id' => "In Progress"),
));
}
catch (CiviCRM_API3_Exception $e) {
// Uh oh, that didn't work. Try to retrieve the recurring id using the primary email.
$contact = civicrm_api3('Contact', 'get', array(
'sequential' => 1,
'return' => "id",
'email' => $customer->email,
'api.ContributionRecur.get' => array('sequential' => 1, 'return' => "id", 'contact_id' => "\$values.id", 'contribution_status_id' => "In Progress"),
));
}
if (!empty($contact['values'][0]['api.ContributionRecur.get']['values'][0]['id'])) {
$recur_id = $contact['values'][0]['api.ContributionRecur.get']['values'][0]['id'];
$p = array(
1 => array($recur_id, 'Integer'),
2 => array($subscriptions->customer_id, 'String'),
);
CRM_Core_DAO::executeQuery('UPDATE civicrm_stripe_subscriptions SET contribution_recur_id = %1 WHERE customer_id = %2;', $p);
} else {
// Crap.
$this->ctx->log->info('Update 5007 failed. Consider adding recurring IDs manuallly to civicrm_stripe_subscriptions. ');
return;
}
if (!empty($recur_id)) {
$p = [
1 => [$recur_id, 'Integer'],
2 => [$subscriptions->invoice_id, 'String'],
];
CRM_Core_DAO::executeQuery('UPDATE civicrm_stripe_subscriptions SET contribution_recur_id = %1 WHERE invoice_id = %2;', $p);
}
return TRUE;
}
return TRUE;
}
*/
/**
/**
* Add change default NOT NULL to NULL in vestigial invoice_id column in civicrm_stripe_subscriptions table if needed. (issue #192)
*
* @return TRUE on success
......@@ -327,7 +268,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
$dbName = DB::connect($config->dsn)->_db;
$sql = "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %1 AND TABLE_NAME = 'civicrm_stripe_subscriptions' AND COLUMN_NAME = 'invoice_id'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
if (!$dao->N) {
$this->ctx->log->info('Skipped civicrm_stripe update 5008. Column not present.');
......@@ -338,7 +279,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
MODIFY COLUMN `invoice_id` varchar(255) NULL default ""
COMMENT "Safe to remove this column if the update retrieving subscription IDs completed satisfactorily."');
}
return TRUE;
return TRUE;
}
/**
......@@ -356,7 +297,7 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
AND TABLE_NAME = 'civicrm_stripe_customers'
AND COLUMN_NAME = 'id'
AND COLUMN_KEY = 'UNI'";
$dao = CRM_Core_DAO::executeQuery($sql, array(1 => array($dbName, 'String')));
$dao = CRM_Core_DAO::executeQuery($sql, [1 => [$dbName, 'String']]);
if ($dao->N) {
$this->ctx->log->info('id is already unique in civicrm_stripe_customers table, no need for civicrm_stripe update 5009.');
}
......@@ -371,9 +312,9 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
public function upgrade_5010() {
$this->ctx->log->info('Applying Stripe update 5010. Adding contact_id to civicrm_stripe_customers.');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'contact_id', FALSE)) {
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_customers`
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_customers`
ADD COLUMN `contact_id` int(10) UNSIGNED DEFAULT NULL COMMENT "FK ID from civicrm_contact"');
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_customers`
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_customers`
ADD CONSTRAINT `FK_civicrm_stripe_customers_contact_id` FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact` (`id`) ON DELETE CASCADE;');
}
......@@ -397,4 +338,118 @@ class CRM_Stripe_Upgrader extends CRM_Stripe_Upgrader_Base {
return TRUE;
}
public function upgrade_5022() {
$this->ctx->log->info('Applying Stripe update 5021. Remove is_live NOT NULL constraint as we don\'t use this parameter any more');
if (CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'is_live', FALSE)) {
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_stripe_customers`
MODIFY COLUMN `is_live` tinyint(4) COMMENT "Whether this is a live or test transaction"');
}
return TRUE;
}
public function upgrade_5023() {
$this->ctx->log->info('Applying Stripe update 5023. Swap over public/secret key settings');
$stripeProcessors = civicrm_api3('PaymentProcessor', 'get', [
'payment_processor_type_id' => "Stripe",
]);
foreach ($stripeProcessors['values'] as $processor) {
if ((substr($processor['user_name'], 0, 3) === 'sk_')
&& (substr($processor['password'], 0, 3) === 'pk_')) {
// Need to switch over parameters
$createParams = [
'id' => $processor['id'],
'user_name' => $processor['password'],
'password' => $processor['user_name'],
];
civicrm_api3('PaymentProcessor', 'create', $createParams);
}
}
CRM_Utils_System::flushCache();
return TRUE;
}
public function upgrade_5024() {
$this->ctx->log->info('Applying Stripe update 5024. Add the civicrm_stripe_paymentintent database table');
CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, E::path('/sql/paymentintent_install.sql'));
CRM_Utils_System::flushCache();
return TRUE;
}
public function upgrade_5025() {
$this->ctx->log->info('Applying Stripe update 5025. Add referrer column to civicrm_stripe_paymentintent database table');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_paymentintent', 'referrer', FALSE)) {
CRM_Core_DAO::executeQuery("ALTER TABLE `civicrm_stripe_paymentintent`
ADD COLUMN `referrer` varchar(255) NULL COMMENT 'HTTP referrer of this paymentIntent'");
}
return TRUE;
}
public function upgrade_5026() {
$this->ctx->log->info('Change paymentintent_id column to stripe_intent_id in civicrm_stripe_paymentintent database table');
if (CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_paymentintent', 'paymentintent_id')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_paymentintent
CHANGE paymentintent_id stripe_intent_id varchar(255) COMMENT 'The Stripe PaymentIntent/SetupIntent/PaymentMethod ID'");
}
if (CRM_Core_BAO_SchemaHandler::checkIfIndexExists('civicrm_stripe_paymentintent', 'UI_paymentintent_id')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_paymentintent
DROP INDEX UI_paymentintent_id, ADD INDEX UI_stripe_intent_id (stripe_intent_id)");
}
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_paymentintent', 'extra_data')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_paymentintent
ADD COLUMN `extra_data` varchar(255) NULL COMMENT 'Extra data collected to help with diagnostics (such as email, name)'");
}
return TRUE;
}
public function upgrade_6801() {
$this->ctx->log->info('In civicrm_stripe_paymentintent database table, change extra_data from varchar to text');
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_paymentintent MODIFY extra_data TEXT');
return TRUE;
}
public function upgrade_6802() {
$this->ctx->log->info('Drop unused \'email\' and \'is_live\' columns from civicrm_stripe_customers database table');
if (CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'email')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers DROP COLUMN email");
}
if (CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'is_live')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers DROP COLUMN is_live");
}
return TRUE;
}
public function upgrade_6803() {
$this->ctx->log->info('In civicrm_stripe_customers database table, rename id to customer_id, add new id column');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'customer_id')) {
// ALTER TABLE ... RENAME COLUMN only in MySQL8+
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers CHANGE COLUMN id customer_id varchar(255) COMMENT 'Stripe Customer ID'");
if (CRM_Core_BAO_SchemaHandler::checkIfIndexExists('civicrm_stripe_customers', 'id')) {
CRM_Core_DAO::executeQuery('ALTER TABLE civicrm_stripe_customers DROP INDEX id');
CRM_Core_DAO::executeQuery('CREATE INDEX customer_id ON civicrm_stripe_customers (customer_id)');
}
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers ADD COLUMN id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'Unique ID' FIRST");
}
return TRUE;
}
public function upgrade_6900() {
$this->ctx->log->info('Add currency to civicrm_stripe_customers because the customer can only have one currency for subscriptions');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'currency')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers ADD COLUMN currency varchar(3) DEFAULT NULL COMMENT '3 character string, value from Stripe customer.'");
}
return TRUE;
}
public function upgrade_6902() {
$this->ctx->log->info('Convert MOTO setting to array');
if (\Civi::settings()->get('stripe_moto') === TRUE) {
\Civi::settings()->set('stripe_moto', ['backend']);
}
else {
\Civi::settings()->set('stripe_moto', []);
}
return TRUE;
}
}
<?php
// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
/**
* Base class which provides helpers to execute upgrade logic
*/
class CRM_Stripe_Upgrader_Base {
/**
* @var varies, subclass of ttis
*/
static $instance;
/**
* @var CRM_Queue_TaskContext
*/
protected $ctx;
/**
* @var string, eg 'com.example.myextension'
*/
protected $extensionName;
/**
* @var string, full path to the extension's source tree
*/
protected $extensionDir;
/**
* @var array(revisionNumber) sorted numerically
*/
private $revisions;
/**
* Obtain a reference to the active upgrade handler.
*/
static public function instance() {
if (! self::$instance) {
// FIXME auto-generate
self::$instance = new CRM_Stripe_Upgrader(
'com.drastikbydesign.stripe',
realpath(__DIR__ .'/../../../')
);
}
return self::$instance;
}
/**
* Adapter that lets you add normal (non-static) member functions to the queue.
*
* Note: Each upgrader instance should only be associated with one
* task-context; otherwise, this will be non-reentrant.
*
* @code
* CRM_Stripe_Upgrader_Base::_queueAdapter($ctx, 'methodName', 'arg1', 'arg2');
* @endcode
*/
static public function _queueAdapter() {
$instance = self::instance();
$args = func_get_args();
$instance->ctx = array_shift($args);
$instance->queue = $instance->ctx->queue;
$method = array_shift($args);
return call_user_func_array(array($instance, $method), $args);
}
public function __construct($extensionName, $extensionDir) {
$this->extensionName = $extensionName;
$this->extensionDir = $extensionDir;
}
// ******** Task helpers ********
/**
* Run a CustomData file.
*
* @param string $relativePath the CustomData XML file path (relative to this extension's dir)
* @return bool
*/
public function executeCustomDataFile($relativePath) {
$xml_file = $this->extensionDir . '/' . $relativePath;
return $this->executeCustomDataFileByAbsPath($xml_file);
}
/**
* Run a CustomData file
*
* @param string $xml_file the CustomData XML file path (absolute path)
*
* @return bool
*/
protected static function executeCustomDataFileByAbsPath($xml_file) {
require_once 'CRM/Utils/Migrate/Import.php';
$import = new CRM_Utils_Migrate_Import();
$import->run($xml_file);
return TRUE;
}
/**
* Run a SQL file.
*
* @param string $relativePath the SQL file path (relative to this extension's dir)
*
* @return bool
*/
public function executeSqlFile($relativePath) {
CRM_Utils_File::sourceSQLFile(
CIVICRM_DSN,
$this->extensionDir . '/' . $relativePath
);
return TRUE;
}
/**
* Run one SQL query.
*
* This is just a wrapper for CRM_Core_DAO::executeSql, but it
* provides syntatic sugar for queueing several tasks that
* run different queries
*/
public function executeSql($query, $params = array()) {
// FIXME verify that we raise an exception on error
CRM_Core_DAO::executeSql($query, $params);
return TRUE;
}
/**
* Syntatic sugar for enqueuing a task which calls a function in this class.
*
* The task is weighted so that it is processed
* as part of the currently-pending revision.
*
* After passing the $funcName, you can also pass parameters that will go to
* the function. Note that all params must be serializable.
*/
public function addTask($title) {
$args = func_get_args();
$title = array_shift($args);
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
$args,
$title
);
return $this->queue->createItem($task, array('weight' => -1));
}
// ******** Revision-tracking helpers ********
/**
* Determine if there are any pending revisions.
*
* @return bool
*/
public function hasPendingRevisions() {
$revisions = $this->getRevisions();
$currentRevision = $this->getCurrentRevision();
if (empty($revisions)) {
return FALSE;
}
if (empty($currentRevision)) {
return TRUE;
}
return ($currentRevision < max($revisions));
}
/**
* Add any pending revisions to the queue.
*/
public function enqueuePendingRevisions(CRM_Queue_Queue $queue) {
$this->queue = $queue;
$currentRevision = $this->getCurrentRevision();
foreach ($this->getRevisions() as $revision) {
if ($revision > $currentRevision) {
$title = ts('Upgrade %1 to revision %2', array(
1 => $this->extensionName,
2 => $revision,
));
// note: don't use addTask() because it sets weight=-1
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
array('upgrade_' . $revision),
$title
);
$this->queue->createItem($task);
$task = new CRM_Queue_Task(
array(get_class($this), '_queueAdapter'),
array('setCurrentRevision', $revision),
$title
);
$this->queue->createItem($task);
}
}
}
/**
* Get a list of revisions.
*
* @return array(revisionNumbers) sorted numerically
*/
public function getRevisions() {
if (! is_array($this->revisions)) {
$this->revisions = array();
$clazz = new ReflectionClass(get_class($this));
$methods = $clazz->getMethods();
foreach ($methods as $method) {
if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) {
$this->revisions[] = $matches[1];
}
}
sort($this->revisions, SORT_NUMERIC);
}
return $this->revisions;
}
public function getCurrentRevision() {
// return CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName);
$key = $this->extensionName . ':version';
return CRM_Core_BAO_Setting::getItem('Extension', $key);
}
public function setCurrentRevision($revision) {
// We call this during hook_civicrm_install, but the underlying SQL
// UPDATE fails because the extension record hasn't been INSERTed yet.
// Instead, track revisions in our own namespace.
// CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision);
$key = $this->extensionName . ':version';
CRM_Core_BAO_Setting::setItem($revision, 'Extension', $key);
return TRUE;
}
// ******** Hook delegates ********
public function onInstall() {
$files = glob($this->extensionDir . '/sql/*_install.sql');
if (is_array($files)) {
foreach ($files as $file) {
CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
}
}
$files = glob($this->extensionDir . '/xml/*_install.xml');
if (is_array($files)) {
foreach ($files as $file) {
$this->executeCustomDataFileByAbsPath($file);
}
}
if (is_callable(array($this, 'install'))) {
$this->install();
}
$revisions = $this->getRevisions();
if (!empty($revisions)) {
$this->setCurrentRevision(max($revisions));
}
}
public function onUninstall() {
if (is_callable(array($this, 'uninstall'))) {
$this->uninstall();
}
$files = glob($this->extensionDir . '/sql/*_uninstall.sql');
if (is_array($files)) {
foreach ($files as $file) {
CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file);
}
}
$this->setCurrentRevision(NULL);
}
public function onEnable() {
// stub for possible future use
if (is_callable(array($this, 'enable'))) {
$this->enable();
}
}
public function onDisable() {
// stub for possible future use
if (is_callable(array($this, 'disable'))) {
$this->disable();
}
}
public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) {
switch ($op) {
case 'check':
return array($this->hasPendingRevisions());
case 'enqueue':
return $this->enqueuePendingRevisions($queue);
default:
}
}
}
<?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_Webhook
*/
class CRM_Stripe_Webhook {
use CRM_Mjwshared_WebhookTrait;
/**
* Checks whether the payment processors have a correctly configured webhook
*
* @see stripe_civicrm_check()
*
* @param array $messages
* @param bool $attemptFix If TRUE, try to fix the webhook.
*
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function check(array &$messages, bool $attemptFix = FALSE) {
$env = \Civi::settings()->get('environment');
if ($env && $env !== 'Production') {
// return;
}
$paymentProcessors = \Civi\Api4\PaymentProcessor::get(FALSE)
->addWhere('class_name', 'LIKE', 'Payment_Stripe%')
->addWhere('is_active', '=', TRUE)
->addWhere('domain_id', '=', 'current_domain')
->addWhere('is_test', 'IN', [TRUE, FALSE])
->execute();
foreach ($paymentProcessors as $paymentProcessor) {
$webhook_path = self::getWebhookPath($paymentProcessor['id']);
$processor = \Civi\Payment\System::singleton()->getById($paymentProcessor['id']);
if ($processor->stripeClient === NULL) {
// This means we only configured live OR test and not both.
continue;
}
try {
$webhooks = $processor->stripeClient->webhookEndpoints->all(["limit" => 100]);
}
catch (Exception $e) {
$error = $e->getMessage();
$messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
$error,
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::ERROR,
'fa-money'
);
continue;
}
$found_wh = FALSE;
foreach ($webhooks->data as $wh) {
if ($wh->url == $webhook_path) {
$found_wh = TRUE;
// Check and update webhook
try {
$updates = $this->checkWebhookEvents($wh);
if (!empty($wh->api_version) && (strtotime($wh->api_version) < strtotime(CRM_Stripe_Check::API_MIN_VERSION))) {
// Add message about API version.
$messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts('Webhook API version is set to %2 but CiviCRM requires %3. To correct this please delete the webhook at Stripe and then revisit this page which will recreate it correctly. <em>Webhook path is: <a href="%1" target="_blank">%1</a>.</em>',
[
1 => urldecode($webhook_path),
2 => $wh->api_version,
3 => CRM_Stripe_Check::API_VERSION,
]
),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
}
if ($updates && $wh->status != 'disabled') {
if ($attemptFix) {
try {
// We should try to update the webhook.
$processor->stripeClient->webhookEndpoints->update($wh->id, $updates);
}
catch (Exception $e) {
$messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts('Unable to update the webhook %1. To correct this please delete the webhook at Stripe and then revisit this page which will recreate it correctly. Error was: %2',
[
1 => urldecode($webhook_path),
2 => htmlspecialchars($e->getMessage()),
]
),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
}
}
else {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts('Problems detected with Stripe webhook! <em>Webhook path is: <a href="%1" target="_blank">%1</a>.</em>',
[1 => urldecode($webhook_path)]
),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
$message->addAction(
E::ts('View and fix problems'),
NULL,
'href',
['path' => 'civicrm/stripe/fix-webhook', 'query' => ['reset' => 1]]
);
$messages[] = $message;
}
}
}
catch (Exception $e) {
$messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts('Could not check/update existing webhooks, got error from stripe <em>%1</em>', [
1 => htmlspecialchars($e->getMessage())
]
),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
}
}
}
if (!$found_wh) {
if ($attemptFix) {
try {
// Try to create one.
$this->createWebhook($paymentProcessor['id']);
}
catch (Exception $e) {
$messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts('Could not create webhook, got error from stripe <em>%1</em>', [
1 => htmlspecialchars($e->getMessage())
]),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
}
}
else {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . $paymentProcessor['id'] . 'stripe_webhook',
E::ts(
'Stripe Webhook missing or needs update! <em>Expected webhook path is: <a href="%1" target="_blank">%1</a></em>',
[1 => $webhook_path]
),
$this->getTitle($paymentProcessor),
\Psr\Log\LogLevel::WARNING,
'fa-money'
);
$message->addAction(
E::ts('View and fix problems'),
NULL,
'href',
['path' => 'civicrm/stripe/fix-webhook', 'query' => ['reset' => 1]]
);
$messages[] = $message;
}
}
}
}
/**
* Get the error message title for the system check
* @param array $paymentProcessor
*
* @return string
*/
private function getTitle(array $paymentProcessor): string {
if (!empty($paymentProcessor['is_test'])) {
$paymentProcessor['name'] .= ' (test)';
}
return E::ts('Stripe Payment Processor: %1 (%2)', [
1 => $paymentProcessor['name'],
2 => $paymentProcessor['id'],
]);
}
/**
* Create a new webhook for payment processor
*
* @param int $paymentProcessorId
*/
public function createWebhook(int $paymentProcessorId) {
$processor = \Civi\Payment\System::singleton()->getById($paymentProcessorId);
$params = [
'enabled_events' => self::getDefaultEnabledEvents(),
'url' => self::getWebhookPath($paymentProcessorId),
'connect' => FALSE,
];
$processor->stripeClient->webhookEndpoints->create($params);
}
/**
* Check and update existing webhook
*
* @param \Stripe\WebhookEndpoint $webhook
*
* @return array of correction params. Empty array if it's OK.
*/
private function checkWebhookEvents(\Stripe\WebhookEndpoint $webhook): array {
$params = [];
if (array_diff(self::getDefaultEnabledEvents(), $webhook->enabled_events)) {
$params['enabled_events'] = self::getDefaultEnabledEvents();
}
return $params;
}
/**
* List of webhooks we currently handle
*
* @return array
*/
public static function getDefaultEnabledEvents(): array {
return [
'invoice.finalized',
'invoice.paid', // Ignore this event because it sometimes causes duplicates (it's sent at almost the same time as invoice.payment_succeeded
// and if they are both processed at the same time the check to see if the payment already exists is missed and it gets created twice.
'invoice.payment_succeeded',
'invoice.payment_failed',
'charge.failed',
'charge.refunded',
'charge.succeeded',
'charge.captured',
'customer.subscription.updated',
'customer.subscription.deleted',
'checkout.session.completed',
];
}
/**
* List of webhooks that we do NOT process immediately.
*
* @return array
*/
public static function getDelayProcessingEvents(): array {
return [
// This event does not need processing in real-time because it will be received simultaneously with
// `invoice.payment_succeeded` if start date is "now".
// If starting a subscription on a specific date we only receive this event until the date the invoice is
// actually due for payment.
// If we allow it to process whichever gets in first (invoice.finalized or invoice.payment_succeeded) we will get
// delays in completing payments/sending receipts until the scheduled job is run.
'invoice.finalized'
];
}
}
<?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);
}
}