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)"); + } +}