diff --git a/CRM/Core/Payment/Stripe.php b/CRM/Core/Payment/Stripe.php
index 9ca4fab..c0e3eee 100644
--- a/CRM/Core/Payment/Stripe.php
+++ b/CRM/Core/Payment/Stripe.php
@@ -203,6 +203,9 @@ public function stripeCatchErrors($op = 'create_customer', $stripe_params, $para
$body = $e->getJsonBody();
$err = $body['error'];
+ if (!array_key_exists('code', $err)) {
+ $err['code'] = null;
+ }
//$error_message .= 'Status is: ' . $e->getHttpStatus() . "
";
////$error_message .= 'Param is: ' . $err['param'] . "
";
$error_message .= 'Type: ' . $err['type'] . "
";
@@ -820,6 +823,7 @@ public function doRecurPayment(&$params, $amount, $stripe_customer) {
// Don't return a $params['trxn_id'] here or else recurring membership contribs will be set
// "Completed" prematurely. Webhook.php does that.
+ $params['subscription_id'] = $subscription_id;
return $params;
}
diff --git a/CRM/Stripe/Page/Webhook.php b/CRM/Stripe/Page/Webhook.php
index 7eda0ea..28d5b1b 100644
--- a/CRM/Stripe/Page/Webhook.php
+++ b/CRM/Stripe/Page/Webhook.php
@@ -7,7 +7,6 @@
require_once 'CRM/Core/Page.php';
class CRM_Stripe_Page_Webhook extends CRM_Core_Page {
- function run() {
function getRecurInfo($subscription_id,$test_mode) {
$query_params = array(
@@ -75,9 +74,19 @@ function getRecurInfo($subscription_id,$test_mode) {
return $recurring_info;
}
+
+ function run($data = null) {
// Get the data from Stripe.
- $data_raw = file_get_contents("php://input");
- $data = json_decode($data_raw);
+ $is_email_receipt = 1;
+ // Don't send emails while running php unit tests.
+ if (defined('STRIPE_PHPUNIT_TEST')) {
+ $is_email_receipt = 0;
+ }
+
+ if (is_null($data)) {
+ $data_raw = file_get_contents("php://input");
+ $data = json_decode($data_raw);
+ }
if (!$data) {
header('HTTP/1.1 406 Not acceptable');
CRM_Core_Error::Fatal("Stripe Callback: cannot json_decode data, exiting.
$data");
@@ -90,13 +99,15 @@ function getRecurInfo($subscription_id,$test_mode) {
$processorId = CRM_Utils_Request::retrieve('ppid', 'Integer');
try {
if (empty($processorId)) {
- $stripe_key = civicrm_api3('PaymentProcessor', 'getvalue', array(
- 'return' => 'user_name',
+ $processor_result = civicrm_api3('PaymentProcessor', 'get', array(
+ 'return' => array('user_name', 'id'),
'payment_processor_type_id' => 'Stripe',
'is_test' => $test_mode,
'is_active' => 1,
'options' => array('limit' => 1),
));
+ $processorId = $processor_result['id'];
+ $stripe_key = $processor_result['values'][$processorId]['user_name'];
}
else {
$stripe_key = civicrm_api3('PaymentProcessor', 'getvalue', array(
@@ -115,9 +126,18 @@ function getRecurInfo($subscription_id,$test_mode) {
\Stripe\Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
\Stripe\Stripe::setApiKey($stripe_key);
- // Retrieve Event from Stripe using ID even though we already have the values now.
- // This is for extra security precautions mentioned here: https://stripe.com/docs/webhooks
- $stripe_event_data = \Stripe\Event::retrieve($data->id);
+ if (defined('STRIPE_PHPUNIT_TEST') && $data->type == 'invoice.payment_failed') {
+ // It's impossible to fake a failed payment on a recurring
+ // contribution in an automated way, so we are faking it in
+ // unit test.
+ $stripe_event_data = $data;
+ }
+ else {
+ // Retrieve Event from Stripe using ID even though we already have the values now.
+ // This is for extra security precautions mentioned here: https://stripe.com/docs/webhooks
+ $stripe_event_data = \Stripe\Event::retrieve($data->id);
+ }
+
$customer_id = $stripe_event_data->data->object->customer;
switch($stripe_event_data->type) {
@@ -149,7 +169,7 @@ function getRecurInfo($subscription_id,$test_mode) {
}
// First, get the recurring contribution id and previous contribution id.
- $recurring_info = getRecurInfo($subscription_id,$test_mode);
+ $recurring_info = self::getRecurInfo($subscription_id,$test_mode);
// Fetch the previous contribution's status.
$previous_contribution = civicrm_api3('Contribution', 'get', array(
@@ -192,8 +212,9 @@ function getRecurInfo($subscription_id,$test_mode) {
'trxn_id' => $charge_id,
'total_amount' => $amount,
'fee_amount' => $fee,
+ 'payment_processor_id' => $processorId,
+ 'is_email_receipt' => $is_email_receipt,
));
-
return;
} else {
@@ -215,9 +236,8 @@ function getRecurInfo($subscription_id,$test_mode) {
'total_amount' => $amount,
'fee_amount' => $fee,
//'invoice_id' => $new_invoice_id - contribution.repeattransaction doesn't support it currently
- 'is_email_receipt' => 1,
- ));
-
+ 'is_email_receipt' => $is_email_receipt,
+ ));
// Update invoice_id manually. repeattransaction doesn't return the new contrib id either, so we update the db.
$query_params = array(
1 => array($new_invoice_id, 'String'),
@@ -264,9 +284,9 @@ function getRecurInfo($subscription_id,$test_mode) {
$transaction_id = $charge->id;
// First, get the recurring contribution id and previous contribution id.
- $recurring_info = getRecurInfo($subscription_id,$test_mode);
-
- // Fetch the previous contribution's status.
+ $recurring_info = self::getRecurInfo($subscription_id,$test_mode);
+
+ // Fetch the previous contribution's status.
$previous_contribution_status = civicrm_api3('Contribution', 'getvalue', array(
'sequential' => 1,
'return' => "contribution_status_id",
@@ -284,7 +304,7 @@ function getRecurInfo($subscription_id,$test_mode) {
'financial_type_id' => $recurring_info->financial_type_id,
'receive_date' => $fail_date,
'total_amount' => $amount,
- 'is_email_receipt' => 1,
+ 'is_email_receipt' => $is_email_receipt,
'is_test' => $test_mode,
));
@@ -299,7 +319,7 @@ function getRecurInfo($subscription_id,$test_mode) {
'financial_type_id' => $recurring_info->financial_type_id,
'receive_date' => $fail_date,
'total_amount' => $amount,
- 'is_email_receipt' => 1,
+ 'is_email_receipt' => $is_email_receipt,
'is_test' => $test_mode,
));
}
@@ -329,7 +349,7 @@ function getRecurInfo($subscription_id,$test_mode) {
$subscription_id = $stripe_event_data->data->object->id;
// First, get the recurring contribution id and previous contribution id.
- $recurring_info = getRecurInfo($subscription_id,$test_mode);
+ $recurring_info = self::getRecurInfo($subscription_id,$test_mode);
//Cancel the recurring contribution
$result = civicrm_api3('ContributionRecur', 'cancel', array(
@@ -372,8 +392,8 @@ function getRecurInfo($subscription_id,$test_mode) {
$new_civi_invoice = md5(uniqid(rand(), TRUE));
// First, get the recurring contribution id and previous contribution id.
- $recurring_info = getRecurInfo($subscription_id,$test_mode);
-
+ $recurring_info = self::getRecurInfo($subscription_id,$test_mode);
+
// Is there a pending charge due to a subcription change? Make up your mind!!
$previous_contribution = civicrm_api3('Contribution', 'get', array(
'sequential' => 1,
@@ -486,7 +506,6 @@ function getRecurInfo($subscription_id,$test_mode) {
}
- parent::run();
}
}
diff --git a/README.md b/README.md
index df41ab0..21067b7 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,37 @@ Inside the customer you will see a Subscriptions section. Click Cancel on the su
Stripe.com will cancel the subscription and will send a webhook to your site (if you have set the webhook options correctly).
Then the stripe_civicrm extension will process the webhook and cancel the Civi recurring contribution.
+API
+------------
+This extension comes with several APIs to help you troubleshoot problems. These can be run via /civicrm/api or via drush if you are using Drupal (drush cvapi Stripe.XXX).
+
+The api commands are:
+
+ * Listevents: Events are the notifications that Stripe sends to the Webhook. Listevents will list all notifications that have been sent. You can further restrict them with the following parameters:
+ * ppid - Use the given Payment Processor ID. By default, uses the saved, live Stripe payment processor and throws an error if there is more than one.
+ * type - Limit to the given Stripe events type. By default, show all. Optinally limit to, for example, invoice.payment_succeeded.
+ * limit - Limit number of results returned (100 is max, 10 is default).
+ * starting_after - Only return results after this event id. This can be used for paging purposes - if you want to retreive more than 100 results.
+ * Populatelog: If you are running a version of CiviCRM that supports the SystemLog - then this API call will populate your SystemLog with all of your past Stripe Events. You can safely re-run and not create duplicates. With a populated SystemLog - you can selectively replay events that may have caused errors the first time or otherwise not been properly recorded. Parameters:
+ * ppid - Use the given Payment Processor ID. By default, uses the saved, live Stripe payment processor and throws an error if there is more than one.
+ * Ipn: Replay a given Stripe Event. Parameters. This will always fetch the chosen Event from Stripe before replaying.
+ * id - The id from the SystemLog of the event to replay.
+ * evtid - The Event ID as provided by Stripe.
+ * ppid - Use the given Payment Processor ID. By default, uses the saved, live Stripe payment processor and throws an error if there is more than one.
+ * noreceipt - Set to 1 if you want to suppress the generation of receipts or set to 0 or leave out to send receipts normally.
+
+TESTS
+------------
+This extension comes with two PHP Unit tests:
+
+ * Ipn - This unit test ensures that a recurring contribution is properly updated after the event is received from Stripe and that it is properly canceled when cancelled via Stripe.
+ * Direct - This unit test ensures that a direct payment to Stripe is properly recorded in the database.
+
+Tests can be run most easily via an installation made through CiviCRM Buildkit (https://github.com/civicrm/civicrm-buildkit) by running:
+
+ phpunit4 tests/phpunit/CRM/Stripe/IpnTest.php
+ phpunit4 tests/phpunit/CRM/Stripe/DirectTest.php
+
GOOD TO KNOW
------------
* The stripe-php package has been added to this project & no longer needs to be
diff --git a/api/v3/Stripe/Ipn.php b/api/v3/Stripe/Ipn.php
new file mode 100644
index 0000000..1724baa
--- /dev/null
+++ b/api/v3/Stripe/Ipn.php
@@ -0,0 +1,95 @@
+ $params['id'], 'return' => array('message', 'context')));
+ if (empty($data)) {
+ throw new API_Exception('Failed to find that entry in the system log', 3234);
+ }
+ $object = json_decode($data['context']);
+ if (preg_match('/processor_id=([0-9]+)$/', $object['message'], $matches)) {
+ $ppid = $matches[1];
+ }
+ else {
+ throw new API_Exception('Failed to find payment processor id in system log', 3235);
+ }
+ }
+ elseif (array_key_exists('evtid', $params)) {
+ if (!array_key_exists('ppid', $params)) {
+ throw new API_Exception('Please pass the payment processor id (ppid) if using evtid.', 3236);
+ }
+ $ppid = $params['ppid'];
+ $results = civicrm_api3('PaymentProcessor', 'getsingle', array('id' => $ppid));
+ // YES! I know, password and user are backwards. wtf??
+ $sk = $results['user_name'];
+
+ require_once ("packages/stripe-php/init.php");
+ \Stripe\Stripe::setApiKey($sk);
+ $object = \Stripe\Event::retrieve($params['evtid']);
+ }
+ // Avoid a SQL error if this one has been processed already.
+ $sql = "SELECT COUNT(*) AS count FROM civicrm_contribution WHERE trxn_id = %0";
+ $count_params = array(
+ 'trxn_id' => $object->data->object->charge,
+ );
+ $count_result = civicrm_api3('Contribution', 'get', $count_params);
+ if ($count_resulst['count'] > 0) {
+ return civicrm_api3_create_error("Ipn already processed.");
+ }
+ if (class_exists('CRM_Core_Payment_StripeIPN')) {
+ // The $_GET['processor_id'] value is normally set by
+ // CRM_Core_Payment::handlePaymentMethod
+ $_GET['processor_id'] = $ppid;
+ $ipnClass = new CRM_Core_Payment_StripeIPN($object);
+ if ($params['noreceipt'] == 1) {
+ $ipnClass->is_email_receipt = 0;
+ }
+ $ipnClass->main();
+ }
+ else {
+ // Deprecated method.
+ $_REQUEST['ppid'] = $ppid;
+ $stripe = new CRM_Stripe_Page_Webhook();
+ $stripe->run($object);
+ }
+ return civicrm_api3_create_success(array());
+
+}
diff --git a/api/v3/Stripe/Listevents.php b/api/v3/Stripe/Listevents.php
new file mode 100644
index 0000000..be8eca4
--- /dev/null
+++ b/api/v3/Stripe/Listevents.php
@@ -0,0 +1,260 @@
+ $ppid);
+ }
+ else {
+ // By default, select the live stripe processor (we expect there to be
+ // only one).
+ $query_params = array('class_name' => 'Payment_Stripe', 'is_test' => 0);
+ }
+ try {
+ $results = civicrm_api3('PaymentProcessor', 'getsingle', $query_params);
+ // YES! I know, password and user are backwards. wtf??
+ $sk = $results['user_name'];
+ }
+ catch (CiviCRM_API3_Exception $e) {
+ if(preg_match('/Expected one PaymentProcessor but/', $e->getMessage())) {
+ throw new API_Exception("Expected one live Stripe payment processor, but found none or more than one. Please specify ppid=.", 1234);
+ }
+ else {
+ throw new API_Exception("Error getting the Stripe Payment Processor to use", 1235);
+ }
+ }
+
+ // Check to see if we should filter by type.
+ if (array_key_exists('type', $params) ) {
+ // Validate - since we will be appending this to an URL.
+ if (!civicrm_api3_stripe_VerifyEventType($params['type'])) {
+ throw new API_Exception("Unrecognized Event Type.", 1236);
+ }
+ else {
+ $type = $params['type'];
+ }
+ }
+
+ // Created can only be passed in as an array
+ if (array_key_exists('created', $params)) {
+ $created = $params['created'];
+ if (!is_array($created)) {
+ throw new API_Exception("Created can only be passed in programatically as an array", 1237);
+ }
+ }
+ return array('sk' => $sk, 'type' => $type, 'created' => $created, 'limit' => $limit, 'starting_after' => $starting_after);
+}
+
+/**
+ * Stripe.ListEvents API
+ *
+ * @param array $params
+ * @return array API result descriptor
+ * @see civicrm_api3_create_success
+ * @see civicrm_api3_create_error
+ * @throws API_Exception
+ */
+function civicrm_api3_stripe_Listevents($params) {
+ $parsed = civicrm_api3_stripe_ProcessParams($params);
+ $sk = $parsed['sk'];
+ $type = $parsed['type'];
+ $created = $parsed['created'];
+ $limit = $parsed['limit'];
+ $starting_after = $parsed['starting_after'];
+
+ $args = array();
+ if ($type) {
+ $args['type'] = $type;
+ }
+ if ($created) {
+ $args['created'] = $created;
+ }
+ if ($limit) {
+ $args['limit'] = $limit;
+ }
+ if ($starting_after) {
+ $args['starting_after'] = $starting_after;
+ }
+
+ require_once ("packages/stripe-php/init.php");
+ \Stripe\Stripe::setApiKey($sk);
+ $data_list = \Stripe\Event::all($args);
+ if (array_key_exists('error', $data_list)) {
+ $err = $data_list['error'];
+ throw new API_Exception(/*errorMessage*/ "Stripe returned an error: " . $err->message, /*errorCode*/ $err->type);
+ }
+ $out = $data_list;
+ if ($params['output'] == 'brief') {
+ $out = array();
+ foreach($data_list['data'] as $data) {
+ $item = array(
+ 'id' => $data['id'],
+ 'created' => date('Y-m-d H:i:s', $data['created']),
+ 'livemode' => $data['livemode'],
+ 'pending_webhooks' => $data['pending_webhooks'],
+ 'type' => $data['type'],
+ );
+ if (preg_match('/invoice\.payment_/', $data['type'])) {
+ $item['invoice'] = $data['data']['object']->id;
+ $item['charge'] = $data['data']['object']->charge;
+ $item['customer'] = $data['data']['object']->customer;
+ $item['subscription'] = $data['data']['object']->subscription;
+ $item['total'] = $data['data']['object']->total;
+
+ // Check if this is in the contributions table.
+ $item['processed'] = 'no';
+ $results = civicrm_api3('Contribution', 'get', array('trxn_id' => $item['charge']));
+ if ($results['count'] > 0) {
+ $item['processed'] = 'yes';
+ }
+ }
+ $out[] = $item;
+ }
+ }
+ return civicrm_api3_create_success($out);
+
+}
+
+
diff --git a/api/v3/Stripe/Populatelog.php b/api/v3/Stripe/Populatelog.php
new file mode 100644
index 0000000..639a2b7
--- /dev/null
+++ b/api/v3/Stripe/Populatelog.php
@@ -0,0 +1,97 @@
+ 'Payment_Stripe', 'is_test' => 0, 'return' => 'id');
+ try {
+ $ppid = civicrm_api3('PaymentProcessor', 'getvalue', $params);
+ }
+ catch (CiviCRM_API3_Exception $e) {
+ throw new API_Exception("Expected one live Stripe payment processor, but found none or more than one. Please specify ppid=.", 2234);
+ }
+ }
+
+ $params = array('limit' => 100, 'type' => 'invoice.payment_succeeded');
+ if ($ppid) {
+ $params['ppid'] = $ppid;
+ }
+
+ $items = array();
+ $last_item = NULL;
+ $more = TRUE;
+ while(1) {
+ if ($last_item) {
+ $params['starting_after'] = $last_item->id;
+ }
+ $objects = civicrm_api3('Stripe', 'Listevents', $params);
+
+ if (count($objects['values']['data']) == 0) {
+ // No more!
+ break;
+ }
+ $items = array_merge($items, $objects['values']['data']);
+ $last_item = end($objects['values']['data']);
+ }
+ $results = array();
+ foreach($items as $item) {
+ $id = $item->id;
+ // Insert into System Log if it doesn't exist.
+ $like_event_id = '%event_id=' . addslashes($id);
+ $sql = "SELECT id FROM civicrm_system_log WHERE message LIKE '$like_event_id'";
+ $dao= CRM_Core_DAO::executeQuery($sql);
+ if ($dao->N == 0) {
+ $message = "payment_notification processor_id=${ppid} event_id=${id}";
+ $contact_id = civicrm_api3_stripe_cid_for_trxn($item->data->object->charge);
+ if ($contact_id) {
+ $item['contact_id'] = $contact_id;
+ }
+ $log = new CRM_Utils_SystemLogger();
+ $log->alert($message, $item);
+ $results[] = $id;
+ }
+ }
+ return civicrm_api3_create_success($results);
+
+}
+
+function civcrm_api3_stripe_cid_for_trxn($trxn) {
+ $params = array('trxn_id' => $trxn, 'return' => 'contact_id');
+ $result = civicrm_api3('Contribution', 'getvalue', $params);
+ return $result;
+}
diff --git a/api/v3/Stripe/Setuptest.php b/api/v3/Stripe/Setuptest.php
new file mode 100644
index 0000000..730fa80
--- /dev/null
+++ b/api/v3/Stripe/Setuptest.php
@@ -0,0 +1,57 @@
+ 'Stripe',
+ 'domain_id' => CRM_Core_Config::domainID(),
+ 'payment_processor_type_id' => 'Stripe',
+ 'title' => 'Stripe',
+ 'is_active' => 1,
+ 'is_default' => 0,
+ 'is_test' => 1,
+ 'is_recur' => 1,
+ 'user_name' => $params['sk'],
+ 'password' => $params['pk'],
+ 'url_site' => 'https://api.stripe.com/v1',
+ 'url_recur' => 'https://api.stripe.com/v1',
+ 'class_name' => 'Payment_Stripe',
+ 'billing_mode' => 1
+ );
+ // First see if it already exists.
+ $result = civicrm_api3('PaymentProcessor', 'get', $params);
+ if ($result['count'] != 1) {
+ // Nope, create it.
+ $result = civicrm_api3('PaymentProcessor', 'create', $params);
+ }
+ return civicrm_api3_create_success($result['values']);
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..0f9f25d
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,18 @@
+
+
+
+
+ ./tests/phpunit
+
+
+
+
+ ./
+
+
+
+
+
+
+
+
diff --git a/tests/phpunit/CRM/Stripe/BaseTest.php b/tests/phpunit/CRM/Stripe/BaseTest.php
new file mode 100644
index 0000000..f11febb
--- /dev/null
+++ b/tests/phpunit/CRM/Stripe/BaseTest.php
@@ -0,0 +1,254 @@
+installMe(__DIR__)
+ ->apply();
+ }
+
+ public function setUp() {
+ parent::setUp();
+ require_once('stripe-php/init.php');
+ $this->createPaymentProcessor();
+ $this->createContact();
+ $this->createContributionPage();
+ $this->_created_ts = time();
+ $this->set_cc();
+ }
+
+ /**
+ * Switch between test cc number that works and that fails
+ *
+ */
+ public function set_cc($type = 'works') {
+ // See https://stripe.com/docs/testing
+ if ($type == 'works') {
+ $this->_cc = '4111111111111111';
+ }
+ elseif ($type == 'fails') {
+ $this->_cc = '4000000000000002';
+ }
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Create contact.
+ */
+ function createContact() {
+ if (!empty($this->_contactID)) {
+ return;
+ }
+ $results = civicrm_api3('Contact', 'create', array(
+ 'contact_type' => 'Individual',
+ 'first_name' => 'Jose',
+ 'last_name' => 'Lopez'
+ ));;
+ $this->_contactID = $results['id'];
+ $this->contact = (Object) array_pop($results['values']);
+
+ // Now we have to add an email address.
+ $email = 'susie@example.org';
+ civicrm_api3('email', 'create', array(
+ 'contact_id' => $this->_contactID,
+ 'email' => $email,
+ 'location_type_id' => 1
+ ));
+ $this->contact->email = $email;
+ }
+
+ /**
+ * Create a stripe payment processor.
+ *
+ */
+ function createPaymentProcessor($params = array()) {
+
+ $result = civicrm_api3('Stripe', 'setuptest', $params);
+ $processor = array_pop($result['values']);
+ $this->_sk = $processor['user_name'];
+ $this->_pk = $processor['password'];
+ $this->_paymentProcessor = $processor;
+ $this->_paymentProcessorID = $result['id'];
+ }
+
+ /**
+ * Create a stripe contribution page.
+ *
+ */
+ function createContributionPage($params = array()) {
+ $params = array_merge(array(
+ 'title' => "Test Contribution Page",
+ 'financial_type_id' => $this->_financialTypeID,
+ 'currency' => 'USD',
+ 'payment_processor' => $this->_paymentProcessorID,
+ 'max_amount' => 1000,
+ 'receipt_from_email' => 'gaia@the.cosmos',
+ 'receipt_from_name' => 'Pachamama',
+ 'is_email_receipt' => FALSE,
+ ), $params);
+ $result = civicrm_api3('ContributionPage', 'create', $params);
+ $this->assertEquals(0, $result['is_error']);
+ $this->_contributionPageID = $result['id'];
+ }
+
+ /**
+ * Submit to stripe
+ */
+ public function doPayment($params = array()) {
+ $mode = 'test';
+ $pp = $this->_paymentProcessor;
+ $stripe = new CRM_Core_Payment_Stripe($mode, $pp);
+ $params = array_merge(array(
+ 'payment_processor_id' => $this->_paymentProcessorID,
+ 'amount' => $this->_total,
+ 'stripe_token' => array(
+ 'number' => $this->_cc,
+ 'exp_month' => '12',
+ 'exp_year' => date('Y') + 1,
+ 'cvc' => '123',
+ 'name' => $this->contact->display_name,
+ 'address_line1' => '123 4th Street',
+ 'address_state' => 'NY',
+ 'address_zip' => '12345',
+ ),
+ 'email' => $this->contact->email,
+ 'description' => 'Test from Stripe Test Code',
+ 'currencyID' => 'USD',
+ 'invoiceID' => $this->_invoiceID,
+ ), $params);
+
+ $ret = $stripe->doDirectPayment($params);
+ if (array_key_exists('trxn_id', $ret)) {
+ $this->_trxn_id = $ret['trxn_id'];
+ }
+ if (array_key_exists('subscription_id', $ret)) {
+ $this->_subscriptionID = $ret['subscription_id'];
+ }
+ }
+
+ /**
+ * Confirm that transaction id is legit and went through.
+ *
+ */
+ public function assertValidTrxn() {
+ $this->assertNotEmpty($this->_trxn_id, "A trxn id was assigned");
+
+ \Stripe\Stripe::setApiKey($this->_sk);
+ $found = FALSE;
+ try {
+ $results = \Stripe\Charge::retrieve(array( "id" => $this->_trxn_id));
+ $found = TRUE;
+ }
+ catch (Stripe_Error $e) {
+ $found = FALSE;
+ }
+
+ $this->assertTrue($found, 'Assigned trxn_id is valid.');
+
+ }
+ /**
+ * Create contribition
+ */
+ public function setupTransaction($params = array()) {
+ $contribution = civicrm_api3('contribution', 'create', array_merge(array(
+ 'contact_id' => $this->_contactID,
+ 'contribution_status_id' => 2,
+ 'payment_processor_id' => $this->_paymentProcessorID,
+ // processor provided ID - use contact ID as proxy.
+ 'processor_id' => $this->_contactID,
+ 'total_amount' => $this->_total,
+ 'invoice_id' => $this->_invoiceID,
+ 'financial_type_id' => $this->_financialTypeID,
+ 'contribution_status_id' => 'Pending',
+ 'contact_id' => $this->_contactID,
+ 'contribution_page_id' => $this->_contributionPageID,
+ 'payment_processor_id' => $this->_paymentProcessorID,
+ 'is_test' => 1,
+ ), $params));
+ $this->assertEquals(0, $contribution['is_error']);
+ $this->_contributionID = $contribution['id'];
+ }
+
+ public function createOrganization() {
+ if (!empty($this->_orgID)) {
+ return;
+ }
+ $results = civicrm_api3('Contact', 'create', array(
+ 'contact_type' => 'Organization',
+ 'organization_name' => 'My Great Group'
+ ));;
+ $this->_orgID = $results['id'];
+ }
+
+ public function createMembershipType() {
+ CRM_Member_PseudoConstant::flush('membershipType');
+ CRM_Core_Config::clearDBCache();
+ $this->createOrganization();
+ $params = array(
+ 'name' => 'General',
+ 'duration_unit' => 'year',
+ 'duration_interval' => 1,
+ 'period_type' => 'rolling',
+ 'member_of_contact_id' => $this->_orgID,
+ 'domain_id' => 1,
+ 'financial_type_id' => 2,
+ 'is_active' => 1,
+ 'sequential' => 1,
+ 'visibility' => 'Public',
+ );
+
+ $result = civicrm_api3('MembershipType', 'Create', $params);
+
+ $this->_membershipTypeID = $result['id'];
+
+ CRM_Member_PseudoConstant::flush('membershipType');
+ CRM_Utils_Cache::singleton()->flush();
+ }
+
+
+}
diff --git a/tests/phpunit/CRM/Stripe/DirectTest.php b/tests/phpunit/CRM/Stripe/DirectTest.php
new file mode 100644
index 0000000..a30ac64
--- /dev/null
+++ b/tests/phpunit/CRM/Stripe/DirectTest.php
@@ -0,0 +1,53 @@
+installMe(__DIR__)
+ ->apply();
+ }
+
+ public function setUp() {
+ parent::setUp();
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+ }
+
+ /**
+ * Test making a recurring contribution.
+ */
+ public function testDirectSuccess() {
+ $this->setupTransaction();
+ $this->doPayment();
+ $this->assertValidTrxn();
+ }
+
+
+}
diff --git a/tests/phpunit/CRM/Stripe/IpnTest.php b/tests/phpunit/CRM/Stripe/IpnTest.php
new file mode 100644
index 0000000..8059dd9
--- /dev/null
+++ b/tests/phpunit/CRM/Stripe/IpnTest.php
@@ -0,0 +1,299 @@
+installMe(__DIR__)
+ ->apply($force);
+ }
+
+ /**
+ * Test creating a membership related recurring contribution and
+ * update it after creation. The membership should also be updated.
+ */
+ public function testIPNRecurMembershipUpdate() {
+ $this->setupRecurringTransaction();
+
+ // Create a membership type (this will create the member org too).
+ $this->createMembershipType();
+
+ // Create the membership and link to the recurring contribution.
+ $params = array(
+ 'contact_id' => $this->_contactID,
+ 'membership_type_id' => $this->_membershipTypeID,
+ 'contribution_recur_id' => $this->_contributionRecurID
+ );
+ $result = civicrm_api3('membership', 'create', $params);
+ $this->_membershipID = $result['id'];
+ $status = $result['values'][$this->_membershipID]['status_id'];
+ $this->assertEquals(1, $status, 'Membership is in new status');
+
+ // Submit the payment.
+ $payment_extra_params = array(
+ 'is_recur' => 1,
+ 'contributionRecurID' => $this->_contributionRecurID,
+ 'frequency_unit' => $this->_frequency_unit,
+ 'frequency_interval' => $this->_frequency_interval,
+ 'installments' => $this->_installments,
+ 'selectMembership' => array(
+ 0 => $this->_membershipTypeID
+ )
+ );
+ $this->doPayment($payment_extra_params);
+
+ // Now check to see if an event was triggered and if so, process it.
+ $payment_object = $this->getEvent('invoice.payment_succeeded');
+ if ($payment_object) {
+ $this->ipn($payment_object);
+ }
+
+ // Now that we have a recurring contribution, let's update it.
+ \Stripe\Stripe::setApiKey($this->_sk);
+ $sub = \Stripe\Subscription::retrieve($this->_subscriptionID);
+
+ // Create a new plan if it doesn't yet exist.
+ $plan_id = 'membertype_1-every-2-month-40000-usd-test';
+
+ // It's possible that this test plan is still in Stripe, so try to
+ // retrieve it and catch the error triggered if it doesn't exist.
+ try {
+ $plan = \Stripe\Plan::retrieve($plan_id);
+ }
+ catch (Stripe\Error\InvalidRequest $e) {
+ // The plan has not been created yet, so create it.
+ $plan_details = array(
+ 'id' => $plan_id,
+ 'amount' => '40000',
+ 'interval' => 'month',
+ 'name' => "Test Updated Plan",
+ 'currency' => 'usd',
+ 'interval_count' => 2
+ );
+ $plan = \Stripe\Plan::create($plan_details);
+
+ }
+ $sub->plan = $plan_id;
+ $sub->save();
+
+ // Now check to see if an event was triggered and if so, process it.
+ $payment_object = $this->getEvent('customer.subscription.updated');
+ if ($payment_object) {
+ $this->ipn($payment_object);
+ }
+
+ // Check for a new recurring contribution.
+ $params = array(
+ 'contact_id' => $this->_contactID,
+ 'amount' => '400',
+ 'contribution_status_id' => "In Progress",
+ 'return' => array('id'),
+ );
+ $result = civicrm_api3('ContributionRecur', 'getsingle', $params);
+ $newContributionRecurID = $result['id'];
+
+ // Now ensure that the membership record is updated to have this
+ // new recurring contribution id.
+ $membership_contribution_recur_id = civicrm_api3('Membership', 'getvalue', array(
+ 'id' => $this->_membershipID,
+ 'return' => 'contribution_recur_id'
+ ));
+ $this->assertEquals($newContributionRecurID, $membership_contribution_recur_id, 'Membership is updated to new contribution recur id');
+
+ // Delete the new plan so we can cleanly run the next time.
+ $plan->delete();
+
+ }
+
+ /**
+ * Test making a failed recurring contribution.
+ */
+ public function testIPNRecurFail() {
+ $this->setupRecurringTransaction();
+ $payment_extra_params = array(
+ 'is_recur' => 1,
+ 'contributionRecurID' => $this->_contributionRecurID,
+ 'frequency_unit' => $this->_frequency_unit,
+ 'frequency_interval' => $this->_frequency_interval,
+ 'installments' => $this->_installments
+ );
+ // Note - this will succeed. It is very hard to test a failed transaction.
+ // We will manipulate the event to make it a failed transactin below.
+ $this->doPayment($payment_extra_params);
+
+ // Now check to see if an event was triggered and if so, process it.
+ $payment_object = $this->getEvent('invoice.payment_succeeded');
+ if ($payment_object) {
+ // Now manipulate the transaction so it appears to be a failed one.
+ $payment_object->type = 'invoice.payment_failed';
+ // Tell Ipn not to verify it - because we manipulated it.
+ $verify = FALSE;
+ $this->ipn($payment_object, $verify);
+ }
+
+ $contribution = civicrm_api3('contribution', 'getsingle', array('id' => $this->_contributionID));
+ $contribution_status_id = $contribution['contribution_status_id'];
+
+ $status = CRM_Contribute_PseudoConstant::contributionStatus($contribution_status_id, 'name');
+ $this->assertEquals('Failed', $status, "Failed contribution was properly marked as failed via a stripe event.");
+ $failure_count = civicrm_api3('ContributionRecur', 'getvalue', array(
+ 'sequential' => 1,
+ 'id' => $this->_contributionRecurID,
+ 'return' => 'failure_count',
+ ));
+ $this->assertEquals(1, $failure_count, "Failed contribution count is correct..");
+
+ }
+ /**
+ * Test making a recurring contribution.
+ */
+ public function testIPNRecurSuccess() {
+ $this->setupRecurringTransaction();
+ $payment_extra_params = array(
+ 'is_recur' => 1,
+ 'contributionRecurID' => $this->_contributionRecurID,
+ 'frequency_unit' => $this->_frequency_unit,
+ 'frequency_interval' => $this->_frequency_interval,
+ 'installments' => $this->_installments
+ );
+ $this->doPayment($payment_extra_params);
+
+ // Now check to see if an event was triggered and if so, process it.
+ $payment_object = $this->getEvent('invoice.payment_succeeded');
+
+ if ($payment_object) {
+ $this->ipn($payment_object);
+ }
+ $contribution = civicrm_api3('contribution', 'getsingle', array('id' => $this->_contributionID));
+ $contribution_status_id = $contribution['contribution_status_id'];
+ $this->assertEquals(1, $contribution_status_id, "Recurring payment was properly processed via a stripe event.");
+
+ // Now, cancel the subscription and ensure it is properly cancelled.
+ \Stripe\Stripe::setApiKey($this->_sk);
+ $sub = \Stripe\Subscription::retrieve($this->_subscriptionID);
+ $sub->cancel();
+
+ $sub_object = $this->getEvent('customer.subscription.deleted');
+ if ($sub_object) {
+ $this->ipn($sub_object);
+ }
+ $this->assertContributionRecurIsCancelled();
+ }
+
+ public function assertContributionRecurIsCancelled() {
+ $contribution_recur = civicrm_api3('contributionrecur', 'getsingle', array('id' => $this->_contributionRecurID));
+ $contribution_recur_status_id = $contribution_recur['contribution_status_id'];
+ $status = CRM_Contribute_PseudoConstant::contributionStatus($contribution_recur_status_id, 'name');
+ $this->assertEquals('Cancelled', $status, "Recurring payment was properly cancelled via a stripe event.");
+ }
+
+ /**
+ * Retrieve the event with a matching subscription id
+ */
+ public function getEvent($type) {
+ // If the type has subscription in it, then the id is the subscription id
+ if (preg_match('/\.subscription\./', $type)) {
+ $property = 'id';
+ }
+ else {
+ // Otherwise, we'll find the subscription id in the subscription property.
+ $property = 'subscription';
+ }
+ // Gather all events since this class was instantiated.
+ $params['sk'] = $this->_sk;
+ $params['created'] = array('gte' => $this->_created_ts);
+ $params['type'] = $type;
+ $params['ppid'] = $this->_paymentProcessorID;
+
+ // Now try to retrieve this transaction.
+ $transactions = civicrm_api3('Stripe', 'listevents', $params );
+ foreach($transactions['values']['data'] as $transaction) {
+ if ($transaction->data->object->$property == $this->_subscriptionID) {
+ return $transaction;
+ }
+ }
+ return NULL;
+
+ }
+
+ /**
+ * Run the webhook/ipn
+ *
+ */
+ public function ipn($data, $verify = TRUE) {
+ if (class_exists('CRM_Core_Payment_StripeIPN')) {
+ // The $_GET['processor_id'] value is normally set by
+ // CRM_Core_Payment::handlePaymentMethod
+ $_GET['processor_id'] = $this->_paymentProcessorID;
+ $ipnClass = new CRM_Core_Payment_StripeIPN($data, $verify);
+ $ipnClass->main();
+ }
+ else {
+ // Deprecated method.
+ $stripe = new CRM_Stripe_Page_Webhook();
+ $stripe->run($data);
+ }
+ }
+
+ /**
+ * Create recurring contribition
+ */
+ public function setupRecurringTransaction($params = array()) {
+ $contributionRecur = civicrm_api3('contribution_recur', 'create', array_merge(array(
+ 'financial_type_id' => $this->_financialTypeID,
+ 'payment_instrument_id' => CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_ContributionRecur', 'payment_instrument_id', 'Credit Card'),
+ 'contact_id' => $this->_contactID,
+ 'amount' => $this->_total,
+ 'sequential' => 1,
+ 'installments' => $this->_installments,
+ 'frequency_unit' => $this->_frequency_unit,
+ 'frequency_interval' => $this->_frequency_interval,
+ 'invoice_id' => $this->_invoiceID,
+ 'contribution_status_id' => 2,
+ 'payment_processor_id' => $this->_paymentProcessorID,
+ // processor provided ID - use contact ID as proxy.
+ 'processor_id' => $this->_contactID,
+ 'api.contribution.create' => array(
+ 'total_amount' => $this->_total,
+ 'invoice_id' => $this->_invoiceID,
+ 'financial_type_id' => $this->_financialTypeID,
+ 'contribution_status_id' => 'Pending',
+ 'contact_id' => $this->_contactID,
+ 'contribution_page_id' => $this->_contributionPageID,
+ 'payment_processor_id' => $this->_paymentProcessorID,
+ 'is_test' => 1,
+ ),
+ ), $params));
+ $this->assertEquals(0, $contributionRecur['is_error']);
+ $this->_contributionRecurID = $contributionRecur['id'];
+ $this->_contributionID = $contributionRecur['values']['0']['api.contribution.create']['id'];
+ }
+}
diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php
new file mode 100644
index 0000000..9de4be6
--- /dev/null
+++ b/tests/phpunit/bootstrap.php
@@ -0,0 +1,49 @@
+ array("pipe", "r"), 1 => array("pipe", "w"), 2 => STDERR);
+ $oldOutput = getenv('CV_OUTPUT');
+ putenv("CV_OUTPUT=json");
+ $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__);
+ putenv("CV_OUTPUT=$oldOutput");
+ fclose($pipes[0]);
+ $result = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ if (proc_close($process) !== 0) {
+ throw new RuntimeException("Command failed ($cmd):\n$result");
+ }
+ switch ($decode) {
+ case 'raw':
+ return $result;
+
+ case 'phpcode':
+ // If the last output is /*PHPCODE*/, then we managed to complete execution.
+ if (substr(trim($result), 0, 12) !== "/*BEGINPHP*/" || substr(trim($result), -10) !== "/*ENDPHP*/") {
+ throw new \RuntimeException("Command failed ($cmd):\n$result");
+ }
+ return $result;
+
+ case 'json':
+ return json_decode($result, 1);
+
+ default:
+ throw new RuntimeException("Bad decoder format ($decode)");
+ }
+}