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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
ClientZkReceiptOperations,
|
||||
|
@ -12,17 +14,31 @@ import {
|
|||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
|
||||
import * as Bytes from '../Bytes';
|
||||
import { donationStateSchema } from '../types/Donations';
|
||||
import type {
|
||||
CardDetail,
|
||||
DonationReceipt,
|
||||
DonationWorkflow,
|
||||
ReceiptContext,
|
||||
} from '../types/Donations';
|
||||
import * as Errors from '../types/errors';
|
||||
import { getRandomBytes, sha256 } from '../Crypto';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
import { createLogger } from '../logging/log';
|
||||
import { donationValidationCompleteRoute } from '../util/signalRoutes';
|
||||
import { safeParseStrict, safeParseUnknown } from '../util/schemas';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { exponentialBackoffSleepTime } from '../util/exponentialBackoff';
|
||||
import { sleeper } from '../util/sleeper';
|
||||
import { isInPast, isOlderThan } from '../util/timestamp';
|
||||
import { DAY, DurationInSeconds } from '../util/durations';
|
||||
import { waitForOnline } from '../util/waitForOnline';
|
||||
import {
|
||||
donationErrorTypeSchema,
|
||||
donationStateSchema,
|
||||
donationWorkflowSchema,
|
||||
} from '../types/Donations';
|
||||
|
||||
import type {
|
||||
CardDetail,
|
||||
DonationErrorType,
|
||||
DonationReceipt,
|
||||
DonationWorkflow,
|
||||
ReceiptContext,
|
||||
} from '../types/Donations';
|
||||
|
||||
const { createDonationReceipt } = DataWriter;
|
||||
|
||||
|
@ -32,17 +48,88 @@ function redactId(id: string) {
|
|||
return `[REDACTED]${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function hashIdToIdempotencyKey(id: string) {
|
||||
const idBytes = Bytes.fromString(id);
|
||||
function hashIdToIdempotencyKey(id: string, apiCallName: string) {
|
||||
const idBytes = Bytes.fromString(id + apiCallName);
|
||||
const hashed = sha256(idBytes);
|
||||
return Buffer.from(hashed).toString('hex');
|
||||
}
|
||||
|
||||
const RECEIPT_SERIAL_LENGTH = 16;
|
||||
const BOOST_LEVEL = 1;
|
||||
const WORKFLOW_STORAGE_KEY = 'donationWorkflow';
|
||||
const MAX_CREDENTIAL_EXPIRATION_IN_DAYS = 90;
|
||||
|
||||
let runDonationAbortController: AbortController | undefined;
|
||||
let isInternalDonationInProgress = false;
|
||||
let isDonationInProgress = false;
|
||||
|
||||
export async function internalDoDonation({
|
||||
// Public API
|
||||
|
||||
// These are the four moments the user provides input to the donation workflow. So,
|
||||
// UI calls these methods directly; everything else happens automatically.
|
||||
|
||||
export async function startDonation({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
}: {
|
||||
currencyType: string;
|
||||
paymentAmount: number;
|
||||
}): Promise<void> {
|
||||
const workflow = await _createPaymentIntent({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
workflow: _getWorkflowFromRedux(),
|
||||
});
|
||||
|
||||
// We don't run the workflow, because there's nothing else to do after this first step
|
||||
await _saveWorkflow(workflow);
|
||||
}
|
||||
|
||||
export async function finishDonationWithCard(
|
||||
paymentDetail: CardDetail
|
||||
): Promise<void> {
|
||||
const existing = _getWorkflowFromRedux();
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
'finishDonationWithCard: Cannot finish nonexistent workflow!'
|
||||
);
|
||||
}
|
||||
let workflow: DonationWorkflow;
|
||||
|
||||
try {
|
||||
workflow = await _createPaymentMethodForIntent(existing, paymentDetail);
|
||||
} catch (error) {
|
||||
if (error.code >= 400 && error.code <= 499) {
|
||||
await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined);
|
||||
} else {
|
||||
await failDonation(donationErrorTypeSchema.Enum.GeneralError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// We run the workflow; it might be that no further user input is required!
|
||||
await _saveAndRunWorkflow(workflow);
|
||||
}
|
||||
|
||||
export async function finish3dsValidation(token: string): Promise<void> {
|
||||
const existing = _getWorkflowFromRedux();
|
||||
if (!existing) {
|
||||
throw new Error('finish3dsValidation: Cannot finish nonexistent workflow!');
|
||||
}
|
||||
|
||||
const workflow = await _completeValidationRedirect(existing, token);
|
||||
await _saveAndRunWorkflow(workflow);
|
||||
}
|
||||
|
||||
export async function clearDonation(): Promise<void> {
|
||||
runDonationAbortController?.abort();
|
||||
await _saveWorkflow(undefined);
|
||||
}
|
||||
|
||||
// For testing
|
||||
|
||||
export async function _internalDoDonation({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
paymentDetail,
|
||||
|
@ -51,55 +138,239 @@ export async function internalDoDonation({
|
|||
paymentAmount: number;
|
||||
paymentDetail: CardDetail;
|
||||
}): Promise<void> {
|
||||
if (isDonationInProgress) {
|
||||
if (isInternalDonationInProgress) {
|
||||
throw new Error("Can't proceed because a donation is in progress.");
|
||||
}
|
||||
|
||||
try {
|
||||
isDonationInProgress = true;
|
||||
isInternalDonationInProgress = true;
|
||||
|
||||
let workflow: DonationWorkflow;
|
||||
|
||||
workflow = await createPaymentIntent({
|
||||
workflow = await _createPaymentIntent({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
workflow: undefined,
|
||||
});
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
await _saveWorkflow(workflow);
|
||||
|
||||
workflow = await createPaymentMethodForIntent(workflow, paymentDetail);
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
|
||||
workflow = await confirmPayment(workflow);
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
|
||||
workflow = await getReceipt(workflow);
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
|
||||
workflow = await redeemReceipt(workflow);
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
|
||||
workflow = await saveReceipt(workflow);
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
workflow = await _createPaymentMethodForIntent(workflow, paymentDetail);
|
||||
await _saveAndRunWorkflow(workflow);
|
||||
} finally {
|
||||
isDonationInProgress = false;
|
||||
isInternalDonationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPaymentIntent({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
}: {
|
||||
currencyType: string;
|
||||
paymentAmount: number;
|
||||
}): Promise<DonationWorkflow> {
|
||||
if (!window.textsecure.server) {
|
||||
throw new Error(
|
||||
'createPaymentIntent: window.textsecure.server is not available!'
|
||||
// High-level functions to move things forward
|
||||
|
||||
export async function _saveAndRunWorkflow(
|
||||
workflow: DonationWorkflow | undefined
|
||||
): Promise<void> {
|
||||
const logId = `_saveAndRunWorkflow(${workflow?.id ? redactId(workflow.id) : 'NONE'}`;
|
||||
await _saveWorkflow(workflow);
|
||||
|
||||
if (isDonationInProgress) {
|
||||
log.info(
|
||||
`${logId}: Donation workflow is already running; not calling it again`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!workflow) {
|
||||
log.info(`${logId}: No need to start workflow; it's been cleared`);
|
||||
}
|
||||
|
||||
await runDonationWorkflow();
|
||||
}
|
||||
|
||||
// There's one place where this is called outside this file - when starting up, in the
|
||||
// onEmpty handler in background.ts.
|
||||
export async function runDonationWorkflow(): Promise<void> {
|
||||
let logId = 'runDonationWorkflow';
|
||||
|
||||
let totalCount = 0;
|
||||
let backoffCount = 0;
|
||||
|
||||
try {
|
||||
if (isDonationInProgress) {
|
||||
log.warn(`${logId}: Can't proceed because a donation is in progress.`);
|
||||
return;
|
||||
}
|
||||
isDonationInProgress = true;
|
||||
runDonationAbortController = new AbortController();
|
||||
|
||||
// We will loop until we explicitly return or throw
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const existing = _getWorkflowFromRedux();
|
||||
const idForLog = existing?.id ? redactId(existing.id) : 'NONE';
|
||||
logId = `runDonationWorkflow(${idForLog})`;
|
||||
|
||||
if (!existing) {
|
||||
log.info(`${logId}: No workflow to process. Returning.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, timestamp } = existing;
|
||||
if (isOlderThan(timestamp, DAY * 90)) {
|
||||
log.info(
|
||||
`${logId}: Workflow timestamp is more than 90 days ago. Clearing.`
|
||||
);
|
||||
await failDonation(donationErrorTypeSchema.Enum.GeneralError);
|
||||
return;
|
||||
}
|
||||
|
||||
totalCount += 1;
|
||||
if (totalCount === 1) {
|
||||
log.info(`${logId}: Starting, with state of ${type}...`);
|
||||
} else {
|
||||
log.info(
|
||||
`${logId}: Continuing at count ${totalCount}, with state of ${type}...`
|
||||
);
|
||||
}
|
||||
|
||||
if (runDonationAbortController?.signal.aborted) {
|
||||
log.info(`${logId}: abortController is aborted. Returning`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.textsecure.server?.isOnline()) {
|
||||
log.info(`${logId}: We are not online; waiting until we are online`);
|
||||
await waitForOnline();
|
||||
log.info(`${logId}: We are back online; starting up again`);
|
||||
}
|
||||
|
||||
backoffCount += 1;
|
||||
const sleepTime = exponentialBackoffSleepTime(backoffCount);
|
||||
if (sleepTime > 0) {
|
||||
const detail = `${logId}: sleeping for backoff for ${type}, backoff count is ${backoffCount}`;
|
||||
log.info(detail);
|
||||
await sleeper.sleep(sleepTime, detail);
|
||||
}
|
||||
|
||||
try {
|
||||
let updated: DonationWorkflow;
|
||||
|
||||
if (type === donationStateSchema.Enum.INTENT) {
|
||||
log.info(`${logId}: Waiting for payment details. Returning.`);
|
||||
return;
|
||||
}
|
||||
if (type === donationStateSchema.Enum.INTENT_METHOD) {
|
||||
log.info(`${logId}: Attempting to confirm payment`);
|
||||
updated = await _confirmPayment(existing);
|
||||
// continuing
|
||||
} else if (type === donationStateSchema.Enum.INTENT_REDIRECT) {
|
||||
log.info(
|
||||
`${logId}: Waiting for user to return from confirmation URL. Returning.`
|
||||
);
|
||||
return;
|
||||
} else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) {
|
||||
log.info(`${logId}: Attempting to get receipt`);
|
||||
updated = await _getReceipt(existing);
|
||||
// continuing
|
||||
} else if (type === donationStateSchema.Enum.RECEIPT) {
|
||||
log.info(`${logId}: Attempting to redeem receipt`);
|
||||
updated = await _redeemReceipt(existing);
|
||||
// continuing
|
||||
} else if (type === donationStateSchema.Enum.DONE) {
|
||||
log.info(`${logId}: Workflow is complete. Returning.`);
|
||||
return;
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
const isAborted = runDonationAbortController?.signal.aborted;
|
||||
if (isAborted) {
|
||||
log.info(`${logId}: abortController is aborted. Returning`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updated.type !== type) {
|
||||
backoffCount = 0;
|
||||
}
|
||||
|
||||
await _saveWorkflow(updated);
|
||||
} catch (error) {
|
||||
if (
|
||||
error.name === 'HTTPError' &&
|
||||
error.code >= 400 &&
|
||||
error.code <= 499
|
||||
) {
|
||||
log.warn(`${logId}: Got a ${error.code} error. Failing donation.`);
|
||||
if (type === donationStateSchema.Enum.INTENT_METHOD) {
|
||||
await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined);
|
||||
} else {
|
||||
await failDonation(
|
||||
donationErrorTypeSchema.Enum.DonationProcessingError
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.name === 'HTTPError' && typeof error.code === 'number') {
|
||||
log.warn(`${logId}: Got a ${error.code} error, retrying donation`);
|
||||
// continuing
|
||||
} else {
|
||||
log.warn(
|
||||
`${logId}: Donation step threw unexpectedly. Failing donation. ${Errors.toLogFormat(error)}`
|
||||
);
|
||||
await failDonation(
|
||||
donationErrorTypeSchema.Enum.DonationProcessingError
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDonationInProgress = false;
|
||||
runDonationAbortController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow steps
|
||||
|
||||
let isDonationStepInProgress = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function withConcurrencyCheck<T extends () => Promise<any>>(
|
||||
name: string,
|
||||
fn: T
|
||||
): Promise<ReturnType<T>> {
|
||||
if (isDonationStepInProgress) {
|
||||
throw new Error(
|
||||
`${name}: Can't proceed because a donation step is already in progress.`
|
||||
);
|
||||
}
|
||||
isDonationStepInProgress = true;
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
isDonationStepInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function _createPaymentIntent({
|
||||
currencyType,
|
||||
paymentAmount,
|
||||
workflow,
|
||||
}: {
|
||||
currencyType: string;
|
||||
paymentAmount: number;
|
||||
workflow: DonationWorkflow | undefined;
|
||||
}): Promise<DonationWorkflow> {
|
||||
const id = uuid();
|
||||
const logId = `createPaymentIntent(${redactId(id)})`;
|
||||
const logId = `_createPaymentIntent(${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`
|
||||
);
|
||||
}
|
||||
if (!window.textsecure.server) {
|
||||
throw new Error(`${logId}: window.textsecure.server is not available!`);
|
||||
}
|
||||
|
||||
log.info(`${logId}: Creating new workflow`);
|
||||
|
||||
const payload = {
|
||||
|
@ -124,17 +395,23 @@ export async function createPaymentIntent({
|
|||
returnToken: uuid(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPaymentMethodForIntent(
|
||||
export async function _createPaymentMethodForIntent(
|
||||
workflow: DonationWorkflow,
|
||||
cardDetail: CardDetail
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `createPaymentMethodForIntent(${redactId(workflow.id)})`;
|
||||
const logId = `_createPaymentMethodForIntent(${redactId(workflow.id)})`;
|
||||
|
||||
if (workflow.type !== donationStateSchema.Enum.INTENT) {
|
||||
return withConcurrencyCheck(logId, async () => {
|
||||
// We need to handle INTENT_METHOD so user can fix their payment info and try again
|
||||
if (
|
||||
workflow.type !== donationStateSchema.Enum.INTENT &&
|
||||
workflow.type !== donationStateSchema.Enum.INTENT_METHOD
|
||||
) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} is not at type INTENT, unable to create payment method`
|
||||
`${logId}: workflow at type ${workflow?.type} is not at type INTENT or INTENT_METHOD, unable to create payment method`
|
||||
);
|
||||
}
|
||||
if (!window.textsecure.server) {
|
||||
|
@ -156,13 +433,15 @@ export async function createPaymentMethodForIntent(
|
|||
timestamp: Date.now(),
|
||||
paymentMethodId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmPayment(
|
||||
export async function _confirmPayment(
|
||||
workflow: DonationWorkflow
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `confirmPayment(${redactId(workflow.id)})`;
|
||||
const logId = `_confirmPayment(${redactId(workflow.id)})`;
|
||||
|
||||
return withConcurrencyCheck(logId, async () => {
|
||||
if (workflow.type !== donationStateSchema.Enum.INTENT_METHOD) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} is not at type INTENT_METHOD, unable to confirm payment`
|
||||
|
@ -174,27 +453,13 @@ export async function confirmPayment(
|
|||
|
||||
log.info(`${logId}: Starting`);
|
||||
|
||||
const serverPublicParams = new ServerPublicParams(
|
||||
Buffer.from(window.getServerPublicParams(), 'base64')
|
||||
);
|
||||
const zkReceipt = new ClientZkReceiptOperations(serverPublicParams);
|
||||
const receiptSerialData = getRandomBytes(RECEIPT_SERIAL_LENGTH);
|
||||
const receiptSerial = new ReceiptSerial(Buffer.from(receiptSerialData));
|
||||
const receiptCredentialRequestContext =
|
||||
zkReceipt.createReceiptCredentialRequestContext(receiptSerial);
|
||||
const receiptCredentialRequest = receiptCredentialRequestContext.getRequest();
|
||||
|
||||
const receiptContext: ReceiptContext = {
|
||||
receiptCredentialRequestContextBase64: Bytes.toBase64(
|
||||
receiptCredentialRequestContext.serialize()
|
||||
),
|
||||
receiptCredentialRequestBase64: Bytes.toBase64(
|
||||
receiptCredentialRequest.serialize()
|
||||
),
|
||||
};
|
||||
const receiptContext = getReceiptContext();
|
||||
|
||||
const { clientSecret, paymentIntentId, paymentMethodId, id } = workflow;
|
||||
const idempotencyKey = hashIdToIdempotencyKey(id);
|
||||
const idempotencyKey = hashIdToIdempotencyKey(
|
||||
id,
|
||||
`confirmPayment/${paymentMethodId}`
|
||||
);
|
||||
const returnUrl = donationValidationCompleteRoute
|
||||
.toAppUrl({ token: workflow.returnToken })
|
||||
.toString();
|
||||
|
@ -209,7 +474,6 @@ export async function confirmPayment(
|
|||
const { next_action: nextAction } =
|
||||
await window.textsecure.server.confirmIntentWithStripe(options);
|
||||
|
||||
// TODO: Support Redirect to URL
|
||||
if (nextAction && nextAction.type === 'redirect_to_url') {
|
||||
const { redirect_to_url: redirectDetails } = nextAction;
|
||||
|
||||
|
@ -244,14 +508,16 @@ export async function confirmPayment(
|
|||
type: donationStateSchema.Enum.INTENT_CONFIRMED,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeValidationRedirect(
|
||||
export async function _completeValidationRedirect(
|
||||
workflow: DonationWorkflow,
|
||||
token: string
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `completeValidationRedirect(${redactId(workflow.id)})`;
|
||||
const logId = `_completeValidationRedirect(${redactId(workflow.id)})`;
|
||||
|
||||
return withConcurrencyCheck(logId, async () => {
|
||||
if (workflow.type !== donationStateSchema.Enum.INTENT_REDIRECT) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} is not type INTENT_REDIRECT, unable to complete redirect`
|
||||
|
@ -260,28 +526,29 @@ export async function completeValidationRedirect(
|
|||
if (!window.textsecure.server) {
|
||||
throw new Error(`${logId}: window.textsecure.server is not available!`);
|
||||
}
|
||||
|
||||
log.info(`${logId}: Starting`);
|
||||
|
||||
if (token !== workflow.returnToken) {
|
||||
throw new Error(`${logId}: The provided token did not match saved token`);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`${logId}: Successfully transitioned to INTENT_CONFIRMED for workflow ${redactId(workflow.id)}`
|
||||
);
|
||||
log.info(`${logId}: Successfully transitioned to INTENT_CONFIRMED`);
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
type: donationStateSchema.Enum.INTENT_CONFIRMED,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReceipt(
|
||||
export async function _getReceipt(
|
||||
workflow: DonationWorkflow
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `getReceipt(${redactId(workflow.id)})`;
|
||||
const logId = `_getReceipt(${redactId(workflow.id)})`;
|
||||
|
||||
return withConcurrencyCheck(logId, async () => {
|
||||
if (workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED, unable to get receipt`
|
||||
|
@ -290,6 +557,7 @@ export async function getReceipt(
|
|||
if (!window.textsecure.server) {
|
||||
throw new Error(`${logId}: window.textsecure.server is not available!`);
|
||||
}
|
||||
|
||||
log.info(`${logId}: Starting`);
|
||||
|
||||
const {
|
||||
|
@ -307,43 +575,68 @@ export async function getReceipt(
|
|||
// If 204, use exponential backoff - payment hasn't gone through yet
|
||||
// if 409, something has gone strangely wrong - we're using a different
|
||||
// credentialRequest for the same paymentIntentId
|
||||
const { receiptCredentialResponse: receiptCredentialResponseBase64 } =
|
||||
await window.textsecure.server.createBoostReceiptCredentials(jsonPayload);
|
||||
let responseWithDetails;
|
||||
try {
|
||||
responseWithDetails =
|
||||
await window.textsecure.server.createBoostReceiptCredentials(
|
||||
jsonPayload
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 409) {
|
||||
// Save for the user's tax records even if something went wrong with credential
|
||||
await saveReceipt(workflow, logId);
|
||||
throw new Error(
|
||||
`${logId}: Got 409 when attempting to get receipt; failing donation`
|
||||
);
|
||||
}
|
||||
|
||||
const receiptCredentialResponse = new ReceiptCredentialResponse(
|
||||
Buffer.from(receiptCredentialResponseBase64, 'base64')
|
||||
);
|
||||
const receiptCredentialRequestContext = new ReceiptCredentialRequestContext(
|
||||
Buffer.from(receiptCredentialRequestContextBase64, 'base64')
|
||||
);
|
||||
const serverPublicParams = new ServerPublicParams(
|
||||
Buffer.from(window.getServerPublicParams(), 'base64')
|
||||
);
|
||||
const zkReceipt = new ClientZkReceiptOperations(serverPublicParams);
|
||||
const receiptCredential = zkReceipt.receiveReceiptCredential(
|
||||
receiptCredentialRequestContext,
|
||||
receiptCredentialResponse
|
||||
);
|
||||
|
||||
// TODO: Validate receiptCredential.level and expiration
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (responseWithDetails.response.status === 204) {
|
||||
log.info(
|
||||
`${logId}: Successfully transitioned to RECEIPT for workflow ${redactId(workflow.id)}`
|
||||
`${logId}: Payment is still processing, leaving workflow at INTENT_CONFIRMED`
|
||||
);
|
||||
return workflow;
|
||||
}
|
||||
const { receiptCredentialResponse: receiptCredentialResponseBase64 } =
|
||||
responseWithDetails.data;
|
||||
|
||||
const receiptCredential = generateCredential(
|
||||
receiptCredentialResponseBase64,
|
||||
receiptCredentialRequestContextBase64
|
||||
);
|
||||
|
||||
const isValid = isCredentialValid(receiptCredential);
|
||||
if (!isValid) {
|
||||
// Save for the user's tax records even if something went wrong with credential
|
||||
await saveReceipt(workflow, logId);
|
||||
throw new Error(
|
||||
`${logId}: Credential returned for donation is invalid; failing donation`
|
||||
);
|
||||
}
|
||||
|
||||
log.info(`${logId}: Successfully transitioned to RECEIPT`);
|
||||
|
||||
// At this point we know that the payment went through, so we save the receipt now.
|
||||
// If the redemption never happens, or fails, the user has it for their tax records.
|
||||
|
||||
await saveReceipt(workflow, logId);
|
||||
return {
|
||||
...workflow,
|
||||
type: donationStateSchema.Enum.RECEIPT,
|
||||
timestamp: Date.now(),
|
||||
receiptCredentialBase64: Bytes.toBase64(receiptCredential.serialize()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function redeemReceipt(
|
||||
export async function _redeemReceipt(
|
||||
workflow: DonationWorkflow
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `redeemReceipt(${redactId(workflow.id)})`;
|
||||
const logId = `_redeemReceipt(${redactId(workflow.id)})`;
|
||||
|
||||
return withConcurrencyCheck(logId, async () => {
|
||||
if (workflow.type !== donationStateSchema.Enum.RECEIPT) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} not type RECEIPT, unable to redeem receipt`
|
||||
|
@ -352,18 +645,12 @@ export async function redeemReceipt(
|
|||
if (!window.textsecure.server) {
|
||||
throw new Error(`${logId}: window.textsecure.server is not available!`);
|
||||
}
|
||||
|
||||
log.info(`${logId}: Starting`);
|
||||
|
||||
const serverPublicParams = new ServerPublicParams(
|
||||
Buffer.from(window.getServerPublicParams(), 'base64')
|
||||
const receiptCredentialPresentation = generateReceiptCredentialPresentation(
|
||||
workflow.receiptCredentialBase64
|
||||
);
|
||||
const zkReceipt = new ClientZkReceiptOperations(serverPublicParams);
|
||||
const { receiptCredentialBase64 } = workflow;
|
||||
const receiptCredential = new ReceiptCredential(
|
||||
Buffer.from(receiptCredentialBase64, 'base64')
|
||||
);
|
||||
const receiptCredentialPresentation =
|
||||
zkReceipt.createReceiptCredentialPresentation(receiptCredential);
|
||||
const receiptCredentialPresentationBase64 = Bytes.toBase64(
|
||||
receiptCredentialPresentation.serialize()
|
||||
);
|
||||
|
@ -375,42 +662,212 @@ export async function redeemReceipt(
|
|||
|
||||
await window.textsecure.server.redeemReceipt(jsonPayload);
|
||||
|
||||
log.info(`${logId}: Successfully transitioned to RECEIPT_REDEEMED`);
|
||||
log.info(`${logId}: Successfully transitioned to DONE`);
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
type: donationStateSchema.Enum.RECEIPT_REDEEMED,
|
||||
type: donationStateSchema.Enum.DONE,
|
||||
id: workflow.id,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveReceipt(
|
||||
workflow: DonationWorkflow
|
||||
): Promise<DonationWorkflow> {
|
||||
const logId = `saveReceipt(${redactId(workflow.id)})`;
|
||||
// Helper functions
|
||||
|
||||
if (workflow.type !== donationStateSchema.Enum.RECEIPT_REDEEMED) {
|
||||
async function failDonation(errorType: DonationErrorType): Promise<void> {
|
||||
const workflow = _getWorkflowFromRedux();
|
||||
|
||||
// We clear the workflow if we didn't just get user input
|
||||
if (
|
||||
workflow &&
|
||||
workflow.type !== donationStateSchema.Enum.INTENT_METHOD &&
|
||||
workflow.type !== donationStateSchema.Enum.INTENT
|
||||
) {
|
||||
await _saveWorkflow(undefined);
|
||||
}
|
||||
|
||||
log.info(`failDonation: Failing with type ${errorType}`);
|
||||
window.reduxActions.donations.updateLastError(errorType);
|
||||
}
|
||||
async function _saveWorkflow(
|
||||
workflow: DonationWorkflow | undefined
|
||||
): Promise<void> {
|
||||
await _saveWorkflowToStorage(workflow);
|
||||
_saveWorkflowToRedux(workflow);
|
||||
}
|
||||
export function _getWorkflowFromRedux(): DonationWorkflow | undefined {
|
||||
return window.reduxStore.getState().donations.currentWorkflow;
|
||||
}
|
||||
export function _saveWorkflowToRedux(
|
||||
workflow: DonationWorkflow | undefined
|
||||
): void {
|
||||
window.reduxActions.donations.updateWorkflow(workflow);
|
||||
}
|
||||
|
||||
export function _getWorkflowFromStorage(): DonationWorkflow | undefined {
|
||||
const logId = '_getWorkflowFromStorage';
|
||||
const workflowJson = window.storage.get(WORKFLOW_STORAGE_KEY);
|
||||
|
||||
if (!workflowJson) {
|
||||
log.info(`${logId}: No workflow found in window.storage`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workflowData = JSON.parse(workflowJson) as unknown;
|
||||
const result = safeParseUnknown(donationWorkflowSchema, workflowData);
|
||||
if (!result.success) {
|
||||
log.error(
|
||||
`${logId}: Workflow from window.storage was malformed: ${result.error.flatten()}`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workflow = result.data;
|
||||
if (workflow.type === donationStateSchema.Enum.INTENT) {
|
||||
log.info(`${logId}: Found existing workflow at type INTENT, dropping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
log.info(`${logId}: Found existing workflow from window.storage`);
|
||||
return workflow;
|
||||
}
|
||||
export async function _saveWorkflowToStorage(
|
||||
workflow: DonationWorkflow | undefined
|
||||
): Promise<void> {
|
||||
const logId = `_saveWorkflowToStorage(${workflow?.id ? redactId(workflow.id) : 'NONE'}`;
|
||||
if (!workflow) {
|
||||
log.info(`${logId}: Clearing workflow`);
|
||||
await window.storage.remove(WORKFLOW_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = safeParseStrict(donationWorkflowSchema, workflow);
|
||||
if (!result.success) {
|
||||
log.error(
|
||||
`${logId}: Provided workflow was malformed: ${result.error.flatten()}`
|
||||
);
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
await window.storage.put(WORKFLOW_STORAGE_KEY, JSON.stringify(workflow));
|
||||
log.info(`${logId}: Saved workflow to window.storage`);
|
||||
}
|
||||
|
||||
async function saveReceipt(workflow: DonationWorkflow, logId: string) {
|
||||
if (
|
||||
workflow.type !== donationStateSchema.Enum.RECEIPT &&
|
||||
workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED
|
||||
) {
|
||||
throw new Error(
|
||||
`${logId}: workflow at type ${workflow?.type} is not ready to save receipt`
|
||||
`${logId}: Cannot save receipt from workflow at type ${workflow?.type}`
|
||||
);
|
||||
}
|
||||
log.info(`${logId}: Starting`);
|
||||
|
||||
const donationReceipt: DonationReceipt = {
|
||||
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.
|
||||
timestamp: workflow.timestamp,
|
||||
};
|
||||
|
||||
await createDonationReceipt(donationReceipt);
|
||||
|
||||
log.info(`${logId}: Successfully saved receipt`);
|
||||
|
||||
window.reduxActions.donations.addReceipt(donationReceipt);
|
||||
|
||||
log.info(`${logId}: Successfully saved receipt`);
|
||||
}
|
||||
|
||||
// Working with zkgroup receipts
|
||||
|
||||
function getServerPublicParams(): ServerPublicParams {
|
||||
return new ServerPublicParams(
|
||||
Buffer.from(window.getServerPublicParams(), 'base64')
|
||||
);
|
||||
}
|
||||
|
||||
function getZkReceiptOperations(): ClientZkReceiptOperations {
|
||||
const serverPublicParams = getServerPublicParams();
|
||||
return new ClientZkReceiptOperations(serverPublicParams);
|
||||
}
|
||||
|
||||
function getReceiptContext(): ReceiptContext {
|
||||
const zkReceipt = getZkReceiptOperations();
|
||||
const receiptSerialData = getRandomBytes(RECEIPT_SERIAL_LENGTH);
|
||||
const receiptSerial = new ReceiptSerial(Buffer.from(receiptSerialData));
|
||||
const receiptCredentialRequestContext =
|
||||
zkReceipt.createReceiptCredentialRequestContext(receiptSerial);
|
||||
const receiptCredentialRequest = receiptCredentialRequestContext.getRequest();
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
type: donationStateSchema.Enum.DONE,
|
||||
receiptCredentialRequestContextBase64: Bytes.toBase64(
|
||||
receiptCredentialRequestContext.serialize()
|
||||
),
|
||||
receiptCredentialRequestBase64: Bytes.toBase64(
|
||||
receiptCredentialRequest.serialize()
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function generateCredential(
|
||||
receiptCredentialResponseBase64: string,
|
||||
receiptCredentialRequestContextBase64: string
|
||||
) {
|
||||
const zkReceipt = getZkReceiptOperations();
|
||||
const receiptCredentialResponse = new ReceiptCredentialResponse(
|
||||
Buffer.from(receiptCredentialResponseBase64, 'base64')
|
||||
);
|
||||
const receiptCredentialRequestContext = new ReceiptCredentialRequestContext(
|
||||
Buffer.from(receiptCredentialRequestContextBase64, 'base64')
|
||||
);
|
||||
|
||||
return zkReceipt.receiveReceiptCredential(
|
||||
receiptCredentialRequestContext,
|
||||
receiptCredentialResponse
|
||||
);
|
||||
}
|
||||
|
||||
function generateReceiptCredentialPresentation(
|
||||
receiptCredentialBase64: string
|
||||
) {
|
||||
const zkReceipt = getZkReceiptOperations();
|
||||
const receiptCredential = new ReceiptCredential(
|
||||
Buffer.from(receiptCredentialBase64, 'base64')
|
||||
);
|
||||
const receiptCredentialPresentation =
|
||||
zkReceipt.createReceiptCredentialPresentation(receiptCredential);
|
||||
return receiptCredentialPresentation;
|
||||
}
|
||||
|
||||
function isCredentialValid(credential: ReceiptCredential): boolean {
|
||||
const logId = 'isCredentialValid';
|
||||
|
||||
const level = credential.getReceiptLevel();
|
||||
if (level !== BigInt(BOOST_LEVEL)) {
|
||||
log.warn(`${logId}: Expected level to be ${BOOST_LEVEL}, but was ${level}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const expirationTime = DurationInSeconds.toMillis(
|
||||
DurationInSeconds.fromSeconds(credential.getReceiptExpirationTime())
|
||||
);
|
||||
if (expirationTime % DAY !== 0) {
|
||||
log.warn(
|
||||
`${logId}: Expiration of ${expirationTime} was not divisible by ${DAY}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isInPast(expirationTime)) {
|
||||
log.warn(`${logId}: Expiration of ${expirationTime} is in the past`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxExpiration = Date.now() + DAY * MAX_CREDENTIAL_EXPIRATION_IN_DAYS;
|
||||
if (expirationTime > maxExpiration) {
|
||||
log.warn(
|
||||
`${logId}: Expiration of ${expirationTime} is greater than max expiration: ${maxExpiration}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -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