diff --git a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee index 680905ca6c..059c5fb02c 100644 --- a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee +++ b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee @@ -4,11 +4,201 @@ request = require 'request' Settings = require "settings-sharelatex" xml2js = require "xml2js" logger = require("logger-sharelatex") +Async = require('async') module.exports = RecurlyWrapper = apiUrl : "https://api.recurly.com/v2" - createSubscription: (user, subscriptionDetails, recurly_token_id, callback)-> + _addressToXml: (address) -> + allowedKeys = ['address1', 'address2', 'city', 'country', 'state', 'zip', 'postal_code'] + resultString = "\n" + for k, v of address + if k == 'postal_code' + k = 'zip' + if v and (k in allowedKeys) + resultString += "<#{k}#{if k == 'address2' then ' nil="nil"' else ''}>#{v || ''}\n" + resultString += "\n" + return resultString + + _paypal: + checkAccountExists: (cache, next) -> + user = cache.user + recurly_token_id = cache.recurly_token_id + subscriptionDetails = cache.subscriptionDetails + logger.log {user_id: user._id, recurly_token_id}, "checking if recurly account exists for user" + RecurlyWrapper.apiRequest({ + url: "accounts/#{user._id}" + method: "GET" + }, (error, response, responseBody) -> + if error + if response.statusCode == 404 # actually not an error in this case, just no existing account + cache.userExists = false + return next(null, cache) + logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account" + return next(error) + logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly" + RecurlyWrapper._parseAccountXml responseBody, (err, account) -> + if err + logger.error {err, user_id: user._id, recurly_token_id}, "error parsing account" + return next(err) + cache.userExists = true + cache.account = account + return next(null, cache) + ) + createAccount: (cache, next) -> + user = cache.user + recurly_token_id = cache.recurly_token_id + subscriptionDetails = cache.subscriptionDetails + address = subscriptionDetails.address + if !address + return next(new Error('no address in subscriptionDetails at createAccount stage')) + if cache.userExists + logger.log {user_id: user._id, recurly_token_id}, "user already exists in recurly" + return next(null, cache) + logger.log {user_id: user._id, recurly_token_id}, "creating user in recurly" + requestBody = """ + + #{user._id} + #{user.email} + #{user.first_name} + #{user.last_name} +
+ #{address.address1} + #{address.address2} + #{address.city || ''} + #{address.state || ''} + #{address.zip || ''} + #{address.country} +
+
+ """ + RecurlyWrapper.apiRequest({ + url : "accounts" + method : "POST" + body : requestBody + }, (error, response, responseBody) => + if error + logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating account" + return next(error) + RecurlyWrapper._parseAccountXml responseBody, (err, account) -> + if err + logger.error {err, user_id: user._id, recurly_token_id}, "error creating account" + return next(err) + cache.account = account + return next(null, cache) + ) + createBillingInfo: (cache, next) -> + user = cache.user + recurly_token_id = cache.recurly_token_id + subscriptionDetails = cache.subscriptionDetails + logger.log {user_id: user._id, recurly_token_id}, "creating billing info in recurly" + accountCode = cache?.account?.account_code + if !accountCode + return next(new Error('no account code at createBillingInfo stage')) + requestBody = """ + + #{recurly_token_id} + + """ + RecurlyWrapper.apiRequest({ + url: "accounts/#{accountCode}/billing_info" + method: "POST" + body: requestBody + }, (error, response, responseBody) => + if error + logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating billing info" + return next(error) + RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) -> + if err + logger.error {err, user_id: user._id, accountCode, recurly_token_id}, "error creating billing info" + return next(err) + cache.billingInfo = billingInfo + return next(null, cache) + ) + + setAddress: (cache, next) -> + user = cache.user + recurly_token_id = cache.recurly_token_id + subscriptionDetails = cache.subscriptionDetails + logger.log {user_id: user._id, recurly_token_id}, "setting billing address in recurly" + accountCode = cache?.account?.account_code + if !accountCode + return next(new Error('no account code at setAddress stage')) + address = subscriptionDetails.address + if !address + return next(new Error('no address in subscriptionDetails at setAddress stage')) + requestBody = RecurlyWrapper._addressToXml(address) + RecurlyWrapper.apiRequest({ + url: "accounts/#{accountCode}/billing_info" + method: "PUT" + body: requestBody + }, (error, response, responseBody) => + if error + logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while setting address" + return next(error) + RecurlyWrapper._parseBillingInfoXml responseBody, (err, billingInfo) -> + if err + logger.error {err, user_id: user._id, recurly_token_id}, "error updating billing info" + return next(err) + cache.billingInfo = billingInfo + return next(null, cache) + ) + createSubscription: (cache, next) -> + user = cache.user + recurly_token_id = cache.recurly_token_id + subscriptionDetails = cache.subscriptionDetails + logger.log {user_id: user._id, recurly_token_id}, "creating subscription in recurly" + requestBody = """ + + #{subscriptionDetails.plan_code} + #{subscriptionDetails.currencyCode} + #{subscriptionDetails.coupon_code} + + #{user._id} + + + """ # TODO: check account details and billing + RecurlyWrapper.apiRequest({ + url : "subscriptions" + method : "POST" + body : requestBody + }, (error, response, responseBody) => + if error + logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while creating subscription" + return next(error) + RecurlyWrapper._parseSubscriptionXml responseBody, (err, subscription) -> + if err + logger.error {err, user_id: user._id, recurly_token_id}, "error creating subscription" + return next(err) + cache.subscription = subscription + return next(null, cache) + ) + + _createPaypalSubscription: (user, subscriptionDetails, recurly_token_id, callback) -> + logger.log {user_id: user._id, recurly_token_id}, "starting process of creating paypal subscription" + # We use `async.waterfall` to run each of these actions in sequence + # passing a `cache` object along the way. The cache is initialized + # with required data, and `async.apply` to pass the cache to the first function + cache = {user, recurly_token_id, subscriptionDetails} + Async.waterfall([ + Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache), + RecurlyWrapper._paypal.createAccount, + RecurlyWrapper._paypal.createBillingInfo, + RecurlyWrapper._paypal.setAddress, + RecurlyWrapper._paypal.createSubscription, + ], (err, result) -> + if err + logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process" + return callback(err) + if !result.subscription + err = new Error('no subscription object in result') + logger.error {err, user_id: user._id, recurly_token_id}, "error in paypal subscription creation process" + return callback(err) + logger.log {user_id: user._id, recurly_token_id}, "done creating paypal subscription for user" + callback(null, result.subscription) + ) + + _createCreditCardSubscription: (user, subscriptionDetails, recurly_token_id, callback) -> requestBody = """ #{subscriptionDetails.plan_code} @@ -25,17 +215,23 @@ module.exports = RecurlyWrapper = """ - @apiRequest({ + RecurlyWrapper.apiRequest({ url : "subscriptions" method : "POST" body : requestBody }, (error, response, responseBody) => return callback(error) if error? - @_parseSubscriptionXml responseBody, callback - ) + RecurlyWrapper._parseSubscriptionXml responseBody, callback + ) + + createSubscription: (user, subscriptionDetails, recurly_token_id, callback)-> + isPaypal = subscriptionDetails.isPaypal + logger.log {user_id: user._id, isPaypal, recurly_token_id}, "setting up subscription in recurly" + fn = if isPaypal then RecurlyWrapper._createPaypalSubscription else RecurlyWrapper._createCreditCardSubscription + fn user, subscriptionDetails, recurly_token_id, callback apiRequest : (options, callback) -> - options.url = @apiUrl + "/" + options.url + options.url = RecurlyWrapper.apiUrl + "/" + options.url options.headers = "Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64") "Accept" : "application/xml" @@ -60,7 +256,7 @@ module.exports = RecurlyWrapper = newAttributes[key] = value else newAttributes[newKey] = value - + return newAttributes crypto.randomBytes 32, (error, buffer) -> @@ -74,14 +270,14 @@ module.exports = RecurlyWrapper = signature = "#{signed}|#{unsignedQuery}" callback null, signature - + getSubscriptions: (accountId, callback)-> - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}/subscriptions" }, (error, response, body) => return callback(error) if error? - @_parseXml body, callback + RecurlyWrapper._parseXml body, callback ) @@ -94,11 +290,11 @@ module.exports = RecurlyWrapper = else url = "subscriptions/#{subscriptionId}" - @apiRequest({ + RecurlyWrapper.apiRequest({ url: url }, (error, response, body) => return callback(error) if error? - @_parseSubscriptionXml body, (error, recurlySubscription) => + RecurlyWrapper._parseSubscriptionXml body, (error, recurlySubscription) => return callback(error) if error? if options.includeAccount if recurlySubscription.account? and recurlySubscription.account.url? @@ -106,7 +302,7 @@ module.exports = RecurlyWrapper = else return callback "I don't understand the response from Recurly" - @getAccount accountId, (error, account) -> + RecurlyWrapper.getAccount accountId, (error, account) -> return callback(error) if error? recurlySubscription.account = account callback null, recurlySubscription @@ -124,9 +320,9 @@ module.exports = RecurlyWrapper = per_page:200 if cursor? opts.qs.cursor = cursor - @apiRequest opts, (error, response, body) => + RecurlyWrapper.apiRequest opts, (error, response, body) => return callback(error) if error? - @_parseXml body, (err, data)-> + RecurlyWrapper._parseXml body, (err, data)-> if err? logger.err err:err, "could not get accoutns" callback(err) @@ -142,19 +338,19 @@ module.exports = RecurlyWrapper = getAccount: (accountId, callback) -> - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}" }, (error, response, body) => return callback(error) if error? - @_parseAccountXml body, callback + RecurlyWrapper._parseAccountXml body, callback ) getBillingInfo: (accountId, callback)-> - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}/billing_info" }, (error, response, body) => return callback(error) if error? - @_parseXml body, callback + RecurlyWrapper._parseXml body, callback ) @@ -166,13 +362,13 @@ module.exports = RecurlyWrapper = #{options.timeframe} """ - @apiRequest({ + RecurlyWrapper.apiRequest({ url : "subscriptions/#{subscriptionId}" method : "put" body : requestBody }, (error, response, responseBody) => return callback(error) if error? - @_parseSubscriptionXml responseBody, callback + RecurlyWrapper._parseSubscriptionXml responseBody, callback ) createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)-> @@ -191,7 +387,7 @@ module.exports = RecurlyWrapper = """ logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon" - @apiRequest({ + RecurlyWrapper.apiRequest({ url : "coupons" method : "post" body : requestBody @@ -203,16 +399,16 @@ module.exports = RecurlyWrapper = lookupCoupon: (coupon_code, callback)-> - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "coupons/#{coupon_code}" }, (error, response, body) => return callback(error) if error? - @_parseXml body, callback + RecurlyWrapper._parseXml body, callback ) cancelSubscription: (subscriptionId, callback) -> logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription" - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "subscriptions/#{subscriptionId}/cancel", method: "put" }, (error, response, body) -> @@ -221,13 +417,13 @@ module.exports = RecurlyWrapper = reactivateSubscription: (subscriptionId, callback) -> logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription" - @apiRequest({ + RecurlyWrapper.apiRequest({ url: "subscriptions/#{subscriptionId}/reactivate", method: "put" }, (error, response, body) -> callback(error) ) - + redeemCoupon: (account_code, coupon_code, callback)-> requestBody = """ @@ -237,7 +433,7 @@ module.exports = RecurlyWrapper = """ logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user" - @apiRequest({ + RecurlyWrapper.apiRequest({ url : "coupons/#{coupon_code}/redeem" method : "post" body : requestBody @@ -251,7 +447,7 @@ module.exports = RecurlyWrapper = next_renewal_date = new Date() next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire) logger.log subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "Exending Free trial for user" - @apiRequest({ + RecurlyWrapper.apiRequest({ url : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false" method : "put" }, (error, response, responseBody) => @@ -261,7 +457,7 @@ module.exports = RecurlyWrapper = ) _parseSubscriptionXml: (xml, callback) -> - @_parseXml xml, (error, data) -> + RecurlyWrapper._parseXml xml, (error, data) -> return callback(error) if error? if data? and data.subscription? recurlySubscription = data.subscription @@ -270,7 +466,7 @@ module.exports = RecurlyWrapper = callback null, recurlySubscription _parseAccountXml: (xml, callback) -> - @_parseXml xml, (error, data) -> + RecurlyWrapper._parseXml xml, (error, data) -> return callback(error) if error? if data? and data.account? account = data.account @@ -278,6 +474,15 @@ module.exports = RecurlyWrapper = return callback "I don't understand the response from Recurly" callback null, account + _parseBillingInfoXml: (xml, callback) -> + RecurlyWrapper._parseXml xml, (error, data) -> + return callback(error) if error? + if data? and data.billing_info? + billingInfo = data.billing_info + else + return callback "I don't understand the response from Recurly" + callback null, billingInfo + _parseXml: (xml, callback) -> convertDataTypes = (data) -> if data? and data["$"]? @@ -299,7 +504,7 @@ module.exports = RecurlyWrapper = else array.push(convertDataTypes(value)) data = array - + if data instanceof Array data = (convertDataTypes(entry) for entry in data) else if typeof data == "object" @@ -315,6 +520,3 @@ module.exports = RecurlyWrapper = return callback(error) if error? result = convertDataTypes(data) callback null, result - - - diff --git a/services/web/public/coffee/main/new-subscription.coffee b/services/web/public/coffee/main/new-subscription.coffee index 404b512e7b..93c2c0a7cc 100644 --- a/services/web/public/coffee/main/new-subscription.coffee +++ b/services/web/public/coffee/main/new-subscription.coffee @@ -115,6 +115,13 @@ define [ currencyCode:pricing.items.currency plan_code:pricing.items.plan.code coupon_code:pricing.items?.coupon?.code || "" + isPaypal: $scope.paymentMethod == 'paypal' + address: + address1: $scope.data.address1 + address2: $scope.data.address2 + country: $scope.data.country + state: $scope.data.state + postal_code: $scope.data.postal_code $http.post("/user/subscription/create", postData) .success (data, status, headers)-> sixpack.convert "in-editor-free-trial-plan", pricing.items.plan.code, (err)-> diff --git a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee index e05040772d..5a5e9fc40c 100644 --- a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee @@ -1,9 +1,10 @@ should = require('chai').should() +expect = require('chai').expect sinon = require 'sinon' crypto = require 'crypto' querystring = require 'querystring' -RecurlyWrapper = require "../../../../app/js/Features/Subscription/RecurlyWrapper" -Settings = require "settings-sharelatex" +modulePath = "../../../../app/js/Features/Subscription/RecurlyWrapper" +SandboxedModule = require('sandboxed-module') tk = require("timekeeper") fixtures = @@ -97,22 +98,37 @@ mockApiRequest = (options, callback) -> describe "RecurlyWrapper", -> - beforeEach -> - Settings.plans = [{ - planCode: "collaborator" - name: "Collaborator" - features: - collaborators: -1 - versioning: true - }] - Settings.defaultPlanCode = - collaborators: 0 - versioning: false + + before -> + @settings = + plans: [{ + planCode: "collaborator" + name: "Collaborator" + features: + collaborators: -1 + versioning: true + }] + defaultPlanCode: + collaborators: 0 + versioning: false + apis: + recurly: + apiKey: 'nonsense' + privateKey: 'private_nonsense' + + @RecurlyWrapper = RecurlyWrapper = SandboxedModule.require modulePath, requires: + "settings-sharelatex": @settings + "logger-sharelatex": + err: sinon.stub() + error: sinon.stub() + log: sinon.stub() + "request": sinon.stub() describe "sign", -> + before (done) -> tk.freeze Date.now() # freeze the time for these tests - RecurlyWrapper.sign({ + @RecurlyWrapper.sign({ subscription : plan_code : "gold" name : "$$$" @@ -127,7 +143,7 @@ describe "RecurlyWrapper", -> it "should be signed correctly", -> signed = @signature.split("|")[0] query = @signature.split("|")[1] - crypto.createHmac("sha1", Settings.apis.recurly.privateKey).update(query).digest("hex").should.equal signed + crypto.createHmac("sha1", @settings.apis.recurly.privateKey).update(query).digest("hex").should.equal signed it "should be url escaped", -> query = @signature.split("|")[1] @@ -149,38 +165,39 @@ describe "RecurlyWrapper", -> describe "_parseXml", -> it "should convert different data types into correct representations", (done) -> - xml = - "" + - "" + - " " + - " " + - " gold" + - " Gold plan" + - " " + - " 44f83d7cba354d5b84812419f923ea96" + - " active" + - " 800" + - " EUR" + - " 1" + - " 2011-05-27T07:00:00Z" + - " " + - " " + - " 2011-06-27T07:00:00Z" + - " 2011-07-27T07:00:00Z" + - " " + - " " + - " " + - " " + - " ipaddresses" + - " 10" + - " 150" + - " " + - " " + - " " + - " " + - " " + - "" - RecurlyWrapper._parseXml xml, (error, data) -> + xml = """ + + + + + gold + Gold plan + + 44f83d7cba354d5b84812419f923ea96 + active + 800 + EUR + 1 + 2011-05-27T07:00:00Z + + + 2011-06-27T07:00:00Z + 2011-07-27T07:00:00Z + + + + + ipaddresses + 10 + 150 + + + + + + + """ + @RecurlyWrapper._parseXml xml, (error, data) -> data.subscription.plan.plan_code.should.equal "gold" data.subscription.plan.name.should.equal "Gold plan" data.subscription.uuid.should.equal "44f83d7cba354d5b84812419f923ea96" @@ -188,32 +205,37 @@ describe "RecurlyWrapper", -> data.subscription.unit_amount_in_cents.should.equal 800 data.subscription.currency.should.equal "EUR" data.subscription.quantity.should.equal 1 + data.subscription.activated_at.should.deep.equal new Date("2011-05-27T07:00:00Z") should.equal data.subscription.canceled_at, null should.equal data.subscription.expires_at, null + data.subscription.current_period_started_at.should.deep.equal new Date("2011-06-27T07:00:00Z") + data.subscription.current_period_ends_at.should.deep.equal new Date("2011-07-27T07:00:00Z") should.equal data.subscription.trial_started_at, null should.equal data.subscription.trial_ends_at, null - data.subscription.subscription_add_ons.should.deep.equal [{ + + data.subscription.subscription_add_ons[0].should.deep.equal { add_on_code: "ipaddresses" quantity: "10" unit_amount_in_cents: "150" - }] + } data.subscription.account.url.should.equal "https://api.recurly.com/v2/accounts/1" data.subscription.url.should.equal "https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96" data.subscription.plan.url.should.equal "https://api.recurly.com/v2/plans/gold" done() - + describe "getSubscription", -> + describe "with proper subscription id", -> before -> - @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest) - RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", (error, recurlySubscription) => + @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest) + @RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", (error, recurlySubscription) => @recurlySubscription = recurlySubscription after -> - RecurlyWrapper.apiRequest.restore() - + @RecurlyWrapper.apiRequest.restore() + it "should look up the subscription at the normal API end point", -> @apiRequest.args[0][0].url.should.equal "subscriptions/44f83d7cba354d5b84812419f923ea96" @@ -222,12 +244,12 @@ describe "RecurlyWrapper", -> describe "with ReculyJS token", -> before -> - @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest) - RecurlyWrapper.getSubscription "70db44b10f5f4b238669480c9903f6f5", {recurlyJsResult: true}, (error, recurlySubscription) => + @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest) + @RecurlyWrapper.getSubscription "70db44b10f5f4b238669480c9903f6f5", {recurlyJsResult: true}, (error, recurlySubscription) => @recurlySubscription = recurlySubscription after -> - RecurlyWrapper.apiRequest.restore() - + @RecurlyWrapper.apiRequest.restore() + it "should return the subscription", -> @recurlySubscription.uuid.should.equal "44f83d7cba354d5b84812419f923ea96" @@ -236,30 +258,30 @@ describe "RecurlyWrapper", -> describe "with includeAccount", -> beforeEach -> - @apiRequest = sinon.stub(RecurlyWrapper, "apiRequest", mockApiRequest) - RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", {includeAccount: true}, (error, recurlySubscription) => + @apiRequest = sinon.stub(@RecurlyWrapper, "apiRequest", mockApiRequest) + @RecurlyWrapper.getSubscription "44f83d7cba354d5b84812419f923ea96", {includeAccount: true}, (error, recurlySubscription) => @recurlySubscription = recurlySubscription afterEach -> - RecurlyWrapper.apiRequest.restore() + @RecurlyWrapper.apiRequest.restore() it "should request the account from the API", -> @apiRequest.args[1][0].url.should.equal "accounts/104" - + it "should populate the account attribute", -> @recurlySubscription.account.account_code.should.equal "104" - + describe "updateSubscription", -> beforeEach (done) -> @recurlySubscriptionId = "subscription-id-123" - @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) => + @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) => @requestOptions = options callback null, {}, fixtures["subscriptions/44f83d7cba354d5b84812419f923ea96"] - RecurlyWrapper.updateSubscription @recurlySubscriptionId, { plan_code : "silver", timeframe: "now" }, (error, recurlySubscription) => + @RecurlyWrapper.updateSubscription @recurlySubscriptionId, { plan_code : "silver", timeframe: "now" }, (error, recurlySubscription) => @recurlySubscription = recurlySubscription done() afterEach -> - RecurlyWrapper.apiRequest.restore() + @RecurlyWrapper.apiRequest.restore() it "should send an update request to the API", -> @apiRequest.called.should.equal true @@ -275,59 +297,723 @@ describe "RecurlyWrapper", -> it "should return the updated subscription", -> should.exist @recurlySubscription @recurlySubscription.plan.plan_code.should.equal "gold" - + describe "cancelSubscription", -> beforeEach (done) -> @recurlySubscriptionId = "subscription-id-123" - @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) => + @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) => options.url.should.equal "subscriptions/#{@recurlySubscriptionId}/cancel" options.method.should.equal "put" callback() - RecurlyWrapper.cancelSubscription(@recurlySubscriptionId, done) + @RecurlyWrapper.cancelSubscription(@recurlySubscriptionId, done) afterEach -> - RecurlyWrapper.apiRequest.restore() + @RecurlyWrapper.apiRequest.restore() it "should send a cancel request to the API", -> @apiRequest.called.should.equal true - + describe "reactivateSubscription", -> beforeEach (done) -> @recurlySubscriptionId = "subscription-id-123" - @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) => + @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) => options.url.should.equal "subscriptions/#{@recurlySubscriptionId}/reactivate" options.method.should.equal "put" callback() - RecurlyWrapper.reactivateSubscription(@recurlySubscriptionId, done) + @RecurlyWrapper.reactivateSubscription(@recurlySubscriptionId, done) afterEach -> - RecurlyWrapper.apiRequest.restore() + @RecurlyWrapper.apiRequest.restore() it "should send a cancel request to the API", -> @apiRequest.called.should.equal true - - + + describe "redeemCoupon", -> beforeEach (done) -> @recurlyAccountId = "account-id-123" @coupon_code = "312321312" - @apiRequest = sinon.stub RecurlyWrapper, "apiRequest", (options, callback) => + @apiRequest = sinon.stub @RecurlyWrapper, "apiRequest", (options, callback) => options.url.should.equal "coupons/#{@coupon_code}/redeem" options.body.indexOf("#{@recurlyAccountId}").should.not.equal -1 options.body.indexOf("USD").should.not.equal -1 options.method.should.equal "post" callback() - RecurlyWrapper.redeemCoupon(@recurlyAccountId, @coupon_code, done) + @RecurlyWrapper.redeemCoupon(@recurlyAccountId, @coupon_code, done) afterEach -> - RecurlyWrapper.apiRequest.restore() + @RecurlyWrapper.apiRequest.restore() it "should send the request to redem the coupon", -> @apiRequest.called.should.equal true - + + describe "_addressToXml", -> + + beforeEach -> + @address = + address1: "addr_one" + address2: "addr_two" + country: "some_country" + state: "some_state" + postal_code: "some_zip" + nonsenseKey: "rubbish" + + it 'should generate the correct xml', () -> + result = @RecurlyWrapper._addressToXml @address + should.equal( + result, + """ + + addr_one + addr_two + some_country + some_state + some_zip + \n + """ + ) + + describe 'createSubscription', -> + + beforeEach -> + @user = + _id: 'some_id' + email: 'user@example.com' + @subscriptionDetails = + currencyCode: "EUR" + plan_code: "some_plan_code" + coupon_code: "" + isPaypal: true + address: + address1: "addr_one" + address2: "addr_two" + country: "some_country" + state: "some_state" + zip: "some_zip" + @subscription = {} + @recurly_token_id = "a-token-id" + @call = (callback) => + @RecurlyWrapper.createSubscription(@user, @subscriptionDetails, @recurly_token_id, callback) + describe 'when paypal', -> + beforeEach -> + @subscriptionDetails.isPaypal = true + @_createPaypalSubscription = sinon.stub(@RecurlyWrapper, '_createPaypalSubscription') + @_createPaypalSubscription.callsArgWith(3, null, @subscription) + + afterEach -> + @_createPaypalSubscription.restore() + + it 'should not produce an error', (done) -> + @call (err, sub) => + expect(err).to.equal null + expect(err).to.not.be.instanceof Error + done() + + it 'should produce a subscription object', (done) -> + @call (err, sub) => + expect(sub).to.deep.equal @subscription + done() + + it 'should call _createPaypalSubscription', (done) -> + @call (err, sub) => + @_createPaypalSubscription.callCount.should.equal 1 + done() + + describe "when _createPaypalSubscription produces an error", -> + + beforeEach -> + @_createPaypalSubscription.callsArgWith(3, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, sub) => + expect(err).to.be.instanceof Error + done() + + describe 'when not paypal', -> + + beforeEach -> + @subscriptionDetails.isPaypal = false + @_createCreditCardSubscription = sinon.stub(@RecurlyWrapper, '_createCreditCardSubscription') + @_createCreditCardSubscription.callsArgWith(3, null, @subscription) + + afterEach -> + @_createCreditCardSubscription.restore() + + it 'should not produce an error', (done) -> + @call (err, sub) => + expect(err).to.equal null + expect(err).to.not.be.instanceof Error + done() + + it 'should produce a subscription object', (done) -> + @call (err, sub) => + expect(sub).to.deep.equal @subscription + done() + + it 'should call _createCreditCardSubscription', (done) -> + @call (err, sub) => + @_createCreditCardSubscription.callCount.should.equal 1 + done() + + describe "when _createCreditCardSubscription produces an error", -> + + beforeEach -> + @_createCreditCardSubscription.callsArgWith(3, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, sub) => + expect(err).to.be.instanceof Error + done() + + + describe '_createCreditCardSubscription', -> + + beforeEach -> + @user = + _id: 'some_id' + email: 'user@example.com' + @subscriptionDetails = + currencyCode: "EUR" + plan_code: "some_plan_code" + coupon_code: "" + isPaypal: true + address: + address1: "addr_one" + address2: "addr_two" + country: "some_country" + state: "some_state" + zip: "some_zip" + @subscription = {} + @recurly_token_id = "a-token-id" + @apiRequest = sinon.stub(@RecurlyWrapper, 'apiRequest') + @response = + statusCode: 200 + @body = "is_bad" + @apiRequest.callsArgWith(1, null, @response, @body) + @_parseSubscriptionXml = sinon.stub(@RecurlyWrapper, '_parseSubscriptionXml') + @_parseSubscriptionXml.callsArgWith(1, null, @subscription) + @call = (callback) => + @RecurlyWrapper._createCreditCardSubscription(@user, @subscriptionDetails, @recurly_token_id, callback) + + afterEach -> + @apiRequest.restore() + @_parseSubscriptionXml.restore() + + it 'should not produce an error', (done) -> + @call (err, sub) => + expect(err).to.not.be.instanceof Error + expect(err).to.equal null + done() + + it 'should produce a subscription', (done) -> + @call (err, sub) => + expect(sub).to.equal @subscription + done() + + it 'should call apiRequest', (done) -> + @call (err, sub) => + @apiRequest.callCount.should.equal 1 + done() + + it 'should call _parseSubscriptionXml', (done) -> + @call (err, sub) => + @_parseSubscriptionXml.callCount.should.equal 1 + done() + + describe 'when api request produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, sub) => + expect(err).to.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, sub) => + @apiRequest.callCount.should.equal 1 + done() + + it 'should not _parseSubscriptionXml', (done) -> + @call (err, sub) => + @_parseSubscriptionXml.callCount.should.equal 0 + done() + + describe 'when parse xml produces an error', -> + + beforeEach -> + @_parseSubscriptionXml.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, sub) => + expect(err).to.be.instanceof Error + done() + + describe '_createPaypalSubscription', -> + + beforeEach -> + @checkAccountExists = sinon.stub(@RecurlyWrapper._paypal, 'checkAccountExists') + @createAccount = sinon.stub(@RecurlyWrapper._paypal, 'createAccount') + @createBillingInfo = sinon.stub(@RecurlyWrapper._paypal, 'createBillingInfo') + @setAddress = sinon.stub(@RecurlyWrapper._paypal, 'setAddress') + @createSubscription = sinon.stub(@RecurlyWrapper._paypal, 'createSubscription') + @user = + _id: 'some_id' + email: 'user@example.com' + @subscriptionDetails = + currencyCode: "EUR" + plan_code: "some_plan_code" + coupon_code: "" + isPaypal: true + address: + address1: "addr_one" + address2: "addr_two" + country: "some_country" + state: "some_state" + zip: "some_zip" + @subscription = {} + @recurly_token_id = "a-token-id" + + # set up data callbacks + user = @user + subscriptionDetails = @subscriptionDetails + recurly_token_id = @recurly_token_id + + @checkAccountExists.callsArgWith(1, null, + {user, subscriptionDetails, recurly_token_id, + userExists: false, account: {accountCode: 'xx'}} + ) + @createAccount.callsArgWith(1, null, + {user, subscriptionDetails, recurly_token_id, + userExists: false, account: {accountCode: 'xx'}} + ) + @createBillingInfo.callsArgWith(1, null, + {user, subscriptionDetails, recurly_token_id, + userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}} + ) + @setAddress.callsArgWith(1, null, + {user, subscriptionDetails, recurly_token_id, + userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}} + ) + @createSubscription.callsArgWith(1, null, + {user, subscriptionDetails, recurly_token_id, + userExists: false, account: {accountCode: 'xx'}, billingInfo: {token_id: 'abc'}, subscription: @subscription} + ) + + @call = (callback) => + @RecurlyWrapper._createPaypalSubscription @user, @subscriptionDetails, @recurly_token_id, callback + + afterEach -> + @checkAccountExists.restore() + @createAccount.restore() + @createBillingInfo.restore() + @setAddress.restore() + @createSubscription.restore() + + it 'should not produce an error', (done) -> + @call (err, sub) => + expect(err).to.not.be.instanceof Error + done() + + it 'should produce a subscription object', (done) -> + @call (err, sub) => + expect(sub).to.not.equal null + expect(sub).to.equal @subscription + done() + + it 'should call each of the paypal stages', (done) -> + @call (err, sub) => + @checkAccountExists.callCount.should.equal 1 + @createAccount.callCount.should.equal 1 + @createBillingInfo.callCount.should.equal 1 + @setAddress.callCount.should.equal 1 + @createSubscription.callCount.should.equal 1 + done() + + describe 'when one of the paypal stages produces an error', -> + + beforeEach -> + @createAccount.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, sub) => + expect(err).to.be.instanceof Error + done() + + it 'should stop calling the paypal stages after the error', (done) -> + @call (err, sub) => + @checkAccountExists.callCount.should.equal 1 + @createAccount.callCount.should.equal 1 + @createBillingInfo.callCount.should.equal 0 + @setAddress.callCount.should.equal 0 + @createSubscription.callCount.should.equal 0 + done() + + describe 'paypal actions', -> + + beforeEach -> + @apiRequest = sinon.stub(@RecurlyWrapper, 'apiRequest') + @_parseAccountXml = sinon.spy(@RecurlyWrapper, '_parseAccountXml') + @_parseBillingInfoXml = sinon.spy(@RecurlyWrapper, '_parseBillingInfoXml') + @_parseSubscriptionXml = sinon.spy(@RecurlyWrapper, '_parseSubscriptionXml') + @cache = + user: @user = {_id: 'some_id'} + recurly_token_id: @recurly_token_id = "some_token" + subscriptionDetails: @subscriptionDetails = + currencyCode: "EUR" + plan_code: "some_plan_code" + coupon_code: "" + isPaypal: true + address: + address1: "addr_one" + address2: "addr_two" + country: "some_country" + state: "some_state" + zip: "some_zip" + + afterEach -> + @apiRequest.restore() + @_parseAccountXml.restore() + @_parseBillingInfoXml.restore() + @_parseSubscriptionXml.restore() + + describe '_paypal.checkAccountExists', -> + + beforeEach -> + @call = (callback) => + @RecurlyWrapper._paypal.checkAccountExists @cache, callback + + describe 'when the account exists', -> + + beforeEach -> + resultXml = 'abc' + @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml) + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + done() + + it 'should call _parseAccountXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseAccountXml.callCount.should.equal 1 + done() + + it 'should add the account to the cumulative result', (done) -> + @call (err, result) => + expect(result.account).to.not.equal null + expect(result.account).to.not.equal undefined + expect(result.account).to.deep.equal { + account_code: 'abc' + } + done() + + it 'should set userExists to true', (done) -> + @call (err, result) => + expect(result.userExists).to.equal true + done() + + describe 'when the account does not exist', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('not found'), {statusCode: 404}, '') + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + @apiRequest.firstCall.args[0].method.should.equal 'GET' + done() + + it 'should not call _parseAccountXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseAccountXml.callCount.should.equal 0 + done() + + it 'should not add the account to result', (done) -> + @call (err, result) => + expect(result.account).to.equal undefined + done() + + it 'should set userExists to false', (done) -> + @call (err, result) => + expect(result.userExists).to.equal false + done() + + describe 'when apiRequest produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500}) + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe '_paypal.createAccount', -> + + beforeEach -> + @call = (callback) => + @RecurlyWrapper._paypal.createAccount @cache, callback + + describe 'when address is missing from subscriptionDetails', -> + + beforeEach -> + @cache.subscriptionDetails.address = null + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe 'when account already exists', -> + + beforeEach -> + @cache.userExists = true + @cache.account = + account_code: 'abc' + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should produce cache object', (done) -> + @call (err, result) => + expect(result).to.deep.equal @cache + expect(result.account).to.deep.equal { + account_code: 'abc' + } + done() + + it 'should not call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 0 + done() + + it 'should not call _parseAccountXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseAccountXml.callCount.should.equal 0 + done() + + describe 'when account does not exist', -> + + beforeEach -> + @cache.userExists = false + resultXml = 'abc' + @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml) + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + @apiRequest.firstCall.args[0].method.should.equal 'POST' + done() + + it 'should call _parseAccountXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseAccountXml.callCount.should.equal 1 + done() + + describe 'when apiRequest produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500}) + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe '_paypal.createBillingInfo', -> + + beforeEach -> + @cache.account = + account_code: 'abc' + @call = (callback) => + @RecurlyWrapper._paypal.createBillingInfo @cache, callback + + describe 'when account_code is missing from cache', -> + + beforeEach -> + @cache.account.account_code = null + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe 'when all goes well', -> + + beforeEach -> + resultXml = '1' + @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml) + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + @apiRequest.firstCall.args[0].method.should.equal 'POST' + done() + + it 'should call _parseBillingInfoXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseBillingInfoXml.callCount.should.equal 1 + done() + + it 'should set billingInfo on cache', (done) -> + @call (err, result) => + expect(result.billingInfo).to.deep.equal { + a: "1" + } + done() + + describe 'when apiRequest produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500}) + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe '_paypal.setAddress', -> + + beforeEach -> + @cache.account = + account_code: 'abc' + @cache.billingInfo = {} + @call = (callback) => + @RecurlyWrapper._paypal.setAddress @cache, callback + + describe 'when account_code is missing from cache', -> + + beforeEach -> + @cache.account.account_code = null + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe 'when address is missing from subscriptionDetails', -> + + beforeEach -> + @cache.subscriptionDetails.address = null + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe 'when all goes well', -> + + beforeEach -> + resultXml = 'London' + @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml) + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + @apiRequest.firstCall.args[0].method.should.equal 'PUT' + done() + + it 'should call _parseBillingInfoXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseBillingInfoXml.callCount.should.equal 1 + done() + + it 'should set billingInfo on cache', (done) -> + @call (err, result) => + expect(result.billingInfo).to.deep.equal { + city: 'London' + } + done() + + describe 'when apiRequest produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500}) + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done() + + describe '_paypal.createSubscription', -> + + beforeEach -> + @cache.account = + account_code: 'abc' + @cache.billingInfo = {} + @call = (callback) => + @RecurlyWrapper._paypal.createSubscription @cache, callback + + describe 'when all goes well', -> + + beforeEach -> + resultXml = '1' + @apiRequest.callsArgWith(1, null, {statusCode: 200}, resultXml) + + it 'should not produce an error', (done) -> + @call (err, result) => + expect(err).to.not.be.instanceof Error + done() + + it 'should call apiRequest', (done) -> + @call (err, result) => + @apiRequest.callCount.should.equal 1 + @apiRequest.firstCall.args[0].method.should.equal 'POST' + done() + + it 'should call _parseSubscriptionXml', (done) -> + @call (err, result) => + @RecurlyWrapper._parseSubscriptionXml.callCount.should.equal 1 + done() + + it 'should set subscription on cache', (done) -> + @call (err, result) => + expect(result.subscription).to.deep.equal { + a: "1" + } + done() + + describe 'when apiRequest produces an error', -> + + beforeEach -> + @apiRequest.callsArgWith(1, new Error('woops'), {statusCode: 500}) + + it 'should produce an error', (done) -> + @call (err, result) => + expect(err).to.be.instanceof Error + done()