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