Skip to content

Commit 5fe613b

Browse files
authored
Merge pull request #95005 from KioCoan/cancelButtonForP2P
[Payment due @Pujan92] Add Cancel payment action for pending P2P wallet payments
2 parents 72c7789 + 31cdc29 commit 5fe613b

4 files changed

Lines changed: 327 additions & 14 deletions

File tree

src/libs/ReportSecondaryActionUtils.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,23 @@ function isUnapproveAction(currentUserLogin: string, currentUserAccountID: numbe
398398
return isReportApprover;
399399
}
400400

401+
// Pay actions are at the report level, not per transaction.
402+
function getReportPayActions(reportID: string): ReportAction[] {
403+
return Object.values(getAllReportActions(reportID)).filter((action): action is ReportAction => !!action && isPayAction(action));
404+
}
405+
406+
// Whether every pay action on the report has a payment type the predicate accepts. Returns false when there are
407+
// no pay actions, since the payment type cannot be determined.
408+
function everyPayActionHasPaymentType(payActions: ReportAction[], matchesPaymentType: (paymentType: string | undefined) => boolean): boolean {
409+
return (
410+
payActions.length > 0 &&
411+
payActions.every((action) => {
412+
const originalMessage = getOriginalMessage(action);
413+
return !!originalMessage && 'paymentType' in originalMessage && matchesPaymentType(originalMessage.paymentType);
414+
})
415+
);
416+
}
417+
401418
function isCancelPaymentAction(
402419
currentAccountID: number,
403420
currentUserEmail: string,
@@ -407,32 +424,36 @@ function isCancelPaymentAction(
407424
policy?: Policy,
408425
): boolean {
409426
const isExpenseReport = isExpenseReportUtils(report);
427+
const isIOUReport = isIOUReportUtils(report);
410428

411-
if (!isExpenseReport) {
429+
if (!isExpenseReport && !isIOUReport) {
412430
return false;
413431
}
414432

415-
const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
416433
const isPayer = isPayerUtils(currentAccountID, currentUserEmail, report, bankAccountList, policy, false);
417434

435+
// A P2P "send money" payment made with the Expensify wallet that is waiting for the receiver to set up their
436+
// wallet is held until they onboard. The sender (payer) can cancel it while it is waiting, which returns the
437+
// held funds.
438+
if (isIOUReport) {
439+
if (!isPayer || !report.isWaitingOnBankAccount) {
440+
return false;
441+
}
442+
443+
const payActions = getReportPayActions(report.reportID);
444+
return everyPayActionHasPaymentType(payActions, (paymentType) => paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY);
445+
}
446+
447+
const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
448+
418449
if (!isAdmin || !isPayer) {
419450
return false;
420451
}
421452

422-
// Get all report actions for this report and filter for pay actions
423-
// Pay actions are at the report level, not per transaction
424-
const allReportActions = getAllReportActions(report.reportID);
425-
const allActionsArray = Object.values(allReportActions);
426-
const payActions = allActionsArray.filter((action): action is ReportAction => !!action && isPayAction(action));
453+
const payActions = getReportPayActions(report.reportID);
427454

428455
// Check if payment was made via bank account (not elsewhere)
429-
// If no pay actions exist, we can't determine the payment type, so we assume it was NOT a bank payment
430-
const isPaidViaBankAccount =
431-
payActions.length > 0 &&
432-
payActions.every((action) => {
433-
const originalMessage = getOriginalMessage(action);
434-
return originalMessage && 'paymentType' in originalMessage && originalMessage.paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE;
435-
});
456+
const isPaidViaBankAccount = everyPayActionHasPaymentType(payActions, (paymentType) => paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE);
436457

437458
// For reports marked as paid elsewhere or when we can't determine payment type, show cancel button
438459
if (report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED && !isPaidViaBankAccount) {

src/libs/actions/IOU/PayMoneyRequest.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
isExpenseReport,
2525
isIndividualInvoiceRoom,
2626
isInvoiceReport as isInvoiceReportReportUtils,
27+
isIOUReport,
2728
updateReportPreview,
2829
} from '@libs/ReportUtils';
2930
import playSound, {SOUNDS} from '@libs/Sound';
@@ -521,6 +522,87 @@ function getPayMoneyRequestParams({
521522
};
522523
}
523524

525+
/**
526+
* Cancels a P2P "send money" wallet payment that is waiting for the receiver to set up their wallet. The
527+
* sender (payer) can do this while the payment is held; it returns the held funds. These reports have no
528+
* policy, approval flow, or next step, so we only reverse the optimistic IOU report state.
529+
*/
530+
function cancelSendMoneyPayment(iouReport: OnyxTypes.Report, chatReport: OnyxTypes.Report, currentUserAccountIDParam: number) {
531+
const optimisticIOUCancelAction = buildOptimisticCancelPaymentReportAction(iouReport.reportID, -(iouReport.total ?? 0), iouReport.currency ?? '', currentUserAccountIDParam);
532+
533+
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT>> = [
534+
{
535+
onyxMethod: Onyx.METHOD.MERGE,
536+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
537+
value: {
538+
[optimisticIOUCancelAction.reportActionID]: optimisticIOUCancelAction as OnyxTypes.ReportAction,
539+
},
540+
},
541+
{
542+
onyxMethod: Onyx.METHOD.MERGE,
543+
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
544+
value: {
545+
isWaitingOnBankAccount: false,
546+
isCancelledIOU: true,
547+
// These reports belong to the sender's personal policy, which approves optionally, so cancelling
548+
// returns the report to submitted and closed rather than approved.
549+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
550+
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
551+
lastVisibleActionCreated: optimisticIOUCancelAction.created,
552+
lastMessageText: getReportActionText(optimisticIOUCancelAction),
553+
lastMessageHtml: getReportActionHtml(optimisticIOUCancelAction),
554+
},
555+
},
556+
];
557+
558+
const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [
559+
{
560+
onyxMethod: Onyx.METHOD.MERGE,
561+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
562+
value: {
563+
[optimisticIOUCancelAction.reportActionID]: {
564+
pendingAction: null,
565+
},
566+
},
567+
},
568+
];
569+
570+
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT>> = [
571+
{
572+
onyxMethod: Onyx.METHOD.MERGE,
573+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
574+
value: {
575+
[optimisticIOUCancelAction.reportActionID]: {
576+
errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other'),
577+
},
578+
},
579+
},
580+
{
581+
onyxMethod: Onyx.METHOD.MERGE,
582+
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
583+
value: {
584+
isWaitingOnBankAccount: iouReport.isWaitingOnBankAccount,
585+
isCancelledIOU: false,
586+
stateNum: iouReport.stateNum,
587+
statusNum: iouReport.statusNum,
588+
},
589+
},
590+
];
591+
592+
API.write(
593+
WRITE_COMMANDS.CANCEL_PAYMENT,
594+
{
595+
iouReportID: iouReport.reportID,
596+
chatReportID: chatReport.reportID,
597+
managerAccountID: iouReport.managerID ?? CONST.DEFAULT_NUMBER_ID,
598+
reportActionID: optimisticIOUCancelAction.reportActionID,
599+
},
600+
{optimisticData, successData, failureData},
601+
);
602+
603+
notifyNewAction(iouReport.reportID, undefined, true);
604+
}
605+
524606
function cancelPayment(
525607
expenseReport: OnyxEntry<OnyxTypes.Report>,
526608
chatReport: OnyxTypes.Report,
@@ -535,6 +617,15 @@ function cancelPayment(
535617
return;
536618
}
537619

620+
// A P2P "send money" payment waiting for the receiver to set up their wallet has no policy, approval
621+
// flow, or next step, so it is cancelled through a simplified path that just reverses the optimistic IOU
622+
// report state and lets the backend return the held funds. Other IOU reports (e.g. a money request paid
623+
// elsewhere) keep using the standard path below.
624+
if (isIOUReport(expenseReport) && expenseReport.isWaitingOnBankAccount) {
625+
cancelSendMoneyPayment(expenseReport, chatReport, currentUserAccountIDParam);
626+
return;
627+
}
628+
538629
// Prefer the freshly computed reimbursableTotal over deriving from the (sometimes stale) stored total.
539630
const optimisticReportAction = buildOptimisticCancelPaymentReportAction(
540631
expenseReport.reportID,

tests/actions/IOUTest/PayMoneyRequestTest.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,45 @@ describe('actions/IOU/PayMoneyRequest', () => {
16821682
});
16831683
});
16841684

1685+
it('cancels a pending P2P wallet payment and optimistically marks the IOU report cancelled', async () => {
1686+
// Given a P2P "send money" IOU report that is waiting for the receiver to set up their wallet
1687+
const chatReportID = '7777';
1688+
const iouReportID = '8888';
1689+
const chatReport: Report = {...createRandomReport(7777, undefined), reportID: chatReportID};
1690+
const iouReport: Report = {
1691+
...createRandomReport(8888, undefined),
1692+
reportID: iouReportID,
1693+
chatReportID,
1694+
type: CONST.REPORT.TYPE.IOU,
1695+
managerID: CARLOS_ACCOUNT_ID,
1696+
ownerAccountID: RORY_ACCOUNT_ID,
1697+
total: -amount,
1698+
currency: CONST.CURRENCY.USD,
1699+
isWaitingOnBankAccount: true,
1700+
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
1701+
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
1702+
};
1703+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport);
1704+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport);
1705+
await waitForBatchedUpdates();
1706+
1707+
// When the payer cancels the pending payment
1708+
cancelPayment(iouReport, chatReport, undefined, true, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, false);
1709+
await waitForBatchedUpdates();
1710+
1711+
// Then the IOU report is optimistically marked cancelled and no longer waiting on the bank account
1712+
const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`);
1713+
expect(updatedReport?.isCancelledIOU).toBe(true);
1714+
expect(updatedReport?.isWaitingOnBankAccount).toBe(false);
1715+
expect(updatedReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.SUBMITTED);
1716+
expect(updatedReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.CLOSED);
1717+
1718+
// And a reimbursement-dequeued cancel action is added to the IOU report
1719+
const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`);
1720+
const cancelAction = Object.values(reportActions ?? {}).find((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED);
1721+
expect(cancelAction).toBeTruthy();
1722+
});
1723+
16851724
it('optimistic nextStep shows waiting to pay when approvals are disabled and bank account is connected', async () => {
16861725
const adminEmail = 'admin@expensifail.com';
16871726
const adminAccountID = 10;

0 commit comments

Comments
 (0)