Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions src/libs/ReportSecondaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -311,28 +311,42 @@ 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), 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 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) => {
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;
Expand Down Expand Up @@ -798,6 +812,7 @@ function getSecondaryReportActions({
if (isDeleteAction(report, reportTransactions, reportActions ?? [])) {
options.push(CONST.REPORT.SECONDARY_ACTIONS.DELETE);
}

return options;
}

Expand Down
154 changes: 152 additions & 2 deletions tests/unit/ReportSecondaryActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -876,10 +879,156 @@ 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit tests added in this PR caused regression of flaky tests.
#78620
#79268

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @situchan

Thanks for the notice!

We already discussed with arosiclair yesterday on slack. Prepared a PR raising it.

});

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.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 = {
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);

Expand All @@ -890,8 +1039,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});

Expand Down
Loading