diff --git a/lib/client.js b/lib/client.js index 9106023..5d4c466 100644 --- a/lib/client.js +++ b/lib/client.js @@ -43,7 +43,7 @@ // 200–299 success if (res.statusCode >= 200 && res.statusCode <= 299) { if (responsedata === '') { - return _cb(res); + return _cb(res, new Error('Response has no data')); } return parser.parseString(responsedata, function (err, result) { return _cb(res, null, result); @@ -83,7 +83,7 @@ } if (callback.length === 2) { if (err) { - return callback(_wrap_response(res, err)); + return callback(err, _wrap_response(res, null)); } return callback(null, _wrap_response(res, data)); diff --git a/lib/recurly.js b/lib/recurly.js index 81e4cce..074d059 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -2,6 +2,8 @@ var Js2xml = require('js2xml').Js2Xml, Client = require('./client'), utils = require('./utils'), + _ = require('lodash'), + url = require('url'), router = require('./routes/'); module.exports = function(config){ @@ -13,6 +15,7 @@ list: function(callback, filter){ t.request(utils.addQueryParams(routes.accounts.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.accounts.list, 'accounts', 'account'), get: function(accountcode, callback){ t.request(utils.addParams(routes.accounts.get, {account_code: accountcode}), callback); }, @@ -26,9 +29,9 @@ t.request(utils.addParams(routes.accounts.close, {account_code: accountcode}), callback); }, reopen: function(accountcode, callback){ - t.request(utils.addParams(routes.accounts.reopen, {account_code: accountcode}), callback) + t.request(utils.addParams(routes.accounts.reopen, {account_code: accountcode}), callback); } - } + }; this.adjustments = { get: function(accountcode, callback){ @@ -40,7 +43,7 @@ remove: function(uuid, callback){ t.request(utils.addParams(routes.adjustments.remove, {uuid: uuid}), callback); } - } + }; //http://docs.recurly.com/api/billing-info this.billingInfo = { @@ -53,13 +56,14 @@ remove: function(accountcode, callback){ t.request(utils.addParams(routes.billingInfo.remove, {account_code: accountcode} ), callback); } - } + }; //http://docs.recurly.com/api/coupons this.coupons = { list: function(callback, filter){ t.request(utils.addQueryParams(routes.coupons.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.coupons.list, 'coupons', 'coupon'), get: function(couponcode, callback){ t.request(utils.addParams(routes.coupons.get, {coupon_code: couponcode}), callback); }, @@ -69,7 +73,7 @@ deactivate: function(couponcode, callback){ t.request(utils.addParams(routes.coupons.deactivate, {coupon_code: couponcode}), callback); } - } + }; this.couponRedemption = { redeem: function(couponcode, details, callback){ @@ -82,20 +86,21 @@ t.request(utils.addParams(routes.couponRedemption.remove, {account_code: accountcode}), callback); }, getByInvoice: function(invoicenumber, callback){ - t.request(utils.addParams(routes.couponRedemption.getByInvoice, {invoice_number: invoicenumber}), callback) + t.request(utils.addParams(routes.couponRedemption.getByInvoice, {invoice_number: invoicenumber}), callback); } - } + }; this.invoices = { list: function(callback, filter){ t.request(utils.addQueryParams(routes.invoices.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.invoices.list, 'invoices', 'invoice'), listByAccount: function(accountcode, callback, filter){ t.request( utils.addParams( - utils.addQueryParams(routes.invoices.listByAccount, filter) - , {account_code: accountcode}) - , callback) + utils.addQueryParams(routes.invoices.listByAccount, filter), + {account_code: accountcode}), + callback); }, get: function(invoicenumber, callback){ t.request(utils.addParams(routes.invoices.get, {invoice_number: invoicenumber}), callback); @@ -109,12 +114,13 @@ markFailed: function(invoicenumber, callback){ t.request(utils.addParams(routes.invoices.markFailed, {invoice_number: invoicenumber}), callback); } - } + }; this.plans = { list: function(callback, filter){ t.request(utils.addQueryParams(routes.plans.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.plans.list, 'plans', 'plan'), get: function(plancode, callback){ t.request(utils.addParams(routes.plans.get, {plan_code: plancode}), callback); }, @@ -127,12 +133,13 @@ remove: function(plancode, callback){ t.request(utils.addParams(routes.plans.remove, {plan_code: plancode}), callback); } - } + }; this.planAddons = { list: function(plancode, callback, filter){ t.request(utils.addParams(utils.addQueryParams(routes.planAddons.list, filter), {plan_code: plancode}), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.planAddons.list, 'add_ons', 'ad_on'), get: function(plancode, addoncode, callback){ t.request(utils.addParams(routes.planAddons.get, {plan_code: plancode, addon_code: addoncode}), callback); }, @@ -141,24 +148,24 @@ }, update: function(plancode, addoncode, details, callback){ t.request(utils.addParams( - routes.planAddons.update, + routes.planAddons.update, { plan_code: plancode, add_on_code: addoncode - }), + }), callback, new Js2xml('add_on', details).toString()); }, remove: function(plancode, addoncode, callback){ t.request(utils.addParams(routes.planAddons.remove, {plan_code: plancode, add_on_code: addoncode}), callback); } - } + }; this.subscriptions = { list: function(callback, filter){ t.request(utils.addQueryParams(routes.subscriptions.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.subscriptions.list, 'subscriptions', 'subscription'), listByAccount: function(accountcode, callback){ t.request(utils.addParams(routes.subscriptions.listByAccount, {account_code: accountcode}), callback); - }, get: function(uuid, callback){ t.request(utils.addParams(routes.subscriptions.get, {uuid: uuid}), callback); @@ -181,12 +188,13 @@ postpone: function(uuid, nextRenewalDate, callback){ t.request(utils.addParams(routes.subscriptions.postpone, {uuid: uuid, next_renewal_date: nextRenewalDate}), callback); } - } + }; this.transactions = { list: function(callback, filter){ t.request(utils.addQueryParams(routes.transactions.list, filter), callback); }, + eachSeries: utils.makeEachSeriesIterator(t, routes.transactions.list, 'transactions', 'transaction'), listByAccount: function(accountCode, callback, filter){ t.request( utils.addParams( @@ -204,8 +212,8 @@ if(amount){ route = utils.addQueryParams(route, { amount_in_cents: amount }); } - t.request(route, callback) + t.request(route, callback); } - } - }//end class + }; + }; //end class })(); diff --git a/lib/routes/index.js b/lib/routes/index.js index 91cf80d..d40a54f 100644 --- a/lib/routes/index.js +++ b/lib/routes/index.js @@ -1,5 +1,5 @@ var routes = { - 'v2': require('./v2') + 'v2': require('./v2') }; diff --git a/lib/routes/v2.js b/lib/routes/v2.js index d3fa0f6..93f4b22 100644 --- a/lib/routes/v2.js +++ b/lib/routes/v2.js @@ -5,33 +5,33 @@ exports.accounts = { update: ['/v2/accounts/:account_code', 'PUT'], close: ['/v2/accounts/:account_code', 'DELETE'], reopen: ['/v2/accounts/:account_code/reopen', 'PUT'] -} +}; exports.adjustments = { get: ['/v2/accounts/:account_code/adjustments', 'GET'], create: ['/v2/accounts/:account_code/adjustments', 'POST'], remove: ['/v2/adjustments/:uuid', 'DELETE'] -} +}; exports.billingInfo = { get: ['/v2/accounts/:account_code/billing_info', 'GET'], update: ['/v2/accounts/:account_code/billing_info', 'PUT'], remove: ['/v2/accounts/:account_code/billing_info', 'DELETE'] -} +}; exports.coupons = { list: ['/v2/coupons', 'GET'], get: ['/v2/coupons/:coupon_code', 'GET'], create: ['/v2/coupons', 'POST'], deactivate: ['/v2/coupons/:coupon_code', 'DELETE'] -} +}; exports.couponRedemption = { redeem: ['/v2/coupons/:coupon_code/redeem', 'POST'], get: ['/v2/accounts/:account_code/redemption', 'GET'], remove: ['/v2/accounts/:account_code/redemption', 'DELETE'], getByInvoice: ['/v2/invoices/:invoice_number/redemption', 'GET'] -} +}; exports.invoices = { list: ['/v2/invoices', 'GET'], @@ -40,7 +40,7 @@ exports.invoices = { create: ['/v2/accounts/:account_code/invoices', 'POST'], markSuccessful: ['/v2/invoices/:invoice_number/mark_successful', 'PUT'], markFailed: ['/v2/invoices/:invoice_number/mark_failed', 'PUT'] -} +}; exports.plans = { list: ['/v2/plans', 'GET'], @@ -48,7 +48,7 @@ exports.plans = { create: ['/v2/plans', 'POST'], update: ['/v2/plans/:plan_code', 'PUT'], remove: ['/v2/plans/:plan_code', 'DELETE'] -} +}; exports.planAddons = { list: ['/v2/plans/:plan_code/add_ons', 'GET'], @@ -56,7 +56,7 @@ exports.planAddons = { create: ['/v2/plans/:plan_code/add_ons', 'POST'], update: ['/v2/plans/:plan_code/add_ons/:add_on_code', 'PUT'], remove: ['/v2/plans/:plan_code/add_ons/:add_on_code', 'DELETE'] -} +}; exports.subscriptions = { list: ['/v2/subscriptions', 'GET'], @@ -68,7 +68,7 @@ exports.subscriptions = { reactivate: ['/v2/subscriptions/:uuid/reactivate', 'PUT'], terminate: ['/v2/subscriptions/:uuid/terminate?refund=:refund_type', 'PUT'], postpone: ['/v2/subscriptions/:uuid/postpone?next_renewal_date=:next_renewal_date', 'PUT'] -} +}; exports.transactions = { list: ['/v2/transactions', 'GET'], @@ -76,4 +76,4 @@ exports.transactions = { get: ['/v2/transactions/:id', 'GET'], create: ['/v2/transactions', 'POST'], refund: ['/v2/transactions/:id', 'DELETE'] -} +}; diff --git a/lib/transparent.js b/lib/transparent.js index 01f6d93..63471a9 100644 --- a/lib/transparent.js +++ b/lib/transparent.js @@ -20,29 +20,29 @@ t.debug(TRANSACTION_URL); t.debug('============================'); - this.billingInfoUrl = function(){return BILLING_INFO_URL}; - this.subscribeUrl = function(){return SUBSCRIBE_URL}; - this.transactionUrl = function(){return TRANSACTION_URL}; + this.billingInfoUrl = function(){return BILLING_INFO_URL;}; + this.subscribeUrl = function(){return SUBSCRIBE_URL;}; + this.transactionUrl = function(){return TRANSACTION_URL;}; this.hidden_field = function(data){ return ''; - } + }; this.getResults = function(confirm, result, status, type, callback){ - validateQueryString(confirm, type, status, result) + validateQueryString(confirm, type, status, result); t.request('/transparent/results/' + result, 'GET', callback); - } + }; this.getFormValuesFromResult = function getFormValuesFromResult(result, type){ var fields = {}; var errors = []; t.traverse(result.data,function(key, value, parent){ var shouldprint = false; - var toprint = '' + var toprint = ''; if(value instanceof Object){ if(Object.keys(value).length === 0){ shouldprint = true; - toprint = '' + toprint = ''; } if(Object.hasOwnProperty('@') || Object.hasOwnProperty('#')){ shouldprint = true; @@ -95,7 +95,7 @@ }); errors = handleFuzzyLogicSpecialCases(errors); return {fields: fields, errors: errors}; - } + }; function processErrors(errors, parent){ var acc = []; @@ -113,7 +113,7 @@ errors.forEach(function(item){ if(item instanceof Array){ //loop through the error list - item.forEach(processSingleError) + item.forEach(processSingleError); } else{ //its a single error so grab it out @@ -151,13 +151,13 @@ function makeDate(){ var d = new Date(); - var addleadingzero = function(n){ return (n<10)?'0'+n:''+n }; + var addleadingzero = function(n){ return (n<10)?'0'+n:''+n ;}; return d.getUTCFullYear() + '-' + - addleadingzero(d.getUTCMonth()+1) + '-' + - addleadingzero(d.getUTCDate()) + 'T' + - addleadingzero(d.getUTCHours()) + ':' + - addleadingzero(d.getUTCMinutes()) + ':' + - addleadingzero(d.getUTCSeconds()) + 'Z'; + addleadingzero(d.getUTCMonth()+1) + '-' + + addleadingzero(d.getUTCDate()) + 'T' + + addleadingzero(d.getUTCHours()) + ':' + + addleadingzero(d.getUTCMinutes()) + ':' + + addleadingzero(d.getUTCSeconds()) + 'Z'; } function hash(data) { @@ -171,7 +171,7 @@ return hmac.digest('hex'); //php: 03021207ad681f2ea9b9e1fc20ac7ae460d8d988 <== Yes this sign is identical to the php version //node: 03021207ad681f2ea9b9e1fc20ac7ae460d8d988 - } + } function buildQueryStringFromSortedObject(params){ return params.map(function(p){ @@ -180,32 +180,32 @@ } function makeSortedObject(obj, casesensitive){ - return Object.keys(obj).map(function(key){ - return {key: key, value: obj[key]}; - }).sort(function(a,b){ - return (casesensitive? a.key : a.key.toLowerCase()) > (casesensitive? b.key : b.key.toLowerCase()); - }); + return Object.keys(obj).map(function(key){ + return {key: key, value: obj[key]}; + }).sort(function(a,b){ + return (casesensitive? a.key : a.key.toLowerCase()) > (casesensitive? b.key : b.key.toLowerCase()); + }); } //Used for validating return params from Recurly - function validateQueryString(confirm, type, status, result_key) - { - var values = { + function validateQueryString(confirm, type, status, result_key) + { + var values = { result: result_key, status: status, type: type - } - var query_values = buildQueryStringFromSortedObject(makeSortedObject(values, true)); - hashed_values = hash(query_values); + }; + var query_values = buildQueryStringFromSortedObject(makeSortedObject(values, true)); + hashed_values = hash(query_values); - if(hashed_values !== confirm) { - throw "Error: Forged query string"; - } + if(hashed_values !== confirm) { + throw "Error: Forged query string"; + } return true; - } + } function handleFuzzyLogicSpecialCases(errors){ - var toreturn = [] + var toreturn = []; errors.forEach(function(e){ switch(e.field){ case 'billing_info[verification_value]': @@ -228,6 +228,6 @@ return {field: name,reason: error.reason}; } - }//END CLASS + }; //END CLASS })(); \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js index 329e404..4834cdc 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,3 +1,6 @@ +var async = require('async'), + url = require('url'); + exports.addParams = function(route, keys){ var newRoute = route.slice(); var path = newRoute[0]; @@ -6,9 +9,9 @@ exports.addParams = function(route, keys){ return keys[key]; }); return newRoute; -} +}; -exports.addQueryParams = function(route, params){ +var addQueryParams = exports.addQueryParams = function(route, params){ var newRoute = route.slice(); var _params = []; if(params){ @@ -18,4 +21,101 @@ exports.addQueryParams = function(route, params){ } if(_params.length > 0) return [newRoute[0] + '?' + _params.join('&'), newRoute[1]]; else return newRoute; -} \ No newline at end of file +}; + +var parseUrlLink = exports.parseUrlLink = function(result, callback) { + var links = {}; + // Parse out our link + if(result.headers.link) { + // link format: + // '; rel="next", ; rel="last"' + result.headers.link.replace(/<([^>]*)>;\s*rel="([\w]*)\"/g, function(m, uri, type) { + links[type] = uri; + }); + return links; + } else { + return null; + } +}; + +/** + * makeEachSeriesIterator + * + * Creates a function that will get all records for `route` and call `iterator` on each record. + * + * @param {Client} client Initialized recurly client. + * @param {String} route URL of resource + * @param {String} endpointName Plural name of resouce. + * @param {String} singleItemName Signular name of resource. + * @return {function(options, iterator, callback)} + */ +exports.makeEachSeriesIterator = function(client, route, endpointName, singleItemName) { + /** + * + * @param {Object} options like: + * per_page: {Number} Default 200. Max number of items to get per page. (Currently recurly api limits you to 200). + * limit: {Number} Default everything. Total number of items to get before calling the final callback. + * @param {function(item, callback)} iterator Function to call for each item returned from recurly. Call callback when + * you finish processing the item. Feeds items to iterator sequentially. + * @param {function(error)} callback Called when all items (up to limit) have been fetched and fed through `iterator`. + */ + return function(options, iterator, callback) { + + options = options || {}; + + var perPage = options.per_page || 200, + limit = options.limit, + initialCursor = addQueryParams(route, {per_page: perPage}), + count = 0; + + nextPage(initialCursor); + + function nextPage(cursor) { + client.request(cursor, function(error, result) { + if (error) { + return callback(error); + } + + var records = result.data[endpointName][singleItemName]; + if (!records) { + return callback(new Error('Response has no data')); + } + + var links, + nextQueryString, + nextCursor, + isLimitHit; + + // Parse out the next cursor + if (result.headers.link) { + links = parseUrlLink(result); + if (links.next) { + nextQueryString = url.parse(links.next, true).query; + nextCursor = addQueryParams(route, nextQueryString); + } + } + + isLimitHit = limit && (count + records.length) > limit; + if (isLimitHit) { + // We will be over the limit, so slice down + records = records.slice(0, limit - count); + } + + // Iterate through results + count += records.length; + async.eachSeries(records, iterator, function(error) { + if (error) { + return callback(error); + } + + if (isLimitHit || !nextCursor) { + // Finished + callback(); + } else { + nextPage(nextCursor); + } + }); + }); + } + }; +}; diff --git a/package.json b/package.json index d869925..ad03cf1 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,34 @@ -{ "name" : "node-recurly" -, "description" : "Library for accessing the api for the Recurly recurring billing service." -, "keywords" : [ "recurly", "e-commerce", "recurring billing" ] -, "version" : "2.1.0" -, "homepage" : "https://github.com/robrighter/node-recurly" -, "author" : "Rob Righter (http://github.com/robrighter)" -, "contributors" : [ - "Iván Guardado (http://github.com/IvanGuardado)", - "Rob Righter (http://github.com/robrighter)", - "Dmitriy Shekhovtsov (https://github.com/valorkin)" - ] -, "bugs" : - { "web" : "https://github.com/robrighter/node-recurly/issues" } -, "directories" : { "lib" : "./lib" } -, "main" : "./lib/recurly.js" -, "dependencies" : { - "xml2js": ">= 0.4.0", - "js2xml": "valorkin/js2xml" -} -, "engines" : { "node" : ">= 0.4" } +{ + "name": "node-recurly", + "description": "Library for accessing the api for the Recurly recurring billing service.", + "keywords": [ + "recurly", + "e-commerce", + "recurring billing" + ], + "version": "3.0.0", + "homepage": "https://github.com/robrighter/node-recurly", + "author": "Rob Righter (http://github.com/robrighter)", + "contributors": [ + "Iván Guardado (http://github.com/IvanGuardado)", + "Rob Righter (http://github.com/robrighter)", + "Dmitriy Shekhovtsov (https://github.com/valorkin)", + "Jeff Zabel (https://github.com/jzabel)" + ], + "bugs": { + "web": "https://github.com/robrighter/node-recurly/issues" + }, + "directories": { + "lib": "./lib" + }, + "main": "./lib/recurly.js", + "dependencies": { + "async": "^0.9.0", + "js2xml": "~1.0.4", + "lodash": "~2.4.1", + "xml2js": ">= 0.4.0" + }, + "engines": { + "node": ">= 0.4" + } }