Donation data workflows for PayPal

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
ayumi-signal
2026-01-27 16:29:27 -08:00
committed by GitHub
parent 7e6661db14
commit 0c7fcfdaef
9 changed files with 526 additions and 18 deletions

View File

@@ -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');

View File

@@ -101,6 +101,7 @@ const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => {
const finalizedStates: Array<DonationStateType> = [
donationStateSchema.Enum.INTENT_CONFIRMED,
donationStateSchema.Enum.INTENT_REDIRECT,
donationStateSchema.Enum.PAYMENT_CONFIRMED,
donationStateSchema.Enum.RECEIPT,
donationStateSchema.Enum.DONE,
];

View File

@@ -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 = (

View File

@@ -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<void> {
await _saveAndRunWorkflow(workflow);
}
export async function approvePaypalPayment({
payerId,
paymentToken,
returnToken,
}: {
payerId: string | undefined;
paymentToken: string | undefined;
returnToken: string;
}): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
});
}
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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,
};

View File

@@ -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<typeof CreateBoostResultSchema>;
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<CreatePaypalBoostResultType> {
return _ajax({
unauthenticated: true,
host: 'chatService',
call: 'createPaypalBoost',
httpType: 'POST',
jsonData: options,
responseType: 'json',
zodSchema: CreatePaypalBoostResultSchema,
});
}
export function confirmPaypalBoostPayment(
options: ConfirmPaypalBoostOptionsType
): Promise<ConfirmPaypalBoostResultType> {
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

View File

@@ -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<typeof donationStateSchema>;
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.

View File

@@ -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:

View File

@@ -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;

View File

@@ -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) {