Donations: Make workflow more robust

This commit is contained in:
Scott Nonnenberg 2025-07-10 07:34:42 +10:00 committed by GitHub
parent b440aec88c
commit 437e791573
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 826 additions and 316 deletions

View file

@ -219,6 +219,7 @@ import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled';
import { NavTab } from './state/ducks/nav'; import { NavTab } from './state/ducks/nav';
import { Page } from './components/Preferences'; import { Page } from './components/Preferences';
import { EditState } from './components/ProfileEditor'; import { EditState } from './components/ProfileEditor';
import { runDonationWorkflow } from './services/donations';
import { MessageRequestResponseSource } from './types/MessageRequestResponseEvent'; import { MessageRequestResponseSource } from './types/MessageRequestResponseEvent';
const log = createLogger('background'); const log = createLogger('background');
@ -2195,6 +2196,8 @@ export async function startApp(): Promise<void> {
drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion)); drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion));
drop(runDonationWorkflow());
if (isFromMessageReceiver) { if (isFromMessageReceiver) {
drop( drop(
(async () => { (async () => {

View file

@ -4,6 +4,8 @@
import { DataReader } from '../sql/Client'; import { DataReader } from '../sql/Client';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { _getWorkflowFromStorage } from './donations';
import type { DonationReceipt } from '../types/Donations'; import type { DonationReceipt } from '../types/Donations';
import type { DonationsStateType } from '../state/ducks/donations'; import type { DonationsStateType } from '../state/ducks/donations';
@ -19,7 +21,8 @@ export function getDonationReceiptsForRedux(): DonationsStateType {
'donation receipts have not been loaded' 'donation receipts have not been loaded'
); );
return { return {
currentWorkflow: undefined, currentWorkflow: _getWorkflowFromStorage(),
lastError: undefined,
receipts: donationReceipts, receipts: donationReceipts,
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,7 @@ import { isStagingServer } from '../../util/isStagingServer';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { import type {
CardDetail, CardDetail,
DonationErrorType,
DonationReceipt, DonationReceipt,
DonationWorkflow, DonationWorkflow,
} from '../../types/Donations'; } from '../../types/Donations';
@ -25,6 +26,7 @@ const log = createLogger('donations');
export type DonationsStateType = ReadonlyDeep<{ export type DonationsStateType = ReadonlyDeep<{
currentWorkflow: DonationWorkflow | undefined; currentWorkflow: DonationWorkflow | undefined;
lastError: DonationErrorType | undefined;
receipts: Array<DonationReceipt>; receipts: Array<DonationReceipt>;
}>; }>;
@ -33,6 +35,7 @@ export type DonationsStateType = ReadonlyDeep<{
export const ADD_RECEIPT = 'donations/ADD_RECEIPT'; export const ADD_RECEIPT = 'donations/ADD_RECEIPT';
export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION'; export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION';
export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW'; export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW';
export const UPDATE_LAST_ERROR = 'donations/UPDATE_LAST_ERROR';
export type AddReceiptAction = ReadonlyDeep<{ export type AddReceiptAction = ReadonlyDeep<{
type: typeof ADD_RECEIPT; type: typeof ADD_RECEIPT;
@ -48,13 +51,21 @@ export type SubmitDonationAction = ReadonlyDeep<{
}; };
}>; }>;
export type UpdateLastErrorAction = ReadonlyDeep<{
type: typeof UPDATE_LAST_ERROR;
payload: { lastError: DonationErrorType | undefined };
}>;
export type UpdateWorkflowAction = ReadonlyDeep<{ export type UpdateWorkflowAction = ReadonlyDeep<{
type: typeof UPDATE_WORKFLOW; type: typeof UPDATE_WORKFLOW;
payload: { nextWorkflow: DonationWorkflow | undefined }; payload: { nextWorkflow: DonationWorkflow | undefined };
}>; }>;
export type DonationsActionType = ReadonlyDeep< export type DonationsActionType = ReadonlyDeep<
AddReceiptAction | SubmitDonationAction | UpdateWorkflowAction | AddReceiptAction
| SubmitDonationAction
| UpdateLastErrorAction
| UpdateWorkflowAction
>; >;
// Action Creators // Action Creators
@ -105,7 +116,7 @@ function submitDonation({
} }
try { try {
await donations.internalDoDonation({ await donations._internalDoDonation({
currencyType, currencyType,
paymentAmount, paymentAmount,
paymentDetail, paymentDetail,
@ -123,6 +134,15 @@ function clearWorkflow(): UpdateWorkflowAction {
}; };
} }
function updateLastError(
lastError: DonationErrorType | undefined
): UpdateLastErrorAction {
return {
type: UPDATE_LAST_ERROR,
payload: { lastError },
};
}
function updateWorkflow( function updateWorkflow(
nextWorkflow: DonationWorkflow | undefined nextWorkflow: DonationWorkflow | undefined
): UpdateWorkflowAction { ): UpdateWorkflowAction {
@ -137,6 +157,7 @@ export const actions = {
clearWorkflow, clearWorkflow,
internalAddDonationReceipt, internalAddDonationReceipt,
submitDonation, submitDonation,
updateLastError,
updateWorkflow, updateWorkflow,
}; };
@ -149,6 +170,7 @@ export const useDonationsActions = (): BoundActionCreatorsMapObject<
export function getEmptyState(): DonationsStateType { export function getEmptyState(): DonationsStateType {
return { return {
currentWorkflow: undefined, currentWorkflow: undefined,
lastError: undefined,
receipts: [], receipts: [],
}; };
} }
@ -164,6 +186,13 @@ export function reducer(
}; };
} }
if (action.type === UPDATE_LAST_ERROR) {
return {
...state,
lastError: action.payload.lastError,
};
}
if (action.type === UPDATE_WORKFLOW) { if (action.type === UPDATE_WORKFLOW) {
return { return {
...state, ...state,

View file

@ -472,6 +472,8 @@ async function _promiseAjax<Type extends ResponseType, OutputShape>(
try { try {
if (DEBUG && !isSuccess(response.status)) { if (DEBUG && !isSuccess(response.status)) {
result = await response.text(); result = await response.text();
// eslint-disable-next-line no-console
console.error(result);
} else if ( } else if (
(options.responseType === 'json' || (options.responseType === 'json' ||
options.responseType === 'jsonwithdetails') && options.responseType === 'jsonwithdetails') &&
@ -1189,6 +1191,9 @@ export type ConfirmIntentWithStripeOptionsType = Readonly<{
returnUrl: string; returnUrl: string;
}>; }>;
const ConfirmIntentWithStripeResultSchema = z.object({ const ConfirmIntentWithStripeResultSchema = z.object({
// https://docs.stripe.com/api/payment_intents/object#payment_intent_object-status
status: z.string(),
// https://docs.stripe.com/api/payment_intents/object#payment_intent_object-next_action
next_action: z next_action: z
.object({ .object({
type: z.string(), type: z.string(),
@ -1200,6 +1205,14 @@ const ConfirmIntentWithStripeResultSchema = z.object({
.nullable(), .nullable(),
}) })
.nullable(), .nullable(),
// https://docs.stripe.com/api/payment_intents/object#payment_intent_object-last_payment_error
last_payment_error: z
.object({
type: z.string(),
advice_code: z.string().nullable(),
message: z.string().nullable(),
})
.nullable(),
}); });
type ConfirmIntentWithStripeResultType = z.infer< type ConfirmIntentWithStripeResultType = z.infer<
typeof ConfirmIntentWithStripeResultSchema typeof ConfirmIntentWithStripeResultSchema
@ -1580,7 +1593,7 @@ export type WebAPIType = {
getAvatar: (path: string) => Promise<Uint8Array>; getAvatar: (path: string) => Promise<Uint8Array>;
createBoostReceiptCredentials: ( createBoostReceiptCredentials: (
options: CreateBoostReceiptCredentialsOptionsType options: CreateBoostReceiptCredentialsOptionsType
) => Promise<CreateBoostReceiptCredentialsResultType>; ) => Promise<JSONWithDetailsType<CreateBoostReceiptCredentialsResultType>>;
redeemReceipt: (options: RedeemReceiptOptionsType) => Promise<void>; redeemReceipt: (options: RedeemReceiptOptionsType) => Promise<void>;
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>; getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>; getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>;
@ -4735,14 +4748,14 @@ export function initialize({
async function createBoostReceiptCredentials( async function createBoostReceiptCredentials(
options: CreateBoostReceiptCredentialsOptionsType options: CreateBoostReceiptCredentialsOptionsType
): Promise<CreateBoostReceiptCredentialsResultType> { ): Promise<JSONWithDetailsType<CreateBoostReceiptCredentialsResultType>> {
return _ajax({ return _ajax({
unauthenticated: true, unauthenticated: true,
host: 'chatService', host: 'chatService',
call: 'boostReceiptCredentials', call: 'boostReceiptCredentials',
httpType: 'POST', httpType: 'POST',
jsonData: options, jsonData: options,
responseType: 'json', responseType: 'jsonwithdetails',
zodSchema: CreateBoostReceiptCredentialsResultSchema, zodSchema: CreateBoostReceiptCredentialsResultSchema,
}); });
} }

View file

@ -9,10 +9,21 @@ export const donationStateSchema = z.enum([
'INTENT_CONFIRMED', 'INTENT_CONFIRMED',
'INTENT_REDIRECT', 'INTENT_REDIRECT',
'RECEIPT', 'RECEIPT',
'RECEIPT_REDEEMED',
'DONE', 'DONE',
]); ]);
export const donationErrorTypeSchema = z.enum([
// Any 4xx error when adding payment method or confirming intent
'PaymentDeclined',
// Only used if we can't support 3DS validation for our first release
'CardNotSupported',
// Any other HTTPError during the process
'DonationProcessingError',
// Any other error
'GeneralError',
]);
export type DonationErrorType = z.infer<typeof donationErrorTypeSchema>;
const coreDataSchema = z.object({ const coreDataSchema = z.object({
// Guid used to prevent duplicates at stripe and in our db // Guid used to prevent duplicates at stripe and in our db
id: z.string(), id: z.string(),
@ -23,8 +34,7 @@ const coreDataSchema = z.object({
// Cents as whole numbers, so multiply by 100 // Cents as whole numbers, so multiply by 100
paymentAmount: z.number(), paymentAmount: z.number(),
// The last time we transitioned into a new state. So the timestamp shown to the user // The last time we transitioned into a new state.
// will be when we redeem the receipt, not when they initiated the donation.
timestamp: z.number(), timestamp: z.number(),
}); });
export type CoreData = z.infer<typeof coreDataSchema>; export type CoreData = z.infer<typeof coreDataSchema>;
@ -71,7 +81,7 @@ export const donationReceiptSchema = z.object({
}); });
export type DonationReceipt = z.infer<typeof donationReceiptSchema>; export type DonationReceipt = z.infer<typeof donationReceiptSchema>;
const donationWorkflowSchema = z.discriminatedUnion('type', [ export const donationWorkflowSchema = z.discriminatedUnion('type', [
z.object({ z.object({
// Track that user has chosen currency and amount, and we've successfully fetched an // Track that user has chosen currency and amount, and we've successfully fetched an
// intent. There is no need to persist this, because we'd need to update // intent. There is no need to persist this, because we'd need to update
@ -82,11 +92,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
}), }),
z.object({ z.object({
// Generally this should be a very short-lived state. The user has entered payment // Once we are here, we can proceed without further user input. The user has entered
// details and pressed the button to make the payment, and we have sent that to // payment details and pressed the button to make the payment, and we have sent that
// stripe. The next step is to use those details to confirm payment. No other // to stripe, which has saved that data behind a paymentMethodId. The only thing
// user interaction is required after this point to continue the process - unless // that might require further user interaction: 3ds validation - see INTENT_REDIRECT.
// 3ds validation is needed - see INTENT_REDIRECT.
type: z.literal(donationStateSchema.Enum.INTENT_METHOD), type: z.literal(donationStateSchema.Enum.INTENT_METHOD),
// Stripe persists the user's payment information for us, behind this id // Stripe persists the user's payment information for us, behind this id
@ -97,10 +106,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
}), }),
z.object({ z.object({
// After we confirm payment details with Stripe, this state represents // By this point, Stripe is attempting to charge the user's provided payment method.
// Stripe's acknowledgement. However it will take some time (usually seconds, // However it will take some time (usually seconds, sometimes minutes or 1 day) to
// sometimes minutes or 1 day) to finalize the transaction. We will only know // finalize the transaction. We will only know when we successfully get a receipt
// when we request a receipt credential from the chat server. // credential from the chat server.
type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED), type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED),
...coreDataSchema.shape, ...coreDataSchema.shape,
@ -129,26 +138,20 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
// successfully; we just need to redeem it on the server anonymously. // successfully; we just need to redeem it on the server anonymously.
type: z.literal(donationStateSchema.Enum.RECEIPT), type: z.literal(donationStateSchema.Enum.RECEIPT),
// the result of mixing the receiptCredentialResponse from the API from our // The result of mixing the receiptCredentialResponse from the API from our
// previously-generated receiptCredentialRequestContext // previously-generated receiptCredentialRequestContext
receiptCredentialBase64: z.string(), receiptCredentialBase64: z.string(),
...coreDataSchema.shape, ...coreDataSchema.shape,
}), }),
z.object({
// A short-lived state, but we'll be in this state until we successfully save a new
// receipt field in the database and add to redux.
type: z.literal(donationStateSchema.Enum.RECEIPT_REDEEMED),
...coreDataSchema.shape,
}),
z.object({ z.object({
// After everything is done, we should notify the user the donation succeeded. // After everything is done, we should notify the user the donation succeeded.
// After we show a notification, or if the user initiates a new donation, // After we show a notification, or if the user initiates a new donation,
// then this workflow can be deleted. // then this workflow can be deleted.
type: z.literal(donationStateSchema.Enum.DONE), type: z.literal(donationStateSchema.Enum.DONE),
id: z.string(), id: coreDataSchema.shape.id,
timestamp: coreDataSchema.shape.timestamp,
}), }),
]); ]);

View file

@ -131,6 +131,7 @@ export type StorageAccessType = {
linkPreviews: boolean; linkPreviews: boolean;
universalExpireTimer: number; universalExpireTimer: number;
retryPlaceholders: ReadonlyArray<RetryItemType>; retryPlaceholders: ReadonlyArray<RetryItemType>;
donationWorkflow: string;
chromiumRegistrationDoneEver: ''; chromiumRegistrationDoneEver: '';
chromiumRegistrationDone: ''; chromiumRegistrationDone: '';
phoneNumberSharingMode: PhoneNumberSharingMode; phoneNumberSharingMode: PhoneNumberSharingMode;

View file

@ -8,6 +8,6 @@ export function isProtoBinaryEncodingEnabled(): boolean {
return true; return true;
} }
// TODO: https://signalmessenger.atlassian.net/browse/DESKTOP-8938 // TODO: DESKTOP-8938
return false; return false;
} }

View file

@ -127,6 +127,7 @@ window.testUtilities = {
storyDistributionLists: [], storyDistributionLists: [],
donations: { donations: {
currentWorkflow: undefined, currentWorkflow: undefined,
lastError: undefined,
receipts: [], receipts: [],
}, },
stickers: { stickers: {