Alex Trofimov 10 лет назад
Родитель
Сommit
ef99ccef13
43 измененных файлов с 2993 добавлено и 0 удалено
  1. 21 0
      plugins/recurly/LICENSE
  2. 122 0
      plugins/recurly/README.md
  3. 45 0
      plugins/recurly/recurly.php
  4. 84 0
      plugins/recurly/recurly/account.php
  5. 25 0
      plugins/recurly/recurly/account_list.php
  6. 58 0
      plugins/recurly/recurly/addon.php
  7. 14 0
      plugins/recurly/recurly/addon_list.php
  8. 28 0
      plugins/recurly/recurly/address.php
  9. 77 0
      plugins/recurly/recurly/adjustment.php
  10. 13 0
      plugins/recurly/recurly/adjustment_list.php
  11. 398 0
      plugins/recurly/recurly/base.php
  12. 60 0
      plugins/recurly/recurly/billing_info.php
  13. 261 0
      plugins/recurly/recurly/client.php
  14. 131 0
      plugins/recurly/recurly/coupon.php
  15. 13 0
      plugins/recurly/recurly/coupon_list.php
  16. 27 0
      plugins/recurly/recurly/currency.php
  17. 74 0
      plugins/recurly/recurly/currency_list.php
  18. 62 0
      plugins/recurly/recurly/error_list.php
  19. 80 0
      plugins/recurly/recurly/errors.php
  20. 143 0
      plugins/recurly/recurly/invoice.php
  21. 34 0
      plugins/recurly/recurly/invoice_list.php
  22. 19 0
      plugins/recurly/recurly/link.php
  23. 16 0
      plugins/recurly/recurly/note.php
  24. 16 0
      plugins/recurly/recurly/note_list.php
  25. 152 0
      plugins/recurly/recurly/pager.php
  26. 65 0
      plugins/recurly/recurly/plan.php
  27. 13 0
      plugins/recurly/recurly/plan_list.php
  28. 68 0
      plugins/recurly/recurly/push_notification.php
  29. 77 0
      plugins/recurly/recurly/recurly_js.php
  30. 45 0
      plugins/recurly/recurly/redemption.php
  31. 13 0
      plugins/recurly/recurly/redemption_list.php
  32. 203 0
      plugins/recurly/recurly/resource.php
  33. 112 0
      plugins/recurly/recurly/response.php
  34. 34 0
      plugins/recurly/recurly/stub.php
  35. 134 0
      plugins/recurly/recurly/subscription.php
  36. 48 0
      plugins/recurly/recurly/subscription_addon.php
  37. 45 0
      plugins/recurly/recurly/subscription_list.php
  38. 13 0
      plugins/recurly/recurly/tax_detail.php
  39. 64 0
      plugins/recurly/recurly/transaction.php
  40. 33 0
      plugins/recurly/recurly/transaction_error.php
  41. 26 0
      plugins/recurly/recurly/transaction_list.php
  42. 14 0
      plugins/recurly/recurly/unique_coupon_code_list.php
  43. 13 0
      plugins/recurly/recurly/util/hmac_hash.php

+ 21 - 0
plugins/recurly/LICENSE

@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2011 Recurly, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 122 - 0
plugins/recurly/README.md

@@ -0,0 +1,122 @@
+# Recurly PHP Client
+
+[![Build Status](https://travis-ci.org/recurly/recurly-client-php.png?branch=master)](https://travis-ci.org/recurly/recurly-client-php)
+
+The Recurly PHP Client library is an open source library to interact with
+Recurly's subscription management from your PHP website. The library interacts
+with Recurly's [REST API](https://dev.recurly.com/docs/getting-started).
+
+**Note:** This version uses Recurly API v2. There are substantial differences
+between this version of the client library and versions before _0.5.0_. Please
+be careful when upgrading.
+
+## Requirements
+
+###cURL and OpenSSL
+
+The PHP library depends on PHP 5.3.0 (or higher) and libcurl compiled with
+OpenSSL support. Open up a `phpinfo();` page and verify that under the curl
+section, there's a line that says something like:
+
+```
+libcurl/7.19.5 OpenSSL/0.9.8g zlib/1.2.3.3 libidn/1.15
+```
+
+### Timezone
+You will need to specify your server's timezone before using the Recurly PHP client. This is necessary for the library to properly handle datetime conversions. You can do this in your `php.ini` file:
+
+```php
+date.timezone = 'America/Los_Angeles'
+```
+
+or in your PHP script:
+
+```php
+date_default_timezone_set('America/Los_Angeles');
+```
+
+## Installation
+
+### Composer
+
+If you're using [Composer](http://getcomposer.org/), you can simply add a
+dependency on `recurly/recurly-client` to your project's `composer.json` file.
+Here's an example of a dependency on 2.5:
+
+```json
+{
+    "require": {
+        "recurly/recurly-client": "2.5.*"
+    }
+}
+```
+
+### Git
+
+If you already have git, the easiest way to download the Recurly PHP Client is
+with the git command:
+
+```
+git clone git://github.com/recurly/recurly-client-php.git /path/to/include/recurly
+```
+
+### By Hand
+
+Alternatively, you may download the PHP files in the `lib/` directory and place
+them within your PHP project.
+
+## Initialization
+
+Load the Recurly library files and set your subdomain and API Key globally:
+
+```php
+<?php
+require_once('./lib/recurly.php');
+
+/* https://<your-subdomain>.recurly.com */
+Recurly_Client::$subdomain = 'your-subdomain';
+/* your private API key */
+Recurly_Client::$apiKey = '012345678901234567890123456789ab';
+```
+
+If you are getting certificate verification errors that look like this:
+
+```
+Fatal error: Uncaught exception 'Recurly_ConnectionError' with message 'Could not verify Recurly's SSL certificate.'
+```
+
+Then there is likely a problem with your php or libcurl package and it cannot find your system's root CA certificates.
+Ideally you would want to fix your installation but if you cannot you can override the path manually:
+
+```php
+// Example on my OS X system, the path will be dependent on your system so ask your sysadmin
+Recurly_Client::$CACertPath = '/usr/local/etc/openssl/cert.pem';
+```
+
+## API Documentation
+
+Please see the [Recurly API](https://dev.recurly.com/docs/getting-started) for more information.
+
+## Unit tests
+
+You can run our unit tests by using Composer to install PHPUnit:
+
+```
+$ curl -s https://getcomposer.org/installer | php
+$ php composer.phar install --dev
+$ vendor/phpunit/phpunit/phpunit Tests/
+```
+
+## Support
+
+- [https://support.recurly.com](https://support.recurly.com)
+- [stackoverflow](http://stackoverflow.com/questions/tagged/recurly)
+
+## Announcements
+
+- [@recurly](https://twitter.com/recurly)
+- [Google Group Announcements](https://groups.google.com/group/recurly-api)
+
+## Contributing Guidelines
+
+Please refer to [CONTRIBUTING.md](CONTRIBUTING.md)

+ 45 - 0
plugins/recurly/recurly.php

@@ -0,0 +1,45 @@
+<?php
+
+// Require all Recurly classes
+require_once(dirname(__FILE__) . '/recurly/base.php');
+require_once(dirname(__FILE__) . '/recurly/client.php');
+require_once(dirname(__FILE__) . '/recurly/currency.php');
+require_once(dirname(__FILE__) . '/recurly/currency_list.php');
+require_once(dirname(__FILE__) . '/recurly/error_list.php');
+require_once(dirname(__FILE__) . '/recurly/errors.php');
+require_once(dirname(__FILE__) . '/recurly/link.php');
+require_once(dirname(__FILE__) . '/recurly/pager.php');
+require_once(dirname(__FILE__) . '/recurly/response.php');
+require_once(dirname(__FILE__) . '/recurly/resource.php');
+require_once(dirname(__FILE__) . '/recurly/stub.php');
+
+require_once(dirname(__FILE__) . '/recurly/address.php');
+require_once(dirname(__FILE__) . '/recurly/account.php');
+require_once(dirname(__FILE__) . '/recurly/account_list.php');
+require_once(dirname(__FILE__) . '/recurly/addon.php');
+require_once(dirname(__FILE__) . '/recurly/addon_list.php');
+require_once(dirname(__FILE__) . '/recurly/adjustment.php');
+require_once(dirname(__FILE__) . '/recurly/adjustment_list.php');
+require_once(dirname(__FILE__) . '/recurly/billing_info.php');
+require_once(dirname(__FILE__) . '/recurly/coupon.php');
+require_once(dirname(__FILE__) . '/recurly/coupon_list.php');
+require_once(dirname(__FILE__) . '/recurly/unique_coupon_code_list.php');
+require_once(dirname(__FILE__) . '/recurly/invoice.php');
+require_once(dirname(__FILE__) . '/recurly/invoice_list.php');
+require_once(dirname(__FILE__) . '/recurly/note.php');
+require_once(dirname(__FILE__) . '/recurly/note_list.php');
+require_once(dirname(__FILE__) . '/recurly/plan.php');
+require_once(dirname(__FILE__) . '/recurly/plan_list.php');
+require_once(dirname(__FILE__) . '/recurly/redemption.php');
+require_once(dirname(__FILE__) . '/recurly/redemption_list.php');
+require_once(dirname(__FILE__) . '/recurly/subscription.php');
+require_once(dirname(__FILE__) . '/recurly/subscription_list.php');
+require_once(dirname(__FILE__) . '/recurly/subscription_addon.php');
+require_once(dirname(__FILE__) . '/recurly/tax_detail.php');
+require_once(dirname(__FILE__) . '/recurly/transaction.php');
+require_once(dirname(__FILE__) . '/recurly/transaction_error.php');
+require_once(dirname(__FILE__) . '/recurly/transaction_list.php');
+
+require_once(dirname(__FILE__) . '/recurly/push_notification.php');
+require_once(dirname(__FILE__) . '/recurly/recurly_js.php');
+require_once(dirname(__FILE__) . '/recurly/util/hmac_hash.php');

+ 84 - 0
plugins/recurly/recurly/account.php

@@ -0,0 +1,84 @@
+<?php
+
+class Recurly_Account extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+  protected static $_requiredAttributes;
+
+  function __construct($accountCode = null, $client = null) {
+    parent::__construct(null, $client);
+    if (!is_null($accountCode))
+      $this->account_code = $accountCode;
+    $this->address = new Recurly_Address();
+  }
+
+  public static function init()
+  {
+    Recurly_Account::$_writeableAttributes = array(
+      'account_code','username','first_name','last_name','vat_number',
+      'email','company_name','accept_language','billing_info','address',
+      'tax_exempt','entity_use_code','cc_emails'
+    );
+    Recurly_Account::$_requiredAttributes = array(
+      'account_code'
+    );
+
+  }
+
+  public function &__get($key)
+  {
+    if ($key == 'redemption' && parent::__isset('redemptions')) {
+      return new Recurly_Stub('redemption', $this->_href . "/redemption", $this->_client);
+    } else {
+      return parent::__get($key);
+    }
+  }
+
+  public static function get($accountCode, $client = null) {
+    return Recurly_Base::_get(Recurly_Account::uriForAccount($accountCode), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_ACCOUNTS);
+  }
+  public function update() {
+    $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+  public function close() {
+    Recurly_Base::_delete($this->uri(), $this->_client);
+    $this->state = 'closed';
+  }
+  public static function closeAccount($accountCode, $client = null) {
+    return Recurly_Base::_delete(Recurly_Account::uriForAccount($accountCode), $client);
+  }
+
+  public function reopen() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/reopen');
+  }
+  public static function reopenAccount($accountCode, $client = null) {
+    return Recurly_Base::_put(Recurly_Account::uriForAccount($accountCode) . '/reopen', $client);
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else
+      return Recurly_Account::uriForAccount($this->account_code);
+  }
+  protected static function uriForAccount($accountCode) {
+    return Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode);
+  }
+
+  protected function getNodeName() {
+    return 'account';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Account::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return Recurly_Account::$_requiredAttributes;
+  }
+}
+
+Recurly_Account::init();

+ 25 - 0
plugins/recurly/recurly/account_list.php

@@ -0,0 +1,25 @@
+<?php
+
+class Recurly_AccountList extends Recurly_Pager
+{
+  public static function getActive($params = null, $client = null) {
+    return self::get(Recurly_Pager::_setState($params, 'active'), $client);
+  }
+
+  public static function getClosed($params = null, $client = null) {
+    return self::get(Recurly_Pager::_setState($params, 'closed'), $client);
+  }
+
+  public static function getPastDue($params = null, $client = null) {
+    return self::get(Recurly_Pager::_setState($params, 'past_due'), $client);
+  }
+
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_ACCOUNTS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'accounts';
+  }
+}

+ 58 - 0
plugins/recurly/recurly/addon.php

@@ -0,0 +1,58 @@
+<?php
+
+class Recurly_Addon extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  function __construct() {
+    parent::__construct();
+    $this->unit_amount_in_cents = new Recurly_CurrencyList('unit_amount_in_cents');
+  }
+
+  public static function init()
+  {
+    Recurly_Addon::$_writeableAttributes = array(
+      'add_on_code','name','display_quantity','default_quantity',
+      'unit_amount_in_cents','accounting_code','tax_code'
+    );
+  }
+
+  public static function get($planCode, $addonCode, $client = null) {
+    return Recurly_Base::_get(Recurly_Addon::uriForAddOn($planCode, $addonCode), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_PLANS . '/' . rawurlencode($this->plan_code) . Recurly_Client::PATH_ADDONS);
+  }
+
+  public function update() {
+    return $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+  public function delete() {
+    return Recurly_Base::_delete($this->uri(), $this->_client);
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else
+      return Recurly_Addon::uriForAddOn($this->plan_code, $this->add_on_code);
+  }
+  protected static function uriForAddOn($planCode, $addonCode) {
+    return (Recurly_Client::PATH_PLANS . '/' . rawurlencode($planCode) .
+            Recurly_Client::PATH_ADDONS . '/' . rawurlencode($addonCode));
+  }
+
+  protected function getNodeName() {
+    return 'add_on';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Addon::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Addon::init();

+ 14 - 0
plugins/recurly/recurly/addon_list.php

@@ -0,0 +1,14 @@
+<?php
+
+class Recurly_AddonList extends Recurly_Pager
+{
+  public static function get($planCode, $client = null)
+  {
+    $uri = Recurly_Client::PATH_PLANS . '/' . rawurlencode($planCode) . Recurly_Client::PATH_ADDONS;
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'add_ons';
+  }
+}

+ 28 - 0
plugins/recurly/recurly/address.php

@@ -0,0 +1,28 @@
+<?php
+
+class Recurly_Address extends Recurly_Resource {
+
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_Address::$_writeableAttributes = array(
+      'address1','address2','city','state',
+      'zip','country','phone'
+    );
+
+  }
+
+  protected function getNodeName() {
+    return 'address';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Address::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+
+}
+
+Recurly_Address::init();

+ 77 - 0
plugins/recurly/recurly/adjustment.php

@@ -0,0 +1,77 @@
+<?php
+
+class Recurly_Adjustment extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_Adjustment::$_writeableAttributes = array(
+      'currency','unit_amount_in_cents','quantity','description',
+      'accounting_code','tax_exempt','tax_code'
+    );
+  }
+
+  public static function get($adjustment_uuid, $client = null) {
+    return Recurly_Base::_get(Recurly_Client::PATH_ADJUSTMENTS . '/' . rawurlencode($adjustment_uuid), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, $this->createUriForAccount());
+  }
+  public function delete() {
+    return Recurly_Base::_delete($this->getHref(), $this->_client);
+  }
+
+  /**
+   * Allows you to refund this particular item if it's a part of
+   * an invoice. It does this by calling the invoice's refund()
+   * Only 'invoiced' adjustments can be refunded.
+   *
+   * @param Integer the quantity you wish to refund, defaults to refunding the entire quantity
+   * @param Boolean indicates whether you want this adjustment refund prorated
+   * @param String indicates the refund order to apply, valid options: {'credit','transaction'}, defaults to 'credit'
+   * @return Recurly_Invoice the new refund invoice
+   * @throws Recurly_Error if the adjustment cannot be refunded.
+   */
+  public function refund($quantity = null, $prorate = false, $refund_apply_order = 'credit') {
+    if ($this->state == 'pending') {
+      throw new Recurly_Error("Only invoiced adjustments can be refunded");
+    }
+    $invoice = $this->invoice->get();
+    return $invoice->refund($this->toRefundAttributes($quantity, $prorate), $refund_apply_order);
+  }
+
+  /**
+   * Converts this adjustment into the attributes needed for a refund.
+   *
+   * @param Integer the quantity you wish to refund, defaults to refunding the entire quantity
+   * @param Boolean indicates whether you want this adjustment refund prorated
+   * @return Array an array of refund attributes to be fed into invoice->refund()
+   */
+  public function toRefundAttributes($quantity = null, $prorate = false) {
+    if (is_null($quantity)) $quantity = $this->quantity;
+
+    return array('uuid' => $this->uuid, 'quantity' => $quantity, 'prorate' => $prorate);
+  }
+
+  protected function createUriForAccount() {
+    if (empty($this->account_code))
+      throw new Recurly_Error("'account_code' is not specified");
+
+    return (Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($this->account_code) .
+            Recurly_Client::PATH_ADJUSTMENTS);
+  }
+
+  protected function getNodeName() {
+    return 'adjustment';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Adjustment::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Adjustment::init();

+ 13 - 0
plugins/recurly/recurly/adjustment_list.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_AdjustmentList extends Recurly_Pager
+{
+  public static function get($accountCode, $params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_ADJUSTMENTS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'adjustments';
+  }
+}

+ 398 - 0
plugins/recurly/recurly/base.php

@@ -0,0 +1,398 @@
+<?php
+
+abstract class Recurly_Base
+{
+  protected $_href;
+  protected $_client;
+  protected $_links;
+
+  public function __construct($href = null, $client = null)
+  {
+    $this->_href = $href;
+    $this->_client = is_null($client) ? new Recurly_Client() : $client;
+    $this->_links = array();
+  }
+
+  /**
+   * Request the URI, validate the response and return the object.
+   * @param string Resource URI, if not fully qualified, the base URL will be appended
+   * @param string Optional client for the request, useful for mocking the client
+   */
+  public static function _get($uri, $client = null)
+  {
+    if (is_null($client)) {
+      $client = new Recurly_Client();
+    }
+    $response = $client->request(Recurly_Client::GET, $uri);
+    $response->assertValidResponse();
+    return Recurly_Base::__parseResponseToNewObject($response, $uri, $client);
+  }
+
+  /**
+   * Post to the URI, validate the response and return the object.
+   * @param string Resource URI, if not fully qualified, the base URL will be appended
+   * @param string Data to post to the URI
+   * @param string Optional client for the request, useful for mocking the client
+   */
+  protected static function _post($uri, $data = null, $client = null)
+  {
+    if (is_null($client)) {
+      $client = new Recurly_Client();
+    }
+    $response = $client->request(Recurly_Client::POST, $uri, $data);
+    $response->assertValidResponse();
+    $object = Recurly_Base::__parseResponseToNewObject($response, $uri, $client);
+    $response->assertSuccessResponse($object);
+    return $object;
+  }
+
+  /**
+   * Put to the URI, validate the response and return the object.
+   * @param string Resource URI, if not fully qualified, the base URL will be appended
+   * @param string Optional client for the request, useful for mocking the client
+   */
+  protected static function _put($uri, $client = null)
+  {
+    if (is_null($client)) {
+      $client = new Recurly_Client();
+    }
+    $response = $client->request(Recurly_Client::PUT, $uri);
+    $response->assertValidResponse();
+    if ($response->body) {
+      $object = Recurly_Base::__parseResponseToNewObject($response, $uri, $client);
+    }
+    $response->assertSuccessResponse($object);
+    return $object;
+  }
+
+  /**
+   * Delete the URI, validate the response and return the object.
+   * @param string Resource URI, if not fully qualified, the base URL will be appended
+   * @param string Optional client for the request, useful for mocking the client
+   */
+  protected static function _delete($uri, $client = null)
+  {
+    if (is_null($client)) {
+      $client = new Recurly_Client();
+    }
+    $response = $client->request(Recurly_Client::DELETE, $uri);
+    $response->assertValidResponse();
+    if ($response->body) {
+      return Recurly_Base::__parseResponseToNewObject($response, $uri, $client);
+    }
+    return null;
+  }
+
+  protected static function _uriWithParams($uri, $params = null) {
+    if (is_null($params) || !is_array($params)) {
+      return $uri;
+    }
+
+    $vals = array();
+    foreach ($params as $k => $v) {
+      $vals[] = $k . '=' . urlencode($v);
+    }
+
+    return $uri . '?' . implode($vals, '&');
+  }
+
+  /**
+   * Pretty string version of the object
+   */
+  public function __toString()
+  {
+    $href = $this->getHref();
+
+    if (empty($href))
+      $href = '[new]';
+    else
+      $href = '[href=' . $href . ']';
+
+    $class = get_class($this);
+    $values = $this->__valuesString();
+    return "<$class$href $values>";
+  }
+
+  protected function __valuesString() {
+    $values = array();
+    ksort($this->_values);
+
+    foreach($this->_values as $key => $value) {
+      if (is_null($value)) {
+        $values[] = "$key=null";
+      } else if (is_int($value)) {
+        $values[] = "$key=$value";
+      } else if (is_bool($value)) {
+        $values[] = "$key=" . ($value ? 'true' : 'false');
+      } else if ($value instanceof Recurly_Stub) {
+        $values[] = "$key=$value";
+      } else if (is_array($value)) {
+        $innerValues = array();
+        foreach ($value as $innerValue)
+          $innerValues[] = strval($innerValue);
+        $innerValues = implode($innerValues, ', ');
+        $values[] = "$key=[$innerValues]";
+      } else if ($value instanceof DateTime) {
+        $values[] = "$key=\"" . $value->format('Y-m-d H:i:s P') . '"';
+      } else {
+        $values[] = "$key=\"$value\"";
+      }
+    }
+
+    return implode($values, ', ');
+  }
+
+  public function getHref() {
+    return $this->_href;
+  }
+  public function setHref($href) {
+    $this->_href = $href;
+  }
+
+  private function addLink($name, $href, $method){
+    $this->_links[$name] = new Recurly_Link($name, $href, $method);
+  }
+
+  public function getLinks() {
+    return $this->_links;
+  }
+
+  /* ******************************************************
+     ** XML Parser
+     ****************************************************** */
+  /**
+   * Mapping of XML node to PHP object name
+   */
+  static $class_map = array(
+    'account' => 'Recurly_Account',
+    'accounts' => 'Recurly_AccountList',
+    'address' => 'Recurly_Address',
+    'add_on' => 'Recurly_Addon',
+    'add_ons' => 'Recurly_AddonList',
+    'billing_info' => 'Recurly_BillingInfo',
+    'adjustment' => 'Recurly_Adjustment',
+    'adjustments' => 'Recurly_AdjustmentList',
+    'coupon' => 'Recurly_Coupon',
+    'unique_coupon_codes' => 'Recurly_UniqueCouponCodeList',
+    'currency' => 'Recurly_Currency',
+    'details' => 'array',
+    'discount_in_cents' => 'Recurly_CurrencyList',
+    'error' => 'Recurly_FieldError',
+    'errors' => 'Recurly_ErrorList',
+    'invoice' => 'Recurly_Invoice',
+    'invoices' => 'Recurly_InvoiceList',
+    'line_items' => 'array',
+    'note' => 'Recurly_Note',
+    'notes' => 'Recurly_NoteList',
+    'plan' => 'Recurly_Plan',
+    'plans' => 'Recurly_PlanList',
+    'plan_code' => 'string',
+    'plan_codes' => 'array',
+    'pending_subscription' => 'Recurly_Subscription',
+    'redemption' => 'Recurly_CouponRedemption',
+    'redemptions' => 'Recurly_CouponRedemptionList',
+    'setup_fee_in_cents' => 'Recurly_CurrencyList',
+    'subscription' => 'Recurly_Subscription',
+    'subscriptions' => 'Recurly_SubscriptionList',
+    'subscription_add_ons' => 'array',
+    'subscription_add_on' => 'Recurly_SubscriptionAddOn',
+    'tax_detail' => 'Recurly_Tax_Detail',
+    'tax_details' => 'array',
+    'transaction' => 'Recurly_Transaction',
+    'transactions' => 'Recurly_TransactionList',
+    'transaction_error' => 'Recurly_TransactionError',
+    'unit_amount_in_cents' => 'Recurly_CurrencyList',
+  );
+
+  // Use a valid Recurly_Response to populate a new object.
+  protected static function __parseResponseToNewObject($response, $uri, $client) {
+    $dom = new DOMDocument();
+    if (empty($response->body) || !$dom->loadXML($response->body, LIBXML_NOBLANKS)) {
+      return null;
+    }
+
+    $rootNode = $dom->documentElement;
+
+    $obj = Recurly_Resource::__createNodeObject($rootNode);
+    $obj->_client = $client;
+    Recurly_Resource::__parseXmlToObject($rootNode->firstChild, $obj);
+    if ($obj instanceof self) {
+      $obj->_afterParseResponse($response, $uri);
+    }
+    return $obj;
+  }
+
+  // Optional method to allow objects access to the full response with headers.
+  protected function _afterParseResponse($response, $uri) { }
+
+  // Use the XML to update $this object.
+  protected function __parseXmlToUpdateObject($xml)
+  {
+    $dom = new DOMDocument();
+    if (empty($xml) || !$dom->loadXML($xml, LIBXML_NOBLANKS)) return null;
+
+    $rootNode = $dom->documentElement;
+
+    if ($rootNode->nodeName == $this->getNodeName()) {
+      // update the current object
+      Recurly_Resource::__parseXmlToObject($rootNode->firstChild, $this);
+    } else if ($rootNode->nodeName == 'errors') {
+      // add element to existing object
+      Recurly_Resource::__parseXmlToObject($rootNode->firstChild, $this->_errors);
+    }
+    $this->updateErrorAttributes();
+  }
+
+  protected static function __parseXmlToObject($node, &$object)
+  {
+    while ($node) {
+      //print "Node: {$node->nodeType} -- {$node->nodeName}\n";
+
+      if ($node->nodeType == XML_TEXT_NODE) {
+        if ($node->wholeText != null) {
+          $text = trim($node->wholeText);
+          if (!empty($text)) {
+            $object->description = $text;
+          }
+        }
+      } else if ($node->nodeType == XML_ELEMENT_NODE) {
+        $nodeName = str_replace("-", "_", $node->nodeName);
+
+        if ($object instanceof Recurly_Pager) {
+          $new_obj = Recurly_Resource::__createNodeObject($node);
+          if (!is_null($new_obj)) {
+            Recurly_Resource::__parseXmlToObject($node->firstChild, $new_obj);
+            $object->_objects[] = $new_obj;
+          }
+          $node = $node->nextSibling;
+          continue;
+        } else if ($object instanceof Recurly_ErrorList) {
+          if ($nodeName == 'error') {
+            $object[] = Recurly_Resource::parseErrorNode($node);
+            $node = $node->nextSibling;
+            continue;
+          }
+        } else if (is_array($object)) {
+          if ($nodeName == 'error') {
+            $object[] = Recurly_Resource::parseErrorNode($node);
+            $node = $node->nextSibling;
+            continue;
+          }
+
+          $new_obj = Recurly_Resource::__createNodeObject($node);
+          if (!is_null($new_obj)) {
+            if (is_object($new_obj) || is_array($new_obj)) {
+              Recurly_Resource::__parseXmlToObject($node->firstChild, $new_obj);
+            }
+            $object[] = $new_obj;
+          }
+          $node = $node->nextSibling;
+          continue;
+        }
+
+        $numChildren = $node->childNodes->length;
+        if ($numChildren == 0) {
+          // No children, we might have a link
+          $href = $node->getAttribute('href');
+          if (!empty($href)) {
+            if ($nodeName == 'a') {
+              $linkName = $node->getAttribute('name');
+              $method = $node->getAttribute('method');
+              $object->addLink($linkName, $href, $method);
+            } else {
+              if (!is_object($object)) {
+                $object->$nodeName = new Recurly_Stub($nodeName, $href);
+              }
+              else {
+                $object->$nodeName = new Recurly_Stub($nodeName, $href, $object->_client);
+              }
+            }
+          }
+        } else if ($node->firstChild->nodeType == XML_ELEMENT_NODE) {
+          // has element children, drop in and continue parsing
+          $new_obj = Recurly_Resource::__createNodeObject($node);
+          if (!is_null($new_obj)) {
+            $object->$nodeName = Recurly_Resource::__parseXmlToObject($node->firstChild, $new_obj);
+          }
+        } else {
+          // we have a single text child
+          if ($node->hasAttribute('nil')) {
+            $object->$nodeName = null;
+          } else {
+            switch ($node->getAttribute('type')) {
+              case 'boolean':
+                $object->$nodeName = ($node->nodeValue == 'true');
+                break;
+              case 'date':
+              case 'datetime':
+                $object->$nodeName = new DateTime($node->nodeValue);
+                break;
+              case 'float':
+                $object->$nodeName = (float)$node->nodeValue;
+                break;
+              case 'integer':
+                $object->$nodeName = (int)$node->nodeValue;
+                break;
+              case 'array':
+                $object->$nodeName = array();
+                break;
+              default:
+                $object->$nodeName = $node->nodeValue;
+            }
+          }
+        }
+      }
+
+      $node = $node->nextSibling;
+    }
+
+    if (isset($object->_unsavedKeys))
+      $object->_unsavedKeys = array();
+
+    return $object;
+  }
+
+  private static function parseErrorNode($node)
+  {
+    $error = new Recurly_FieldError();
+    $error->field = $node->getAttribute('field');
+    $error->symbol = $node->getAttribute('symbol');
+    $error->description = $node->firstChild->wholeText;
+
+    return $error;
+  }
+
+  private static function __createNodeObject($node)
+  {
+    $nodeName = str_replace("-", "_", $node->nodeName);
+
+    if (!array_key_exists($nodeName, Recurly_Resource::$class_map)) {
+      return null; // Unknown element
+    }
+
+    $node_class = Recurly_Resource::$class_map[$nodeName];
+
+    if ($node_class == null)
+      return new Recurly_Object();
+    else if ($node_class == 'array')
+      return array();
+    else if ($node_class == 'string')
+      return $node->firstChild->wholeText;
+    else {
+      if ($node_class == 'Recurly_CurrencyList') {
+        $new_obj = new $node_class($nodeName);
+      } else
+        $new_obj = new $node_class();
+
+      $href = $node->getAttribute('href');
+      if (!empty($href)) {
+        $new_obj->setHref($href);
+      }
+
+      return $new_obj;
+    }
+  }
+}
+
+// In case node_class is not specified.
+class Recurly_Object {}

+ 60 - 0
plugins/recurly/recurly/billing_info.php

@@ -0,0 +1,60 @@
+<?php
+
+class Recurly_BillingInfo extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_BillingInfo::$_writeableAttributes = array(
+      'first_name','last_name','name_on_account','ip_address',
+      'address1','address2','city','state','country','zip','phone','vat_number',
+      'number','month','year','verification_value','start_year','start_month','issue_number',
+      'account_number','routing_number','account_type',
+      'paypal_billing_agreement_id', 'amazon_billing_agreement_id', 'currency',
+      'token_id'
+    );
+  }
+
+  public static function get($accountCode, $client = null) {
+    return Recurly_Base::_get(Recurly_BillingInfo::uriForBillingInfo($accountCode), $client);
+  }
+
+  public function create() {
+    $this->update();
+  }
+  public function update() {
+    $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+  public function delete() {
+    return Recurly_Base::_delete($this->uri(), $this->_client);
+  }
+  public static function deleteForAccount($accountCode, $client = null) {
+    return Recurly_Base::_delete(Recurly_BillingInfo::uriForBillingInfo($accountCode), $client);
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else if (!empty($this->account_code))
+      return Recurly_BillingInfo::uriForBillingInfo($this->account_code);
+    else
+      throw new Recurly_Error("'account_code' not specified.");
+  }
+  protected static function uriForBillingInfo($accountCode) {
+    return Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_BILLING_INFO;
+  }
+
+  protected function getNodeName() {
+    return 'billing_info';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_BillingInfo::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_BillingInfo::init();

+ 261 - 0
plugins/recurly/recurly/client.php

@@ -0,0 +1,261 @@
+<?php
+
+/**
+ * Recurly_Client provides methods for interacting with the {@link https://dev.recurly.com/docs/getting-started Recurly} API.
+ *
+ * @category   Recurly
+ * @package    Recurly_Client_PHP
+ * @copyright  Copyright (c) 2011 {@link http://recurly.com Recurly, Inc.}
+ */
+class Recurly_Client
+{
+  /**
+   * Subdomain for all requests.
+   */
+  public static $subdomain = 'api';
+
+  /**
+   * Default API key for all requests, may be overridden with the Recurly_Client constructor
+   */
+  public static $apiKey;
+
+  /**
+   * Base API URL
+   */
+  public static $apiUrl = 'https://%s.recurly.com/v2';
+
+  /**
+   * API Version
+   */
+  public static $apiVersion = '2.1';
+
+  /**
+   * The path to your CA certs. Use only if needed (if you can't fix libcurl/php).
+   */
+  public static $CACertPath = false;
+
+  /**
+   * API Key instance, may differ from the static key
+   */
+  private $_apiKey;
+
+  /**
+   * Language for API validation messages
+   */
+  private $_acceptLanguage = 'en-US';
+
+  const API_CLIENT_VERSION = '2.5.1';
+  const DEFAULT_ENCODING = 'UTF-8';
+
+  const GET = 'GET';
+  const POST = 'POST';
+  const PUT = 'PUT';
+  const DELETE = 'DELETE';
+  const HEAD = 'HEAD';
+
+  const PATH_ACCOUNTS = '/accounts';
+  const PATH_ADDONS = '/add_ons';
+  const PATH_ADJUSTMENTS = '/adjustments';
+  const PATH_BILLING_INFO = '/billing_info';
+  const PATH_COUPON = '/coupon';
+  const PATH_COUPON_REDEMPTION = '/redemption';
+  const PATH_COUPON_REDEMPTIONS = '/redemptions';
+  const PATH_COUPONS = '/coupons';
+  const PATH_UNIQUE_COUPONS = '/unique_coupon_codes';
+  const PATH_INVOICES = '/invoices';
+  const PATH_NOTES = '/notes';
+  const PATH_PLANS = '/plans';
+  const PATH_SUBSCRIPTIONS = '/subscriptions';
+  const PATH_TRANSACTIONS = '/transactions';
+
+  const PATH_RECURLY_JS_RESULT = '/recurly_js/result';
+
+  const PATH_TRANSPARENT = '/transparent/';
+  const PATH_TRANSPARENT_SUBSCRIPTION = '/subscription';
+  const PATH_TRANSPARENT_TRANSACTION = '/transaction';
+  const PATH_TRANSPARENT_BILLING_INFO = '/billing_info';
+  const PATH_TRANSPARENT_RESULTS = 'results/';
+
+  /**
+   * Create a new Recurly Client
+   * @param string API key. Do not specify to use the default API key (which must be set at the static variable)
+   * @param string "Accept-language" header variable from the current user's browser. Localizes validation messages.
+   */
+  function __construct($apiKey = null, $acceptLanguage = 'en-US') {
+    $this->_apiKey = $apiKey;
+    $this->_acceptLanguage = $acceptLanguage;
+  }
+
+  public function request($method, $uri, $data = null)
+  {
+    return $this->_sendRequest($method, $uri, $data);
+  }
+
+  public function baseUri() {
+    return sprintf(Recurly_Client::$apiUrl, Recurly_Client::$subdomain);
+  }
+
+  /**
+   * Current API key
+   * @return string API key
+   */
+  public function apiKey() {
+    return (empty($this->_apiKey) ? Recurly_Client::$apiKey : $this->_apiKey);
+  }
+
+  /**
+  * Sends an HTTP request to the Recurly API
+  *
+  * @param string  $method Specifies the HTTP method to be used for this request
+  * @param string  $uri    Target URI for this request (relative to the API root)
+  * @param mixed   $data   x-www-form-urlencoded data (or array) to be sent in a POST request body
+  *
+  * @return $code, $response
+  */
+  private function _sendRequest($method, $uri, $data = '')
+  {
+    if(function_exists('mb_internal_encoding'))
+      mb_internal_encoding(self::DEFAULT_ENCODING);
+
+    if (substr($uri,0,4) != 'http')
+      $uri = $this->baseUri() . $uri;
+
+    $ch = curl_init();
+    curl_setopt($ch, CURLOPT_URL, $uri);
+    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE);
+    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+    if (self::$CACertPath) {
+      curl_setopt($ch, CURLOPT_CAINFO, self::$CACertPath);
+    }
+    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, FALSE);
+    curl_setopt($ch, CURLOPT_MAXREDIRS, 1);
+    curl_setopt($ch, CURLOPT_HEADER, TRUE);
+    curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
+    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+    curl_setopt($ch, CURLOPT_TIMEOUT, 45);
+    curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+      'Content-Type: application/xml; charset=utf-8',
+      'Accept: application/xml',
+      Recurly_Client::__userAgent(),
+      'Accept-Language: ' . $this->_acceptLanguage,
+      'X-Api-Version: ' . Recurly_Client::$apiVersion
+    ));
+    curl_setopt($ch, CURLOPT_USERPWD, $this->apiKey());
+
+    if ('POST' == $method)
+    {
+      curl_setopt($ch, CURLOPT_POST, TRUE);
+      curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
+    }
+    else if ('PUT' == $method)
+    {
+      curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
+      curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
+    }
+    else if('GET' != $method)
+    {
+      curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+    }
+
+    $response = curl_exec($ch);
+
+    if ($response === false)
+    {
+      $errorNumber = curl_errno($ch);
+      $message = curl_error($ch);
+      curl_close($ch);
+      $this->_raiseCurlError($errorNumber, $message);
+    }
+
+    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+    curl_close($ch);
+
+    list($header, $body) = explode("\r\n\r\n", $response, 2);
+    // Larger responses end up prefixed by "HTTP/1.1 100 Continue\r\n\r\n" which
+    // needs to be discarded.
+    if (strpos($header," 100 Continue") !== false ) {
+      list($header, $body) = explode("\r\n\r\n", $body, 2);
+    }
+    $headers = $this->_getHeaders($header);
+
+    return new Recurly_ClientResponse($statusCode, $headers, $body);
+  }
+
+  private static function __userAgent() {
+    return "User-Agent: Recurly/" . self::API_CLIENT_VERSION . '; PHP ' . phpversion() . ' [' . php_uname('s') . ']';
+  }
+
+  private function _getHeaders($headerText)
+  {
+    $headers = explode("\r\n", $headerText);
+    $returnHeaders = array();
+    foreach ($headers as &$header) {
+      preg_match('/([^:]+): (.*)/', $header, $matches);
+      if (sizeof($matches) > 2)
+        $returnHeaders[$matches[1]] = $matches[2];
+    }
+    return $returnHeaders;
+  }
+
+  private function _raiseCurlError($errorNumber, $message)
+  {
+    switch ($errorNumber) {
+      case CURLE_COULDNT_CONNECT:
+      case CURLE_COULDNT_RESOLVE_HOST:
+      case CURLE_OPERATION_TIMEOUTED:
+        throw new Recurly_ConnectionError("Failed to connect to Recurly ($message).");
+      case CURLE_SSL_CACERT:
+      case CURLE_SSL_PEER_CERTIFICATE:
+        throw new Recurly_ConnectionError("Could not verify Recurly's SSL certificate.");
+      default:
+        throw new Recurly_ConnectionError("An unexpected error occurred connecting with Recurly.");
+    }
+  }
+
+  /**
+  *  Requests a PDF document from the given URI
+  *
+  * @param  string $uri      Target URI for this request (relative to the API root)
+  * @param  string $locale   Locale for the PDF invoice (e.g. "en-GB", "en-US", "fr")
+  * @return string $response PDF document
+  */
+  public function getPdf($uri, $locale = null)
+  {
+    if (substr($uri,0,4) != 'http')
+      $uri = $this->baseUri() . $uri;
+
+    if (is_null($locale))
+      $locale = $this->_acceptLanguage;
+
+    $ch = curl_init();
+    curl_setopt($ch, CURLOPT_URL, $uri);
+    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE);
+    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, FALSE);
+    curl_setopt($ch, CURLOPT_MAXREDIRS, 1);
+    curl_setopt($ch, CURLOPT_HEADER, FALSE); // do not return headers
+    curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
+    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
+    curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+    curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+      'Accept: application/pdf',
+      Recurly_Client::__userAgent(),
+      'Accept-Language: ' . $locale
+    ));
+    curl_setopt($ch, CURLOPT_USERPWD, $this->apiKey());
+
+    $response = curl_exec($ch);
+
+    if ($response === false)
+    {
+      $errorNumber = curl_errno($ch);
+      $message = curl_error($ch);
+      curl_close($ch);
+      $this->_raiseCurlError($errorNumber, $message);
+    }
+
+    curl_close($ch);
+
+    return $response;
+  }
+}

+ 131 - 0
plugins/recurly/recurly/coupon.php

@@ -0,0 +1,131 @@
+<?php
+
+class Recurly_Coupon extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+  protected static $_updatableAttributes;
+  protected $_redeemUrl;
+
+  function __construct($href = null, $client = null) {
+    parent::__construct($href, $client);
+    $this->discount_in_cents = new Recurly_CurrencyList('discount_in_cents');
+  }
+
+  public static function init()
+  {
+    Recurly_Coupon::$_writeableAttributes = array(
+      'coupon_code','name','discount_type','redeem_by_date','single_use','applies_for_months',
+      'duration', 'temporal_unit', 'temporal_amount',
+      'max_redemptions','applies_to_all_plans','discount_percent','discount_in_cents','plan_codes',
+      'hosted_description','invoice_description', 'applies_to_non_plan_charges', 'redemption_resource',
+      'max_redemptions_per_account', 'coupon_type', 'unique_code_template', 'unique_coupon_codes'
+    );
+    Recurly_Coupon::$_updatableAttributes = array('name', 'max_redemptions',
+      'max_redemptions_per_account', 'hosted_description', 'invoice_description', 'redeem_by_date'
+    );
+  }
+
+  public static function get($couponCode, $client = null) {
+    return Recurly_Base::_get(Recurly_Coupon::uriForCoupon($couponCode), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_COUPONS);
+  }
+
+  public function redeemCoupon($accountCode, $currency, $subscriptionUUID = null) {
+    if ($this->state != 'redeemable') {
+      throw new Recurly_Error('Coupon is not redeemable.');
+    }
+
+    $redemption = new Recurly_CouponRedemption(null, $this->_client);
+    $redemption->account_code = $accountCode;
+    $redemption->currency = $currency;
+    $redemption->subscription_uuid = $subscriptionUUID;
+
+    foreach ($this->_links as $link) {
+      if ($link->name == 'redeem') {
+        $redemption->_save(strtoupper($link->method), $link->href);
+        return $redemption;
+      }
+    }
+  }
+
+  public function update() {
+    $this->_save(Recurly_Client::PUT, $this->uri(), $this->createUpdateXML());
+  }
+
+  public function restore() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/restore', $this->createUpdateXML());
+  }
+
+  public function delete() {
+    return Recurly_Base::_delete($this->uri(), $this->_client);
+  }
+  public static function deleteCoupon($couponCode, $client = null) {
+    return Recurly_Base::_delete(Recurly_Coupon::uriForCoupon($couponCode), $client);
+  }
+
+  // generates the xml needed for a coupon update
+  // only uses the updateable attributes
+  public function createUpdateXML() {
+    $doc = $this->createDocument();
+
+    $root = $doc->appendChild($doc->createElement($this->getNodeName()));
+
+    foreach ($this->getUpdatableAttributes() as $attr) {
+      $val = $this->$attr;
+
+      if ($val instanceof DateTime) {
+        $val = $val->format('c');
+      }
+
+      $root->appendChild($doc->createElement($attr, $val));
+    }
+
+    return $this->renderXML($doc);
+  }
+
+  public function generate($number) {
+    $doc = $this->createDocument();
+
+    $root = $doc->appendChild($doc->createElement($this->getNodeName()));
+    $root->appendChild($doc->createElement('number_of_unique_codes', $number));
+
+    $response = $this->_client->request(Recurly_Client::POST, $this->uri() . '/generate', $this->renderXML($doc));
+    $response->assertValidResponse();
+
+    $coupons = array();
+    foreach (new Recurly_UniqueCouponCodeList($response->headers['Location'], $this->_client) as $coupon) {
+      $coupons[] = $coupon;
+      if (count($coupons) == $number) break;
+    }
+
+    return $coupons;
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else
+      return Recurly_Coupon::uriForCoupon($this->coupon_code);
+  }
+  protected static function uriForCoupon($couponCode) {
+    return Recurly_Client::PATH_COUPONS . '/' . rawurlencode($couponCode);
+  }
+
+  protected function getNodeName() {
+    return 'coupon';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Coupon::$_writeableAttributes;
+  }
+  protected function getUpdatableAttributes() {
+    return Recurly_Coupon::$_updatableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Coupon::init();

+ 13 - 0
plugins/recurly/recurly/coupon_list.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_CouponList extends Recurly_Pager
+{
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_COUPONS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'coupons';
+  }
+}

+ 27 - 0
plugins/recurly/recurly/currency.php

@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * An amount with a currency.
+ */
+class Recurly_Currency
+{
+  var $currencyCode;
+  var $amount_in_cents;
+  
+  function __construct($currencyCode, $amountInCents) {
+    $this->currencyCode = $currencyCode;
+    $this->amount_in_cents = $amountInCents;
+  }
+
+  /**
+   * Amount in dollars, or whatever your currency may be.
+   * @return float Amount in dollars
+   */
+  public function amount() {
+    return $this->amount_in_cents / 100.0;
+  }
+  
+  public function __toString() {
+    return "<Recurly_Currency currencyCode=\"{$this->currencyCode}\" amount_in_cents={$this->amount_in_cents}>";
+  }
+}

+ 74 - 0
plugins/recurly/recurly/currency_list.php

@@ -0,0 +1,74 @@
+<?php
+
+class Recurly_CurrencyList implements ArrayAccess, Countable, IteratorAggregate
+{
+  private $currencies;
+  private $nodeName;
+
+  function __construct($nodeName) {
+    $this->nodeName = $nodeName;
+    $this->currencies = array();
+  }
+
+  public function addCurrency($currencyCode, $amountInCents) {
+    if (is_string($currencyCode) && strlen($currencyCode) == 3) {
+      $this->currencies[$currencyCode] = new Recurly_Currency($currencyCode, $amountInCents);
+    }
+  }
+
+  public function getCurrency($currencyCode) {
+    return isset($this->currencies[$currencyCode]) ? $this->currencies[$currencyCode] : null;
+  }
+
+  public function offsetSet($offset, $value) {
+    return $this->addCurrency($offset, $value);
+  }
+
+  public function offsetExists($offset) {
+    return isset($this->currencies[$offset]);
+  }
+
+  public function offsetUnset($offset) {
+    unset($this->currencies[$offset]);
+  }
+
+  public function offsetGet($offset) {
+    return $this->getCurrency($offset);
+  }
+
+  public function __set($k, $v) {
+    return $this->offsetSet($k, $v);
+  }
+
+  public function __get($k) {
+    return $this->offsetGet($k);
+  }
+
+  public function count() {
+    return count($this->currencies);
+  }
+
+  public function getIterator() {
+    return new ArrayIterator($this->currencies);
+  }
+
+  public function populateXmlDoc(&$doc, &$node) {
+    // Don't emit an element if there are no currencies.
+    if ($this->currencies) {
+      $currencyNode = $node->appendChild($doc->createElement($this->nodeName));
+      foreach($this->currencies as $currency) {
+        $currencyNode->appendChild($doc->createElement($currency->currencyCode, $currency->amount_in_cents));
+      }
+    }
+  }
+
+  public function __toString() {
+    $values = array();
+    foreach($this->currencies as $currencyCode => $currency) {
+      $amount = isset($currency->amount_in_cents) ? number_format($currency->amount(), 2) : 'null';
+      $values[] = "{$currency->currencyCode}={$amount}";
+    }
+    $values = implode($values, ', ');
+    return "<Recurly_CurrencyList [$values]>";
+  }
+}

+ 62 - 0
plugins/recurly/recurly/error_list.php

@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * Array of errors
+ */
+class Recurly_ErrorList implements ArrayAccess, Countable, IteratorAggregate
+{
+  /**
+   * Transaction. Set on transaction attempts
+   */
+  var $transaction;
+
+  /**
+   * Transaction error information. Set on transaction errors
+   */
+  var $transaction_error;
+
+  /**
+   * Array of field errors
+   */
+  private $errors;
+
+  function __construct() {
+    $this->errors = array();
+  }
+
+  // array access to the errors collection
+  public function offsetSet($offset, $value) {
+    if (is_null($offset)) {
+      $this->errors[] = $value;
+    } else {
+      $this->errors[$offset] = $value;
+    }
+  }
+  public function offsetExists($offset) {
+    return isset($this->errors[$offset]);
+  }
+  public function offsetUnset($offset) {
+    unset($this->errors[$offset]);
+  }
+  public function offsetGet($offset) {
+    return isset($this->errors[$offset]) ? $this->errors[$offset] : null;
+  }
+
+  public function count()
+  {
+    return count($this->errors);
+  }
+
+  public function getIterator() {
+    return new ArrayIterator($this->errors);
+  }
+
+  public function __toString() {
+    $values = array();
+    foreach($this->errors as $error) {
+      $values[] = strval($error);
+    }
+    $values = implode($values, ', ');
+    return "<Recurly_ErrorList [$values] transaction=[{$this->transaction}] transaction_error=[{$this->transaction_error}]>";
+  }
+}

+ 80 - 0
plugins/recurly/recurly/errors.php

@@ -0,0 +1,80 @@
+<?php
+
+/**
+ * Exception class used by the Recurly PHP Client.
+ *
+ * @category   Recurly
+ * @package    Recurly_Client_PHP
+ * @copyright  Copyright (c) 2011 {@link http://recurly.com Recurly, Inc.}
+ */
+class Recurly_Error extends Exception {}
+
+class Recurly_NotFoundError extends Recurly_Error {}
+
+class Recurly_UnauthorizedError extends Recurly_Error {}
+
+class Recurly_ConfigurationError extends Recurly_Error {}
+
+class Recurly_ConnectionError extends Recurly_Error {}
+
+class Recurly_RequestError extends Recurly_Error {}
+
+class Recurly_ApiRateLimitError extends Recurly_RequestError {}
+
+class Recurly_ForgedQueryStringError extends Recurly_Error {}
+
+class Recurly_ValidationError extends Recurly_Error
+{
+  var $object;
+  var $errors;
+  
+  function __construct($message, $object, $errors) {
+    $this->object = $object;
+    $this->errors = $errors;
+
+    // Create a better error message
+    $errs = array();
+    foreach ($errors as $err) {
+      if ($err instanceof Recurly_TransactionError) {
+        # Return just the customer message from the transaction error
+        parent::__construct($err->customer_message);
+        return;
+      }
+      else
+        $errs[] = strval($err);
+    }
+    $message = ucfirst(implode($errs, ', '));
+    if (substr($message, -1) != '.')
+      $message .= '.';
+    parent::__construct($message);
+  }
+}
+
+class Recurly_ServerError extends Recurly_Error {}
+
+class Recurly_FieldError
+{
+  var $field;
+  var $symbol;
+  var $description;
+  
+  public function __toString() {
+    if (!empty($this->field) && ($this->__readableField() != 'base')) {
+      return $this->__readableField() . ' ' . $this->description;
+    }
+    else {
+      return $this->description;
+    }
+  }
+  
+  private function __readableField() {
+    if (empty($this->field))
+      return null;
+
+    $pos = strrpos($this->field, '.');
+    if ($pos === false)
+      return str_replace('_', ' ', $this->field);
+    else
+      return str_replace('_', ' ', substr($this->field, $pos + 1));
+  }
+}

+ 143 - 0
plugins/recurly/recurly/invoice.php

@@ -0,0 +1,143 @@
+<?php
+
+class Recurly_Invoice extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_Invoice::$_writeableAttributes = array('terms_and_conditions', 'customer_notes', 'vat_reverse_charge_notes', 'collection_method', 'net_terms', 'po_number');
+  }
+
+  /**
+   * Lookup an invoice by its ID
+   * @param string Invoice number
+   * @return Recurly_Invoice invoice
+   */
+  public static function get($invoiceNumber, $client = null) {
+    return self::_get(Recurly_Invoice::uriForInvoice($invoiceNumber), $client);
+  }
+
+  /**
+   * Retrieve the PDF version of this invoice
+   */
+  public function getPdf($locale = null) {
+    return Recurly_Invoice::getInvoicePdf($this->invoiceNumberWithPrefix(), $locale, $this->_client);
+  }
+
+  /**
+   * Retrieve the PDF version of an invoice
+   */
+  public static function getInvoicePdf($invoiceNumber, $locale = null, $client = null) {
+    $uri = self::uriForInvoice($invoiceNumber);
+
+    if (is_null($client))
+      $client = new Recurly_Client();
+
+    return $client->getPdf($uri, $locale);
+  }
+
+  /**
+   * Creates an invoice for an account using its pending charges
+   * @param string Unique account code
+   * @param array additional invoice attributes (see writeableAttributes)
+   * @return Recurly_Invoice invoice on success
+   */
+  public static function invoicePendingCharges($accountCode, $attributes = array(), $client = null) {
+    $uri = Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_INVOICES;
+    $invoice = new self();
+    return self::_post($uri, $invoice->setValues($attributes)->xml(), $client);
+  }
+
+  /**
+   * Previews an invoice for an account using its pending charges
+   * @param string Unique account code
+   * @param array additional invoice attributes (see writeableAttributes)
+   * @return Recurly_Invoice invoice on success
+   */
+  public static function previewPendingCharges($accountCode, $attributes = array(), $client = null) {
+    $uri = Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_INVOICES . '/preview';
+    $invoice = new self();
+    return self::_post($uri, $invoice->setValues($attributes)->xml(), $client);
+  }
+
+  public function markSuccessful() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/mark_successful');
+  }
+  public function markFailed() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/mark_failed');
+  }
+
+  public function invoiceNumberWithPrefix() {
+    return $this->invoice_number_prefix . $this->invoice_number;
+  }
+
+  /**
+   * Refunds an open amount from the invoice and returns a new refund invoice
+   * @param Integer amount in cents to refund from this invoice
+   * @param String indicates the refund order to apply, valid options: {'credit','transaction'}, defaults to 'credit'
+   * @return Recurly_Invoice a new refund invoice
+   */
+  public function refundAmount($amount_in_cents, $refund_apply_order = 'credit') {
+    $doc = $this->createDocument();
+
+    $root = $doc->appendChild($doc->createElement($this->getNodeName()));
+    $root->appendChild($doc->createElement('refund_apply_order', $refund_apply_order));
+    $root->appendChild($doc->createElement('amount_in_cents', $amount_in_cents));
+
+    return $this->createRefundInvoice($this->renderXML($doc));
+  }
+
+  /**
+   * Refunds given line items from an invoice and returns new refund invoice
+   * @param Array refund attributes or Array of refund attributes to refund (see 'REFUND ATTRIBUTES' in docs or Recurly_Adjustment#toRefundAttributes)
+   * @param String indicates the refund order to apply, valid options: {'credit','transaction'}, defaults to 'credit'
+   * @return Recurly_Invoice a new refund invoice
+   */
+  public function refund($line_items, $refund_apply_order = 'credit') {
+    if (isset($line_items['uuid'])) { $line_items = array($line_items); }
+
+    $doc = $this->createDocument();
+
+    $root = $doc->appendChild($doc->createElement($this->getNodeName()));
+    $root->appendChild($doc->createElement('refund_apply_order', $refund_apply_order));
+    $line_items_node = $root->appendChild($doc->createElement('line_items'));
+
+    foreach ($line_items as $line_item) {
+      $adjustment_node = $line_items_node->appendChild($doc->createElement('adjustment'));
+      $adjustment_node->appendChild($doc->createElement('uuid', $line_item['uuid']));
+      $adjustment_node->appendChild($doc->createElement('quantity', $line_item['quantity']));
+      $adjustment_node->appendChild($doc->createElement('prorate', $line_item['prorate'] ? 'true' : 'false'));
+    }
+
+    return $this->createRefundInvoice($this->renderXML($doc));
+  }
+
+  protected function createRefundInvoice($xml_string) {
+    return self::_post($this->uri() . '/refund', $xml_string, $this->_client);
+  }
+
+  protected function getNodeName() {
+    return 'invoice';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Invoice::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+  protected function uri() {
+    $invoiceNumberWithPrefix = $this->invoiceNumberWithPrefix();
+    if (!empty($this->_href))
+      return $this->getHref();
+    else if (!empty($invoiceNumberWithPrefix))
+      return Recurly_Invoice::uriForInvoice($invoiceNumberWithPrefix);
+    else
+      throw new Recurly_Error("Invoice number not specified");
+  }
+  protected static function uriForInvoice($invoiceNumber) {
+    return Recurly_Client::PATH_INVOICES . '/' . rawurlencode($invoiceNumber);
+  }
+}
+
+Recurly_Invoice::init();

+ 34 - 0
plugins/recurly/recurly/invoice_list.php

@@ -0,0 +1,34 @@
+<?php
+
+class Recurly_InvoiceList extends Recurly_Pager
+{
+  public static function getOpen($params = null, $client = null) {
+    return Recurly_InvoiceList::get(Recurly_Pager::_setState($params, 'open'), $client);
+  }
+
+  public static function getCollected($params = null, $client = null) {
+    return Recurly_InvoiceList::get(Recurly_Pager::_setState($params, 'collected'), $client);
+  }
+
+  public static function getFailed($params = null, $client = null) {
+    return Recurly_InvoiceList::get(Recurly_Pager::_setState($params, 'failed'), $client);
+  }
+
+  public static function getPastDue($params = null, $client = null) {
+    return Recurly_InvoiceList::get(Recurly_Pager::_setState($params, 'past_due'), $client);
+  }
+
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_INVOICES, $params);
+    return new self($uri, $client);
+  }
+
+  public static function getForAccount($accountCode, $params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_INVOICES, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'invoices';
+  }
+}

+ 19 - 0
plugins/recurly/recurly/link.php

@@ -0,0 +1,19 @@
+<?php
+
+class Recurly_Link
+{
+  var $href;
+  var $name;
+  var $method;
+
+  function __construct($name, $href, $method) {
+    $this->href = $href;
+    $this->name = $name;
+    $this->method = $method;
+  }
+
+  public function __toString()
+  {
+    return "<Recurly_Link[{$this->name}] href=\"$this->href\" method=\"$this->method\">";
+  }
+}

+ 16 - 0
plugins/recurly/recurly/note.php

@@ -0,0 +1,16 @@
+<?php
+
+class Recurly_Note extends Recurly_Resource
+{
+  protected function getNodeName() {
+    return 'note';
+  }
+
+  protected function getWriteableAttributes() {
+   return array();
+  }
+  
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}

+ 16 - 0
plugins/recurly/recurly/note_list.php

@@ -0,0 +1,16 @@
+<?php
+
+class Recurly_NoteList extends Recurly_Pager
+{
+  public static function get($accountCode, $client = null) {
+    return Recurly_Base::_get(Recurly_NoteList::uriForNotes($accountCode), $client);
+  }
+
+  protected static function uriForNotes($accountCode) {
+    return Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_NOTES;
+  }
+
+  protected function getNodeName() {
+    return 'note';
+  }
+}

+ 152 - 0
plugins/recurly/recurly/pager.php

@@ -0,0 +1,152 @@
+<?php
+
+/**
+ * Paginate through multiple pages of a resource. Used for lists that
+ * may require multiple API calls to retrieve all the results.
+ *
+ * The pager moves forward only and can rewind to the first item.
+ */
+abstract class Recurly_Pager extends Recurly_Base implements Iterator
+{
+  private $_position = 0;    // position within the current page
+  protected $_count = null;  // total number of records
+  protected $_objects;       // current page of records
+
+  /**
+   * Number of records in this list.
+   * @return integer number of records in list
+   */
+  public function count() {
+    if (!isset($this->_count)) {
+      if (isset($this->_objects)) {
+        $this->_count = count($this->_objects);
+      } elseif (isset($this->_href)) {
+        // Don't bother with the HEAD request the server takes the same amount
+        // of time to generate them so, might as well just get the results at
+        // the same time.
+        $this->_loadFrom($this->_href);
+      }
+    }
+    return $this->_count;
+  }
+
+  /**
+   * Rewind to the beginning
+   */
+  public function rewind() {
+    $this->_loadFrom($this->_href);
+    $this->_position = 0;
+  }
+
+  /**
+   * The current object
+   * @return Recurly_Resource the current object
+   */
+  public function current()
+  {
+    // Work around pre-PHP 5.5 issue that prevents `empty($this->count())`:
+    if ($this->count() == 0) {
+      return null;
+    }
+
+    while ($this->_position >= sizeof($this->_objects)) {
+      if (isset($this->_links['next'])) {
+        $this->_loadFrom($this->_links['next']);
+        $this->_position = 0;
+      }
+    }
+
+    return $this->_objects[$this->_position];
+  }
+
+  /**
+   * @return integer current position within the current page
+   */
+  public function key() {
+    return $this->_position;
+  }
+
+  /**
+   * Increments the position to the next element
+   */
+  public function next() {
+    ++$this->_position;
+  }
+
+  /**
+   * @return boolean True if the current position is valid.
+   */
+  public function valid() {
+    return (isset($this->_objects[$this->_position]) || isset($this->_links['next']));
+  }
+
+  /**
+   * Load another page of results into this pager.
+   */
+  protected function _loadFrom($uri, $params = null) {
+    if (empty($uri)) {
+      return;
+    }
+
+    $uri = Recurly_Base::_uriWithParams($uri, $params);
+    $response = $this->_client->request(Recurly_Client::GET, $uri);
+    $response->assertValidResponse();
+
+    $this->_objects = array();
+    $this->__parseXmlToUpdateObject($response->body);
+    $this->_afterParseResponse($response, $uri);
+  }
+
+  protected function _afterParseResponse($response, $uri) {
+    $this->_href = $uri;
+    $this->_loadRecordCount($response);
+    $this->_loadLinks($response);
+  }
+
+  protected static function _setState($params, $state) {
+    if (is_null($params)) {
+      $params = array();
+    }
+    $params['state'] = $state;
+    return $params;
+  }
+
+  /**
+   * The 'Links' header contains links to the next, previous, and starting pages.
+   * This parses the links header into an array of links if the header is present.
+   */
+  private function _loadLinks($response) {
+    $this->_links = array();
+
+    if (isset($response->headers['Link'])) {
+      $links = $response->headers['Link'];
+      preg_match_all('/\<([^>]+)\>; rel=\"([^"]+)\"/', $links, $matches);
+      if (sizeof($matches) > 2) {
+        for ($i = 0; $i < sizeof($matches[1]); $i++) {
+          $this->_links[$matches[2][$i]] = $matches[1][$i];
+        }
+      }
+    }
+  }
+
+  /**
+   * Find the total number of results in the collection from the 'X-Records' header.
+   */
+  private function _loadRecordCount($response)
+  {
+    if (isset($response->headers['X-Records'])) {
+      $this->_count = intval($response->headers['X-Records']);
+    }
+  }
+
+  protected function updateErrorAttributes() {}
+
+
+  public function __toString()
+  {
+    $class = get_class($this);
+    $count = (!empty($this->_count) ? "count={$this->_count}" : '');
+
+    return "<{$class}[href={$this->getHref()}] $count>";
+  }
+}

+ 65 - 0
plugins/recurly/recurly/plan.php

@@ -0,0 +1,65 @@
+<?php
+
+class Recurly_Plan extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  function __construct() {
+    parent::__construct();
+    $this->setup_fee_in_cents = new Recurly_CurrencyList('setup_fee_in_cents');
+    $this->unit_amount_in_cents = new Recurly_CurrencyList('unit_amount_in_cents');
+  }
+
+  public static function init()
+  {
+    Recurly_Plan::$_writeableAttributes = array(
+      'plan_code','name','description','success_url','cancel_url',
+      'display_donation_amounts','display_quantity','display_phone_number',
+      'bypass_hosted_confirmation','unit_name','payment_page_tos_link',
+      'plan_interval_length','plan_interval_unit','trial_interval_length',
+      'trial_interval_unit','unit_amount_in_cents','setup_fee_in_cents',
+      'total_billing_cycles','accounting_code','setup_fee_accounting_code',
+      'tax_exempt','tax_code'
+    );
+  }
+
+  public static function get($planCode, $client = null) {
+    return Recurly_Base::_get(Recurly_Plan::uriForPlan($planCode), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_PLANS);
+  }
+  public function update() {
+    $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+  public function delete() {
+    return Recurly_Base::_delete($this->uri(), $this->_client);
+  }
+  public static function deletePlan($planCode, $client = null) {
+    return Recurly_Base::_delete(Recurly_Plan::uriForPlan($planCode), $client);
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else
+      return Recurly_Plan::uriForPlan($this->plan_code);
+  }
+  protected static function uriForPlan($planCode) {
+    return Recurly_Client::PATH_PLANS . '/' . rawurlencode($planCode);
+  }
+
+  protected function getNodeName() {
+    return 'plan';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Plan::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Plan::init();

+ 13 - 0
plugins/recurly/recurly/plan_list.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_PlanList extends Recurly_Pager
+{
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_PLANS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'plans';
+  }
+}

+ 68 - 0
plugins/recurly/recurly/push_notification.php

@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * Example:
+ * <code>
+ * $post_xml = file_get_contents ("php://input");
+ * $notification = new Recurly_PushNotification($post_xml);
+ * </code>
+ */
+class Recurly_PushNotification
+{
+  /* Notification type:
+   *    new_account_notification
+   *    updated_account_notification
+   *    canceled_account_notification
+   *    new_subscription_notification
+   *    updated_subscription_notification
+   *    canceled_subscription_notification
+   *    expired_subscription_notification
+   *    successful_payment_notification
+   *    failed_payment_notification
+   *    successful_refund_notification
+   *    void_payment_notification
+   *    new_invoice_notification
+   *    closed_invoice_notification
+   *    past_due_invoice_notification
+   */
+  var $type;
+
+  var $account;
+  var $subscription;
+  var $transaction;
+  var $invoice;
+
+  function __construct($post_xml)
+  {
+    $this->parseXml($post_xml);
+  }
+
+  function parseXml($post_xml)
+  {
+    if (!@simplexml_load_string ($post_xml)) {
+      return;
+    }
+    $xml = new SimpleXMLElement ($post_xml);
+
+    $this->type = $xml->getName();
+
+    foreach ($xml->children() as $child_node)
+    {
+      switch ($child_node->getName())
+      {
+        case 'account':
+          $this->account = $child_node;
+          break;
+        case 'subscription':
+          $this->subscription = $child_node;
+          break;
+        case 'transaction':
+          $this->transaction = $child_node;
+          break;
+        case 'invoice':
+          $this->invoice = $child_node;
+          break;
+      }
+    }
+  }
+}

+ 77 - 0
plugins/recurly/recurly/recurly_js.php

@@ -0,0 +1,77 @@
+<?php
+
+/*
+ * Helper class for signing Recurly.js requests
+ */
+class Recurly_js
+{
+  /**
+   * Recurly.js Private Key
+   */
+  public static $privateKey;
+
+  private $data;
+
+
+  function __construct($data)
+  {
+    if (!is_array($data)) {
+      throw new Exception("Recurly_js expects an array");
+    }
+
+    $this->data = $data;
+  }
+
+  // Create a signature with the protected data
+  public function get_signature()
+  {
+    $this->data['timestamp'] = $this->utc_timestamp();
+
+    if (!in_array('nonce', $this->data)) {
+      $this->data['nonce'] = $this->get_nonce();
+    }
+
+    ksort($this->data);
+    $queryString = http_build_query($this->data, null, '&');
+    return Recurly_js::_hash($queryString) . "|" . $queryString;
+  }
+
+  // Convenience function providing parity between this and the other libraries.
+  // get_signature() is implemented as a non-static method for ease of testing.
+  //
+  // TODO - Eliminate $type arg in favor of using static keyword for stubbing
+  // if we ever drop PHP < 5.3.
+  public static function sign($data, $type = "Recurly_js")
+  {
+    $rjs = new $type($data);
+    return $rjs->get_signature();
+  }
+
+  // Lookup the result of a Recurly.js operation
+  public static function fetch($token, $client = null)
+  {
+    $uri = Recurly_Client::PATH_RECURLY_JS_RESULT . '/' . rawurlencode($token);
+    return Recurly_Base::_get($uri, $client);
+  }
+
+  // Hash a message using the client's private key
+  public static function _hash($message)
+  {
+    if (!isset(Recurly_js::$privateKey) || strlen(Recurly_js::$privateKey) != 32) {
+      throw new Recurly_ConfigurationError("Recurly.js private key is not set. The private key must be 32 characters.");
+    }
+    return hash_hmac('sha1', $message, Recurly_js::$privateKey);
+  }
+
+  // In its own function so it can be stubbed for testing
+  protected function utc_timestamp()
+  {
+    return time();
+  }
+
+  // In its own function so it can be stubbed for testing
+  protected function get_nonce()
+  {
+    return uniqid();
+  }
+}

+ 45 - 0
plugins/recurly/recurly/redemption.php

@@ -0,0 +1,45 @@
+<?php
+
+class Recurly_CouponRedemption extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+  protected static $_redeemUrl;
+
+  public static function init()
+  {
+    Recurly_CouponRedemption::$_writeableAttributes = array('account_code','currency','subscription_uuid');
+  }
+
+  public static function get($accountCode, $client = null) {
+    return Recurly_Base::_get(Recurly_CouponRedemption::uriForAccount($accountCode), $client);
+  }
+
+  public function delete($accountCode = null) {
+    return Recurly_Base::_delete($this->uri($accountCode), $this->_client);
+  }
+
+  protected function uri($accountCode = null) {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else if(!empty($accountCode))
+      return Recurly_CouponRedemption::uriForAccount($accountCode);
+    else
+      return false;
+  }
+
+  protected static function uriForAccount($accountCode) {
+    return Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_COUPON_REDEMPTION;
+  }
+
+  protected function getNodeName() {
+    return 'redemption';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_CouponRedemption::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_CouponRedemption::init();

+ 13 - 0
plugins/recurly/recurly/redemption_list.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_CouponRedemptionList extends Recurly_Pager
+{
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_COUPON_REDEMPTIONS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'redemptions';
+  }
+}

+ 203 - 0
plugins/recurly/recurly/resource.php

@@ -0,0 +1,203 @@
+<?php
+
+abstract class Recurly_Resource extends Recurly_Base
+{
+  protected $_values;
+  protected $_unsavedKeys;
+  protected $_errors;
+
+  abstract protected function getNodeName();
+  abstract protected function getWriteableAttributes();
+  abstract protected function getRequiredAttributes();
+
+  public function __construct($href = null, $client = null)
+  {
+    parent::__construct($href, $client);
+    $this->_values = array();
+    $this->_unsavedKeys = array();
+    $this->_errors = new Recurly_ErrorList();
+  }
+
+
+  public function __set($k, $v)
+  {
+    $this->_values[$k] = $v;
+    $this->_unsavedKeys[$k] = true;
+  }
+  public function __isset($k)
+  {
+    return isset($this->_values[$k]);
+  }
+  public function __unset($k)
+  {
+    unset($this->_values[$k]);
+  }
+  public function &__get($key)
+  {
+    if (isset($this->_values[$key])) {
+      return $this->_values[$key];
+    //} else if ($this->_attributes->include($key)) {
+    //  return null;
+    } else if ($key == 'errors') {
+      return $this->_errors;
+    } else {
+      $null_val = null;
+      return $null_val;
+    }
+  }
+
+  /**
+   * Return all of the values associated with this resource.
+   *
+   * @return array
+   *   The array of values stored with this resource.
+   */
+  public function getValues() {
+    return $this->_values;
+  }
+
+  public function getErrors() {
+    return $this->_errors;
+  }
+
+  /**
+   * Does a mass assignment on this resource's values
+   *
+   * @param array
+   *   The array of values to set on the resource.
+   */
+  public function setValues($values) {
+    foreach($values as $key => $value) {
+      $this->$key = $value;
+    }
+    return $this;
+  }
+
+  protected function _save($method, $uri, $data = null)
+  {
+    $this->_errors = array(); // reset errors
+
+    if (is_null($data)) {
+      $data = $this->xml();
+    }
+
+    $response = $this->_client->request($method, $uri, $data);
+    $response->assertValidResponse();
+
+    if (isset($response->body)) {
+      Recurly_Resource::__parseXmlToUpdateObject($response->body);
+    }
+    $response->assertSuccessResponse($this);
+  }
+
+
+  public function xml()
+  {
+    $doc = $this->createDocument();
+    $root = $doc->appendChild($doc->createElement($this->getNodeName()));
+    $this->populateXmlDoc($doc, $root, $this);
+    return $this->renderXML($doc);
+  }
+
+  public function createDocument() {
+    return new DOMDocument("1.0");
+  }
+
+  public function renderXML($doc) {
+    // To be able to consistently run tests across different XML libraries,
+    // favor `<foo></foo>` over `<foo/>`.
+    return $doc->saveXML(null, LIBXML_NOEMPTYTAG);
+  }
+
+  protected function populateXmlDoc(&$doc, &$node, &$obj, $nested = false)
+  {
+    $attributes = $obj->getChangedAttributes($nested);
+
+    foreach ($attributes as $key => $val) {
+      if ($val instanceof Recurly_CurrencyList) {
+        $val->populateXmlDoc($doc, $node);
+      } else if ($val instanceof Recurly_Resource) {
+        $attribute_node = $node->appendChild($doc->createElement($key));
+        $this->populateXmlDoc($doc, $attribute_node, $val, true);
+      } else if (is_array($val)) {
+        $attribute_node = $node->appendChild($doc->createElement($key));
+        foreach ($val as $child => $childValue) {
+          if (is_null($child) || is_null($childValue)) {
+            continue;
+          }
+          elseif (is_string($child)) {
+            // e.g. "<discount_in_cents><USD>1000</USD></discount_in_cents>"
+            $attribute_node->appendChild($doc->createElement($child, $childValue));
+          }
+          elseif (is_int($child)) {
+            if (is_object($childValue)) {
+              // e.g. "<subscription_add_ons><subscription_add_on>...</subscription_add_on></subscription_add_ons>"
+              $childValue->populateXmlDoc($doc, $attribute_node, $childValue);
+            }
+            elseif (substr($key, -1) == "s") {
+              // e.g. "<plan_codes><plan_code>gold</plan_code><plan_code>monthly</plan_code></plan_codes>"
+              $attribute_node->appendChild($doc->createElement(substr($key, 0, -1), $childValue));
+            }
+          }
+        }
+      } else if (is_null($val)) {
+        $domAttribute = $doc->createAttribute('nil');
+        $domAttribute->value = 'nil';
+        $attribute_node = $node->appendChild($doc->createElement($key));
+        $attribute_node->appendChild($domAttribute);
+      } else {
+        if ($val instanceof DateTime) {
+          $val = $val->format('c');
+        } else if (is_bool($val)) {
+          $val = ($val ? 'true' : 'false');
+        }
+        $attribute_node = $node->appendChild($doc->createElement($key));
+        $attribute_node->appendChild($doc->createTextNode($val));
+      }
+    }
+  }
+
+  protected function getChangedAttributes($nested = false)
+  {
+    $attributes = array();
+    $writableAttributes = $this->getWriteableAttributes();
+    $requiredAttributes = $this->getRequiredAttributes();
+
+    foreach($writableAttributes as $attr) {
+      if(!array_key_exists($attr, $this->_values)) { continue; }
+
+      if(isset($this->_unsavedKeys[$attr]) ||
+         $nested && in_array($attr, $requiredAttributes) ||
+         (is_array($this->_values[$attr]) || $this->_values[$attr] instanceof ArrayAccess))
+      {
+        $attributes[$attr] = $this->$attr;
+      }
+
+      // Check for nested objects.
+      if ($this->_values[$attr] instanceof Recurly_Resource) {
+        if ($this->_values[$attr]->getChangedAttributes()) {
+          $attributes[$attr] = $this->$attr;
+        }
+      }
+    }
+
+    return $attributes;
+  }
+
+  protected function updateErrorAttributes()
+  {
+    if (sizeof($this->_errors) > 0) {
+      for ($i = sizeof($this->_errors) - 1; $i >= 0; $i--) {
+        $error = $this->_errors[$i];
+
+        if (isset($error->field)) {
+          if (substr($error->field, 0, strlen($this->getNodeName()) + 1) == ($this->getNodeName() . '.'))
+            $error->field = substr($error->field, strlen($this->getNodeName()) + 1);
+        }
+
+        // TODO: If there are more dots, then apply these to sub elements
+      }
+    }
+  }
+}
+

+ 112 - 0
plugins/recurly/recurly/response.php

@@ -0,0 +1,112 @@
+<?php
+
+class Recurly_ClientResponse
+{
+  var $statusCode;
+  var $headers;
+  var $body;
+
+  function __construct($statusCode, $headers, $body) {
+    $this->statusCode = $statusCode;
+    $this->headers = $headers;
+    $this->body = $body;
+  }
+
+  public function assertSuccessResponse($object)
+  {
+    if ($this->statusCode == 422)
+    {
+      if ($object instanceof Recurly_FieldError)
+        throw new Recurly_ValidationError('Validation error', null, array($object));
+      else if ($object instanceof Recurly_ErrorList)
+        throw new Recurly_ValidationError('Validation error', null, $object);
+      else if (is_array($object) && count($object) == 3) {
+        $trans_error = $object[0];
+        $transaction = $object[2];
+        if ($trans_error instanceof Recurly_TransactionError && $transaction instanceof Recurly_Transaction)
+          throw new Recurly_ValidationError($trans_error->customer_message, $transaction, array($trans_error));
+      }
+      else
+        throw new Recurly_ValidationError('Validation error', $object, $object->getErrors());
+    }
+  }
+
+  public function assertValidResponse()
+  {
+    // Successful response code
+    if ($this->statusCode >= 200 && $this->statusCode < 400)
+      return;
+
+    // Do not fail here if the response is not valid XML
+    $error = @$this->parseErrorXml($this->body);
+
+    switch ($this->statusCode) {
+      case 0:
+        throw new Recurly_ConnectionError('An error occurred while connecting to Recurly.');
+      case 400:
+        $message = (is_null($error) ? 'Bad API Request' : $error->description);
+        throw new Recurly_Error($message);
+      case 401:
+        throw new Recurly_UnauthorizedError('Your API Key is not authorized to connect to Recurly.');
+      case 403:
+        throw new Recurly_UnauthorizedError('Please use an API key to connect to Recurly.');
+      case 404:
+        $message = (is_null($error) ? 'Object not found' : $error->description);
+        throw new Recurly_NotFoundError($message);
+      case 422:
+        // Handled in assertSuccessResponse()
+        return;
+      case 429:
+        throw new Recurly_ApiRateLimitError('You have made too many API requests in the last 5 minutes. Future API requests may be ignored until your rate limit resets in 5 minutes. Please visit: https://dev.recurly.com/docs/rate-limits');
+      case 500:
+        $message = (is_null($error) ? 'An error occurred while connecting to Recurly' :
+                   'An error occurred while connecting to Recurly: ' . $error->description);
+        throw new Recurly_ServerError($message);
+      case 502:
+      case 503:
+      case 504:
+        throw new Recurly_ConnectionError('An error occurred while connecting to Recurly.');
+    }
+
+    // Catch future 400-499 errors as request errors
+    if ($this->statusCode >= 400 && $this->statusCode < 500)
+      throw new Recurly_RequestError("Invalid request, status code: {$this->statusCode}");
+
+    // Catch future 500-599 errors as server errors
+    if ($this->statusCode >= 500 && $this->statusCode < 600) {
+      $message = (is_null($error) ? 'An error occurred while connecting to Recurly' :
+                 'An error occurred while connecting to Recurly: ' . $error->description);
+      throw new Recurly_ServerError($message);
+    }
+  }
+
+  private function parseErrorXml($xml) {
+    $dom = new DOMDocument();
+    if (empty($xml) || !$dom->loadXML($xml)) return null;
+
+    $rootNode = $dom->documentElement;
+    if ($rootNode->nodeName == 'error')
+      return Recurly_ClientResponse::parseErrorNode($rootNode);
+    else
+      return null;
+  }
+
+  private static function parseErrorNode($node)
+  {
+    $node = $node->firstChild;
+    $error = new Recurly_FieldError();
+
+    while ($node) {
+      switch ($node->nodeName) {
+        case 'symbol':
+          $error->symbol = $node->nodeValue;
+          break;
+        case 'description':
+          $error->description = $node->nodeValue;
+          break;
+      }
+      $node = $node->nextSibling;
+    }
+    return $error;
+  }
+}

+ 34 - 0
plugins/recurly/recurly/stub.php

@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * Resource stub for Recurly API. Call get() to retrieve the stubbed resource.
+ */
+class Recurly_Stub extends Recurly_Base
+{
+  /**
+   * Stubbed object type. Useful for printing the current object as a string.
+   */
+  var $objectType;
+
+  function __construct($objectType, $href, $client = null)
+  {
+    parent::__construct($href, $client);
+    $this->objectType = $objectType;
+  }
+
+  /**
+   * Retrieve the stubbed resource.
+   */
+  function get() {
+    $stub = self::_get($this->_href, $this->_client);
+    if ($this->_href && !$stub->getHref()) {
+      $stub->setHref($this->_href);
+    }
+    return $stub;
+  }
+
+  public function __toString()
+  {
+    return "<Recurly_Stub[{$this->objectType}] href={$this->_href}>";
+  }
+}

+ 134 - 0
plugins/recurly/recurly/subscription.php

@@ -0,0 +1,134 @@
+<?php
+
+class Recurly_Subscription extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_Subscription::$_writeableAttributes = array(
+      'account','plan_code','coupon_code','coupon_codes','unit_amount_in_cents','quantity',
+      'currency','starts_at','trial_ends_at','total_billing_cycles', 'first_renewal_date',
+      'timeframe', 'subscription_add_ons', 'net_terms', 'po_number', 'collection_method',
+      'cost_in_cents', 'remaining_billing_cycles', 'bulk', 'terms_and_conditions', 'customer_notes',
+      'vat_reverse_charge_notes', 'bank_account_authorized_at'
+    );
+  }
+
+  public function __construct($href = null, $client = null) {
+    parent::__construct($href, $client);
+    $this->subscription_add_ons = array();
+  }
+
+  public static function get($uuid, $client = null) {
+    return Recurly_Base::_get(Recurly_Subscription::uriForSubscription($uuid), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_SUBSCRIPTIONS);
+  }
+
+  public function preview() {
+    if ($this->uuid) {
+      $this->_save(Recurly_Client::POST, $this->uri() . '/preview');
+    } else {
+      $this->_save(Recurly_Client::POST, Recurly_Client::PATH_SUBSCRIPTIONS . '/preview');
+    }
+  }
+
+  /**
+   * Cancel the subscription; it will remain active until it renews.
+   */
+  public function cancel() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/cancel');
+  }
+  /**
+   * Reactivate a canceled subscription. The subscription will return back to the active,
+   * auto renewing state.
+   */
+  public function reactivate() {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/reactivate');
+  }
+
+  /**
+   * Make an update that takes effect immediately.
+   */
+  public function updateImmediately() {
+    $this->timeframe = 'now';
+    $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+  /**
+   * Make an update that applies when the subscription renews.
+   */
+  public function updateAtRenewal() {
+    $this->timeframe = 'renewal';
+    $this->_save(Recurly_Client::PUT, $this->uri());
+  }
+
+
+  /**
+   * Terminate the subscription immediately and issue a full refund of the last renewal
+   */
+  public function terminateAndRefund() {
+    $this->terminate('full');
+  }
+  /**
+   * Terminate the subscription immediately and issue a prorated/partial refund of the last renewal
+   */
+  public function terminateAndPartialRefund() {
+    $this->terminate('partial');
+  }
+  /**
+   * Terminate the subscription immediately without a refund
+   */
+  public function terminateWithoutRefund() {
+    $this->terminate('none');
+  }
+  private function terminate($refundType) {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/terminate?refund=' . $refundType);
+  }
+
+  /**
+   * Postpone a subscription's renewal date.
+   *
+   * @param String ISO8601 DateTime String, postpone the subscription to this date
+   * @param Boolean bulk is for making bulk updates, setting to true bypasses api check for accidental duplicate subscriptions.
+   **/
+  public function postpone($nextRenewalDate, $bulk = false) {
+    $this->_save(Recurly_Client::PUT, $this->uri() . '/postpone?next_renewal_date=' . $nextRenewalDate . '&bulk=' . ((bool) $bulk));
+  }
+
+  /**
+   * Updates the notes fields of the subscription without generating a SubscriptionChange.
+   *
+   * @param array of notes, allowed keys: (customer_notes, terms_and_conditions, vat_reverse_charge_notes)
+   **/
+  public function updateNotes($notes) {
+    $this->setValues($notes)->_save(Recurly_Client::PUT, $this->uri() . '/notes');
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else if (!empty($this->uuid))
+      return Recurly_Subscription::uriForSubscription($this->uuid);
+    else
+      throw new Recurly_Error("Subscription UUID not specified");
+  }
+  protected static function uriForSubscription($uuid) {
+    return Recurly_Client::PATH_SUBSCRIPTIONS . '/' . rawurlencode($uuid);
+  }
+
+  protected function getNodeName() {
+    return 'subscription';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Subscription::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Subscription::init();

+ 48 - 0
plugins/recurly/recurly/subscription_addon.php

@@ -0,0 +1,48 @@
+<?php
+
+class Recurly_SubscriptionAddOn extends Recurly_Resource {
+
+  protected static $_writeableAttributes;
+
+  public static function init() {
+    Recurly_SubscriptionAddOn::$_writeableAttributes = array(
+      'add_on_code',
+      'quantity',
+      'unit_amount_in_cents'
+    );
+  }
+
+  protected function getNodeName() {
+    return 'subscription_add_on';
+  }
+
+  protected function getWriteableAttributes() {
+    return Recurly_SubscriptionAddOn::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+
+  protected function populateXmlDoc(&$doc, &$node, &$obj, $nested = false) {
+    $addonNode = $node->appendChild($doc->createElement($this->getNodeName()));
+    parent::populateXmlDoc($doc, $addonNode, $obj);
+  }
+
+  protected function getChangedAttributes($nested = false) {
+    // Ignore the name, it can't be changed.
+    return array_diff_key($this->_values, array('name' => 0));
+  }
+
+  /**
+   * Pretty string version of the object
+   */
+  public function __toString()
+  {
+    $class = get_class($this);
+    $values = $this->__valuesString();
+    return "<$class $values>";
+  }
+}
+
+Recurly_SubscriptionAddOn::init();
+

+ 45 - 0
plugins/recurly/recurly/subscription_list.php

@@ -0,0 +1,45 @@
+<?php
+
+class Recurly_SubscriptionList extends Recurly_Pager
+{
+  public static function getActive($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'active'), $client);
+  }
+
+  public static function getCanceled($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'canceled'), $client);
+  }
+
+  public static function getExpired($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'expired'), $client);
+  }
+
+  public static function getFuture($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'future'), $client);
+  }
+
+  public static function getLive($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'live'), $client);
+  }
+
+  public static function getPastDue($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'past_due'), $client);
+  }
+  public static function getTrials($params = null, $client = null) {
+    return Recurly_SubscriptionList::get(Recurly_Pager::_setState($params, 'in_trial'), $client);
+  }
+
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_SUBSCRIPTIONS, $params);
+    return new self($uri, $client);
+  }
+
+  public static function getForAccount($accountCode, $params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_SUBSCRIPTIONS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'subscriptions';
+  }
+}

+ 13 - 0
plugins/recurly/recurly/tax_detail.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_Tax_Detail extends Recurly_Resource {
+  protected function getNodeName() {
+    return 'tax_detail';
+  }
+  protected function getWriteableAttributes() {
+    return array();
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}

+ 64 - 0
plugins/recurly/recurly/transaction.php

@@ -0,0 +1,64 @@
+<?php
+
+class Recurly_Transaction extends Recurly_Resource
+{
+  protected static $_writeableAttributes;
+
+  public static function init()
+  {
+    Recurly_Transaction::$_writeableAttributes = array(
+      'account','amount_in_cents','currency','description','accounting_code', 'tax_exempt', 'tax_code'
+    );
+  }
+
+  public static function get($uuid, $client = null) {
+    return Recurly_Base::_get(Recurly_Transaction::uriForTransaction($uuid), $client);
+  }
+
+  public function create() {
+    $this->_save(Recurly_Client::POST, Recurly_Client::PATH_TRANSACTIONS);
+  }
+
+  /**
+   * Refund a previous, successful transaction
+   */
+  public function refund($amountInCents = null) {
+    $uri = $this->uri();
+    if (!is_null($amountInCents)) {
+      $uri .= '?amount_in_cents=' . strval(intval($amountInCents));
+    }
+    $this->_save(Recurly_Client::DELETE, $uri);
+  }
+
+  /**
+   * Attempt a void, otherwise refund
+   */
+  public function void() {
+    trigger_error('Deprecated method: void(). Use refund() instead.', E_USER_NOTICE);
+    $this->refund();
+  }
+
+  protected function uri() {
+    if (!empty($this->_href))
+      return $this->getHref();
+    else if (!empty($this->uuid))
+      return Recurly_Transaction::uriForTransaction($this->uuid);
+    else
+      throw new Recurly_Error('"uuid" is not supplied');
+  }
+  protected static function uriForTransaction($uuid) {
+    return Recurly_Client::PATH_TRANSACTIONS . '/' . rawurlencode($uuid);
+  }
+
+  protected function getNodeName() {
+    return 'transaction';
+  }
+  protected function getWriteableAttributes() {
+    return Recurly_Transaction::$_writeableAttributes;
+  }
+  protected function getRequiredAttributes() {
+    return array();
+  }
+}
+
+Recurly_Transaction::init();

+ 33 - 0
plugins/recurly/recurly/transaction_error.php

@@ -0,0 +1,33 @@
+<?php
+
+class Recurly_TransactionError
+{
+  /**
+   * Error code
+   */
+  var $error_code;
+
+  /**
+   * Error category
+   */
+  var $error_category;
+
+  /**
+   * Message to display to the customer
+   */
+  var $customer_message;
+
+  /**
+   * Advice to the merchant on why the transaction failed
+   */
+  var $merchant_message;
+
+  /**
+   * The error code returned by the gateway
+   */
+  var $gateway_error_code;
+
+  public function __toString() {
+    return "<Recurly_TransactionError error_code=\"{$this->error_code}\" error_category=\"{$this->error_category}\" customer_message=\"{$this->customer_message}\" transaction_error=\"{$this->merchant_message}\" gateway_error_code=\"{$this->gateway_error_code}\">";
+  }
+}

+ 26 - 0
plugins/recurly/recurly/transaction_list.php

@@ -0,0 +1,26 @@
+<?php
+
+class Recurly_TransactionList extends Recurly_Pager
+{
+  public static function getSuccessful($params = null, $client = null) {
+    return Recurly_TransactionList::get(Recurly_Pager::_setState($params, 'successful'), $client);
+  }
+
+  public static function getVoided($params = null, $client = null) {
+    return Recurly_TransactionList::get(Recurly_Pager::_setState($params, 'voided'), $client);
+  }
+
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_TRANSACTIONS, $params);
+    return new self($uri, $client);
+  }
+
+  public static function getForAccount($accountCode, $params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_ACCOUNTS . '/' . rawurlencode($accountCode) . Recurly_Client::PATH_TRANSACTIONS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'transactions';
+  }
+}

+ 14 - 0
plugins/recurly/recurly/unique_coupon_code_list.php

@@ -0,0 +1,14 @@
+<?php
+
+class Recurly_UniqueCouponCodeList extends Recurly_Pager
+{
+
+  public static function get($params = null, $client = null) {
+    $uri = self::_uriWithParams(Recurly_Client::PATH_UNIQUE_COUPONS, $params);
+    return new self($uri, $client);
+  }
+
+  protected function getNodeName() {
+    return 'unique_coupon_codes';
+  }
+}

+ 13 - 0
plugins/recurly/recurly/util/hmac_hash.php

@@ -0,0 +1,13 @@
+<?php
+
+class Recurly_HmacHash
+{
+  public static function hash($privateKey, $message)
+  {
+    if (strlen($privateKey) != 32) {
+      throw new Recurly_Error("Recurly private key is not set. The private key must be 32 characters.");
+    }
+
+    return hash_hmac('sha1', $message, $privateKey);
+  }
+}