From a0af1ca50b51f2ef21abb7da724a675be7f86737 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 17 Dec 2025 20:31:10 +0300 Subject: [PATCH 1/3] fix: Pay with Expensify-Cancel payment option is not displayed for the paid expense --- src/libs/ReportSecondaryActionUtils.ts | 39 +++++++++----- tests/unit/ReportSecondaryActionUtilsTest.ts | 57 +++++++++++++++++++- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index d8b3ff80303e..4b9a762fd3cf 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -19,7 +19,7 @@ import { isPreferredExporter, isSubmitAndClose, } from './PolicyUtils'; -import {getIOUActionForReportID, getIOUActionForTransactionID, getOneTransactionThreadReportID, getReportAction, isPayAction} from './ReportActionsUtils'; +import {getAllReportActions, getIOUActionForTransactionID, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isPayAction} from './ReportActionsUtils'; import {getReportPrimaryAction, isPrimaryPayAction} from './ReportPrimaryActionUtils'; import { canAddTransaction, @@ -310,28 +310,40 @@ function isCancelPaymentAction(report: Report, reportTransactions: Transaction[] return false; } - const isReportPaidElsewhere = report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + // Get all report actions for this report and filter for pay actions + // Pay actions are at the report level, not per transaction + const allReportActions = getAllReportActions(report.reportID); + const allActionsArray = Object.values(allReportActions); + const payActions = allActionsArray.filter((action): action is ReportAction => !!action && isPayAction(action)); - if (isReportPaidElsewhere) { + // Check if payment was made via bank account (not elsewhere) + // If no pay actions exist, we can't determine the payment type, so we assume it was NOT a bank payment + const isPaidViaBankAccount = + payActions.length > 0 && + payActions.every((action) => { + const originalMessage = getOriginalMessage(action); + return originalMessage && 'paymentType' in originalMessage && originalMessage.paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE; + }); + + // For reports marked as paid elsewhere or when we can't determine payment type, show cancel button + if (report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED && !isPaidViaBankAccount) { return true; } - const isPaymentProcessing = !!report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; - - const payActions = reportTransactions.reduce((acc, transaction) => { - const action = getIOUActionForReportID(report.reportID, transaction.transactionID); - if (action && isPayAction(action)) { - acc.push(action); - } - return acc; - }, [] as ReportAction[]); + // Bank payment is processing when: + // 1. In BILLING state (ACH batch submitted), OR + // 2. In APPROVED + REIMBURSED state (immediately after paying via bank, before batch is sent) + const isInBillingState = report.stateNum === CONST.REPORT.STATE_NUM.BILLING; + const isApprovedAndReimbursed = report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + const isBankProcessing = isPaidViaBankAccount && (isInBillingState || isApprovedAndReimbursed); + const isPaymentProcessing = (!!report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED) || isBankProcessing; const hasDailyNachaCutoffPassed = payActions.some((action) => { const now = new Date(); const paymentDatetime = new Date(action.created); const nowUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds())); const cutoffTimeUTC = new Date(Date.UTC(paymentDatetime.getUTCFullYear(), paymentDatetime.getUTCMonth(), paymentDatetime.getUTCDate(), 23, 45, 0)); - return nowUTC.getTime() < cutoffTimeUTC.getTime(); + return nowUTC.getTime() > cutoffTimeUTC.getTime(); }); return isPaymentProcessing && !hasDailyNachaCutoffPassed; @@ -794,6 +806,7 @@ function getSecondaryReportActions({ if (isDeleteAction(report, reportTransactions, reportActions ?? [])) { options.push(CONST.REPORT.SECONDARY_ACTIONS.DELETE); } + return options; } diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index af5c782e03b9..1bf9902d4217 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -876,10 +876,62 @@ describe('getSecondaryAction', () => { reportID: REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.BILLING, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, isWaitingOnBankAccount: true, + managerID: EMPLOYEE_ACCOUNT_ID, } as unknown as Report; - const policy = {role: CONST.POLICY.ROLE.ADMIN} as unknown as Policy; + const policy = { + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + } as unknown as Policy; + const TRANSACTION_ID = 'transaction_id'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const ACTION_ID = 'action_id'; + const reportAction = { + actionID: ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + message: { + IOUTransactionID: TRANSACTION_ID, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + }, + created: new Date().toISOString(), + } as unknown as ReportAction; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[ACTION_ID]: reportAction}); + + const result = getSecondaryReportActions({ + currentUserEmail: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [ + { + transactionID: TRANSACTION_ID, + } as unknown as Transaction, + ], + originalTransaction: {} as Transaction, + violations: {}, + policy, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT option for bank payment in BILLING state', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.BILLING, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: EMPLOYEE_ACCOUNT_ID, + } as unknown as Report; + const policy = { + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + } as unknown as Policy; const TRANSACTION_ID = 'transaction_id'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); @@ -890,8 +942,9 @@ describe('getSecondaryAction', () => { message: { IOUTransactionID: TRANSACTION_ID, type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, }, - created: '2025-03-06 18:00:00.000', + created: new Date().toISOString(), } as unknown as ReportAction; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[ACTION_ID]: reportAction}); From 9485cca4ae52548c2849e6ca63ac88fa3da54b6e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 19 Dec 2025 13:03:33 +0300 Subject: [PATCH 2/3] fix: add AUTOREIMBURSED state and status check to cancel payment logic - Added AUTOREIMBURSED (stateNum: 6) state check for bank processing - Added STATUS_NUM.REIMBURSED check to isInBillingState for consistency --- src/libs/ReportSecondaryActionUtils.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 63b2ef17a9f9..14166cdb8ae1 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -333,10 +333,12 @@ function isCancelPaymentAction(report: Report, reportTransactions: Transaction[] // Bank payment is processing when: // 1. In BILLING state (ACH batch submitted), OR - // 2. In APPROVED + REIMBURSED state (immediately after paying via bank, before batch is sent) - const isInBillingState = report.stateNum === CONST.REPORT.STATE_NUM.BILLING; + // 2. In APPROVED + REIMBURSED state (immediately after paying via bank, before batch is sent), OR + // 3. In AUTOREIMBURSED state (automatically reimbursed) + const isInBillingState = report.stateNum === CONST.REPORT.STATE_NUM.BILLING && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; const isApprovedAndReimbursed = report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; - const isBankProcessing = isPaidViaBankAccount && (isInBillingState || isApprovedAndReimbursed); + const isAutoReimbursed = report.stateNum === CONST.REPORT.STATE_NUM.AUTOREIMBURSED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + const isBankProcessing = isPaidViaBankAccount && (isInBillingState || isApprovedAndReimbursed || isAutoReimbursed); const isPaymentProcessing = (!!report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED) || isBankProcessing; const hasDailyNachaCutoffPassed = payActions.some((action) => { From 9009a69025c331c980f881d4b04e449575db33df Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 20 Dec 2025 01:14:41 +0300 Subject: [PATCH 3/3] fix: add AUTOREIMBURSED state and complete status checks for cancel payment --- tests/unit/ReportSecondaryActionUtilsTest.ts | 99 +++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 22ffe8916c18..5a09d4113345 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -853,9 +853,12 @@ describe('getSecondaryAction', () => { ownerAccountID: EMPLOYEE_ACCOUNT_ID, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + managerID: EMPLOYEE_ACCOUNT_ID, } as unknown as Report; const policy = { role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, } as unknown as Policy; const result = getSecondaryReportActions({ @@ -924,7 +927,101 @@ describe('getSecondaryAction', () => { type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: EMPLOYEE_ACCOUNT_ID, stateNum: CONST.REPORT.STATE_NUM.BILLING, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + managerID: EMPLOYEE_ACCOUNT_ID, + } as unknown as Report; + const policy = { + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + } as unknown as Policy; + const TRANSACTION_ID = 'transaction_id'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const ACTION_ID = 'action_id'; + const reportAction = { + actionID: ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + message: { + IOUTransactionID: TRANSACTION_ID, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + }, + created: new Date().toISOString(), + } as unknown as ReportAction; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[ACTION_ID]: reportAction}); + + const result = getSecondaryReportActions({ + currentUserEmail: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [ + { + transactionID: TRANSACTION_ID, + } as unknown as Transaction, + ], + originalTransaction: {} as Transaction, + violations: {}, + policy, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT option for bank payment in APPROVED + REIMBURSED state', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + managerID: EMPLOYEE_ACCOUNT_ID, + } as unknown as Report; + const policy = { + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + } as unknown as Policy; + const TRANSACTION_ID = 'transaction_id'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const ACTION_ID = 'action_id'; + const reportAction = { + actionID: ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + message: { + IOUTransactionID: TRANSACTION_ID, + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + }, + created: new Date().toISOString(), + } as unknown as ReportAction; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[ACTION_ID]: reportAction}); + + const result = getSecondaryReportActions({ + currentUserEmail: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [ + { + transactionID: TRANSACTION_ID, + } as unknown as Transaction, + ], + originalTransaction: {} as Transaction, + violations: {}, + policy, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT)).toBe(true); + }); + + it('includes CANCEL_PAYMENT option for auto-reimbursed payment', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.AUTOREIMBURSED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, managerID: EMPLOYEE_ACCOUNT_ID, } as unknown as Report; const policy = {