mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-02 00:07:56 +01:00
1426 lines
42 KiB
TypeScript
1426 lines
42 KiB
TypeScript
// 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,
|
|
ReceiptCredential,
|
|
ReceiptCredentialRequestContext,
|
|
ReceiptCredentialResponse,
|
|
ReceiptSerial,
|
|
ServerPublicParams,
|
|
} from '@signalapp/libsignal-client/zkgroup.js';
|
|
import * as countryCodes from 'country-codes-list';
|
|
|
|
import * as Bytes from '../Bytes.std.js';
|
|
import * as Errors from '../types/errors.std.js';
|
|
import { getRandomBytes, sha256 } from '../Crypto.node.js';
|
|
import { DataWriter } from '../sql/Client.preload.js';
|
|
import { createLogger } from '../logging/log.std.js';
|
|
import { getProfile } from '../util/getProfile.preload.js';
|
|
import {
|
|
donationPaypalApprovedRoute,
|
|
donationPaypalCanceledRoute,
|
|
donationValidationCompleteRoute,
|
|
} from '../util/signalRoutes.std.js';
|
|
import { safeParseStrict, safeParseUnknown } from '../util/schemas.std.js';
|
|
import { missingCaseError } from '../util/missingCaseError.std.js';
|
|
import { exponentialBackoffSleepTime } from '../util/exponentialBackoff.std.js';
|
|
import { sleeper } from '../util/sleeper.std.js';
|
|
import { isInPast, isOlderThan } from '../util/timestamp.std.js';
|
|
import { DAY, DurationInSeconds } from '../util/durations/index.std.js';
|
|
import { waitForOnline } from '../util/waitForOnline.dom.js';
|
|
import {
|
|
donationErrorTypeSchema,
|
|
donationStateSchema,
|
|
donationWorkflowSchema,
|
|
donationProcessorSchema,
|
|
} from '../types/Donations.std.js';
|
|
|
|
import type {
|
|
CardDetail,
|
|
DonationErrorType,
|
|
DonationReceipt,
|
|
DonationWorkflow,
|
|
ReceiptContext,
|
|
StripeDonationAmount,
|
|
} from '../types/Donations.std.js';
|
|
import { ToastType } from '../types/Toast.dom.js';
|
|
import { NavTab, SettingsPage } from '../types/Nav.std.js';
|
|
import { getRegionCodeForNumber } from '../util/libphonenumberUtil.std.js';
|
|
import {
|
|
createBoostPaymentIntent,
|
|
createPaymentMethodWithStripe,
|
|
confirmIntentWithStripe,
|
|
createBoostReceiptCredentials,
|
|
redeemReceipt,
|
|
isOnline,
|
|
createPaypalBoostPayment,
|
|
confirmPaypalBoostPayment,
|
|
} from '../textsecure/WebAPI.preload.js';
|
|
import { itemStorage } from '../textsecure/Storage.preload.js';
|
|
|
|
const { createDonationReceipt } = DataWriter;
|
|
|
|
const log = createLogger('donations');
|
|
|
|
function redactId(id: string) {
|
|
return `[REDACTED]${id.slice(-4)}`;
|
|
}
|
|
|
|
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;
|
|
let isInitialized = false;
|
|
|
|
// Public API
|
|
|
|
// Starting everything up
|
|
|
|
export async function initialize(): Promise<void> {
|
|
if (isInitialized) {
|
|
return;
|
|
}
|
|
|
|
isInitialized = true;
|
|
|
|
const workflow = _getWorkflowFromRedux();
|
|
if (!workflow) {
|
|
return;
|
|
}
|
|
|
|
const shouldShowToast =
|
|
didResumeWorkflowAtStartup() && !isDonationPageVisible();
|
|
const isTooOld = isOlderThan(workflow.timestamp, DAY);
|
|
|
|
if (
|
|
isTooOld &&
|
|
(workflow.type === donationStateSchema.Enum.INTENT_METHOD ||
|
|
workflow.type === donationStateSchema.Enum.INTENT_REDIRECT ||
|
|
workflow.type === donationStateSchema.Enum.PAYPAL_INTENT)
|
|
) {
|
|
log.info(
|
|
`initialize: Workflow at ${workflow.type} is too old, canceling donation.`
|
|
);
|
|
await clearDonation();
|
|
await failDonation(donationErrorTypeSchema.Enum.TimedOut);
|
|
|
|
return;
|
|
}
|
|
|
|
if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) {
|
|
if (shouldShowToast) {
|
|
log.info(
|
|
'initialize: Showing confirmation toast, workflow is at INTENT_METHOD.'
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationConfirmationNeeded,
|
|
});
|
|
}
|
|
|
|
// Note that we are not starting the workflow here
|
|
return;
|
|
}
|
|
|
|
if (workflow.type === donationStateSchema.Enum.PAYPAL_INTENT) {
|
|
if (shouldShowToast) {
|
|
log.info(
|
|
'initialize: Showing confirmation toast, workflow is at PAYPAL_INTENT.'
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationPaypalConfirmationNeeded,
|
|
});
|
|
}
|
|
|
|
// Note that we are not starting the workflow here
|
|
return;
|
|
}
|
|
|
|
if (shouldShowToast) {
|
|
log.info(
|
|
'initialize: We resumed at startup and donation page not visible. Showing processing toast.'
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationProcessing,
|
|
});
|
|
}
|
|
|
|
await _runDonationWorkflow();
|
|
}
|
|
|
|
// These are the five moments the user provides input to the donation workflow. So,
|
|
// UI calls these methods directly; everything else happens automatically.
|
|
|
|
export async function startStripeDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
}: {
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
}): 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 startPaypalDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
}: {
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
}): Promise<void> {
|
|
const workflow = await _createPaypalIntent({
|
|
currencyType,
|
|
paymentAmount,
|
|
workflow: _getWorkflowFromRedux(),
|
|
});
|
|
|
|
// We don't run the workflow. The next step is to wait for the user to approve the
|
|
// PayPal payment, and then they will be redirected to the app.
|
|
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) {
|
|
const errorType: string | undefined = error.response?.error?.type;
|
|
if (error.code >= 400 && error.code <= 499 && errorType === 'card_error') {
|
|
await failDonation(
|
|
donationErrorTypeSchema.Enum.PaymentDeclined,
|
|
errorType
|
|
);
|
|
} else {
|
|
await failDonation(donationErrorTypeSchema.Enum.GeneralError, errorType);
|
|
}
|
|
|
|
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> {
|
|
let workflow: DonationWorkflow;
|
|
|
|
try {
|
|
const existing = _getWorkflowFromRedux();
|
|
if (!existing) {
|
|
throw new Error(
|
|
'finish3dsValidation: Cannot finish nonexistent workflow!'
|
|
);
|
|
}
|
|
|
|
workflow = await _completeValidationRedirect(existing, token);
|
|
} catch (error) {
|
|
await failDonation(donationErrorTypeSchema.Enum.Failed3dsValidation);
|
|
throw error;
|
|
}
|
|
|
|
await _saveAndRunWorkflow(workflow);
|
|
}
|
|
|
|
export async function approvePaypalPayment({
|
|
payerId,
|
|
paymentToken,
|
|
returnToken,
|
|
}: {
|
|
payerId: string | undefined;
|
|
paymentToken: string | undefined;
|
|
returnToken: string;
|
|
}): Promise<void> {
|
|
let workflow: DonationWorkflow;
|
|
|
|
try {
|
|
const existing = _getWorkflowFromRedux();
|
|
const lastReturnToken = _getLastReturnTokenFromRedux();
|
|
|
|
if (!existing) {
|
|
// This can happen if after you finished a Paypal donation, but you go back to
|
|
// the Paypal website and click Return to Signal again.
|
|
if (returnToken === lastReturnToken) {
|
|
if (!isDonationPageVisible()) {
|
|
redirectToPage(SettingsPage.Donations);
|
|
}
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
'approvePaypalPayment: Cannot finish nonexistent workflow!'
|
|
);
|
|
}
|
|
|
|
// If you visit the approval link twice in succession, this can happen
|
|
if (isPaypalAlreadyApproved(existing)) {
|
|
log.warn(
|
|
'approvePaypalPayment: Existing workflow already approved, not trying to approve again'
|
|
);
|
|
if (!isDonationPageVisible()) {
|
|
redirectToPage(SettingsPage.Donations);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payerId == null || paymentToken == null) {
|
|
throw new Error(
|
|
'approvePaypalPayment: payerId or paymentToken are missing'
|
|
);
|
|
}
|
|
|
|
workflow = await _completePaypalApprovalRedirect({
|
|
workflow: existing,
|
|
returnToken,
|
|
payerId,
|
|
paymentToken,
|
|
});
|
|
} catch (error) {
|
|
await failDonation(donationErrorTypeSchema.Enum.PaypalError);
|
|
throw error;
|
|
}
|
|
|
|
await _saveAndRunWorkflow(workflow);
|
|
}
|
|
|
|
// The cancel flow happens when the user views the PayPal payment form and clicks the
|
|
// link "Return to Signal". We handle this by focusing the app donations PayPal page,
|
|
// where the user can take action to abandon the flow.
|
|
export async function cancelPaypalPayment(_returnToken: string): Promise<void> {
|
|
const logId = 'cancelPaypalPayment';
|
|
log.info(`${logId}: User visited PayPal cancel URI, showing donate flow`);
|
|
|
|
if (!isDonationPageVisible()) {
|
|
redirectToPage(SettingsPage.DonationsDonateFlow);
|
|
}
|
|
}
|
|
|
|
function isPaypalAlreadyApproved(workflow: DonationWorkflow): boolean {
|
|
const { type } = workflow;
|
|
return (
|
|
type === donationStateSchema.Enum.PAYPAL_APPROVED ||
|
|
type === donationStateSchema.Enum.PAYMENT_CONFIRMED ||
|
|
type === donationStateSchema.Enum.RECEIPT ||
|
|
type === donationStateSchema.Enum.DONE
|
|
);
|
|
}
|
|
|
|
export async function clearDonation(): Promise<void> {
|
|
runDonationAbortController?.abort();
|
|
await _saveWorkflow(undefined);
|
|
}
|
|
|
|
export async function resumeDonation(): Promise<void> {
|
|
const existing = _getWorkflowFromRedux();
|
|
if (!existing) {
|
|
throw new Error('resumeDonation: Cannot finish nonexistent workflow!');
|
|
}
|
|
|
|
await _saveAndRunWorkflow(existing);
|
|
}
|
|
|
|
// For testing
|
|
|
|
export async function _internalDoDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
paymentDetail,
|
|
}: {
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
paymentDetail: CardDetail;
|
|
}): Promise<void> {
|
|
if (isInternalDonationInProgress) {
|
|
throw new Error("Can't proceed because a donation is in progress.");
|
|
}
|
|
|
|
try {
|
|
isInternalDonationInProgress = true;
|
|
|
|
let workflow: DonationWorkflow;
|
|
|
|
workflow = await _createPaymentIntent({
|
|
currencyType,
|
|
paymentAmount,
|
|
workflow: undefined,
|
|
});
|
|
await _saveWorkflow(workflow);
|
|
|
|
workflow = await _createPaymentMethodForIntent(workflow, paymentDetail);
|
|
await _saveAndRunWorkflow(workflow);
|
|
} catch (error) {
|
|
const errorType: string | undefined = error.response?.error?.type;
|
|
await failDonation(donationErrorTypeSchema.Enum.GeneralError, errorType);
|
|
} finally {
|
|
isInternalDonationInProgress = false;
|
|
}
|
|
}
|
|
|
|
// For testing
|
|
|
|
export async function _internalDoPaypalDonation({
|
|
currencyType,
|
|
paymentAmount,
|
|
}: {
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
}): Promise<void> {
|
|
if (isInternalDonationInProgress) {
|
|
throw new Error("Can't proceed because a donation is in progress.");
|
|
}
|
|
|
|
const logId = '_internalDoPaypalDonation';
|
|
try {
|
|
isInternalDonationInProgress = true;
|
|
|
|
const workflow = await _createPaypalIntent({
|
|
currencyType,
|
|
paymentAmount,
|
|
workflow: undefined,
|
|
});
|
|
await _saveWorkflow(workflow);
|
|
if (workflow.type !== donationStateSchema.Enum.PAYPAL_INTENT) {
|
|
throw new Error(`${logId}: Resulting workflow not PAYPAL_INTENT`);
|
|
}
|
|
|
|
const { approvalUrl } = workflow;
|
|
log.info(`${logId}: Visit URL in browser to continue:`, approvalUrl);
|
|
} catch (error) {
|
|
log.error(logId, error);
|
|
const errorType: string | undefined = error.response?.error?.type;
|
|
await failDonation(donationErrorTypeSchema.Enum.GeneralError, errorType);
|
|
} finally {
|
|
isInternalDonationInProgress = false;
|
|
}
|
|
}
|
|
|
|
// High-level functions to move things forward
|
|
|
|
export async function _saveAndRunWorkflow(
|
|
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();
|
|
}
|
|
|
|
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 (!isOnline()) {
|
|
log.info(`${logId}: We are not online; waiting until we are online`);
|
|
await waitForOnline({ server: { isOnline } });
|
|
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) {
|
|
if (didResumeWorkflowAtStartup()) {
|
|
log.info(
|
|
`${logId}: Resumed after startup and haven't charged payment method. Waiting for user confirmation.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
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.`
|
|
);
|
|
if (!isDonationPageVisible()) {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing verification needed toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationVerificationNeeded,
|
|
});
|
|
}
|
|
return;
|
|
} else if (type === donationStateSchema.Enum.PAYPAL_INTENT) {
|
|
log.info(
|
|
`${logId}: Waiting for user to return from PayPal. Returning.`
|
|
);
|
|
return;
|
|
} else if (type === donationStateSchema.Enum.PAYPAL_APPROVED) {
|
|
log.info(`${logId}: Attempting to confirm PayPal payment`);
|
|
updated = await _confirmPaypalPayment(existing);
|
|
} else if (
|
|
type === donationStateSchema.Enum.INTENT_CONFIRMED ||
|
|
type === donationStateSchema.Enum.PAYMENT_CONFIRMED
|
|
) {
|
|
log.info(`${logId}: Attempting to get receipt`);
|
|
updated = await _getReceipt(existing);
|
|
// continuing
|
|
} else if (type === donationStateSchema.Enum.RECEIPT) {
|
|
log.info(`${logId}: Attempting to redeem receipt`);
|
|
updated = await _redeemReceipt(existing);
|
|
// continuing
|
|
} else if (type === donationStateSchema.Enum.DONE) {
|
|
if (isDonationPageVisible()) {
|
|
if (isDonationsDonateFlowVisible()) {
|
|
redirectToPage(SettingsPage.Donations);
|
|
}
|
|
} else {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing complete toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationCompleted,
|
|
});
|
|
}
|
|
|
|
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) {
|
|
const errorType: string | undefined = error.response?.error?.type;
|
|
|
|
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 &&
|
|
errorType === 'card_error'
|
|
) {
|
|
await failDonation(
|
|
donationErrorTypeSchema.Enum.PaymentDeclined,
|
|
errorType
|
|
);
|
|
} else {
|
|
await failDonation(
|
|
donationErrorTypeSchema.Enum.GeneralError,
|
|
errorType
|
|
);
|
|
}
|
|
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.GeneralError,
|
|
errorType
|
|
);
|
|
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: StripeDonationAmount;
|
|
workflow: DonationWorkflow | undefined;
|
|
}): Promise<DonationWorkflow> {
|
|
const id = uuid();
|
|
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`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Creating new workflow`);
|
|
|
|
const payload = {
|
|
currency: currencyType,
|
|
amount: paymentAmount,
|
|
level: 1,
|
|
paymentMethod: 'CARD',
|
|
};
|
|
const { clientSecret } = await createBoostPaymentIntent(payload);
|
|
const paymentIntentId = clientSecret.split('_secret_')[0];
|
|
|
|
log.info(`${logId}: Successfully transitioned to INTENT`);
|
|
|
|
return {
|
|
type: donationStateSchema.Enum.INTENT,
|
|
id,
|
|
currencyType,
|
|
paymentAmount,
|
|
paymentIntentId,
|
|
clientSecret,
|
|
returnToken: uuid(),
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _createPaymentMethodForIntent(
|
|
workflow: DonationWorkflow,
|
|
cardDetail: CardDetail
|
|
): Promise<DonationWorkflow> {
|
|
const logId = `_createPaymentMethodForIntent(${redactId(workflow.id)})`;
|
|
|
|
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 or INTENT_METHOD, unable to create payment method`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Starting`);
|
|
|
|
const { id: paymentMethodId } = await createPaymentMethodWithStripe({
|
|
cardDetail,
|
|
});
|
|
|
|
log.info(`${logId}: Successfully transitioned to INTENT_METHOD`);
|
|
|
|
return {
|
|
...workflow,
|
|
type: donationStateSchema.Enum.INTENT_METHOD,
|
|
timestamp: Date.now(),
|
|
paymentMethodId,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _confirmPayment(
|
|
workflow: DonationWorkflow
|
|
): Promise<DonationWorkflow> {
|
|
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`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Starting`);
|
|
|
|
const receiptContext = getReceiptContext();
|
|
|
|
const { clientSecret, paymentIntentId, paymentMethodId, id } = workflow;
|
|
const idempotencyKey = hashIdToIdempotencyKey(
|
|
id,
|
|
`confirmPayment/${paymentMethodId}`
|
|
);
|
|
const returnUrl = donationValidationCompleteRoute
|
|
.toAppUrl({ token: workflow.returnToken })
|
|
.toString();
|
|
const options = {
|
|
clientSecret,
|
|
idempotencyKey,
|
|
paymentIntentId,
|
|
paymentMethodId,
|
|
returnUrl,
|
|
};
|
|
|
|
const { next_action: nextAction } = await confirmIntentWithStripe(options);
|
|
|
|
if (nextAction && nextAction.type === 'redirect_to_url') {
|
|
const { redirect_to_url: redirectDetails } = nextAction;
|
|
|
|
if (!redirectDetails || !redirectDetails.url) {
|
|
throw new Error(
|
|
`${logId}: nextAction type was redirect_to_url, but no url was supplied!`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Successfully transitioned to INTENT_REDIRECT`);
|
|
|
|
return {
|
|
...workflow,
|
|
...receiptContext,
|
|
type: donationStateSchema.Enum.INTENT_REDIRECT,
|
|
timestamp: Date.now(),
|
|
redirectTarget: redirectDetails.url,
|
|
};
|
|
}
|
|
|
|
if (nextAction) {
|
|
throw new Error(
|
|
`${logId}: Unsupported nextAction type ${nextAction.type}!`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Successfully transitioned to PAYMENT_CONFIRMED`);
|
|
|
|
return {
|
|
...workflow,
|
|
...receiptContext,
|
|
type: donationStateSchema.Enum.PAYMENT_CONFIRMED,
|
|
processor: donationProcessorSchema.enum.Stripe,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _confirmPaypalPayment(
|
|
workflow: DonationWorkflow
|
|
): Promise<DonationWorkflow> {
|
|
const logId = `_confirmPaypalPayment(${redactId(workflow.id)})`;
|
|
|
|
return withConcurrencyCheck(logId, async () => {
|
|
if (workflow.type !== donationStateSchema.Enum.PAYPAL_APPROVED) {
|
|
throw new Error(
|
|
`${logId}: workflow at type ${workflow?.type} is not at type PAYPAL_APPROVED, unable to confirm payment`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Starting`);
|
|
|
|
const {
|
|
currencyType: currency,
|
|
paymentAmount: amount,
|
|
paypalPayerId: payerId,
|
|
paypalPaymentId: paymentId,
|
|
paypalPaymentToken: paymentToken,
|
|
} = workflow;
|
|
const payload = {
|
|
currency,
|
|
amount,
|
|
level: 1,
|
|
payerId,
|
|
paymentId,
|
|
paymentToken,
|
|
};
|
|
const result = await confirmPaypalBoostPayment(payload);
|
|
const { paymentId: paymentIntentId } = result;
|
|
|
|
const receiptContext = getReceiptContext();
|
|
|
|
log.info(`${logId}: Successfully transitioned to PAYMENT_CONFIRMED`);
|
|
|
|
return {
|
|
...workflow,
|
|
...receiptContext,
|
|
type: donationStateSchema.Enum.PAYMENT_CONFIRMED,
|
|
processor: donationProcessorSchema.enum.Paypal,
|
|
paymentIntentId,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _completeValidationRedirect(
|
|
workflow: DonationWorkflow,
|
|
token: string
|
|
): Promise<DonationWorkflow> {
|
|
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`
|
|
);
|
|
}
|
|
|
|
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 PAYMENT_CONFIRMED`);
|
|
|
|
return {
|
|
...workflow,
|
|
type: donationStateSchema.Enum.PAYMENT_CONFIRMED,
|
|
processor: donationProcessorSchema.enum.Stripe,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _completePaypalApprovalRedirect({
|
|
workflow,
|
|
returnToken,
|
|
payerId,
|
|
paymentToken,
|
|
}: {
|
|
workflow: DonationWorkflow;
|
|
returnToken: string;
|
|
payerId: string;
|
|
paymentToken: string;
|
|
}): Promise<DonationWorkflow> {
|
|
const logId = `_completePaypalApprovalRedirect(${redactId(workflow.id)})`;
|
|
|
|
return withConcurrencyCheck(logId, async () => {
|
|
if (workflow.type !== donationStateSchema.Enum.PAYPAL_INTENT) {
|
|
throw new Error(
|
|
`${logId}: workflow at type ${workflow?.type} is not type PAYPAL_INTENT, unable to complete redirect`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Starting`);
|
|
|
|
if (returnToken !== workflow.returnToken) {
|
|
throw new Error(`${logId}: The provided token did not match saved token`);
|
|
}
|
|
|
|
log.info(`${logId}: Successfully transitioned to PAYPAL_APPROVED`);
|
|
|
|
return {
|
|
...workflow,
|
|
type: donationStateSchema.Enum.PAYPAL_APPROVED,
|
|
paypalPayerId: payerId,
|
|
paypalPaymentToken: paymentToken,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _createPaypalIntent({
|
|
currencyType,
|
|
paymentAmount,
|
|
workflow,
|
|
}: {
|
|
currencyType: string;
|
|
paymentAmount: StripeDonationAmount;
|
|
workflow: DonationWorkflow | undefined;
|
|
}): Promise<DonationWorkflow> {
|
|
const id = uuid();
|
|
const logId = `_createPaypalIntent(${redactId(id)})`;
|
|
|
|
return withConcurrencyCheck(logId, async () => {
|
|
if (workflow && workflow.type !== donationStateSchema.Enum.DONE) {
|
|
throw new Error(
|
|
`${logId}: existing workflow at type ${workflow.type} is not at type DONE, unable to create payment intent`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Creating new PayPal workflow`);
|
|
|
|
const returnToken = uuid();
|
|
const returnUrl = donationPaypalApprovedRoute
|
|
.toWebUrl({ returnToken })
|
|
.toString();
|
|
const cancelUrl = donationPaypalCanceledRoute
|
|
.toWebUrl({ returnToken })
|
|
.toString();
|
|
const payload = {
|
|
currency: currencyType,
|
|
amount: paymentAmount,
|
|
level: 1,
|
|
returnUrl,
|
|
cancelUrl,
|
|
};
|
|
const { approvalUrl, paymentId: paypalPaymentId } =
|
|
await createPaypalBoostPayment(payload);
|
|
|
|
log.info(`${logId}: Successfully transitioned to PAYPAL_INTENT`);
|
|
|
|
return {
|
|
type: donationStateSchema.Enum.PAYPAL_INTENT,
|
|
id,
|
|
currencyType,
|
|
paymentAmount,
|
|
paypalPaymentId,
|
|
approvalUrl,
|
|
returnToken,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function _getReceipt(
|
|
workflow: DonationWorkflow
|
|
): Promise<DonationWorkflow> {
|
|
const logId = `_getReceipt(${redactId(workflow.id)})`;
|
|
|
|
return withConcurrencyCheck(logId, async () => {
|
|
const { type: workflowType } = workflow;
|
|
if (
|
|
workflowType !== donationStateSchema.Enum.INTENT_CONFIRMED &&
|
|
workflowType !== donationStateSchema.Enum.PAYMENT_CONFIRMED
|
|
) {
|
|
throw new Error(
|
|
`${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED or PAYMENT_CONFIRMED, unable to get receipt`
|
|
);
|
|
}
|
|
log.info(`${logId}: Starting`);
|
|
|
|
const {
|
|
paymentIntentId,
|
|
receiptCredentialRequestBase64,
|
|
receiptCredentialRequestContextBase64,
|
|
} = workflow;
|
|
|
|
let processor: 'STRIPE' | 'BRAINTREE';
|
|
if (workflowType === donationStateSchema.Enum.INTENT_CONFIRMED) {
|
|
// Deprecated
|
|
processor = 'STRIPE';
|
|
} else if (workflowType === donationStateSchema.Enum.PAYMENT_CONFIRMED) {
|
|
const { processor: workflowProcessor } = workflow;
|
|
if (workflowProcessor === donationProcessorSchema.enum.Stripe) {
|
|
processor = 'STRIPE';
|
|
} else if (workflowProcessor === donationProcessorSchema.enum.Paypal) {
|
|
processor = 'BRAINTREE';
|
|
} else {
|
|
throw missingCaseError(workflowProcessor);
|
|
}
|
|
} else {
|
|
throw missingCaseError(workflowType);
|
|
}
|
|
|
|
const jsonPayload = {
|
|
paymentIntentId,
|
|
receiptCredentialRequest: receiptCredentialRequestBase64,
|
|
processor,
|
|
};
|
|
|
|
// Payment could ultimately fail here, especially with other payment types
|
|
// 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
|
|
let responseWithDetails;
|
|
try {
|
|
responseWithDetails = await 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`
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
if (responseWithDetails.response.status === 204) {
|
|
log.info(
|
|
`${logId}: Payment is still processing, leaving workflow at ${workflowType}`
|
|
);
|
|
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(
|
|
workflow: DonationWorkflow
|
|
): Promise<DonationWorkflow> {
|
|
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`
|
|
);
|
|
}
|
|
|
|
log.info(`${logId}: Starting`);
|
|
|
|
const receiptCredentialPresentation = generateReceiptCredentialPresentation(
|
|
workflow.receiptCredentialBase64
|
|
);
|
|
const receiptCredentialPresentationBase64 = Bytes.toBase64(
|
|
receiptCredentialPresentation.serialize()
|
|
);
|
|
|
|
const me = window.ConversationController.getOurConversationOrThrow();
|
|
const myBadges = me.attributes.badges;
|
|
|
|
const jsonPayload = {
|
|
receiptCredentialPresentation: receiptCredentialPresentationBase64,
|
|
visible:
|
|
!!myBadges &&
|
|
myBadges.length > 0 &&
|
|
myBadges.every(myBadge => 'isVisible' in myBadge && myBadge.isVisible),
|
|
primary: false,
|
|
};
|
|
|
|
await redeemReceipt(jsonPayload);
|
|
|
|
// After the receipt credential, our profile will change to add new badges.
|
|
// Refresh our profile to get new badges.
|
|
await getProfile({
|
|
serviceId: me.getServiceId() ?? null,
|
|
e164: me.get('e164') ?? null,
|
|
groupId: null,
|
|
});
|
|
|
|
log.info(`${logId}: Successfully transitioned to DONE`);
|
|
|
|
return {
|
|
type: donationStateSchema.Enum.DONE,
|
|
id: workflow.id,
|
|
timestamp: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
async function failDonation(
|
|
errorType: DonationErrorType,
|
|
details: string | undefined = undefined
|
|
): Promise<void> {
|
|
const workflow = _getWorkflowFromRedux();
|
|
const logId = `failDonation(${workflow?.id ? redactId(workflow.id) : 'NONE'})`;
|
|
|
|
// 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 &&
|
|
workflow.type !== donationStateSchema.Enum.INTENT_REDIRECT
|
|
) {
|
|
await _saveWorkflow(undefined);
|
|
}
|
|
|
|
log.info(
|
|
`failDonation: Failing with type ${errorType} ${details ? `details=${details}` : ''}`
|
|
);
|
|
if (!isDonationPageVisible()) {
|
|
if (errorType === donationErrorTypeSchema.Enum.Failed3dsValidation) {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing 'verification failed' toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationVerificationFailed,
|
|
});
|
|
} else if (errorType === donationErrorTypeSchema.Enum.TimedOut) {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing 'donation canceled w/view' toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationCanceledWithView,
|
|
});
|
|
} else if (errorType === donationErrorTypeSchema.Enum.PaypalCanceled) {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing 'Paypal canceled' toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationPaypalCanceled,
|
|
});
|
|
} else if (errorType === donationErrorTypeSchema.Enum.PaypalError) {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing 'Paypal approval unknown' toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationPaypalError,
|
|
});
|
|
} else {
|
|
log.info(
|
|
`${logId}: Donation page not visible. Showing 'error processing donation' toast.`
|
|
);
|
|
window.reduxActions.toast.showToast({
|
|
toastType: ToastType.DonationError,
|
|
});
|
|
}
|
|
}
|
|
|
|
window.reduxActions.donations.updateLastError(errorType);
|
|
}
|
|
async function _saveWorkflow(
|
|
workflow: DonationWorkflow | undefined
|
|
): Promise<void> {
|
|
await _saveWorkflowToStorage(workflow);
|
|
_saveWorkflowToRedux(workflow);
|
|
}
|
|
export function _getLastReturnTokenFromRedux(): string | undefined {
|
|
return window.reduxStore.getState().donations.lastReturnToken;
|
|
}
|
|
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 = itemStorage.get(WORKFLOW_STORAGE_KEY);
|
|
|
|
if (!workflowJson) {
|
|
log.info(`${logId}: No workflow found in storage`);
|
|
return undefined;
|
|
}
|
|
|
|
const workflowData = JSON.parse(workflowJson) as unknown;
|
|
const result = safeParseUnknown(donationWorkflowSchema, workflowData);
|
|
if (!result.success) {
|
|
log.error(
|
|
`${logId}: Workflow from 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 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 itemStorage.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 itemStorage.put(WORKFLOW_STORAGE_KEY, JSON.stringify(workflow));
|
|
log.info(`${logId}: Saved workflow to storage`);
|
|
}
|
|
|
|
async function saveReceipt(workflow: DonationWorkflow, logId: string) {
|
|
if (
|
|
workflow.type !== donationStateSchema.Enum.RECEIPT &&
|
|
workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED &&
|
|
workflow.type !== donationStateSchema.Enum.PAYMENT_CONFIRMED
|
|
) {
|
|
throw new Error(
|
|
`${logId}: Cannot save receipt from workflow at type ${workflow?.type}`
|
|
);
|
|
}
|
|
const donationReceipt: DonationReceipt = {
|
|
id: workflow.id,
|
|
currencyType: workflow.currencyType,
|
|
paymentAmount: workflow.paymentAmount,
|
|
// This will be when we transitioned to PAYMENT_CONFIRMED, most likely. It may be
|
|
// close to when the user clicks the Donate button, or delayed by a bit.
|
|
timestamp: workflow.timestamp,
|
|
};
|
|
|
|
await createDonationReceipt(donationReceipt);
|
|
window.reduxActions.donations.addReceipt(donationReceipt);
|
|
|
|
log.info(`${logId}: Successfully saved receipt`);
|
|
}
|
|
|
|
function didResumeWorkflowAtStartup() {
|
|
return window.reduxStore.getState().donations.didResumeWorkflowAtStartup;
|
|
}
|
|
|
|
function isDonationPageVisible() {
|
|
const { selectedLocation } = window.reduxStore.getState().nav;
|
|
return (
|
|
selectedLocation.tab === NavTab.Settings &&
|
|
(selectedLocation.details.page === SettingsPage.Donations ||
|
|
selectedLocation.details.page === SettingsPage.DonationsDonateFlow ||
|
|
selectedLocation.details.page === SettingsPage.DonationsReceiptList)
|
|
);
|
|
}
|
|
|
|
function isDonationsDonateFlowVisible() {
|
|
const { selectedLocation } = window.reduxStore.getState().nav;
|
|
return (
|
|
selectedLocation.tab === NavTab.Settings &&
|
|
selectedLocation.details.page === SettingsPage.DonationsDonateFlow
|
|
);
|
|
}
|
|
|
|
function redirectToPage(
|
|
page:
|
|
| SettingsPage.Donations
|
|
| SettingsPage.DonationsDonateFlow
|
|
| SettingsPage.DonationsReceiptList
|
|
) {
|
|
window.reduxActions.nav.changeLocation({
|
|
tab: NavTab.Settings,
|
|
details: {
|
|
page,
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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 {
|
|
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;
|
|
}
|
|
|
|
export function phoneNumberToCurrencyCode(e164: string): string {
|
|
const regionCode = getRegionCodeForNumber(e164) ?? 'US';
|
|
const countryData = countryCodes.findOne('countryCode', regionCode);
|
|
return countryData?.currencyCode ?? 'USD';
|
|
}
|