diff --git a/app/main.ts b/app/main.ts index 7a3e0b03b1..0725706521 100644 --- a/app/main.ts +++ b/app/main.ts @@ -2746,6 +2746,7 @@ ipc.on('get-config', async event => { registrationChallengeUrl: config.get('registrationChallengeUrl'), serverPublicParams: config.get('serverPublicParams'), serverTrustRoot: config.get('serverTrustRoot'), + stripePublishableKey: config.get('stripePublishableKey'), genericServerPublicParams: config.get('genericServerPublicParams'), backupServerPublicParams: config.get('backupServerPublicParams'), theme, @@ -2914,6 +2915,8 @@ function handleSignalRoute(route: ParsedSignalRoute) { challengeHandler.handleCaptcha(route.args.captchaId); // Show window after handling captcha showWindow(); + } else if (route.key === 'donationValidationComplete') { + log.info('donationValidationComplete route handled'); } else { log.info('handleSignalRoute: Unknown signal route:', route.key); mainWindow.webContents.send('unknown-sgnl-link'); diff --git a/config/default.json b/config/default.json index f16acde421..56cf7abc19 100644 --- a/config/default.json +++ b/config/default.json @@ -26,5 +26,6 @@ "serverPublicParams": "ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==", "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx", "genericServerPublicParams": "AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N", - "backupServerPublicParams": "AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8" + "backupServerPublicParams": "AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8", + "stripePublishableKey": "pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD" } diff --git a/config/production.json b/config/production.json index 5b9cae3ed3..ee1fc6523d 100644 --- a/config/production.json +++ b/config/production.json @@ -15,5 +15,6 @@ "serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF", "genericServerPublicParams": "AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN", "backupServerPublicParams": "AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O", + "stripePublishableKey": "pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D", "updatesEnabled": true } diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 86f26bdbb1..273ea03f19 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -170,7 +170,18 @@ function RenderProfileEditor(): JSX.Element { function RenderDonationsPane(): JSX.Element { const contentsRef = useRef(null); - return ; + return ( + + ); } function renderToastManager(): JSX.Element { diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index d8a33177e8..f619c50f39 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -203,6 +203,8 @@ type PropsFunctionType = { // Render props renderDonationsPane: (options: { contentsRef: MutableRefObject; + page: Page; + setPage: (page: Page) => void; }) => JSX.Element; renderProfileEditor: (options: { contentsRef: MutableRefObject; @@ -326,6 +328,7 @@ export enum Page { // Sub pages ChatColor = 'ChatColor', ChatFolders = 'ChatFolders', + DonationsDonateFlow = 'DonationsDonateFlow', EditChatFolder = 'EditChatFolder', PNP = 'PNP', BackupsDetails = 'BackupsDetails', @@ -593,6 +596,12 @@ export function Preferences({ if (page === Page.Backups && !shouldShowBackupsPage) { setPage(Page.General); } + if ( + (page === Page.Donations || page === Page.DonationsDonateFlow) && + !donationsFeatureEnabled + ) { + setPage(Page.General); + } if (page === Page.Internal && !isInternalUser) { setPage(Page.General); } @@ -888,9 +897,11 @@ export function Preferences({ title={i18n('icu:Preferences__button--general')} /> ); - } else if (page === Page.Donations) { + } else if (page === Page.Donations || page === Page.DonationsDonateFlow) { content = renderDonationsPane({ contentsRef: settingsPaneRef, + page, + setPage, }); } else if (page === Page.Appearance) { let zoomFactors = DEFAULT_ZOOM_FACTORS; @@ -2354,7 +2365,9 @@ export function Preferences({ className={classNames({ Preferences__button: true, 'Preferences__button--appearance': true, - 'Preferences__button--selected': page === Page.Donations, + 'Preferences__button--selected': + page === Page.Donations || + page === Page.DonationsDonateFlow, })} onClick={() => setPage(Page.Donations)} > diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx new file mode 100644 index 0000000000..dd3c9c52bd --- /dev/null +++ b/ts/components/PreferencesDonateFlow.tsx @@ -0,0 +1,177 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useRef, useState } from 'react'; + +import type { MutableRefObject } from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; +import { PreferencesContent } from './Preferences'; +import { Button, ButtonVariant } from './Button'; +import type { CardDetail, DonationWorkflow } from '../types/Donations'; +import { Input } from './Input'; + +type PropsExternalType = { + contentsRef: MutableRefObject; +}; + +export type PropsDataType = { + i18n: LocalizerType; + workflow: DonationWorkflow | undefined; +}; + +type PropsActionType = { + clearWorkflow: () => void; + onBack: () => void; + submitDonation: (options: { + currencyType: string; + paymentAmount: number; + paymentDetail: CardDetail; + }) => void; +}; + +export type PropsType = PropsDataType & PropsActionType & PropsExternalType; + +export function PreferencesDonateFlow({ + contentsRef, + i18n, + workflow, + clearWorkflow, + onBack, + submitDonation, +}: PropsType): JSX.Element { + const tryClose = useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'PreferencesDonateFlow', + tryClose, + }); + + const [amount, setAmount] = useState('10.00'); + const [cardExpirationMonth, setCardExpirationMonth] = useState(''); + const [cardExpirationYear, setCardExpirationYear] = useState(''); + const [cardNumber, setCardNumber] = useState(''); + const [cardCvc, setCardCvc] = useState(''); + + const handleDonateClicked = useCallback(() => { + const parsedAmount = parseFloat(amount); + // Note: Whether to multiply by 100 depends on the specific currency + // e.g. JPY is not multipled by 100 + const paymentAmount = parsedAmount * 100; + + submitDonation({ + currencyType: 'USD', + paymentAmount, + paymentDetail: { + expirationMonth: cardExpirationMonth, + expirationYear: cardExpirationYear, + number: cardNumber, + cvc: cardCvc, + }, + }); + }, [ + amount, + cardCvc, + cardExpirationMonth, + cardExpirationYear, + cardNumber, + submitDonation, + ]); + + const isDonateDisabled = workflow !== undefined; + + const backButton = ( + + + )} + + + setAmount(value)} + placeholder="5" + value={amount} + /> + + setCardNumber(value)} + placeholder="0000000000000000" + maxLengthCount={16} + value={cardNumber} + /> + + setCardExpirationMonth(value)} + placeholder="MM" + value={cardExpirationMonth} + /> + + setCardExpirationYear(value)} + placeholder="YY" + value={cardExpirationYear} + /> + + setCardCvc(value)} + placeholder="123" + value={cardCvc} + /> + + + ); + + return ( + <> + {confirmDiscardModal} + + + + ); +} diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index e91fd5ed6c..5da4d7360c 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -1,13 +1,15 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useRef } from 'react'; +import React from 'react'; import type { MutableRefObject } from 'react'; import type { LocalizerType } from '../types/Util'; -import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; -import { PreferencesContent } from './Preferences'; +import { Page, PreferencesContent } from './Preferences'; +import { Button, ButtonVariant } from './Button'; +import { PreferencesDonateFlow } from './PreferencesDonateFlow'; +import type { CardDetail, DonationWorkflow } from '../types/Donations'; type PropsExternalType = { contentsRef: MutableRefObject; @@ -15,61 +17,64 @@ type PropsExternalType = { export type PropsDataType = { i18n: LocalizerType; + isStaging: boolean; + page: Page; + workflow: DonationWorkflow | undefined; }; -// type PropsActionType = {}; -export type PropsType = PropsDataType /* & PropsActionType */ & - PropsExternalType; +type PropsActionType = { + clearWorkflow: () => void; + setPage: (page: Page) => void; + submitDonation: (options: { + currencyType: string; + paymentAmount: number; + paymentDetail: CardDetail; + }) => void; +}; + +export type PropsType = PropsDataType & PropsActionType & PropsExternalType; export function PreferencesDonations({ contentsRef, i18n, + isStaging, + page, + workflow, + clearWorkflow, + setPage, + submitDonation, }: PropsType): JSX.Element { - const tryClose = useRef<() => void | undefined>(); - const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ - i18n, - name: 'PreferencesDonations', - tryClose, - }); + if (page === Page.DonationsDonateFlow) { + return ( + setPage(Page.Donations)} + submitDonation={submitDonation} + /> + ); + } - // TODO: only show back button when on a sub-page. - // See ProfileEditor for its approach, an enum describing the current page - // Note that we would want to then add that to nav/location stuff - const backButton = ( - + )} + ); - const onTryClose = useCallback(() => { - // TODO: check to see if we're dirty before navigating away - const isDirty = false; - const onDiscard = () => { - // clear data that the user had been working on, perhaps? - }; - const onCancel = () => { - // is there anything to do if the user cancels out of navigation? - }; - - confirmDiscardIf(isDirty, onDiscard, onCancel); - }, [confirmDiscardIf]); - tryClose.current = onTryClose; - - const content = 'Empty for now'; - return ( - <> - {confirmDiscardModal} - - {content}} - contentsRef={contentsRef} - title={i18n('icu:Preferences__DonateTitle')} - /> - + ); } diff --git a/ts/services/donationReceiptsLoader.ts b/ts/services/donationReceiptsLoader.ts index 546f36893c..8b687d710a 100644 --- a/ts/services/donationReceiptsLoader.ts +++ b/ts/services/donationReceiptsLoader.ts @@ -19,6 +19,7 @@ export function getDonationReceiptsForRedux(): DonationsStateType { 'donation receipts have not been loaded' ); return { + currentWorkflow: undefined, receipts: donationReceipts, }; } diff --git a/ts/services/donations.ts b/ts/services/donations.ts new file mode 100644 index 0000000000..efb7641f30 --- /dev/null +++ b/ts/services/donations.ts @@ -0,0 +1,423 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { v4 as uuid } from 'uuid'; +import { + ClientZkReceiptOperations, + ReceiptCredential, + ReceiptCredentialRequestContext, + ReceiptCredentialResponse, + ReceiptSerial, + ServerPublicParams, +} from '@signalapp/libsignal-client/zkgroup'; + +import * as Bytes from '../Bytes'; +import { donationStateSchema, paymentTypeSchema } from '../types/Donations'; +import type { + CardDetail, + DonationReceipt, + DonationWorkflow, + ReceiptContext, +} from '../types/Donations'; +import { getRandomBytes, sha256 } from '../Crypto'; +import { DataWriter } from '../sql/Client'; +import { createLogger } from '../logging/log'; +import { donationValidationCompleteRoute } from '../util/signalRoutes'; + +const { createDonationReceipt } = DataWriter; + +const log = createLogger('donations'); + +function redactId(id: string) { + return `[REDACTED]${id.slice(-4)}`; +} + +function hashIdToIdempotencyKey(id: string) { + const idBytes = Bytes.fromString(id); + const hashed = sha256(idBytes); + return Buffer.from(hashed).toString('hex'); +} + +const RECEIPT_SERIAL_LENGTH = 16; + +let isDonationInProgress = false; + +export async function internalDoDonation({ + currencyType, + paymentAmount, + paymentDetail, +}: { + currencyType: string; + paymentAmount: number; + paymentDetail: CardDetail; +}): Promise { + if (isDonationInProgress) { + throw new Error("Can't proceed because a donation is in progress."); + } + + try { + isDonationInProgress = true; + + let workflow: DonationWorkflow; + + workflow = await createPaymentIntent({ + currencyType, + paymentAmount, + }); + window.reduxActions.donations.updateWorkflow(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); + } finally { + isDonationInProgress = false; + } +} + +export async function createPaymentIntent({ + currencyType, + paymentAmount, +}: { + currencyType: string; + paymentAmount: number; +}): Promise { + if (!window.textsecure.server) { + throw new Error( + 'createPaymentIntent: window.textsecure.server is not available!' + ); + } + + const id = uuid(); + const logId = `createPaymentIntent(${redactId(id)})`; + log.info(`${logId}: Creating new workflow`); + + const payload = { + currency: currencyType, + amount: paymentAmount, + level: 1, + paymentMethod: 'CARD', + }; + const { clientSecret } = + await window.textsecure.server.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 { + const logId = `createPaymentMethodForIntent(${redactId(workflow.id)})`; + + if (workflow.type !== donationStateSchema.Enum.INTENT) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not at type INTENT, unable to create payment method` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + + log.info(`${logId}: Starting`); + + const { id: paymentMethodId } = + await window.textsecure.server.createPaymentMethodWithStripe({ + cardDetail, + }); + + log.info(`${logId}: Successfully transitioned to INTENT_METHOD`); + + return { + ...workflow, + type: donationStateSchema.Enum.INTENT_METHOD, + timestamp: Date.now(), + paymentMethodId, + paymentType: paymentTypeSchema.Enum.CARD, + paymentDetail: { + lastFourDigits: cardDetail.number.slice(-4), + }, + }; +} + +export async function confirmPayment( + workflow: DonationWorkflow +): Promise { + const logId = `confirmPayment(${redactId(workflow.id)})`; + + 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` + ); + } + 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 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 { clientSecret, paymentIntentId, paymentMethodId, id } = workflow; + const idempotencyKey = hashIdToIdempotencyKey(id); + const returnUrl = donationValidationCompleteRoute + .toAppUrl({ token: workflow.returnToken }) + .toString(); + const options = { + clientSecret, + idempotencyKey, + paymentIntentId, + paymentMethodId, + returnUrl, + }; + + 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; + + 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 INTENT_CONFIRMED`); + + return { + ...workflow, + ...receiptContext, + type: donationStateSchema.Enum.INTENT_CONFIRMED, + timestamp: Date.now(), + }; +} + +export async function completeValidationRedirect( + workflow: DonationWorkflow, + token: string +): Promise { + const logId = `completeValidationRedirect(${redactId(workflow.id)})`; + + 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` + ); + } + 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)}` + ); + + return { + ...workflow, + type: donationStateSchema.Enum.INTENT_CONFIRMED, + timestamp: Date.now(), + }; +} + +export async function getReceipt( + workflow: DonationWorkflow +): Promise { + const logId = `getReceipt(${redactId(workflow.id)})`; + + if (workflow.type !== donationStateSchema.Enum.INTENT_CONFIRMED) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} not type INTENT_CONFIRMED, unable to get receipt` + ); + } + if (!window.textsecure.server) { + throw new Error(`${logId}: window.textsecure.server is not available!`); + } + log.info(`${logId}: Starting`); + + const { + paymentIntentId, + receiptCredentialRequestBase64, + receiptCredentialRequestContextBase64, + } = workflow; + const jsonPayload = { + paymentIntentId, + receiptCredentialRequest: receiptCredentialRequestBase64, + processor: 'STRIPE', + }; + + // 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 + const { receiptCredentialResponse: receiptCredentialResponseBase64 } = + await window.textsecure.server.createBoostReceiptCredentials(jsonPayload); + + 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 + + log.info( + `${logId}: Successfully transitioned to RECEIPT for workflow ${redactId(workflow.id)}` + ); + + return { + ...workflow, + type: donationStateSchema.Enum.RECEIPT, + timestamp: Date.now(), + receiptCredentialBase64: Bytes.toBase64(receiptCredential.serialize()), + }; +} + +export async function redeemReceipt( + workflow: DonationWorkflow +): Promise { + const logId = `redeemReceipt(${redactId(workflow.id)})`; + + if (workflow.type !== donationStateSchema.Enum.RECEIPT) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} not type RECEIPT, unable to redeem receipt` + ); + } + 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 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() + ); + const jsonPayload = { + receiptCredentialPresentation: receiptCredentialPresentationBase64, + visible: false, + primary: false, + }; + + await window.textsecure.server.redeemReceipt(jsonPayload); + + log.info(`${logId}: Successfully transitioned to RECEIPT_REDEEMED`); + + return { + ...workflow, + type: donationStateSchema.Enum.RECEIPT_REDEEMED, + timestamp: Date.now(), + }; +} + +export async function saveReceipt( + workflow: DonationWorkflow +): Promise { + const logId = `saveReceipt(${redactId(workflow.id)})`; + + if (workflow.type !== donationStateSchema.Enum.RECEIPT_REDEEMED) { + throw new Error( + `${logId}: workflow at type ${workflow?.type} is not ready to save receipt` + ); + } + log.info(`${logId}: Starting`); + + // TODO: Should we generate a new UUID to break all links with Stripe? + const donationReceipt: DonationReceipt = { + id: workflow.id, + currencyType: workflow.currencyType, + paymentAmount: workflow.paymentAmount, + timestamp: workflow.timestamp, + paymentType: workflow.paymentType, + paymentDetail: workflow.paymentDetail, + }; + + await createDonationReceipt(donationReceipt); + + log.info(`${logId}: Successfully saved receipt`); + + window.reduxActions.donations.addReceipt(donationReceipt); + + return { + id: workflow.id, + type: donationStateSchema.Enum.DONE, + }; +} diff --git a/ts/signal.ts b/ts/signal.ts index b25d6cec44..9d387d9848 100644 --- a/ts/signal.ts +++ b/ts/signal.ts @@ -35,6 +35,7 @@ import { initializeUpdateListener } from './services/updateListener'; import { calling } from './services/calling'; import * as storage from './services/storage'; import { backupsService } from './services/backups'; +import * as donations from './services/donations'; import type { LoggerType } from './types/Logging'; import type { @@ -465,6 +466,7 @@ export const setup = (options: { initializeUpdateListener, // Testing + donations, storage, }; diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index da13f78c52..4e0cfe304a 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -10,32 +10,63 @@ import * as Errors from '../../types/errors'; import { isStagingServer } from '../../util/isStagingServer'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; -import type { DonationReceipt } from '../../types/Donations'; +import type { + CardDetail, + DonationReceipt, + DonationWorkflow, +} from '../../types/Donations'; import type { StateType as RootStateType } from '../reducer'; import { DataWriter } from '../../sql/Client'; +import * as donations from '../../services/donations'; const log = createLogger('donations'); // State export type DonationsStateType = ReadonlyDeep<{ + currentWorkflow: DonationWorkflow | undefined; receipts: Array; }>; // Actions export const ADD_RECEIPT = 'donations/ADD_RECEIPT'; +export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION'; +export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW'; export type AddReceiptAction = ReadonlyDeep<{ type: typeof ADD_RECEIPT; payload: { receipt: DonationReceipt }; }>; -export type DonationsActionType = ReadonlyDeep; +export type SubmitDonationAction = ReadonlyDeep<{ + type: typeof SUBMIT_DONATION; + payload: { + currencyType: string; + amount: number; + paymentDetail: CardDetail; + }; +}>; + +export type UpdateWorkflowAction = ReadonlyDeep<{ + type: typeof UPDATE_WORKFLOW; + payload: { nextWorkflow: DonationWorkflow | undefined }; +}>; + +export type DonationsActionType = ReadonlyDeep< + AddReceiptAction | SubmitDonationAction | UpdateWorkflowAction +>; // Action Creators -export function internalAddDonationReceipt( +export function addReceipt(receipt: DonationReceipt): AddReceiptAction { + return { + type: ADD_RECEIPT, + payload: { receipt }, + }; +} + +function internalAddDonationReceipt( receipt: DonationReceipt ): ThunkAction { return async dispatch => { @@ -58,8 +89,55 @@ export function internalAddDonationReceipt( }; } +function submitDonation({ + currencyType, + paymentAmount, + paymentDetail, +}: { + currencyType: string; + paymentAmount: number; + paymentDetail: CardDetail; +}): ThunkAction { + return async () => { + if (!isStagingServer()) { + log.error('internalAddDonationReceipt: Only available on staging server'); + return; + } + + try { + await donations.internalDoDonation({ + currencyType, + paymentAmount, + paymentDetail, + }); + } catch (error) { + log.warn('submitDonation failed', Errors.toLogFormat(error)); + } + }; +} + +function clearWorkflow(): UpdateWorkflowAction { + return { + type: UPDATE_WORKFLOW, + payload: { nextWorkflow: undefined }, + }; +} + +function updateWorkflow( + nextWorkflow: DonationWorkflow | undefined +): UpdateWorkflowAction { + return { + type: UPDATE_WORKFLOW, + payload: { nextWorkflow }, + }; +} + export const actions = { + addReceipt, + clearWorkflow, internalAddDonationReceipt, + submitDonation, + updateWorkflow, }; export const useDonationsActions = (): BoundActionCreatorsMapObject< @@ -70,6 +148,7 @@ export const useDonationsActions = (): BoundActionCreatorsMapObject< export function getEmptyState(): DonationsStateType { return { + currentWorkflow: undefined, receipts: [], }; } @@ -85,5 +164,12 @@ export function reducer( }; } + if (action.type === UPDATE_WORKFLOW) { + return { + ...state, + currentWorkflow: action.payload.nextWorkflow, + }; + } + return state; } diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 47b6be6fbd..752de764cc 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -100,10 +100,22 @@ function renderToastManager(props: { return ; } -function renderDonationsPane(options: { +function renderDonationsPane({ + contentsRef, + page, + setPage, +}: { contentsRef: MutableRefObject; + page: Page; + setPage: (page: Page) => void; }): JSX.Element { - return ; + return ( + + ); } function getSystemTraySettingValues( diff --git a/ts/state/smart/PreferencesDonations.tsx b/ts/state/smart/PreferencesDonations.tsx index ef73c1d3f8..06b210aaa7 100644 --- a/ts/state/smart/PreferencesDonations.tsx +++ b/ts/state/smart/PreferencesDonations.tsx @@ -8,13 +8,39 @@ import type { MutableRefObject } from 'react'; import { getIntl } from '../selectors/user'; import { PreferencesDonations } from '../../components/PreferencesDonations'; +import type { Page } from '../../components/Preferences'; +import { useDonationsActions } from '../ducks/donations'; +import type { StateType } from '../reducer'; +import { isStagingServer } from '../../util/isStagingServer'; export const SmartPreferencesDonations = memo( - function SmartPreferencesDonations(props: { + function SmartPreferencesDonations({ + contentsRef, + page, + setPage, + }: { contentsRef: MutableRefObject; + page: Page; + setPage: (page: Page) => void; }) { + const isStaging = isStagingServer(); const i18n = useSelector(getIntl); + const workflow = useSelector( + (state: StateType) => state.donations.currentWorkflow + ); + const { clearWorkflow, submitDonation } = useDonationsActions(); - return ; + return ( + + ); } ); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 95d86c390e..e899c394d7 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -14,6 +14,7 @@ import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; import { z } from 'zod'; import type { Readable } from 'stream'; +import qs from 'querystring'; import type { KEMPublicKey, PublicKey, @@ -92,6 +93,7 @@ import type { ServerAlert } from '../util/handleServerAlerts'; import { isAbortError } from '../util/isAbortError'; import { missingCaseError } from '../util/missingCaseError'; import { drop } from '../util/drop'; +import type { CardDetail } from '../types/Donations'; const log = createLogger('WebAPI'); @@ -101,6 +103,8 @@ const log = createLogger('WebAPI'); const DEBUG = false; const DEFAULT_TIMEOUT = 30 * SECOND; +const CONTENT_TYPE_FORM_ENCODING = 'application/x-www-form-urlencoded'; + function _createRedactor( ...toReplace: ReadonlyArray ): RedactUrl { @@ -687,8 +691,10 @@ const CHAT_CALLS = { attachmentUploadForm: 'v4/attachments/form/upload', attestation: 'v1/attestation', batchIdentityCheck: 'v1/profile/identity_check/batch', + boostReceiptCredentials: 'v1/subscription/boost/receipt_credentials', challenge: 'v1/challenge', config: 'v1/config', + createBoost: 'v1/subscription/boost/create', deliveryCert: 'v1/certificate/delivery', devices: 'v1/devices', directoryAuthV2: 'v2/directory/auth', @@ -711,6 +717,7 @@ const CHAT_CALLS = { backupMediaBatch: 'v1/archives/media/batch', backupMediaDelete: 'v1/archives/media/delete', callLinkCreateAuth: 'v1/call-link/create-auth', + redeemReceipt: 'v1/donation/redeem-receipt', registration: 'v1/registration', registerCapabilities: 'v1/devices/capabilities', reportMessage: 'v1/messages/report', @@ -762,6 +769,7 @@ type InitializeOptionsType = { proxyUrl: string | undefined; version: string; disableIPv6: boolean; + stripePublishableKey: string; }; export type MessageType = Readonly<{ @@ -1139,6 +1147,71 @@ export type CreateAccountResultType = Readonly<{ pni: Pni; }>; +export type CreateBoostOptionsType = Readonly<{ + currency: string; + amount: number; + level: number; + paymentMethod: string; +}>; +const CreateBoostResultSchema = z.object({ + clientSecret: z.string(), +}); +export type CreateBoostResultType = z.infer; + +export type CreateBoostReceiptCredentialsOptionsType = Readonly<{ + paymentIntentId: string; + receiptCredentialRequest: string; + processor: string; +}>; +const CreateBoostReceiptCredentialsResultSchema = z.object({ + receiptCredentialResponse: z.string(), +}); +export type CreateBoostReceiptCredentialsResultType = z.infer< + typeof CreateBoostReceiptCredentialsResultSchema +>; + +// https://docs.stripe.com/api/payment_methods/create?api-version=2025-05-28.basil&lang=node#create_payment_method-card +type CreatePaymentMethodWithStripeOptionsType = Readonly<{ + cardDetail: CardDetail; +}>; +const CreatePaymentMethodWithStripeResultSchema = z.object({ + id: z.string(), +}); +type CreatePaymentMethodWithStripeResultType = z.infer< + typeof CreatePaymentMethodWithStripeResultSchema +>; + +// https://docs.stripe.com/api/payment_intents/confirm?api-version=2025-05-28.basil +export type ConfirmIntentWithStripeOptionsType = Readonly<{ + clientSecret: string; + idempotencyKey: string; + paymentIntentId: string; + paymentMethodId: string; + returnUrl: string; +}>; +const ConfirmIntentWithStripeResultSchema = z.object({ + next_action: z + .object({ + type: z.string(), + redirect_to_url: z + .object({ + return_url: z.string(), // what we provided originally + url: z.string(), // what we need to redirect to + }) + .nullable(), + }) + .nullable(), +}); +type ConfirmIntentWithStripeResultType = z.infer< + typeof ConfirmIntentWithStripeResultSchema +>; + +export type RedeemReceiptOptionsType = Readonly<{ + receiptCredentialPresentation: string; + visible: boolean; + primary: boolean; +}>; + export type RequestVerificationResultType = Readonly<{ sessionId: string; }>; @@ -1473,6 +1546,15 @@ export type WebAPIType = { createAccount: ( options: CreateAccountOptionsType ) => Promise; + confirmIntentWithStripe: ( + options: ConfirmIntentWithStripeOptionsType + ) => Promise; + createPaymentMethodWithStripe: ( + options: CreatePaymentMethodWithStripeOptionsType + ) => Promise; + createBoostPaymentIntent: ( + options: CreateBoostOptionsType + ) => Promise; createGroup: ( group: Proto.IGroup, options: GroupCredentialsType @@ -1497,6 +1579,10 @@ export type WebAPIType = { }) => Promise; getAttachmentUploadForm: () => Promise; getAvatar: (path: string) => Promise; + createBoostReceiptCredentials: ( + options: CreateBoostReceiptCredentialsOptionsType + ) => Promise; + redeemReceipt: (options: RedeemReceiptOptionsType) => Promise; getHasSubscription: (subscriberId: Uint8Array) => Promise; getGroup: (options: GroupCredentialsType) => Promise; getGroupFromLink: ( @@ -1866,6 +1952,7 @@ export function initialize({ contentProxyUrl, proxyUrl, version, + stripePublishableKey, }: InitializeOptionsType): WebAPIConnectType { if (!isString(chatServiceUrl)) { throw new Error('WebAPI.initialize: Invalid chatServiceUrl'); @@ -1903,6 +1990,9 @@ export function initialize({ if (!isString(version)) { throw new Error('WebAPI.initialize: Invalid version'); } + if (!isString(stripePublishableKey)) { + throw new Error('WebAPI.initialize: Invalid stripePublishableKey'); + } // We store server alerts (returned on the WS upgrade response headers) so that the app // can query them later, which is necessary if they arrive before app state is ready @@ -2051,8 +2141,12 @@ export function initialize({ createAccount, callLinkCreateAuth, createFetchForAttachmentUpload, + confirmIntentWithStripe, confirmUsername, + createBoostPaymentIntent, + createBoostReceiptCredentials, createGroup, + createPaymentMethodWithStripe, deleteUsername, deleteUsernameLink, downloadOnboardingStories, @@ -2123,6 +2217,7 @@ export function initialize({ putProfile, putStickers, reconnect, + redeemReceipt, refreshBackup, registerCapabilities, registerKeys, @@ -2416,6 +2511,18 @@ export function initialize({ }; } + async function redeemReceipt( + options: RedeemReceiptOptionsType + ): Promise { + await _ajax({ + host: 'chatService', + call: 'redeemReceipt', + httpType: 'POST', + jsonData: options, + responseType: 'byteswithdetails', + }); + } + async function getReleaseNoteHash({ uuid, locale, @@ -4625,6 +4732,112 @@ export function initialize({ }); } + function createBoostPaymentIntent( + options: CreateBoostOptionsType + ): Promise { + return _ajax({ + unauthenticated: true, + host: 'chatService', + call: 'createBoost', + httpType: 'POST', + jsonData: options, + responseType: 'json', + zodSchema: CreateBoostResultSchema, + }); + } + + async function createBoostReceiptCredentials( + options: CreateBoostReceiptCredentialsOptionsType + ): Promise { + return _ajax({ + unauthenticated: true, + host: 'chatService', + call: 'boostReceiptCredentials', + httpType: 'POST', + jsonData: options, + responseType: 'json', + zodSchema: CreateBoostReceiptCredentialsResultSchema, + }); + } + + // https://docs.stripe.com/api/payment_intents/confirm?api-version=2025-05-28.basil + function confirmIntentWithStripe( + options: ConfirmIntentWithStripeOptionsType + ): Promise { + const { + clientSecret, + idempotencyKey, + paymentIntentId, + paymentMethodId, + returnUrl, + } = options; + const safePaymentIntentId = encodeURIComponent(paymentIntentId); + const url = `https://api.stripe.com/v1/payment_intents/${safePaymentIntentId}/confirm`; + const formData = { + client_secret: clientSecret, + payment_method: paymentMethodId, + return_url: returnUrl, + }; + const basicAuth = getBasicAuth({ + username: stripePublishableKey, + password: '', + }); + const formBytes = Bytes.fromString(qs.encode(formData)); + + // This is going to Stripe, so we use _outerAjax + return _outerAjax(url, { + data: formBytes, + headers: { + Authorization: basicAuth, + 'Content-Type': CONTENT_TYPE_FORM_ENCODING, + 'Content-Length': formBytes.byteLength.toString(), + 'Idempotency-Key': idempotencyKey, + }, + proxyUrl, + redactUrl: () => { + return url.replace(safePaymentIntentId, '[REDACTED]'); + }, + responseType: 'json', + type: 'POST', + version, + zodSchema: ConfirmIntentWithStripeResultSchema, + }); + } + + // https://docs.stripe.com/api/payment_methods/create?api-version=2025-05-28.basil&lang=node#create_payment_method-card + function createPaymentMethodWithStripe( + options: CreatePaymentMethodWithStripeOptionsType + ): Promise { + const { cardDetail } = options; + const formData = { + type: 'card', + 'card[cvc]': cardDetail.cvc, + 'card[exp_month]': cardDetail.expirationMonth, + 'card[exp_year]': cardDetail.expirationYear, + 'card[number]': cardDetail.number, + }; + const basicAuth = getBasicAuth({ + username: stripePublishableKey, + password: '', + }); + const formBytes = Bytes.fromString(qs.encode(formData)); + + // This is going to Stripe, so we use _outerAjax + return _outerAjax('https://api.stripe.com/v1/payment_methods', { + data: formBytes, + headers: { + Authorization: basicAuth, + 'Content-Type': CONTENT_TYPE_FORM_ENCODING, + 'Content-Length': formBytes.byteLength.toString(), + }, + proxyUrl, + responseType: 'json', + type: 'POST', + version, + zodSchema: CreatePaymentMethodWithStripeResultSchema, + }); + } + async function createGroup( group: Proto.IGroup, options: GroupCredentialsType diff --git a/ts/types/Donations.ts b/ts/types/Donations.ts index a9d4c4a1ad..ae28f62c1d 100644 --- a/ts/types/Donations.ts +++ b/ts/types/Donations.ts @@ -3,43 +3,39 @@ import { z } from 'zod'; -const donationStateSchema = z.enum([ +export const donationStateSchema = z.enum([ 'INTENT', 'INTENT_METHOD', 'INTENT_CONFIRMED', 'INTENT_REDIRECT', 'RECEIPT', 'RECEIPT_REDEEMED', + 'DONE', ]); -export type DonationState = z.infer; -const paymentTypeSchema = z.enum(['CARD', 'PAYPAL']); -export type PaymentType = z.infer; +export const paymentTypeSchema = z.enum(['CARD']); const coreDataSchema = z.object({ - // guid used to prevent duplicates at stripe and in our db. - // we'll hash it and provide it to stripe as the idempotencyKey: https://docs.stripe.com/error-low-level#idempotency + // Guid used to prevent duplicates at stripe and in our db id: z.string(), - // the code, like USD + // Currency code, like USD currencyType: z.string(), - // cents as whole numbers, so multiply by 100 + // 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 click the button. + // will be when we redeem the receipt, not when they initiated the donation. timestamp: z.number(), }); export type CoreData = z.infer; -// When we add more payment types, this will become a discriminatedUnion like this: -// const paymentDetailSchema = z.discriminatedUnion('paymentType', [ const paymentDetailSchema = z.object({ paymentType: z.literal(paymentTypeSchema.Enum.CARD), - // Note: we really don't want this to be null, but sometimes it won't parse, and in - // that case we still move forward and display the receipt best we can. + // Note: we really don't want this to be null, but sometimes it won't parse from the DB, + // and in that case we still move forward and display the receipt best we can. paymentDetail: z .object({ lastFourDigits: z.string(), @@ -48,12 +44,155 @@ const paymentDetailSchema = z.object({ }); export type PaymentDetails = z.infer; +// Payment type: CARD +export type CardDetail = { + // Two digits + expirationMonth: string; + + // Four digts + expirationYear: string; + + // String with no separators, just 16 digits + number: string; + + // String + cvc: string; +}; + +const stripeDataSchema = z.object({ + // Received after creation of intent + clientSecret: z.string(), + + // Parsed out of clientSecret - it's everything up to the '_secret_' + // https://docs.stripe.com/api/payment_intents/object + paymentIntentId: z.string(), + + // Used for any validation that takes the user somewhere else + returnToken: z.string(), +}); +export type StripeData = z.infer; + +// We need these for durability. if we keep these around throughout the process, retries +// later in the process won't give us weird errors. +// Generated by libsignal. +const receiptContextSchema = z.object({ + receiptCredentialRequestContextBase64: z.string(), + receiptCredentialRequestBase64: z.string(), +}); +export type ReceiptContext = z.infer; + export const donationReceiptSchema = z.intersection( z.object({ ...coreDataSchema.shape, }), - // This type will demand the z.intersection when it is a discriminatedUnion. When - // it is a discriminatedUnion, we can't use the ...schema.shape approach + // TODO: This type will demand the z.intersection when it is a discriminatedUnion. + // When it is a discriminatedUnion, we can't use the ...schema.shape approach paymentDetailSchema ); export type DonationReceipt = z.infer; + +// We would like this to be a discriminated union, but you cannot put z.intersection() +// inside of a discriminatedUnion today: https://github.com/colinhacks/zod/issues/1768 +const donationWorkflowSchema = z.union([ + 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 + // currency/amount on the intent if we want to continue to use it. + type: z.literal(donationStateSchema.Enum.INTENT), + ...coreDataSchema.shape, + ...stripeDataSchema.shape, + }), + + z.intersection( + 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. + type: z.literal(donationStateSchema.Enum.INTENT_METHOD), + + // Stripe persists the user's payment information for us, behind this id + paymentMethodId: z.string(), + + ...coreDataSchema.shape, + ...stripeDataSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion + paymentDetailSchema + ), + + z.intersection( + 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. + type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED), + + ...coreDataSchema.shape, + ...stripeDataSchema.shape, + ...receiptContextSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion + paymentDetailSchema + ), + + z.intersection( + z.object({ + // An alternate state to INTENT_CONFIRMED. A response from Stripe indicated + // the user's card requires 3ds authentication, so we need to redirect to their + // bank, which will complete verification, then redirect back to us. We hand that + // service a token to connect it back to this process. If the user never comes back, + // we need to offer the redirect again. + type: z.literal(donationStateSchema.Enum.INTENT_REDIRECT), + + // Where user should be sent; in this state we are waiting for them to come back + redirectTarget: z.string(), + + ...coreDataSchema.shape, + ...stripeDataSchema.shape, + ...receiptContextSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion + paymentDetailSchema + ), + + z.intersection( + z.object({ + // We now have everything we need to redeem. We know the payment has gone through + // 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 + // previously-generated receiptCredentialRequestContext + receiptCredentialBase64: z.string(), + + ...coreDataSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion + paymentDetailSchema + ), + + z.intersection( + 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, + ...paymentDetailSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion + paymentDetailSchema + ), + + 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(), + }), +]); + +export type DonationWorkflow = z.infer; diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index 452af250ed..f4011d29b2 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -70,6 +70,7 @@ export const rendererConfigSchema = z.object({ serverUrl: configRequiredStringSchema, sfuUrl: configRequiredStringSchema, storageUrl: configRequiredStringSchema, + stripePublishableKey: configRequiredStringSchema, theme: themeSettingSchema, updatesUrl: configRequiredStringSchema, resourcesUrl: configRequiredStringSchema, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index cab7440f8e..ca6b54fdb4 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1480,10 +1480,10 @@ }, { "rule": "React-useRef", - "path": "ts/components/PreferencesDonations.tsx", + "path": "ts/components/PreferencesDonateFlow.tsx", "line": " const tryClose = useRef<() => void | undefined>();", "reasonCategory": "usageTrusted", - "updated": "2025-06-10T19:00:29.489Z", + "updated": "2025-06-26T23:23:57.292Z", "reasonDetail": "Holding on to a close function" }, { diff --git a/ts/util/signalRoutes.ts b/ts/util/signalRoutes.ts index d2b1addde5..8be1a4beb6 100644 --- a/ts/util/signalRoutes.ts +++ b/ts/util/signalRoutes.ts @@ -53,6 +53,7 @@ type AllHostnamePatterns = | 'start-call-lobby' | 'show-window' | 'cancel-presenting' + | 'donation-validation-complete' | ':captchaId(.+)' | ''; @@ -543,6 +544,42 @@ export const cancelPresentingRoute = _route('cancelPresenting', { }, }); +/** + * Resume donation workflow after completing 3ds validation + * @example + * ```ts + * donationValidationCompleteRoute.toAppUrl({ + * token: "123", + * }) + * // URL { "sgnl://donation-validation-complete?token=123" } + * ``` + */ +export const donationValidationCompleteRoute = _route( + 'donationValidationComplete', + { + patterns: [ + _pattern('sgnl:', 'donation-validation-complete', '{/}?', { + search: ':params', + }), + ], + schema: z.object({ + token: paramSchema, + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + token: params.get('token'), + }; + }, + toAppUrl(args) { + const params = new URLSearchParams({ token: args.token }); + return new URL( + `sgnl://donation-validation-complete?${params.toString()}` + ); + }, + } +); + /** * Should include all routes for matching purposes. * @internal @@ -559,6 +596,7 @@ const _allSignalRoutes = [ startCallLobbyRoute, showWindowRoute, cancelPresentingRoute, + donationValidationCompleteRoute, ] as const; strictAssert( diff --git a/ts/windows/main/phase2-dependencies.ts b/ts/windows/main/phase2-dependencies.ts index 528124ef4d..088f6619d6 100644 --- a/ts/windows/main/phase2-dependencies.ts +++ b/ts/windows/main/phase2-dependencies.ts @@ -43,6 +43,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({ proxyUrl: config.proxyUrl, version: config.version, disableIPv6: config.disableIPv6, + stripePublishableKey: config.stripePublishableKey, }); window.libphonenumberInstance = PhoneNumberUtil.getInstance(); diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index 6f18039ce2..b65e3cc229 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -126,6 +126,7 @@ window.testUtilities = { stories: [], storyDistributionLists: [], donations: { + currentWorkflow: undefined, receipts: [], }, stickers: {