querystring = require 'querystring' crypto = require 'crypto' 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" _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" expect404: true }, (error, response, responseBody) -> if error logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account" return next(error) if response.statusCode == 404 # actually not an error in this case, just no existing account logger.log {user_id: user._id, recurly_token_id}, "user does not currently exist in recurly, proceed" cache.userExists = false return next(null, cache) 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} #{subscriptionDetails.currencyCode} #{subscriptionDetails.coupon_code} #{user._id} #{user.email} #{user.first_name} #{user.last_name} #{recurly_token_id} """ RecurlyWrapper.apiRequest({ url : "subscriptions" method : "POST" body : requestBody }, (error, response, responseBody) => return callback(error) if error? 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 = RecurlyWrapper.apiUrl + "/" + options.url options.headers = "Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64") "Accept" : "application/xml" "Content-Type" : "application/xml; charset=utf-8" expect404 = options.expect404 delete options.expect404 request options, (error, response, body) -> unless error? or response.statusCode == 200 or response.statusCode == 201 or response.statusCode == 204 or (response.statusCode == 404 and expect404) logger.err err:error, body:body, options:options, statusCode:response?.statusCode, "error returned from recurly" error = "Recurly API returned with status code: #{response.statusCode}" if response.statusCode == 404 and expect404 logger.log {url: options.url, method: options.method}, "got 404 response from recurly, expected as valid response" callback(error, response, body) sign : (parameters, callback) -> nestAttributesForQueryString = (attributes, base) -> newAttributes = {} for key, value of attributes if base? newKey = "#{base}[#{key}]" else newKey = key if typeof value == "object" for key, value of nestAttributesForQueryString(value, newKey) newAttributes[key] = value else newAttributes[newKey] = value return newAttributes crypto.randomBytes 32, (error, buffer) -> return callback error if error? parameters.nonce = buffer.toString "base64" parameters.timestamp = Math.round((new Date()).getTime() / 1000) unsignedQuery = querystring.stringify nestAttributesForQueryString(parameters) signed = crypto.createHmac("sha1", Settings.apis.recurly.privateKey).update(unsignedQuery).digest("hex") signature = "#{signed}|#{unsignedQuery}" callback null, signature getSubscriptions: (accountId, callback)-> RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}/subscriptions" }, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseXml body, callback ) getSubscription: (subscriptionId, options, callback) -> callback = options unless callback? options ||= {} if options.recurlyJsResult url = "recurly_js/result/#{subscriptionId}" else url = "subscriptions/#{subscriptionId}" RecurlyWrapper.apiRequest({ url: url }, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseSubscriptionXml body, (error, recurlySubscription) => return callback(error) if error? if options.includeAccount if recurlySubscription.account? and recurlySubscription.account.url? accountId = recurlySubscription.account.url.match(/accounts\/(.*)/)[1] else return callback "I don't understand the response from Recurly" RecurlyWrapper.getAccount accountId, (error, account) -> return callback(error) if error? recurlySubscription.account = account callback null, recurlySubscription else callback null, recurlySubscription ) getAccounts: (callback)-> allAccounts = [] getPageOfAccounts = (cursor = null)=> opts = url: "accounts" qs: per_page:200 if cursor? opts.qs.cursor = cursor RecurlyWrapper.apiRequest opts, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseXml body, (err, data)-> if err? logger.err err:err, "could not get accoutns" callback(err) allAccounts = allAccounts.concat(data.accounts) logger.log "got another #{data.accounts.length}, total now #{allAccounts.length}" cursor = response.headers.link?.match(/cursor=([0-9]+)&/)?[1] if cursor? getPageOfAccounts(cursor) else callback(err, allAccounts) getPageOfAccounts() getAccount: (accountId, callback) -> RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}" }, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseAccountXml body, callback ) getBillingInfo: (accountId, callback)-> RecurlyWrapper.apiRequest({ url: "accounts/#{accountId}/billing_info" }, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseXml body, callback ) updateSubscription: (subscriptionId, options, callback) -> logger.log subscriptionId:subscriptionId, options:options, "telling recurly to update subscription" requestBody = """ #{options.plan_code} #{options.timeframe} """ RecurlyWrapper.apiRequest({ url : "subscriptions/#{subscriptionId}" method : "put" body : requestBody }, (error, response, responseBody) => return callback(error) if error? RecurlyWrapper._parseSubscriptionXml responseBody, callback ) createFixedAmmountCoupon: (coupon_code, name, currencyCode, discount_in_cents, plan_code, callback)-> requestBody = """ #{coupon_code} #{name} dollars <#{currencyCode}>#{discount_in_cents} #{plan_code} false """ logger.log coupon_code:coupon_code, requestBody:requestBody, "creating coupon" RecurlyWrapper.apiRequest({ url : "coupons" method : "post" body : requestBody }, (error, response, responseBody) => if error? logger.err err:error, coupon_code:coupon_code, "error creating coupon" callback(error) ) lookupCoupon: (coupon_code, callback)-> RecurlyWrapper.apiRequest({ url: "coupons/#{coupon_code}" }, (error, response, body) => return callback(error) if error? RecurlyWrapper._parseXml body, callback ) cancelSubscription: (subscriptionId, callback) -> logger.log subscriptionId:subscriptionId, "telling recurly to cancel subscription" RecurlyWrapper.apiRequest({ url: "subscriptions/#{subscriptionId}/cancel", method: "put" }, (error, response, body) -> if error? RecurlyWrapper._parseXml body, (_err, parsed) -> if parsed?.error?.description == "A canceled subscription can't transition to canceled" logger.log {subscriptionId, error, body}, "subscription already cancelled, not really an error, proceeding" callback(null) else callback(error) else callback(null) ) reactivateSubscription: (subscriptionId, callback) -> logger.log subscriptionId:subscriptionId, "telling recurly to reactivating subscription" RecurlyWrapper.apiRequest({ url: "subscriptions/#{subscriptionId}/reactivate", method: "put" }, (error, response, body) -> callback(error) ) redeemCoupon: (account_code, coupon_code, callback)-> requestBody = """ #{account_code} USD """ logger.log account_code:account_code, coupon_code:coupon_code, requestBody:requestBody, "redeeming coupon for user" RecurlyWrapper.apiRequest({ url : "coupons/#{coupon_code}/redeem" method : "post" body : requestBody }, (error, response, responseBody) => if error? logger.err err:error, account_code:account_code, coupon_code:coupon_code, "error redeeming coupon" callback(error) ) extendTrial: (subscriptionId, daysUntilExpire = 7, callback)-> 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" RecurlyWrapper.apiRequest({ url : "/subscriptions/#{subscriptionId}/postpone?next_renewal_date=#{next_renewal_date}&bulk=false" method : "put" }, (error, response, responseBody) => if error? logger.err err:error, subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire, "error exending trial" callback(error) ) listAccountActiveSubscriptions: (account_id, callback = (error, subscriptions) ->) -> RecurlyWrapper.apiRequest { url: "accounts/#{account_id}/subscriptions" qs: state: "active" expect404: true }, (error, response, body) -> return callback(error) if error? if response.statusCode == 404 return callback null, [] else RecurlyWrapper._parseSubscriptionsXml body, callback _parseSubscriptionsXml: (xml, callback) -> RecurlyWrapper._parseXmlAndGetAttribute xml, "subscriptions", callback _parseSubscriptionXml: (xml, callback) -> RecurlyWrapper._parseXmlAndGetAttribute xml, "subscription", callback _parseAccountXml: (xml, callback) -> RecurlyWrapper._parseXmlAndGetAttribute xml, "account", callback _parseBillingInfoXml: (xml, callback) -> RecurlyWrapper._parseXmlAndGetAttribute xml, "billing_info", callback _parseXmlAndGetAttribute: (xml, attribute, callback) -> RecurlyWrapper._parseXml xml, (error, data) -> return callback(error) if error? if data? and data[attribute]? return callback null, data[attribute] else return callback(new Error("I don't understand the response from Recurly")) _parseXml: (xml, callback) -> convertDataTypes = (data) -> if data? and data["$"]? if data["$"]["nil"] == "nil" data = null else if data["$"].href? data.url = data["$"].href delete data["$"] else if data["$"]["type"] == "integer" data = parseInt(data["_"], 10) else if data["$"]["type"] == "datetime" data = new Date(data["_"]) else if data["$"]["type"] == "array" delete data["$"] array = [] for key, value of data if value instanceof Array array = array.concat(convertDataTypes(value)) else array.push(convertDataTypes(value)) data = array if data instanceof Array data = (convertDataTypes(entry) for entry in data) else if typeof data == "object" for key, value of data data[key] = convertDataTypes(value) return data parser = new xml2js.Parser( explicitRoot : true explicitArray : false ) parser.parseString xml, (error, data) -> return callback(error) if error? result = convertDataTypes(data) callback null, result