diff --git a/app/main.main.ts b/app/main.main.ts index 10ae8d8295..53609f229e 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -3052,6 +3052,12 @@ function handleSignalRoute(route: ParsedSignalRoute) { } else if (route.key === 'donationValidationComplete') { log.info('donationValidationComplete route handled'); mainWindow.webContents.send('donation-validation-complete', route.args); + } else if (route.key === 'donationPaypalApproved') { + log.info('donationPaypalApproved route handled'); + mainWindow.webContents.send('donation-paypal-approved', route.args); + } else if (route.key === 'donationPaypalCanceled') { + log.info('donationPaypalCanceled route handled'); + mainWindow.webContents.send('donation-paypal-canceled', route.args); } else { log.info('handleSignalRoute: Unknown signal route:', route.key); mainWindow.webContents.send('unknown-sgnl-link'); diff --git a/ts/components/PreferencesDonateFlow.dom.tsx b/ts/components/PreferencesDonateFlow.dom.tsx index 8dad600e77..b945793db4 100644 --- a/ts/components/PreferencesDonateFlow.dom.tsx +++ b/ts/components/PreferencesDonateFlow.dom.tsx @@ -101,6 +101,7 @@ const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => { const finalizedStates: Array = [ donationStateSchema.Enum.INTENT_CONFIRMED, donationStateSchema.Enum.INTENT_REDIRECT, + donationStateSchema.Enum.PAYMENT_CONFIRMED, donationStateSchema.Enum.RECEIPT, donationStateSchema.Enum.DONE, ]; diff --git a/ts/components/PreferencesDonations.dom.tsx b/ts/components/PreferencesDonations.dom.tsx index a4fb991296..c410c67f21 100644 --- a/ts/components/PreferencesDonations.dom.tsx +++ b/ts/components/PreferencesDonations.dom.tsx @@ -591,6 +591,7 @@ export function PreferencesDonations({ if ( workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.PAYMENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT || workflow?.type === donationStateSchema.Enum.DONE ) { @@ -693,6 +694,7 @@ export function PreferencesDonations({ settingsLocation.page === SettingsPage.DonationsDonateFlow && (isSubmitted || workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.PAYMENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT) ) { // We can't transition away from the payment screen until that payment information @@ -700,6 +702,7 @@ export function PreferencesDonations({ if ( hasProcessingExpired && (workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || + workflow?.type === donationStateSchema.Enum.PAYMENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT) ) { dialog = ( diff --git a/ts/services/donations.preload.ts b/ts/services/donations.preload.ts index 992f6e1ea9..96504922b4 100644 --- a/ts/services/donations.preload.ts +++ b/ts/services/donations.preload.ts @@ -20,7 +20,11 @@ import { getRandomBytes, sha256 } from '../Crypto.node.js'; import { DataWriter } from '../sql/Client.preload.js'; import { createLogger } from '../logging/log.std.js'; import { getProfile } from '../util/getProfile.preload.js'; -import { donationValidationCompleteRoute } from '../util/signalRoutes.std.js'; +import { + donationPaypalApprovedRoute, + donationPaypalCanceledRoute, + donationValidationCompleteRoute, +} from '../util/signalRoutes.std.js'; import { safeParseStrict, safeParseUnknown } from '../util/schemas.std.js'; import { missingCaseError } from '../util/missingCaseError.std.js'; import { exponentialBackoffSleepTime } from '../util/exponentialBackoff.std.js'; @@ -32,6 +36,7 @@ import { donationErrorTypeSchema, donationStateSchema, donationWorkflowSchema, + donationProcessorSchema, } from '../types/Donations.std.js'; import type { @@ -52,6 +57,8 @@ import { createBoostReceiptCredentials, redeemReceipt, isOnline, + createPaypalBoostPayment, + confirmPaypalBoostPayment, } from '../textsecure/WebAPI.preload.js'; import { itemStorage } from '../textsecure/Storage.preload.js'; @@ -210,6 +217,65 @@ export async function finish3dsValidation(token: string): Promise { await _saveAndRunWorkflow(workflow); } +export async function approvePaypalPayment({ + payerId, + paymentToken, + returnToken, +}: { + payerId: string | undefined; + paymentToken: string | undefined; + returnToken: string; +}): Promise { + let workflow: DonationWorkflow; + + try { + const existing = _getWorkflowFromRedux(); + if (!existing) { + throw new Error( + 'approvePaypalPayment: Cannot finish nonexistent workflow!' + ); + } + + if (payerId == null || paymentToken == null) { + throw new Error( + 'approvePaypalPayment: payerId or paymentToken are missing' + ); + } + + workflow = await _completePaypalApprovalRedirect({ + workflow: existing, + returnToken, + payerId, + paymentToken, + }); + } catch (error) { + await failDonation(donationErrorTypeSchema.Enum.GeneralError); + throw error; + } + + await _saveAndRunWorkflow(workflow); +} + +export async function cancelPaypalPayment(returnToken: string): Promise { + const logId = 'cancelPaypalPayment'; + log.info(`${logId}: Canceling workflow after user visited cancel URI`); + + const existing = _getWorkflowFromRedux(); + if (!existing) { + throw new Error(`${logId}: Cannot finish nonexistent workflow!`); + } + + if (existing.type !== donationStateSchema.Enum.PAYPAL_INTENT) { + throw new Error(`${logId}: Workflow not type PAYPAL_INTENT`); + } + + if (returnToken !== existing.returnToken) { + throw new Error(`${logId}: The provided token did not match saved token`); + } + + await clearDonation(); +} + export async function clearDonation(): Promise { runDonationAbortController?.abort(); await _saveWorkflow(undefined); @@ -261,6 +327,44 @@ export async function _internalDoDonation({ } } +// For testing + +export async function _internalDoPaypalDonation({ + currencyType, + paymentAmount, +}: { + currencyType: string; + paymentAmount: StripeDonationAmount; +}): Promise { + if (isInternalDonationInProgress) { + throw new Error("Can't proceed because a donation is in progress."); + } + + const logId = '_internalDoPaypalDonation'; + try { + isInternalDonationInProgress = true; + + const workflow = await _createPaypalIntent({ + currencyType, + paymentAmount, + workflow: undefined, + }); + await _saveWorkflow(workflow); + if (workflow.type !== donationStateSchema.Enum.PAYPAL_INTENT) { + throw new Error(`${logId}: Resulting workflow not PAYPAL_INTENT`); + } + + const { approvalUrl } = workflow; + log.info(`${logId}: Visit URL in browser to continue:`, approvalUrl); + } catch (error) { + log.error(logId, error); + const errorType: string | undefined = error.response?.error?.type; + await failDonation(donationErrorTypeSchema.Enum.GeneralError, errorType); + } finally { + isInternalDonationInProgress = false; + } +} + // High-level functions to move things forward export async function _saveAndRunWorkflow( @@ -376,7 +480,18 @@ export async function _runDonationWorkflow(): Promise { }); } return; - } else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) { + } else if (type === donationStateSchema.Enum.PAYPAL_INTENT) { + log.info( + `${logId}: Waiting for user to return from PayPal. Returning.` + ); + return; + } else if (type === donationStateSchema.Enum.PAYPAL_APPROVED) { + log.info(`${logId}: Attempting to confirm PayPal payment`); + updated = await _confirmPaypalPayment(existing); + } else if ( + type === donationStateSchema.Enum.INTENT_CONFIRMED || + type === donationStateSchema.Enum.PAYMENT_CONFIRMED + ) { log.info(`${logId}: Attempting to get receipt`); updated = await _getReceipt(existing); // continuing @@ -629,12 +744,60 @@ export async function _confirmPayment( ); } - log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`); + log.info(`${logId}: Successfully transitioned to PAYMENT_CONFIRMED`); return { ...workflow, ...receiptContext, - type: donationStateSchema.Enum.INTENT_CONFIRMED, + type: donationStateSchema.Enum.PAYMENT_CONFIRMED, + processor: donationProcessorSchema.Enum.STRIPE, + timestamp: Date.now(), + }; + }); +} + +export async function _confirmPaypalPayment( + workflow: DonationWorkflow +): Promise { + const logId = `_confirmPaypalPayment(${redactId(workflow.id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.PAYPAL_APPROVED) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not at type PAYPAL_APPROVED, unable to confirm payment` + ); + } + + log.info(`${logId}: Starting`); + + const { + currencyType: currency, + paymentAmount: amount, + paypalPayerId: payerId, + paypalPaymentId: paymentId, + paypalPaymentToken: paymentToken, + } = workflow; + const payload = { + currency, + amount, + level: 1, + payerId, + paymentId, + paymentToken, + }; + const result = await confirmPaypalBoostPayment(payload); + const { paymentId: paymentIntentId } = result; + + const receiptContext = getReceiptContext(); + + log.info(`${logId}: Successfully transitioned to PAYMENT_CONFIRMED`); + + return { + ...workflow, + ...receiptContext, + type: donationStateSchema.Enum.PAYMENT_CONFIRMED, + processor: donationProcessorSchema.Enum.PAYPAL, + paymentIntentId, timestamp: Date.now(), }; }); @@ -659,11 +822,103 @@ export async function _completeValidationRedirect( throw new Error(`${logId}: The provided token did not match saved token`); } - log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`); + log.info(`${logId}: Successfully transitioned to PAYMENT_CONFIRMED`); return { ...workflow, - type: donationStateSchema.Enum.INTENT_CONFIRMED, + type: donationStateSchema.Enum.PAYMENT_CONFIRMED, + processor: donationProcessorSchema.Enum.STRIPE, + timestamp: Date.now(), + }; + }); +} + +export async function _completePaypalApprovalRedirect({ + workflow, + returnToken, + payerId, + paymentToken, +}: { + workflow: DonationWorkflow; + returnToken: string; + payerId: string; + paymentToken: string; +}): Promise { + const logId = `_completePaypalApprovalRedirect(${redactId(workflow.id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow.type !== donationStateSchema.Enum.PAYPAL_INTENT) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not type PAYPAL_INTENT, unable to complete redirect` + ); + } + + log.info(`${logId}: Starting`); + + if (returnToken !== workflow.returnToken) { + throw new Error(`${logId}: The provided token did not match saved token`); + } + + log.info(`${logId}: Successfully transitioned to PAYPAL_APPROVED`); + + return { + ...workflow, + type: donationStateSchema.Enum.PAYPAL_APPROVED, + paypalPayerId: payerId, + paypalPaymentToken: paymentToken, + timestamp: Date.now(), + }; + }); +} + +export async function _createPaypalIntent({ + currencyType, + paymentAmount, + workflow, +}: { + currencyType: string; + paymentAmount: StripeDonationAmount; + workflow: DonationWorkflow | undefined; +}): Promise { + const id = uuid(); + const logId = `_createPaypalIntent(${redactId(id)})`; + + return withConcurrencyCheck(logId, async () => { + if (workflow && workflow.type !== donationStateSchema.Enum.DONE) { + throw new Error( + `${logId}: existing workflow at type ${workflow.type} is not at type DONE, unable to create payment intent` + ); + } + + log.info(`${logId}: Creating new PayPal workflow`); + + const returnToken = uuid(); + const returnUrl = donationPaypalApprovedRoute + .toWebUrl({ returnToken }) + .toString(); + const cancelUrl = donationPaypalCanceledRoute + .toWebUrl({ returnToken }) + .toString(); + const payload = { + currency: currencyType, + amount: paymentAmount, + level: 1, + returnUrl, + cancelUrl, + }; + const { approvalUrl, paymentId: paypalPaymentId } = + await createPaypalBoostPayment(payload); + + log.info(`${logId}: Successfully transitioned to PAYPAL_INTENT`); + + return { + type: donationStateSchema.Enum.PAYPAL_INTENT, + id: uuid(), + currencyType, + paymentAmount, + paypalPaymentId, + approvalUrl, + returnToken, timestamp: Date.now(), }; }); @@ -675,12 +930,15 @@ export async function _getReceipt( const logId = `_getReceipt(${redactId(workflow.id)})`; return withConcurrencyCheck(logId, async () => { - if (workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED) { + const { type: workflowType } = workflow; + if ( + workflowType !== donationStateSchema.Enum.INTENT_CONFIRMED && + workflowType !== donationStateSchema.Enum.PAYMENT_CONFIRMED + ) { throw new Error( - `${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED, unable to get receipt` + `${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED or PAYMENT_CONFIRMED, unable to get receipt` ); } - log.info(`${logId}: Starting`); const { @@ -688,10 +946,28 @@ export async function _getReceipt( receiptCredentialRequestBase64, receiptCredentialRequestContextBase64, } = workflow; + + let processor: 'STRIPE' | 'BRAINTREE'; + if (workflowType === donationStateSchema.Enum.INTENT_CONFIRMED) { + // Deprecated + processor = 'STRIPE'; + } else if (workflowType === donationStateSchema.Enum.PAYMENT_CONFIRMED) { + const { processor: workflowProcessor } = workflow; + if (workflowProcessor === donationProcessorSchema.Enum.STRIPE) { + processor = 'STRIPE'; + } else if (workflowProcessor === donationProcessorSchema.Enum.PAYPAL) { + processor = 'BRAINTREE'; + } else { + throw missingCaseError(workflowProcessor); + } + } else { + throw missingCaseError(workflowType); + } + const jsonPayload = { paymentIntentId, receiptCredentialRequest: receiptCredentialRequestBase64, - processor: 'STRIPE', + processor, }; // Payment could ultimately fail here, especially with other payment types @@ -715,7 +991,7 @@ export async function _getReceipt( if (responseWithDetails.response.status === 204) { log.info( - `${logId}: Payment is still processing, leaving workflow at INTENT_CONFIRMED` + `${logId}: Payment is still processing, leaving workflow at ${workflowType}` ); return workflow; } @@ -920,7 +1196,8 @@ export async function _saveWorkflowToStorage( async function saveReceipt(workflow: DonationWorkflow, logId: string) { if ( workflow.type !== donationStateSchema.Enum.RECEIPT && - workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED + workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED && + workflow.type !== donationStateSchema.Enum.PAYMENT_CONFIRMED ) { throw new Error( `${logId}: Cannot save receipt from workflow at type ${workflow?.type}` @@ -930,8 +1207,8 @@ async function saveReceipt(workflow: DonationWorkflow, logId: string) { id: workflow.id, currencyType: workflow.currencyType, paymentAmount: workflow.paymentAmount, - // This will be when we transitioned to INTENT_CONFIRMED, most likely. It may be close - // to when the user clicks the Donate button, or delayed by a bit. + // This will be when we transitioned to PAYMENT_CONFIRMED, most likely. It may be + // close to when the user clicks the Donate button, or delayed by a bit. timestamp: workflow.timestamp, }; diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index 8539f032cf..4f799adb95 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -736,6 +736,8 @@ const CHAT_CALLS = { challenge: 'v1/challenge', configV2: 'v2/config', createBoost: 'v1/subscription/boost/create', + createPaypalBoost: 'v1/subscription/boost/paypal/create', + confirmPaypalBoost: 'v1/subscription/boost/paypal/confirm', deliveryCert: 'v1/certificate/delivery', devices: 'v1/devices', directoryAuthV2: 'v2/directory/auth', @@ -1172,7 +1174,7 @@ export type CreateBoostResultType = z.infer; export type CreateBoostReceiptCredentialsOptionsType = Readonly<{ paymentIntentId: string; receiptCredentialRequest: string; - processor: string; + processor: 'STRIPE' | 'BRAINTREE'; }>; const CreateBoostReceiptCredentialsResultSchema = z.object({ receiptCredentialResponse: z.string(), @@ -1228,6 +1230,36 @@ type ConfirmIntentWithStripeResultType = z.infer< typeof ConfirmIntentWithStripeResultSchema >; +export type CreatePaypalBoostOptionsType = Readonly<{ + currency: string; + amount: StripeDonationAmount; + level: number; + returnUrl: string; + cancelUrl: string; +}>; +const CreatePaypalBoostResultSchema = z.object({ + approvalUrl: z.string(), + paymentId: z.string(), +}); +export type CreatePaypalBoostResultType = z.infer< + typeof CreatePaypalBoostResultSchema +>; + +export type ConfirmPaypalBoostOptionsType = Readonly<{ + currency: string; + amount: number; + level: number; + payerId: string; + paymentId: string; + paymentToken: string; +}>; +const ConfirmPaypalBoostResultSchema = z.object({ + paymentId: z.string(), +}); +export type ConfirmPaypalBoostResultType = z.infer< + typeof ConfirmPaypalBoostResultSchema +>; + export type RedeemReceiptOptionsType = Readonly<{ receiptCredentialPresentation: string; visible: boolean; @@ -4487,6 +4519,34 @@ export function createPaymentMethodWithStripe( }); } +export function createPaypalBoostPayment( + options: CreatePaypalBoostOptionsType +): Promise { + return _ajax({ + unauthenticated: true, + host: 'chatService', + call: 'createPaypalBoost', + httpType: 'POST', + jsonData: options, + responseType: 'json', + zodSchema: CreatePaypalBoostResultSchema, + }); +} + +export function confirmPaypalBoostPayment( + options: ConfirmPaypalBoostOptionsType +): Promise { + return _ajax({ + unauthenticated: true, + host: 'chatService', + call: 'confirmPaypalBoost', + httpType: 'POST', + jsonData: options, + responseType: 'json', + zodSchema: ConfirmPaypalBoostResultSchema, + }); +} + export async function createGroup( group: Proto.IGroup, options: GroupCredentialsType diff --git a/ts/types/Donations.std.ts b/ts/types/Donations.std.ts index 6c6ec8ef53..2f7b22a1da 100644 --- a/ts/types/Donations.std.ts +++ b/ts/types/Donations.std.ts @@ -6,17 +6,33 @@ import { z } from 'zod'; export const ONE_TIME_DONATION_CONFIG_ID = '1'; export const BOOST_ID = 'BOOST'; +/** + * For one-time donations, there are 2 possible flows: + * + * Stripe: + * INTENT -> INTENT_METHOD -> (INTENT_REDIRECT) -> PAYMENT_CONFIRMED -> RECEIPT -> DONE + * - INTENT_REDIRECT only occurs when Stripe requires additional validation (e.g. 3ds). + * + * PayPal: + * PAYPAL_INTENT -> PAYPAL_APPROVED -> PAYMENT_CONFIRMED -> RECEIPT -> DONE + */ + export const donationStateSchema = z.enum([ 'INTENT', 'INTENT_METHOD', - 'INTENT_CONFIRMED', + 'INTENT_CONFIRMED', // Deprecated 'INTENT_REDIRECT', + 'PAYPAL_INTENT', + 'PAYPAL_APPROVED', + 'PAYMENT_CONFIRMED', 'RECEIPT', 'DONE', ]); export type DonationStateType = z.infer; +export const donationProcessorSchema = z.enum(['PAYPAL', 'STRIPE']); + export const donationErrorTypeSchema = z.enum([ // Used if the user is redirected back from validation, but continuing forward fails 'Failed3dsValidation', @@ -113,6 +129,7 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [ }), z.object({ + // Deprecated. We no longer enter this state -- PAYMENT_CONFIRMED has replaced it. // By this point, Stripe is attempting to charge the user's provided payment method. // However it will take some time (usually seconds, sometimes minutes or 1 day) to // finalize the transaction. We will only know when we successfully get a receipt @@ -125,7 +142,24 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [ }), z.object({ - // An alternate state to INTENT_CONFIRMED. A response from Stripe indicated + // This state is shared by Stripe and PayPal. + // Stripe: By this point, Stripe is attempting to charge the user's payment method. + // However it will take some time (usually seconds, sometimes minutes or 1 day) to + // finalize the transaction. We will only know when we successfully get a receipt + // credential from the chat server. + // PayPal: Payment should finalize immediately. The subsequent call to get a receipt + // should always succeed. + type: z.literal(donationStateSchema.Enum.PAYMENT_CONFIRMED), + + processor: donationProcessorSchema, + paymentIntentId: z.string(), + + ...coreDataSchema.shape, + ...receiptContextSchema.shape, + }), + + z.object({ + // An alternate state to PAYMENT_CONFIRMED. A response from Stripe indicated // the user's card requires 3ds authentication, so we need to redirect to their // bank, which will complete verification, then redirect back to us. We hand that // service a token to connect it back to this process. If the user never comes back, @@ -140,6 +174,40 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [ ...receiptContextSchema.shape, }), + z.object({ + // User has selected currency and amount, and we've initiated a PayPal payment + // via the chat server. To complete the payment, the user needs to visit the + // PayPal website. Upon approval, the website redirects to the app URI to + // continue the donation process to get a receipt and badge from the chat server. + // To cancel, a user can either cancel on the Paypal website, or cancel from within + // the app (which just clears the active transaction locally). + type: z.literal(donationStateSchema.Enum.PAYPAL_INTENT), + + paypalPaymentId: z.string(), + + // The user needs to visit this URL to complete payment on PayPal. + approvalUrl: z.string(), + + // When the user returns to the app, we check the returnToken to confirm it matches + // the active workflow. + returnToken: z.string(), + + ...coreDataSchema.shape, + }), + + z.object({ + // After PayPal approval, the user is redirected back to us with a payerId and + // paymentToken. We save them immediately, then + // confirm the payment on the chat server. + type: z.literal(donationStateSchema.Enum.PAYPAL_APPROVED), + + paypalPaymentId: z.string(), + paypalPayerId: z.string(), + paypalPaymentToken: z.string(), + + ...coreDataSchema.shape, + }), + z.object({ // We now have everything we need to redeem. We know the payment has gone through // successfully; we just need to redeem it on the server anonymously. diff --git a/ts/util/donations.dom.ts b/ts/util/donations.dom.ts index f6ee062f94..bcc40d3fa8 100644 --- a/ts/util/donations.dom.ts +++ b/ts/util/donations.dom.ts @@ -30,6 +30,8 @@ export function getInProgressDonation(workflow: DonationWorkflow | undefined): case donationStateSchema.Enum.INTENT_METHOD: case donationStateSchema.Enum.INTENT_REDIRECT: case donationStateSchema.Enum.INTENT_CONFIRMED: + case donationStateSchema.Enum.PAYPAL_APPROVED: + case donationStateSchema.Enum.PAYMENT_CONFIRMED: case donationStateSchema.Enum.RECEIPT: { const { currencyType: currency, paymentAmount } = workflow; const amount = brandStripeDonationAmount(paymentAmount); @@ -39,6 +41,7 @@ export function getInProgressDonation(workflow: DonationWorkflow | undefined): }; } case donationStateSchema.Enum.INTENT: + case donationStateSchema.Enum.PAYPAL_INTENT: case donationStateSchema.Enum.DONE: return; default: diff --git a/ts/util/signalRoutes.std.ts b/ts/util/signalRoutes.std.ts index 5ccced8017..913307104e 100644 --- a/ts/util/signalRoutes.std.ts +++ b/ts/util/signalRoutes.std.ts @@ -40,6 +40,7 @@ const SignalRouteHostnames = [ 'signal.group', 'signal.link', 'signal.art', + 'signaldonations.org', ] as const; /** @@ -56,6 +57,8 @@ type AllHostnamePatterns = | 'start-call-lobby' | 'show-window' | 'cancel-presenting' + | 'donation-paypal-approved' + | 'donation-paypal-canceled' | 'donation-validation-complete' | ':captchaId(.+)' | ''; @@ -590,6 +593,76 @@ export const donationValidationCompleteRoute = _route( } ); +/** + * Resume donation workflow after completing PayPal web flow. + * @example + * ```ts + * donationPaypalApprovedRoute.toWebURL({ + * returnToken: "123", + * }) + * // URL { "sgnl://donation-paypal-approved?returnToken=123" } + * ``` + */ +export const donationPaypalApprovedRoute = _route('donationPaypalApproved', { + patterns: [ + _pattern('sgnl:', 'donation-paypal-approved', '{/}?', { + search: ':params', + }), + ], + schema: z.object({ + payerId: paramSchema.optional(), + paymentToken: paramSchema.optional(), + returnToken: paramSchema, + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + payerId: params.get('PayerID'), + paymentToken: params.get('token'), + returnToken: params.get('returnToken'), + }; + }, + toWebUrl(args) { + const params = new URLSearchParams({ returnToken: args.returnToken }); + return new URL( + `https://signaldonations.org/redirect/donation-paypal-approved?${params.toString()}` + ); + }, +}); + +/** + * Resume and cancel donation workflow after canceling PayPal web flow + * @example + * ```ts + * donationPaypalCanceledRoute.toAppUrl({ + * returnToken: "123", + * }) + * // URL { "sgnl://donation-paypal-canceled?returnToken=123" } + * ``` + */ +export const donationPaypalCanceledRoute = _route('donationPaypalCanceled', { + patterns: [ + _pattern('sgnl:', 'donation-paypal-canceled', '{/}?', { + search: ':params', + }), + ], + schema: z.object({ + returnToken: paramSchema, + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + returnToken: params.get('returnToken'), + }; + }, + toWebUrl(args) { + const params = new URLSearchParams({ returnToken: args.returnToken }); + return new URL( + `https://signaldonations.org/redirect/donation-paypal-canceled?${params.toString()}` + ); + }, +}); + /** * Should include all routes for matching purposes. * @internal @@ -606,6 +679,8 @@ const _allSignalRoutes = [ startCallLobbyRoute, showWindowRoute, cancelPresentingRoute, + donationPaypalApprovedRoute, + donationPaypalCanceledRoute, donationValidationCompleteRoute, ] as const; diff --git a/ts/windows/main/phase1-ipc.preload.ts b/ts/windows/main/phase1-ipc.preload.ts index ab09211a97..6fdd4fa4ae 100644 --- a/ts/windows/main/phase1-ipc.preload.ts +++ b/ts/windows/main/phase1-ipc.preload.ts @@ -21,7 +21,11 @@ import { drop } from '../../util/drop.std.js'; import { explodePromise } from '../../util/explodePromise.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import type { WindowsNotificationData } from '../../types/notifications.std.js'; -import { finish3dsValidation } from '../../services/donations.preload.js'; +import { + approvePaypalPayment, + cancelPaypalPayment, + finish3dsValidation, +} from '../../services/donations.preload.js'; import { AggregatedStats } from '../../textsecure/WebsocketResources.preload.js'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager.preload.js'; import { isProduction } from '../../util/version.std.js'; @@ -399,6 +403,17 @@ ipc.on('donation-validation-complete', (_event, { token }) => { drop(finish3dsValidation(token)); }); +ipc.on( + 'donation-paypal-approved', + (_event, { payerId, paymentToken, returnToken }) => { + drop(approvePaypalPayment({ payerId, paymentToken, returnToken })); + } +); + +ipc.on('donation-paypal-canceled', (_event, { returnToken }) => { + drop(cancelPaypalPayment(returnToken)); +}); + ipc.on('show-conversation-via-token', (_event, token: string) => { const { showConversationViaToken } = window.Events; if (showConversationViaToken) {