<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[2325] sites/trunk/wordcamp.org/public_html/wp-content/plugins: WordCamp.org: Add initial pass at the QBO plugin pair.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="http://meta.trac.wordpress.org/changeset/2325">2325</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"http://meta.trac.wordpress.org/changeset/2325","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>kovshenin</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2016-01-19 17:30:41 +0000 (Tue, 19 Jan 2016)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>WordCamp.org: Add initial pass at the QBO plugin pair.</pre>

<h3>Added Paths</h3>
<ul>
<li>sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/</li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboclasswordcampqbooauthclientphp">sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/class-wordcamp-qbo-oauth-client.php</a></li>
<li>sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/</li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboimagesqboconnectpng">sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png</a></li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqbowordcampqbophp">sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/wordcamp-qbo.php</a></li>
<li>sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo-client/</li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboclientwordcampqboclientphp">sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo-client/wordcamp-qbo-client.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboclasswordcampqbooauthclientphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/class-wordcamp-qbo-oauth-client.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/class-wordcamp-qbo-oauth-client.php                          (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/class-wordcamp-qbo-oauth-client.php    2016-01-19 17:30:41 UTC (rev 2325)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,169 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * WordCamp QBO Oauth Client
+ *
+ * Note: This is NOT a general-purpose OAuth client, it is only suitable
+ * for the WordCamp QBO plugin.
+ */
+class WordCamp_QBO_OAuth_Client {
+    private $consumer_key;
+    private $consumer_secret;
+    private $oauth_token;
+    private $oauth_token_secret;
+
+    /**
+     * @param string $consumer_key The OAuth consumer key
+     * @param string $consumer_secret The secret
+     */
+    public function __construct( $consumer_key, $consumer_secret ) {
+        $this->consumer_key = $consumer_key;
+        $this->consumer_secret = $consumer_secret;
+    }
+
+    /**
+     * Set current OAuth token
+     *
+     * @param string $oauth_token An OAuth token.
+     * @param string $oauth_token_secret The OAuth token secret.
+     */
+    public function set_token( $oauth_token, $oauth_token_secret ) {
+        $this->oauth_token = $oauth_token;
+        $this->oauth_token_secret = $oauth_token_secret;
+    }
+
+    /**
+     * Get a request token.
+     *
+     * @param string $callback_url The URL to which a successful authentication will return.
+     *
+     * @return array An array with the tokens.
+     */
+    public function get_request_token( $request_url, $callback_url ) {
+        $args = array_merge( $this->_get_default_args(), array(
+            'oauth_callback' => $callback_url,
+        ) );
+
+        $args['oauth_signature'] = $this->_get_signature( 'POST', $request_url, $args );
+        $args = array_map( 'rawurlencode', $args );
+
+        $response = wp_remote_post( add_query_arg( $args, $request_url ) );
+        if ( is_wp_error( $response ) )
+            return $response;
+
+        if ( wp_remote_retrieve_response_code( $response ) != 200 )
+            return new WP_Error( 'error', 'Could not get OAuth request token.' );
+
+        $result = wp_parse_args( wp_remote_retrieve_body( $response ), array(
+            'oauth_token' => '',
+            'oauth_token_secret' => '',
+            'oauth_callback_confirmed' => '',
+        ) );
+
+        return $result;
+    }
+
+    /**
+     * Get an OAuth access token.
+     *
+     * @param string $verifier A verifier token from the authentication flow.
+     *
+     * @return array The access token.
+     */
+    public function get_access_token( $request_url, $verifier ) {
+        $args = array_merge( $this->_get_default_args(), array(
+            'oauth_verifier' => $verifier,
+            'oauth_token' => $this->oauth_token,
+        ) );
+
+        $args['oauth_signature'] = $this->_get_signature( 'POST', $request_url, $args );
+        $args = array_map( 'rawurlencode', $args );
+
+        $response = wp_remote_post( add_query_arg( $args, $request_url ) );
+
+        if ( is_wp_error( $response ) )
+            return $response;
+
+        if ( wp_remote_retrieve_response_code( $response ) != 200 )
+            return new WP_Error( 'error', 'Could not get OAuth access token.' );
+
+        $result = wp_parse_args( wp_remote_retrieve_body( $response ), array(
+            'oauth_token' => '',
+            'oauth_token_secret' => '',
+        ) );
+
+        return $result;
+    }
+
+    /**
+     * Get a string suitable for the Authorization header.
+     *
+     * @see http://oauth.net/core/1.0a/#auth_header
+     *
+     * @param string $method The request method.
+     * @param string $request_url The request URL (without query)
+     * @param array|string $request_args Any additional query/body args.
+     *
+     * @return string An OAuth string ready for the Authorization header.
+     */
+    public function get_oauth_header( $method, $request_url, $request_args = array() ) {
+        $oauth_args = array_merge( $this->_get_default_args(), array(
+            'oauth_token' => $this->oauth_token,
+        ) );
+
+        $all_args = $oauth_args;
+        if ( is_array( $request_args ) && ! empty( $request_args ) )
+            $all_args = array_merge( $oauth_args, $request_args );
+
+        $oauth_args['oauth_signature'] = $this->_get_signature( $method, $request_url, $all_args );
+
+        $header_parts = array();
+        foreach ( $oauth_args as $key => $value )
+            $header_parts[] = sprintf( '%s="%s"', rawurlencode( $key ), rawurlencode( $value ) );
+
+        $header = 'OAuth ' . implode( ',', $header_parts );
+        return $header;
+    }
+
+    /**
+     * Get a default set of OAuth arguments.
+     *
+     * @return array Default OAuth arguments.
+     */
+    private function _get_default_args() {
+        return array(
+            'oauth_nonce' => md5( wp_generate_password( 12 ) ),
+            'oauth_consumer_key' => $this->consumer_key,
+            'oauth_signature_method' => 'HMAC-SHA1',
+            'oauth_timestamp' => time(),
+            'oauth_version' => '1.0',
+        );
+    }
+
+    /**
+     * Get an OAuth signature.
+     *
+     * @see http://oauth.net/core/1.0a/#signing_process
+     *
+     * @param string $method The request method, GET, POST, etc.
+     * @param string $url The request URL (without any query)
+     * @param array $args An optional array of query or body args.
+     *
+     * @return string A base64-encoded hmac-sha1 signature.
+     */
+    private function _get_signature( $method, $url, $args ) {
+        ksort( $args );
+
+        // Don't sign a signature.
+        unset( $args['oauth_signature'] );
+
+        $parameter_string = '';
+        foreach ( $args as $key => $value )
+            $parameter_string .= sprintf( '&%s=%s', rawurlencode( $key ), rawurlencode( $value ) );
+
+        $parameter_string = trim( $parameter_string, '&' );
+        $signature_base = strtoupper( $method ) . '&' . rawurlencode( $url ) . '&' . rawurlencode( $parameter_string );
+        $signing_key = rawurlencode( $this->consumer_secret ) . '&' . rawurlencode( $this->oauth_token_secret );
+
+        return base64_encode( hash_hmac( 'sha1', $signature_base, $signing_key, true ) );
+    }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of file
</span></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboimagesqboconnectpng"></a>
<div class="binary"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png</h4>
<pre class="diff"><span>
<span class="cx">(Binary files differ)
</span></span></pre></div>
<span class="cx" style="display: block; padding: 0 10px">Index: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png
</span><span class="cx" style="display: block; padding: 0 10px">===================================================================
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">--- sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png  2016-01-19 17:20:35 UTC (rev 2324)
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+++ sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png   2016-01-19 17:30:41 UTC (rev 2325)
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/images/qbo-connect.png
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span><a id="svnmimetype"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:mime-type</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+application/octet-stream
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqbowordcampqbophp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/wordcamp-qbo.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/wordcamp-qbo.php                             (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo/wordcamp-qbo.php       2016-01-19 17:30:41 UTC (rev 2325)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,471 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Plugin Name: WordCamp.org QBO Integration
+ */
+
+class WordCamp_QBO {
+    private static $app_token;
+    private static $consumer_key;
+    private static $consumer_secret;
+    private static $hmac_key;
+
+    private static $options;
+    private static $categories_map;
+
+    public static function load_options() {
+        if ( isset( self::$options ) )
+            return self::$options;
+
+        self::$options = wp_parse_args( get_option( 'wordcamp-qbo', array() ), array(
+            'auth' => array(),
+        ) );
+    }
+
+    /**
+     * Runs immediately.
+     */
+    public static function load() {
+        add_action( 'plugins_loaded', array( __CLASS__, 'plugins_loaded' ) );
+    }
+
+    /**
+     * Runs during plugins_loaded.
+     */
+    public static function plugins_loaded() {
+
+        $init_options = wp_parse_args( apply_filters( 'wordcamp_qbo_options', array() ), array(
+            'app_token' => '',
+            'consumer_key' => '',
+            'consumer_secret' => '',
+            'hmac_key' => '',
+
+            'categories_map' => array(),
+        ) );
+
+        foreach ( $init_options as $key => $value )
+            self::$$key = $value;
+
+        // There's no point in doing anything if we don't have the secrets.
+        if ( empty( self::$consumer_key ) )
+            return;
+
+        add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
+        add_action( 'admin_init', array( __CLASS__, 'admin_init' ) );
+        add_filter( 'rest_api_init', array( __CLASS__, 'rest_api_init' ) );
+
+        self::maybe_oauth_request();
+    }
+
+    /**
+     * Runs during rest_api_init.
+     */
+    public static function rest_api_init() {
+        register_rest_route( 'wordcamp-qbo/v1', '/expense', array(
+            'methods' => 'GET, POST',
+            'callback' => array( __CLASS__, 'rest_callback_expense' ),
+        ) );
+
+        register_rest_route( 'wordcamp-qbo/v1', '/classes', array(
+            'methods' => 'GET',
+            'callback' => array( __CLASS__, 'rest_callback_classes' ),
+        ) );
+    }
+
+    /**
+     * REST: /expense
+     *
+     * @param WP_REST_Request $request
+     */
+    public static function rest_callback_expense( $request ) {
+        if ( ! self::_is_valid_request( $request ) )
+            return new WP_Error( 'unauthorized', 'Unauthorized', array( 'status' => 401 ) );
+
+        self::load_options();
+        $oauth = self::_get_oauth();
+        $oauth->set_token( self::$options['auth']['oauth_token'], self::$options['auth']['oauth_token_secret'] );
+
+        $amount = floatval( $request->get_param( 'amount' ) );
+        if ( ! $amount )
+            return new WP_Error( 'error', 'An amount was not given.' );
+
+        $description = $request->get_param( 'description' );
+        if ( empty( $description ) )
+            return new WP_Error( 'error', 'The expense description can not be empty.' );
+
+        $category = $request->get_param( 'category' );
+        if ( empty( $category ) || ! array_key_exists( $category, self::$categories_map ) )
+            return new WP_Error( 'error', 'The category you have picked is invalid.' );
+
+        $date = $request->get_param( 'date' );
+        if ( empty( $date ) )
+            return new WP_Error( 'error', 'The expense date can not be empty.' );
+
+        $date = absint( $date );
+
+        $class = $request->get_param( 'class' );
+        if ( empty( $class ) )
+            return new WP_Error( 'error', 'You need to set a class.' );
+
+        $classes = self::_get_classes();
+        if ( ! array_key_exists( $class, $classes ) )
+            return new WP_Error( 'error', 'Unknown class.' );
+
+        $class = array(
+            'value' => $class,
+            'name' => $classes[ $class ],
+        );
+
+        $payload = array(
+            'AccountRef' => array(
+                'value' => '61',
+                'name' => 'Checking-JPM',
+            ),
+            'TxnDate' => gmdate( 'Y-m-d', $date ),
+            'PaymentType' => 'Cash',
+            'Line' => array(
+                array(
+                    'Id' => 1,
+                    'Description' => $description,
+                    'Amount' => $amount,
+                    'DetailType' => 'AccountBasedExpenseLineDetail',
+                    'AccountBasedExpenseLineDetail' => array(
+                        'ClassRef' => $class,
+                        'AccountRef' => self::$categories_map[ $category ],
+                    ),
+                ),
+            ),
+        );
+
+        if ( $request->get_param('id') ) {
+            $payload['Id'] = absint( $request->get_param('id') );
+
+            $request_url = esc_url_raw( sprintf( 'https://quickbooks.api.intuit.com/v3/company/%d/purchase/%d',
+                self::$options['auth']['realmId'], $payload['Id'] ) );
+            $oauth_header = $oauth->get_oauth_header( 'GET', $request_url );
+            $response = wp_remote_get( $request_url, array(
+                'headers' => array(
+                    'Authorization' => $oauth_header,
+                    'Accept' => 'application/json',
+                ),
+            ) );
+
+            if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response != 200 ) )
+                return new WP_Error( 'error', 'Could not find purchase to update.' );
+
+            $body = json_decode( wp_remote_retrieve_body( $response ), true );
+            if ( ! isset( $body['Purchase']['SyncToken'] ) )
+                return new WP_Error( 'error', 'Could not decode purchase for update.' );
+
+            $payload['SyncToken'] = $body['Purchase']['SyncToken'];
+            unset( $response );
+        }
+
+        $payload = json_encode( $payload );
+        $request_url = esc_url_raw( sprintf( 'https://quickbooks.api.intuit.com/v3/company/%d/purchase',
+            self::$options['auth']['realmId'] ) );
+
+        $oauth_header = $oauth->get_oauth_header( 'POST', $request_url, $payload );
+        $response = wp_remote_post( $request_url, array(
+            'headers' => array(
+                'Authorization' => $oauth_header,
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+            ),
+            'body' => $payload,
+        ) );
+
+        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) != 200 )
+            return new WP_Error( 'error', 'Could not create purchase.' );
+
+        $body = json_decode( wp_remote_retrieve_body( $response ), true );
+        if ( empty( $body ) )
+            return new WP_Error( 'error', 'Could not decode create purchase result.' );
+
+        return array(
+            'transaction_id' => intval( $body['Purchase']['Id'] ),
+        );
+    }
+
+    /**
+     * REST: /classes
+     *
+     * @param WP_REST_Request $request
+     */
+    public static function rest_callback_classes( $request ) {
+        if ( ! self::_is_valid_request( $request ) )
+            return new WP_Error( 'unauthorized', 'Unauthorized', array( 'status' => 401 ) );
+
+        return self::_get_classes();
+    }
+
+    /**
+     * Get an array of available QBO classes.
+     *
+     * @uses get_transient, set_transient
+     *
+     * @return array An array of class IDs as keys, names as values.
+     */
+    private static function _get_classes() {
+        $cache_key = md5( 'wordcamp-qbo:classes' );
+        $cache = get_transient( $cache_key );
+
+        if ( $cache !== false )
+            return $cache;
+
+        self::load_options();
+        $oauth = self::_get_oauth();
+        $oauth->set_token( self::$options['auth']['oauth_token'], self::$options['auth']['oauth_token_secret'] );
+
+        $args = array(
+            'query' => 'SELECT * FROM Class',
+            'minorversion' => 4,
+        );
+
+        $request_url = esc_url_raw( sprintf( 'https://quickbooks.api.intuit.com/v3/company/%d/query',
+            self::$options['auth']['realmId'] ) );
+
+        $oauth_header = $oauth->get_oauth_header( 'GET', $request_url, $args );
+        $response = wp_remote_get( esc_url_raw( add_query_arg( $args, $request_url ) ), array( 'headers' => array(
+            'Authorization' => $oauth_header,
+            'Accept' => 'application/json',
+        ) ) );
+
+        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
+            return new WP_Error( 'error', 'Could not fetch classes.' );
+        }
+
+        $body = json_decode( wp_remote_retrieve_body( $response ), true );
+        if ( empty( $body ) ) {
+            return new WP_Error( 'error', 'Could not fetch classes body.' );
+        }
+
+        $classes = array();
+        foreach ( $body['QueryResponse']['Class'] as $class ) {
+            $classes[ $class['Id'] ] = $class['Name'];
+        }
+
+        asort( $classes );
+
+        set_transient( $cache_key, $classes, 12 * HOUR_IN_SECONDS );
+        return $classes;
+    }
+
+    /**
+     * Verify an HMAC signature for an API request.
+     *
+     * @param WP_REST_Request $request The REST API request.
+     *
+     * @return bool True if valid, false if invalid.
+     */
+    private static function _is_valid_request( $request ) {
+        if ( ! $request->get_header( 'authorization' ) )
+            return false;
+
+        if ( ! preg_match( '#^wordcamp-qbo-hmac (.+)$#', $request->get_header( 'authorization' ), $matches ) )
+            return false;
+
+        $given_hmac = $matches[1];
+        $request_url = esc_url_raw( home_url( parse_url( home_url( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) );
+        $payload = json_encode( array( strtolower( $request->get_method() ), strtolower( $request_url ),
+            $request->get_body(), $request->get_query_params() ) );
+
+        return hash_equals( hash_hmac( 'sha256', $payload, self::$hmac_key ), $given_hmac );
+    }
+
+    /**
+     * Update self::$options.
+     */
+    public static function update_options() {
+        self::load_options();
+        update_option( 'wordcamp-qbo', self::$options );
+    }
+
+    /**
+     * Catch an OAuth authentication flow if it is one.
+     */
+    public static function maybe_oauth_request() {
+        if ( empty( $_GET['wordcamp-qbo-oauth-request'] ) )
+            return;
+
+        if ( empty( $_GET['wordcamp-qbo-oauth-nonce'] ) || ! wp_verify_nonce( $_GET['wordcamp-qbo-oauth-nonce'], 'oauth-request' ) )
+            wp_die( 'Could not verify nonce.' );
+
+        self::load_options();
+        $oauth = self::_get_oauth();
+
+        if ( empty( $_GET['oauth_token'] ) ) {
+
+            // We don't have an access token yet.
+            $request_url = 'https://oauth.intuit.com/oauth/v1/get_request_token';
+            $callback_url = esc_url_raw( add_query_arg( array(
+                'wordcamp-qbo-oauth-request' => 1,
+                'wordcamp-qbo-oauth-nonce' => wp_create_nonce( 'oauth-request' ),
+            ), admin_url() ) );
+
+            $request_token = $oauth->get_request_token( $request_url, $callback_url );
+            if ( is_wp_error( $request_token ) )
+                wp_die( $request_token->get_error_message() );
+
+            update_user_meta( get_current_user_id(), 'wordcamp-qbo-oauth', $request_token );
+
+            wp_redirect( esc_url_raw( add_query_arg( 'oauth_token', $request_token['oauth_token'],
+                'https://appcenter.intuit.com/Connect/Begin' ) ) );
+            die();
+
+        } else {
+
+            // We have a token.
+            $request_token = get_user_meta( get_current_user_id(), 'wordcamp-qbo-oauth', true );
+
+            if ( $request_token['oauth_token'] != $_GET['oauth_token'] )
+                wp_die( 'Could not verify OAuth token.' );
+
+            if ( empty( $_GET['oauth_verifier'] ) )
+                wp_die( 'Could not obtain OAuth verifier.' );
+
+            $oauth->set_token( $request_token['oauth_token'], $request_token['oauth_token_secret'] );
+            $request_url = 'https://oauth.intuit.com/oauth/v1/get_access_token';
+
+            $access_token = $oauth->get_access_token( $request_url, $_GET['oauth_verifier'] );
+
+            if ( is_wp_error( $access_token ) )
+                wp_die( 'Could not obtain an access token.' );
+
+            // We have an access token.
+            $data = array(
+                'oauth_token' => $access_token['oauth_token'],
+                'oauth_token_secret' => $access_token['oauth_token_secret'],
+                'realmId' => $_GET['realmId'],
+            );
+
+            self::$options['auth'] = $data;
+
+            $oauth->set_token( self::$options['auth']['oauth_token'], self::$options['auth']['oauth_token_secret'] );
+            $request_url = sprintf( 'https://quickbooks.api.intuit.com/v3/company/%d/companyinfo/%d',
+                self::$options['auth']['realmId'], self::$options['auth']['realmId'] );
+
+            $oauth_header = $oauth->get_oauth_header( 'GET', $request_url );
+            $response = wp_remote_get( $request_url, array( 'headers' => array(
+                'Authorization' => $oauth_header,
+                'Accept' => 'application/json',
+            ) ) );
+
+            if ( is_wp_error( $response ) ) {
+                wp_die( 'Could not obtain company information.' );
+            }
+
+            $body = json_decode( wp_remote_retrieve_body( $response ), true );
+            if ( empty( $body ) ) {
+                wp_die( 'Could not obtain company information.' );
+            }
+            $company_name = $body['CompanyInfo']['CompanyName'];
+
+            self::$options['auth']['name'] = $company_name;
+            self::$options['auth']['timestamp'] = time();
+            self::update_options();
+
+            // Flush some caches.
+            delete_transient( md5( 'wordcamp-qbo:classes' ) );
+
+            wp_die( sprintf( 'Your QBO account (%s) has been linked. You can now close this window.', esc_html( $company_name ) ) );
+        }
+    }
+
+    /**
+     * Runs during admin_menu
+     */
+    public static function admin_menu() {
+        $cap = is_multisite() ? 'manage_network' : 'manage_options';
+        add_submenu_page( 'options-general.php', 'WordCamp QBO', 'QuickBooks',
+            $cap, 'wordcamp-qbo', array( __CLASS__, 'render_settings' ) );
+    }
+
+    /**
+     * Runs during admin_init.
+     */
+    public static function admin_init() {
+        register_setting( 'wordcamp-qbo', 'wordcamp-qbo', array( __CLASS__, 'sanitize_options' ) );
+    }
+
+    /**
+     * Runs whenever our options are updated, not necessarily
+     * in an admin or POST context.
+     */
+    public static function sanitize_options( $input ) {
+        self::load_options();
+        $output = self::$options;
+
+        return $output;
+    }
+
+    /**
+     * Get an OAuth client object.
+     *
+     * @return WordCamp_QBO_OAuth_Client object.
+     */
+    private static function _get_oauth() {
+        static $oauth;
+
+        if ( ! isset( $oauth ) ) {
+            require_once( plugin_dir_path( __FILE__ ) . 'class-wordcamp-qbo-oauth-client.php' );
+            $oauth = new WordCamp_QBO_OAuth_Client( self::$consumer_key, self::$consumer_secret );
+        }
+
+        return $oauth;
+    }
+
+    /**
+     * Render the plugin settings screen.
+     */
+    public static function render_settings() {
+        self::load_options();
+        ?>
+        <style>
+        .qbo-connect {
+            width: 195px;
+            height: 34px;
+            display: inline-block;
+            background: url(<?php echo esc_url( plugins_url( '/images/qbo-connect.png', __FILE__ ) ); ?>) 0 0 no-repeat;
+            background-size: 195px 34px;
+            text-indent: -4000px;
+        }
+        </style>
+        <div class="wrap wordcamp-qbo-settings">
+            <h2>QuickBooks Settings</h2>
+            <form method="post" action="options.php">
+                <?php settings_fields( 'wordcamp-qbo' ); ?>
+
+                <h2>Account</h2>
+
+                <?php if ( ! empty( self::$options['auth']['name'] ) ) : ?>
+                <?php $expires = (int) ( 180 - ( time() - self::$options['auth']['timestamp'] ) / DAY_IN_SECONDS ); ?>
+                <p>Connected to <?php echo esc_html( self::$options['auth']['name'] ); ?>.
+                    <?php printf( _n( 'Expires in %d day.', 'Expires in %d days.', $expires ), $expires ); ?>
+                    <br />Use the button below to connect to a QuickBooks account.</p>
+                <?php endif; ?>
+
+                <a href="#" class="qbo-connect">Connect to QuickBooks</a>
+                <?php wp_nonce_field( 'oauth-request', 'wordcamp-qbo-oauth-nonce' ); ?>
+
+                <?php /* submit_button(); */ ?>
+            </form>
+        </div>
+        <script>
+        (function($){
+            $('.qbo-connect').on('click', function(){
+                var $form = $('.wordcamp-qbo-settings'),
+                    nonce = $form.find('input[name="wordcamp-qbo-oauth-nonce"]').val(),
+                    url = '<?php echo esc_js( add_query_arg( 'wordcamp-qbo-oauth-request', 1, admin_url() ) ); ?>',
+                    popup = null;
+
+                url += '&wordcamp-qbo-oauth-nonce=' + nonce;
+                popup = window.open(url, 'qbo-oauth', 'width=800, height=560');
+                return false;
+            });
+        }(jQuery));
+        </script>
+        <?php
+    }
+}
+
+WordCamp_QBO::load();
</ins></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentpluginswordcampqboclientwordcampqboclientphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo-client/wordcamp-qbo-client.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo-client/wordcamp-qbo-client.php                               (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-qbo-client/wordcamp-qbo-client.php 2016-01-19 17:30:41 UTC (rev 2325)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,293 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Plugin Name: WordCamp.org QBO Client
+ */
+
+class WordCamp_QBO_Client {
+    private static $hmac_key;
+    private static $api_base;
+    private static $options;
+
+    public static function load_options() {
+        if ( isset( self::$options ) )
+            return self::$options;
+
+        self::$options = wp_parse_args( get_option( 'wordcamp-qbo-client', array() ), array(
+            'default-class' => '',
+        ) );
+    }
+
+    public static function load() {
+        add_action( 'plugins_loaded', array( __CLASS__, 'plugins_loaded' ) );
+    }
+
+    public static function plugins_loaded() {
+        $init_options = wp_parse_args( apply_filters( 'wordcamp_qbo_client_options', array() ), array(
+            'hmac_key' => '',
+            'api_base' => '',
+        ) );
+
+        foreach ( $init_options as $key => $value )
+            self::$$key = $value;
+
+        if ( empty( self::$hmac_key ) )
+            return;
+
+        add_action( 'admin_init', array( __CLASS__, 'admin_init' ), 20 );
+    }
+
+    public static function admin_init() {
+        $cap = is_multisite() ? 'manage_network' : 'manage_options';
+
+        if ( ! current_user_can( $cap ) )
+            return;
+
+        if ( ! class_exists( 'WCP_Payment_Request' ) )
+            return;
+
+        add_action( 'add_meta_boxes', array( __CLASS__, 'add_meta_boxes' ) );
+        add_action( 'admin_notices', array( __CLASS__, 'admin_notices' ) );
+        add_action( 'save_post', array( __CLASS__, 'save_post' ), 20, 2 );
+    }
+
+    public static function admin_notices() {
+        $screen = get_current_screen();
+        if ( $screen->id != 'wcp_payment_request' )
+            return;
+
+        $post = get_post();
+        if ( $post->post_status == 'auto-draft' )
+            return;
+
+        $data = get_post_meta( $post->ID, '_wordcamp-qbo-client-data', true );
+        if ( empty( $data['last_error'] ) )
+            return;
+
+        printf( '<div class="notice error is-dismissible"><p>QBO Sync Error: %s</p></div>', esc_html( $data['last_error'] ) );
+    }
+
+    public static function update_options() {
+        self::load_options();
+        update_option( 'wordcamp-qbo-client', self::$options );
+    }
+
+    public static function add_meta_boxes() {
+       add_meta_box( 'qbo-metabox-quickbooks', 'QuickBooks', array( __CLASS__, 'metabox_quickbooks' ),
+               WCP_Payment_Request::POST_TYPE, 'side', 'high' );
+    }
+
+    /**
+     * Get an array of classes from QBO.
+     *
+     * @uses get_transient()
+     *
+     * @return array Class IDs as keys, names as values.
+     */
+    private static function _get_classes() {
+        $cache_key = md5( 'wordcamp-qbo-client:classes' );
+        $cache = get_transient( $cache_key );
+
+        if ( $cache !== false )
+            return $cache;
+
+        $request_url = esc_url_raw( self::$api_base . '/classes/' );
+        $response = wp_remote_get( $request_url, array(
+            'headers' => array(
+                'Content-Type' => 'application/json',
+                'Authorization' => self::_get_auth_header( 'get', $request_url ),
+            ),
+        ) );
+
+        $classes = array();
+
+        if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) == 200 ) {
+            $body = json_decode( wp_remote_retrieve_body( $response ), true );
+            if ( ! empty( $body ) && is_array( $body ) )
+                $classes = $body;
+        }
+
+        set_transient( $cache_key, $classes, 12 * HOUR_IN_SECONDS );
+        return $classes;
+    }
+
+    public static function metabox_quickbooks() {
+        self::load_options();
+
+        $post = get_post();
+        $classes = self::_get_classes();
+        $data = get_post_meta( $post->ID, '_wordcamp-qbo-client-data', true );
+
+        $selected_class = self::$options['default-class'];
+        if ( ! empty( $data['class'] ) && array_key_exists( $data['class'], $classes ) )
+            $selected_class = $data['class'];
+
+        ?>
+
+        <?php if ( ! empty( $data['last_error'] ) ) : ?>
+        <p><?php echo esc_html( $data['last_error'] ); ?></p>
+        <?php endif; ?>
+
+        <?php if ( empty( $data['transaction_id'] ) ) : ?>
+        <p>This request has not been synced with QuickBooks yet.</p>
+        <?php else: ?>
+        <pre><?php echo esc_html( print_r( $data, true ) ); ?></pre>
+        <?php endif; ?>
+
+        <input type="hidden" name="wordcamp-qbo-client-post" value="<?php echo esc_attr( $post->ID ); ?>" />
+        <?php wp_nonce_field( 'wordcamp-qbo-client-push-' . $post->ID, 'wordcamp-qbo-client-nonce' ); ?>
+
+        <p>
+            <label>
+                <input type="checkbox" value="1" name="wordcamp-qbo-client-push"
+                    <?php checked( ! empty( $data['transaction_id'] ) ); ?> />
+
+                <?php if ( empty( $data['transaction_id'] ) ) : ?>
+                    Push to QuickBooks
+                <?php else : ?>
+                    Push Changes to QuickBooks
+                <?php endif; ?>
+            </label>
+        </p>
+        <p>
+            <label>QuickBooks Class:</label>
+            <select name="wordcamp-qbo-client-class">
+                <option value="">Not Set</option>
+                <?php foreach ( self::_get_classes() as $id => $class ) : ?>
+                <option value="<?php echo esc_attr( $id ); ?>" <?php selected( $id, $selected_class ); ?>><?php echo esc_html( $class ); ?></option>
+                <?php endforeach; ?>
+            </select>
+        </p>
+
+        <?php if ( ! empty( $data['transaction_id'] ) ) : ?>
+            <p>
+                Last Sync: <?php echo gmdate( 'Y-m-d H:i:s', absint( $data['timestamp'] ) ); ?> UTC<br />
+                Transaction ID: <?php echo absint( $data['transaction_id'] ); ?>
+            </p>
+        <?php endif; ?>
+        <?php
+    }
+
+    public static function save_post( $post_id, $post ) {
+        if ( $post->post_type !== WCP_Payment_Request::POST_TYPE )
+            return;
+
+        if ( empty( $_POST['wordcamp-qbo-client-nonce'] ) || empty( $_POST['wordcamp-qbo-client-post'] ) )
+            return;
+
+        if ( intval( $_POST['wordcamp-qbo-client-post'] ) !== $post->ID )
+            return;
+
+        if ( ! wp_verify_nonce( $_POST['wordcamp-qbo-client-nonce'], 'wordcamp-qbo-client-push-' . $post->ID ) )
+            wp_die( 'Could not verify QBO nonce. Please go back, refresh the page and try again.' );
+
+        // No need to push.
+        if ( empty( $_POST['wordcamp-qbo-client-push'] ) )
+            return;
+
+        if ( $post->post_status != 'paid' )
+            wp_die( 'A request has to be marked as paid before it could be synced to QuickBooks.' );
+
+        if ( empty( $_POST['wordcamp-qbo-client-class'] ) )
+            wp_die( 'You need to set a QuickBooks class before you can sync this payment request.' );
+
+        $class = $_POST['wordcamp-qbo-client-class'];
+        if ( ! array_key_exists( $class, self::_get_classes() ) )
+            wp_die( 'The class you have picked does not exist.' );
+
+        $data = get_post_meta( $post->ID, '_wordcamp-qbo-client-data', true );
+        $txn_id = false;
+
+        if ( ! is_array( $data ) )
+            $data = array();
+
+        // This request has not been synced before.
+        if ( ! empty( $data['transaction_id'] ) )
+            $txn_id = $data['transaction_id'];
+
+        $amount = get_post_meta( $post->ID, '_camppayments_payment_amount', true );
+        $amount = preg_replace( '#[^\d.-]+#', '', $amount );
+               $amount = floatval( $amount );
+
+        $currency = get_post_meta( $post->ID, '_camppayments_currency', true );
+        if ( strtoupper( $currency ) != 'USD' )
+            wp_die( 'Non-USD payments sync to QuickBooks is not available yet.' );
+
+        $description_chunks = array( $post->post_title );
+        $description = get_post_meta( $post->ID, '_camppayments_description', true );
+        if ( ! empty( $description ) )
+            $description_chunks[] = $description;
+
+        $description_chunks[] = esc_url_raw( get_edit_post_link( $post->ID, 'raw' ) );
+        $description = implode( "\n", $description_chunks );
+        unset( $description_chunks );
+
+        $category = get_post_meta( $post->ID, '_camppayments_payment_category', true );
+        $date = absint( get_post_meta( $post->ID, '_camppayments_date_vendor_paid', true ) );
+
+        $body = array(
+            'id' => $txn_id,
+            'date' => $date,
+            'amount' => $amount,
+            'category' => $category,
+            'description' => $description,
+            'class' => $class,
+        );
+
+        $body = json_encode( $body );
+        $request_url = esc_url_raw( self::$api_base . '/expense/' );
+        $response = wp_remote_post( $request_url, array(
+            'body' => $body,
+            'headers' => array(
+                'Content-Type' => 'application/json',
+                'Authorization' => self::_get_auth_header( 'post', $request_url, $body ),
+            ),
+        ) );
+
+        if ( is_wp_error( $response ) ) {
+            $data['last_error'] = $response->get_error_message();
+        } elseif ( wp_remote_retrieve_response_code( $response ) != 200 ) {
+            $data['last_error'] = 'Could not create or update the QBO transaction.';
+        } else {
+            $body = json_decode( wp_remote_retrieve_body( $response ), true );
+            if ( empty( $body['transaction_id'] ) ) {
+                $data['last_error'] = 'Could not decode JSON response from API.';
+            } else {
+                unset( $data['last_error'] );
+                $data['transaction_id'] = $body['transaction_id'];
+                $data['timestamp'] = time();
+                $data['class'] = $class;
+
+                // Remember this class for future reference.
+                if ( self::$options['default-class'] != $class ) {
+                    self::$options['default-class'] = $class;
+                    self::update_options();
+                }
+            }
+        }
+
+        update_post_meta( $post->ID, '_wordcamp-qbo-client-data', $data );
+    }
+
+    /**
+     * Create an HMAC signature header for a request.
+     *
+     * Use with Authorization HTTP header.
+     *
+     * @see WordCamp_QBO::_is_valid_request()
+     *
+     * @param string $method The request method: GET, POST, etc.
+     * @param string $request_url The clean request URI, without any query arguments.
+     * @param string $body The payload body.
+     * @param array $args The query arguments.
+     *
+     * @return string A sha256 HMAC signature.
+     */
+    private static function _get_auth_header( $method, $request_url, $body = '', $args = array() ) {
+        $signature = hash_hmac( 'sha256', json_encode( array( strtolower( $method ),
+            strtolower( $request_url ), $body, $args ) ), self::$hmac_key );
+
+        return 'wordcamp-qbo-hmac ' . $signature;
+    }
+}
+
+WordCamp_QBO_Client::load();
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of file
</span></span></pre>
</div>
</div>

</body>
</html>