diff --git a/CRM/Core/Payment/Stripe.php b/CRM/Core/Payment/Stripe.php index 68cc7ddd076406689a84ef3c2e366869570df636..3b73542777b0e8deb085740e4ec8674105db3025 100644 --- a/CRM/Core/Payment/Stripe.php +++ b/CRM/Core/Payment/Stripe.php @@ -162,7 +162,9 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment { // Since it's a decline, Stripe_CardError will be caught $body = $e->getJsonBody(); $err = $body['error']; - + if (!isset($err['code'])) { + $err['code'] = null; + } //$error_message .= 'Status is: ' . $e->getHttpStatus() . "<br />"; ////$error_message .= 'Param is: ' . $err['param'] . "<br />"; $error_message .= 'Type: ' . $err['type'] . '<br />'; @@ -874,6 +876,10 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment { // Don't return a $params['trxn_id'] here or else recurring membership contribs will be set // "Completed" prematurely. Webhook.php does that. + + // Add subscription_id so tests can properly work with recurring + // contributions. + $params['subscription_id'] = $subscription_id; return $params; diff --git a/README.md b/README.md index 863fdd49e04dde4f467b716d4016b905f96fec82..b8f3db97f940e9c91a62f432283b6946be0b1d06 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,41 @@ OTHER CREDITS For bug fixes, new features, and documentiation, thanks to: rgburton, Swingline0, BorislavZlatanov, agh1, & jmcclelland +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 invoice.payment_succeeded. Change to 'all' to show all. + * output - What information to show. Defaults to 'brief' which provides a summary. Alternatively use raw to get the raw JSON returned by Stripe. + * 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. + TESTING -------- + +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 changing into the extension directory and running: + + phpunit4 tests/phpunit/CRM/Stripe/IpnTest.php + phpunit4 tests/phpunit/CRM/Stripe/DirectTest.php + +The following manual tests should also be run: + 1. Test webform submission with payment and user-select, single processor. 1. Test online contribution page with single processor, multi-processor (stripe default, stripe non-default). 1. Test offline contribution page with single processor, multi-processor (stripe default, stripe non-default). diff --git a/api/v3/Stripe/Ipn.php b/api/v3/Stripe/Ipn.php new file mode 100644 index 0000000000000000000000000000000000000000..94ed32ca8cdf050ece8db54ab80ebb7c5e2200bb --- /dev/null +++ b/api/v3/Stripe/Ipn.php @@ -0,0 +1,91 @@ +<?php + +/** + * This api allows you to replay Stripe events. + * + * You can either pass the id of an entry in the System Log (which can + * be populated with the Stripe.PopulateLog call) or you can pass a + * event id from Stripe directly. + * + * When processing an event, the event will always be re-fetched from the + * Stripe server first, so this will not work while offline or with + * events that were not generated by the Stripe server. + */ + +/** + * Stripe.Ipn API specification + * + * @param array $spec description of fields supported by this API call + * @return void + * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards + */ +function _civicrm_api3_stripe_Ipn_spec(&$spec) { + $spec['id']['title'] = ts("CiviCRM System Log id to replay from system log."); + $spec['evtid']['title'] = ts("An event id as generated by Stripe."); + $spec['ppid']['title'] = ts("The payment processor to use (required if using evtid)"); + $spec['noreceipt']['title'] = ts("Set to 1 to override contribution page settings and do not send a receipt (default is off or 0). )"); + $spec['noreceipt']['api.default'] = 0; +} + +/** + * Stripe.Ipn 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_Ipn($params) { + $object = NULL; + $ppid = NULL; + if (array_key_exists('id', $params)) { + $data = civicrm_api3('SystemLog', 'getsingle', array('id' => $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 ("vendor/stripe/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"; + $sql_params = array(0 => array($object->data->object->charge, 'String')); + $dao = CRM_Core_DAO::executeQuery($sql, $sql_params); + $dao->fetch(); + if ($dao->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 { + trigger_error("The api depends on CRM_Core_Payment_StripeIPN"); + } + 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 0000000000000000000000000000000000000000..e0d5535ae3df613968189897499aa9de4a24187d --- /dev/null +++ b/api/v3/Stripe/Listevents.php @@ -0,0 +1,261 @@ +<?php + +/** + * This api provides a list of events generated by Stripe + * + * See the Stripe event reference for a full explanation of the options. + * https://stripe.com/docs/api#events + */ + +/** + * Stripe.ListEvents API specification + * + * + * @param array $spec description of fields supported by this API call + * @return void + * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards + */ +function _civicrm_api3_stripe_ListEvents_spec(&$spec) { + $spec['ppid']['title'] = ts("Use the given Payment Processor ID"); + $spec['ppid']['type'] = CRM_Utils_Type::T_INT; + $spec['type']['title'] = ts("Limit to the given Stripe events type, defaults to invoice.payment_succeeded."); + $spec['type']['api.default'] = 'invoice.payment_succeeded'; + $spec['limit']['title'] = ts("Limit number of results returned (100 is max)"); + $spec['starting_after']['title'] = ts("Only return results after this event id."); + $spec['output']['api.default'] = 'brief'; + $spec['output']['title'] = ts("How to format the output, brief or raw. Defaults to brief."); +} + +/** + * Stripe.VerifyEventType + * + * @param string $eventType + * @return bolean True if valid type, false otherwise. + */ +function civicrm_api3_stripe_VerifyEventType($eventType) { + + return in_array($eventType, array( + 'account.external_account.created', + 'account.external_account.deleted', + 'account.external_account.updated', + 'application_fee.created', + 'application_fee.refunded', + 'application_fee.refund.updated', + 'balance.available', + 'bitcoin.receiver.created', + 'bitcoin.receiver.filled', + 'bitcoin.receiver.updated', + 'bitcoin.receiver.transaction.created', + 'charge.captured', + 'charge.failed', + 'charge.pending', + 'charge.refunded', + 'charge.succeeded', + 'charge.updated', + 'charge.dispute.closed', + 'charge.dispute.created', + 'charge.dispute.funds_reinstated', + 'charge.dispute.funds_withdrawn', + 'charge.dispute.updated', + 'charge.refund.updated', + 'coupon.created', + 'coupon.deleted', + 'coupon.updated', + 'customer.created', + 'customer.deleted', + 'customer.updated', + 'customer.discount.created', + 'customer.discount.deleted', + 'customer.discount.updated', + 'customer.source.created', + 'customer.source.deleted', + 'customer.source.updated', + 'customer.subscription.created', + 'customer.subscription.deleted', + 'customer.subscription.trial_will_end', + 'customer.subscription.updated', + 'invoice.created', + 'invoice.payment_failed', + 'invoice.payment_succeeded', + 'invoice.upcoming', + 'invoice.updated', + 'invoiceitem.created', + 'invoiceitem.deleted', + 'invoiceitem.updated', + 'order.created', + 'order.payment_failed', + 'order.payment_succeeded', + 'order.updated', + 'order_return.created', + 'payout.canceled', + 'payout.created', + 'payout.failed', + 'payout.paid', + 'payout.updated', + 'plan.created', + 'plan.deleted', + 'plan.updated', + 'product.created', + 'product.deleted', + 'product.updated', + 'recipient.created', + 'recipient.deleted', + 'recipient.updated', + 'review.closed', + 'review.opened', + 'sku.created', + 'sku.deleted', + 'sku.updated', + 'source.canceled', + 'source.chargeable', + 'source.failed', + 'source.transaction.created', + 'transfer.created', + 'transfer.reversed', + 'transfer.updated', + 'ping', + ) + ); +} + +/** + * Process parameters to determine ppid and sk. + * + * @param array $params + */ +function civicrm_api3_stripe_ProcessParams($params) { + $ppid = NULL; + $type = NULL; + $created = NULL; + $limit = NULL; + $starting_after = NULL; + $sk = NULL; + + if (array_key_exists('ppid', $params) ) { + $ppid = $params['ppid']; + } + if (array_key_exists('created', $params) ) { + $created = $params['created']; + } + if (array_key_exists('limit', $params) ) { + $limit = $params['limit']; + } + if (array_key_exists('starting_after', $params) ) { + $starting_after = $params['starting_after']; + } + + // Select the right payment processor to use. + if ($ppid) { + $query_params = array('id' => $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 ("vendor/stripe/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 0000000000000000000000000000000000000000..639a2b7f5d3c447fbe08fdf7bdeb325957a7fc30 --- /dev/null +++ b/api/v3/Stripe/Populatelog.php @@ -0,0 +1,97 @@ +<?php + +/** + * Populate the CiviCRM civicrm_system_log with Stripe events. + * + * This api will take all stripe events known to Stripe that are of the type + * invoice.payment_succeeded and add them * to the civicrm_system_log table. + * It will not add an event that has already been added, so it can be run + * multiple times. Once added, they can be replayed using the Stripe.Ipn + * api call. + */ + +/** + * Stripe.Populatelog API specification + * + * @param array $spec description of fields supported by this API call + * @return void + * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards + */ +function _civicrm_api3_stripe_Populatelog_spec(&$spec) { + $spec['ppid']['title'] = ts("The id of the payment processor."); +} + +/** + * Stripe.Populatelog 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_Populatelog($params) { + $ppid = NULL; + if (array_key_exists('ppid', $params)) { + $ppid = $params['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, '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 0000000000000000000000000000000000000000..730fa809e62b20191381607684af9d35f777e64e --- /dev/null +++ b/api/v3/Stripe/Setuptest.php @@ -0,0 +1,57 @@ +<?php + +/** + * This api sets up a Stripe Payment Processor with test credentials. + * + * This api should only be used for testing purposes. + */ + +/** + * Stripe.Setuptest API specification + * + * @param array $spec description of fields supported by this API call + * @return void + * @see http://wiki.civicrm.org/confluence/display/CRMDOC/API+Architecture+Standards + */ +function _civicrm_api3_stripe_Setuptest_spec(&$spec) { + // Note: these test credentials belong to PTP and are contributed to + // tests can be automated. If you are setting up your own testing + // infrastructure, please use your own keys. + $spec['sk']['api.default'] = 'sk_test_TlGdeoi8e1EOPC3nvcJ4q5UZ'; + $spec['pk']['api.default'] = 'pk_test_k2hELLGpBLsOJr6jZ2z9RaYh'; +} + +/** + * Stripe.Setuptest 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_Setuptest($params) { + $params = array( + 'name' => '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 0000000000000000000000000000000000000000..0f9f25d307a9cd62aa26edb1928ddf2de81d249a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<phpunit backupGlobals="false" backupStaticAttributes="false" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" syntaxCheck="false" bootstrap="tests/phpunit/bootstrap.php"> + <testsuites> + <testsuite name="My Test Suite"> + <directory>./tests/phpunit</directory> + </testsuite> + </testsuites> + <filter> + <whitelist> + <directory suffix=".php">./</directory> + </whitelist> + </filter> + <listeners> + <listener class="Civi\Test\CiviTestListener"> + <arguments/> + </listener> + </listeners> +</phpunit> diff --git a/tests/phpunit/CRM/Stripe/BaseTest.php b/tests/phpunit/CRM/Stripe/BaseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..47878b0ac0b04366033b7769bccdc584ea2920f1 --- /dev/null +++ b/tests/phpunit/CRM/Stripe/BaseTest.php @@ -0,0 +1,255 @@ +<?php + +use Civi\Test\HeadlessInterface; +use Civi\Test\HookInterface; +use Civi\Test\TransactionalInterface; + +define('STRIPE_PHPUNIT_TEST', 1); + +/** + * FIXME - Add test description. + * + * Tips: + * - With HookInterface, you may implement CiviCRM hooks directly in the test class. + * Simply create corresponding functions (e.g. "hook_civicrm_post(...)" or similar). + * - With TransactionalInterface, any data changes made by setUp() or test****() functions will + * rollback automatically -- as long as you don't manipulate schema or truncate tables. + * If this test needs to manipulate schema or truncate tables, then either: + * a. Do all that using setupHeadless() and Civi\Test. + * b. Disable TransactionalInterface, and handle all setup/teardown yourself. + * + * @group headless + */ +class CRM_Stripe_BaseTest extends \PHPUnit_Framework_TestCase implements HeadlessInterface, HookInterface, TransactionalInterface { + + protected $_contributionID; + protected $_invoiceID = 'in_19WvbKAwDouDdbFCkOnSwAN7'; + protected $_financialTypeID = 1; + protected $org; + protected $_orgID; + protected $contact; + protected $_contactID; + protected $_contributionPageID; + protected $_paymentProcessorID; + protected $_paymentProcessor; + protected $_trxn_id; + protected $_created_ts; + protected $_subscriptionID; + protected $_membershipTypeID; + // Secret/public keys are PTP test keys. + protected $_sk = 'sk_test_TlGdeoi8e1EOPC3nvcJ4q5UZ'; + protected $_pk = 'pk_test_k2hELLGpBLsOJr6jZ2z9RaYh'; + protected $_cc = NULL; + + public function setUpHeadless() { + // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile(). + // See: https://github.com/civicrm/org.civicrm.testapalooza/blob/master/civi-test.md + return \Civi\Test::headless() + ->installMe(__DIR__) + ->apply(); + } + + public function setUp() { + parent::setUp(); + require_once('vendor/stripe/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 0000000000000000000000000000000000000000..a30ac64dc953eaac313c5ef9a33cb5762ae96a85 --- /dev/null +++ b/tests/phpunit/CRM/Stripe/DirectTest.php @@ -0,0 +1,53 @@ +<?php + +use Civi\Test\HeadlessInterface; +use Civi\Test\HookInterface; +use Civi\Test\TransactionalInterface; + +/** + * FIXME - Add test description. + * + * Tips: + * - With HookInterface, you may implement CiviCRM hooks directly in the test class. + * Simply create corresponding functions (e.g. "hook_civicrm_post(...)" or similar). + * - With TransactionalInterface, any data changes made by setUp() or test****() functions will + * rollback automatically -- as long as you don't manipulate schema or truncate tables. + * If this test needs to manipulate schema or truncate tables, then either: + * a. Do all that using setupHeadless() and Civi\Test. + * b. Disable TransactionalInterface, and handle all setup/teardown yourself. + * + * @group headless + */ +require ('BaseTest.php'); +class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest { + + protected $_contributionRecurID; + protected $_total = '200'; + + public function setUpHeadless() { + // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile(). + // See: https://github.com/civicrm/org.civicrm.testapalooza/blob/master/civi-test.md + return \Civi\Test::headless() + ->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 0000000000000000000000000000000000000000..a4306ee4a01c71ad7c5fb8735561b52810196ed3 --- /dev/null +++ b/tests/phpunit/CRM/Stripe/IpnTest.php @@ -0,0 +1,297 @@ +<?php + +use Civi\Test\HeadlessInterface; +use Civi\Test\HookInterface; +use Civi\Test\TransactionalInterface; + +/** + * FIXME - Add test description. + * + * Tips: + * - With HookInterface, you may implement CiviCRM hooks directly in the test class. + * Simply create corresponding functions (e.g. "hook_civicrm_post(...)" or similar). + * - With TransactionalInterface, any data changes made by setUp() or test****() functions will + * rollback automatically -- as long as you don't manipulate schema or truncate tables. + * If this test needs to manipulate schema or truncate tables, then either: + * a. Do all that using setupHeadless() and Civi\Test. + * b. Disable TransactionalInterface, and handle all setup/teardown yourself. + * + * @group headless + */ +require ('BaseTest.php'); +class CRM_Stripe_IpnTest extends CRM_Stripe_BaseTest { + protected $_total = '200'; + protected $_contributionRecurID; + protected $_installments = 5; + protected $_frequency_unit = 'month'; + protected $_frequency_interval = 1; + protected $_membershipID; + + // This test is particularly dirty for some reason so we have to + // force a reset. + public function setUpHeadless() { + $force = TRUE; + return \Civi\Test::headless() + ->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; + $params['output'] = 'raw'; + + // 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 { + trigger_error("Test suite depends on CRM_Core_Payment_StripeIPN"); + } + } + + /** + * 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 0000000000000000000000000000000000000000..9de4be632a1dd1cc8939f364f2e8d5ed9599444f --- /dev/null +++ b/tests/phpunit/bootstrap.php @@ -0,0 +1,49 @@ +<?php + +ini_set('memory_limit', '2G'); +ini_set('safe_mode', 0); +eval(cv('php:boot --level=classloader', 'phpcode')); + +/** + * Call the "cv" command. + * + * @param string $cmd + * The rest of the command to send. + * @param string $decode + * Ex: 'json' or 'phpcode'. + * @return string + * Response output (if the command executed normally). + * @throws \RuntimeException + * If the command terminates abnormally. + */ +function cv($cmd, $decode = 'json') { + $cmd = 'cv ' . $cmd; + $descriptorSpec = array(0 => 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)"); + } +}