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'),
|
registrationChallengeUrl: config.get<string>('registrationChallengeUrl'),
|
||||||
serverPublicParams: config.get<string>('serverPublicParams'),
|
serverPublicParams: config.get<string>('serverPublicParams'),
|
||||||
serverTrustRoot: config.get<string>('serverTrustRoot'),
|
serverTrustRoot: config.get<string>('serverTrustRoot'),
|
||||||
|
stripePublishableKey: config.get<string>('stripePublishableKey'),
|
||||||
genericServerPublicParams: config.get<string>('genericServerPublicParams'),
|
genericServerPublicParams: config.get<string>('genericServerPublicParams'),
|
||||||
backupServerPublicParams: config.get<string>('backupServerPublicParams'),
|
backupServerPublicParams: config.get<string>('backupServerPublicParams'),
|
||||||
theme,
|
theme,
|
||||||
|
@ -2914,6 +2915,8 @@ function handleSignalRoute(route: ParsedSignalRoute) {
|
||||||
challengeHandler.handleCaptcha(route.args.captchaId);
|
challengeHandler.handleCaptcha(route.args.captchaId);
|
||||||
// Show window after handling captcha
|
// Show window after handling captcha
|
||||||
showWindow();
|
showWindow();
|
||||||
|
} else if (route.key === 'donationValidationComplete') {
|
||||||
|
log.info('donationValidationComplete route handled');
|
||||||
} else {
|
} else {
|
||||||
log.info('handleSignalRoute: Unknown signal route:', route.key);
|
log.info('handleSignalRoute: Unknown signal route:', route.key);
|
||||||
mainWindow.webContents.send('unknown-sgnl-link');
|
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==",
|
"serverPublicParams": "ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==",
|
||||||
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx",
|
"serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx",
|
||||||
"genericServerPublicParams": "AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N",
|
"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",
|
"serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF",
|
||||||
"genericServerPublicParams": "AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN",
|
"genericServerPublicParams": "AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN",
|
||||||
"backupServerPublicParams": "AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O",
|
"backupServerPublicParams": "AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O",
|
||||||
|
"stripePublishableKey": "pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D",
|
||||||
"updatesEnabled": true
|
"updatesEnabled": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -170,7 +170,18 @@ function RenderProfileEditor(): JSX.Element {
|
||||||
|
|
||||||
function RenderDonationsPane(): JSX.Element {
|
function RenderDonationsPane(): JSX.Element {
|
||||||
const contentsRef = useRef<HTMLDivElement | null>(null);
|
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 {
|
function renderToastManager(): JSX.Element {
|
||||||
|
|
|
@ -203,6 +203,8 @@ type PropsFunctionType = {
|
||||||
// Render props
|
// Render props
|
||||||
renderDonationsPane: (options: {
|
renderDonationsPane: (options: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
page: Page;
|
||||||
|
setPage: (page: Page) => void;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
renderProfileEditor: (options: {
|
renderProfileEditor: (options: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
@ -326,6 +328,7 @@ export enum Page {
|
||||||
// Sub pages
|
// Sub pages
|
||||||
ChatColor = 'ChatColor',
|
ChatColor = 'ChatColor',
|
||||||
ChatFolders = 'ChatFolders',
|
ChatFolders = 'ChatFolders',
|
||||||
|
DonationsDonateFlow = 'DonationsDonateFlow',
|
||||||
EditChatFolder = 'EditChatFolder',
|
EditChatFolder = 'EditChatFolder',
|
||||||
PNP = 'PNP',
|
PNP = 'PNP',
|
||||||
BackupsDetails = 'BackupsDetails',
|
BackupsDetails = 'BackupsDetails',
|
||||||
|
@ -593,6 +596,12 @@ export function Preferences({
|
||||||
if (page === Page.Backups && !shouldShowBackupsPage) {
|
if (page === Page.Backups && !shouldShowBackupsPage) {
|
||||||
setPage(Page.General);
|
setPage(Page.General);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(page === Page.Donations || page === Page.DonationsDonateFlow) &&
|
||||||
|
!donationsFeatureEnabled
|
||||||
|
) {
|
||||||
|
setPage(Page.General);
|
||||||
|
}
|
||||||
if (page === Page.Internal && !isInternalUser) {
|
if (page === Page.Internal && !isInternalUser) {
|
||||||
setPage(Page.General);
|
setPage(Page.General);
|
||||||
}
|
}
|
||||||
|
@ -888,9 +897,11 @@ export function Preferences({
|
||||||
title={i18n('icu:Preferences__button--general')}
|
title={i18n('icu:Preferences__button--general')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === Page.Donations) {
|
} else if (page === Page.Donations || page === Page.DonationsDonateFlow) {
|
||||||
content = renderDonationsPane({
|
content = renderDonationsPane({
|
||||||
contentsRef: settingsPaneRef,
|
contentsRef: settingsPaneRef,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
});
|
});
|
||||||
} else if (page === Page.Appearance) {
|
} else if (page === Page.Appearance) {
|
||||||
let zoomFactors = DEFAULT_ZOOM_FACTORS;
|
let zoomFactors = DEFAULT_ZOOM_FACTORS;
|
||||||
|
@ -2354,7 +2365,9 @@ export function Preferences({
|
||||||
className={classNames({
|
className={classNames({
|
||||||
Preferences__button: true,
|
Preferences__button: true,
|
||||||
'Preferences__button--appearance': true,
|
'Preferences__button--appearance': true,
|
||||||
'Preferences__button--selected': page === Page.Donations,
|
'Preferences__button--selected':
|
||||||
|
page === Page.Donations ||
|
||||||
|
page === Page.DonationsDonateFlow,
|
||||||
})}
|
})}
|
||||||
onClick={() => setPage(Page.Donations)}
|
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
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useRef } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
import { Page, PreferencesContent } from './Preferences';
|
||||||
import { PreferencesContent } from './Preferences';
|
import { Button, ButtonVariant } from './Button';
|
||||||
|
import { PreferencesDonateFlow } from './PreferencesDonateFlow';
|
||||||
|
import type { CardDetail, DonationWorkflow } from '../types/Donations';
|
||||||
|
|
||||||
type PropsExternalType = {
|
type PropsExternalType = {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
@ -15,61 +17,64 @@ type PropsExternalType = {
|
||||||
|
|
||||||
export type PropsDataType = {
|
export type PropsDataType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
isStaging: boolean;
|
||||||
|
page: Page;
|
||||||
|
workflow: DonationWorkflow | undefined;
|
||||||
};
|
};
|
||||||
// type PropsActionType = {};
|
|
||||||
|
|
||||||
export type PropsType = PropsDataType /* & PropsActionType */ &
|
type PropsActionType = {
|
||||||
PropsExternalType;
|
clearWorkflow: () => void;
|
||||||
|
setPage: (page: Page) => void;
|
||||||
|
submitDonation: (options: {
|
||||||
|
currencyType: string;
|
||||||
|
paymentAmount: number;
|
||||||
|
paymentDetail: CardDetail;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
|
||||||
|
|
||||||
export function PreferencesDonations({
|
export function PreferencesDonations({
|
||||||
contentsRef,
|
contentsRef,
|
||||||
i18n,
|
i18n,
|
||||||
|
isStaging,
|
||||||
|
page,
|
||||||
|
workflow,
|
||||||
|
clearWorkflow,
|
||||||
|
setPage,
|
||||||
|
submitDonation,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const tryClose = useRef<() => void | undefined>();
|
if (page === Page.DonationsDonateFlow) {
|
||||||
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
return (
|
||||||
i18n,
|
<PreferencesDonateFlow
|
||||||
name: 'PreferencesDonations',
|
contentsRef={contentsRef}
|
||||||
tryClose,
|
i18n={i18n}
|
||||||
});
|
workflow={workflow}
|
||||||
|
clearWorkflow={clearWorkflow}
|
||||||
|
onBack={() => setPage(Page.Donations)}
|
||||||
|
submitDonation={submitDonation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: only show back button when on a sub-page.
|
const content = (
|
||||||
// See ProfileEditor for its approach, an enum describing the current page
|
<div className="PreferencesDonations">
|
||||||
// Note that we would want to then add that to nav/location stuff
|
{isStaging && (
|
||||||
const backButton = (
|
<Button
|
||||||
<button
|
onClick={() => setPage(Page.DonationsDonateFlow)}
|
||||||
aria-label={i18n('icu:goBack')}
|
variant={ButtonVariant.Primary}
|
||||||
className="Preferences__back-icon"
|
>
|
||||||
onClick={() => /* TODO */ undefined}
|
Donate
|
||||||
type="button"
|
</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 (
|
return (
|
||||||
<>
|
<PreferencesContent
|
||||||
{confirmDiscardModal}
|
contents={content}
|
||||||
|
contentsRef={contentsRef}
|
||||||
<PreferencesContent
|
title={i18n('icu:Preferences__DonateTitle')}
|
||||||
backButton={backButton}
|
/>
|
||||||
contents={<div className="PreferencesDonations">{content}</div>}
|
|
||||||
contentsRef={contentsRef}
|
|
||||||
title={i18n('icu:Preferences__DonateTitle')}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ export function getDonationReceiptsForRedux(): DonationsStateType {
|
||||||
'donation receipts have not been loaded'
|
'donation receipts have not been loaded'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
currentWorkflow: undefined,
|
||||||
receipts: donationReceipts,
|
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 { calling } from './services/calling';
|
||||||
import * as storage from './services/storage';
|
import * as storage from './services/storage';
|
||||||
import { backupsService } from './services/backups';
|
import { backupsService } from './services/backups';
|
||||||
|
import * as donations from './services/donations';
|
||||||
|
|
||||||
import type { LoggerType } from './types/Logging';
|
import type { LoggerType } from './types/Logging';
|
||||||
import type {
|
import type {
|
||||||
|
@ -465,6 +466,7 @@ export const setup = (options: {
|
||||||
initializeUpdateListener,
|
initializeUpdateListener,
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
|
donations,
|
||||||
storage,
|
storage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -10,32 +10,63 @@ import * as Errors from '../../types/errors';
|
||||||
import { isStagingServer } from '../../util/isStagingServer';
|
import { isStagingServer } from '../../util/isStagingServer';
|
||||||
|
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
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 type { StateType as RootStateType } from '../reducer';
|
||||||
import { DataWriter } from '../../sql/Client';
|
import { DataWriter } from '../../sql/Client';
|
||||||
|
import * as donations from '../../services/donations';
|
||||||
|
|
||||||
const log = createLogger('donations');
|
const log = createLogger('donations');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type DonationsStateType = ReadonlyDeep<{
|
export type DonationsStateType = ReadonlyDeep<{
|
||||||
|
currentWorkflow: DonationWorkflow | undefined;
|
||||||
receipts: Array<DonationReceipt>;
|
receipts: Array<DonationReceipt>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
export const ADD_RECEIPT = 'donations/ADD_RECEIPT';
|
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<{
|
export type AddReceiptAction = ReadonlyDeep<{
|
||||||
type: typeof ADD_RECEIPT;
|
type: typeof ADD_RECEIPT;
|
||||||
payload: { receipt: DonationReceipt };
|
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
|
// Action Creators
|
||||||
|
|
||||||
export function internalAddDonationReceipt(
|
export function addReceipt(receipt: DonationReceipt): AddReceiptAction {
|
||||||
|
return {
|
||||||
|
type: ADD_RECEIPT,
|
||||||
|
payload: { receipt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalAddDonationReceipt(
|
||||||
receipt: DonationReceipt
|
receipt: DonationReceipt
|
||||||
): ThunkAction<void, RootStateType, unknown, AddReceiptAction> {
|
): ThunkAction<void, RootStateType, unknown, AddReceiptAction> {
|
||||||
return async dispatch => {
|
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 = {
|
export const actions = {
|
||||||
|
addReceipt,
|
||||||
|
clearWorkflow,
|
||||||
internalAddDonationReceipt,
|
internalAddDonationReceipt,
|
||||||
|
submitDonation,
|
||||||
|
updateWorkflow,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDonationsActions = (): BoundActionCreatorsMapObject<
|
export const useDonationsActions = (): BoundActionCreatorsMapObject<
|
||||||
|
@ -70,6 +148,7 @@ export const useDonationsActions = (): BoundActionCreatorsMapObject<
|
||||||
|
|
||||||
export function getEmptyState(): DonationsStateType {
|
export function getEmptyState(): DonationsStateType {
|
||||||
return {
|
return {
|
||||||
|
currentWorkflow: undefined,
|
||||||
receipts: [],
|
receipts: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -85,5 +164,12 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === UPDATE_WORKFLOW) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentWorkflow: action.payload.nextWorkflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,10 +100,22 @@ function renderToastManager(props: {
|
||||||
return <SmartToastManager disableMegaphone {...props} />;
|
return <SmartToastManager disableMegaphone {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDonationsPane(options: {
|
function renderDonationsPane({
|
||||||
|
contentsRef,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
page: Page;
|
||||||
|
setPage: (page: Page) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return <SmartPreferencesDonations contentsRef={options.contentsRef} />;
|
return (
|
||||||
|
<SmartPreferencesDonations
|
||||||
|
contentsRef={contentsRef}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemTraySettingValues(
|
function getSystemTraySettingValues(
|
||||||
|
|
|
@ -8,13 +8,39 @@ import type { MutableRefObject } from 'react';
|
||||||
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { PreferencesDonations } from '../../components/PreferencesDonations';
|
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(
|
export const SmartPreferencesDonations = memo(
|
||||||
function SmartPreferencesDonations(props: {
|
function SmartPreferencesDonations({
|
||||||
|
contentsRef,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
}: {
|
||||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
page: Page;
|
||||||
|
setPage: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isStaging = isStagingServer();
|
||||||
const i18n = useSelector(getIntl);
|
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 { v4 as getGuid } from 'uuid';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Readable } from 'stream';
|
import type { Readable } from 'stream';
|
||||||
|
import qs from 'querystring';
|
||||||
import type {
|
import type {
|
||||||
KEMPublicKey,
|
KEMPublicKey,
|
||||||
PublicKey,
|
PublicKey,
|
||||||
|
@ -92,6 +93,7 @@ import type { ServerAlert } from '../util/handleServerAlerts';
|
||||||
import { isAbortError } from '../util/isAbortError';
|
import { isAbortError } from '../util/isAbortError';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
|
import type { CardDetail } from '../types/Donations';
|
||||||
|
|
||||||
const log = createLogger('WebAPI');
|
const log = createLogger('WebAPI');
|
||||||
|
|
||||||
|
@ -101,6 +103,8 @@ const log = createLogger('WebAPI');
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
const DEFAULT_TIMEOUT = 30 * SECOND;
|
const DEFAULT_TIMEOUT = 30 * SECOND;
|
||||||
|
|
||||||
|
const CONTENT_TYPE_FORM_ENCODING = 'application/x-www-form-urlencoded';
|
||||||
|
|
||||||
function _createRedactor(
|
function _createRedactor(
|
||||||
...toReplace: ReadonlyArray<string | undefined | null>
|
...toReplace: ReadonlyArray<string | undefined | null>
|
||||||
): RedactUrl {
|
): RedactUrl {
|
||||||
|
@ -687,8 +691,10 @@ const CHAT_CALLS = {
|
||||||
attachmentUploadForm: 'v4/attachments/form/upload',
|
attachmentUploadForm: 'v4/attachments/form/upload',
|
||||||
attestation: 'v1/attestation',
|
attestation: 'v1/attestation',
|
||||||
batchIdentityCheck: 'v1/profile/identity_check/batch',
|
batchIdentityCheck: 'v1/profile/identity_check/batch',
|
||||||
|
boostReceiptCredentials: 'v1/subscription/boost/receipt_credentials',
|
||||||
challenge: 'v1/challenge',
|
challenge: 'v1/challenge',
|
||||||
config: 'v1/config',
|
config: 'v1/config',
|
||||||
|
createBoost: 'v1/subscription/boost/create',
|
||||||
deliveryCert: 'v1/certificate/delivery',
|
deliveryCert: 'v1/certificate/delivery',
|
||||||
devices: 'v1/devices',
|
devices: 'v1/devices',
|
||||||
directoryAuthV2: 'v2/directory/auth',
|
directoryAuthV2: 'v2/directory/auth',
|
||||||
|
@ -711,6 +717,7 @@ const CHAT_CALLS = {
|
||||||
backupMediaBatch: 'v1/archives/media/batch',
|
backupMediaBatch: 'v1/archives/media/batch',
|
||||||
backupMediaDelete: 'v1/archives/media/delete',
|
backupMediaDelete: 'v1/archives/media/delete',
|
||||||
callLinkCreateAuth: 'v1/call-link/create-auth',
|
callLinkCreateAuth: 'v1/call-link/create-auth',
|
||||||
|
redeemReceipt: 'v1/donation/redeem-receipt',
|
||||||
registration: 'v1/registration',
|
registration: 'v1/registration',
|
||||||
registerCapabilities: 'v1/devices/capabilities',
|
registerCapabilities: 'v1/devices/capabilities',
|
||||||
reportMessage: 'v1/messages/report',
|
reportMessage: 'v1/messages/report',
|
||||||
|
@ -762,6 +769,7 @@ type InitializeOptionsType = {
|
||||||
proxyUrl: string | undefined;
|
proxyUrl: string | undefined;
|
||||||
version: string;
|
version: string;
|
||||||
disableIPv6: boolean;
|
disableIPv6: boolean;
|
||||||
|
stripePublishableKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageType = Readonly<{
|
export type MessageType = Readonly<{
|
||||||
|
@ -1139,6 +1147,71 @@ export type CreateAccountResultType = Readonly<{
|
||||||
pni: Pni;
|
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<{
|
export type RequestVerificationResultType = Readonly<{
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}>;
|
}>;
|
||||||
|
@ -1473,6 +1546,15 @@ export type WebAPIType = {
|
||||||
createAccount: (
|
createAccount: (
|
||||||
options: CreateAccountOptionsType
|
options: CreateAccountOptionsType
|
||||||
) => Promise<CreateAccountResultType>;
|
) => Promise<CreateAccountResultType>;
|
||||||
|
confirmIntentWithStripe: (
|
||||||
|
options: ConfirmIntentWithStripeOptionsType
|
||||||
|
) => Promise<ConfirmIntentWithStripeResultType>;
|
||||||
|
createPaymentMethodWithStripe: (
|
||||||
|
options: CreatePaymentMethodWithStripeOptionsType
|
||||||
|
) => Promise<CreatePaymentMethodWithStripeResultType>;
|
||||||
|
createBoostPaymentIntent: (
|
||||||
|
options: CreateBoostOptionsType
|
||||||
|
) => Promise<CreateBoostResultType>;
|
||||||
createGroup: (
|
createGroup: (
|
||||||
group: Proto.IGroup,
|
group: Proto.IGroup,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
@ -1497,6 +1579,10 @@ export type WebAPIType = {
|
||||||
}) => Promise<Readable>;
|
}) => Promise<Readable>;
|
||||||
getAttachmentUploadForm: () => Promise<AttachmentUploadFormResponseType>;
|
getAttachmentUploadForm: () => Promise<AttachmentUploadFormResponseType>;
|
||||||
getAvatar: (path: string) => Promise<Uint8Array>;
|
getAvatar: (path: string) => Promise<Uint8Array>;
|
||||||
|
createBoostReceiptCredentials: (
|
||||||
|
options: CreateBoostReceiptCredentialsOptionsType
|
||||||
|
) => Promise<CreateBoostReceiptCredentialsResultType>;
|
||||||
|
redeemReceipt: (options: RedeemReceiptOptionsType) => Promise<void>;
|
||||||
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
|
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
|
||||||
getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>;
|
getGroup: (options: GroupCredentialsType) => Promise<Proto.IGroupResponse>;
|
||||||
getGroupFromLink: (
|
getGroupFromLink: (
|
||||||
|
@ -1866,6 +1952,7 @@ export function initialize({
|
||||||
contentProxyUrl,
|
contentProxyUrl,
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
version,
|
version,
|
||||||
|
stripePublishableKey,
|
||||||
}: InitializeOptionsType): WebAPIConnectType {
|
}: InitializeOptionsType): WebAPIConnectType {
|
||||||
if (!isString(chatServiceUrl)) {
|
if (!isString(chatServiceUrl)) {
|
||||||
throw new Error('WebAPI.initialize: Invalid chatServiceUrl');
|
throw new Error('WebAPI.initialize: Invalid chatServiceUrl');
|
||||||
|
@ -1903,6 +1990,9 @@ export function initialize({
|
||||||
if (!isString(version)) {
|
if (!isString(version)) {
|
||||||
throw new Error('WebAPI.initialize: Invalid 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
|
// 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
|
// can query them later, which is necessary if they arrive before app state is ready
|
||||||
|
@ -2051,8 +2141,12 @@ export function initialize({
|
||||||
createAccount,
|
createAccount,
|
||||||
callLinkCreateAuth,
|
callLinkCreateAuth,
|
||||||
createFetchForAttachmentUpload,
|
createFetchForAttachmentUpload,
|
||||||
|
confirmIntentWithStripe,
|
||||||
confirmUsername,
|
confirmUsername,
|
||||||
|
createBoostPaymentIntent,
|
||||||
|
createBoostReceiptCredentials,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
createPaymentMethodWithStripe,
|
||||||
deleteUsername,
|
deleteUsername,
|
||||||
deleteUsernameLink,
|
deleteUsernameLink,
|
||||||
downloadOnboardingStories,
|
downloadOnboardingStories,
|
||||||
|
@ -2123,6 +2217,7 @@ export function initialize({
|
||||||
putProfile,
|
putProfile,
|
||||||
putStickers,
|
putStickers,
|
||||||
reconnect,
|
reconnect,
|
||||||
|
redeemReceipt,
|
||||||
refreshBackup,
|
refreshBackup,
|
||||||
registerCapabilities,
|
registerCapabilities,
|
||||||
registerKeys,
|
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({
|
async function getReleaseNoteHash({
|
||||||
uuid,
|
uuid,
|
||||||
locale,
|
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(
|
async function createGroup(
|
||||||
group: Proto.IGroup,
|
group: Proto.IGroup,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
|
|
@ -3,43 +3,39 @@
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const donationStateSchema = z.enum([
|
export const donationStateSchema = z.enum([
|
||||||
'INTENT',
|
'INTENT',
|
||||||
'INTENT_METHOD',
|
'INTENT_METHOD',
|
||||||
'INTENT_CONFIRMED',
|
'INTENT_CONFIRMED',
|
||||||
'INTENT_REDIRECT',
|
'INTENT_REDIRECT',
|
||||||
'RECEIPT',
|
'RECEIPT',
|
||||||
'RECEIPT_REDEEMED',
|
'RECEIPT_REDEEMED',
|
||||||
|
'DONE',
|
||||||
]);
|
]);
|
||||||
export type DonationState = z.infer<typeof donationStateSchema>;
|
|
||||||
|
|
||||||
const paymentTypeSchema = z.enum(['CARD', 'PAYPAL']);
|
export const paymentTypeSchema = z.enum(['CARD']);
|
||||||
export type PaymentType = z.infer<typeof paymentTypeSchema>;
|
|
||||||
|
|
||||||
const coreDataSchema = z.object({
|
const coreDataSchema = z.object({
|
||||||
// guid used to prevent duplicates at stripe and in our db.
|
// 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
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
||||||
// the code, like USD
|
// Currency code, like USD
|
||||||
currencyType: z.string(),
|
currencyType: z.string(),
|
||||||
|
|
||||||
// cents as whole numbers, so multiply by 100
|
// Cents as whole numbers, so multiply by 100
|
||||||
paymentAmount: z.number(),
|
paymentAmount: z.number(),
|
||||||
|
|
||||||
// The last time we transitioned into a new state. So the timestamp shown to the user
|
// 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(),
|
timestamp: z.number(),
|
||||||
});
|
});
|
||||||
export type CoreData = z.infer<typeof coreDataSchema>;
|
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({
|
const paymentDetailSchema = z.object({
|
||||||
paymentType: z.literal(paymentTypeSchema.Enum.CARD),
|
paymentType: z.literal(paymentTypeSchema.Enum.CARD),
|
||||||
|
|
||||||
// Note: we really don't want this to be null, but sometimes it won't parse, and in
|
// Note: we really don't want this to be null, but sometimes it won't parse from the DB,
|
||||||
// that case we still move forward and display the receipt best we can.
|
// and in that case we still move forward and display the receipt best we can.
|
||||||
paymentDetail: z
|
paymentDetail: z
|
||||||
.object({
|
.object({
|
||||||
lastFourDigits: z.string(),
|
lastFourDigits: z.string(),
|
||||||
|
@ -48,12 +44,155 @@ const paymentDetailSchema = z.object({
|
||||||
});
|
});
|
||||||
export type PaymentDetails = z.infer<typeof paymentDetailSchema>;
|
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(
|
export const donationReceiptSchema = z.intersection(
|
||||||
z.object({
|
z.object({
|
||||||
...coreDataSchema.shape,
|
...coreDataSchema.shape,
|
||||||
}),
|
}),
|
||||||
// This type will demand the z.intersection when it is a discriminatedUnion. When
|
// TODO: This type will demand the z.intersection when it is a discriminatedUnion.
|
||||||
// it is a discriminatedUnion, we can't use the ...schema.shape approach
|
// When it is a discriminatedUnion, we can't use the ...schema.shape approach
|
||||||
paymentDetailSchema
|
paymentDetailSchema
|
||||||
);
|
);
|
||||||
export type DonationReceipt = z.infer<typeof donationReceiptSchema>;
|
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,
|
serverUrl: configRequiredStringSchema,
|
||||||
sfuUrl: configRequiredStringSchema,
|
sfuUrl: configRequiredStringSchema,
|
||||||
storageUrl: configRequiredStringSchema,
|
storageUrl: configRequiredStringSchema,
|
||||||
|
stripePublishableKey: configRequiredStringSchema,
|
||||||
theme: themeSettingSchema,
|
theme: themeSettingSchema,
|
||||||
updatesUrl: configRequiredStringSchema,
|
updatesUrl: configRequiredStringSchema,
|
||||||
resourcesUrl: configRequiredStringSchema,
|
resourcesUrl: configRequiredStringSchema,
|
||||||
|
|
|
@ -1480,10 +1480,10 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/PreferencesDonations.tsx",
|
"path": "ts/components/PreferencesDonateFlow.tsx",
|
||||||
"line": " const tryClose = useRef<() => void | undefined>();",
|
"line": " const tryClose = useRef<() => void | undefined>();",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2025-06-10T19:00:29.489Z",
|
"updated": "2025-06-26T23:23:57.292Z",
|
||||||
"reasonDetail": "Holding on to a close function"
|
"reasonDetail": "Holding on to a close function"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -53,6 +53,7 @@ type AllHostnamePatterns =
|
||||||
| 'start-call-lobby'
|
| 'start-call-lobby'
|
||||||
| 'show-window'
|
| 'show-window'
|
||||||
| 'cancel-presenting'
|
| 'cancel-presenting'
|
||||||
|
| 'donation-validation-complete'
|
||||||
| ':captchaId(.+)'
|
| ':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.
|
* Should include all routes for matching purposes.
|
||||||
* @internal
|
* @internal
|
||||||
|
@ -559,6 +596,7 @@ const _allSignalRoutes = [
|
||||||
startCallLobbyRoute,
|
startCallLobbyRoute,
|
||||||
showWindowRoute,
|
showWindowRoute,
|
||||||
cancelPresentingRoute,
|
cancelPresentingRoute,
|
||||||
|
donationValidationCompleteRoute,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
strictAssert(
|
strictAssert(
|
||||||
|
|
|
@ -43,6 +43,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({
|
||||||
proxyUrl: config.proxyUrl,
|
proxyUrl: config.proxyUrl,
|
||||||
version: config.version,
|
version: config.version,
|
||||||
disableIPv6: config.disableIPv6,
|
disableIPv6: config.disableIPv6,
|
||||||
|
stripePublishableKey: config.stripePublishableKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.libphonenumberInstance = PhoneNumberUtil.getInstance();
|
window.libphonenumberInstance = PhoneNumberUtil.getInstance();
|
||||||
|
|
|
@ -126,6 +126,7 @@ window.testUtilities = {
|
||||||
stories: [],
|
stories: [],
|
||||||
storyDistributionLists: [],
|
storyDistributionLists: [],
|
||||||
donations: {
|
donations: {
|
||||||
|
currentWorkflow: undefined,
|
||||||
receipts: [],
|
receipts: [],
|
||||||
},
|
},
|
||||||
stickers: {
|
stickers: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue