Skip to content

Commit 3e5e449

Browse files
authored
Merge pull request Expensify#89478 from wildan-m/wildan/83295-partial-approve-offline-pay-total
Fix stale Pay total after offline partial approval of held expenses
2 parents 413a4c3 + 0a7dea9 commit 3e5e449

2 files changed

Lines changed: 193 additions & 1 deletion

File tree

src/libs/actions/IOU/Hold.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,11 @@ function getReportFromHoldRequestsOnyxData({
818818

819819
const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false;
820820

821+
// Held transactions just moved out, leaving total/nonReimbursableTotal stale on this report —
822+
// offline consumers (e.g. the Pay button) would read the wrong amount until server reconciles.
823+
// unheldTotal stays as-is: every remaining transaction is unheld, so it already equals the new total.
824+
const shouldUpdateOriginalReportTotals = holdTransactions.length > 0 && iouReport?.unheldTotal !== undefined;
825+
821826
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [
822827
{
823828
onyxMethod: Onyx.METHOD.MERGE,
@@ -871,6 +876,17 @@ function getReportFromHoldRequestsOnyxData({
871876
},
872877
];
873878

879+
if (shouldUpdateOriginalReportTotals) {
880+
optimisticData.push({
881+
onyxMethod: Onyx.METHOD.MERGE,
882+
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
883+
value: {
884+
total: iouReport?.unheldTotal ?? 0,
885+
nonReimbursableTotal: iouReport?.unheldNonReimbursableTotal ?? 0,
886+
},
887+
});
888+
}
889+
874890
const bringReportActionsBack: Record<string, OnyxTypes.ReportAction> = {};
875891
for (const reportAction of holdReportActions) {
876892
bringReportActionsBack[reportAction.reportActionID] = reportAction;
@@ -936,6 +952,17 @@ function getReportFromHoldRequestsOnyxData({
936952
},
937953
];
938954

955+
if (shouldUpdateOriginalReportTotals) {
956+
failureData.push({
957+
onyxMethod: Onyx.METHOD.MERGE,
958+
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
959+
value: {
960+
total: iouReport?.total,
961+
nonReimbursableTotal: iouReport?.nonReimbursableTotal,
962+
},
963+
});
964+
}
965+
939966
// Copy submission/approval actions to the new report
940967
const [copiedActionsOptimistic, copiedActionsSuccess, copiedActionsFailure, optimisticReportActionCopyIDs] = getDuplicateActionsForPartialReport(
941968
iouReport?.reportID,

tests/actions/IOUTest/HoldTest.ts

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
22
import Onyx from 'react-native-onyx';
33
import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx';
4-
import {putOnHold, putTransactionsOnHold, unholdRequest} from '@libs/actions/IOU/Hold';
4+
import {getReportFromHoldRequestsOnyxData, putOnHold, putTransactionsOnHold, unholdRequest} from '@libs/actions/IOU/Hold';
55
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
66
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
77
import type * as PolicyUtils from '@libs/PolicyUtils';
@@ -564,4 +564,169 @@ describe('actions/IOU/Hold', () => {
564564
});
565565
});
566566
});
567+
568+
describe('getReportFromHoldRequestsOnyxData', () => {
569+
const buildHeldTransaction = (reportID: string, amount: number) => {
570+
const transaction = buildOptimisticTransaction({
571+
transactionParams: {amount, currency: 'USD', reportID},
572+
});
573+
return {
574+
...transaction,
575+
comment: {...transaction.comment, hold: 'hold-reason'},
576+
};
577+
};
578+
579+
const buildScenario = (overrides: {total: number; nonReimbursableTotal: number; unheldTotal?: number; unheldNonReimbursableTotal?: number; heldAmount?: number}) => {
580+
const baseIouReport = buildOptimisticIOUReport(1, 2, overrides.total, '99', 'USD');
581+
const iouReport: Report = {
582+
...baseIouReport,
583+
total: overrides.total,
584+
nonReimbursableTotal: overrides.nonReimbursableTotal,
585+
unheldTotal: overrides.unheldTotal,
586+
unheldNonReimbursableTotal: overrides.unheldNonReimbursableTotal,
587+
};
588+
const chatReport: Report = {
589+
reportID: '99',
590+
iouReportID: iouReport.reportID,
591+
lastVisibleActionCreated: '2026-01-01 00:00:00.000',
592+
} as Report;
593+
594+
const heldAmount = overrides.heldAmount ?? 0;
595+
const heldTransaction = heldAmount > 0 ? buildHeldTransaction(iouReport.reportID, heldAmount) : undefined;
596+
const heldIouAction = heldTransaction
597+
? buildOptimisticIOUReportAction({
598+
type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
599+
amount: heldTransaction.amount,
600+
currency: 'USD',
601+
comment: '',
602+
participants: [],
603+
transactionID: heldTransaction.transactionID,
604+
})
605+
: undefined;
606+
607+
const reportCollection: ReportCollectionDataSet = {
608+
[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport,
609+
[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`]: chatReport,
610+
};
611+
const transactionCollection: TransactionCollectionDataSet = heldTransaction ? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${heldTransaction.transactionID}`]: heldTransaction} : {};
612+
const actionCollection: ReportActionsCollectionDataSet = heldIouAction
613+
? {
614+
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]: {
615+
[heldIouAction.reportActionID]: heldIouAction,
616+
},
617+
}
618+
: {};
619+
620+
return {chatReport, iouReport, reportCollection, transactionCollection, actionCollection};
621+
};
622+
623+
test('should optimistically update original report total to unheldTotal when held transactions are moved out', () => {
624+
const {chatReport, iouReport, reportCollection, transactionCollection, actionCollection} = buildScenario({
625+
total: 300,
626+
nonReimbursableTotal: 50,
627+
unheldTotal: 200,
628+
unheldNonReimbursableTotal: 30,
629+
heldAmount: 100,
630+
});
631+
632+
return waitForBatchedUpdates()
633+
.then(() => Onyx.multiSet({...reportCollection, ...transactionCollection, ...actionCollection}))
634+
.then(() => {
635+
const result = getReportFromHoldRequestsOnyxData({
636+
chatReport,
637+
iouReport,
638+
recipient: {accountID: 1},
639+
policy: undefined,
640+
betas: [],
641+
});
642+
const totalsUpdate = result.optimisticData.find((entry) => entry.onyxMethod === Onyx.METHOD.MERGE && entry.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`);
643+
expect(totalsUpdate).toBeDefined();
644+
expect(totalsUpdate?.value).toEqual({total: 200, nonReimbursableTotal: 30});
645+
});
646+
});
647+
648+
test('should include matching failureData entry to restore original totals on rollback', () => {
649+
const {chatReport, iouReport, reportCollection, transactionCollection, actionCollection} = buildScenario({
650+
total: 300,
651+
nonReimbursableTotal: 50,
652+
unheldTotal: 200,
653+
unheldNonReimbursableTotal: 30,
654+
heldAmount: 100,
655+
});
656+
657+
return waitForBatchedUpdates()
658+
.then(() => Onyx.multiSet({...reportCollection, ...transactionCollection, ...actionCollection}))
659+
.then(() => {
660+
const result = getReportFromHoldRequestsOnyxData({
661+
chatReport,
662+
iouReport,
663+
recipient: {accountID: 1},
664+
policy: undefined,
665+
betas: [],
666+
});
667+
const restorationEntries = result.failureData.filter(
668+
(entry) => entry.onyxMethod === Onyx.METHOD.MERGE && entry.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
669+
);
670+
const totalsRestore = restorationEntries.find((entry) => {
671+
const value = entry.value as Partial<Report> | undefined;
672+
return value?.total !== undefined || value?.nonReimbursableTotal !== undefined;
673+
});
674+
expect(totalsRestore?.value).toEqual({total: 300, nonReimbursableTotal: 50});
675+
});
676+
});
677+
678+
test('should not push a totals update when no held transactions exist', () => {
679+
const {chatReport, iouReport, reportCollection, transactionCollection, actionCollection} = buildScenario({
680+
total: 300,
681+
nonReimbursableTotal: 50,
682+
unheldTotal: 300,
683+
unheldNonReimbursableTotal: 50,
684+
heldAmount: 0,
685+
});
686+
687+
return waitForBatchedUpdates()
688+
.then(() => Onyx.multiSet({...reportCollection, ...transactionCollection, ...actionCollection}))
689+
.then(() => {
690+
const result = getReportFromHoldRequestsOnyxData({
691+
chatReport,
692+
iouReport,
693+
recipient: {accountID: 1},
694+
policy: undefined,
695+
betas: [],
696+
});
697+
const totalsUpdates = result.optimisticData.filter((entry) => {
698+
const value = entry.value as Partial<Report> | undefined;
699+
return entry.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}` && (value?.total !== undefined || value?.nonReimbursableTotal !== undefined);
700+
});
701+
expect(totalsUpdates).toEqual([]);
702+
});
703+
});
704+
705+
test('should not push a totals update when iouReport.unheldTotal is undefined', () => {
706+
const {chatReport, iouReport, reportCollection, transactionCollection, actionCollection} = buildScenario({
707+
total: 300,
708+
nonReimbursableTotal: 50,
709+
unheldTotal: undefined,
710+
unheldNonReimbursableTotal: undefined,
711+
heldAmount: 100,
712+
});
713+
714+
return waitForBatchedUpdates()
715+
.then(() => Onyx.multiSet({...reportCollection, ...transactionCollection, ...actionCollection}))
716+
.then(() => {
717+
const result = getReportFromHoldRequestsOnyxData({
718+
chatReport,
719+
iouReport,
720+
recipient: {accountID: 1},
721+
policy: undefined,
722+
betas: [],
723+
});
724+
const totalsUpdates = result.optimisticData.filter((entry) => {
725+
const value = entry.value as Partial<Report> | undefined;
726+
return entry.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}` && (value?.total !== undefined || value?.nonReimbursableTotal !== undefined);
727+
});
728+
expect(totalsUpdates).toEqual([]);
729+
});
730+
});
731+
});
567732
});

0 commit comments

Comments
 (0)