UNPKG

splitwise

Version:

A JavaScript SDK for the Splitwise API.

675 lines (626 loc) 20.7 kB
module.exports = (function () { 'use strict' const { OAuth2 } = require('oauth') const querystring = require('querystring') const promisify = require('es6-promisify') const validate = require('validate.js') const R = require('./ramda.js') const { LOG_LEVELS, getLogger } = require('./logger.js') const { splitwisifyParameters, getSplitwiseErrors } = require('./utils.js') const { METHOD_VERBS, PROP_NAMES, ID_PARAM_NAMES, API_URL } = require('./constants.js') const METHODS = { TEST: { endpoint: 'test', methodName: 'test', verb: METHOD_VERBS.GET }, GET_CURRENCIES: { endpoint: 'get_currencies', methodName: 'getCurrencies', verb: METHOD_VERBS.GET, propName: PROP_NAMES.CURRENCIES }, GET_CATEGORIES: { endpoint: 'get_categories', methodName: 'getCategories', verb: METHOD_VERBS.GET, propName: PROP_NAMES.CATEGORIES }, PARSE_SENTENCE: { endpoint: 'parse_sentence', methodName: 'parseSentence', verb: METHOD_VERBS.POST, paramNames: ['input', 'group_id', 'friend_id', 'autosave'] }, GET_CURRENT_USER: { endpoint: 'get_current_user', methodName: 'getCurrentUser', verb: METHOD_VERBS.GET, propName: PROP_NAMES.USER }, GET_USER: { endpoint: 'get_user', methodName: 'getUser', verb: METHOD_VERBS.GET, propName: PROP_NAMES.USER, idParamName: ID_PARAM_NAMES.USER }, UPDATE_USER: { endpoint: 'update_user', methodName: 'updateUser', verb: METHOD_VERBS.PUT, propName: PROP_NAMES.USER, idParamName: ID_PARAM_NAMES.USER, paramNames: [ 'first_name', 'last_name', 'email', 'password', 'locale', 'date_format', 'default_currency', 'default_group_id', 'notification_settings' ] }, GET_GROUPS: { endpoint: 'get_groups', methodName: 'getGroups', verb: METHOD_VERBS.GET, propName: PROP_NAMES.GROUPS }, GET_GROUP: { endpoint: 'get_group', methodName: 'getGroup', verb: METHOD_VERBS.GET, propName: PROP_NAMES.GROUP, idParamName: ID_PARAM_NAMES.GROUP }, CREATE_GROUP: { endpoint: 'create_group', methodName: 'createGroup', verb: METHOD_VERBS.POST, propName: PROP_NAMES.GROUP, paramNames: ['name', 'group_type', 'country_code', 'users'] }, DELETE_GROUP: { endpoint: 'delete_group', methodName: 'deleteGroup', verb: METHOD_VERBS.POST, propName: PROP_NAMES.SUCCESS, idParamName: PROP_NAMES.GROUP }, ADD_USER_TO_GROUP: { endpoint: 'add_user_to_group', methodName: 'addUserToGroup', verb: METHOD_VERBS.POST, propName: PROP_NAMES.SUCCESS, paramNames: ['group_id', 'user_id', 'first_name', 'last_name', 'email'] }, REMOVE_USER_FROM_GROUP: { endpoint: 'remove_user_from_group', methodName: 'removeUserFromGroup', verb: METHOD_VERBS.POST, propName: PROP_NAMES.SUCCESS, paramNames: ['user_id', 'group_id'] }, GET_EXPENSES: { endpoint: 'get_expenses', methodName: 'getExpenses', verb: METHOD_VERBS.GET, propName: PROP_NAMES.EXPENSES, paramNames: [ 'group_id', 'friendship_id', 'dated_after', 'dated_before', 'updated_after', 'updated_before', 'limit', 'offset' ] }, GET_EXPENSE: { endpoint: 'get_expense', methodName: 'getExpense', verb: METHOD_VERBS.GET, propName: PROP_NAMES.EXPENSE, idParamName: ID_PARAM_NAMES.EXPENSE }, CREATE_EXPENSE: { endpoint: 'create_expense', methodName: 'createExpense', verb: METHOD_VERBS.POST, propName: PROP_NAMES.EXPENSES, paramNames: [ 'payment', 'cost', 'description', 'group_id', 'friendship_id', 'details', 'creation_method', 'date', 'repeat_interval', 'currency_code', 'category_id', 'users' ] }, UPDATE_EXPENSE: { endpoint: 'update_expense', methodName: 'updateExpense', verb: METHOD_VERBS.POST, propName: PROP_NAMES.EXPENSES, idParamName: ID_PARAM_NAMES.EXPENSE, paramNames: [ 'group_id', 'friendship_id', 'expense_bundle_id', 'description', 'details', 'payment', 'cost', 'date', 'category_id', 'users' ] }, DELETE_EXPENSE: { endpoint: 'delete_expense', methodName: 'deleteExpense', verb: METHOD_VERBS.POST, propName: PROP_NAMES.SUCCESS, idParamName: ID_PARAM_NAMES.EXPENSE }, GET_FRIENDS: { endpoint: 'get_friends', methodName: 'getFriends', verb: METHOD_VERBS.GET, propName: PROP_NAMES.FRIENDS }, GET_FRIEND: { endpoint: 'get_friend', methodName: 'getFriend', verb: METHOD_VERBS.GET, propName: PROP_NAMES.FRIEND, idParamName: ID_PARAM_NAMES.FRIEND }, CREATE_FRIEND: { endpoint: 'create_friend', methodName: 'createFriend', verb: METHOD_VERBS.POST, propName: PROP_NAMES.FRIENDS, paramNames: ['user_email', 'user_first_name', 'user_last_name'] }, CREATE_FRIENDS: { endpoint: 'create_friends', methodName: 'createFriends', verb: METHOD_VERBS.POST, propName: PROP_NAMES.FRIENDS, paramNames: ['friends'] }, DELETE_FRIEND: { endpoint: 'delete_friend', methodName: 'deleteFriend', verb: METHOD_VERBS.DELETE, propName: PROP_NAMES.SUCCESS, idParamName: ID_PARAM_NAMES.FRIEND }, GET_NOTIFICATIONS: { endpoint: 'get_notifications', methodName: 'getNotifications', verb: METHOD_VERBS.GET, propName: PROP_NAMES.NOTIFICATIONS, paramNames: ['updated_after', 'limit'] }, GET_MAIN_DATA: { endpoint: 'get_main_data', methodName: 'getMainData', verb: METHOD_VERBS.GET, paramNames: ['no_expenses', 'limit', 'cachebust'] } } /** * Consistently handles error scenarios * @param {Object} options - `fail` consumes arguments via an options object * @param {string} options.context - The context in which the error occured * @param {string} options.message - A description of the error * @param {Function} options.callback - Will be called with the error as it's first argument * @param {Function} options.logger - Will be called with a message and the ERROR log level * @param {boolean} options.shouldThrow - Rather than returning a promise, will throw an error * @returns {Promise.<Error>} A promise that has been rejected with an Error */ const fail = ({context, message, callback, logger, shouldThrow} = {}) => { const contextPrefix = context ? `${context} - ` : '' const errorMessage = `${contextPrefix}${message}` if (logger) { logger({ level: LOG_LEVELS.ERROR, message: errorMessage }) } const error = new Error(errorMessage) if (shouldThrow) { throw error } if (callback) { callback(error, null) } return Promise.reject(error) } /** * @param {Function} logger - The logger provided by getLogger * @param {Object} oauth2 - An instance of OAuth2 * @returns {Function} A method which can make oauth requests */ const getOAuthRequestWrapper = (logger, oauth2) => { // eslint-disable-next-line no-underscore-dangle const oAuthRequest = promisify(oauth2._request, { thisArg: oauth2 }) const oAuthRequestWrapperFail = message => fail({ logger, message, context: 'oAuthRequestWrapper' }) /** * Make an oauth request * @param {string} url - The endpoint to send a request to * @param {string} verb - Which http verb to use * @param {Object} data - The data to be sent along with the request * @param {string} accessToken - The oauth access token * @returns {Promise} The data from the endpoint */ const oAuthRequestWrapper = (url, verb, data, accessToken) => { if (!url) { return oAuthRequestWrapperFail('a URL must be provided') } if (!METHOD_VERBS[verb]) { return oAuthRequestWrapperFail('unknown http verb') } if (!accessToken) { return oAuthRequestWrapperFail('an access token must be provided') } return oAuthRequest( verb, url, { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: oauth2.buildAuthHeader(accessToken) }, querystring.stringify(data), null ) } return oAuthRequestWrapper } /** * @param {Function} logger - The logger provided by getLogger * @param {Object} oauth2 - An instance of OAuth2 * @returns A method for making requests to Splitwise */ const getSplitwiseRequest = (logger, oauth2) => { const oAuthGet = promisify(oauth2.get, { thisArg: oauth2 }) const splitwiseRequestFail = message => fail({ logger, message, context: 'splitwiseRequest' }) /** * Make a request to splitwise * @param {string} endpoint - The endpoint to send a request to * @returns {Promise} The data returned from Splitwise */ const splitwiseRequest = (endpoint) => { if (!endpoint) { return splitwiseRequestFail('an endpoint must be specified') } return accessToken => oAuthGet( `${API_URL}${endpoint}`, accessToken ).then(JSON.parse) } return splitwiseRequest } /** * @param {Function} logger - The logger provided by getLogger * @param {Object} oauth2 - An instance of OAuth2 * @returns {Function} A method for making requests with data to Splitwise */ const getSplitwiseRequestWithData = (logger, oauth2) => { const oAuthRequestWrapper = getOAuthRequestWrapper(logger, oauth2) const splitwiseRequestWithDataFail = message => fail({ logger, message, context: 'splitwiseRequestWithData' }) /** * Make a request with data to Splitwise * @param {string} endpoint - The endpoint to send a request to * @param {string} verb - Which http verb to use * @param {Object} data - The data to be sent along with the request * @returns {Promise} The data returned from Splitwise */ const splitwiseRequestWithData = (endpoint, verb, data) => { if (!endpoint) { return splitwiseRequestWithDataFail('an endpoint must be specified') } if (!data) { return splitwiseRequestWithDataFail('data must be provided') } return accessToken => oAuthRequestWrapper( `${API_URL}${endpoint}`, verb, splitwisifyParameters(data), // un-nest data, and convert bools into numbers accessToken ).then(JSON.parse) } return splitwiseRequestWithData } /** * Returns a promise for a Splitwise access token * @param {Function} logger - The logger provided by getLogger * @param {Object} oauth2 - An instance of OAuth2 * @returns {Promise.<string>} A Splitwise access token */ const getAccessTokenPromise = (logger, oauth2) => { const getOAuthAccessToken = promisify( oauth2.getOAuthAccessToken, { thisArg: oauth2 } ) const accessTokenPromise = getOAuthAccessToken('', { grant_type: 'client_credentials' }) accessTokenPromise.then(() => { logger({ message: 'successfully aquired access token' }) }, () => {}) const handledAccessToken = accessTokenPromise.catch(error => { const data = (() => { try { return JSON.parse(error.data) } catch (e) { } return null })() const reason = (() => { if (data && data.error === 'invalid_client') { return 'your credentials are incorrect' } if (error && error.statusCode >= 400 && error.statusCode < 500) { return 'client error' } if (error && error.statusCode >= 500 && error.statusCode < 600) { return 'server error' } return 'unknown error' })() return fail({ logger, message: `authentication failed - ${reason}` }) }) return handledAccessToken } /** * @param {Function} logger - The logger provided by getLogger * @param {Promise.<string>} accessTokenPromise - A promise for a Splitwise access token * @param {Object} defaultIDs - A map of IDs to use by default if one is not provided * @param {Object} oauth2 - An instance of OAuth2 * @returns {Function} A method for generating methods for interacting with Splitwise */ const getEndpointMethodGenerator = (logger, accessTokenPromise, defaultIDs, oauth2) => { const splitwiseRequest = getSplitwiseRequest(logger, oauth2) const splitwiseRequestWithData = getSplitwiseRequestWithData(logger, oauth2) const endpointMethodGeneratorFail = message => fail({ logger, message, shouldThrow: true, context: 'endpointMethodGenerator' }) /** * @param {string} verb - Which http verb to use * @param {string} endpoint - Which Splitwise endpoint to use * @param {string} propName - The key under which the return value is nested * @param {string} methodName - The returned method will have this name bound to it * @param {string} idParamName - The name of the default ID to use * @param {string[]} paramNames - The names of the parameters to expect * @param {Object} constraints - validate.js constraints on the params * @returns {Function} A method for interacting with a Splitwise endpoint */ const endpointMethodGenerator = ({ verb, endpoint, propName, methodName, idParamName, paramNames = [], constraints = {} }) => { if (!endpoint) { endpointMethodGeneratorFail('an endpoint must be specified') } if (!METHOD_VERBS[verb]) { endpointMethodGeneratorFail('unknown http verb') } if (!methodName) { endpointMethodGeneratorFail('a method name must be provided') } const wrappedFail = ({message, callback}) => fail({ logger, message, callback, context: methodName }) const augmentedConstraints = (() => { if (idParamName) { return R.assoc( 'id', { presence: { allowEmpty: false } }, constraints ) } return constraints })() const makeErrorMessage = (errors) => { if (errors.length === 0) { return '' } if (errors.length === 1) { return `${methodName} - ${errors[0]}` } return errors.reduce((messageSoFar, nextError) => { return `${messageSoFar}\n - ${nextError}` }, `${methodName}:`) } /** * Makes a call to a specific Splitwise endpoint * @param {Object} params - Arguments to be passed to the endpoint * @param {Function} callback - Will be called with the error as the first arg, * and the results as the second * @returns {Promise} An error or the response from the endpoint */ const wrapped = (params = {}, callback) => { const id = (idParamName && (String(params.id) || String(defaultIDs[idParamName]))) || '' const augmentedParams = R.assoc( 'id', id, params ) // Ensure the provided params are valid const allErrors = validate(augmentedParams, augmentedConstraints, {fullMessages: false}) if (allErrors) { const flattenedErrors = R.flatten(R.toPairs(allErrors).map(([argument, errors]) => { return errors.map(error => `\`${argument}\` ${error}`) })) const message = makeErrorMessage(flattenedErrors) return wrappedFail({message, callback}) } let url = `${endpoint}/${id}` // Get the access token let resultPromise = accessTokenPromise resultPromise.then(() => { logger({ message: `${methodName} - making request` }) }, () => { }) // Make the request if (verb === METHOD_VERBS.GET) { const queryParams = querystring.stringify(R.pick(paramNames, params)) if (queryParams) { url = `${url}?${queryParams}` } resultPromise = resultPromise.then(splitwiseRequest(url)) } else { resultPromise = resultPromise.then( splitwiseRequestWithData(url, verb, R.pick(paramNames, params)) ) } // Handle any errors resultPromise = resultPromise.then(result => { const errors = getSplitwiseErrors(R.pick(['error', 'errors'], result)) const message = makeErrorMessage(errors) || ( (result.success === false) && 'request was unsuccessful' ) if (message) { return wrappedFail({message, callback}) } logger({ message: `${methodName} - successfully made request` }) return result }, error => { const errors = getSplitwiseErrors(error) let message = makeErrorMessage(errors) || 'request was unsuccessful' return wrappedFail({ message, callback }) }) // Return data, not nested within an object if (propName) { resultPromise = resultPromise.then(val => R.propOr(val, propName, val)) } // Annoying edge case if (endpoint === 'create_expense') { resultPromise = resultPromise.then(val => val[0]) } // Call the callback if it's given if (callback) { resultPromise.then( result => { callback(null, result) }, error => { callback(error, null) } ) } return resultPromise } // Assign the method's name far the sake of stack traces Object.defineProperty(wrapped, 'name', { value: methodName, writable: false }) return wrapped } return endpointMethodGenerator } /** * @class */ class Splitwise { constructor (options) { const consumerKey = options.consumerKey const consumerSecret = options.consumerSecret const accessToken = options.accessToken const defaultIDs = { groupID: options.group_id, userID: options.user_id, expenseID: options.expense_id, friendID: options.friend_id } const logger = getLogger(options.logger, options.logLevel) if (!consumerKey || !consumerSecret) { const message = 'both a consumer key, and a consumer secret must be provided' logger({ level: LOG_LEVELS.ERROR, message }) throw new Error(message) } const oauth2 = new OAuth2( consumerKey, consumerSecret, 'https://secure.splitwise.com/', null, 'oauth/token', null ) const accessTokenPromise = (() => { if (accessToken) { logger({ message: 'using provided access token' }) return Promise.resolve(accessToken) } else { logger({ message: 'making request for access token' }) return getAccessTokenPromise(logger, oauth2) } })() const generateEndpointMethod = getEndpointMethodGenerator( logger, accessTokenPromise, defaultIDs, oauth2 ) // Each of the provided methods is generated from an element in METHODS // and added as an instance method R.values(METHODS).forEach(method => { this[method.methodName] = generateEndpointMethod(method) }) this.getAccessToken = () => accessTokenPromise } // Bonus utility method for easily making transactions from one person to one person createDebt ({ from, to, amount, description, group_id }) { return this.createExpense({ description, group_id, payment: false, cost: amount, users: [ { user_id: from, paid_share: amount }, { user_id: to, owed_share: amount } ] }) } } /** * We don't want a class to be visible in the public API, so it's hidden in this factory method */ return (opts) => new Splitwise(opts) }())