Initial workflow for donations

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
ayumi-signal 2025-06-27 13:48:50 -07:00 committed by GitHub
parent f62c53fdee
commit f2241cf613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1230 additions and 76 deletions

View file

@ -2746,6 +2746,7 @@ ipc.on('get-config', async event => {
registrationChallengeUrl: config.get<string>('registrationChallengeUrl'),
serverPublicParams: config.get<string>('serverPublicParams'),
serverTrustRoot: config.get<string>('serverTrustRoot'),
stripePublishableKey: config.get<string>('stripePublishableKey'),
genericServerPublicParams: config.get<string>('genericServerPublicParams'),
backupServerPublicParams: config.get<string>('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');

View file

@ -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"
}

View file

@ -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
}

View file

@ -170,7 +170,18 @@ function RenderProfileEditor(): JSX.Element {
function RenderDonationsPane(): JSX.Element {
const contentsRef = useRef<HTMLDivElement | null>(null);
return <PreferencesDonations i18n={i18n} contentsRef={contentsRef} />;
return (
<PreferencesDonations
i18n={i18n}
contentsRef={contentsRef}
clearWorkflow={action('clearWorkflow')}
isStaging={false}
page={Page.Donations}
setPage={action('setPage')}
submitDonation={action('submitDonation')}
workflow={undefined}
/>
);
}
function renderToastManager(): JSX.Element {

View file

@ -203,6 +203,8 @@ type PropsFunctionType = {
// Render props
renderDonationsPane: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: Page;
setPage: (page: Page) => void;
}) => JSX.Element;
renderProfileEditor: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
@ -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)}
>

View file

@ -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<HTMLDivElement | null>;
};
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 = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={onBack}
type="button"
/>
);
const onTryClose = useCallback(() => {
const onDiscard = () => {
// TODO: DESKTOP-8950
};
confirmDiscardIf(true, onDiscard);
}, [confirmDiscardIf]);
tryClose.current = onTryClose;
const content = (
<div className="PreferencesDonations">
{workflow && (
<div>
<h2>Current Workflow</h2>
<blockquote>{JSON.stringify(workflow)}</blockquote>
<Button onClick={clearWorkflow} variant={ButtonVariant.Destructive}>
Reset
</Button>
</div>
)}
<label htmlFor="amount">Amount (USD)</label>
<Input
id="amount"
i18n={i18n}
onChange={value => setAmount(value)}
placeholder="5"
value={amount}
/>
<label htmlFor="cardNumber">Card Number</label>
<Input
id="cardNumber"
i18n={i18n}
onChange={value => setCardNumber(value)}
placeholder="0000000000000000"
maxLengthCount={16}
value={cardNumber}
/>
<label htmlFor="cardExpirationMonth">Expiration Month</label>
<Input
id="cardExpirationMonth"
i18n={i18n}
onChange={value => setCardExpirationMonth(value)}
placeholder="MM"
value={cardExpirationMonth}
/>
<label htmlFor="cardExpirationYear">Expiration Year</label>
<Input
id="cardExpirationYear"
i18n={i18n}
onChange={value => setCardExpirationYear(value)}
placeholder="YY"
value={cardExpirationYear}
/>
<label htmlFor="cardCvc">Cvc</label>
<Input
id="cardCvc"
i18n={i18n}
onChange={value => setCardCvc(value)}
placeholder="123"
value={cardCvc}
/>
<Button
disabled={isDonateDisabled}
onClick={handleDonateClicked}
variant={ButtonVariant.Primary}
>
Donate $10
</Button>
</div>
);
return (
<>
{confirmDiscardModal}
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={i18n('icu:Preferences__DonateTitle')}
/>
</>
);
}

View file

@ -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<HTMLDivElement | null>;
@ -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 (
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
workflow={workflow}
clearWorkflow={clearWorkflow}
onBack={() => 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 = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => /* TODO */ undefined}
type="button"
/>
const content = (
<div className="PreferencesDonations">
{isStaging && (
<Button
onClick={() => setPage(Page.DonationsDonateFlow)}
variant={ButtonVariant.Primary}
>
Donate
</Button>
)}
</div>
);
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}
<PreferencesContent
backButton={backButton}
contents={<div className="PreferencesDonations">{content}</div>}
contentsRef={contentsRef}
title={i18n('icu:Preferences__DonateTitle')}
/>
</>
<PreferencesContent
contents={content}
contentsRef={contentsRef}
title={i18n('icu:Preferences__DonateTitle')}
/>
);
}

View file

@ -19,6 +19,7 @@ export function getDonationReceiptsForRedux(): DonationsStateType {
'donation receipts have not been loaded'
);
return {
currentWorkflow: undefined,
receipts: donationReceipts,
};
}

423
ts/services/donations.ts Normal file
View file

@ -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<void> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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<DonationWorkflow> {
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,
};
}

View file

@ -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,
};

View file

@ -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<DonationReceipt>;
}>;
// 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<AddReceiptAction>;
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<void, RootStateType, unknown, AddReceiptAction> {
return async dispatch => {
@ -58,8 +89,55 @@ export function internalAddDonationReceipt(
};
}
function submitDonation({
currencyType,
paymentAmount,
paymentDetail,
}: {
currencyType: string;
paymentAmount: number;
paymentDetail: CardDetail;
}): ThunkAction<void, RootStateType, unknown, UpdateWorkflowAction> {
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;
}

View file

@ -100,10 +100,22 @@ function renderToastManager(props: {
return <SmartToastManager disableMegaphone {...props} />;
}
function renderDonationsPane(options: {
function renderDonationsPane({
contentsRef,
page,
setPage,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: Page;
setPage: (page: Page) => void;
}): JSX.Element {
return <SmartPreferencesDonations contentsRef={options.contentsRef} />;
return (
<SmartPreferencesDonations
contentsRef={contentsRef}
page={page}
setPage={setPage}
/>
);
}
function getSystemTraySettingValues(

View file

@ -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<HTMLDivElement | null>;
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 <PreferencesDonations contentsRef={props.contentsRef} i18n={i18n} />;
return (
<PreferencesDonations
contentsRef={contentsRef}
i18n={i18n}
isStaging={isStaging}
page={page}
workflow={workflow}
clearWorkflow={clearWorkflow}
submitDonation={submitDonation}
setPage={setPage}
/>
);
}
);

View file

@ -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<string | undefined | null>
): 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<typeof CreateBoostResultSchema>;
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<CreateAccountResultType>;
confirmIntentWithStripe: (
options: ConfirmIntentWithStripeOptionsType
) => Promise<ConfirmIntentWithStripeResultType>;
createPaymentMethodWithStripe: (
options: CreatePaymentMethodWithStripeOptionsType
) => Promise<CreatePaymentMethodWithStripeResultType>;
createBoostPaymentIntent: (
options: CreateBoostOptionsType
) => Promise<CreateBoostResultType>;
createGroup: (
group: Proto.IGroup,
options: GroupCredentialsType
@ -1497,6 +1579,10 @@ export type WebAPIType = {
}) => Promise<Readable>;
getAttachmentUploadForm: () => Promise<AttachmentUploadFormResponseType>;
getAvatar: (path: string) => Promise<Uint8Array>;
createBoostReceiptCredentials: (
options: CreateBoostReceiptCredentialsOptionsType
) => Promise<CreateBoostReceiptCredentialsResultType>;
redeemReceipt: (options: RedeemReceiptOptionsType) => Promise<void>;
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>;
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<void> {
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<CreateBoostResultType> {
return _ajax({
unauthenticated: true,
host: 'chatService',
call: 'createBoost',
httpType: 'POST',
jsonData: options,
responseType: 'json',
zodSchema: CreateBoostResultSchema,
});
}
async function createBoostReceiptCredentials(
options: CreateBoostReceiptCredentialsOptionsType
): Promise<CreateBoostReceiptCredentialsResultType> {
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<ConfirmIntentWithStripeResultType> {
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<CreatePaymentMethodWithStripeResultType> {
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

View file

@ -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<typeof donationStateSchema>;
const paymentTypeSchema = z.enum(['CARD', 'PAYPAL']);
export type PaymentType = z.infer<typeof paymentTypeSchema>;
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<typeof coreDataSchema>;
// 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<typeof paymentDetailSchema>;
// 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<typeof stripeDataSchema>;
// 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<typeof receiptContextSchema>;
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<typeof donationReceiptSchema>;
// 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<typeof donationWorkflowSchema>;

View file

@ -70,6 +70,7 @@ export const rendererConfigSchema = z.object({
serverUrl: configRequiredStringSchema,
sfuUrl: configRequiredStringSchema,
storageUrl: configRequiredStringSchema,
stripePublishableKey: configRequiredStringSchema,
theme: themeSettingSchema,
updatesUrl: configRequiredStringSchema,
resourcesUrl: configRequiredStringSchema,

View file

@ -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"
},
{

View file

@ -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(

View file

@ -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();

View file

@ -126,6 +126,7 @@ window.testUtilities = {
stories: [],
storyDistributionLists: [],
donations: {
currentWorkflow: undefined,
receipts: [],
},
stickers: {