<?php /* * @file * Handle Stripe Webhooks for recurring payments. */ class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN { use CRM_Core_Payment_StripeIPNTrait; /** * 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 */ protected $transaction_id; // 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; /** * Do we send an email receipt for each contribution? * * @var int */ protected $is_email_receipt = NULL; // Properties of the event. protected $event_type = NULL; protected $subscription_id = NULL; protected $customer_id = NULL; 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; protected $invoice_id = NULL; protected $receive_date = NULL; protected $amount = NULL; protected $fee = NULL; protected $net_amount = NULL; protected $previous_contribution = []; /** * CRM_Core_Payment_StripeIPN constructor. * * @param $ipnData * @param bool $verify * * @throws \CRM_Core_Exception */ public function __construct($ipnData, $verify = TRUE) { $this->verify_event = $verify; $this->setInputParameters($ipnData); 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 * * @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']); // 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(); } if ($this->verify_event) { $this->_inputParameters = \Stripe\Event::retrieve($parameters->id); } else { $this->_inputParameters = $parameters; } http_response_code(200); } /** * Get a parameter given to us by Stripe. * * @param string $name * @param $type * @param bool $abort * * @return false|int|null|string * @throws \CRM_Core_Exception */ 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; case 'Stripe\Subscription': switch ($name) { case 'frequency_interval': $value = $this->_inputParameters->data->object->plan->interval_count; break; case 'frequency_unit': $value = $this->_inputParameters->data->object->plan->interval; break; case 'plan_amount': $value = $this->_inputParameters->data->object->plan->amount / 100; break; case 'plan_id': $value = $this->_inputParameters->data->object->plan->id; break; case 'plan_name': $value = $this->_inputParameters->data->object->plan->name; break; case 'plan_start': $value = date("Y-m-d H:i:s", $this->_inputParameters->data->object->start); break; case 'subscription_id': $value = $this->_inputParameters->data->object->id; break; } break; } // Common parameters switch ($name) { case 'customer_id': $value = $this->_inputParameters->data->object->customer; break; case 'event_type': $value = $this->_inputParameters->type; break; case 'previous_plan_id': if (preg_match('/\.updated$/', $this->_inputParameters->type)) { $value = $this->_inputParameters->data->previous_attributes->plan->id; } break; } $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"); } return $value; } /** * @throws \CRM_Core_Exception * @throws \CiviCRM_API3_Exception */ 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' => $this->getSendEmailReceipt(), )); } // 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); } $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; // 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; // 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]); } return TRUE; 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 ]); } } } return TRUE; case 'charge.succeeded': $this->setInfo(); if ($this->previous_contribution['contribution_status_id'] == $pendingStatusId) { $this->completeContribution(); } return TRUE; 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, ]); 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', 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' => $this->getSendEmailReceipt(), )); } /** * 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. * * @throws \CRM_Core_Exception */ 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; } else { $this->amount = 0; $this->fee = 0; } } 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 the recurring contribution record associated with the Stripe subscription. try { $contributionRecur = civicrm_api3('ContributionRecur', 'getsingle', ['trxn_id' => $this->subscription_id]); $this->contribution_recur_id = $contributionRecur['id']; } catch (Exception $e) { $this->exception('Cannot find recurring contribution for subscription ID: ' . $this->subscription_id . '. ' . $e->getMessage()); } } // If a recurring contribution has been found, get the most recent contribution belonging to it. if ($this->contribution_recur_id) { try { // Same approach as api repeattransaction. $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' => isset($this->_paymentProcessor['is_test']) && $this->_paymentProcessor['is_test'] ? 1 : 0, 'options' => array('limit' => 1, 'sort' => 'id DESC'), )); $this->previous_contribution = $contribution; } catch (Exception $e) { $this->exception('Cannot find any contributions with recurring contribution ID: ' . $this->contribution_recur_id . '. ' . $e->getMessage()); } } } public function exception($message) { $errorMessage = 'StripeIPN Exception: Event: ' . $this->event_type . ' Error: ' . $message; Civi::log()->debug($errorMessage); http_response_code(400); exit(1); } }