diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index c97bfbfc675d..d2ce71a9d39b 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -138,9 +138,11 @@ class MoneyRequestConfirmationList extends Component { * @returns {Array} */ getParticipantsWithAmount(participants) { + const iouAmount = IOUUtils.calculateAmount(participants, this.props.iouAmount, this.props.iou.selectedCurrencyCode); + return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( participants, - this.props.numberFormat(IOUUtils.calculateAmount(participants, this.props.iouAmount) / 100, { + this.props.numberFormat(iouAmount / 100, { style: 'currency', currency: this.props.iou.selectedCurrencyCode, }), @@ -172,9 +174,10 @@ class MoneyRequestConfirmationList extends Component { const formattedUnselectedParticipants = this.getParticipantsWithoutAmount(unselectedParticipants); const formattedParticipants = _.union(formattedSelectedParticipants, formattedUnselectedParticipants); + const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants, this.props.iouAmount, this.props.iou.selectedCurrencyCode, true); const formattedMyPersonalDetails = OptionsListUtils.getIOUConfirmationOptionsFromMyPersonalDetail( this.props.currentUserPersonalDetails, - this.props.numberFormat(IOUUtils.calculateAmount(selectedParticipants, this.props.iouAmount, true) / 100, { + this.props.numberFormat(myIOUAmount / 100, { style: 'currency', currency: this.props.iou.selectedCurrencyCode, }), diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js index b4e8f1ff46ce..1151f9c77225 100644 --- a/src/libs/IOUUtils.js +++ b/src/libs/IOUUtils.js @@ -1,31 +1,75 @@ import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; + +let currencyList = {}; +Onyx.connect({ + key: ONYXKEYS.CURRENCY_LIST, + callback: (val) => { + if (_.isEmpty(val)) { + return; + } + + currencyList = val; + }, +}); + +/** + * Returns the number of digits after the decimal separator for a specific currency. + * For currencies that have decimal places > 2, floor to 2 instead: + * https://github.com/Expensify/App/issues/15878#issuecomment-1496291464 + * + * @param {String} currency - IOU currency + * @returns {Number} + */ +function getCurrencyDecimals(currency = CONST.CURRENCY.USD) { + const decimals = lodashGet(currencyList, [currency, 'decimals']); + return _.isUndefined(decimals) ? 2 : Math.min(decimals, 2); +} + +/** + * Returns the currency's minor unit quantity + * e.g. Cent in USD + * + * @param {String} currency - IOU currency + * @returns {Number} + */ +function getCurrencyUnit(currency = CONST.CURRENCY.USD) { + return 10 ** getCurrencyDecimals(currency); +} /** * Calculates the amount per user given a list of participants * @param {Array} participants - List of logins for the participants in the chat. It should not include the current user's login. * @param {Number} total - IOU total amount + * @param {String} currency - IOU currency * @param {Boolean} isDefaultUser - Whether we are calculating the amount for the current user * @returns {Number} */ -function calculateAmount(participants, total, isDefaultUser = false) { +function calculateAmount(participants, total, currency, isDefaultUser = false) { // Convert to cents before working with iouAmount to avoid // javascript subtraction with decimal problem -- when dealing with decimals, // because they are encoded as IEEE 754 floating point numbers, some of the decimal // numbers cannot be represented with perfect accuracy. - // Cents is temporary and there must be support for other currencies in the future - const iouAmount = Math.round(parseFloat(total * 100)); + // Currencies that do not have minor units (i.e. no decimal place) are also supported. + // https://github.com/Expensify/App/issues/15878 + const currencyUnit = getCurrencyUnit(currency); + const iouAmount = Math.round(parseFloat(total * currencyUnit)); + const totalParticipants = participants.length + 1; const amountPerPerson = Math.round(iouAmount / totalParticipants); - if (!isDefaultUser) { - return amountPerPerson; - } + let finalAmount = amountPerPerson; - const sumAmount = amountPerPerson * totalParticipants; - const difference = iouAmount - sumAmount; + if (isDefaultUser) { + const sumAmount = amountPerPerson * totalParticipants; + const difference = iouAmount - sumAmount; + finalAmount = iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + } - return iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + return (finalAmount * 100) / currencyUnit; } /** @@ -139,4 +183,6 @@ export { updateIOUOwnerAndTotal, getIOUReportActions, isIOUReportPendingCurrencyConversion, + getCurrencyUnit, + getCurrencyDecimals, }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index e73751dc5959..ed8c90cb8193 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -311,8 +311,8 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment ]; // Loop through participants creating individual chats, iouReports and reportActionIDs as needed - const splitAmount = IOUUtils.calculateAmount(participants, amount); - const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, true)}]; + const splitAmount = IOUUtils.calculateAmount(participants, amount, currency, false); + const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, currency, true)}]; const hasMultipleParticipants = participants.length > 1; _.each(participants, (participant) => { diff --git a/tests/unit/IOUUtilsTest.js b/tests/unit/IOUUtilsTest.js index 989b33a42359..4ae4f3c5bc4b 100644 --- a/tests/unit/IOUUtilsTest.js +++ b/tests/unit/IOUUtilsTest.js @@ -1,6 +1,10 @@ +import Onyx from 'react-native-onyx'; import CONST from '../../src/CONST'; import * as IOUUtils from '../../src/libs/IOUUtils'; import * as ReportUtils from '../../src/libs/ReportUtils'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import currencyList from './currencyList.json'; let iouReport; let reportActions; @@ -38,27 +42,37 @@ function cancelMoneyRequest(moneyRequestAction, {isOnline} = {}) { ); } -beforeEach(() => { - reportActions = []; - const chatReportID = ReportUtils.generateReportID(); - const amount = 1000; - const currency = 'USD'; - - iouReport = ReportUtils.buildOptimisticIOUReport( - ownerEmail, - managerEmail, - amount, - chatReportID, - currency, - CONST.LOCALES.EN, - ); - - // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD - // All requests in the tests are assumed to be offline, unless isOnline is specified - createIOUReportAction('create', amount, currency, {IOUTransactionID: '', isOnline: true}); -}); +function initCurrencyList() { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + return waitForPromisesToResolve(); +} describe('isIOUReportPendingCurrencyConversion', () => { + beforeEach(() => { + reportActions = []; + const chatReportID = ReportUtils.generateReportID(); + const amount = 1000; + const currency = 'USD'; + + iouReport = ReportUtils.buildOptimisticIOUReport( + ownerEmail, + managerEmail, + amount, + chatReportID, + currency, + CONST.LOCALES.EN, + ); + + // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD + // All requests in the tests are assumed to be offline, unless isOnline is specified + createIOUReportAction('create', amount, currency, {IOUTransactionID: '', isOnline: true}); + }); + test('Requesting money offline in a different currency will show the pending conversion message', () => { // Request money offline in AED createIOUReportAction('create', 100, 'AED'); @@ -132,3 +146,57 @@ describe('isIOUReportPendingCurrencyConversion', () => { }); }); +describe('getCurrencyDecimals', () => { + beforeAll(() => initCurrencyList()); + test('Currency decimals smaller than or equal 2', () => { + expect(IOUUtils.getCurrencyDecimals('JPY')).toBe(0); + expect(IOUUtils.getCurrencyDecimals('USD')).toBe(2); + }); + + test('Currency decimals larger than 2 should return 2', () => { + // Actual: 3 + expect(IOUUtils.getCurrencyDecimals('LYD')).toBe(2); + + // Actual: 4 + expect(IOUUtils.getCurrencyDecimals('UYW')).toBe(2); + }); +}); + +describe('getCurrencyUnit', () => { + beforeAll(() => initCurrencyList()); + test('Currency with decimals smaller than or equal 2', () => { + expect(IOUUtils.getCurrencyUnit('JPY')).toBe(1); + expect(IOUUtils.getCurrencyUnit('USD')).toBe(100); + }); + + test('Currency with decimals larger than 2 should be floor to 2', () => { + expect(IOUUtils.getCurrencyUnit('LYD')).toBe(100); + }); +}); + +describe('calculateAmount', () => { + beforeAll(() => initCurrencyList()); + test('103 JPY split among 3 participants including the default user should be [35, 34, 34]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 103, 'JPY', true)).toBe(3500); + expect(IOUUtils.calculateAmount(participants, 103, 'JPY')).toBe(3400); + }); + + test('10 AFN split among 4 participants including the default user should be [1, 3, 3, 3]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com', 'suestorm@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 10, 'AFN', true)).toBe(100); + expect(IOUUtils.calculateAmount(participants, 10, 'AFN')).toBe(300); + }); + + test('10 BHD split among 3 participants including the default user should be [334, 333, 333]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 10, 'BHD', true)).toBe(334); + expect(IOUUtils.calculateAmount(participants, 10, 'BHD')).toBe(333); + }); + + test('0.02 USD split among 4 participants including the default user should be [-1, 1, 1, 1]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com', 'suestorm@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 0.02, 'USD', true)).toBe(-1); + expect(IOUUtils.calculateAmount(participants, 0.02, 'USD')).toBe(1); + }); +}); diff --git a/tests/unit/currencyList.json b/tests/unit/currencyList.json index 740b3caf2b28..c6eda7bdd766 100644 --- a/tests/unit/currencyList.json +++ b/tests/unit/currencyList.json @@ -7,11 +7,13 @@ "AFN": { "symbol": "Af", "name": "Afghan Afghani", + "decimals": 0, "ISO4217": "971" }, "ALL": { "symbol": "ALL", "name": "Albanian Lek", + "decimals": 0, "ISO4217": "008" }, "AMD": { @@ -123,6 +125,7 @@ "BYR": { "symbol": "BR", "name": "Belarus Ruble", + "decimals": 0, "retired": true, "retirementDate": "2016-07-01", "ISO4217": "974" @@ -330,11 +333,13 @@ "IQD": { "symbol": "IQD", "name": "Iraqi Dinar", + "decimals": 0, "ISO4217": "368" }, "IRR": { "symbol": "﷼", "name": "Iran Rial", + "decimals": 0, "ISO4217": "364" }, "ISK": { @@ -377,16 +382,19 @@ "KMF": { "symbol": "CF", "name": "Comoros Franc", + "decimals": 0, "ISO4217": "174" }, "KPW": { "symbol": "KP₩", "name": "North Korean Won", + "decimals": 0, "ISO4217": "408" }, "KRW": { "symbol": "₩", "name": "Korean Won", + "decimals": 0, "ISO4217": "410" }, "KWD": { @@ -407,11 +415,13 @@ "LAK": { "symbol": "₭", "name": "Lao Kip", + "decimals": 0, "ISO4217": "418" }, "LBP": { "symbol": "LBP", "name": "Lebanese Pound", + "decimals": 0, "ISO4217": "422" }, "LKR": { @@ -460,6 +470,7 @@ "MGA": { "symbol": "MGA", "name": "Malagasy Ariary", + "decimals": 0, "ISO4217": "969" }, "MKD": { @@ -470,6 +481,7 @@ "MMK": { "symbol": "Ks", "name": "Myanmar Kyat", + "decimals": 0, "ISO4217": "104" }, "MNT": { @@ -594,6 +606,7 @@ "PYG": { "symbol": "₲", "name": "Paraguayan Guarani", + "decimals": 0, "ISO4217": "600" }, "QAR": { @@ -609,6 +622,7 @@ "RSD": { "symbol": "РСД", "name": "Serbian Dinar", + "decimals": 0, "ISO4217": "941" }, "RUB": { @@ -660,11 +674,13 @@ "SLL": { "symbol": "Le", "name": "Sierra Leone Leone", + "decimals": 0, "ISO4217": "694" }, "SOS": { "symbol": "So.", "name": "Somali Shilling", + "decimals": 0, "ISO4217": "706" }, "SRD": { @@ -675,6 +691,7 @@ "STD": { "symbol": "Db", "name": "Sao Tome Dobra", + "decimals": 0, "retired": true, "retirementDate": "2018-07-11", "ISO4217": "678" @@ -692,6 +709,7 @@ "SYP": { "symbol": "SYP", "name": "Syrian Pound", + "decimals": 0, "ISO4217": "760" }, "SZL": { @@ -798,6 +816,7 @@ "VUV": { "symbol": "Vt", "name": "Vanuatu Vatu", + "decimals": 0, "ISO4217": "548" }, "WST": { @@ -830,7 +849,8 @@ }, "YER": { "symbol": "YER", - "name": "Yemen Riyal", + "name": "Yemen Riyal", + "decimals": 0, "ISO4217": "886" }, "ZAR": { @@ -841,6 +861,7 @@ "ZMK": { "symbol": "ZK", "name": "Zambian Kwacha", + "decimals": 0, "retired": true, "retirementDate": "2013-01-01", "ISO4217": "894" @@ -852,4 +873,3 @@ "ISO4217": "967" } } - \ No newline at end of file