Donations: Make workflow more robust
This commit is contained in:
parent
b440aec88c
commit
437e791573
9 changed files with 826 additions and 316 deletions
|
@ -219,6 +219,7 @@ import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled';
|
|||
import { NavTab } from './state/ducks/nav';
|
||||
import { Page } from './components/Preferences';
|
||||
import { EditState } from './components/ProfileEditor';
|
||||
import { runDonationWorkflow } from './services/donations';
|
||||
import { MessageRequestResponseSource } from './types/MessageRequestResponseEvent';
|
||||
|
||||
const log = createLogger('background');
|
||||
|
@ -2195,6 +2196,8 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion));
|
||||
|
||||
drop(runDonationWorkflow());
|
||||
|
||||
if (isFromMessageReceiver) {
|
||||
drop(
|
||||
(async () => {
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
import { DataReader } from '../sql/Client';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
import { _getWorkflowFromStorage } from './donations';
|
||||
|
||||
import type { DonationReceipt } from '../types/Donations';
|
||||
import type { DonationsStateType } from '../state/ducks/donations';
|
||||
|
||||
|
@ -19,7 +21,8 @@ export function getDonationReceiptsForRedux(): DonationsStateType {
|
|||
'donation receipts have not been loaded'
|
||||
);
|
||||
return {
|
||||
currentWorkflow: undefined,
|
||||
currentWorkflow: _getWorkflowFromStorage(),
|
||||
lastError: undefined,
|
||||
receipts: donationReceipts,
|
||||
};
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -12,6 +12,7 @@ import { isStagingServer } from '../../util/isStagingServer';
|
|||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
import type {
|
||||
CardDetail,
|
||||
DonationErrorType,
|
||||
DonationReceipt,
|
||||
DonationWorkflow,
|
||||
} from '../../types/Donations';
|
||||
|
@ -25,6 +26,7 @@ const log = createLogger('donations');
|
|||
|
||||
export type DonationsStateType = ReadonlyDeep<{
|
||||
currentWorkflow: DonationWorkflow | undefined;
|
||||
lastError: DonationErrorType | undefined;
|
||||
receipts: Array<DonationReceipt>;
|
||||
}>;
|
||||
|
||||
|
@ -33,6 +35,7 @@ export type DonationsStateType = ReadonlyDeep<{
|
|||
export const ADD_RECEIPT = 'donations/ADD_RECEIPT';
|
||||
export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION';
|
||||
export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW';
|
||||
export const UPDATE_LAST_ERROR = 'donations/UPDATE_LAST_ERROR';
|
||||
|
||||
export type AddReceiptAction = ReadonlyDeep<{
|
||||
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<{
|
||||
type: typeof UPDATE_WORKFLOW;
|
||||
payload: { nextWorkflow: DonationWorkflow | undefined };
|
||||
}>;
|
||||
|
||||
export type DonationsActionType = ReadonlyDeep<
|
||||
AddReceiptAction | SubmitDonationAction | UpdateWorkflowAction
|
||||
| AddReceiptAction
|
||||
| SubmitDonationAction
|
||||
| UpdateLastErrorAction
|
||||
| UpdateWorkflowAction
|
||||
>;
|
||||
|
||||
// Action Creators
|
||||
|
@ -105,7 +116,7 @@ function submitDonation({
|
|||
}
|
||||
|
||||
try {
|
||||
await donations.internalDoDonation({
|
||||
await donations._internalDoDonation({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
paymentDetail,
|
||||
|
@ -123,6 +134,15 @@ function clearWorkflow(): UpdateWorkflowAction {
|
|||
};
|
||||
}
|
||||
|
||||
function updateLastError(
|
||||
lastError: DonationErrorType | undefined
|
||||
): UpdateLastErrorAction {
|
||||
return {
|
||||
type: UPDATE_LAST_ERROR,
|
||||
payload: { lastError },
|
||||
};
|
||||
}
|
||||
|
||||
function updateWorkflow(
|
||||
nextWorkflow: DonationWorkflow | undefined
|
||||
): UpdateWorkflowAction {
|
||||
|
@ -137,6 +157,7 @@ export const actions = {
|
|||
clearWorkflow,
|
||||
internalAddDonationReceipt,
|
||||
submitDonation,
|
||||
updateLastError,
|
||||
updateWorkflow,
|
||||
};
|
||||
|
||||
|
@ -149,6 +170,7 @@ export const useDonationsActions = (): BoundActionCreatorsMapObject<
|
|||
export function getEmptyState(): DonationsStateType {
|
||||
return {
|
||||
currentWorkflow: undefined,
|
||||
lastError: undefined,
|
||||
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) {
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -472,6 +472,8 @@ async function _promiseAjax<Type extends ResponseType, OutputShape>(
|
|||
try {
|
||||
if (DEBUG && !isSuccess(response.status)) {
|
||||
result = await response.text();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(result);
|
||||
} else if (
|
||||
(options.responseType === 'json' ||
|
||||
options.responseType === 'jsonwithdetails') &&
|
||||
|
@ -1189,6 +1191,9 @@ export type ConfirmIntentWithStripeOptionsType = Readonly<{
|
|||
returnUrl: string;
|
||||
}>;
|
||||
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
|
||||
.object({
|
||||
type: z.string(),
|
||||
|
@ -1200,6 +1205,14 @@ const ConfirmIntentWithStripeResultSchema = z.object({
|
|||
.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<
|
||||
typeof ConfirmIntentWithStripeResultSchema
|
||||
|
@ -1580,7 +1593,7 @@ export type WebAPIType = {
|
|||
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||
createBoostReceiptCredentials: (
|
||||
options: CreateBoostReceiptCredentialsOptionsType
|
||||
) => Promise<CreateBoostReceiptCredentialsResultType>;
|
||||
) => Promise<JSONWithDetailsType<CreateBoostReceiptCredentialsResultType>>;
|
||||
redeemReceipt: (options: RedeemReceiptOptionsType) => Promise<void>;
|
||||
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
|
||||
getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>;
|
||||
|
@ -4735,14 +4748,14 @@ export function initialize({
|
|||
|
||||
async function createBoostReceiptCredentials(
|
||||
options: CreateBoostReceiptCredentialsOptionsType
|
||||
): Promise<CreateBoostReceiptCredentialsResultType> {
|
||||
): Promise<JSONWithDetailsType<CreateBoostReceiptCredentialsResultType>> {
|
||||
return _ajax({
|
||||
unauthenticated: true,
|
||||
host: 'chatService',
|
||||
call: 'boostReceiptCredentials',
|
||||
httpType: 'POST',
|
||||
jsonData: options,
|
||||
responseType: 'json',
|
||||
responseType: 'jsonwithdetails',
|
||||
zodSchema: CreateBoostReceiptCredentialsResultSchema,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,10 +9,21 @@ export const donationStateSchema = z.enum([
|
|||
'INTENT_CONFIRMED',
|
||||
'INTENT_REDIRECT',
|
||||
'RECEIPT',
|
||||
'RECEIPT_REDEEMED',
|
||||
'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({
|
||||
// Guid used to prevent duplicates at stripe and in our db
|
||||
id: z.string(),
|
||||
|
@ -23,8 +34,7 @@ const coreDataSchema = z.object({
|
|||
// Cents as whole numbers, so multiply by 100
|
||||
paymentAmount: z.number(),
|
||||
|
||||
// The last time we transitioned into a new state. So the timestamp shown to the user
|
||||
// will be when we redeem the receipt, not when they initiated the donation.
|
||||
// The last time we transitioned into a new state.
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CoreData = z.infer<typeof coreDataSchema>;
|
||||
|
@ -71,7 +81,7 @@ export const donationReceiptSchema = z.object({
|
|||
});
|
||||
export type DonationReceipt = z.infer<typeof donationReceiptSchema>;
|
||||
|
||||
const donationWorkflowSchema = z.discriminatedUnion('type', [
|
||||
export const donationWorkflowSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
// 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
|
||||
|
@ -82,11 +92,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
|
|||
}),
|
||||
|
||||
z.object({
|
||||
// Generally this should be a very short-lived state. The user has entered payment
|
||||
// details and pressed the button to make the payment, and we have sent that to
|
||||
// stripe. The next step is to use those details to confirm payment. No other
|
||||
// user interaction is required after this point to continue the process - unless
|
||||
// 3ds validation is needed - see INTENT_REDIRECT.
|
||||
// Once we are here, we can proceed without further user input. The user has entered
|
||||
// payment details and pressed the button to make the payment, and we have sent that
|
||||
// to stripe, which has saved that data behind a paymentMethodId. The only thing
|
||||
// that might require further user interaction: 3ds validation - see INTENT_REDIRECT.
|
||||
type: z.literal(donationStateSchema.Enum.INTENT_METHOD),
|
||||
|
||||
// Stripe persists the user's payment information for us, behind this id
|
||||
|
@ -97,10 +106,10 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
|
|||
}),
|
||||
|
||||
z.object({
|
||||
// After we confirm payment details with Stripe, this state represents
|
||||
// Stripe's acknowledgement. However it will take some time (usually seconds,
|
||||
// sometimes minutes or 1 day) to finalize the transaction. We will only know
|
||||
// when we request a receipt credential from the chat server.
|
||||
// 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
|
||||
// credential from the chat server.
|
||||
type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED),
|
||||
|
||||
...coreDataSchema.shape,
|
||||
|
@ -129,26 +138,20 @@ const donationWorkflowSchema = z.discriminatedUnion('type', [
|
|||
// successfully; we just need to redeem it on the server anonymously.
|
||||
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
|
||||
receiptCredentialBase64: z.string(),
|
||||
|
||||
...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({
|
||||
// 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,
|
||||
// then this workflow can be deleted.
|
||||
type: z.literal(donationStateSchema.Enum.DONE),
|
||||
id: z.string(),
|
||||
id: coreDataSchema.shape.id,
|
||||
timestamp: coreDataSchema.shape.timestamp,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -131,6 +131,7 @@ export type StorageAccessType = {
|
|||
linkPreviews: boolean;
|
||||
universalExpireTimer: number;
|
||||
retryPlaceholders: ReadonlyArray<RetryItemType>;
|
||||
donationWorkflow: string;
|
||||
chromiumRegistrationDoneEver: '';
|
||||
chromiumRegistrationDone: '';
|
||||
phoneNumberSharingMode: PhoneNumberSharingMode;
|
||||
|
|
|
@ -8,6 +8,6 @@ export function isProtoBinaryEncodingEnabled(): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
// TODO: https://signalmessenger.atlassian.net/browse/DESKTOP-8938
|
||||
// TODO: DESKTOP-8938
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -127,6 +127,7 @@ window.testUtilities = {
|
|||
storyDistributionLists: [],
|
||||
donations: {
|
||||
currentWorkflow: undefined,
|
||||
lastError: undefined,
|
||||
receipts: [],
|
||||
},
|
||||
stickers: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue