Newer
Older
Kurund Jalmi
committed
use CRM_Stripe_ExtensionUtil as E;
class CRM_Core_Payment_Stripe extends CRM_Core_Payment {

mattwire
committed
use CRM_Core_Payment_MJWTrait;
protected $_mode = NULL;
public static function getApiVersion() {
return self::API_VERSION;
}
* @param string $mode
* The mode of operation: live or test.

mattwire
committed
* @param array $paymentProcessor

mattwire
committed
public function __construct($mode, $paymentProcessor) {
$this->_mode = $mode;

mattwire
committed
$this->_processorName = E::SHORT_NAME;
/**
* @param array $paymentProcessor
*
* @return string
*/
public static function getSecretKey($paymentProcessor) {
return trim(CRM_Utils_Array::value('password', $paymentProcessor));
}
/**
* @param array $paymentProcessor
*
* @return string
*/
public static function getPublicKey($paymentProcessor) {
return trim(CRM_Utils_Array::value('user_name', $paymentProcessor));
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
}
/**
* Given a payment processor id, return the public key
*
* @param $paymentProcessorId
*
* @return string
*/
public static function getPublicKeyById($paymentProcessorId) {
try {
$paymentProcessor = civicrm_api3('PaymentProcessor', 'getsingle', [
'id' => $paymentProcessorId,
]);
$key = self::getPublicKey($paymentProcessor);
}
catch (CiviCRM_API3_Exception $e) {
return '';
}
return $key;
}
/**
* Given a payment processor id, return the secret key
*
* @param $paymentProcessorId
*
* @return string
*/
public static function getSecretKeyById($paymentProcessorId) {
try {
$paymentProcessor = civicrm_api3('PaymentProcessor', 'getsingle', [
'id' => $paymentProcessorId,
]);
$key = self::getSecretKey($paymentProcessor);
}
catch (CiviCRM_API3_Exception $e) {
return '';
}
return $key;
}
* This function checks to see if we have the right config values.
public function checkConfig() {

mattwire
committed
$error = [];
if (!empty($error)) {
return implode('<p>', $error);
}
else {
return NULL;
}
}
/**
* We can use the smartdebit processor on the backend
* @return bool
*/
public function supportsBackOffice() {
// @fixme Make this work again with stripe elements / 6.0
return FALSE;
// return TRUE;
}
/**
* We can edit smartdebit recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return FALSE;
}
public function supportsRecurring() {
// @fixme: Test and make this work for stripe elements / 6.0
return FALSE;
}
/**
* Does this payment processor support refund?
*
* @return bool
*/
public function supportsRefund() {
return TRUE;
}
/**
* We can configure a start date for a smartdebit mandate
* @return bool
*/
public function supportsFutureRecurStartDate() {
return FALSE;
}
/**
* Get the currency for the transaction.
*
* Handle any inconsistency about how it is passed in here.
*
* @param $params
*
* @return string
*/
public function getAmount($params) {
// Stripe amount required in cents.
$amount = number_format($params['amount'], 2, '.', '');
$amount = (int) preg_replace('/[^\d]/', '', strval($amount));
return $amount;
}
* Set API parameters for Stripe (such as identifier, api version, api key)
public function setAPIParams() {
// Set plugin info and API credentials.
\Stripe\Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
\Stripe\Stripe::setApiKey(self::getSecretKey($this->_paymentProcessor));
\Stripe\Stripe::setApiVersion(self::getApiVersion());

mattwire
committed
}
/**
* Handle an error from Stripe API and notify the user
*
* @param array $err
* @param string $bounceURL
*
* @return string errorMessage (or statusbounce if URL is specified)
*/

mattwire
committed
public function handleErrorNotification($err, $bounceURL = NULL) {
return self::handleError("{$err['type']} {$err['code']}", $err['message'], $bounceURL);
/**
* Stripe exceptions contain a json object in the body "error". This function extracts and returns that as an array.
* @param String $op
* @param Exception $e
* @param Boolean $log
*
* @return array $err
*/
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;
}
return $err;
}
/**
* Create or update a Stripe Plan
*
* @param array $params
* @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 ($this->_paymentProcessor['is_test']) {
$planId .= '-test';
}
// 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';
}

mattwire
committed
$product = \Stripe\Product::create([
"name" => $productName,
"type" => "service"

mattwire
committed
]);
// Create a new Plan.

mattwire
committed
$stripePlan = [
'amount' => $amount,
'interval' => $params['frequency_unit'],
'product' => $product->id,
'currency' => $currency,
'id' => $planId,
'interval_count' => $params['frequency_interval'],

mattwire
committed
];
$plan = \Stripe\Plan::create($stripePlan);
}
}
return $plan;
}
*/
public function getPaymentFormFields() {

mattwire
committed
return [];
}
/**
* Return an array of all the details about the fields potentially required for payment fields.
*
* Only those determined by getPaymentFormFields will actually be assigned to the form
*
* @return array
* field metadata
*/
public function getPaymentFormFieldsMetadata() {

mattwire
committed
return [];
/**
* Get form metadata for billing address fields.
*
* @param int $billingLocationID
*
* @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();
}
// 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;
}
return $metadata;
}
* Set default values when loading the (payment) form
public function buildForm(&$form) {

mattwire
committed
$jsVars = [
'id' => $form->_paymentProcessor['id'],

mattwire
committed
'currency' => $this->getDefaultCurrencyForForm($form),

mattwire
committed
'billingAddressID' => CRM_Core_BAO_LocationType::getBilling(),
'publishableKey' => CRM_Core_Payment_Stripe::getPublicKeyById($form->_paymentProcessor['id']),
'jsDebug' => TRUE,

mattwire
committed
];
\Civi::resources()->addVars(E::SHORT_NAME, $jsVars);
// Assign to smarty so we can add via Card.tpl for drupal webform because addVars doesn't work in that context
$form->assign('stripeJSVars', $jsVars);
// Add help and javascript
CRM_Core_Region::instance('billing-block')->add(
['template' => 'CRM/Core/Payment/Stripe/Card.tpl', 'weight' => -1]);
// 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::resources()->getUrl(E::LONG_NAME, 'css/elements.css'),
'weight' => -1,
]);
\Civi::resources()->addScriptFile(E::LONG_NAME, 'js/civicrm_stripe.js');
* Process payment
* Submit a payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
* Payment processors should set payment_status_id.
* @param array $params
* Assoc array of input parameters for this transaction.
* @param string $component
* @return array
* Result array
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
public function doPayment(&$params, $component = 'contribute') {

mattwire
committed
$params = $this->beginDoPayment($params);

mattwire
committed
// Get the passed in paymentIntent
if(!empty(CRM_Utils_Array::value('paymentIntentID', $params))) {
$paymentIntentID = CRM_Utils_Array::value('paymentIntentID', $params);
elseif (CRM_Core_Session::singleton()->get('stripePaymentIntent')) {
// @fixme Hack for contributionpages - see https://github.com/civicrm/civicrm-core/pull/15252
$paymentIntentID = CRM_Core_Session::singleton()->get('stripePaymentIntent');
}

mattwire
committed
else {
CRM_Core_Error::statusBounce(E::ts('Unable to complete payment! Missing paymentIntent ID.'));
Civi::log()->debug('paymentIntentID not found. $params: ' . print_r($params, TRUE));
}
$pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
$this->setAPIParams();
// 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.

mattwire
committed
$params['stripe_error_url'] = NULL;
}
else {
$qfKey = $params['qfKey'];

mattwire
committed
$parsedUrl = parse_url($params['entryURL']);
$urlPath = substr($parsedUrl['path'], 1);
$query = $parsedUrl['query'];
if (strpos($query, '_qf_Main_display=1') === FALSE) {
$query .= '&_qf_Main_display=1';
}
if (strpos($query, 'qfKey=') === FALSE) {
$query .= "&qfKey={$qfKey}";
}
$params['stripe_error_url'] = CRM_Utils_System::url($urlPath, $query, FALSE, NULL, FALSE);
$amount = self::getAmount($params);
$contactId = $this->getContactId($params);
$email = $this->getBillingEmail($params, $contactId);
// See if we already have a stripe customer
$customerParams = [
'contact_id' => $contactId,
'processor_id' => $this->_paymentProcessor['id'],
'email' => $email,
// Include this to allow redirect within session on payment failure
'stripe_error_url' => $params['stripe_error_url'],
];
$stripeCustomerId = CRM_Stripe_Customer::find($customerParams);

mattwire
committed
// Customer not in civicrm database. Create a new Customer in Stripe.
if (!isset($stripeCustomerId)) {

mattwire
committed
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
}
else {

mattwire
committed
// Customer was found in civicrm database, fetch from Stripe.
try {
$stripeCustomer = \Stripe\Customer::retrieve($stripeCustomerId);

mattwire
committed
} catch (Exception $e) {
$err = self::parseStripeException('retrieve_customer', $e, FALSE);

mattwire
committed
$errorMessage = $this->handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to retrieve Stripe Customer: ' . $errorMessage);
}
if ($stripeCustomer->isDeleted()) {
// Customer doesn't exist, create a new one
CRM_Stripe_Customer::delete($customerParams);
try {

mattwire
committed
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
} catch (Exception $e) {
// We still failed to create a customer

mattwire
committed
$errorMessage = $this->handleErrorNotification($stripeCustomer, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Customer: ' . $errorMessage);
}
}
if (empty($params['description'])) {

mattwire
committed
$params['description'] = E::ts('Backend Stripe contribution');

mattwire
committed
// This is where we actually charge the customer
try {

mattwire
committed
$intent = \Stripe\PaymentIntent::retrieve($paymentIntentID);
$intent->description = $params['description'] . ' # Invoice ID: ' . CRM_Utils_Array::value('invoiceID', $params);
$intent->customer = $stripeCustomer->id;
switch ($intent->status) {
case 'requires_confirmation':
$intent->confirm();
case 'requires_capture':
$intent->capture(['amount_to_capture' => $this->getAmount($params)]);
break;
}
catch (Exception $e) {

mattwire
committed
$this->handleError($e->getCode(), $e->getMessage(), $params['stripe_error_url']);
// Handle recurring payments in doRecurPayment().
if (CRM_Utils_Array::value('is_recur', $params) && $params['contributionRecurID']) {
// 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.
$paymentMethod = \Stripe\PaymentMethod::retrieve($intent->payment_method);
$paymentMethod->attach(['customer' => $stripeCustomer->id]);
// 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, $paymentMethod);
}
// Return fees & net amount for Civi reporting.
$stripeCharge = $intent->charges->data[0];
try {
$stripeBalanceTransaction = \Stripe\BalanceTransaction::retrieve($stripeCharge->balance_transaction);
catch (Exception $e) {
$err = self::parseStripeException('retrieve_balance_transaction', $e, FALSE);

mattwire
committed
$errorMessage = $this->handleErrorNotification($err, $params['stripe_error_url']);
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to retrieve Stripe Balance Transaction: ' . $errorMessage);
}

mattwire
committed
// Success!

mattwire
committed
// Set the desired contribution status which will be set later (do not set on the contribution here!)
$params['contribution_status_id'] = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');

mattwire
committed
// 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...

mattwire
committed
$this->setPaymentProcessorOrderID($stripeCharge->id);
$newParams['fee_amount'] = $stripeBalanceTransaction->fee / 100;
$newParams['net_amount'] = $stripeBalanceTransaction->net / 100;

mattwire
committed
return $this->endDoPayment($params, $newParams);
}
/**
* Submit a recurring payment using Stripe's PHP API:
* https://stripe.com/docs/api?lang=php
*
* @param array $params
* Assoc array of input parameters for this transaction.
* @param int $amount
* Transaction amount in USD cents.
* @param \Stripe\Customer $stripeCustomer
* Stripe customer object generated by Stripe API.
* @param \Stripe\PaymentMethod $stripePaymentMethod
* The result in a nice formatted array (or an error object).
* @throws \CRM_Core_Exception
public function doRecurPayment($params, $amount, $stripeCustomer, $stripePaymentMethod) {
$requiredParams = ['contributionRecurID', 'frequency_unit'];
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);
}
// Make sure frequency_interval is set (default to 1 if not)
empty($params['frequency_interval']) ? $params['frequency_interval'] = 1 : NULL;
// Create the stripe plan
$planId = self::createPlan($params, $amount);
$subscriptionParams = [
'prorate' => FALSE,
'plan' => $planId,
'default_payment_method' => $stripePaymentMethod,
];
// 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);
}
}
// 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
}
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
/**
* Submit a refund payment
*
* @param array $params
* Assoc array of input parameters for this transaction.
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doRefund(&$params) {
$requiredParams = ['charge_id', 'payment_processor_id'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = 'Stripe doRefund: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
Throw new \Civi\Payment\Exception\PaymentProcessorException($message);
}
}
$refundParams = [
'charge' => $params['charge_id'],
];
if (!empty($params['amount'])) {
$refundParams['amount'] = $this->getAmount($params);
}
try {
$refund = \Stripe\Refund::create($refundParams);
}
catch (Exception $e) {
$this->handleError($e->getCode(), $e->getMessage());
Throw new \Civi\Payment\Exception\PaymentProcessorException($e->getMessage());
}
}
/**
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
*
* @return string
* @throws \CRM_Core_Exception
*/
public function calculateEndDate($params) {
$requiredParams = ['start_date', 'installments', 'frequency_interval', 'frequency_unit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = 'Stripe calculateEndDate: Missing mandatory parameter: ' . $required;
Civi::log()->error($message);
throw new CRM_Core_Exception($message);
}
}
switch ($params['frequency_unit']) {
case 'day':
$frequencyUnit = 'D';
break;
case 'week':
$frequencyUnit = 'W';
break;
case 'month':
$frequencyUnit = 'M';
break;
case 'year':
$frequencyUnit = 'Y';
break;
}
$numberOfUnits = $params['installments'] * $params['frequency_interval'];
$endDate = new DateTime($params['start_date']);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd') . '235959';
* Calculate the end_date for a recurring contribution based on the number of installments
* @param $params
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
* @return string
* @throws \CRM_Core_Exception
*/
public function calculateNextScheduledDate($params) {
$requiredParams = ['frequency_interval', 'frequency_unit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = 'Stripe 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'])) {
$startDate = date('YmdHis');
}
elseif (!empty($params['next_sched_contribution_date'])) {
if ($params['next_sched_contribution_date'] < date('YmdHis')) {
$startDate = $params['next_sched_contribution_date'];
}
}
else {
$startDate = $params['start_date'];
}
switch ($params['frequency_unit']) {
case 'day':
$frequencyUnit = 'D';
break;
case 'week':
$frequencyUnit = 'W';
break;
case 'month':
$frequencyUnit = 'M';
break;
case 'year':
$frequencyUnit = 'Y';
break;
}
$numberOfUnits = $params['frequency_interval'];
$endDate = new DateTime($startDate);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd');
}
/**
* Default payment instrument validation.
*
* Implement the usual Luhn algorithm via a static function in the CRM_Core_Payment_Form if it's a credit card
* Not a static function, because I need to check for payment_type.
*
* @param array $values
* @param array $errors
*/
public function validatePaymentInstrument($values, &$errors) {
// Use $_POST here and not $values - for webform fields are not set in $values, but are in $_POST
CRM_Core_Form::validateMandatoryFields($this->getMandatoryFields(), $_POST, $errors);
}
/**
* @param string $message
* @param array $params
*
* @return bool|object
*/
public function cancelSubscription(&$message = '', $params = []) {
$this->setAPIParams();
$contributionRecurId = $this->getRecurringContributionId($params);
try {

mattwire
committed
$contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', [
'id' => $contributionRecurId,

mattwire
committed
]);
catch (Exception $e) {
return FALSE;
}
if (empty($contributionRecur['trxn_id'])) {

mattwire
committed
CRM_Core_Session::setStatus(E::ts('The recurring contribution cannot be cancelled (No reference (trxn_id) found).'), 'Smart Debit', 'error');
return FALSE;
}
try {
$subscription = \Stripe\Subscription::retrieve($contributionRecur['trxn_id']);
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;
}
return TRUE;
* Process incoming payment notification (IPN).
* @throws \CiviCRM_API3_Exception
$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);
}