Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add convert method #17

Merged
merged 18 commits into from
May 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": ["standard", "prettier", "prettier/standard"],
"env": {
"es6": true
"es6": true,
"browser": true
},
"rules": {
"no-var": "error",
Expand Down
2 changes: 2 additions & 0 deletions src/dinero-polyfilled.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import 'core-js/fn/array/find-index'
import 'core-js/fn/array/find'
import 'core-js/fn/array/keys'
import 'core-js/fn/object/assign'
import 'core-js/fn/object/entries'
import 'core-js/fn/number/is-integer'
import 'core-js/fn/math/sign'
import 'core-js/fn/promise'

import Dinero from './dinero'
export default Dinero
110 changes: 110 additions & 0 deletions src/dinero.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Defaults, Globals } from './services/settings'
import Format from './services/format'
import Calculator from './services/calculator'
import CurrencyConverter from './services/currency-converter'
import {
assert,
assertPercentage,
assertValidRatios,
assertInteger
} from './services/assert'
import { isUndefined } from './services/helpers'

const calculator = Calculator()

Expand Down Expand Up @@ -57,6 +59,11 @@ const Dinero = options => {
globalFormatRoundingMode
} = Dinero

const globalExchangeRatesApi = Object.assign(
{},
Dinero.globalExchangeRatesApi
)

/**
* Uses ES5 function notation so `this` can be passed through call, apply and bind
* @ignore
Expand Down Expand Up @@ -309,6 +316,109 @@ const Dinero = options => {

return shares
},
/**
* Returns a Promise containing a new Dinero object converted to another currency.
*
* You must provide your own API to retrieve exchange rates. This method won't work if you don't set either {@link Globals global API parameters}, or local ones for your instance.
*
* Here are some exchange rates APIs you can use:
*
* * [Fixer](https://fixer.io)
* * [Open Exchange Rates](https://openexchangerates.org)
* * [Coinbase](https://api.coinbase.com/v2/exchange-rates)
* * More [foreign](https://github.com/toddmotto/public-apis#currency-exchange) and [crypto](https://github.com/toddmotto/public-apis#cryptocurrency) exchange rates APIs.
*
* You will need to specify at least:
*
* * a **destination currency**: the currency in which you want to convert your Dinero object. You can specify it with `currency`.
* * an **endpoint**: the API URL to query exchange rates, with parameters. You can specify it with `options.endpoint`.
* * a **property path**: the path to access the wanted rate in your API's JSON response. For example, with a response of:
* ```json
* {
* "data": {
* "base": "USD",
* "destination": "EUR",
* "rate": "0.827728919"
* }
* }
* ```
* Then the property path is `'data.rate'`. You can specify it with `options.propertyPath`.
*
* The base currency (the currency of your Dinero object) and the destination currency can be used as "merge tags" with the mustache syntax, respectively `{{from}}` and `{{to}}`.
* You can use these tags to refer to these values in `options.endpoint` and `options.propertyPath`.
*
* For example, if you need to specify the base currency as a query parameter, you can do the following:
*
* ```js
* {
* endpoint: 'https://yourexchangerates.api/latest?base={{from}}'
* }
* ```
*
* @param {String} currency - The destination currency, expressed as an {@link https://en.wikipedia.org/wiki/ISO_4217#Active_codes ISO 4217 currency code}.
* @param {String} options.endpoint - The API endpoint to retrieve exchange rates.
* @param {String} options.propertyPath - The property path to the rate.
* @param {Object} [options.headers] - The HTTP headers to provide, if needed.
* @param {String} [options.roundingMode='HALF_EVEN'] - The rounding mode to use: `'HALF_ODD'`, `'HALF_EVEN'`, `'HALF_UP'`, `'HALF_DOWN'`, `'HALF_TOWARDS_ZERO'` or `'HALF_AWAY_FROM_ZERO'`.
*
* @example
* // your global API parameters
* Dinero.globalExchangeRatesApi = { ... }
*
* // returns a Promise containing a Dinero object with the destination currency
* // and the initial amount converted to the new currency.
* Dinero({ amount: 500 }).convert('EUR')
* @example
* // returns a Promise containing a Dinero object,
* // with specific API parameters and rounding mode for this specific instance.
* Dinero({ amount: 500 })
* .convert('XBT', {
* endpoint: 'https://yourexchangerates.api/latest?base={{from}}',
* propertyPath: 'data.rates.{{to}}',
* headers: {
* 'user-key': 'xxxxxxxxx'
* },
* roundingMode: 'HALF_UP'
* })
* @example
* // usage with Promise.prototype.then and Promise.prototype.catch
* Dinero({ amount: 500 })
* .convert('EUR')
* .then(dinero => {
* dinero.getCurrency() // returns 'EUR'
* })
* .catch(err => {
* // handle errors
* })
* @example
* // usage with async/await
* (async () => {
* const price = await Dinero({ amount: 500 }).convert('EUR')
* price.getCurrency() // returns 'EUR'
* })()
*
* @return {Promise}
*/
convert(currency, options) {
options = Object.assign({}, globalExchangeRatesApi, options)
return CurrencyConverter(options)
.getExchangeRate(this.getCurrency(), currency)
.then(rate => {
assert(
!isUndefined(rate),
new TypeError(
`No rate was found for the destination currency "${currency}".`
)
)
return create.call(this, {
amount: calculator.round(
calculator.multiply(this.getAmount(), parseFloat(rate)),
options.roundingMode
),
currency
})
})
},
/**
* Checks whether the value represented by this object equals to the other.
*
Expand Down
29 changes: 29 additions & 0 deletions src/services/currency-converter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getJSON, flattenObject } from './helpers'

export default function CurrencyConverter(options) {
/* istanbul ignore next */
const mergeTags = (string = '', tags) => {
for (const tag in tags) {
string = string.replace(`{{${tag}}}`, tags[tag])
}
return string
}

return {
/**
* Returns the exchange rate.
* @param {String} from - The base currency.
* @param {String} to - The destination currency.
* @return {Promise}
* @ignore
*/
getExchangeRate(from, to) {
return getJSON(mergeTags(options.endpoint, { from, to }), {
headers: options.headers
}).then(
data =>
flattenObject(data)[mergeTags(options.propertyPath, { from, to })]
)
}
}
}
83 changes: 83 additions & 0 deletions src/services/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,86 @@ export function countFractionDigits(number = 0) {
export function isHalf(number) {
return Math.abs(number) % 1 === 0.5
}

/**
* Fetches a JSON resource.
* @ignore
*
* @param {String} url - The resource to fetch.
* @param {Object} [options.headers] - The headers to pass.
*
* @throws {Error} If `request.status` is lesser than 200 or greater or equal to 400.
* @throws {Error} If network fails.
*
* @return {JSON}
*/
export function getJSON(url, options = {}) {
return new Promise((resolve, reject) => {
const request = Object.assign(new XMLHttpRequest(), {
onreadystatechange() {
if (request.readyState === 4) {
if (request.status >= 200 && request.status < 400)
resolve(JSON.parse(request.responseText))
else reject(new Error(request.statusText))
}
},
onerror() {
reject(new Error('Network error'))
}
})

request.open('GET', url, true)
setXHRHeaders(request, options.headers)
request.send()
})
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if there is an explicit browser-support contract, but any reason not to use fetch here? Seems like any browser that supports Object#assign would also support fetch.

Copy link
Collaborator Author

@sarahdayan sarahdayan Apr 29, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to be backward-compatible at least down to IE11 (I shipped polyfilled versions today in v1.2.0 as I realized there were some holes). I picked core-js for polyfills (the same as Babel uses), and unfortunately, it doesn't provide one for Fetch.

I want to avoid adding dependencies for each feature, so let's wait for this one and refactor later when it's time to drop IE altogether.


/**
* Returns an XHR object with attached headers.
* @ignore
*
* @param {XMLHttpRequest} xhr - The XHR request to set headers to.
* @param {Object} headers - The headers to set.
*
* @return {XMLHttpRequest}
*/
export function setXHRHeaders(xhr, headers = {}) {
for (const header in headers) xhr.setRequestHeader(header, headers[header])
return xhr
}

/**
* Returns whether a value is undefined.
* @ignore
*
* @param {} value - The value to test.
*
* @return {Boolean}
*/
export function isUndefined(value) {
return typeof value === 'undefined'
}

/**
* Returns an object flattened to one level deep.
* @ignore
*
* @param {Object} object - The object to flatten.
* @param {String} separator - The separator to use between flattened nodes.
*
* @return {Object}
*/
export function flattenObject(object, separator = '.') {
const finalObject = {}
Object.entries(object).forEach(item => {
if (typeof item[1] === 'object') {
const flatObject = flattenObject(item[1])
Object.entries(flatObject).forEach(node => {
finalObject[item[0] + separator + node[0]] = node[1]
})
} else {
finalObject[item[0]] = item[1]
}
})
return finalObject
}
19 changes: 18 additions & 1 deletion src/services/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,33 @@ export const Defaults = {
* @property {String} globalFormat - The global format for new Dinero objects (see {@link module:Dinero~toFormat toFormat} for format).
* @property {String} globalRoundingMode - The global rounding mode for new Dinero objects (see {@link module:Dinero~multiply multiply} or {@link module:Dinero~divide divide} for format).
* @property {String} globalFormatRoundingMode - The global rounding mode to format new Dinero objects (see {@link module:Dinero~toFormat toFormat} or {@link module:Dinero~toRoundedUnit toRoundedUnit} for format).
* @property {String} globalExchangeRatesApi.endpoint - The global exchange rates API endpoint for new Dinero objects (see {@link module:Dinero~convert convert} for format).
* @property {String} globalExchangeRatesApi.propertyPath - The global exchange rates API property path for new Dinero objects (see {@link module:Dinero~convert convert} for format).
* @property {Object} globalExchangeRatesApi.headers - The global exchange rates API headers for new Dinero objects (see {@link module:Dinero~convert convert} for format).
*
* @example
* // Will set locale to 'fr-FR' for all Dinero objects.
* Dinero.globalLocale = 'fr-FR'
* @example
* // Will set global exchange rates API parameters for all Dinero objects.
* Dinero.globalExchangeRatesApi = {
* endpoint: 'https://yourexchangerates.api/latest?base={{from}}',
* propertyPath: 'data.rates.{{to}}',
* headers: {
* 'user-key': 'xxxxxxxxx'
* }
* }
*
* @type {Object}
*/
export const Globals = {
globalLocale: 'en-US',
globalFormat: '$0,0.00',
globalRoundingMode: 'HALF_EVEN',
globalFormatRoundingMode: 'HALF_AWAY_FROM_ZERO'
globalFormatRoundingMode: 'HALF_AWAY_FROM_ZERO',
globalExchangeRatesApi: {
endpoint: undefined,
headers: undefined,
propertyPath: undefined
}
}
40 changes: 40 additions & 0 deletions test/unit/currency-converter.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import CurrencyConverter from '../../src/services/currency-converter'
import { getJSON } from '../../src/services/helpers'

jest.mock('../../src/services/helpers', () =>
Object.assign(require.requireActual('../../src/services/helpers'), {
getJSON: jest.fn()
})
)

const options = {
endpoint: 'https://yourexchangerates.api/latest?base={{from}}',
propertyPath: 'rates.{{to}}',
headers: {
'user-key': 'xxxxxxxxx'
},
roundingMode: 'HALF_UP'
}

describe('CurrencyConverter', () => {
describe('#getExchangeRate()', () => {
test('should return a rate as a number when input and output currencies are valid', async () => {
getJSON.mockResolvedValue({
base: 'USD',
date: '2018-03-31',
rates: {
EUR: 0.81162
}
})
await expect(
CurrencyConverter(options).getExchangeRate('USD', 'EUR')
).resolves.toEqual(0.81162)
})
test('should throw when API returns an error', async () => {
getJSON.mockRejectedValue(new Error())
await expect(
CurrencyConverter(options).getExchangeRate('USD', 'EUR')
).rejects.toThrow()
})
})
})
37 changes: 36 additions & 1 deletion test/unit/dinero.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import Dinero from '../../src/dinero'
import { getJSON } from '../../src/services/helpers'

jest.mock('../../src/services/helpers', () =>
Object.assign(require.requireActual('../../src/services/helpers'), {
getJSON: jest.fn()
})
)

describe('Dinero', () => {
describe('instantiation', () => {
Expand Down Expand Up @@ -198,7 +205,35 @@ describe('Dinero', () => {
expect(() => Dinero({ amount: 1003 }).allocate([])).toThrow()
})
})
describe('#equalsTo', () => {
describe('#convert', () => {
beforeEach(() => {
getJSON.mockResolvedValue({
base: 'USD',
date: '2018-03-31',
rates: {
EUR: 0.81162
}
})
})
test('should return a new converted Dinero object when base and destination currencies are valid', async () => {
const res = await Dinero({ amount: 500 }).convert('EUR', {
endpoint: 'https://yourexchangerates.api/latest?base={{from}}',
propertyPath: 'rates.{{to}}',
headers: {
'user-key': 'xxxxxxxxx'
},
roundingMode: 'HALF_UP'
})
expect(res.toObject()).toMatchObject({
amount: 406,
currency: 'EUR'
})
})
test('should throw when destination currency is not valid', async () => {
await expect(Dinero({ amount: 500 }).convert('EURO')).rejects.toThrow()
})
})
describe('#equalsTo()', () => {
test('should return true when both amount and currencies are equal', () => {
expect(
Dinero({ amount: 500, currency: 'EUR' }).equalsTo(
Expand Down
Loading