diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b88b150e3f..3e4002e044 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -9986,6 +9986,10 @@ "messageformat": "Remove info", "description": "While making a donation and after entering payment details, if you try to navigate away then a dialog shows and its primary button has this text to confirm cancellation of the donation." }, + "icu:DonateFlow__discard-paypal-dialog-body": { + "messageformat": "Leaving this page will remove your PayPal information. Do you want to proceed?", + "description": "While making a donation and after selecting Paypal, if you try to navigate away then a dialog shows with this body text." + }, "icu:DonateFlow__continue": { "messageformat": "Continue", "description": "While making a donation and selecting the donation amount, this is the primary button to move to the next step." @@ -10046,6 +10050,14 @@ "messageformat": "You have a donation in progress that requires confirmation.", "description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup." }, + "icu:Donations__Toast__PaypalCanceled": { + "messageformat": "PayPal donation canceled", + "description": "Shown when the user cancels a PayPal payment and they are not on the Preferences/Donations screen." + }, + "icu:Donations__Toast__PaypalError": { + "messageformat": "Error processing PayPal donation. You have not been charged.", + "description": "Shown when the user approves a PayPal payment, but we are unable to process it. The most likely cause is because the user canceled a pending PayPal payment within the app, then approved it on the PayPal website anyway." + }, "icu:Donations__BadgeApplicationFailed__Title": { "messageformat": "Donation succeeded, but we could not update your badge settings", "description": "Modal title shown when donation completes successfully but badge application fails" @@ -10066,6 +10078,10 @@ "messageformat": "Try again", "description": "The button in the 'donation interrupted' dialog which allows the user to move forward with the donation." }, + "icu:Donations__PaymentMethod": { + "messageformat": "Payment method", + "description": "Label for payment method when making a donation and choosing a payment method." + }, "icu:Donations__PaymentMethodDeclined": { "messageformat": "Payment method declined", "description": "Title of the dialog shown with the user's provided payment method has not worked" @@ -10074,6 +10090,42 @@ "messageformat": "Try another payment method or contact your bank for more information.", "description": "An explanation for the 'payment declined' dialog" }, + "icu:DonateFlow__PaymentCardIcon__AccessibilityLabel": { + "messageformat": "Payment card icon", + "description": "Aria label for credit or debit card when making a donation and selecting a payment method." + }, + "icu:DonateFlow__CreditOrDebitCard": { + "messageformat": "Credit or Debit Card", + "description": "Label for credit or debit card when making a donation and selecting a payment method." + }, + "icu:DonateFlow__Paypal__AccessibilityLabel": { + "messageformat": "PayPal", + "description": "Aria label for PayPal when making a donation and selecting a payment method." + }, + "icu:DonateFlow__Paypal__Cancel": { + "messageformat": "Cancel", + "description": "Label for cancel button when making a donation with PayPal." + }, + "icu:DonateFlow__Paypal__CompleteDonation": { + "messageformat": "Complete Donation", + "description": "Label to complete donation when making a donation with PayPal." + }, + "icu:Donations__PaypalCanceled": { + "messageformat": "PayPal donation canceled", + "description": "Title of the dialog shown when the user cancels a PayPal payment." + }, + "icu:Donations__PaypalCanceled__Description": { + "messageformat": "You have not been charged. Try again or use another payment method.", + "description": "An explanation for the dialog shown when the user cancels a PayPal payment." + }, + "icu:Donations__PaypalError": { + "messageformat": "Error processing PayPal donation", + "description": "Title of the dialog shown when the user approves a PayPal payment, but we are unable to process it. The most likely cause is because the user canceled a pending PayPal payment within the app, then approved it on the PayPal website anyway." + }, + "icu:Donations__PaypalError__Description": { + "messageformat": "You have not been charged. Try again or use another payment method.", + "description": "An explanation for the 'Error processing PayPal payment' dialog when the user approves a PayPal payment, but we are unable to process it." + }, "icu:Donations__Failed3dsValidation": { "messageformat": "Verification Failed", "description": "Title of the dialog shown when something went wrong processing a user's 3ds verification with their bank" diff --git a/images/paypal.svg b/images/paypal.svg new file mode 100644 index 0000000000..418c40fd2c --- /dev/null +++ b/images/paypal.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 8567328894..29b1751654 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -274,6 +274,8 @@ $color-deep-red: #ff261f; $color-selected-message-background-light: rgba(44, 107, 237, 0.24); $color-selected-message-background-dark: $color-gray-65; +$color-paypal-yellow: #f6c757; + // -- A few layout variables used cross-file $header-height: 52px; diff --git a/stylesheets/components/PreferencesDonations.scss b/stylesheets/components/PreferencesDonations.scss index a973e831a9..7a07864d7d 100644 --- a/stylesheets/components/PreferencesDonations.scss +++ b/stylesheets/components/PreferencesDonations.scss @@ -502,3 +502,27 @@ variables.$color-white-alpha-55 ); } + +.PreferencesDonations__payment-icon { + display: flex; + width: 20px; + height: 20px; + @include mixins.color-svg( + '../images/icons/v3/payment/payment.svg', + variables.$color-white + ); +} + +.PreferencesDonations__paypal-button { + display: flex; + width: 280px; + height: 36px; + background: variables.$color-paypal-yellow; + border-radius: 6px; + justify-content: center; +} + +.PreferencesDonations__paypal-icon { + display: flex; + background-image: url('../images/paypal.svg'); +} diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index dcc7fe8dc1..20c1adcf51 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -32,6 +32,8 @@ const log = createLogger('RemoteConfig'); const SemverKeys = [ 'desktop.callQualitySurvey.beta', 'desktop.callQualitySurvey.prod', + 'desktop.donationPaypal.beta', + 'desktop.donationPaypal.prod', 'desktop.pinnedMessages.receive.beta', 'desktop.pinnedMessages.receive.prod', 'desktop.pinnedMessages.send.beta', diff --git a/ts/components/DonationErrorModal.dom.stories.tsx b/ts/components/DonationErrorModal.dom.stories.tsx index f99cd3a4d2..4339709263 100644 --- a/ts/components/DonationErrorModal.dom.stories.tsx +++ b/ts/components/DonationErrorModal.dom.stories.tsx @@ -48,6 +48,24 @@ export function PaymentDeclined(): React.JSX.Element { ); } +export function PaypalCanceled(): React.JSX.Element { + return ( + + ); +} + +export function PaypalError(): React.JSX.Element { + return ( + + ); +} + export function TimedOut(): React.JSX.Element { return ( ; + }) => + renderDonationsPane({ + contentsRef, + me, + donationReceipts: [], + settingsLocation: { page: SettingsPage.DonationsDonateFlow }, + setSettingsLocation: action('setSettingsLocation'), + saveAttachmentToDisk: async () => { + action('saveAttachmentToDisk')(); + return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; + }, + generateDonationReceiptBlob: async () => { + action('generateDonationReceiptBlob')(); + return new Blob(); + }, + showToast: action('showToast'), + workflow: { + type: 'PAYPAL_INTENT', + timestamp: Date.now() - 60, + paypalPaymentId: 'a', + paymentAmount: 500, + currencyType: 'USD', + id: 'a', + returnToken: 'a', + approvalUrl: 'https://www.signal.org', + }, + }), +}; + export const Internal = Template.bind({}); Internal.args = { settingsLocation: { page: SettingsPage.Internal }, diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index f025cc2221..512ab4db0d 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -99,6 +99,7 @@ import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/Prefer import { AxoButton } from '../axo/AxoButton.dom.js'; import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js'; import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js'; +import { isDonationsPage } from './PreferencesDonations.dom.js'; const { isNumber, noop, partition } = lodash; @@ -342,14 +343,6 @@ export type PropsType = PropsDataType & PropsFunctionType; export type PropsPreloadType = Omit; -function isDonationsPage(page: SettingsPage): boolean { - return ( - page === SettingsPage.Donations || - page === SettingsPage.DonationsDonateFlow || - page === SettingsPage.DonationsReceiptList - ); -} - enum LanguageDialog { Selection, Confirmation, diff --git a/ts/components/PreferencesDonateFlow.dom.tsx b/ts/components/PreferencesDonateFlow.dom.tsx index b945793db4..3e9f72a658 100644 --- a/ts/components/PreferencesDonateFlow.dom.tsx +++ b/ts/components/PreferencesDonateFlow.dom.tsx @@ -14,6 +14,8 @@ import classNames from 'classnames'; import type { LocalizerType } from '../types/Util.std.js'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js'; import { + donationErrorTypeSchema, + DonationProcessor, donationStateSchema, ONE_TIME_DONATION_CONFIG_ID, } from '../types/Donations.std.js'; @@ -41,11 +43,13 @@ import { } from '../types/DonationsCardForm.std.js'; import { brandHumanDonationAmount, + brandStripeDonationAmount, type CurrencyFormatResult, getCurrencyFormat, getMaximumStripeAmount, parseCurrencyString, toHumanCurrencyString, + toHumanDonationAmount, toStripeDonationAmount, } from '../util/currency.dom.js'; import { PreferencesContent } from './Preferences.dom.js'; @@ -70,12 +74,17 @@ import { DonateInputAmount } from './preferences/donations/DonateInputAmount.dom import { Tooltip, TooltipPlacement } from './Tooltip.dom.js'; import { offsetDistanceModifier } from '../util/popperUtil.std.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; +import { missingCaseError } from '../util/missingCaseError.std.js'; +import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser.dom.js'; +import { usePrevious } from '../hooks/usePrevious.std.js'; +import { tw } from '../axo/tw.dom.js'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; + isDonationPaypalEnabled: boolean; isOnline: boolean; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; lastError: DonationErrorType | undefined; @@ -101,6 +110,7 @@ const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => { const finalizedStates: Array = [ donationStateSchema.Enum.INTENT_CONFIRMED, donationStateSchema.Enum.INTENT_REDIRECT, + donationStateSchema.Enum.PAYPAL_APPROVED, donationStateSchema.Enum.PAYMENT_CONFIRMED, donationStateSchema.Enum.RECEIPT, donationStateSchema.Enum.DONE, @@ -112,6 +122,7 @@ export function PreferencesDonateFlow({ contentsRef, i18n, initialCurrency, + isDonationPaypalEnabled, isOnline, donationAmountsConfig, lastError, @@ -124,23 +135,47 @@ export function PreferencesDonateFlow({ onBack, }: PropsType): React.JSX.Element { const tryClose = useRef<() => void | undefined>(); - const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ - i18n, - bodyText: i18n('icu:DonateFlow__discard-dialog-body'), - discardText: i18n('icu:DonateFlow__discard-dialog-remove-info'), - name: 'PreferencesDonateFlow', - tryClose, - }); - const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount'); + // When returning to the donate flow with a pending PayPal payment, load the pending + // amount in case the user wants to go back and choose a different payment processor. + const { initialStep, initialAmount } = useMemo((): { + initialStep: 'amount' | 'paypal'; + initialAmount: HumanDonationAmount | undefined; + } => { + if ( + workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT || + workflow?.type === donationStateSchema.Enum.PAYPAL_APPROVED + ) { + const savedAmount = brandStripeDonationAmount(workflow.paymentAmount); + const humanAmount = toHumanDonationAmount({ + amount: savedAmount, + currency: workflow.currencyType, + }); + return { initialStep: 'paypal', initialAmount: humanAmount }; + } - const [amount, setAmount] = useState(); + return { + initialStep: 'amount', + initialAmount: undefined, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [step, setStep] = useState< + 'amount' | 'paymentProcessor' | 'stripePaymentDetails' | 'paypal' + >(initialStep); + + const [amount, setAmount] = useState( + initialAmount + ); const [currency, setCurrency] = useState(initialCurrency); const [isCardFormDisabled, setIsCardFormDisabled] = useState(false); const [cardFormValues, setCardFormValues] = useState< CardFormValues | undefined >(); + const prevStep = usePrevious(step, step); + const hasCardFormData = useMemo(() => { if (!cardFormValues) { return false; @@ -158,41 +193,126 @@ export function PreferencesDonateFlow({ setCurrency(value); }, []); - const handleAmountPickerResult = useCallback((result: AmountPickerResult) => { - const { currency: pickedCurrency, amount: pickedAmount } = result; - setAmount(pickedAmount); - setCurrency(pickedCurrency); - setStep('paymentDetails'); - }, []); + const handleAmountPickerResult = useCallback( + (result: AmountPickerResult) => { + const { currency: pickedCurrency, amount: pickedAmount } = result; + setAmount(pickedAmount); + setCurrency(pickedCurrency); + if (isDonationPaypalEnabled) { + setStep('paymentProcessor'); + } else { + setStep('stripePaymentDetails'); + } + }, + [isDonationPaypalEnabled] + ); const handleCardFormChanged = useCallback((values: CardFormValues) => { setCardFormValues(values); }, []); const handleSubmitDonation = useCallback( - (cardDetail: CardDetail) => { + ({ + processor, + cardDetail, + }: { + processor: DonationProcessor; + cardDetail?: CardDetail; + }): boolean => { if (amount == null || currency == null) { - return; + return false; } const paymentAmount = toStripeDonationAmount({ amount, currency }); - setIsCardFormDisabled(true); - submitDonation({ - currencyType: currency, - paymentAmount, - paymentDetail: cardDetail, - }); + if (processor === DonationProcessor.Stripe) { + strictAssert(cardDetail, 'cardDetail is required for Stripe'); + submitDonation({ + currencyType: currency, + paymentAmount, + processor: DonationProcessor.Stripe, + paymentDetail: cardDetail, + }); + } else if (processor === DonationProcessor.Paypal) { + submitDonation({ + currencyType: currency, + paymentAmount, + processor: DonationProcessor.Paypal, + }); + } else { + throw missingCaseError(processor); + } + + return true; }, - [amount, currency, setIsCardFormDisabled, submitDonation] + [amount, currency, submitDonation] ); + const handleSubmitStripeDonation = useCallback( + (cardDetail: CardDetail) => { + if ( + handleSubmitDonation({ + processor: DonationProcessor.Stripe, + cardDetail, + }) + ) { + setIsCardFormDisabled(true); + } + }, + [handleSubmitDonation] + ); + + const handleSubmitPaypalDonation = useCallback(() => { + handleSubmitDonation({ processor: DonationProcessor.Paypal }); + // An effect will transition step to paypal after chat server confirmation + }, [handleSubmitDonation]); + + const handleBackFromCardForm = useCallback(() => { + if (isDonationPaypalEnabled) { + setStep('paymentProcessor'); + } else { + setStep('amount'); + } + }, [isDonationPaypalEnabled]); + useEffect(() => { if (!workflow || lastError) { setIsCardFormDisabled(false); } }, [lastError, setIsCardFormDisabled, workflow]); + useEffect(() => { + // When starting a Paypal payment, we create a workflow in the PAYPAL_INTENT state + // which contains the approvalUrl. + if ( + prevStep === 'paymentProcessor' && + workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT + ) { + setStep('paypal'); + openLinkInWebBrowser(workflow.approvalUrl); + } + }, [prevStep, workflow]); + + const discardModalBodyText = useMemo(() => { + if (step === 'stripePaymentDetails') { + return i18n('icu:DonateFlow__discard-dialog-body'); + } + + if (step === 'paypal') { + return i18n('icu:DonateFlow__discard-paypal-dialog-body'); + } + + return undefined; + }, [i18n, step]); + + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + bodyText: discardModalBodyText, + discardText: i18n('icu:DonateFlow__discard-dialog-remove-info'), + name: 'PreferencesDonateFlow', + tryClose, + }); + const onTryClose = useCallback(() => { const onDiscard = () => { // Don't clear the workflow if we're processing the payment and @@ -202,8 +322,9 @@ export function PreferencesDonateFlow({ } }; const isConfirmationNeeded = - hasCardFormData && - !isCardFormDisabled && + ((hasCardFormData && !isCardFormDisabled) || + (step === 'paypal' && + lastError !== donationErrorTypeSchema.Enum.PaypalCanceled)) && (!workflow || !isPaymentDetailFinalizedInWorkflow(workflow)); confirmDiscardIf(isConfirmationNeeded, onDiscard); @@ -212,6 +333,8 @@ export function PreferencesDonateFlow({ confirmDiscardIf, hasCardFormData, isCardFormDisabled, + lastError, + step, workflow, ]); tryClose.current = onTryClose; @@ -238,7 +361,52 @@ export function PreferencesDonateFlow({ ); // Dismiss DonateFlow and return to Donations home handleBack = () => onBack(); - } else { + } else if (step === 'paymentProcessor') { + strictAssert(amount, 'Amount is required for payment processor form'); + innerContent = ( + <> + + setStep('stripePaymentDetails')} + type="button" + > + + + {i18n('icu:DonateFlow__CreditOrDebitCard')} + + + + + + > + ); + handleBack = () => { + setStep('amount'); + }; + } else if (step === 'stripePaymentDetails') { strictAssert(amount, 'Amount is required for payment card form'); innerContent = ( <> @@ -252,15 +420,64 @@ export function PreferencesDonateFlow({ initialValues={cardFormValues} isOnline={isOnline} onChange={handleCardFormChanged} - onSubmit={handleSubmitDonation} + onSubmit={handleSubmitStripeDonation} showPrivacyModal={showPrivacyModal} /> > ); + handleBack = handleBackFromCardForm; + } else if (step === 'paypal') { + strictAssert(amount, 'Amount is required for Paypal page'); + const isDisabled = + workflow?.type !== donationStateSchema.Enum.PAYPAL_INTENT; + innerContent = ( + <> + + + + + {i18n('icu:Donations__PaymentMethod')} + + + + + + {i18n('icu:DonateFlow__Paypal__Cancel')} + + { + if (workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT) { + openLinkInWebBrowser(workflow.approvalUrl); + } + }} + > + {i18n('icu:DonateFlow__Paypal__CompleteDonation')} + + + > + ); handleBack = () => { + clearWorkflow(); setStep('amount'); + setIsCardFormDisabled(false); }; + } else { + throw missingCaseError(step); } const backButton = ( diff --git a/ts/components/PreferencesDonations.dom.tsx b/ts/components/PreferencesDonations.dom.tsx index 59e97d0b76..1e1a5ecbf7 100644 --- a/ts/components/PreferencesDonations.dom.tsx +++ b/ts/components/PreferencesDonations.dom.tsx @@ -69,6 +69,7 @@ type PropsExternalType = { export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; + isDonationPaypalEnabled: boolean; isOnline: boolean; settingsLocation: SettingsLocation; didResumeWorkflowAtStartup: boolean; @@ -134,7 +135,7 @@ type PreferencesHomeProps = Pick< renderDonationHero: () => React.JSX.Element; }; -function isDonationPage(page: SettingsPage): page is DonationPage { +export function isDonationsPage(page: SettingsPage): page is DonationPage { return ( page === SettingsPage.Donations || page === SettingsPage.DonationsDonateFlow || @@ -238,7 +239,7 @@ function DonationsHome({ @@ -542,6 +543,7 @@ export function PreferencesDonations({ contentsRef, i18n, initialCurrency, + isDonationPaypalEnabled, isOnline, settingsLocation, workflow, @@ -613,7 +615,7 @@ export function PreferencesDonations({ [badge, color, firstName, i18n, profileAvatarUrl, theme] ); - if (!isDonationPage(settingsLocation.page)) { + if (!isDonationsPage(settingsLocation.page)) { return null; } @@ -715,6 +717,9 @@ export function PreferencesDonations({ }} /> ); + } else if (workflow?.type === donationStateSchema.Enum.PAYPAL_INTENT) { + // No need to show the dialog here because PreferencesDonateFlow already + // initiates a dialog when redirecting to PayPal. } else { dialog = ( { + clearWorkflow(); + setIsSubmitted(false); + }} renderDonationHero={renderDonationHero} submitDonation={details => { setIsSubmitted(true); diff --git a/ts/components/ToastManager.dom.stories.tsx b/ts/components/ToastManager.dom.stories.tsx index 38af154588..373b29b7bd 100644 --- a/ts/components/ToastManager.dom.stories.tsx +++ b/ts/components/ToastManager.dom.stories.tsx @@ -136,6 +136,10 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.DonationConfirmationNeeded }; case ToastType.DonationError: return { toastType: ToastType.DonationError }; + case ToastType.DonationPaypalCanceled: + return { toastType: ToastType.DonationPaypalCanceled }; + case ToastType.DonationPaypalError: + return { toastType: ToastType.DonationPaypalError }; case ToastType.DonationProcessing: return { toastType: ToastType.DonationProcessing }; case ToastType.DonationVerificationFailed: diff --git a/ts/components/ToastManager.dom.tsx b/ts/components/ToastManager.dom.tsx index ecda977837..af678fcb8f 100644 --- a/ts/components/ToastManager.dom.tsx +++ b/ts/components/ToastManager.dom.tsx @@ -412,6 +412,8 @@ export function renderToast({ toastType === ToastType.DonationCanceledWithView || toastType === ToastType.DonationConfirmationNeeded || toastType === ToastType.DonationError || + toastType === ToastType.DonationPaypalCanceled || + toastType === ToastType.DonationPaypalError || toastType === ToastType.DonationVerificationFailed || toastType === ToastType.DonationVerificationNeeded ) { @@ -423,6 +425,12 @@ export function renderToast({ 'icu:Donations__Toast__ConfirmationNeeded' ), [ToastType.DonationError]: i18n('icu:Donations__Toast__Error'), + [ToastType.DonationPaypalCanceled]: i18n( + 'icu:Donations__Toast__PaypalCanceled' + ), + [ToastType.DonationPaypalError]: i18n( + 'icu:Donations__Toast__PaypalError' + ), [ToastType.DonationVerificationFailed]: i18n( 'icu:Donations__Toast__VerificationFailed' ), diff --git a/ts/services/donations.preload.ts b/ts/services/donations.preload.ts index 96504922b4..a30bd39cf7 100644 --- a/ts/services/donations.preload.ts +++ b/ts/services/donations.preload.ts @@ -120,10 +120,13 @@ export async function initialize(): Promise { return; } - if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) { + if ( + workflow.type === donationStateSchema.Enum.INTENT_METHOD || + workflow.type === donationStateSchema.Enum.PAYPAL_INTENT + ) { if (shouldShowToast) { log.info( - 'initialize: Showing confirmation toast, workflow is at INTENT_METHOD.' + `initialize: Showing confirmation toast, workflow is at ${workflow.type}.` ); window.reduxActions.toast.showToast({ toastType: ToastType.DonationConfirmationNeeded, @@ -149,7 +152,7 @@ export async function initialize(): Promise { // 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 startDonation({ +export async function startStripeDonation({ currencyType, paymentAmount, }: { @@ -166,6 +169,24 @@ export async function startDonation({ await _saveWorkflow(workflow); } +export async function startPaypalDonation({ + currencyType, + paymentAmount, +}: { + currencyType: string; + paymentAmount: StripeDonationAmount; +}): Promise { + 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 { @@ -249,7 +270,7 @@ export async function approvePaypalPayment({ paymentToken, }); } catch (error) { - await failDonation(donationErrorTypeSchema.Enum.GeneralError); + await failDonation(donationErrorTypeSchema.Enum.PaypalError); throw error; } @@ -269,11 +290,25 @@ export async function cancelPaypalPayment(returnToken: string): Promise { throw new Error(`${logId}: Workflow not type PAYPAL_INTENT`); } + // This case will happen if the user initiated 2 PayPal requests but cancels the + // older one. We interpret this case as an intention to cancel. If the user then + // approves the more recent payment, we will show an error and ask them to try again. if (returnToken !== existing.returnToken) { - throw new Error(`${logId}: The provided token did not match saved token`); + log.warn( + `${logId}: The provided token did not match saved token. Canceling anyway.` + ); } - await clearDonation(); + await failDonation(donationErrorTypeSchema.Enum.PaypalCanceled); + + if (isDonationsDonateFlowVisible()) { + window.reduxActions.nav.changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.Donations, + }, + }); + } } export async function clearDonation(): Promise { @@ -750,7 +785,7 @@ export async function _confirmPayment( ...workflow, ...receiptContext, type: donationStateSchema.Enum.PAYMENT_CONFIRMED, - processor: donationProcessorSchema.Enum.STRIPE, + processor: donationProcessorSchema.enum.Stripe, timestamp: Date.now(), }; }); @@ -796,7 +831,7 @@ export async function _confirmPaypalPayment( ...workflow, ...receiptContext, type: donationStateSchema.Enum.PAYMENT_CONFIRMED, - processor: donationProcessorSchema.Enum.PAYPAL, + processor: donationProcessorSchema.enum.Paypal, paymentIntentId, timestamp: Date.now(), }; @@ -827,7 +862,7 @@ export async function _completeValidationRedirect( return { ...workflow, type: donationStateSchema.Enum.PAYMENT_CONFIRMED, - processor: donationProcessorSchema.Enum.STRIPE, + processor: donationProcessorSchema.enum.Stripe, timestamp: Date.now(), }; }); @@ -953,9 +988,9 @@ export async function _getReceipt( processor = 'STRIPE'; } else if (workflowType === donationStateSchema.Enum.PAYMENT_CONFIRMED) { const { processor: workflowProcessor } = workflow; - if (workflowProcessor === donationProcessorSchema.Enum.STRIPE) { + if (workflowProcessor === donationProcessorSchema.enum.Stripe) { processor = 'STRIPE'; - } else if (workflowProcessor === donationProcessorSchema.Enum.PAYPAL) { + } else if (workflowProcessor === donationProcessorSchema.enum.Paypal) { processor = 'BRAINTREE'; } else { throw missingCaseError(workflowProcessor); @@ -1117,6 +1152,20 @@ async function failDonation( 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.` diff --git a/ts/state/ducks/donations.preload.ts b/ts/state/ducks/donations.preload.ts index 836fce4297..2f11c34568 100644 --- a/ts/state/ducks/donations.preload.ts +++ b/ts/state/ducks/donations.preload.ts @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReadonlyDeep } from 'type-fest'; +import type { ReadonlyDeep, Simplify } from 'type-fest'; import type { ThunkAction } from 'redux-thunk'; import { useBoundActions } from '../../hooks/useBoundActions.std.js'; @@ -10,7 +10,10 @@ import * as Errors from '../../types/errors.std.js'; import { isStagingServer } from '../../util/isStagingServer.dom.js'; import { DataWriter } from '../../sql/Client.preload.js'; import * as donations from '../../services/donations.preload.js'; -import { donationStateSchema } from '../../types/Donations.std.js'; +import { + donationStateSchema, + DonationProcessor, +} from '../../types/Donations.std.js'; import { drop } from '../../util/drop.std.js'; import { storageServiceUploadJob } from '../../services/storage.preload.js'; import { getMe } from '../selectors/conversations.dom.js'; @@ -31,6 +34,7 @@ import type { import type { BadgeType } from '../../badges/types.std.js'; import type { StateType as RootStateType } from '../reducer.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { missingCaseError } from '../../util/missingCaseError.std.js'; const log = createLogger('donations'); @@ -144,17 +148,58 @@ function resumeWorkflow(): ThunkAction< }; } -export type SubmitDonationType = ReadonlyDeep<{ +type SubmitDonationData = ReadonlyDeep<{ currencyType: string; paymentAmount: StripeDonationAmount; - paymentDetail: CardDetail; }>; -function submitDonation({ +type SubmitStripeDonationData = ReadonlyDeep< + SubmitDonationData & { + paymentDetail: CardDetail; + } +>; + +export type SubmitDonationType = Simplify< + ReadonlyDeep< + | ({ processor: DonationProcessor.Stripe } & SubmitStripeDonationData) + | ({ processor: DonationProcessor.Paypal } & SubmitDonationData) + > +>; + +function submitDonation( + data: SubmitDonationType +): ThunkAction< + void, + RootStateType, + unknown, + UpdateWorkflowAction | UpdateLastErrorAction +> { + const { currencyType, paymentAmount, processor } = data; + + if (processor === DonationProcessor.Stripe) { + const { paymentDetail } = data; + return _submitStripeDonation({ + currencyType, + paymentAmount, + paymentDetail, + }); + } + + if (processor === DonationProcessor.Paypal) { + return _submitPaypalDonation({ + currencyType, + paymentAmount, + }); + } + + throw missingCaseError(processor); +} + +function _submitStripeDonation({ currencyType, paymentAmount, paymentDetail, -}: SubmitDonationType): ThunkAction< +}: SubmitStripeDonationData): ThunkAction< void, RootStateType, unknown, @@ -171,7 +216,7 @@ function submitDonation({ // we can proceed without starting afresh } else { await donations.clearDonation(); - await donations.startDonation({ + await donations.startStripeDonation({ currencyType, paymentAmount, }); @@ -179,7 +224,33 @@ function submitDonation({ await donations.finishDonationWithCard(paymentDetail); } catch (error) { - log.error('submitDonation failed', Errors.toLogFormat(error)); + log.error('_submitStripeDonation failed', Errors.toLogFormat(error)); + dispatch({ + type: UPDATE_LAST_ERROR, + payload: { lastError: 'GeneralError' }, + }); + } + }; +} + +function _submitPaypalDonation({ + currencyType, + paymentAmount, +}: SubmitDonationData): ThunkAction< + void, + RootStateType, + unknown, + UpdateWorkflowAction | UpdateLastErrorAction +> { + return async dispatch => { + try { + await donations.clearDonation(); + await donations.startPaypalDonation({ + currencyType, + paymentAmount, + }); + } catch (error) { + log.error('_submitPaypalDonation failed', Errors.toLogFormat(error)); dispatch({ type: UPDATE_LAST_ERROR, payload: { lastError: 'GeneralError' }, diff --git a/ts/state/selectors/user.std.ts b/ts/state/selectors/user.std.ts index 693029d9a4..b4560bdaba 100644 --- a/ts/state/selectors/user.std.ts +++ b/ts/state/selectors/user.std.ts @@ -103,7 +103,7 @@ export const getTheme = createSelector( } ); -const getVersion = createSelector( +export const getVersion = createSelector( getUser, (state: UserStateType) => state.version ); diff --git a/ts/state/smart/PreferencesDonations.preload.tsx b/ts/state/smart/PreferencesDonations.preload.tsx index 3e0529b478..209bec803b 100644 --- a/ts/state/smart/PreferencesDonations.preload.tsx +++ b/ts/state/smart/PreferencesDonations.preload.tsx @@ -6,7 +6,12 @@ import { useSelector } from 'react-redux'; import type { MutableRefObject } from 'react'; -import { getIntl, getTheme, getUserNumber } from '../selectors/user.std.js'; +import { + getIntl, + getTheme, + getUserNumber, + getVersion, +} from '../selectors/user.std.js'; import { getMe } from '../selectors/conversations.dom.js'; import { PreferencesDonations } from '../../components/PreferencesDonations.dom.js'; import type { SettingsLocation } from '../../types/Nav.std.js'; @@ -35,6 +40,8 @@ import { parseBoostBadgeListFromServer } from '../../badges/parseBadgesFromServe import { createLogger } from '../../logging/log.std.js'; import { useBadgesActions } from '../ducks/badges.preload.js'; import { getNetworkIsOnline } from '../selectors/network.preload.js'; +import { getItems } from '../selectors/items.dom.js'; +import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; const log = createLogger('SmartPreferencesDonations'); @@ -58,6 +65,7 @@ export const SmartPreferencesDonations = memo( const isOnline = useSelector(getNetworkIsOnline); const i18n = useSelector(getIntl); + const items = useSelector(getItems); const theme = useSelector(getTheme); const donationsState = useSelector((state: StateType) => state.donations); @@ -81,6 +89,15 @@ export const SmartPreferencesDonations = memo( (state: StateType) => state.donations.receipts ); + const version = useSelector(getVersion); + + const isDonationPaypalEnabled = isFeaturedEnabledSelector({ + currentVersion: version, + remoteConfig: items.remoteConfig, + betaKey: 'desktop.donationPaypal.beta', + prodKey: 'desktop.donationPaypal.prod', + }); + const { updateOrCreate } = useBadgesActions(); // Function to fetch donation badge data @@ -141,6 +158,7 @@ export const SmartPreferencesDonations = memo( showToast={showToast} contentsRef={contentsRef} initialCurrency={initialCurrency} + isDonationPaypalEnabled={isDonationPaypalEnabled} isOnline={isOnline} settingsLocation={settingsLocation} didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup} diff --git a/ts/types/Donations.std.ts b/ts/types/Donations.std.ts index 2f7b22a1da..0a168ed75d 100644 --- a/ts/types/Donations.std.ts +++ b/ts/types/Donations.std.ts @@ -31,7 +31,12 @@ export const donationStateSchema = z.enum([ export type DonationStateType = z.infer; -export const donationProcessorSchema = z.enum(['PAYPAL', 'STRIPE']); +export enum DonationProcessor { + Paypal = 'PAYPAL', + Stripe = 'STRIPE', +} + +export const donationProcessorSchema = z.nativeEnum(DonationProcessor); export const donationErrorTypeSchema = z.enum([ // Used if the user is redirected back from validation, but continuing forward fails @@ -40,6 +45,12 @@ export const donationErrorTypeSchema = z.enum([ 'GeneralError', // Any 4xx error when adding payment method or confirming intent 'PaymentDeclined', + // When the user approves PayPal payment but they canceled it in-app, so we lost the + // Paypal state. The user will not be charged, because they are only charged when we + // confirm with the server. + 'PaypalError', + // When the user cancels PayPal payment. + 'PaypalCanceled', // When it's been too long since the last step of the donation, and card wasn't charged 'TimedOut', // When donation succeeds but badge application fails diff --git a/ts/types/Toast.dom.tsx b/ts/types/Toast.dom.tsx index b45047110e..1846ae0fd1 100644 --- a/ts/types/Toast.dom.tsx +++ b/ts/types/Toast.dom.tsx @@ -41,6 +41,8 @@ export enum ToastType { DonationCompleted = 'DonationCompleted', DonationConfirmationNeeded = 'DonationConfirmationNeeded', DonationError = 'DonationError', + DonationPaypalCanceled = 'DonationPaypalCanceled', + DonationPaypalError = 'DonationPaypalError', DonationProcessing = 'DonationProcessing', DonationVerificationNeeded = 'DonationVerificationNeeded', DonationVerificationFailed = 'DonationVerificationFailed', @@ -160,6 +162,8 @@ export type AnyToast = | { toastType: ToastType.DonationCompleted } | { toastType: ToastType.DonationConfirmationNeeded } | { toastType: ToastType.DonationError } + | { toastType: ToastType.DonationPaypalCanceled } + | { toastType: ToastType.DonationPaypalError } | { toastType: ToastType.DonationProcessing } | { toastType: ToastType.DonationVerificationFailed } | { toastType: ToastType.DonationVerificationNeeded } diff --git a/ts/util/signalRoutes.std.ts b/ts/util/signalRoutes.std.ts index 913307104e..fb72ba8e2d 100644 --- a/ts/util/signalRoutes.std.ts +++ b/ts/util/signalRoutes.std.ts @@ -610,8 +610,8 @@ export const donationPaypalApprovedRoute = _route('donationPaypalApproved', { }), ], schema: z.object({ - payerId: paramSchema.optional(), - paymentToken: paramSchema.optional(), + payerId: paramSchema.nullable().optional(), + paymentToken: paramSchema.nullable().optional(), returnToken: paramSchema, }), parse(result) {