mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Donation data workflows for PayPal
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user