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 { 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 () => {
|
||||||
|
|
|
@ -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
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,6 +127,7 @@ window.testUtilities = {
|
||||||
storyDistributionLists: [],
|
storyDistributionLists: [],
|
||||||
donations: {
|
donations: {
|
||||||
currentWorkflow: undefined,
|
currentWorkflow: undefined,
|
||||||
|
lastError: undefined,
|
||||||
receipts: [],
|
receipts: [],
|
||||||
},
|
},
|
||||||
stickers: {
|
stickers: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue