Initial workflow for donations
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
f62c53fdee
commit
f2241cf613
20 changed files with 1230 additions and 76 deletions
|
@ -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');
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)}
|
||||
>
|
||||
|
|
177
ts/components/PreferencesDonateFlow.tsx
Normal file
177
ts/components/PreferencesDonateFlow.tsx
Normal 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')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
423
ts/services/donations.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -70,6 +70,7 @@ export const rendererConfigSchema = z.object({
|
|||
serverUrl: configRequiredStringSchema,
|
||||
sfuUrl: configRequiredStringSchema,
|
||||
storageUrl: configRequiredStringSchema,
|
||||
stripePublishableKey: configRequiredStringSchema,
|
||||
theme: themeSettingSchema,
|
||||
updatesUrl: configRequiredStringSchema,
|
||||
resourcesUrl: configRequiredStringSchema,
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -126,6 +126,7 @@ window.testUtilities = {
|
|||
stories: [],
|
||||
storyDistributionLists: [],
|
||||
donations: {
|
||||
currentWorkflow: undefined,
|
||||
receipts: [],
|
||||
},
|
||||
stickers: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue