Commit a4421acd authored by mattwire's avatar mattwire
Browse files

Support recurring payments with paymentIntents/Elements. Cancel subscription...

Support recurring payments with paymentIntents/Elements. Cancel subscription with Stripe when we reach recurring end date
parent 606d3ba5
......@@ -137,8 +137,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
public function supportsRecurring() {
// @fixme: Test and make this work for stripe elements / 6.0
return FALSE;
return TRUE;
}
/**
......@@ -151,7 +150,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
/**
* 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() {
......@@ -366,6 +365,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
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');
$params['paymentIntentID'] = $paymentIntentID;
}
else {
CRM_Core_Error::statusBounce(E::ts('Unable to complete payment! Missing paymentIntent ID.'));
......@@ -489,7 +489,9 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$params['contribution_status_id'] = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
// 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...
// For a single charge there is no stripe invoice.
$this->setPaymentProcessorOrderID($stripeCharge->id);
$this->setPaymentProcessorTrxnID($stripeCharge->id);
$newParams['fee_amount'] = $stripeBalanceTransaction->fee / 100;
$newParams['net_amount'] = $stripeBalanceTransaction->net / 100;
......@@ -537,13 +539,14 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
];
// Create the stripe subscription for the customer
$stripeSubscription = $stripeCustomer->subscriptions->create($subscriptionParams);
$this->setPaymentProcessorSubscriptionID($stripeSubscription->id);
$recurParams = [
'id' => $params['contributionRecurID'],
'trxn_id' => $stripeSubscription->id,
'trxn_id' => $this->getPaymentProcessorSubscriptionID(),
// 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,
'processor_id' => $this->getPaymentProcessorSubscriptionID(),
'auto_renew' => 1,
'cycle_day' => date('d'),
'next_sched_contribution_date' => $this->calculateNextScheduledDate($params),
......@@ -555,6 +558,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
if ($params['installments']) {
$recurParams['end_date'] = $this->calculateEndDate($params);
$recurParams['installments'] = $params['installments'];
}
}
......@@ -562,9 +566,11 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
CRM_Stripe_Hook::updateRecurringContribution($recurParams);
// Update the recurring payment
civicrm_api3('ContributionRecur', 'create', $recurParams);
// Update the contribution status
return $params;
// Set the orderID (trxn_id) to the invoice ID
// The IPN will change it to the charge_id
$this->setPaymentProcessorOrderID($stripeSubscription->latest_invoice);
return $this->endDoPayment($params);
}
/**
......
......@@ -60,10 +60,8 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
/**
* CRM_Core_Payment_StripeIPN constructor.
*
* @param $ipnData
* @param \stdClass $ipnData
* @param bool $verify
*
* @throws \CRM_Core_Exception
*/
public function __construct($ipnData, $verify = TRUE) {
$this->verify_event = $verify;
......@@ -71,47 +69,6 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
parent::__construct();
}
/**
* Set the value of is_email_receipt to use when a new contribution is received for a recurring contribution
* This is used for the API Stripe.Ipn function. If not set, we respect the value set on the ContributionRecur entity.
*
* @param int $sendReceipt The value of is_email_receipt
*/
public function setSendEmailReceipt($sendReceipt) {
switch ($sendReceipt) {
case 0:
$this->is_email_receipt = 0;
break;
case 1:
$this->is_email_receipt = 1;
break;
default:
$this->is_email_receipt = 0;
}
}
/**
* Get the value of is_email_receipt to use when a new contribution is received for a recurring contribution
* This is used for the API Stripe.Ipn function. If not set, we respect the value set on the ContributionRecur entity.
*
* @return int
* @throws \CiviCRM_API3_Exception
*/
public function getSendEmailReceipt() {
if (isset($this->is_email_receipt)) {
return (int) $this->is_email_receipt;
}
if (!empty($this->contribution_recur_id)) {
$this->is_email_receipt = civicrm_api3('ContributionRecur', 'getvalue', [
'return' => "is_email_receipt",
'id' => $this->contribution_recur_id,
]);
}
return (int) $this->is_email_receipt;
}
/**
* Store input array on the class.
* We override base because our input parameter is an object
......@@ -168,8 +125,10 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
}
/**
* @return bool
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Error\Api
*/
public function main() {
// Collect and determine all data about this event.
......@@ -178,132 +137,113 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
// NOTE: If you add an event here make sure you add it to the webhook or it will never be received!
switch($this->event_type) {
// Successful recurring payment.
case 'invoice.payment_succeeded':
// Successful recurring payment. Either we are completing an existing contribution or it's the next one in a subscription
$this->setInfo();
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
$this->completeContribution();
$params = [
'id' => $this->contribution['id'],
'trxn_date' => $this->receive_date,
'contribution_trxn_id' => $this->invoice_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
];
$this->updateContributionCompleted($params);
// Don't touch the contributionRecur as it's updated automatically by Contribution.completetransaction
}
elseif ($this->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.
$repeatParams = [
elseif ($this->contribution['trxn_id'] != $this->invoice_id) {
// Stripe has generated a new invoice (next payment in a subscription) so we
// create a new contribution in CiviCRM
$params = [
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_status_id' => 'Completed',
'receive_date' => $this->receive_date,
'trxn_id' => $this->charge_id,
'contribution_trxn_id' => $this->invoice_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'is_email_receipt' => $this->getSendEmailReceipt(),
'previous_contribution' => $this->previous_contribution,
];
if ($this->previous_contribution) {
$repeatParams['original_contribution_id'] = $this->previous_contribution['id'];
}
civicrm_api3('Contribution', 'repeattransaction', $repeatParams);
$this->repeatContribution($params);
// Don't touch the contributionRecur as it's updated automatically by Contribution.repeattransaction
}
// Successful charge & more to come.
civicrm_api3('ContributionRecur', 'create', [
'id' => $this->contribution_recur_id,
'failure_count' => 0,
'contribution_status_id' => 'In Progress'
]);
$this->handleInstallmentsForSubscription();
return TRUE;
// Failed recurring payment.
case 'invoice.payment_failed':
// Failed recurring payment. Either we are failing an existing contribution or it's the next one in a subscription
$this->setInfo();
$failDate = date('YmdHis');
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
// If this contribution is Pending, set it to Failed.
civicrm_api3('Contribution', 'create', [
$params = [
'id' => $this->contribution['id'],
'contribution_status_id' => "Failed",
'receive_date' => $failDate,
'is_email_receipt' => 0,
]);
'receive_date' => $this->receive_date,
'cancel_reason' => $this->retrieve('failure_message', 'String'),
'payment_trxn_id' => $this->charge_id,
];
$this->updateContributionFailed($params);
}
else {
$contributionParams = [
elseif ($this->contribution['trxn_id'] != $this->invoice_id) {
$params = [
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_status_id' => 'Failed',
'receive_date' => $failDate,
'receive_date' => $this->receive_date,
'contribution_trxn_id' => $this->invoice_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'is_email_receipt' => 0,
'fee_amount' => $this->fee,
'previous_contribution' => $this->previous_contribution,
];
civicrm_api3('Contribution', 'repeattransaction', $contributionParams);
$this->repeatContribution($params);
// Don't touch the contributionRecur as it's updated automatically by Contribution.completetransaction
}
$failureCount = civicrm_api3('ContributionRecur', 'getvalue', [
'id' => $this->contribution_recur_id,
'return' => 'failure_count',
]);
$failureCount++;
// Change the status of the Recurring and update failed attempts.
civicrm_api3('ContributionRecur', 'create', [
'id' => $this->contribution_recur_id,
'contribution_status_id' => "Failed",
'failure_count' => $failureCount,
'modified_date' => $failDate,
]);
return TRUE;
// Subscription is cancelled
case 'customer.subscription.deleted':
// Subscription is cancelled
$this->setInfo();
// Cancel the recurring contribution
civicrm_api3('ContributionRecur', 'cancel', [
'id' => $this->contribution_recur_id,
]);
$this->updateRecurCancelled(['id' => $this->contribution_recur_id, 'cancel_date' => $this->retrieve('cancel_date', 'String', FALSE)]);
return TRUE;
// One-time donation and per invoice payment.
case 'charge.failed':
$failureCode = $this->retrieve('failure_code', 'String');
$failureMessage = $this->retrieve('failure_message', 'String');
$chargeId = $this->retrieve('charge_id', 'String');
// @fixme: Check if "note" param actually does anything!
try {
$contribution = civicrm_api3('Contribution', 'getsingle', [
'trxn_id' => $chargeId,
'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
'return' => 'id'
]);
}
catch (Exception $e) {
// No failed contribution found, we won't record in CiviCRM for now
return TRUE;
}
$this->setInfo();
$params = [
'note' => "{$failureCode} : {$failureMessage}",
'contribution_id' => $contribution['id'],
'id' => $this->contribution['id'],
'receive_date' => $this->receive_date,
'cancel_reason' => $this->retrieve('failure_message', 'String'),
'payment_trxn_id' => $this->charge_id,
];
$this->recordFailed($params);
$this->updateContributionFailed($params);
return TRUE;
case 'charge.refunded':
$chargeId = $this->retrieve('charge_id', 'String');
$refunds = \Stripe\Refund::all(['charge' => $chargeId, 'limit' => 1]);
$this->setInfo();
$refunds = \Stripe\Refund::all(['charge' => $this->charge_id, 'limit' => 1]);
$params = [
'contribution_id' => civicrm_api3('Contribution', 'getvalue', [
'trxn_id' => $chargeId,
'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
'return' => 'id'
]),
'id' => $this->contribution['id'],
'total_amount' => $this->retrieve('amount_refunded', 'Float'),
'cancel_reason' => $refunds->data[0]->reason,
'cancel_date' => date('YmdHis', $refunds->data[0]->created),
];
$this->recordRefund($params);
$this->updateContributionRefund($params);
return TRUE;
case 'charge.succeeded':
$this->setInfo();
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
$this->recordCompleted(['contribution_id' => $this->contribution['id']]);
$params = [
'id' => $this->contribution['id'],
'trxn_date' => $this->receive_date,
'contribution_trxn_id' => $this->charge_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
];
$this->updateContributionCompleted($params);
}
return TRUE;
......@@ -322,42 +262,12 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
'frequency_unit' => $this->frequency_unit,
'frequency_interval' => $this->frequency_interval,
]);
civicrm_api3('Contribution', 'create', [
'id' => $this->contribution['id'],
'total_amount' => $this->plan_amount,
'contribution_recur_id' => $this->contribution_recur_id,
]);
return TRUE;
}
// Unhandled event type.
return TRUE;
}
/**
* Complete a pending contribution and update associated entities (recur/membership)
*
* @throws \CiviCRM_API3_Exception
*/
public function completeContribution() {
// Update the contribution to include the fee.
civicrm_api3('Contribution', 'create', [
'id' => $this->contribution['id'],
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
]);
// The last one was not completed, so complete it.
civicrm_api3('Contribution', 'completetransaction', [
'id' => $this->contribution['id'],
'trxn_date' => $this->receive_date,
'trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'payment_processor_id' => $this->_paymentProcessor->getPaymentProcessor()['id'],
'is_email_receipt' => $this->getSendEmailReceipt(),
]);
}
/**
* Gather and set info as class properties.
*
......@@ -431,7 +341,18 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
]);
}
catch (Exception $e) {
// Contribution not yet created?
// Contribution not found - that's ok
}
}
if (!$this->contribution && $this->invoice_id) {
try {
$this->contribution = civicrm_api3('Contribution', 'getsingle', [
'trxn_id' => $this->invoice_id,
'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
]);
}
catch (Exception $e) {
// Contribution not found - that's ok
}
}
if (!$this->contribution && $this->contribution_recur_id) {
......@@ -450,6 +371,42 @@ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {
$this->exception('Cannot find any contributions with recurring contribution ID: ' . $this->contribution_recur_id . '. ' . $e->getMessage());
}
}
if (!$this->contribution) {
$this->exception('No matching contributions for event ' . CRM_Stripe_Api::getParam('id', $this->_inputParameters));
}
}
/**
* This allows us to end a subscription once:
* a) We've reached the end date / number of installments
* b) The recurring contribution is marked as completed
*
* @throws \CiviCRM_API3_Exception
*/
private function handleInstallmentsForSubscription() {
if ((!$this->contribution_recur_id) || (!$this->subscription_id)) {
return;
}
$contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', [
'id' => $this->contribution_recur_id,
]);
if (empty($contributionRecur['installments']) && empty($contributionRecur['end_date'])) {
return;
}
$stripeSubscription = \Stripe\Subscription::retrieve($this->subscription_id);
// If we've passed the end date cancel the subscription
if (($stripeSubscription->current_period_end >= strtotime($contributionRecur['end_date']))
|| ($contributionRecur['contribution_status_id']
== CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_ContributionRecur', 'contribution_status_id', 'Completed'))) {
\Stripe\Subscription::update($this->subscription_id, ['cancel_at_period_end' => TRUE]);
$this->updateRecurCompleted(['id' => $this->contribution_recur_id]);
}
// There is no easy way of retrieving a count of all invoices for a subscription so we ignore the "installments"
// parameter for now and rely on checking end_date (which was calculated based on number of installments...)
// $stripeInvoices = \Stripe\Invoice::all(['subscription' => $this->subscription_id, 'limit' => 100]);
}
}
......@@ -9,20 +9,12 @@
*/
class CRM_Stripe_AJAX {
public static function getClientSecret() {
$amount = CRM_Utils_Request::retrieveValue('amount', 'Money', NULL, TRUE);
$currency = CRM_Utils_Request::retrieveValue('currency', 'String', CRM_Core_Config::singleton()->defaultCurrency);
$processorID = CRM_Utils_Request::retrieveValue('id', 'Integer', NULL, TRUE);
$processor = new CRM_Core_Payment_Stripe('', civicrm_api3('PaymentProcessor', 'getsingle', ['id' => $processorID]));
$processor->setAPIParams();
$intent = \Stripe\PaymentIntent::create([
'amount' => $processor->getAmount(['amount' => $amount]),
'currency' => $currency,
]);
CRM_Utils_JSON::output(['client_secret' => $intent->client_secret]);
}
/**
* Generate the paymentIntent for civicrm_stripe.js
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
*/
public static function confirmPayment() {
$paymentMethodID = CRM_Utils_Request::retrieveValue('payment_method_id', 'String', NULL, TRUE);
$paymentIntentID = CRM_Utils_Request::retrieveValue('payment_intent_id', 'String');
......@@ -57,10 +49,14 @@ class CRM_Stripe_AJAX {
}
}
self::generatePaymentResponse($intent);
}
/**
* Generate the json response for civicrm_stripe.js
*
* @param \Stripe\PaymentIntent $intent
*/
private static function generatePaymentResponse($intent) {
if ($intent->status == 'requires_action' &&
$intent->next_action->type == 'use_stripe_sdk') {
......@@ -68,9 +64,8 @@ class CRM_Stripe_AJAX {
CRM_Utils_JSON::output([
'requires_action' => true,
'payment_intent_client_secret' => $intent->client_secret,
//'payment_method_id' => $intent->payment_method,
]);
} else if ($intent->status == 'requires_capture') {
} else if (($intent->status == 'requires_capture') || ($intent->status == 'requires_confirmation')) {
// The payment intent has been confirmed, we just need to capture the payment
// Handle post-payment fulfillment
CRM_Utils_JSON::output([
......
......@@ -28,6 +28,9 @@ class CRM_Stripe_Api {
case 'balance_transaction':
return (string) $stripeObject->balance_transaction;
case 'receive_date':
return self::formatDate($stripeObject->created);
}
break;
......@@ -40,7 +43,7 @@ class CRM_Stripe_Api {
return (string) $stripeObject->id;
case 'receive_date':
return $stripeObject->date ? date("Y-m-d H:i:s", $stripeObject->date) : NULL;
return self::formatDate($stripeObject->created);
case 'subscription_id':
return (string) $stripeObject->subscription;
......@@ -71,6 +74,9 @@ class CRM_Stripe_Api {
case 'customer_id':
return (string) $stripeObject->customer;
case 'failure_message':
$stripeCharge = \Stripe\Charge::retrieve($stripeObject->charge);
return (string) $stripeCharge->failure_message;
}
break;
......@@ -96,7 +102,10 @@ class CRM_Stripe_Api {
return (string) $stripeObject->plan->name;
case 'plan_start':
return $stripeObject->start ? date("Y-m-d H:i:s", $stripeObject->start) : NULL;
return self::formatDate($stripeObject->start_date);
case 'cancel_date':
return self::formatDate($stripeObject->canceled_at);
case 'cycle_day':
return date("d", $stripeObject->billing_cycle_anchor);
......@@ -122,6 +131,16 @@ class CRM_Stripe_Api {
return NULL;
}
/**
* Return a formatted date from a stripe timestamp or NULL if not set
* @param int $stripeTimestamp
*
* @return string|null
*/
private static function formatDate($stripeTimestamp) {
return $stripeTimestamp ? date('YmdHis', $stripeTimestamp) : NULL;
}
public static function getParam($name, $stripeObject) {
// Common parameters
switch ($name) {
......@@ -131,6 +150,9 @@ class CRM_Stripe_Api {
case 'event_type':
return (string) $stripeObject->type;
case 'id':
return (string) $stripeObject->id;
case 'previous_plan_id':
if (preg_match('/\.updated$/', $stripeObject->type)) {
return (string) $stripeObject->data->previous_attributes->plan->id;
......
......@@ -116,7 +116,7 @@ class CRM_Stripe_Customer {
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function create($params, $stripe) {
$requiredParams = ['contact_id', 'card_token', 'processor_id'];
$requiredParams = ['contact_id', 'processor_id'];
// $optionalParams = ['email'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
......
......@@ -6,12 +6,6 @@
<title>Check and Fix Stripe Webhooks</title>
<access_arguments>access CiviCRM</access_arguments>
</item>
<item>
<path>civicrm/stripe/client-secret</path>
<page_callback>CRM_Stripe_AJAX::getClientSecret</page_callback>
<title>Client Secret</title>
<access_callback>1</access_callback>
</item>
<item>
<path>civicrm/stripe/confirm-payment</path>
<page_callback>CRM_Stripe_AJAX::confirmPayment</page_callback>
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment