Commit 6dc347a7 authored by mattwire's avatar mattwire
Browse files

Implement setupIntents to perform 3DSecure etc on same form as card details

parent 9936ccfa
......@@ -226,9 +226,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* Get the currency for the transaction.
*
* Handle any inconsistency about how it is passed in here.
* Get the amount for the Stripe API formatted in lowest (ie. cents / pennies).
*
* @param array|PropertyBag $params
*
......@@ -531,11 +529,23 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$propertyBag = $this->beginDoPayment($propertyBag);
$isRecur = ($propertyBag->getIsRecur() && $this->getRecurringContributionId($propertyBag));
if ($isRecur || $this->isPaymentForEventAdditionalParticipants($propertyBag)) {
$propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, TRUE);
// 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('paymentIntentID', $propertyBag, TRUE);
$propertyBag = $this->getTokenParameter('paymentMethodID', $propertyBag, FALSE);
if ($propertyBag->has('paymentMethodID')) {
$paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
}
else {
$propertyBag = $this->getTokenParameter('paymentIntentID', $propertyBag, TRUE);
}
}
// @fixme DO NOT SET ANYTHING ON $propertyBag or $params BELOW THIS LINE (we are reading from both)
......@@ -588,11 +598,17 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to create Stripe Customer: ' . $errorMessage);
}
}
else {
CRM_Stripe_Customer::updateMetadata($customerParams, $this, $stripeCustomer->id);
}
}
// Attach the paymentMethod to the customer and set as default for new invoices
if (isset($paymentMethodID)) {
$paymentMethod = $this->stripeClient->paymentMethods->retrieve($paymentMethodID);
$paymentMethod->attach(['customer' => $stripeCustomer->id]);
$customerParams['invoice_settings']['default_payment_method'] = $paymentMethodID;
}
CRM_Stripe_Customer::updateMetadata($customerParams, $this, $stripeCustomer->id);
// Handle recurring payments in doRecurPayment().
if ($isRecur) {
// We're processing a recurring payment - for recurring payments we first saved a paymentMethod via the browser js.
......@@ -600,21 +616,13 @@ class CRM_Core_Payment_Stripe extends CRM_Core_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.
$paymentMethodID = $propertyBag->getCustomProperty('paymentMethodID');
$paymentMethod = $this->stripeClient->paymentMethods->retrieve($paymentMethodID);
$paymentMethod->attach(['customer' => $stripeCustomer->id]);
$stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomer->id);
return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer, $paymentMethod);
return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer);
}
elseif ($this->isPaymentForEventAdditionalParticipants($propertyBag)) {
// We're processing an event registration for multiple participants - because we did not know
// the amount until now we process via a saved paymentMethod.
$paymentMethod = $this->stripeClient->paymentMethods->retrieve($propertyBag->getCustomProperty('paymentMethodID'));
$paymentMethod->attach(['customer' => $stripeCustomer->id]);
$stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomer->id);
$intent = $this->stripeClient->paymentIntents->create([
'payment_method' => $propertyBag->getCustomProperty('paymentMethodID'),
'payment_method' => $paymentMethodID,
'customer' => $stripeCustomer->id,
'amount' => $amountFormattedForStripe,
'currency' => $this->getCurrency($params),
......@@ -685,7 +693,6 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* Transaction amount in cents.
* @param \Stripe\Customer $stripeCustomer
* Stripe customer object generated by Stripe API.
* @param \Stripe\PaymentMethod $stripePaymentMethod
*
* @return array
* The result in a nice formatted array (or an error object).
......@@ -693,7 +700,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
public function doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer, $stripePaymentMethod) {
public function doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer) {
$params = $this->getPropertyBagAsArray($propertyBag);
// @fixme FROM HERE we are using $params array (but some things are READING from $propertyBag)
......@@ -723,10 +730,10 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$subscriptionParams = [
'proration_behavior' => 'none',
'plan' => $planId,
'default_payment_method' => $stripePaymentMethod,
'metadata' => ['Description' => $params['description']],
'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.
......@@ -739,32 +746,37 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$stripeSubscription = $this->stripeClient->subscriptions->create($subscriptionParams);
$this->setPaymentProcessorSubscriptionID($stripeSubscription->id);
$recurParams = [
'id' => $this->getRecurringContributionId($propertyBag),
// @fixme trxn_id/processor_id - see https://lab.civicrm.org/dev/financial/-/issues/57#note_19168
// We need to set them both but one should be removed. doCancelRecurring()/updateSubscriptionBillingInfo() both
// get processor_id
'trxn_id' => $this->getPaymentProcessorSubscriptionID(),
'processor_id' => $this->getPaymentProcessorSubscriptionID(),
'auto_renew' => 1,
'next_sched_contribution_date' => $this->calculateNextScheduledDate($params),
];
$recurParams['cycle_day'] = date('d', strtotime($recurParams['next_sched_contribution_date']));
$nextScheduledContributionDate = $this->calculateNextScheduledDate($params);
$contributionRecur = \Civi\Api4\ContributionRecur::update(FALSE)
->addWhere('id', '=', $this->getRecurringContributionId($propertyBag))
->addValue('processor_id', $this->getPaymentProcessorSubscriptionID())
->addValue('auto_renew', 1)
->addValue('next_sched_contribution_date', $nextScheduledContributionDate)
->addValue('cycle_day', date('d', strtotime($nextScheduledContributionDate)));
if (!empty($params['installments'])) {
// We set an end date if installments > 0
if (empty($params['receive_date'])) {
$params['receive_date'] = date('YmdHis');
}
if ($params['installments']) {
$recurParams['end_date'] = $this->calculateEndDate($params);
$recurParams['installments'] = $params['installments'];
$contributionRecur
->addValue('end_date', $this->calculateEndDate($params))
->addValue('installments', $params['installments']);
}
}
// Hook to allow modifying recurring contribution params
CRM_Stripe_Hook::updateRecurringContribution($recurParams);
if ($stripeSubscription->status === 'incomplete') {
$contributionRecur->addValue('contribution_status_id:name', 'Failed');
}
// Update the recurring payment
civicrm_api3('ContributionRecur', 'create', $recurParams);
$contributionRecur->execute();
if ($stripeSubscription->status === 'incomplete') {
// For example with test card 4000000000000341 (Attaching this card to a Customer object succeeds, but attempts to charge the customer fail)
\Civi::log()->warning('Stripe subscription status=incomplete. ID:' . $stripeSubscription->id);
throw new \Civi\Payment\Exception\PaymentProcessorException('Payment failed');
}
// artfulrobot: Q. what do we normally get here?
if ($stripeSubscription->latest_invoice) {
......
......@@ -9,6 +9,7 @@
+--------------------------------------------------------------------+
*/
use Civi\Api4\Contact;
use CRM_Stripe_ExtensionUtil as E;
/**
......@@ -199,21 +200,33 @@ class CRM_Stripe_Customer {
* @throws \CiviCRM_API3_Exception
*/
private static function getStripeCustomerMetadata($params) {
$contactDisplayName = civicrm_api3('Contact', 'getvalue', [
'return' => 'display_name',
'id' => $params['contact_id'],
]);
$contactDisplayName = Contact::get(FALSE)
->addSelect('display_name')
->addWhere('id', '=', $params['contact_id'])
->execute()
->first()['display_name'];
$domainName = \Civi\Api4\Domain::get(FALSE)
->setCurrentDomain(TRUE)
->addSelect('name')
->execute()
->first()['name'];
$extVersion = civicrm_api3('Extension', 'getvalue', ['return' => 'version', 'full_name' => E::LONG_NAME]);
$stripeCustomerParams = [
'name' => $contactDisplayName,
'description' => 'CiviCRM: ' . civicrm_api3('Domain', 'getvalue', ['current_domain' => 1, 'return' => 'name']),
'email' => CRM_Utils_Array::value('email', $params),
'description' => 'CiviCRM: ' . $domainName,
'email' => $params['email'] ?? '',
'metadata' => [
'CiviCRM Contact ID' => $params['contact_id'],
'CiviCRM URL' => CRM_Utils_System::url('civicrm/contact/view', "reset=1&cid={$params['contact_id']}", TRUE, NULL, TRUE, FALSE, TRUE),
'CiviCRM Version' => CRM_Utils_System::version() . ' ' . civicrm_api3('Extension', 'getvalue', ['return' => "version", 'full_name' => E::LONG_NAME]),
'CiviCRM Version' => CRM_Utils_System::version() . ' ' . $extVersion,
],
];
// This is used for new subscriptions/invoices as the default payment method
if (isset($params['invoice_settings'])) {
$stripeCustomerParams['invoice_settings'] = $params['invoice_settings'];
}
return $stripeCustomerParams;
}
......
<?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 |
+--------------------------------------------------------------------+
*/
/**
* 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(['recurContributionParams'], $recurContributionParams, CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject,
CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject, 'civicrm_stripe_updateRecurringContribution');
}
}
......@@ -9,12 +9,68 @@
+--------------------------------------------------------------------+
*/
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 array
*/
protected $extraData = [];
/**
* @param \CRM_Core_Payment_Stripe $paymentProcessor
*/
public function __construct($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 array $extraData
*
* @return void
*/
public function setExtraData($extraData) {
$this->extraData = $extraData;
}
/**
* Add a paymentIntent to the database
*
......@@ -162,4 +218,225 @@ class CRM_Stripe_PaymentIntent {
return $dao->toArray();
}
public function processSetupIntent($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';
try {
$intent = $this->paymentProcessor->stripeClient->setupIntents->create($intentParams);
} catch (Exception $e) {
// Save the "error" in the paymentIntent table in in case investigation is required.
$stripePaymentintentParams = [
'stripe_intent_id' => 'null',
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => 'failed',
'description' => "{$e->getRequestId()};{$e->getMessage()};{$this->description}",
'referrer' => $this->referrer,
];
if (!empty($this->extraData)) {
$stripePaymentintentParams['extra_data'] = $this->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,
];
if (!empty($this->extraData)) {
$stripePaymentintentParams['extra_data'] = $this->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;
}
public function processPaymentIntent($params) {
/*
$params = [
// Either paymentIntentID or paymentMethodID must be set
'paymentIntentID' => 'pi_xx',
'paymentMethodID' => 'pm_xx',
'capture' => TRUE/FALSE,
'amount' => '12.05',
'currency' => 'USD',
];
*/
$resultObject = (object) ['ok' => FALSE, 'message' => '', 'data' => []];
$intentParams['confirm'] = TRUE;
$intentParams['confirmation_method'] = 'manual';
if (empty($params['paymentIntentID']) && empty($params['paymentMethodID'])) {
$intentParams['confirm'] = FALSE;
$intentParams['confirmation_method'] = 'automatic';
}
if ($params['paymentIntentID']) {
// We already have a PaymentIntent, retrieve and attempt confirm.
$intent = $this->paymentProcessor->stripeClient->paymentIntents->retrieve($params['paymentIntentID']);
if ($intent->status === 'requires_confirmation') {
$intent->confirm();
}
if ($params['capture'] && $intent->status === 'requires_capture') {
$intent->capture();
}
}
else {
// We don't yet have a PaymentIntent, create one using the
// Payment Method ID and attempt to confirm it too.
try {
$intentParams['amount'] = $this->paymentProcessor->getAmount(['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 ($params['paymentMethodID']) {
$intentParams['payment_method'] = $params['paymentMethodID'];
}
$intent = $this->paymentProcessor->stripeClient->paymentIntents->create($intentParams);
} catch (Exception $e) {
// Save the "error" in the paymentIntent table in in case investigation is required.
$stripePaymentintentParams = [
'stripe_intent_id' => 'null',
'payment_processor_id' => $this->paymentProcessor->getID(),
'status' => 'failed',
'description' => "{$e->getRequestId()};{$e->getMessage()};{$this->description}",
'referrer' => $this->referrer,
];
if (!empty($this->extraData)) {
$stripePaymentintentParams['extra_data'] = $this->extraData;
}
CRM_Stripe_BAO_StripePaymentintent::create($stripePaymentintentParams);
if ($e instanceof \Stripe\Exception\CardException) {
if (($e->getDeclineCode() === 'fraudulent') && class_exists('\Civi\Firewall\Event\FraudEvent')) {
\Civi\Firewall\Event\FraudEvent::trigger(\CRM_Utils_System::ipAddress(), 'CRM_Stripe_AJAX::confirmPayment');
}
$message = $e->getMessage();
}
elseif ($e instanceof \Stripe\Exception\InvalidRequestException) {
$message = 'Invalid request';
}
$resultObject->ok = FALSE;
$resultObject->message = $message;
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);
// 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,
'paymentIntentClientSecret' => $intent->client_secret,
];
}
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,
'paymentIntent' => ['id' => $intent->id],
];
}
elseif ($intent->status === 'succeeded') {
$resultObject->ok = TRUE;
$resultObject->data = [
'success' => true,
'paymentIntent' => ['id' => $intent->id],
];
}
elseif ($intent->status === 'requires_payment_method') {
$resultObject->ok = TRUE;
$resultObject->data = [
'requires_payment_method' => true,
'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;
}
}
......@@ -167,16 +167,15 @@ function civicrm_api3_stripe_paymentintent_process($params) {
$paymentIntentID = CRM_Utils_Type::validate($params['payment_intent_id'] ?? '', 'String');
$capture = CRM_Utils_Type::validate($params['capture'] ?? NULL, 'Boolean', FALSE);
$amount = CRM_Utils_Type::validate($params['amount'], 'String');
$setup = CRM_Utils_Type::validate($params['setup'] ?? NULL, 'Boolean', FALSE);
// $capture is normally true if we have already created the intent and just need to get extra
// authentication from the user (eg. on the confirmation page). So we don't need the amount
// in this case.
if (empty($amount) && !$capture) {
if (empty($amount) && !$capture && !$setup) {
_civicrm_api3_stripe_paymentintent_returnInvalid();
}
$referrer = $_SERVER['HTTP_REFERER'] ?? '';
$title = CRM_Utils_Type::validate($params['description'], 'String');
$intentParams['confirm'] = TRUE;
$description = CRM_Utils_Type::validate($params['description'], 'String');
$currency = CRM_Utils_Type::validate($params['currency'], 'String', CRM_Core_Config::singleton()->defaultCurrency);
// Until 6.6.1 we were passing 'id' instead of the correct 'payment_processor_id' from js scripts. This retains
......@@ -192,112 +191,39 @@ function civicrm_api3_stripe_paymentintent_process($params) {
$paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
($paymentProcessor->getPaymentProcessor()['class_name'] === 'Payment_Stripe') ?: _civicrm_api3_stripe_paymentintent_returnInvalid();
$intentParams['confirmation_method'] = 'manual';
if (empty($paymentIntentID) && empty($paymentMethodID)) {
$intentParams['confirm'] = FALSE;
$intentParams['confirmation_method'] = 'automatic';
}
if ($paymentIntentID) {
// We already have a PaymentIntent, retrieve and attempt confirm.
$intent = $paymentProcessor->stripeClient->paymentIntents->retrieve($paymentIntentID);
if ($intent->status === 'requires_confirmation') {
$intent->confirm();
}
if ($capture && $intent->status === 'requires_capture') {
$intent->capture();
}
}
else {
// We don't yet have a PaymentIntent, create one using the
// Payment Method ID and attempt to confirm it too.
try {
$intentParams['amount'] = $paymentProcessor->getAmount(['amount' => $amount, 'currency' => $currency]);
$intentParams['currency'] = $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 ($paymentMethodID) {
$intentParams['payment_method'] = $paymentMethodID;
}
$intent = $paymentProcessor->stripeClient->paymentIntents->create($intentParams);
$stripePaymentIntent = new CRM_Stripe_PaymentIntent($paymentProcessor);
$stripePaymentIntent->setDescription($description);
$stripePaymentIntent->setReferrer($_SERVER['HTTP_REFERER'] ?? '');
$stripePaymentIntent->setExtraData($params['extra_data'] ?? []);
if ($setup) {
$params = [
// Optional paymentMethodID
'paymentMethodID' => $paymentMethodID ?? NULL,
// 'customer => 'cus_xx',
];
$processIntentResult = $stripePaymentIntent->processSetupIntent($params);
if ($processIntentResult->ok) {
return civicrm_api3_create_success($processIntentResult->data);
}
catch (Exception $e)