Newer
Older
/**
* https://civicrm.org/licensing
/**
* Class CRM_Core_Payment_StripeIPN
*/
class CRM_Core_Payment_StripeIPN extends CRM_Core_Payment_BaseIPN {

mattwire
committed
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
*/
protected $transaction_id;
// By default, always retrieve the event from stripe to ensure we are

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

mattwire
committed
/**
* 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;

mattwire
committed
// Derived properties.
/**
* @var int The recurring contribution ID (linked to Stripe Subscription) (if available)
*/
protected $contribution_recur_id = NULL;
/**
* @var string The Stripe Event ID
*/
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;
/**
* @var float The amount paid
*/
/**
* @var float The fee charged by Stripe
*/
/**
* @var array The current contribution (linked to Stripe charge(single)/invoice(subscription)
*/
/**
* CRM_Core_Payment_StripeIPN constructor.
*
* @param \stdClass $ipnData
public function __construct($ipnData, $verify = TRUE) {
$this->verify_event = $verify;
$this->setInputParameters($ipnData);
parent::__construct();
}
/**
* Store input array on the class.
* We override base because our input parameter is an object
*
* @param array $parameters

mattwire
committed
*/
public function setInputParameters($parameters) {
// Determine the proper Stripe Processor ID so we can get the secret key
// and initialize Stripe.
$this->_paymentProcessor->setAPIParams();
if (!is_object($parameters)) {
$this->exception('Invalid input parameters');
}
// Now re-retrieve the data from Stripe to ensure it's legit.
// Special case if this is the test webhook
if (substr($parameters->id, -15, 15) === '_00000000000000') {
http_response_code(200);
$test = (boolean) $this->_paymentProcessor->getPaymentProcessor()['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) {
$value = CRM_Stripe_Api::getObjectParam($name, $this->_inputParameters->data->object);
$value = CRM_Utils_Type::validate($value, $type, FALSE);
if ($abort && $value === NULL) {
echo "Failure: Missing or invalid parameter<p>" . CRM_Utils_Type::escape($name, 'String');
$this->exception("Missing or invalid parameter {$name}");
}
return $value;
}
* @return bool
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Error\Api
// Collect and determine all data about this event.
$this->event_type = CRM_Stripe_Api::getParam('event_type', $this->_inputParameters);
$pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
// 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) {
case 'invoice.payment_succeeded':
// Successful recurring payment. Either we are completing an existing contribution or it's the next one in a subscription

mattwire
committed
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
$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->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,
'contribution_trxn_id' => $this->invoice_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'original_contribution_id' => $this->contribution['id'],
$this->repeatContribution($params);
// Don't touch the contributionRecur as it's updated automatically by Contribution.repeattransaction
$this->handleInstallmentsForSubscription();
case 'invoice.payment_failed':
// Failed recurring payment. Either we are failing an existing contribution or it's the next one in a subscription

mattwire
committed
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
// If this contribution is Pending, set it to Failed.
$params = [

mattwire
committed
'id' => $this->contribution['id'],
'receive_date' => $this->receive_date,
'cancel_reason' => $this->retrieve('failure_message', 'String'),
'payment_trxn_id' => $this->charge_id,
];
$this->updateContributionFailed($params);
elseif ($this->contribution['trxn_id'] != $this->invoice_id) {
$params = [
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_status_id' => 'Failed',
'receive_date' => $this->receive_date,
'contribution_trxn_id' => $this->invoice_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
'original_contribution_id' => $this->contribution['id'],
];
$this->repeatContribution($params);
// Don't touch the contributionRecur as it's updated automatically by Contribution.completetransaction
case 'customer.subscription.deleted':
// Subscription is cancelled
$this->updateRecurCancelled(['id' => $this->contribution_recur_id, 'cancel_date' => $this->retrieve('cancel_date', 'String', FALSE)]);
// One-time donation and per invoice payment.
case 'charge.failed':
// If we don't have a customer_id we can't do anything with it!
// It's quite likely to be a fraudulent/spam so we ignore.
if (empty(CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object))) {
return TRUE;
}
$this->setInfo();

mattwire
committed
$params = [
'id' => $this->contribution['id'],
'receive_date' => $this->receive_date,
'cancel_reason' => $this->retrieve('failure_message', 'String'),
'payment_trxn_id' => $this->charge_id,

mattwire
committed
];
$this->updateContributionFailed($params);
case 'charge.refunded':
// Cancelling an uncaptured paymentIntent triggers charge.refunded but we don't want to process that
if (empty(CRM_Stripe_Api::getObjectParam('captured', $this->_inputParameters->data->object))) {
return TRUE;
};
// This charge was actually captured, so record the refund in CiviCRM
$this->setInfo();
// This gives us the actual amount refunded
$amountRefunded = CRM_Stripe_Api::getObjectParam('amount_refunded', $this->_inputParameters->data->object);
// This gives us the refund date + reason code
$refunds = \Stripe\Refund::all(['charge' => $this->charge_id, 'limit' => 1]);
// This gets the fee refunded
$this->setBalanceTransactionDetails($refunds->data[0]->balance_transaction);

mattwire
committed
$params = [
'contribution_id' => $this->contribution['id'],
'total_amount' => 0 - abs($amountRefunded),
'trxn_date' => date('YmdHis', $refunds->data[0]->created),
'trxn_result_code' => $refunds->data[0]->reason,
'fee_amount' => 0 - abs($this->fee),
'trxn_id' => $this->charge_id,
'order_reference' => $this->invoice_id ?? NULL,

mattwire
committed
];
$this->updateContributionRefund($params);
case 'charge.succeeded':
// For a recurring contribution we can process charge.succeeded once we receive the event with an invoice ID.
// For a single contribution we can't process charge.succeeded because it only triggers BEFORE the charge is captured
if (empty(CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object))) {
return TRUE;
};
case 'charge.captured':
// For a single contribution we have to use charge.captured because it has the customer_id.
$this->setInfo();

mattwire
committed
if ($this->contribution['contribution_status_id'] == $pendingStatusId) {
$params = [
'id' => $this->contribution['id'],
'trxn_date' => $this->receive_date,
'contribution_trxn_id' => $this->invoice_id ?: $this->charge_id,
'payment_trxn_id' => $this->charge_id,
'total_amount' => $this->amount,
'fee_amount' => $this->fee,
];
$this->updateContributionCompleted($params);
}
case 'customer.subscription.updated':

mattwire
committed
$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,

mattwire
committed
]);
}
// Unhandled event type.

mattwire
committed
/**
* Gather and set info as class properties.
*
* Given the data passed to us via the Stripe Event, try to determine

mattwire
committed
* as much as we can about this event and set that information as
* properties to be used later.
$stripeObjectName = get_class($this->_inputParameters->data->object);
$this->customer_id = CRM_Stripe_Api::getObjectParam('customer_id', $this->_inputParameters->data->object);
if (empty($this->customer_id)) {
$this->exception('Missing customer_id!');
}
$this->previous_plan_id = CRM_Stripe_Api::getParam('previous_plan_id', $this->_inputParameters);
$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->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);
if (($stripeObjectName !== 'Stripe\Charge') && ($this->charge_id !== NULL)) {
$charge = \Stripe\Charge::retrieve($this->charge_id);
$balanceTransactionID = CRM_Stripe_Api::getObjectParam('balance_transaction', $charge);
}
else {
$balanceTransactionID = CRM_Stripe_Api::getObjectParam('balance_transaction', $this->_inputParameters->data->object);
}
$this->setBalanceTransactionDetails($balanceTransactionID);
// 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());
}
}
$contributionParamsToReturn = [
'id',
'trxn_id',
'contribution_status_id',
'total_amount',
'fee_amount',
'net_amount',
'tax_amount',
];
if ($this->charge_id) {
try {
$this->contribution = civicrm_api3('Contribution', 'getsingle', [
'trxn_id' => $this->charge_id,
'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
'return' => $contributionParamsToReturn,
]);
}
catch (Exception $e) {
// 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(),
'return' => $contributionParamsToReturn,
]);
}
catch (Exception $e) {
// Contribution not found - that's ok
if (!$this->contribution && $this->contribution_recur_id) {
// If a recurring contribution has been found, get the most recent contribution belonging to it.
try {
// Same approach as api repeattransaction.
$this->contribution = civicrm_api3('contribution', 'getsingle', [

mattwire
committed
'return' => ['id', 'contribution_status_id', 'total_amount', 'trxn_id'],
'contribution_recur_id' => $this->contribution_recur_id,
'contribution_test' => $this->_paymentProcessor->getIsTestMode(),
'return' => $contributionParamsToReturn,

mattwire
committed
'options' => ['limit' => 1, 'sort' => 'id DESC'],
]);
}
catch (Exception $e) {
$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));
}
}
private function setBalanceTransactionDetails($balanceTransactionID) {
// Gather info about the amount and fee.
// Get the Stripe charge object if one exists. Null charge still needs processing.
// If the transaction is declined, there won't be a balance_transaction_id.
$this->amount = 0.0;
$this->fee = 0.0;
if ($balanceTransactionID) {
try {
$balanceTransaction = \Stripe\BalanceTransaction::retrieve($balanceTransactionID);
$this->amount = $balanceTransaction->amount / 100;
$this->fee = $balanceTransaction->fee / 100;
}
catch(Exception $e) {
$this->exception('Error retrieving balance transaction. ' . $e->getMessage());
}
}
}
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
/**
* 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]);