From 09b006e14b75ce4aaea102cf080a6e624c8381b3 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:15:13 -0800 Subject: [PATCH] Handle multiple visits to Paypal approval return URI --- ts/services/donations.preload.ts | 63 ++++++++++++++++++++----- ts/services/donationsLoader.preload.ts | 1 + ts/state/ducks/donations.preload.ts | 11 +++++ ts/windows/main/preload_test.preload.ts | 1 + 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/ts/services/donations.preload.ts b/ts/services/donations.preload.ts index 5c1d38e799..e0f08b56b1 100644 --- a/ts/services/donations.preload.ts +++ b/ts/services/donations.preload.ts @@ -262,12 +262,34 @@ export async function approvePaypalPayment({ try { const existing = _getWorkflowFromRedux(); + const lastReturnToken = _getLastReturnTokenFromRedux(); + if (!existing) { + // This can happen if after you finished a Paypal donation, but you go back to + // the Paypal website and click Return to Signal again. + if (returnToken === lastReturnToken) { + if (!isDonationPageVisible()) { + redirectToPage(SettingsPage.Donations); + } + return; + } + throw new Error( 'approvePaypalPayment: Cannot finish nonexistent workflow!' ); } + // If you visit the approval link twice in succession, this can happen + if (isPaypalAlreadyApproved(existing)) { + log.warn( + 'approvePaypalPayment: Existing workflow already approved, not trying to approve again' + ); + if (!isDonationPageVisible()) { + redirectToPage(SettingsPage.Donations); + } + return; + } + if (payerId == null || paymentToken == null) { throw new Error( 'approvePaypalPayment: payerId or paymentToken are missing' @@ -296,15 +318,20 @@ export async function cancelPaypalPayment(_returnToken: string): Promise { log.info(`${logId}: User visited PayPal cancel URI, showing donate flow`); if (!isDonationPageVisible()) { - window.reduxActions.nav.changeLocation({ - tab: NavTab.Settings, - details: { - page: SettingsPage.DonationsDonateFlow, - }, - }); + redirectToPage(SettingsPage.DonationsDonateFlow); } } +function isPaypalAlreadyApproved(workflow: DonationWorkflow): boolean { + const { type } = workflow; + return ( + type === donationStateSchema.Enum.PAYPAL_APPROVED || + type === donationStateSchema.Enum.PAYMENT_CONFIRMED || + type === donationStateSchema.Enum.RECEIPT || + type === donationStateSchema.Enum.DONE + ); +} + export async function clearDonation(): Promise { runDonationAbortController?.abort(); await _saveWorkflow(undefined); @@ -531,12 +558,7 @@ export async function _runDonationWorkflow(): Promise { } else if (type === donationStateSchema.Enum.DONE) { if (isDonationPageVisible()) { if (isDonationsDonateFlowVisible()) { - window.reduxActions.nav.changeLocation({ - tab: NavTab.Settings, - details: { - page: SettingsPage.Donations, - }, - }); + redirectToPage(SettingsPage.Donations); } } else { log.info( @@ -1178,6 +1200,9 @@ async function _saveWorkflow( await _saveWorkflowToStorage(workflow); _saveWorkflowToRedux(workflow); } +export function _getLastReturnTokenFromRedux(): string | undefined { + return window.reduxStore.getState().donations.lastReturnToken; +} export function _getWorkflowFromRedux(): DonationWorkflow | undefined { return window.reduxStore.getState().donations.currentWorkflow; } @@ -1283,6 +1308,20 @@ function isDonationsDonateFlowVisible() { ); } +function redirectToPage( + page: + | SettingsPage.Donations + | SettingsPage.DonationsDonateFlow + | SettingsPage.DonationsReceiptList +) { + window.reduxActions.nav.changeLocation({ + tab: NavTab.Settings, + details: { + page, + }, + }); +} + // Working with zkgroup receipts function getServerPublicParams(): ServerPublicParams { diff --git a/ts/services/donationsLoader.preload.ts b/ts/services/donationsLoader.preload.ts index 69758a3613..b61e09a5bc 100644 --- a/ts/services/donationsLoader.preload.ts +++ b/ts/services/donationsLoader.preload.ts @@ -26,6 +26,7 @@ export function getDonationsForRedux(): DonationsStateType { currentWorkflow, didResumeWorkflowAtStartup: Boolean(currentWorkflow), lastError: undefined, + lastReturnToken: undefined, receipts: donationReceipts, configCache: undefined, }; diff --git a/ts/state/ducks/donations.preload.ts b/ts/state/ducks/donations.preload.ts index d6eab4b610..2897327f81 100644 --- a/ts/state/ducks/donations.preload.ts +++ b/ts/state/ducks/donations.preload.ts @@ -45,6 +45,7 @@ export type DonationsStateType = ReadonlyDeep<{ currentWorkflow: DonationWorkflow | undefined; didResumeWorkflowAtStartup: boolean; lastError: DonationErrorType | undefined; + lastReturnToken: string | undefined; receipts: Array; configCache: OneTimeDonationHumanAmounts | undefined; }>; @@ -435,6 +436,7 @@ export function getEmptyState(): DonationsStateType { currentWorkflow: undefined, didResumeWorkflowAtStartup: false, lastError: undefined, + lastReturnToken: undefined, receipts: [], configCache: undefined, }; @@ -475,6 +477,14 @@ export function reducer( if (action.type === UPDATE_WORKFLOW) { const { nextWorkflow } = action.payload; + let lastReturnToken: string | undefined; + const { currentWorkflow } = state; + if (currentWorkflow && 'returnToken' in currentWorkflow) { + lastReturnToken = currentWorkflow.returnToken; + } else { + lastReturnToken = state.lastReturnToken; + } + // If we've cleared the workflow or are starting afresh, we clear the startup flag const didResumeWorkflowAtStartup = !nextWorkflow || nextWorkflow.type === donationStateSchema.Enum.INTENT @@ -485,6 +495,7 @@ export function reducer( ...state, didResumeWorkflowAtStartup, currentWorkflow: nextWorkflow, + lastReturnToken, }; } diff --git a/ts/windows/main/preload_test.preload.ts b/ts/windows/main/preload_test.preload.ts index 8f99af3d0f..a1dcee6d9a 100644 --- a/ts/windows/main/preload_test.preload.ts +++ b/ts/windows/main/preload_test.preload.ts @@ -137,6 +137,7 @@ window.testUtilities = { currentWorkflow: undefined, didResumeWorkflowAtStartup: false, lastError: undefined, + lastReturnToken: undefined, receipts: [], configCache: undefined, },