Skip to content

Commit

Permalink
Merge pull request #13329 from Expensify/youssef_iou_pending_currency…
Browse files Browse the repository at this point in the history
…_conversion

Improve cancelling money in a different currency offline
  • Loading branch information
mountiny authored Jan 5, 2023
2 parents 69fd192 + ba2ab0b commit 8c675d4
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 10 deletions.
45 changes: 40 additions & 5 deletions src/components/ReportActionItem/IOUAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import React from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
import CONST from '../../CONST';
import {withNetwork} from '../OnyxProvider';
import compose from '../../libs/compose';
import IOUQuote from './IOUQuote';
import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes';
import networkPropTypes from '../networkPropTypes';
import iouReportPropTypes from '../../pages/iouReportPropTypes';
import IOUPreview from './IOUPreview';
import Navigation from '../../libs/Navigation/Navigation';
import ROUTES from '../../ROUTES';
import styles from '../../styles/styles';
import * as IOUUtils from '../../libs/IOUUtils';

const propTypes = {
/** All the data of the action */
Expand All @@ -29,9 +35,16 @@ const propTypes = {
hasOutstandingIOU: PropTypes.bool.isRequired,
}),

/** IOU report data object */
iouReport: iouReportPropTypes.isRequired,

/** Array of report actions for this report */
reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)).isRequired,

/** Whether the IOU is hovered so we can modify its style */
isHovered: PropTypes.bool,

network: networkPropTypes.isRequired,
};

const defaultProps = {
Expand All @@ -51,6 +64,17 @@ const IOUAction = (props) => {
&& Boolean(props.action.originalMessage.IOUReportID)
&& props.chatReport.hasOutstandingIOU) || props.action.originalMessage.type === 'pay';

let shouldShowPendingConversionMessage = false;
if (
props.iouReport
&& props.chatReport.hasOutstandingIOU
&& props.isMostRecentIOUReportAction
&& props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD
&& props.network.isOffline
) {
shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(props.reportActions, props.iouReport);
}

return (
<>
<IOUQuote
Expand All @@ -62,6 +86,7 @@ const IOUAction = (props) => {
<IOUPreview
iouReportID={props.action.originalMessage.IOUReportID.toString()}
chatReportID={props.chatReportID}
shouldShowPendingConversionMessage={shouldShowPendingConversionMessage}
onPayButtonPressed={launchDetailsModal}
onPreviewPressed={launchDetailsModal}
containerStyles={[
Expand All @@ -81,8 +106,18 @@ IOUAction.propTypes = propTypes;
IOUAction.defaultProps = defaultProps;
IOUAction.displayName = 'IOUAction';

export default withOnyx({
chatReport: {
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
},
})(IOUAction);
export default compose(
withOnyx({
chatReport: {
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
},
iouReport: {
key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
},
reportActions: {
key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
canEvict: false,
},
}),
withNetwork(),
)(IOUAction);
17 changes: 12 additions & 5 deletions src/components/ReportActionItem/IOUPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,18 @@ const IOUPreview = (props) => {
</Text>
)
: (
<Text>
{props.iouReport.hasOutstandingIOU
? props.translate('iou.owesyou', {manager: managerName})
: props.translate('iou.paidyou', {manager: managerName})}
</Text>
<>
<Text>
{props.iouReport.hasOutstandingIOU
? props.translate('iou.owesyou', {manager: managerName})
: props.translate('iou.paidyou', {manager: managerName})}
</Text>
{props.shouldShowPendingConversionMessage && (
<Text style={[styles.textLabel, styles.colorMuted]}>
{props.translate('iou.pendingConversionMessage')}
</Text>
)}
</>
)}
{(isCurrentUserManager
&& !props.shouldHidePayButton
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export default {
split: ({amount}) => `Split ${amount}`,
send: ({amount}) => `Send ${amount}`,
noReimbursableExpenses: 'This report has an invalid amount',
pendingConversionMessage: 'Total will update when you\'re back online',
error: {
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export default {
split: ({amount}) => `Dividir ${amount}`,
send: ({amount}) => `Enviar ${amount}`,
noReimbursableExpenses: 'El monto de este informe es inválido',
pendingConversionMessage: 'El total se actualizará cuando estés online',
error: {
invalidSplit: 'La suma de las partes no equivale al monto total',
other: 'Error inesperado, por favor inténtalo más tarde',
Expand Down
71 changes: 71 additions & 0 deletions src/libs/IOUUtils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import _ from 'underscore';
import CONST from '../CONST';

/**
Expand Down Expand Up @@ -65,7 +66,77 @@ function updateIOUOwnerAndTotal(iouReport, actorEmail, amount, currency, type =
return iouReportUpdate;
}

/**
* Returns the list of IOU actions depending on the type and whether or not they are pending.
* Used below so that we can decide if an IOU report is pending currency conversion.
*
* @param {Array} reportActions
* @param {Object} iouReport
* @param {String} type - iouReportAction type. Can be oneOf(create, decline, cancel, pay, split)
* @param {String} pendingAction
* @param {Boolean} filterRequestsInDifferentCurrency
*
* @returns {Array}
*/
function getIOUReportActions(reportActions, iouReport, type = '', pendingAction = '', filterRequestsInDifferentCurrency = false) {
return _.chain(reportActions)
.filter(action => action.originalMessage
&& action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU
&& action.originalMessage.IOUReportID.toString() === iouReport.reportID.toString())
.filter(action => (!_.isEmpty(type) ? action.originalMessage.type === type : true))
.filter(action => (!_.isEmpty(pendingAction) ? action.pendingAction === pendingAction : true))
.filter(action => (filterRequestsInDifferentCurrency ? action.originalMessage.currency !== iouReport.currency : true))
.value();
}

/**
* Returns whether or not an IOU report contains money requests in a different currency
* that are either created or cancelled offline, and thus haven't been converted to the report's currency yet
*
* @param {Array} reportActions
* @param {Object} iouReport
*
* @returns {Boolean}
*/
function isIOUReportPendingCurrencyConversion(reportActions, iouReport) {
// Pending money requests that are in a different currency
const pendingRequestsInDifferentCurrency = _.chain(getIOUReportActions(
reportActions,
iouReport,
CONST.IOU.REPORT_ACTION_TYPE.CREATE,
CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
true,
)).map(action => action.originalMessage.IOUTransactionID)
.sort()
.value();

// Pending cancelled money requests that are in a different currency
const pendingCancelledRequestsInDifferentCurrency = _.chain(getIOUReportActions(
reportActions,
iouReport,
CONST.IOU.REPORT_ACTION_TYPE.CANCEL,
CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
true,
)).map(action => action.originalMessage.IOUTransactionID)
.sort()
.value();

const hasPendingRequests = Boolean(pendingRequestsInDifferentCurrency.length || pendingCancelledRequestsInDifferentCurrency.length);

// If we have pending money requests made offline, check if all of them have been cancelled offline
// In order to do that, we can grab transactionIDs of all the created and cancelled money requests and check if they're identical
if (hasPendingRequests && _.isEqual(pendingRequestsInDifferentCurrency, pendingCancelledRequestsInDifferentCurrency)) {
return false;
}

// Not all requests made offline had been cancelled,
// simply return if we have any pending created or cancelled requests
return hasPendingRequests;
}

export {
calculateAmount,
updateIOUOwnerAndTotal,
getIOUReportActions,
isIOUReportPendingCurrencyConversion,
};
1 change: 1 addition & 0 deletions src/pages/home/report/ReportActionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ class ReportActionItem extends Component {
children = (
<IOUAction
chatReportID={this.props.report.reportID}
iouReportID={this.props.report.iouReportID}
action={this.props.action}
isMostRecentIOUReportAction={this.props.isMostRecentIOUReportAction}
isHovered={hovered}
Expand Down
1 change: 1 addition & 0 deletions src/pages/iou/IOUTransactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class IOUTransactions extends Component {
<ReportTransaction
chatReportID={this.props.chatReportID}
iouReportID={this.props.iouReportID}
reportActions={this.props.reportActions}
action={reportAction}
key={reportAction.reportActionID}
canBeRejected={canBeRejected}
Expand Down
134 changes: 134 additions & 0 deletions tests/unit/IOUUtilsTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import * as IOUUtils from '../../src/libs/IOUUtils';
import * as ReportUtils from '../../src/libs/ReportUtils';

let iouReport;
let reportActions;
const ownerEmail = 'owner@iou.com';
const managerEmail = 'manager@iou.com';

function createIOUReportAction(type, amount, currency, {IOUTransactionID, isOnline} = {}) {
const moneyRequestAction = ReportUtils.buildOptimisticIOUReportAction(
1,
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';

reportActions.push(moneyRequestAction);
return moneyRequestAction;
}

function cancelMoneyRequest(moneyRequestAction, {isOnline} = {}) {
createIOUReportAction(
'cancel',
moneyRequestAction.originalMessage.amount,
moneyRequestAction.originalMessage.currency,
{
IOUTransactionID: moneyRequestAction.originalMessage.IOUTransactionID,
isOnline,
},
);
}

beforeEach(() => {
reportActions = [];
const chatReportID = ReportUtils.generateReportID();
const amount = 1000;
const currency = 'USD';

iouReport = ReportUtils.buildOptimisticIOUReport(
ownerEmail,
managerEmail,
amount,
chatReportID,
currency,
'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});
});

describe('isIOUReportPendingCurrencyConversion', () => {
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 cancelled', () => {
// Create two requests offline
const moneyRequestA = createIOUReportAction('create', 1000, 'AED');
const moneyRequestB = createIOUReportAction('create', 1000, 'AED');

// Cancel both requests
cancelMoneyRequest(moneyRequestA);
cancelMoneyRequest(moneyRequestB);

// Both requests made offline have been cancelled, total won't update so no need to show a pending conversion message
expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
});

test('Cancelling a request made online shows the preview', () => {
// Request money online in AED
const moneyRequest = createIOUReportAction('create', 1000, 'AED', {isOnline: true});

// Cancel it offline
cancelMoneyRequest(moneyRequest);

// 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('Cancelling 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});

// Another request offline
const moneyRequestOffline = createIOUReportAction('create', 1000, 'AED');

// Cancel the request made offline
cancelMoneyRequest(moneyRequestOffline);

expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
});

test('Cancelling 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});

// Requet money again but offline
createIOUReportAction('create', 1000, 'AED');

// Cancel the request made online
cancelMoneyRequest(moneyRequestOnline);

// 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('Cancelling 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});

// Request money online in a different currency
createIOUReportAction('create', 2000, 'AED', {isOnline: true});

// Cancel the USD request offline
cancelMoneyRequest(onlineMoneyRequestInUSD);

expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
});
});

0 comments on commit 8c675d4

Please sign in to comment.