diff --git a/jest/setup.js b/jest/setup.js index 2c8ca9091a7c..407059251579 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -9,9 +9,10 @@ reanimatedJestUtils.setUpTests(); // https://reactnavigation.org/docs/testing/#mocking-native-modules jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); -// We have to mock the SQLiteStorage provider because it uses the native module SQLiteStorage, which is not available in jest. -// Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project -jest.mock('react-native-onyx/lib/storage/providers/SQLiteStorage', () => require('react-native-onyx/lib/storage/__mocks__')); +// Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. +// Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, +// and we only want to mock the storage layer, not the whole Onyx module. +jest.mock('react-native-onyx/lib/storage', () => require('react-native-onyx/lib/storage/__mocks__')); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params) => { diff --git a/package-lock.json b/package-lock.json index a56a1723b501..5ee55af932e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,11 @@ "license": "MIT", "dependencies": { "@expensify/react-native-web": "0.18.15", - "@formatjs/intl-getcanonicallocales": "^1.5.8", - "@formatjs/intl-locale": "^2.4.21", - "@formatjs/intl-numberformat": "^6.2.5", - "@formatjs/intl-pluralrules": "^4.0.13", + "@formatjs/intl-getcanonicallocales": "^2.2.0", + "@formatjs/intl-listformat": "^7.2.2", + "@formatjs/intl-locale": "^3.3.0", + "@formatjs/intl-numberformat": "^8.5.0", + "@formatjs/intl-pluralrules": "^5.2.2", "@gorhom/portal": "^1.0.14", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050", "@onfido/react-native-sdk": "7.4.0", @@ -76,7 +77,7 @@ "react-native-key-command": "^1.0.0", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "^1.0.41", + "react-native-onyx": "1.0.41", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", @@ -2474,58 +2475,77 @@ "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { - "version": "1.11.4", - "license": "MIT", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.15.0.tgz", + "integrity": "sha512-7bAYAv0w4AIao9DNg0avfOLTCPE9woAgs6SpXuMq11IN3A+l+cq8ghczwqSZBM11myvPSJA7vLn72q0rJ0QK6Q==", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" + } + }, + "node_modules/@formatjs/intl-enumerator": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-enumerator/-/intl-enumerator-1.3.0.tgz", + "integrity": "sha512-q563xxoaQC6lu4VcDTsf/bbVtSDjXElbNGF5oguT3TpF3jdqxJgS4o1M+6qwjoubUYknLMfHM89OXU1rzhqSVQ==", "dependencies": { - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" + "tslib": "^2.4.0" } }, "node_modules/@formatjs/intl-getcanonicallocales": { - "version": "1.9.2", - "license": "MIT", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.2.0.tgz", + "integrity": "sha512-LvjCj2DFaD8NSHW5pGBOcvgTzn5bT9aj4iNY/ejwVWkpAowoy1B4bZVXAJXLda5zqPV16zw/IXhxseiSflO/4A==", "dependencies": { - "tslib": "^2.1.0" + "tslib": "^2.4.0" } }, - "node_modules/@formatjs/intl-locale": { - "version": "2.4.47", - "license": "MIT", + "node_modules/@formatjs/intl-listformat": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.2.2.tgz", + "integrity": "sha512-YIruRGwUrmgVOXjWi6VbwPcRNBkEfgK2DFjyyqopCmpfJ+39vnl46oLpVchErnuXs6kkARy5GcGaGV7xRsH4lw==", "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/intl-getcanonicallocales": "1.9.2", - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.2.25", - "license": "MIT", + "node_modules/@formatjs/intl-locale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-3.3.0.tgz", + "integrity": "sha512-mKfnuvPZqzrOBpwnWHXfmdp78oLe0fNHGevX/k7KMk9LqTEIL64qt1G26kRwvFs88NdAYrCJVVOKShTTt0Qw6Q==", "dependencies": { - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-enumerator": "1.3.0", + "@formatjs/intl-getcanonicallocales": "2.2.0", + "tslib": "^2.4.0" } }, - "node_modules/@formatjs/intl-numberformat": { - "version": "6.2.10", - "license": "MIT", + "node_modules/@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", "dependencies": { - "@formatjs/ecma402-abstract": "1.7.1", - "tslib": "^2.1.0" + "tslib": "^2.4.0" } }, - "node_modules/@formatjs/intl-numberformat/node_modules/@formatjs/ecma402-abstract": { - "version": "1.7.1", - "license": "MIT", + "node_modules/@formatjs/intl-numberformat": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-numberformat/-/intl-numberformat-8.5.0.tgz", + "integrity": "sha512-z6qseXkCBp5UpSmg7oUKcq/mzum6ZMmMWrSkcVerF2jW289yX7ElXVxD0Da3VwumSVhJUxxYeyYogBbTFQ/kLw==", "dependencies": { - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, "node_modules/@formatjs/intl-pluralrules": { - "version": "4.3.3", - "license": "MIT", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.2.tgz", + "integrity": "sha512-mEbnbRzsSCIYqaBmrmUlOsPu5MG6KfMcnzekPzUrUucX2dNiI1KWBGHK6IoXl5c8zx60L1NXJ6cSQ7akoc15SQ==", "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, "node_modules/@gar/promisify": { @@ -42839,53 +42859,77 @@ "version": "1.0.0" }, "@formatjs/ecma402-abstract": { - "version": "1.11.4", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.15.0.tgz", + "integrity": "sha512-7bAYAv0w4AIao9DNg0avfOLTCPE9woAgs6SpXuMq11IN3A+l+cq8ghczwqSZBM11myvPSJA7vLn72q0rJ0QK6Q==", "requires": { - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" + } + }, + "@formatjs/intl-enumerator": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-enumerator/-/intl-enumerator-1.3.0.tgz", + "integrity": "sha512-q563xxoaQC6lu4VcDTsf/bbVtSDjXElbNGF5oguT3TpF3jdqxJgS4o1M+6qwjoubUYknLMfHM89OXU1rzhqSVQ==", + "requires": { + "tslib": "^2.4.0" } }, "@formatjs/intl-getcanonicallocales": { - "version": "1.9.2", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.2.0.tgz", + "integrity": "sha512-LvjCj2DFaD8NSHW5pGBOcvgTzn5bT9aj4iNY/ejwVWkpAowoy1B4bZVXAJXLda5zqPV16zw/IXhxseiSflO/4A==", + "requires": { + "tslib": "^2.4.0" + } + }, + "@formatjs/intl-listformat": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.2.2.tgz", + "integrity": "sha512-YIruRGwUrmgVOXjWi6VbwPcRNBkEfgK2DFjyyqopCmpfJ+39vnl46oLpVchErnuXs6kkARy5GcGaGV7xRsH4lw==", "requires": { - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, "@formatjs/intl-locale": { - "version": "2.4.47", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-3.3.0.tgz", + "integrity": "sha512-mKfnuvPZqzrOBpwnWHXfmdp78oLe0fNHGevX/k7KMk9LqTEIL64qt1G26kRwvFs88NdAYrCJVVOKShTTt0Qw6Q==", "requires": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/intl-getcanonicallocales": "1.9.2", - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-enumerator": "1.3.0", + "@formatjs/intl-getcanonicallocales": "2.2.0", + "tslib": "^2.4.0" } }, "@formatjs/intl-localematcher": { - "version": "0.2.25", + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", "requires": { - "tslib": "^2.1.0" + "tslib": "^2.4.0" } }, "@formatjs/intl-numberformat": { - "version": "6.2.10", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-numberformat/-/intl-numberformat-8.5.0.tgz", + "integrity": "sha512-z6qseXkCBp5UpSmg7oUKcq/mzum6ZMmMWrSkcVerF2jW289yX7ElXVxD0Da3VwumSVhJUxxYeyYogBbTFQ/kLw==", "requires": { - "@formatjs/ecma402-abstract": "1.7.1", - "tslib": "^2.1.0" - }, - "dependencies": { - "@formatjs/ecma402-abstract": { - "version": "1.7.1", - "requires": { - "tslib": "^2.1.0" - } - } + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, "@formatjs/intl-pluralrules": { - "version": "4.3.3", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.2.tgz", + "integrity": "sha512-mEbnbRzsSCIYqaBmrmUlOsPu5MG6KfMcnzekPzUrUucX2dNiI1KWBGHK6IoXl5c8zx60L1NXJ6cSQ7akoc15SQ==", "requires": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" + "@formatjs/ecma402-abstract": "1.15.0", + "@formatjs/intl-localematcher": "0.2.32", + "tslib": "^2.4.0" } }, "@gar/promisify": { diff --git a/package.json b/package.json index 15e5737b0070..af888e04a59e 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,11 @@ }, "dependencies": { "@expensify/react-native-web": "0.18.15", - "@formatjs/intl-getcanonicallocales": "^1.5.8", - "@formatjs/intl-locale": "^2.4.21", - "@formatjs/intl-numberformat": "^6.2.5", - "@formatjs/intl-pluralrules": "^4.0.13", + "@formatjs/intl-getcanonicallocales": "^2.2.0", + "@formatjs/intl-listformat": "^7.2.2", + "@formatjs/intl-locale": "^3.3.0", + "@formatjs/intl-numberformat": "^8.5.0", + "@formatjs/intl-pluralrules": "^5.2.2", "@gorhom/portal": "^1.0.14", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050", "@onfido/react-native-sdk": "7.4.0", @@ -109,7 +110,7 @@ "react-native-key-command": "^1.0.0", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "^1.0.41", + "react-native-onyx": "1.0.41", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", diff --git a/src/CONST.js b/src/CONST.js index 5bee33d176d7..7e6946474997 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -391,6 +391,7 @@ const CONST = { DROP_NATIVE_ID: 'report-dropzone', ACTIVE_DROP_NATIVE_ID: 'report-dropzone', MAXIMUM_PARTICIPANTS: 8, + SPLIT_REPORTID: '-2', ACTIONS: { LIMIT: 50, TYPE: { diff --git a/src/components/CurrentWalletBalance.js b/src/components/CurrentWalletBalance.js index 008cfb295338..72f05d138939 100644 --- a/src/components/CurrentWalletBalance.js +++ b/src/components/CurrentWalletBalance.js @@ -6,6 +6,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import compose from '../libs/compose'; import ONYXKEYS from '../ONYXKEYS'; import Text from './Text'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; const propTypes = { /** The user's wallet account */ @@ -31,10 +32,7 @@ const defaultProps = { }; const CurrentWalletBalance = (props) => { - const formattedBalance = props.numberFormat( - props.userWallet.currentBalance / 100, // Divide by 100 because balance is in cents - {style: 'currency', currency: 'USD'}, - ); + const formattedBalance = CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance); return ( { name: ownerEmail, }; const cachedTotal = props.iouReport.total && props.iouReport.currency - ? props.numberFormat( - Math.abs(props.iouReport.total) / 100, - {style: 'currency', currency: props.iouReport.currency}, - ) : ''; + ? CurrencyUtils.convertToDisplayString(props.iouReport.total, props.iouReport.currency) + : ''; const avatarTooltip = [Str.removeSMSDomain(managerEmail), Str.removeSMSDomain(ownerEmail)]; const showContextMenu = (event) => { diff --git a/src/components/ReportTransaction.js b/src/components/ReportTransaction.js index fe728146e4fa..d3819da47a48 100644 --- a/src/components/ReportTransaction.js +++ b/src/components/ReportTransaction.js @@ -2,7 +2,6 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; import styles from '../styles/styles'; -import CONST from '../CONST'; import * as IOU from '../libs/actions/IOU'; import * as ReportActions from '../libs/actions/ReportActions'; import reportActionPropTypes from '../pages/home/report/reportActionPropTypes'; @@ -57,16 +56,7 @@ class ReportTransaction extends Component { render() { return ( { - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { - ReportActions.clearSendMoneyErrors(this.props.chatReportID, this.props.action.reportActionID); - } - if (this.props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - ReportActions.deleteOptimisticReportAction(this.props.chatReportID, this.props.action.reportActionID); - } else { - ReportActions.clearReportActionErrors(this.props.chatReportID, this.props.action.reportActionID); - } - }} + onClose={() => ReportActions.clearReportActionErrors(this.props.chatReportID, this.props.action)} pendingAction={this.props.action.pendingAction} errors={this.props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} diff --git a/src/components/TextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol.js index 1c50b62e5e21..122fa9f0dcbc 100644 --- a/src/components/TextInputWithCurrencySymbol.js +++ b/src/components/TextInputWithCurrencySymbol.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import AmountTextInput from './AmountTextInput'; import CurrencySymbolButton from './CurrencySymbolButton'; -import * as CurrencySymbolUtils from '../libs/CurrencySymbolUtils'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; const propTypes = { /** A ref to forward to amount text input */ @@ -23,9 +23,6 @@ const propTypes = { /** Placeholder value for amount text input */ placeholder: PropTypes.string.isRequired, - /** Preferred locale of the user */ - preferredLocale: PropTypes.string.isRequired, - /** Currency code of user's selected currency */ selectedCurrencyCode: PropTypes.string.isRequired, @@ -48,8 +45,8 @@ const defaultProps = { }; function TextInputWithCurrencySymbol(props) { - const currencySymbol = CurrencySymbolUtils.getLocalizedCurrencySymbol(props.preferredLocale, props.selectedCurrencyCode); - const isCurrencySymbolLTR = CurrencySymbolUtils.isCurrencySymbolLTR(props.preferredLocale, props.selectedCurrencyCode); + const currencySymbol = CurrencyUtils.getLocalizedCurrencySymbol(props.selectedCurrencyCode); + const isCurrencySymbolLTR = CurrencyUtils.isCurrencySymbolLTR(props.selectedCurrencyCode); const currencySymbolButton = ( (zipSampleFormat ? `e.g. ${zipSampleFormat}` : ''), description: 'Description', + with: 'with', }, attachmentPicker: { cameraPermissionRequired: 'Camera access', @@ -1225,6 +1226,7 @@ export default { }, }, report: { + genericCreateReportFailureMessage: 'Unexpected error creating this chat, please try again later', genericAddCommentFailureMessage: 'Unexpected error while posting the comment, please try again later', noActivityYet: 'No activity yet', }, diff --git a/src/languages/es.js b/src/languages/es.js index 001687c1df06..9be73683c9fc 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -132,6 +132,7 @@ export default { websiteExample: 'p. ej. https://www.expensify.com', zipCodeExampleFormat: ({zipSampleFormat}) => (zipSampleFormat ? `p. ej. ${zipSampleFormat}` : ''), description: 'Descripción', + with: 'con', }, attachmentPicker: { cameraPermissionRequired: 'Permiso para acceder a la cámara', @@ -1226,6 +1227,7 @@ export default { }, }, report: { + genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde', genericAddCommentFailureMessage: 'Error inesperado al agregar el comentario. Por favor, inténtalo más tarde', noActivityYet: 'Sin actividad todavía', }, diff --git a/src/libs/CurrencySymbolUtils.js b/src/libs/CurrencySymbolUtils.js deleted file mode 100644 index bbe37939fd17..000000000000 --- a/src/libs/CurrencySymbolUtils.js +++ /dev/null @@ -1,37 +0,0 @@ -import _ from 'underscore'; -import * as NumberFormatUtils from './NumberFormatUtils'; - -/** - * Get localized currency symbol for currency(ISO 4217) Code - * @param {String} preferredLocale - * @param {String} currencyCode - * @returns {String} - */ -function getLocalizedCurrencySymbol(preferredLocale, currencyCode) { - const parts = NumberFormatUtils.formatToParts(preferredLocale, 0, { - style: 'currency', - currency: currencyCode, - }); - return _.find(parts, part => part.type === 'currency').value; -} - -/** - * Whether the currency symbol is left-to-right. - * @param {String} preferredLocale - * @param {String} currencyCode - * @returns {Boolean} - */ -function isCurrencySymbolLTR(preferredLocale, currencyCode) { - const parts = NumberFormatUtils.formatToParts(preferredLocale, 0, { - style: 'currency', - currency: currencyCode, - }); - - // Currency is LTR when the first part is of currency type. - return parts[0].type === 'currency'; -} - -export { - getLocalizedCurrencySymbol, - isCurrencySymbolLTR, -}; diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.js new file mode 100644 index 000000000000..01146913149a --- /dev/null +++ b/src/libs/CurrencyUtils.js @@ -0,0 +1,133 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; +import CONST from '../CONST'; +import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; +import * as NumberFormatUtils from './NumberFormatUtils'; + +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); +} + +/** + * Get localized currency symbol for currency(ISO 4217) Code + * + * @param {String} currencyCode + * @returns {String} + */ +function getLocalizedCurrencySymbol(currencyCode) { + const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { + style: 'currency', + currency: currencyCode, + }); + return _.find(parts, part => part.type === 'currency').value; +} + +/** + * Whether the currency symbol is left-to-right. + * + * @param {String} currencyCode + * @returns {Boolean} + */ +function isCurrencySymbolLTR(currencyCode) { + const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { + style: 'currency', + currency: currencyCode, + }); + + // Currency is LTR when the first part is of currency type. + return parts[0].type === 'currency'; +} + +/** + * Takes an amount as a floating point number and converts it to an integer amount. + * For example, given [25, USD], return 2500. + * Given [25.50, USD] return 2550. + * Given [2500, JPY], return 2500. + * + * @note we do not currently support any currencies with more than two decimal places. Sorry Tunisia :( + * + * @param {String} currency + * @param {Number} amountAsFloat + * @returns {Number} + */ +function convertToSmallestUnit(currency, amountAsFloat) { + const currencyUnit = getCurrencyUnit(currency); + return Math.trunc(amountAsFloat * currencyUnit); +} + +/** + * Takes an amount as an integer and converts it to a floating point amount. + * For example, give [25, USD], return 0.25 + * Given [2550, USD], return 25.50 + * Given [2500, JPY], return 2500 + * + * @note we do not support any currencies with more than two decimal places. + * + * @param {String} currency + * @param {Number} amountAsInt + * @returns {Number} + */ +function convertToWholeUnit(currency, amountAsInt) { + const currencyUnit = getCurrencyUnit(currency); + return Math.trunc(amountAsInt) / currencyUnit; +} + +/** + * Given an amount in the smallest units of a currency, convert it to a string for display in the UI. + * + * @param {Number} amountInSmallestUnit – should be an integer. Anything after a decimal place will be dropped. + * @param {String} currency + * @returns {String} + */ +function convertToDisplayString(amountInSmallestUnit, currency = CONST.CURRENCY.USD) { + const currencyUnit = getCurrencyUnit(currency); + const convertedAmount = Math.trunc(amountInSmallestUnit) / currencyUnit; + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + }); +} + +export { + getCurrencyDecimals, + getCurrencyUnit, + getLocalizedCurrencySymbol, + isCurrencySymbolLTR, + convertToSmallestUnit, + convertToWholeUnit, + convertToDisplayString, +}; diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js index 1bfdb8f27f91..46528b3c8c90 100644 --- a/src/libs/IOUUtils.js +++ b/src/libs/IOUUtils.js @@ -1,75 +1,24 @@ 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 {Number} total - IOU total amount in the smallest units of the currency * @param {Boolean} isDefaultUser - Whether we are calculating the amount for the current user * @returns {Number} */ -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. - // 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)); - +function calculateAmount(participants, total, isDefaultUser = false) { const totalParticipants = participants.length + 1; - const amountPerPerson = Math.round(iouAmount / totalParticipants); - + const amountPerPerson = Math.round(total / totalParticipants); let finalAmount = amountPerPerson; - if (isDefaultUser) { const sumAmount = amountPerPerson * totalParticipants; - const difference = iouAmount - sumAmount; - finalAmount = iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + const difference = total - sumAmount; + finalAmount = total !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; } - - return (finalAmount * 100) / currencyUnit; + return finalAmount; } /** @@ -184,6 +133,4 @@ export { updateIOUOwnerAndTotal, getIOUReportActions, isIOUReportPendingCurrencyConversion, - getCurrencyUnit, - getCurrencyDecimals, }; diff --git a/src/libs/IntlPolyfill/index.js b/src/libs/IntlPolyfill/index.js index 17d07f32cfce..3925b98729a9 100644 --- a/src/libs/IntlPolyfill/index.js +++ b/src/libs/IntlPolyfill/index.js @@ -1,4 +1,3 @@ -import shouldPolyfill from './shouldPolyfill'; import polyfillNumberFormat from './polyfillNumberFormat'; /** @@ -6,10 +5,6 @@ import polyfillNumberFormat from './polyfillNumberFormat'; * This ensures that the currency data is consistent across platforms and browsers. */ export default function intlPolyfill() { - if (!shouldPolyfill()) { - return; - } - // Just need to polyfill Intl.NumberFormat for web based platforms polyfillNumberFormat(); } diff --git a/src/libs/IntlPolyfill/index.native.js b/src/libs/IntlPolyfill/index.native.js index b104d490e091..a628654fefea 100644 --- a/src/libs/IntlPolyfill/index.native.js +++ b/src/libs/IntlPolyfill/index.native.js @@ -1,17 +1,14 @@ -import shouldPolyfill from './shouldPolyfill'; import polyfillNumberFormat from './polyfillNumberFormat'; +import polyfillListFormat from './polyfillListFormat'; /** * Polyfill the Intl API, always performed for native devices. */ export default function polyfill() { - if (!shouldPolyfill()) { - return; - } - // Native devices require extra polyfills require('@formatjs/intl-getcanonicallocales/polyfill'); require('@formatjs/intl-locale/polyfill'); require('@formatjs/intl-pluralrules/polyfill'); polyfillNumberFormat(); + polyfillListFormat(); } diff --git a/src/libs/IntlPolyfill/polyfillListFormat.js b/src/libs/IntlPolyfill/polyfillListFormat.js new file mode 100644 index 000000000000..21bb26cadc4b --- /dev/null +++ b/src/libs/IntlPolyfill/polyfillListFormat.js @@ -0,0 +1,11 @@ +export default function () { + if (Intl && 'ListFormat' in Intl) { + return; + } + + require('@formatjs/intl-listformat/polyfill-force'); + + // Load en & es Locale data + require('@formatjs/intl-listformat/locale-data/en'); + require('@formatjs/intl-listformat/locale-data/es'); +} diff --git a/src/libs/IntlPolyfill/polyfillNumberFormat.js b/src/libs/IntlPolyfill/polyfillNumberFormat.js index 91bda9d6c368..67c4c775e228 100644 --- a/src/libs/IntlPolyfill/polyfillNumberFormat.js +++ b/src/libs/IntlPolyfill/polyfillNumberFormat.js @@ -1,13 +1,28 @@ +import CONST from '../../CONST'; + /** - * Common imports that are required for all platforms + * Check if the locale data is as expected on the device. + * Ensures that the currency data is consistent across devices. + * @returns {Boolean} */ +function hasOldCurrencyData() { + return ( + new Intl.NumberFormat(CONST.LOCALES.DEFAULT, { + style: CONST.POLYFILL_TEST.STYLE, + currency: CONST.POLYFILL_TEST.CURRENCY, + currencyDisplay: CONST.POLYFILL_TEST.FORMAT, + }).format(CONST.POLYFILL_TEST.SAMPLE_INPUT) !== CONST.POLYFILL_TEST.EXPECTED_OUTPUT + ); +} + +export default function () { + if (Intl && 'NumberFormat' in Intl && !hasOldCurrencyData()) { + return; + } -function polyfillNumberFormat() { require('@formatjs/intl-numberformat/polyfill-force'); // Load en & es Locale data require('@formatjs/intl-numberformat/locale-data/en'); require('@formatjs/intl-numberformat/locale-data/es'); } - -export default polyfillNumberFormat; diff --git a/src/libs/IntlPolyfill/shouldPolyfill.js b/src/libs/IntlPolyfill/shouldPolyfill.js deleted file mode 100644 index 0845056d9a72..000000000000 --- a/src/libs/IntlPolyfill/shouldPolyfill.js +++ /dev/null @@ -1,29 +0,0 @@ -import CONST from '../../CONST'; - -/** - * Check if the locale data is as expected on the device. - * Ensures that the currency data is consistent across devices. - * @returns {Boolean} - */ -function oldCurrencyData() { - return ( - new Intl.NumberFormat(CONST.LOCALES.DEFAULT, { - style: CONST.POLYFILL_TEST.STYLE, - currency: CONST.POLYFILL_TEST.CURRENCY, - currencyDisplay: CONST.POLYFILL_TEST.FORMAT, - }).format(CONST.POLYFILL_TEST.SAMPLE_INPUT) !== CONST.POLYFILL_TEST.EXPECTED_OUTPUT - ); -} - -/** - * Check for the existence of the Intl API and ensure results will - * be as expected. - * @returns {Boolean} - */ -export default function shouldPolyfill() { - return ( - typeof Intl === 'undefined' - || !('NumberFormat' in Intl) - || oldCurrencyData() - ); -} diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.js index 24eb2e060064..5956ab83c7ed 100644 --- a/src/libs/Localize/index.js +++ b/src/libs/Localize/index.js @@ -12,6 +12,24 @@ import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; // Listener when an update in Onyx happens so we use the updated locale when translating/localizing items. LocaleListener.connect(); +// Note: This has to be initialized inside a function and not at the top level of the file, because Intl is polyfilled, +// and if React Native executes this code upon import, then the polyfill will not be available yet and it will barf +let CONJUNCTION_LIST_FORMATS_FOR_LOCALES; +function init() { + CONJUNCTION_LIST_FORMATS_FOR_LOCALES = _.reduce(CONST.LOCALES, (memo, locale) => { + // This is not a supported locale, so we'll use ES_ES instead + if (locale === CONST.LOCALES.ES_ES_ONFIDO) { + // eslint-disable-next-line no-param-reassign + memo[locale] = new Intl.ListFormat(CONST.LOCALES.ES_ES, {style: 'long', type: 'conjunction'}); + return memo; + } + + // eslint-disable-next-line no-param-reassign + memo[locale] = new Intl.ListFormat(locale, {style: 'long', type: 'conjunction'}); + return memo; + }, {}); +} + /** * Return translated string for given locale and phrase * @@ -76,16 +94,11 @@ function translateLocal(phrase, variables) { * @return {String} */ function arrayToString(anArray) { - const and = translateLocal('common.and'); - let aString = ''; - if (_.size(anArray) === 1) { - aString = anArray[0]; - } else if (_.size(anArray) === 2) { - aString = anArray.join(` ${and} `); - } else if (_.size(anArray) > 2) { - aString = `${anArray.slice(0, -1).join(', ')} ${and} ${anArray.slice(-1)}`; + if (!CONJUNCTION_LIST_FORMATS_FOR_LOCALES) { + init(); } - return aString; + const listFormat = CONJUNCTION_LIST_FORMATS_FOR_LOCALES[BaseLocaleListener.getPreferredLocale()]; + return listFormat.format(anArray); } /** diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 2e9ea66f7fd4..b12fab7b6d99 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -272,6 +272,21 @@ function getLatestReportActionFromOnyxData(onyxData) { return _.last(sortedReportActions); } +/** + * Find the transaction associated with this reportAction, if one exists. + * + * @param {String} reportID + * @param {String} reportActionID + * @returns {String|null} + */ +function getLinkedTransactionID(reportID, reportActionID) { + const reportAction = lodashGet(allReportActions, [reportID, reportActionID]); + if (!reportAction || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return null; + } + return reportAction.originalMessage.IOUTransactionID; +} + export { getSortedReportActions, getLastVisibleAction, @@ -284,4 +299,5 @@ export { getSortedReportActionsForDisplay, getLastClosedReportAction, getLatestReportActionFromOnyxData, + getLinkedTransactionID, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 90c5ac9643f0..229a88cd8b36 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -21,6 +21,7 @@ import * as defaultAvatars from '../components/Icon/DefaultAvatars'; import isReportMessageAttachment from './isReportMessageAttachment'; import * as defaultWorkspaceAvatars from '../components/Icon/WorkspaceDefaultAvatars'; import * as LocalePhoneNumber from './LocalePhoneNumber'; +import * as CurrencyUtils from './CurrencyUtils'; let sessionEmail; Onyx.connect({ @@ -1020,21 +1021,15 @@ function buildOptimisticAddCommentReportAction(text, file) { * * @param {String} ownerEmail - Email of the person generating the IOU. * @param {String} userEmail - Email of the other person participating in the IOU. - * @param {Number} total - IOU amount in cents. + * @param {Number} total - IOU amount in the smallest unit of the currency. * @param {String} chatReportID - Report ID of the chat where the IOU is. * @param {String} currency - IOU currency. - * @param {String} locale - Locale where the IOU is created * @param {Boolean} isSendingMoney - If we send money the IOU should be created as settled * * @returns {Object} */ -function buildOptimisticIOUReport(ownerEmail, userEmail, total, chatReportID, currency, locale, isSendingMoney = false) { - const formattedTotal = NumberFormatUtils.format(locale, - total, { - style: 'currency', - currency, - }); - +function buildOptimisticIOUReport(ownerEmail, userEmail, total, chatReportID, currency, isSendingMoney = false) { + const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); return { // If we're sending money, hasOutstandingIOU should be false hasOutstandingIOU: !isSendingMoney, @@ -1116,15 +1111,13 @@ function getIOUReportActionMessage(type, total, participants, comment, currency, * @param {String} currency * @param {String} comment - User comment for the IOU. * @param {Array} participants - An array with participants details. + * @param {String} transactionID * @param {String} [paymentType] - Only required if the IOUReportAction type is 'pay'. Can be oneOf(elsewhere, payPal, Expensify). - * @param {String} [iouTransactionID] - Only required if the IOUReportAction type is 'delete'. Generates a randomID as default. - * @param {String} [iouReportID] - Only required if the IOUReportActions type is oneOf(delete, pay). Generates a randomID as default. + * @param {String} [iouReportID] - Only required if the IOUReportActions type is oneOf(decline, cancel, pay). Generates a randomID as default. * @param {Boolean} [isSettlingUp] - Whether we are settling up an IOU. - * * @returns {Object} */ -function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, paymentType = '', iouTransactionID = '', iouReportID = '', isSettlingUp = false) { - const IOUTransactionID = iouTransactionID || NumberUtils.rand64(); +function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, transactionID, paymentType = '', iouReportID = '', isSettlingUp = false) { const IOUReportID = iouReportID || generateReportID(); const parser = new ExpensiMark(); const commentText = getParsedComment(comment); @@ -1134,7 +1127,7 @@ function buildOptimisticIOUReportAction(type, amount, currency, comment, partici amount, comment: textForNewComment, currency, - IOUTransactionID, + IOUTransactionID: transactionID, IOUReportID, type, }; diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js new file mode 100644 index 000000000000..a0a2d4099e3a --- /dev/null +++ b/src/libs/TransactionUtils.js @@ -0,0 +1,43 @@ +import CONST from '../CONST'; +import DateUtils from './DateUtils'; +import * as NumberUtils from './NumberUtils'; + +/** + * Optimistically generate a transaction. + * + * @param {Number} amount – in cents + * @param {String} currency + * @param {String} reportID + * @param {String} [comment] + * @param {String} [source] + * @param {String} [originalTransactionID] + * @param {String} [merchant] + * @returns {Object} + */ +function buildOptimisticTransaction(amount, currency, reportID, comment = '', source = '', originalTransactionID = '', merchant = CONST.REPORT.TYPE.IOU) { + // transactionIDs are random, positive, 64-bit numeric strings. + // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) + const transactionID = NumberUtils.rand64(); + + const commentJSON = {comment}; + if (source) { + commentJSON.source = source; + } + if (originalTransactionID) { + commentJSON.originalTransactionID = originalTransactionID; + } + + return { + transactionID, + amount, + reportID, + comment: commentJSON, + merchant, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + +export default { + buildOptimisticTransaction, +}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 6dbd7ec6de3f..6fa895e19443 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -13,6 +13,7 @@ import * as ReportUtils from '../ReportUtils'; import * as IOUUtils from '../IOUUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import DateUtils from '../DateUtils'; +import TransactionUtils from '../TransactionUtils'; const chatReports = {}; const iouReports = {}; @@ -42,23 +43,11 @@ Onyx.connect({ }, }); -let preferredLocale = CONST.LOCALES.DEFAULT; -Onyx.connect({ - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - callback: (val) => { - if (!val) { - return; - } - - preferredLocale = val; - }, -}); - /** * Request money from another user * * @param {Object} report - * @param {Number} amount + * @param {Number} amount - always in the smallest unit of the currency * @param {String} currency * @param {String} recipientEmail * @param {Object} participant @@ -84,9 +73,32 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com currency, ); } else { - iouReport = ReportUtils.buildOptimisticIOUReport(recipientEmail, debtorEmail, amount, chatReport.reportID, currency, preferredLocale); + iouReport = ReportUtils.buildOptimisticIOUReport(recipientEmail, debtorEmail, amount, chatReport.reportID, currency); } + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, iouReport.reportID, comment); + const optimisticTransactionData = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: optimisticTransaction, + }; + const transactionSuccessData = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + pendingAction: null, + }, + }; + const transactionFailureData = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }; + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(recipientEmail); const optimisticReportAction = ReportUtils.buildOptimisticIOUReportAction( @@ -95,7 +107,7 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com currency, comment, [participant], - '', + optimisticTransaction.transactionID, '', iouReport.reportID, ); @@ -152,8 +164,6 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, value: { [optimisticReportAction.reportActionID]: { - ...optimisticReportAction, - pendingAction: null, errors: { [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), }, @@ -179,22 +189,27 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com errorFields: null, }, }; - chatReportFailureData.value.pendingFields = null; + chatReportFailureData.value.errorFields = { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }; // Then add an optimistic created action optimisticReportActionsData.value[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; reportActionsSuccessData.value[optimisticCreatedAction.reportActionID] = {pendingAction: null}; - reportActionsFailureData.value[optimisticCreatedAction.reportActionID] = {pendingAction: null}; } const optimisticData = [ optimisticChatReportData, optimisticIOUReportData, optimisticReportActionsData, + optimisticTransactionData, ]; const successData = [ reportActionsSuccessData, + transactionSuccessData, ]; if (!_.isEmpty(chatReportSuccessData)) { successData.push(chatReportSuccessData); @@ -203,6 +218,7 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com const failureData = [ chatReportFailureData, reportActionsFailureData, + transactionFailureData, ]; const parsedComment = ReportUtils.getParsedComment(comment); @@ -213,7 +229,7 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com comment: parsedComment, iouReportID: iouReport.reportID, chatReportID: chatReport.reportID, - transactionID: optimisticReportAction.originalMessage.IOUTransactionID, + transactionID: optimisticTransaction.transactionID, reportActionID: optimisticReportAction.reportActionID, createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : 0, }, {optimisticData, successData, failureData}); @@ -233,15 +249,14 @@ function requestMoney(report, amount, currency, recipientEmail, participant, com * ] * @param {Array} participants * @param {String} currentUserLogin - * @param {Number} amount + * @param {Number} amount - always in the smallest unit of the currency * @param {String} comment * @param {String} currency - * @param {String} locale * @param {String} existingGroupChatReportID * * @return {Object} */ -function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, locale, existingGroupChatReportID = '') { +function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, existingGroupChatReportID = '') { const currentUserEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); const participantLogins = _.map(participants, participant => OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase()); const existingGroupChatReport = existingGroupChatReportID @@ -249,14 +264,27 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment : ReportUtils.getChatByParticipants(participantLogins); const groupChatReport = existingGroupChatReport || ReportUtils.buildOptimisticChatReport(participantLogins); + // ReportID is -2 (aka "deleted") on the group transaction: https://github.com/Expensify/Auth/blob/3fa2698654cd4fbc30f9de38acfca3fbeb7842e4/auth/command/SplitTransaction.cpp#L24-L27 + const formattedParticipants = Localize.arrayToString([currentUserLogin, ..._.map(participants, participant => participant.login)]); + const groupTransaction = TransactionUtils.buildOptimisticTransaction( + amount, + currency, + CONST.REPORT.SPLIT_REPORTID, + comment, + '', + '', + `${Localize.translateLocal('iou.splitBill')} ${Localize.translateLocal('common.with')} ${formattedParticipants} [${DateUtils.getDBTime().slice(0, 10)}]`, + ); + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat const groupCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); const groupIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.SPLIT, - Math.round(amount * 100), + amount, currency, comment, participants, + groupTransaction.transactionID, ); groupChatReport.lastReadTime = DateUtils.getDBTime(); @@ -286,14 +314,14 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment [groupIOUReportAction.reportActionID]: groupIOUReportAction, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, + value: groupTransaction, + }, ]; const successData = [ - { - onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, - value: {pendingFields: {createChat: null}}, - }, { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, @@ -302,29 +330,61 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment [groupIOUReportAction.reportActionID]: {pendingAction: null}, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, + value: {pendingAction: null}, + }, ]; + if (!existingGroupChatReport) { + successData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, + value: {pendingFields: {createChat: null}}, + }); + } + const failureData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, value: { - pendingFields: {createChat: null}, + [groupIOUReportAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, }, }, { - onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, value: { - ...(existingGroupChatReport ? {} : {[groupCreatedReportAction.reportActionID]: {pendingAction: null}}), - [groupIOUReportAction.reportActionID]: {pendingAction: null}, + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, }, }, ]; + if (!existingGroupChatReport) { + failureData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, + value: { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }, + }); + } + // Loop through participants creating individual chats, iouReports and reportActionIDs as needed - const splitAmount = IOUUtils.calculateAmount(participants, amount, currency, false); - const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, currency, true)}]; + const splitAmount = IOUUtils.calculateAmount(participants, amount, false); + const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, true)}]; const hasMultipleParticipants = participants.length > 1; _.each(participants, (participant) => { @@ -348,18 +408,20 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment ); oneOnOneChatReport.hasOutstandingIOU = oneOnOneIOUReport.total !== 0; } else { - oneOnOneIOUReport = ReportUtils.buildOptimisticIOUReport( - currentUserEmail, - email, - splitAmount, - oneOnOneChatReport.reportID, - currency, - locale, - ); + oneOnOneIOUReport = ReportUtils.buildOptimisticIOUReport(currentUserEmail, email, splitAmount, oneOnOneChatReport.reportID, currency); oneOnOneChatReport.hasOutstandingIOU = true; oneOnOneChatReport.iouReportID = oneOnOneIOUReport.reportID; } + const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction( + splitAmount, + currency, + oneOnOneIOUReport.reportID, + comment, + CONST.IOU.MONEY_REQUEST_TYPE.SPLIT, + groupTransaction.transactionID, + ); + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat const oneOnOneCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); const oneOnOneIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( @@ -368,7 +430,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment currency, comment, [participant], - '', + oneOnOneTransaction.transactionID, '', oneOnOneIOUReport.reportID, ); @@ -399,14 +461,14 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment [oneOnOneIOUReportAction.reportActionID]: oneOnOneIOUReportAction, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, + value: oneOnOneTransaction, + }, ); successData.push( - { - onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, - value: {pendingFields: {createChat: null}}, - }, { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, @@ -418,31 +480,60 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment [oneOnOneIOUReportAction.reportActionID]: {pendingAction: null}, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, + value: {pendingAction: null}, + }, ); + if (!existingOneOnOneChatReport) { + successData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, + value: {pendingFields: {createChat: null}}, + }); + } + failureData.push( { onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, value: { - pendingFields: {createChat: null}, - hasOutstandingIOU: existingOneOnOneChatReport ? existingOneOnOneChatReport.hasOutstandingIOU : false, - iouReportID: existingOneOnOneChatReport ? existingOneOnOneChatReport.iouReportID : null, + [oneOnOneIOUReportAction.reportActionID]: { + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, }, }, { - onyxMethod: CONST.ONYX.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneOnOneChatReport.reportID}`, + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${oneOnOneTransaction.transactionID}`, value: { - ...(existingOneOnOneChatReport - ? {} - : {[oneOnOneCreatedReportAction.reportActionID]: {pendingAction: null}} - ), - [oneOnOneIOUReportAction.reportActionID]: {pendingAction: null}, + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, }, }, ); + if (!existingOneOnOneChatReport) { + failureData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.reportID}`, + value: { + hasOutstandingIOU: existingOneOnOneChatReport ? existingOneOnOneChatReport.hasOutstandingIOU : false, + iouReportID: existingOneOnOneChatReport ? existingOneOnOneChatReport.iouReportID : null, + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }, + }); + } + // Regardless of the number of participants, we always want to push the iouReport update to onyxData optimisticData.push({ // We want to use set in case we are creating the the optimistic chat. @@ -464,7 +555,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment amount: splitAmount, iouReportID: oneOnOneIOUReport.reportID, chatReportID: oneOnOneChatReport.reportID, - transactionID: oneOnOneIOUReportAction.originalMessage.IOUTransactionID, + transactionID: oneOnOneTransaction.transactionID, reportActionID: oneOnOneIOUReportAction.reportActionID, }; @@ -477,7 +568,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment const groupData = { chatReportID: groupChatReport.reportID, - transactionID: groupIOUReportAction.originalMessage.IOUTransactionID, + transactionID: groupTransaction.transactionID, reportActionID: groupIOUReportAction.reportActionID, }; @@ -495,19 +586,18 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment /** * @param {Array} participants * @param {String} currentUserLogin - * @param {Number} amount + * @param {Number} amount - always in smallest currency unit * @param {String} comment * @param {String} currency - * @param {String} locale * @param {String} existingGroupChatReportID */ -function splitBill(participants, currentUserLogin, amount, comment, currency, locale, existingGroupChatReportID = '') { - const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, locale, existingGroupChatReportID); +function splitBill(participants, currentUserLogin, amount, comment, currency, existingGroupChatReportID = '') { + const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, existingGroupChatReportID); const parsedComment = ReportUtils.getParsedComment(comment); API.write('SplitBill', { reportID: groupData.chatReportID, - amount: Math.round(amount * 100), + amount, splits: JSON.stringify(splits), currency, comment: parsedComment, @@ -522,18 +612,17 @@ function splitBill(participants, currentUserLogin, amount, comment, currency, lo /** * @param {Array} participants * @param {String} currentUserLogin - * @param {Number} amount + * @param {Number} amount - always in smallest currency unit * @param {String} comment * @param {String} currency - * @param {String} locale */ -function splitBillAndOpenReport(participants, currentUserLogin, amount, comment, currency, locale) { - const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, locale); +function splitBillAndOpenReport(participants, currentUserLogin, amount, comment, currency) { + const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency); const parsedComment = ReportUtils.getParsedComment(comment); API.write('SplitBillAndOpenReport', { reportID: groupData.chatReportID, - amount: Math.round(amount * 100), + amount, splits: JSON.stringify(splits), currency, comment: parsedComment, @@ -566,8 +655,8 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul moneyRequestAction.originalMessage.currency, Str.htmlDecode(moneyRequestAction.originalMessage.comment), [], - '', transactionID, + '', iouReportID, ); @@ -718,7 +807,14 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType chatReport = ReportUtils.buildOptimisticChatReport([recipientEmail]); isNewChat = true; } - const optimisticIOUReport = ReportUtils.buildOptimisticIOUReport(recipientEmail, managerEmail, amount, chatReport.reportID, currency, preferredLocale, true); + const optimisticIOUReport = ReportUtils.buildOptimisticIOUReport(recipientEmail, managerEmail, amount, chatReport.reportID, currency, true); + + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount * 100, currency, optimisticIOUReport.reportID, comment); + const optimisticTransactionData = { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: optimisticTransaction, + }; // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(recipientEmail); @@ -728,8 +824,8 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType currency, comment, [recipient], + optimisticTransaction.transactionID, paymentMethodType, - '', optimisticIOUReport.reportID, ); @@ -771,6 +867,11 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: {pendingAction: null}, + }, ]; const failureData = [ @@ -785,6 +886,16 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + pendingAction: null, + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), + }, + }, + }, ]; // Now, let's add the data we need just when we are creating a new chat report @@ -802,6 +913,17 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: optimisticChatReportData.key, value: {pendingFields: null}, }); + failureData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: optimisticChatReportData.key, + value: { + errorFields: { + createChat: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), + }, + }, + }, + }); // Add an optimistic created action to the optimistic reportActions data optimisticReportActionsData.value[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; @@ -811,6 +933,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType optimisticChatReportData, optimisticIOUReportData, optimisticReportActionsData, + optimisticTransactionData, ]; return { @@ -819,7 +942,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType chatReportID: chatReport.reportID, reportActionID: optimisticIOUReportAction.reportActionID, paymentMethodType, - transactionID: optimisticIOUReportAction.originalMessage.IOUTransactionID, + transactionID: optimisticTransaction.transactionID, newIOUReportDetails, createdReportActionID: isNewChat ? optimisticCreatedAction.reportActionID : 0, }, @@ -837,14 +960,15 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType * @returns {Object} */ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) { + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(iouReport.total, iouReport.currency, iouReport.reportID); const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, iouReport.total, iouReport.currency, '', [recipient], + optimisticTransaction.transactionID, paymentMethodType, - '', iouReport.reportID, true, ); @@ -882,6 +1006,11 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: optimisticTransaction, + }, ]; const successData = [ @@ -901,6 +1030,13 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho iouReportID: null, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + pendingAction: null, + }, + }, ]; const failureData = [ @@ -916,6 +1052,16 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, + value: { + pendingAction: null, + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), + }, + }, + }, ]; return { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 6991ae7f316d..378bd0d83e1d 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -20,6 +20,7 @@ import DateUtils from '../DateUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import * as Localize from '../Localize'; +import * as CollectionUtils from '../CollectionUtils'; let currentUserEmail; let currentUserAccountID; @@ -50,6 +51,18 @@ Onyx.connect({ }, }); +const allReportActions = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + callback: (actions, key) => { + if (!key || !actions) { + return; + } + const reportID = CollectionUtils.extractCollectionItemID(key); + allReportActions[reportID] = actions; + }, +}); + const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -1103,13 +1116,40 @@ function addPolicyReport(policy, reportName, visibility) { Navigation.navigate(ROUTES.getReportRoute(policyReport.reportID)); } +/** + * Deletes a report, along with its reportActions, any linked reports, and any linked IOU report. + * + * @param {String} reportID + */ +function deleteReport(reportID) { + const report = allReports[reportID]; + const onyxData = { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: null, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: null, + }; + + // Delete linked transactions + const reportActionsForReport = allReportActions[reportID]; + _.chain(reportActionsForReport) + .filter(reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) + .map(reportAction => reportAction.originalMessage.IOUTransactionID) + .uniq() + .each(transactionID => onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] = null); + + Onyx.multiSet(onyxData); + + // Delete linked IOU report + if (report && report.iouReportID) { + deleteReport(report.iouReportID); + } +} + /** * @param {String} reportID The reportID of the policy report (workspace room) */ function navigateToConciergeChatAndDeleteReport(reportID) { navigateToConciergeChat(); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, null); + deleteReport(reportID); } /** @@ -1479,6 +1519,7 @@ export { navigateToConciergeChat, setReportWithDraft, addPolicyReport, + deleteReport, navigateToConciergeChatAndDeleteReport, setIsComposerFullSize, markCommentAsUnread, diff --git a/src/libs/actions/ReportActions.js b/src/libs/actions/ReportActions.js index 6205fa847ed2..55d81cb6eb14 100644 --- a/src/libs/actions/ReportActions.js +++ b/src/libs/actions/ReportActions.js @@ -1,43 +1,36 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; +import * as ReportActionUtils from '../ReportActionsUtils'; /** * @param {String} reportID - * @param {String} reportActionID + * @param {Object} reportAction */ -function deleteOptimisticReportAction(reportID, reportActionID) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [reportActionID]: null, - }); -} +function clearReportActionErrors(reportID, reportAction) { + if (reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + // Delete the optimistic action + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportAction.reportActionID]: null, + }); -/** - * @param {String} reportID - * @param {String} reportActionID - */ -function clearReportActionErrors(reportID, reportActionID) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - [reportActionID]: { - errors: null, - }, - }); -} + // If there's a linked transaction, delete that too + const linkedTransactionID = ReportActionUtils.getLinkedTransactionID(reportID, reportAction.reportActionID); + if (linkedTransactionID) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); + } -/** - * This method clears the errors for a chat where send money action was done - * @param {String} chatReportID - * @param {String} reportActionID - */ -function clearSendMoneyErrors(chatReportID, reportActionID) { - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { - [reportActionID]: { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [reportAction.reportActionID]: { errors: null, }, }); } export { + // eslint-disable-next-line import/prefer-default-export clearReportActionErrors, - deleteOptimisticReportAction, - clearSendMoneyErrors, }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index c59da68aeb4d..70f54eb3dbfb 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -333,13 +333,7 @@ class ReportActionItem extends Component { )} > { - if (this.props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - ReportActions.deleteOptimisticReportAction(this.props.report.reportID, this.props.action.reportActionID); - } else { - ReportActions.clearReportActionErrors(this.props.report.reportID, this.props.action.reportActionID); - } - }} + onClose={() => ReportActions.clearReportActionErrors(this.props.report.reportID, this.props.action)} pendingAction={this.props.draftMessage ? null : this.props.action.pendingAction} errors={this.props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 463887c1a217..1cb04a9770cb 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -11,7 +11,7 @@ import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import compose from '../../libs/compose'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import * as IOU from '../../libs/actions/IOU'; -import * as CurrencySymbolUtils from '../../libs/CurrencySymbolUtils'; +import * as CurrencyUtils from '../../libs/CurrencyUtils'; import {withNetwork} from '../../components/OnyxProvider'; import CONST from '../../CONST'; import themeColors from '../../styles/themes/default'; @@ -94,7 +94,7 @@ class IOUCurrencySelection extends Component { return _.map(this.props.currencyList, (currencyInfo, currencyCode) => { const isSelectedCurrency = currencyCode === this.props.iou.selectedCurrencyCode; return { - text: `${currencyCode} - ${CurrencySymbolUtils.getLocalizedCurrencySymbol(this.props.preferredLocale, currencyCode)}`, + text: `${currencyCode} - ${CurrencyUtils.getLocalizedCurrencySymbol(currencyCode)}`, currencyCode, keyForList: currencyCode, customIcon: isSelectedCurrency ? greenCheckmark : undefined, diff --git a/src/pages/iou/MoneyRequestModal.js b/src/pages/iou/MoneyRequestModal.js index fb727646a61b..33cb083dcf78 100644 --- a/src/pages/iou/MoneyRequestModal.js +++ b/src/pages/iou/MoneyRequestModal.js @@ -29,6 +29,7 @@ import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import * as ReportScrollManager from '../../libs/ReportScrollManager'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; +import * as CurrencyUtils from '../../libs/CurrencyUtils'; /** * A modal used for requesting money, splitting bills or sending money. @@ -121,7 +122,7 @@ const MoneyRequestModal = (props) => { ? OptionsListUtils.getPolicyExpenseReportOptions(props.report) : OptionsListUtils.getParticipantsOptions(props.report, props.personalDetails), ); - const [amount, setAmount] = useState(''); + const [amount, setAmount] = useState(0); useEffect(() => { PersonalDetails.openMoneyRequestModalPage(); @@ -265,7 +266,6 @@ const MoneyRequestModal = (props) => { * @param {String} paymentMethodType */ const sendMoney = useCallback((paymentMethodType) => { - const amountInDollars = Math.round(amount * 100); const currency = props.iou.selectedCurrencyCode; const trimmedComment = props.iou.comment.trim(); const participant = selectedOptions[0]; @@ -273,7 +273,7 @@ const MoneyRequestModal = (props) => { if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { IOU.sendMoneyElsewhere( props.report, - amountInDollars, + amount, currency, trimmedComment, props.currentUserPersonalDetails.login, @@ -285,7 +285,7 @@ const MoneyRequestModal = (props) => { if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.PAYPAL_ME) { IOU.sendMoneyViaPaypal( props.report, - amountInDollars, + amount, currency, trimmedComment, props.currentUserPersonalDetails.login, @@ -297,7 +297,7 @@ const MoneyRequestModal = (props) => { if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { IOU.sendMoneyWithWallet( props.report, - amountInDollars, + amount, currency, trimmedComment, props.currentUserPersonalDetails.login, @@ -316,15 +316,7 @@ const MoneyRequestModal = (props) => { // IOUs created from a group report will have a reportID param in the route. // Since the user is already viewing the report, we don't need to navigate them to the report if (props.hasMultipleParticipants && CONST.REGEX.NUMBER.test(reportID)) { - IOU.splitBill( - selectedParticipants, - props.currentUserPersonalDetails.login, - amount, - trimmedComment, - props.iou.selectedCurrencyCode, - props.preferredLocale, - reportID, - ); + IOU.splitBill(selectedParticipants, props.currentUserPersonalDetails.login, amount, trimmedComment, props.iou.selectedCurrencyCode, reportID); return; } @@ -336,7 +328,6 @@ const MoneyRequestModal = (props) => { amount, trimmedComment, props.iou.selectedCurrencyCode, - props.preferredLocale, ); return; } @@ -347,13 +338,13 @@ const MoneyRequestModal = (props) => { } IOU.requestMoney( props.report, - Math.round(amount * 100), + amount, props.iou.selectedCurrencyCode, props.currentUserPersonalDetails.login, selectedParticipants[0], trimmedComment, ); - }, [amount, props.iou.comment, props.currentUserPersonalDetails.login, props.hasMultipleParticipants, props.iou.selectedCurrencyCode, props.preferredLocale, props.report, props.route]); + }, [amount, props.iou.comment, props.currentUserPersonalDetails.login, props.hasMultipleParticipants, props.iou.selectedCurrencyCode, props.report, props.route]); const currentStep = steps[currentStepIndex]; const moneyRequestStepIndex = _.indexOf(steps, Steps.MoneyRequestConfirm); @@ -386,12 +377,13 @@ const MoneyRequestModal = (props) => { {modalHeader} { - setAmount(value); + const amountInSmallestCurrencyUnits = CurrencyUtils.convertToSmallestUnit(props.iou.selectedCurrencyCode, Number.parseFloat(value)); + setAmount(amountInSmallestCurrencyUnits); navigateToNextStep(); }} reportID={reportID} hasMultipleParticipants={props.hasMultipleParticipants} - selectedAmount={amount} + selectedAmount={CurrencyUtils.convertToWholeUnit(props.iou.selectedCurrencyCode, amount)} navigation={props.navigation} iouType={props.iouType} buttonText={amountButtonText} diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js index fd788003823b..4cc0b1b2eef2 100755 --- a/src/pages/iou/steps/MoneyRequestAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -30,7 +30,7 @@ const propTypes = { onStepComplete: PropTypes.func.isRequired, /** Previously selected amount to show if the user comes back to this screen */ - selectedAmount: PropTypes.string.isRequired, + selectedAmount: PropTypes.number.isRequired, /** Text to display on the button that "saves" the amount */ buttonText: PropTypes.string.isRequired, @@ -66,12 +66,13 @@ class MoneyRequestAmountPage extends React.Component { this.numPadContainerViewID = 'numPadContainerView'; this.numPadViewID = 'numPadView'; + const selectedAmountAsString = props.selectedAmount ? props.selectedAmount.toString() : ''; this.state = { - amount: props.selectedAmount, + amount: selectedAmountAsString, shouldUpdateSelection: true, selection: { - start: props.selectedAmount.length, - end: props.selectedAmount.length, + start: selectedAmountAsString.length, + end: selectedAmountAsString.length, }, }; } @@ -321,7 +322,6 @@ class MoneyRequestAmountPage extends React.Component { onChangeAmount={this.updateAmount} onCurrencyButtonPress={this.navigateToCurrencySelectionPage} placeholder={this.props.numberFormat(0)} - preferredLocale={this.props.preferredLocale} ref={el => this.textInput = el} selectedCurrencyCode={this.props.iou.selectedCurrencyCode} selection={this.state.selection} diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 673c229ad9c9..5aa8898d8843 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -15,7 +15,7 @@ const propTypes = { hasMultipleParticipants: PropTypes.bool.isRequired, /** IOU amount */ - iouAmount: PropTypes.string.isRequired, + iouAmount: PropTypes.number.isRequired, /** Selected participants from MoneyRequestModal with login */ participants: PropTypes.arrayOf(optionPropTypes).isRequired, diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 774da65b3010..4c2c303dd961 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -38,6 +38,7 @@ import * as UserUtils from '../../libs/UserUtils'; import policyMemberPropType from '../policyMemberPropType'; import * as ReportActionContextMenu from '../home/report/ContextMenu/ReportActionContextMenu'; import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'; +import * as CurrencyUtils from '../../libs/CurrencyUtils'; const propTypes = { /* Onyx Props */ @@ -146,10 +147,8 @@ class InitialSettingsPage extends React.Component { */ getWalletBalance(isPaymentItem) { return (isPaymentItem && Permissions.canUseWallet(this.props.betas)) - ? this.props.numberFormat( - this.props.userWallet.currentBalance / 100, // Divide by 100 because balance is in cents - {style: 'currency', currency: 'USD'}, - ) : undefined; + ? CurrencyUtils.convertToDisplayString(this.props.userWallet.currentBalance) + : undefined; } /** diff --git a/src/pages/settings/Payments/TransferBalancePage.js b/src/pages/settings/Payments/TransferBalancePage.js index d9bad8d84f77..18d3f23afe9f 100644 --- a/src/pages/settings/Payments/TransferBalancePage.js +++ b/src/pages/settings/Payments/TransferBalancePage.js @@ -25,6 +25,7 @@ import ROUTES from '../../../ROUTES'; import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton'; import {withNetwork} from '../../../components/OnyxProvider'; import ConfirmationPage from '../../../components/ConfirmationPage'; +import * as CurrencyUtils from '../../../libs/CurrencyUtils'; const propTypes = { /** User's wallet information */ @@ -71,10 +72,7 @@ class TransferBalancePage extends React.Component { title: this.props.translate('transferAmountPage.instant'), description: this.props.translate('transferAmountPage.instantSummary', { rate: this.props.numberFormat(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.RATE), - minAmount: this.props.numberFormat( - CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.MINIMUM_FEE / 100, - {style: 'currency', currency: 'USD'}, - ), + minAmount: CurrencyUtils.convertToDisplayString(CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT.MINIMUM_FEE), }), icon: Expensicons.Bolt, type: CONST.PAYMENT_METHODS.DEBIT_CARD, @@ -242,10 +240,7 @@ class TransferBalancePage extends React.Component { - {this.props.numberFormat( - calculatedFee / 100, - {style: 'currency', currency: 'USD'}, - )} + {CurrencyUtils.convertToDisplayString(calculatedFee)} @@ -255,10 +250,8 @@ class TransferBalancePage extends React.Component { 'transferAmountPage.transfer', { amount: isTransferable - ? this.props.numberFormat( - transferAmount / 100, - {style: 'currency', currency: 'USD'}, - ) : '', + ? CurrencyUtils.convertToDisplayString(transferAmount) + : '', }, )} isLoading={this.props.walletTransfer.loading} diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index f1dbbbb7f1d3..2bed6d9a61be 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -26,6 +26,7 @@ import BlockingView from '../../components/BlockingViews/BlockingView'; import {withNetwork} from '../../components/OnyxProvider'; import * as ReimbursementAccountProps from '../ReimbursementAccount/reimbursementAccountPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; +import * as CurrencyUtils from '../../libs/CurrencyUtils'; const propTypes = { /* Onyx Props */ @@ -110,10 +111,8 @@ class WorkspacesListPage extends Component { */ getWalletBalance(isPaymentItem) { return (isPaymentItem && Permissions.canUseWallet(this.props.betas)) - ? this.props.numberFormat( - this.props.userWallet.currentBalance / 100, // Divide by 100 because balance is in cents - {style: 'currency', currency: 'USD'}, - ) : undefined; + ? CurrencyUtils.convertToDisplayString(this.props.userWallet.currentBalance) + : undefined; } /** diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js new file mode 100644 index 000000000000..49c59e418239 --- /dev/null +++ b/tests/actions/IOUTest.js @@ -0,0 +1,1022 @@ +import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import CONST from '../../src/CONST'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import * as IOU from '../../src/libs/actions/IOU'; +import * as TestHelper from '../utils/TestHelper'; +import DateUtils from '../../src/libs/DateUtils'; +import * as NumberUtils from '../../src/libs/NumberUtils'; +import * as Localize from '../../src/libs/Localize'; +import * as ReportActions from '../../src/libs/actions/ReportActions'; +import * as Report from '../../src/libs/actions/Report'; + +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const JULES_EMAIL = 'jules@expensifail.com'; +const RORY_EMAIL = 'rory@expensifail.com'; +const VIT_EMAIL = 'vit@expensifail.com'; + +describe('actions/IOU', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + global.fetch = TestHelper.getGlobalFetchMock(); + return Onyx.clear().then(waitForPromisesToResolve); + }); + + describe('requestMoney', () => { + it('creates new chat if needed', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReportID; + let iouReportID; + let createdAction; + let iouAction; + let transactionID; + fetch.pause(); + IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, {login: CARLOS_EMAIL}, comment); + return waitForPromisesToResolve() + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + + // A chat report and an iou report should be created + const chatReports = _.filter(allReports, report => report.type === CONST.REPORT.TYPE.CHAT); + const iouReports = _.filter(allReports, report => report.type === CONST.REPORT.TYPE.IOU); + expect(_.size(chatReports)).toBe(1); + expect(_.size(iouReports)).toBe(1); + const chatReport = chatReports[0]; + chatReportID = chatReport.reportID; + const iouReport = iouReports[0]; + iouReportID = iouReport.reportID; + + // They should be linked together + expect(chatReport.participants).toEqual([CARLOS_EMAIL]); + expect(chatReport.iouReportID).toBe(iouReport.reportID); + expect(chatReport.hasOutstandingIOU).toBe(true); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + + // The chat report should have a CREATED action and IOU action + expect(_.size(reportActionsForChatReport)).toBe(2); + const createdActions = _.filter(reportActionsForChatReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const iouActions = _.filter(reportActionsForChatReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(_.size(createdActions)).toBe(1); + expect(_.size(iouActions)).toBe(1); + createdAction = createdActions[0]; + iouAction = iouActions[0]; + + // The CREATED action should not be created after the IOU action + expect(Date.parse(createdAction.created)).toBeLessThanOrEqual(Date.parse(iouAction.created)); + + // The IOUReportID should be correct + expect(iouAction.originalMessage.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(iouAction.originalMessage.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(iouAction.originalMessage.amount).toBe(amount); + + // The IOU type should be correct + expect(iouAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // Both actions should be pending + expect(createdAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(iouAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + + // There should be one transaction + expect(_.size(allTransactions)).toBe(1); + const transaction = _.find(allTransactions, t => !_.isEmpty(t)); + transactionID = transaction.transactionID; + + // The transaction should be attached to the IOU report + expect(transaction.reportID).toBe(iouReportID); + + // Its amount should match the amount of the request + expect(transaction.amount).toBe(amount); + + // The comment should be correct + expect(transaction.comment.comment).toBe(comment); + + // It should be pending + expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(iouAction.originalMessage.IOUTransactionID).toBe(transactionID); + + expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + + resolve(); + }, + }); + })) + .then(fetch.resume) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + expect(_.size(reportActionsForChatReport)).toBe(2); + _.each(reportActionsForChatReport, reportAction => expect(reportAction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: true, + callback: (transaction) => { + Onyx.disconnect(connectionID); + expect(transaction.pendingAction).toBeFalsy(); + resolve(); + }, + }); + })); + }); + + it('updates existing chat report if there is one', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReport = { + reportID: 1234, + type: CONST.REPORT.TYPE.CHAT, + hasOutstandingIOU: false, + participants: [CARLOS_EMAIL], + }; + const createdAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + let iouReportID; + let iouAction; + let transactionID; + fetch.pause(); + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, { + [createdAction.reportActionID]: createdAction, + })) + .then(() => { + IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, {login: CARLOS_EMAIL}, comment); + return waitForPromisesToResolve(); + }) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + + // The same chat report should be reused, and an IOU report should be created + expect(_.size(allReports)).toBe(2); + expect(_.find(allReports, report => report.type === CONST.REPORT.TYPE.CHAT).reportID).toBe(chatReport.reportID); + chatReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.CHAT); + const iouReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.IOU); + iouReportID = iouReport.reportID; + + // They should be linked together + expect(chatReport.iouReportID).toBe(iouReportID); + expect(chatReport.hasOutstandingIOU).toBe(true); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connectionID); + + // The chat report should have a CREATED and an IOU action + expect(_.size(allReportActions)).toBe(2); + iouAction = _.find(allReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + + // The CREATED action should not be created after the IOU action + expect(Date.parse(createdAction.created)).toBeLessThanOrEqual(Date.parse(iouAction.created)); + + // The IOUReportID should be correct + expect(iouAction.originalMessage.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(iouAction.originalMessage.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(iouAction.originalMessage.amount).toBe(amount); + + // The IOU action type should be correct + expect(iouAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // The IOU action should be pending + expect(iouAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + + // There should be one transaction + expect(_.size(allTransactions)).toBe(1); + const transaction = _.find(allTransactions, t => !_.isEmpty(t)); + transactionID = transaction.transactionID; + + // The transaction should be attached to the IOU report + expect(transaction.reportID).toBe(iouReportID); + + // Its amount should match the amount of the request + expect(transaction.amount).toBe(amount); + + // The comment should be correct + expect(transaction.comment.comment).toBe(comment); + + expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + + // It should be pending + expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(iouAction.originalMessage.IOUTransactionID).toBe(transactionID); + + resolve(); + }, + }); + })) + .then(fetch.resume) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + expect(_.size(reportActionsForChatReport)).toBe(2); + _.each(reportActionsForChatReport, reportAction => expect(reportAction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: true, + callback: (transaction) => { + Onyx.disconnect(connectionID); + expect(transaction.pendingAction).toBeFalsy(); + resolve(); + }, + }); + })); + }); + + it('updates existing IOU report if there is one', () => { + const amount = 10000; + const comment = 'Giv money plz'; + const chatReportID = 1234; + const iouReportID = 5678; + let chatReport = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + hasOutstandingIOU: true, + iouReportID, + participants: [CARLOS_EMAIL], + }; + const createdAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + const existingTransaction = { + transactionID: NumberUtils.rand64(), + amount: 1000, + comment: '', + created: DateUtils.getDBTime(), + }; + let iouReport = { + reportID: iouReportID, + chatReportID, + type: CONST.REPORT.TYPE.IOU, + ownerEmail: RORY_EMAIL, + managerEmail: CARLOS_EMAIL, + currency: CONST.CURRENCY.USD, + total: existingTransaction.amount, + }; + const iouAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorEmail: RORY_EMAIL, + created: DateUtils.getDBTime(), + originalMessage: { + IOUReportID: iouReportID, + IOUTransactionID: existingTransaction.transactionID, + amount: existingTransaction.amount, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + participants: [RORY_EMAIL, CARLOS_EMAIL], + }, + }; + let newIOUAction; + let newTransaction; + fetch.pause(); + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport)) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, { + [createdAction.reportActionID]: createdAction, + [iouAction.reportActionID]: iouAction, + })) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction)) + .then(() => { + IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, {login: CARLOS_EMAIL}, comment); + return waitForPromisesToResolve(); + }) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + + // No new reports should be created + expect(_.size(allReports)).toBe(2); + expect(_.find(allReports, report => report.reportID === chatReportID)).toBeTruthy(); + expect(_.find(allReports, report => report.reportID === iouReportID)).toBeTruthy(); + + chatReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.CHAT); + iouReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.IOU); + + // The total on the iou report should be updated + expect(iouReport.total).toBe(11000); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + + expect(_.size(reportActionsForChatReport)).toBe(3); + newIOUAction = _.find(reportActionsForChatReport, reportAction => ( + reportAction.reportActionID !== createdAction.reportActionID + && reportAction.reportActionID !== iouAction.reportActionID + )); + + // The IOUReportID should be correct + expect(iouAction.originalMessage.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(newIOUAction.originalMessage.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(newIOUAction.originalMessage.amount).toBe(amount); + + // The type of the IOU action should be correct + expect(newIOUAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // The IOU action should be pending + expect(newIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + + // There should be two transactions + expect(_.size(allTransactions)).toBe(2); + + newTransaction = _.find(allTransactions, transaction => transaction.transactionID !== existingTransaction.transactionID); + + expect(newTransaction.reportID).toBe(iouReportID); + expect(newTransaction.amount).toBe(amount); + expect(newTransaction.comment.comment).toBe(comment); + expect(newTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + expect(newTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(newIOUAction.originalMessage.IOUTransactionID).toBe(newTransaction.transactionID); + + resolve(); + }, + }); + })) + .then(fetch.resume) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + expect(_.size(reportActionsForChatReport)).toBe(3); + _.each(reportActionsForChatReport, reportAction => expect(reportAction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + _.each(allTransactions, transaction => expect(transaction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })); + }); + + it('correctly implements RedBrickRoad error handling', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReportID; + let iouReportID; + let createdAction; + let iouAction; + let transactionID; + fetch.pause(); + IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, {login: CARLOS_EMAIL}, comment); + return waitForPromisesToResolve() + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + + // A chat report and an iou report should be created + const chatReports = _.filter(allReports, report => report.type === CONST.REPORT.TYPE.CHAT); + const iouReports = _.filter(allReports, report => report.type === CONST.REPORT.TYPE.IOU); + expect(_.size(chatReports)).toBe(1); + expect(_.size(iouReports)).toBe(1); + const chatReport = chatReports[0]; + chatReportID = chatReport.reportID; + const iouReport = iouReports[0]; + iouReportID = iouReport.reportID; + + // They should be linked together + expect(chatReport.participants).toEqual([CARLOS_EMAIL]); + expect(chatReport.iouReportID).toBe(iouReport.reportID); + expect(chatReport.hasOutstandingIOU).toBe(true); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + + // The chat report should have a CREATED action and IOU action + expect(_.size(reportActionsForChatReport)).toBe(2); + const createdActions = _.filter(reportActionsForChatReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const iouActions = _.filter(reportActionsForChatReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(_.size(createdActions)).toBe(1); + expect(_.size(iouActions)).toBe(1); + createdAction = createdActions[0]; + iouAction = iouActions[0]; + + // The CREATED action should not be created after the IOU action + expect(Date.parse(createdAction.created)).toBeLessThanOrEqual(Date.parse(iouAction.created)); + + // The IOUReportID should be correct + expect(iouAction.originalMessage.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(iouAction.originalMessage.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(iouAction.originalMessage.amount).toBe(amount); + + // The type should be correct + expect(iouAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // Both actions should be pending + expect(createdAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(iouAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + + // There should be one transaction + expect(_.size(allTransactions)).toBe(1); + const transaction = _.find(allTransactions, t => !_.isEmpty(t)); + transactionID = transaction.transactionID; + + expect(transaction.reportID).toBe(iouReportID); + expect(transaction.amount).toBe(amount); + expect(transaction.comment.comment).toBe(comment); + expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(iouAction.originalMessage.IOUTransactionID).toBe(transactionID); + + resolve(); + }, + }); + })) + .then(() => { + fetch.fail(); + return fetch.resume(); + }) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForChatReport) => { + Onyx.disconnect(connectionID); + expect(_.size(reportActionsForChatReport)).toBe(2); + iouAction = _.find(reportActionsForChatReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(iouAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + const errorMessage = _.values(iouAction.errors)[0]; + expect(errorMessage).toBe(Localize.translateLocal('iou.error.genericCreateFailureMessage')); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: true, + callback: (transaction) => { + Onyx.disconnect(connectionID); + expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(transaction.errors).toBeTruthy(); + expect(_.values(transaction.errors)[0]).toBe(Localize.translateLocal('iou.error.genericCreateFailureMessage')); + resolve(); + }, + }); + })) + + // If the user clears the errors on the IOU action + .then(() => new Promise((resolve) => { + ReportActions.clearReportActionErrors(chatReportID, iouAction); + resolve(); + })) + + // Then the reportAction should be removed from Onyx + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: true, + callback: (reportActionsForReport) => { + Onyx.disconnect(connectionID); + iouAction = _.find(reportActionsForReport, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(iouAction).toBeFalsy(); + resolve(); + }, + }); + })) + + // Along with the associated transaction + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: true, + callback: (transaction) => { + Onyx.disconnect(connectionID); + expect(transaction).toBeFalsy(); + resolve(); + }, + }); + })) + + // If a user clears the errors on the CREATED action (which, technically are just errors on the report) + .then(() => new Promise((resolve) => { + Report.deleteReport(chatReportID); + resolve(); + })) + + // Then the report should be deleted + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + _.each(allReports, report => expect(report).toBeFalsy()); + resolve(); + }, + }); + })) + + // All reportActions should also be deleted + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connectionID); + _.each(allReportActions, reportAction => expect(reportAction).toBeFalsy()); + resolve(); + }, + }); + })) + + // All transactions should also be deleted + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + _.each(allTransactions, transaction => expect(transaction).toBeFalsy()); + resolve(); + }, + }); + })) + + // Cleanup + .then(fetch.succeed); + }); + }); + + describe('split bill', () => { + it('creates and updates new chats and IOUs as needed', () => { + /* + * Given that: + * - Rory and Carlos have chatted before + * - Rory and Jules have chatted before and have an active IOU report + * - Rory and Vit have never chatted together before + * - There is no existing group chat with the four of them + */ + const amount = 400; + const comment = 'Yes, I am splitting a bill for $4 USD'; + let carlosChatReport = { + reportID: NumberUtils.rand64(), + type: CONST.REPORT.TYPE.CHAT, + hasOutstandingIOU: false, + participants: [CARLOS_EMAIL], + }; + const carlosCreatedAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + const julesIOUReportID = NumberUtils.rand64(); + let julesChatReport = { + reportID: NumberUtils.rand64(), + type: CONST.REPORT.TYPE.CHAT, + hasOutstandingIOU: true, + iouReportID: julesIOUReportID, + participants: [JULES_EMAIL], + }; + const julesCreatedAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + jest.advanceTimersByTime(200); + const julesExistingTransaction = { + transactionID: NumberUtils.rand64(), + amount: 1000, + comment: 'This is an existing transaction', + created: DateUtils.getDBTime(), + }; + let julesIOUReport = { + reportID: julesIOUReportID, + chatReportID: julesChatReport.reportID, + type: CONST.REPORT.TYPE.IOU, + ownerEmail: RORY_EMAIL, + managerEmail: JULES_EMAIL, + currency: CONST.CURRENCY.USD, + total: julesExistingTransaction.amount, + }; + const julesExistingIOUAction = { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorEmail: RORY_EMAIL, + created: DateUtils.getDBTime(), + originalMessage: { + IOUReportID: julesIOUReportID, + IOUTransactionID: julesExistingTransaction.transactionID, + amount: julesExistingTransaction.amount, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + participants: [RORY_EMAIL, JULES_EMAIL], + }, + }; + + let carlosIOUReport; + let carlosIOUAction; + let carlosTransaction; + + let julesIOUAction; + let julesTransaction; + + let vitChatReport; + let vitIOUReport; + let vitCreatedAction; + let vitIOUAction; + let vitTransaction; + + let groupChat; + let groupCreatedAction; + let groupIOUAction; + let groupTransaction; + + return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { + [`${ONYXKEYS.COLLECTION.REPORT}${carlosChatReport.reportID}`]: carlosChatReport, + [`${ONYXKEYS.COLLECTION.REPORT}${julesChatReport.reportID}`]: julesChatReport, + [`${ONYXKEYS.COLLECTION.REPORT}${julesIOUReport.reportID}`]: julesIOUReport, + }) + .then(() => Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport.reportID}`]: { + [carlosCreatedAction.reportActionID]: carlosCreatedAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.reportID}`]: { + [julesCreatedAction.reportActionID]: julesCreatedAction, + [julesExistingIOUAction.reportActionID]: julesExistingIOUAction, + }, + })) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction.transactionID}`, julesExistingTransaction)) + .then(() => { + // When we split a bill offline + fetch.pause(); + IOU.splitBill(_.map([CARLOS_EMAIL, JULES_EMAIL, VIT_EMAIL], email => ({login: email})), RORY_EMAIL, amount, comment, CONST.CURRENCY.USD); + return waitForPromisesToResolve(); + }) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + + // There should now be 7 reports + expect(_.size(allReports)).toBe(7); + + // 1. The chat report with Rory + Carlos + carlosChatReport = _.find(allReports, report => report.reportID === carlosChatReport.reportID); + expect(_.isEmpty(carlosChatReport)).toBe(false); + expect(carlosChatReport.pendingFields).toBeFalsy(); + + // 2. The IOU report with Rory + Carlos (new) + carlosIOUReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.IOU && report.managerEmail === CARLOS_EMAIL); + expect(_.isEmpty(carlosIOUReport)).toBe(false); + expect(carlosIOUReport.total).toBe(amount / 4); + + // 3. The chat report with Rory + Jules + julesChatReport = _.find(allReports, report => report.reportID === julesChatReport.reportID); + expect(_.isEmpty(julesChatReport)).toBe(false); + expect(julesChatReport.pendingFields).toBeFalsy(); + + // 4. The IOU report with Rory + Jules + julesIOUReport = _.find(allReports, report => report.reportID === julesIOUReport.reportID); + expect(_.isEmpty(julesIOUReport)).toBe(false); + expect(julesChatReport.pendingFields).toBeFalsy(); + expect(julesIOUReport.total).toBe(julesExistingTransaction.amount + (amount / 4)); + + // 5. The chat report with Rory + Vit (new) + vitChatReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.CHAT && _.isEqual(report.participants, [VIT_EMAIL])); + expect(_.isEmpty(vitChatReport)).toBe(false); + expect(vitChatReport.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + // 6. The IOU report with Rory + Vit (new) + vitIOUReport = _.find(allReports, report => report.type === CONST.REPORT.TYPE.IOU && report.managerEmail === VIT_EMAIL); + expect(_.isEmpty(vitIOUReport)).toBe(false); + expect(vitIOUReport.total).toBe(amount / 4); + + // 7. The group chat with everyone + groupChat = _.find(allReports, report => report.type === CONST.REPORT.TYPE.CHAT && _.isEqual(report.participants, [CARLOS_EMAIL, JULES_EMAIL, VIT_EMAIL])); + expect(_.isEmpty(groupChat)).toBe(false); + expect(groupChat.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + // The 1:1 chat reports and the IOU reports should be linked together + expect(carlosChatReport.hasOutstandingIOU).toBe(true); + expect(carlosChatReport.iouReportID).toBe(carlosIOUReport.reportID); + expect(carlosIOUReport.chatReportID).toBe(carlosChatReport.reportID); + + expect(julesChatReport.hasOutstandingIOU).toBe(true); + expect(julesChatReport.iouReportID).toBe(julesIOUReport.reportID); + expect(julesIOUReport.chatReportID).toBe(julesChatReport.reportID); + + expect(vitChatReport.hasOutstandingIOU).toBe(true); + expect(vitChatReport.iouReportID).toBe(vitIOUReport.reportID); + expect(vitIOUReport.chatReportID).toBe(vitChatReport.reportID); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connectionID); + + // There should be reportActions on all 4 chat reports + expect(_.size(allReportActions)).toBe(4); + + const carlosReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport.reportID}`]; + const julesReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport.reportID}`]; + const vitReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport.reportID}`]; + const groupReportActions = allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChat.reportID}`]; + + // Carlos DM should have two reportActions – the existing CREATED action and an pending IOU action + expect(_.size(carlosReportActions)).toBe(2); + expect(carlosReportActions[carlosCreatedAction.reportActionID]).toStrictEqual(carlosCreatedAction); + carlosIOUAction = _.find(carlosReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(carlosIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(carlosIOUAction.originalMessage.IOUReportID).toBe(carlosIOUReport.reportID); + expect(carlosIOUAction.originalMessage.amount).toBe(amount / 4); + expect(carlosIOUAction.originalMessage.comment).toBe(comment); + expect(carlosIOUAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(carlosCreatedAction.created)).toBeLessThanOrEqual(Date.parse(carlosIOUAction.created)); + + // Jules DM should have three reportActions, the existing CREATED action, the existing IOU action, and a new pending IOU action + expect(_.size(julesReportActions)).toBe(3); + expect(julesReportActions[julesCreatedAction.reportActionID]).toStrictEqual(julesCreatedAction); + julesIOUAction = _.find(julesReportActions, reportAction => ( + reportAction.reportActionID !== julesCreatedAction.reportActionID + && reportAction.reportActionID !== julesExistingIOUAction.reportActionID + )); + expect(julesIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(julesIOUAction.originalMessage.IOUReportID).toBe(julesIOUReport.reportID); + expect(julesIOUAction.originalMessage.amount).toBe(amount / 4); + expect(julesIOUAction.originalMessage.comment).toBe(comment); + expect(julesIOUAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(julesCreatedAction.created)).toBeLessThanOrEqual(Date.parse(julesIOUAction.created)); + + // Vit DM should have two reportActions – a pending CREATED action and a pending IOU action + expect(_.size(vitReportActions)).toBe(2); + vitCreatedAction = _.find(vitReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + vitIOUAction = _.find(vitReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(vitCreatedAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitIOUAction.originalMessage.IOUReportID).toBe(vitIOUReport.reportID); + expect(vitIOUAction.originalMessage.amount).toBe(amount / 4); + expect(vitIOUAction.originalMessage.comment).toBe(comment); + expect(vitIOUAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(vitCreatedAction.created)).toBeLessThanOrEqual(Date.parse(vitIOUAction.created)); + + // Group chat should have two reportActions – a pending CREATED action and a pending IOU action w/ type SPLIT + expect(_.size(groupReportActions)).toBe(2); + groupCreatedAction = _.find(groupReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + groupIOUAction = _.find(groupReportActions, reportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + expect(groupCreatedAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupIOUAction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupIOUAction.originalMessage).not.toHaveProperty('IOUReportID'); + expect(groupIOUAction.originalMessage.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.SPLIT); + expect(Date.parse(groupCreatedAction.created)).toBeLessThanOrEqual(Date.parse(groupIOUAction.created)); + + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + + /* There should be 5 transactions + * – one existing one with Jules + * - one for each of the three IOU reports + * - one on the group chat w/ deleted report + */ + expect(_.size(allTransactions)).toBe(5); + expect(allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction.transactionID}`]).toBeTruthy(); + + carlosTransaction = _.find(allTransactions, transaction => transaction.transactionID === carlosIOUAction.originalMessage.IOUTransactionID); + julesTransaction = _.find(allTransactions, transaction => transaction.transactionID === julesIOUAction.originalMessage.IOUTransactionID); + vitTransaction = _.find(allTransactions, transaction => transaction.transactionID === vitIOUAction.originalMessage.IOUTransactionID); + groupTransaction = _.find(allTransactions, transaction => transaction.reportID === CONST.REPORT.SPLIT_REPORTID); + + expect(carlosTransaction.reportID).toBe(carlosIOUReport.reportID); + expect(julesTransaction.reportID).toBe(julesIOUReport.reportID); + expect(vitTransaction.reportID).toBe(vitIOUReport.reportID); + expect(groupTransaction).toBeTruthy(); + + expect(carlosTransaction.amount).toBe(amount / 4); + expect(julesTransaction.amount).toBe(amount / 4); + expect(vitTransaction.amount).toBe(amount / 4); + expect(groupTransaction.amount).toBe(amount); + + expect(carlosTransaction.comment.comment).toBe(comment); + expect(julesTransaction.comment.comment).toBe(comment); + expect(vitTransaction.comment.comment).toBe(comment); + expect(groupTransaction.comment.comment).toBe(comment); + + expect(carlosTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + expect(julesTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + expect(vitTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU); + expect(groupTransaction.merchant) + .toBe(`Split bill with ${RORY_EMAIL}, ${CARLOS_EMAIL}, ${JULES_EMAIL}, and ${VIT_EMAIL} [${DateUtils.getDBTime().slice(0, 10)}]`); + + expect(carlosTransaction.comment.source).toBe(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT); + expect(julesTransaction.comment.source).toBe(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT); + expect(vitTransaction.comment.source).toBe(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT); + + expect(carlosTransaction.comment.originalTransactionID).toBe(groupTransaction.transactionID); + expect(julesTransaction.comment.originalTransactionID).toBe(groupTransaction.transactionID); + expect(vitTransaction.comment.originalTransactionID).toBe(groupTransaction.transactionID); + + expect(carlosTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(julesTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + })) + .then(fetch.resume) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + _.each(allReports, (report) => { + if (!report.pendingFields) { + return; + } + _.each(report.pendingFields, pendingField => expect(pendingField).toBeFalsy()); + }); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connectionID); + _.each(allReportActions, reportAction => expect(reportAction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })) + .then(() => new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connectionID); + _.each(allTransactions, transaction => expect(transaction.pendingAction).toBeFalsy()); + resolve(); + }, + }); + })); + }); + }); +}); diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index b7379e3c6b42..39bef91bc922 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -6,12 +6,10 @@ import { beforeEach, beforeAll, afterEach, describe, it, expect, } from '@jest/globals'; import ONYXKEYS from '../../src/ONYXKEYS'; -import * as Pusher from '../../src/libs/Pusher/pusher'; -import PusherConnectionManager from '../../src/libs/PusherConnectionManager'; -import CONFIG from '../../src/CONFIG'; import CONST from '../../src/CONST'; import * as Report from '../../src/libs/actions/Report'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import PusherHelper from '../utils/PusherHelper'; import * as TestHelper from '../utils/TestHelper'; import Log from '../../src/libs/Log'; import * as PersistedRequests from '../../src/libs/actions/PersistedRequests'; @@ -30,21 +28,7 @@ jest.mock('../../src/libs/actions/Report', () => { describe('actions/Report', () => { beforeAll(() => { - // When using the Pusher mock the act of calling Pusher.isSubscribed will create a - // channel already in a subscribed state. These methods are normally used to prevent - // duplicated subscriptions, but we don't need them for this test so forcing them to - // return false will make the testing less complex. - Pusher.isSubscribed = jest.fn().mockReturnValue(false); - Pusher.isAlreadySubscribing = jest.fn().mockReturnValue(false); - - // Connect to Pusher - PusherConnectionManager.init(); - Pusher.init({ - appKey: CONFIG.PUSHER.APP_KEY, - cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, - }); - + PusherHelper.setup(); Onyx.init({ keys: ONYXKEYS, registerStorageEventListener: () => {}, @@ -53,11 +37,7 @@ describe('actions/Report', () => { beforeEach(() => Onyx.clear().then(waitForPromisesToResolve)); - afterEach(() => { - // Unsubscribe from account channel after each test since we subscribe in the function - // subscribeToUserEvents and we don't want duplicate event subscriptions. - Pusher.unsubscribe(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`); - }); + afterEach(PusherHelper.teardown); it('should store a new report action in Onyx when onyxApiUpdate event is handled via Pusher', () => { global.fetch = TestHelper.getGlobalFetchMock(); @@ -106,8 +86,7 @@ describe('actions/Report', () => { // We subscribed to the Pusher channel above and now we need to simulate a reportComment action // Pusher event so we can verify that action was handled correctly and merged into the reportActions. - const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`); - channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [ + PusherHelper.emitOnyxUpdate([ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, @@ -225,7 +204,6 @@ describe('actions/Report', () => { const USER_1_ACCOUNT_ID = 1; const USER_2_LOGIN = 'different-user@test.com'; const USER_2_ACCOUNT_ID = 2; - const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${USER_1_ACCOUNT_ID}${CONFIG.PUSHER.SUFFIX}`); return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportName: 'Test', reportID: REPORT_ID}) .then(() => TestHelper.signInWithTestUser(USER_1_ACCOUNT_ID, USER_1_LOGIN)) .then(() => { @@ -237,7 +215,7 @@ describe('actions/Report', () => { .then(() => { // When a Pusher event is handled for a new report comment that includes a mention of the current user reportActionCreatedDate = DateUtils.getDBTime(); - channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [ + PusherHelper.emitOnyxUpdate([ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, @@ -382,7 +360,7 @@ describe('actions/Report', () => { optimisticReportActions.value[400].created = reportActionCreatedDate; // When we emit the events for these pending created actions to update them to not pending - channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [ + PusherHelper.emitOnyxUpdate([ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, @@ -522,8 +500,7 @@ describe('actions/Report', () => { }) .then(() => { // Simulate a Pusher Onyx update with a report action with shouldNotify - const channel = Pusher.getChannel(`${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${TEST_USER_ACCOUNT_ID}${CONFIG.PUSHER.SUFFIX}`); - channel.emit(Pusher.TYPE.ONYX_API_UPDATE, [ + PusherHelper.emitOnyxUpdate([ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, diff --git a/tests/unit/CurrencySymbolUtilsTest.js b/tests/unit/CurrencySymbolUtilsTest.js deleted file mode 100644 index ef18e2026de3..000000000000 --- a/tests/unit/CurrencySymbolUtilsTest.js +++ /dev/null @@ -1,39 +0,0 @@ -import _ from 'underscore'; -import * as CurrencySymbolUtils from '../../src/libs/CurrencySymbolUtils'; -import CONST from '../../src/CONST'; - -// This file can get outdated. In that case, you can follow these steps to update it: -// - open your browser console and navigate to the Network tab -// - refresh the App -// - click on the OpenApp request and in the preview tab locate the key `currencyList` -// - copy the value and format it to valid json using some external tool -// - update currencyList.json -import currencyList from './currencyList.json'; - -const currencyCodeList = _.keys(currencyList); -const AVAILABLE_LOCALES = [CONST.LOCALES.EN, CONST.LOCALES.ES]; - -// Contains item [isLeft, locale, currencyCode] -const symbolPositions = [ - [true, CONST.LOCALES.EN, 'USD'], - [false, CONST.LOCALES.ES, 'USD'], -]; - -describe('CurrencySymbolUtils', () => { - describe('getLocalizedCurrencySymbol', () => { - test.each(AVAILABLE_LOCALES)('Returns non empty string for all currencyCode with preferredLocale %s', (prefrredLocale) => { - _.forEach(currencyCodeList, (currencyCode) => { - const localizedSymbol = CurrencySymbolUtils.getLocalizedCurrencySymbol(prefrredLocale, currencyCode); - - expect(localizedSymbol).toBeTruthy(); - }); - }); - }); - - describe('isCurrencySymbolLTR', () => { - test.each(symbolPositions)('Returns %s for preferredLocale %s and currencyCode %s', (isLeft, locale, currencyCode) => { - const isSymbolLeft = CurrencySymbolUtils.isCurrencySymbolLTR(locale, currencyCode); - expect(isSymbolLeft).toBe(isLeft); - }); - }); -}); diff --git a/tests/unit/CurrencyUtilsTest.js b/tests/unit/CurrencyUtilsTest.js new file mode 100644 index 000000000000..c47445ce4353 --- /dev/null +++ b/tests/unit/CurrencyUtilsTest.js @@ -0,0 +1,138 @@ +import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import CONST from '../../src/CONST'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import * as CurrencyUtils from '../../src/libs/CurrencyUtils'; +import LocaleListener from '../../src/libs/Localize/LocaleListener'; + +// This file can get outdated. In that case, you can follow these steps to update it: +// - open your browser console and navigate to the Network tab +// - refresh the App +// - click on the OpenApp request and in the preview tab locate the key `currencyList` +// - copy the value and format it to valid json using some external tool +// - update currencyList.json +import currencyList from './currencyList.json'; + +const currencyCodeList = _.keys(currencyList); +const AVAILABLE_LOCALES = [CONST.LOCALES.EN, CONST.LOCALES.ES]; + +describe('CurrencyUtils', () => { + beforeAll(() => { + Onyx.init({ + keys: { + NVP_PREFERRED_LOCALE: ONYXKEYS.NVP_PREFERRED_LOCALE, + CURRENCY_LIST: ONYXKEYS.CURRENCY_LIST, + }, + initialKeyStates: { + [ONYXKEYS.NVP_PREFERRED_LOCALE]: CONST.LOCALES.DEFAULT, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + LocaleListener.connect(); + return waitForPromisesToResolve(); + }); + + afterEach(() => Onyx.clear()); + + describe('getLocalizedCurrencySymbol', () => { + test.each(AVAILABLE_LOCALES)('Returns non empty string for all currencyCode with preferredLocale %s', prefrredLocale => ( + Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, prefrredLocale) + .then(() => { + _.forEach(currencyCodeList, (currencyCode) => { + const localizedSymbol = CurrencyUtils.getLocalizedCurrencySymbol(currencyCode); + + expect(localizedSymbol).toBeTruthy(); + }); + }) + )); + }); + + describe('isCurrencySymbolLTR', () => { + test.each([ + [true, CONST.LOCALES.EN, 'USD'], + [false, CONST.LOCALES.ES, 'USD'], + ])('Returns %s for preferredLocale %s and currencyCode %s', (isLeft, locale, currencyCode) => ( + Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, locale) + .then(() => { + const isSymbolLeft = CurrencyUtils.isCurrencySymbolLTR(currencyCode); + expect(isSymbolLeft).toBe(isLeft); + }) + )); + }); + + describe('getCurrencyDecimals', () => { + test('Currency decimals smaller than or equal 2', () => { + expect(CurrencyUtils.getCurrencyDecimals('JPY')).toBe(0); + expect(CurrencyUtils.getCurrencyDecimals('USD')).toBe(2); + }); + + test('Currency decimals larger than 2 should return 2', () => { + // Actual: 3 + expect(CurrencyUtils.getCurrencyDecimals('LYD')).toBe(2); + + // Actual: 4 + expect(CurrencyUtils.getCurrencyDecimals('UYW')).toBe(2); + }); + }); + + describe('getCurrencyUnit', () => { + test('Currency with decimals smaller than or equal 2', () => { + expect(CurrencyUtils.getCurrencyUnit('JPY')).toBe(1); + expect(CurrencyUtils.getCurrencyUnit('USD')).toBe(100); + }); + + test('Currency with decimals larger than 2 should be floor to 2', () => { + expect(CurrencyUtils.getCurrencyUnit('LYD')).toBe(100); + }); + }); + + describe('convertToSmallestUnit', () => { + test.each([ + [CONST.CURRENCY.USD, 25, 2500], + [CONST.CURRENCY.USD, 25.5, 2550], + [CONST.CURRENCY.USD, 25.50, 2550], + ['JPY', 25, 25], + ['JPY', 2500, 2500], + ['JPY', 25.5, 25], + ])('Correctly converts %s to amount in smallest units', (currency, amount, expectedResult) => { + expect(CurrencyUtils.convertToSmallestUnit(currency, amount)).toBe(expectedResult); + }); + }); + + describe('convertToWholeUnit', () => { + test.each([ + [CONST.CURRENCY.USD, 2500, 25], + [CONST.CURRENCY.USD, 2550, 25.5], + ['JPY', 25, 25], + ['JPY', 2500, 2500], + ['JPY', 25.5, 25], + ])('Correctly converts %s to amount in whole units', (currency, amount, expectedResult) => { + expect(CurrencyUtils.convertToWholeUnit(currency, amount)).toBe(expectedResult); + }); + }); + + describe('convertToDisplayString', () => { + test.each([ + [CONST.CURRENCY.USD, 25, '$0.25'], + [CONST.CURRENCY.USD, 2500, '$25.00'], + [CONST.CURRENCY.USD, 150, '$1.50'], + [CONST.CURRENCY.USD, 250000, '$2,500.00'], + ['JPY', 25, '¥25'], + ['JPY', 2500, '¥2,500'], + ['JPY', 25.5, '¥25'], + ])('Correctly displays %s', (currency, amount, expectedResult) => { + expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult); + }); + + test.each([ + ['EUR', 25, '0,25\xa0€'], + ['EUR', 2500, '25,00\xa0€'], + ['EUR', 250000, '2500,00\xa0€'], + ['EUR', 250000000, '2.500.000,00\xa0€'], + ])('Correctly displays %s in ES locale', (currency, amount, expectedResult) => ( + Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES) + .then(() => expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult)) + )); + }); +}); diff --git a/tests/unit/IOUUtilsTest.js b/tests/unit/IOUUtilsTest.js index 025b08705456..fbac3e602139 100644 --- a/tests/unit/IOUUtilsTest.js +++ b/tests/unit/IOUUtilsTest.js @@ -1,7 +1,8 @@ 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 * as NumberUtils from '../../src/libs/NumberUtils'; +import CONST from '../../src/CONST'; import ONYXKEYS from '../../src/ONYXKEYS'; import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import currencyList from './currencyList.json'; @@ -11,34 +12,34 @@ let reportActions; const ownerEmail = 'owner@iou.com'; const managerEmail = 'manager@iou.com'; -function createIOUReportAction(type, amount, currency, {IOUTransactionID, isOnline} = {}) { +function createIOUReportAction(type, amount, currency, isOffline = false, IOUTransactionID = NumberUtils.rand64()) { const moneyRequestAction = ReportUtils.buildOptimisticIOUReportAction( type, amount, currency, 'Test comment', [managerEmail], - '', IOUTransactionID, + '', iouReport.reportID, ); - // Default is to create requests offline, if this is specified then we need to remove the pendingAction - moneyRequestAction.pendingAction = isOnline ? null : 'add'; + // Default is to create requests online, if `isOffline` is not specified then we need to remove the pendingAction + if (!isOffline) { + moneyRequestAction.pendingAction = null; + } reportActions.push(moneyRequestAction); return moneyRequestAction; } -function deleteMoneyRequest(moneyRequestAction, {isOnline} = {}) { +function deleteMoneyRequest(moneyRequestAction, isOffline = false) { createIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.DELETE, moneyRequestAction.originalMessage.amount, moneyRequestAction.originalMessage.currency, - { - IOUTransactionID: moneyRequestAction.originalMessage.IOUTransactionID, - isOnline, - }, + isOffline, + moneyRequestAction.originalMessage.IOUTransactionID, ); } @@ -52,151 +53,113 @@ function initCurrencyList() { 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'); - - // We requested money offline in a different currency, we don't know the total of the iouReport until we're back online - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); - }); - - test('IOUReport is not pending conversion when all requests made offline have been deleted', () => { - // Create two requests offline - const moneyRequestA = createIOUReportAction('create', 1000, 'AED'); - const moneyRequestB = createIOUReportAction('create', 1000, 'AED'); +describe('IOUUtils', () => { + describe('isIOUReportPendingCurrencyConversion', () => { + beforeEach(() => { + reportActions = []; + const chatReportID = ReportUtils.generateReportID(); + const amount = 1000; + const currency = 'USD'; - // Delete both requests - deleteMoneyRequest(moneyRequestA); - deleteMoneyRequest(moneyRequestB); + iouReport = ReportUtils.buildOptimisticIOUReport(ownerEmail, managerEmail, amount, chatReportID, currency); - // Both requests made offline have been deleted, total won't update so no need to show a pending conversion message - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); - }); + // 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 online, unless isOffline is specified + createIOUReportAction('create', amount, currency); + }); - test('Deleting a request made online shows the preview', () => { - // Request money online in AED - const moneyRequest = createIOUReportAction('create', 1000, 'AED', {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', true); - // Delete it offline - deleteMoneyRequest(moneyRequest); + // We requested money offline in a different currency, we don't know the total of the iouReport until we're back online + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); - // We don't know what the total is because we need to subtract the converted amount of the offline request from the total - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); - }); + test('IOUReport is not pending conversion when all requests made offline have been deleted', () => { + // Create two requests offline + const moneyRequestA = createIOUReportAction('create', 1000, 'AED', true); + const moneyRequestB = createIOUReportAction('create', 1000, 'AED', true); - test('Deleting a request made offline while there\'s a previous one made online will not show the pending conversion message', () => { - // Request money online in AED - createIOUReportAction('create', 1000, 'AED', {isOnline: true}); + // Delete both requests + deleteMoneyRequest(moneyRequestA, true); + deleteMoneyRequest(moneyRequestB, true); - // Another request offline - const moneyRequestOffline = createIOUReportAction('create', 1000, 'AED'); + // Both requests made offline have been deleted, total won't update so no need to show a pending conversion message + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); - // Delete the request made offline - deleteMoneyRequest(moneyRequestOffline); + test('Deleting a request made online shows the preview', () => { + // Request money online in AED + const moneyRequest = createIOUReportAction('create', 1000, 'AED'); - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); - }); + // Delete it offline + deleteMoneyRequest(moneyRequest, true); - test('Deleting a request made online while we have one made offline will show the pending conversion message', () => { - // Request money online in AED - const moneyRequestOnline = createIOUReportAction('create', 1000, 'AED', {isOnline: true}); + // We don't know what the total is because we need to subtract the converted amount of the offline request from the total + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); - // Requet money again but offline - createIOUReportAction('create', 1000, 'AED'); + test('Deleting a request made offline while there\'s a previous one made online will not show the pending conversion message', () => { + // Request money online in AED + createIOUReportAction('create', 1000, 'AED'); - // Delete the request made online - deleteMoneyRequest(moneyRequestOnline); + // Another request offline + const moneyRequestOffline = createIOUReportAction('create', 1000, 'AED', true); - // We don't know what the total is because we need to subtract the converted amount of the offline request from the total - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); - }); + // Delete the request made offline + deleteMoneyRequest(moneyRequestOffline, true); - test('Deleting a request offline in the report\'s currency when we have requests in a different currency does not show the pending conversion message', () => { - // Request money in the report's curreny (USD) - const onlineMoneyRequestInUSD = createIOUReportAction('create', 1000, 'USD', {isOnline: true}); + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); - // Request money online in a different currency - createIOUReportAction('create', 2000, 'AED', {isOnline: true}); + test('Deleting a request made online while we have one made offline will show the pending conversion message', () => { + // Request money online in AED + const moneyRequestOnline = createIOUReportAction('create', 1000, 'AED'); - // Delete the USD request offline - deleteMoneyRequest(onlineMoneyRequestInUSD); + // Request money again but offline + createIOUReportAction('create', 1000, 'AED', true); - expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); - }); -}); + // Delete the request made online + deleteMoneyRequest(moneyRequestOnline, true); -describe('getCurrencyDecimals', () => { - beforeAll(() => initCurrencyList()); - test('Currency decimals smaller than or equal 2', () => { - expect(IOUUtils.getCurrencyDecimals('JPY')).toBe(0); - expect(IOUUtils.getCurrencyDecimals('USD')).toBe(2); - }); + // We don't know what the total is because we need to subtract the converted amount of the offline request from the total + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); - test('Currency decimals larger than 2 should return 2', () => { - // Actual: 3 - expect(IOUUtils.getCurrencyDecimals('LYD')).toBe(2); + test('Deleting a request offline in the report\'s currency when we have requests in a different currency does not show the pending conversion message', () => { + // Request money in the report's currency (USD) + const onlineMoneyRequestInUSD = createIOUReportAction('create', 1000, 'USD'); - // Actual: 4 - expect(IOUUtils.getCurrencyDecimals('UYW')).toBe(2); - }); -}); + // Request money online in a different currency + createIOUReportAction('create', 2000, 'AED'); -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); - }); + // Delete the USD request offline + deleteMoneyRequest(onlineMoneyRequestInUSD, true); - test('Currency with decimals larger than 2 should be floor to 2', () => { - expect(IOUUtils.getCurrencyUnit('LYD')).toBe(100); + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); }); -}); -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); - }); + describe('calculateAmount', () => { + beforeAll(() => initCurrencyList()); - 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('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, true)).toBe(35); + expect(IOUUtils.calculateAmount(participants, 103)).toBe(34); + }); - 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('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, true)).toBe(1); + expect(IOUUtils.calculateAmount(participants, 10)).toBe(3); + }); - 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); + 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, 2, true)).toBe(-1); + expect(IOUUtils.calculateAmount(participants, 2)).toBe(1); + }); }); }); diff --git a/tests/unit/LocalizeTests.js b/tests/unit/LocalizeTests.js new file mode 100644 index 000000000000..cbe158ffb913 --- /dev/null +++ b/tests/unit/LocalizeTests.js @@ -0,0 +1,49 @@ +import Onyx from 'react-native-onyx'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import CONST from '../../src/CONST'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import * as Localize from '../../src/libs/Localize'; + +describe('localize', () => { + beforeAll(() => { + Onyx.init({ + keys: {NVP_PREFERRED_LOCALE: ONYXKEYS.NVP_PREFERRED_LOCALE}, + initialKeyStates: {[ONYXKEYS.NVP_PREFERRED_LOCALE]: CONST.LOCALES.DEFAULT}, + }); + return waitForPromisesToResolve(); + }); + + afterEach(() => Onyx.clear()); + + describe('arrayToString', () => { + test.each([ + [[], { + [CONST.LOCALES.DEFAULT]: '', + [CONST.LOCALES.ES]: '', + }], + [['rory'], { + [CONST.LOCALES.DEFAULT]: 'rory', + [CONST.LOCALES.ES]: 'rory', + }], + [['rory', 'vit'], { + [CONST.LOCALES.DEFAULT]: 'rory and vit', + [CONST.LOCALES.ES]: 'rory y vit', + }], + [['rory', 'vit', 'jules'], { + [CONST.LOCALES.DEFAULT]: 'rory, vit, and jules', + [CONST.LOCALES.ES]: 'rory, vit y jules', + }], + [['rory', 'vit', 'ionatan'], { + [CONST.LOCALES.DEFAULT]: 'rory, vit, and ionatan', + [CONST.LOCALES.ES]: 'rory, vit e ionatan', + }], + ])('arrayToSpokenList(%s)', (input, { + [CONST.LOCALES.DEFAULT]: expectedOutput, + [CONST.LOCALES.ES]: expectedOutputES, + }) => { + expect(Localize.arrayToString(input)).toBe(expectedOutput); + return Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.ES) + .then(() => expect(Localize.arrayToString(input)).toBe(expectedOutputES)); + }); + }); +}); diff --git a/tests/utils/PusherHelper.js b/tests/utils/PusherHelper.js new file mode 100644 index 000000000000..d3d7cdede559 --- /dev/null +++ b/tests/utils/PusherHelper.js @@ -0,0 +1,43 @@ +import * as Pusher from '../../src/libs/Pusher/pusher'; +import PusherConnectionManager from '../../src/libs/PusherConnectionManager'; +import CONFIG from '../../src/CONFIG'; +import CONST from '../../src/CONST'; + +const CHANNEL_NAME = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`; + +function setup() { + // When using the Pusher mock the act of calling Pusher.isSubscribed will create a + // channel already in a subscribed state. These methods are normally used to prevent + // duplicated subscriptions, but we don't need them for this test so forcing them to + // return false will make the testing less complex. + Pusher.isSubscribed = jest.fn().mockReturnValue(false); + Pusher.isAlreadySubscribing = jest.fn().mockReturnValue(false); + + // Connect to Pusher + PusherConnectionManager.init(); + Pusher.init({ + appKey: CONFIG.PUSHER.APP_KEY, + cluster: CONFIG.PUSHER.CLUSTER, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, + }); +} + +/** + * @param {Array} args + */ +function emitOnyxUpdate(args) { + const channel = Pusher.getChannel(CHANNEL_NAME); + channel.emit(Pusher.TYPE.ONYX_API_UPDATE, args); +} + +function teardown() { + // Unsubscribe from account channel after each test since we subscribe in the function + // subscribeToUserEvents and we don't want duplicate event subscriptions. + Pusher.unsubscribe(CHANNEL_NAME); +} + +export default { + setup, + emitOnyxUpdate, + teardown, +}; diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js index c45e9096f55b..4286354c5851 100644 --- a/tests/utils/TestHelper.js +++ b/tests/utils/TestHelper.js @@ -1,3 +1,4 @@ +import _ from 'underscore'; import Onyx from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; import CONST from '../../src/CONST'; @@ -134,7 +135,12 @@ function signOutTestUser() { } /** - * Use for situations where fetch() is required. + * Use for situations where fetch() is required. This mock is stateful and has some additional methods to control its behavior: + * + * - pause() – stop resolving promises until you call resume() + * - resume() - flush the queue of promises, and start resolving new promises immediately + * - fail() - start returning a failure response + * - success() - go back to returning a success response * * @example * @@ -145,13 +151,37 @@ function signOutTestUser() { * @returns {Function} */ function getGlobalFetchMock() { - return jest.fn() - .mockResolvedValue({ + const queue = []; + let isPaused = false; + let shouldFail = false; + + const getResponse = () => (shouldFail + ? { ok: true, - json: () => Promise.resolve({ - jsonCode: 200, - }), + json: () => Promise.resolve({jsonCode: 400}), + } : { + ok: true, + json: () => Promise.resolve({jsonCode: 200}), + }); + + const mockFetch = jest.fn() + .mockImplementation(() => { + if (!isPaused) { + return Promise.resolve(getResponse()); + } + return new Promise(resolve => queue.push(resolve)); }); + + mockFetch.pause = () => isPaused = true; + mockFetch.resume = () => { + isPaused = false; + _.each(queue, resolve => resolve(getResponse())); + return waitForPromisesToResolve(); + }; + mockFetch.fail = () => shouldFail = true; + mockFetch.succeed = () => shouldFail = false; + + return mockFetch; } /**