Initial donation amount picker

Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2025-07-15 12:31:07 -05:00 committed by GitHub
parent 233d5e6e28
commit 42bcec03cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 855 additions and 186 deletions

View file

@ -8822,6 +8822,10 @@
"messageformat": "Donate",
"description": "Button text to make a donation"
},
"icu:PreferencesDonations__donate-button-with-amount": {
"messageformat": "Donate {formattedCurrencyAmount}",
"description": "Button text to make a donation after selecting a currency amount. Amount includes the currency symbol and is formatted in the locale's standard format. Examples: Donate $10; Donate ¥1000; Donate €10"
},
"icu:PreferencesDonations__mobile-info": {
"messageformat": "Badges and monthly donations can be managed on your mobile device.",
"description": "(Deleted 2025/07/09) Information about donations receipt syncing limitations"

View file

@ -15,7 +15,7 @@ const log = createLogger('parseBadgesFromServer');
const MAX_BADGES = 1000;
const badgeFromServerSchema = z.object({
export const badgeFromServerSchema = z.object({
category: z.string(),
description: z.string(),
id: z.string(),
@ -27,7 +27,7 @@ const badgeFromServerSchema = z.object({
});
// GET /v1/subscription/configuration
const boostBadgesFromServerSchema = z.object({
export const boostBadgesFromServerSchema = z.object({
levels: z.record(
z
.object({

View file

@ -31,7 +31,10 @@ import type { WidthBreakpoint } from './_util';
import type { MessageAttributesType } from '../model-types';
import { PreferencesDonations } from './PreferencesDonations';
import { strictAssert } from '../util/assert';
import type { DonationReceipt } from '../types/Donations';
import type {
DonationReceipt,
OneTimeDonationHumanAmounts,
} from '../types/Donations';
import type { AnyToast } from '../types/Toast';
const { i18n } = window.SignalContext;
@ -106,6 +109,23 @@ const exportLocalBackupResult = {
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169',
};
const donationAmountsConfig = {
jpy: {
minimum: 400,
oneTime: {
'1': [500, 1000, 2000, 3000, 5000, 10000],
'100': [500],
},
},
usd: {
minimum: 3,
oneTime: {
1: [5, 10, 20, 30, 50, 100],
100: [5],
},
},
} as unknown as OneTimeDonationHumanAmounts;
function renderUpdateDialog(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element {
@ -200,6 +220,8 @@ function RenderDonationsPane(props: {
color={props.me.color}
firstName={props.me.firstName}
profileAvatarUrl={props.me.profileAvatarUrl}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={Object.keys(donationAmountsConfig)}
donationReceipts={props.donationReceipts}
saveAttachmentToDisk={props.saveAttachmentToDisk}
generateDonationReceiptBlob={props.generateDonationReceiptBlob}

View file

@ -1,35 +1,63 @@
// 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 React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { LocalizerType } from '../types/Util';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { Button, ButtonVariant } from './Button';
import type { CardDetail, DonationWorkflow } from '../types/Donations';
import type { HumanDonationAmount } from '../types/Donations';
import {
ONE_TIME_DONATION_CONFIG_ID,
type DonationWorkflow,
type OneTimeDonationHumanAmounts,
} from '../types/Donations';
import {
brandHumanDonationAmount,
parseCurrencyString,
toHumanCurrencyString,
toStripeDonationAmount,
} from '../util/currency';
import { Input } from './Input';
import { PreferencesContent } from './Preferences';
import type { SubmitDonationType } from '../state/ducks/donations';
import { Select } from './Select';
export type PropsDataType = {
i18n: LocalizerType;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
workflow: DonationWorkflow | undefined;
};
type PropsHousekeepingType = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
};
type PropsActionType = {
clearWorkflow: () => void;
submitDonation: (options: {
currencyType: string;
paymentAmount: number;
paymentDetail: CardDetail;
}) => void;
submitDonation: (payload: SubmitDonationType) => void;
onBack: () => void;
};
export type PropsType = PropsDataType & PropsActionType;
export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
export function PreferencesDonateFlow({
contentsRef,
i18n,
donationAmountsConfig,
validCurrencies,
workflow,
clearWorkflow,
submitDonation,
onBack,
}: PropsType): JSX.Element {
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
@ -38,20 +66,35 @@ export function PreferencesDonateFlow({
tryClose,
});
const [amount, setAmount] = useState('10.00');
const [step, setStep] = useState<'amount' | 'paymentDetails'>('amount');
const [amount, setAmount] = useState<HumanDonationAmount>();
const [currency, setCurrency] = useState<string>();
const [cardExpirationMonth, setCardExpirationMonth] = useState('');
const [cardExpirationYear, setCardExpirationYear] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardCvc, setCardCvc] = useState('');
const formattedCurrencyAmount = useMemo<string>(() => {
return toHumanCurrencyString({ amount, currency });
}, [amount, currency]);
const handleAmountPickerResult = useCallback((result: AmountPickerResult) => {
const { currency: pickedCurrency, amount: pickedAmount } = result;
setAmount(pickedAmount);
setCurrency(pickedCurrency);
setStep('paymentDetails');
}, []);
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;
if (amount == null || currency == null) {
return;
}
const paymentAmount = toStripeDonationAmount({ amount, currency });
submitDonation({
currencyType: 'USD',
currencyType: currency,
paymentAmount,
paymentDetail: {
expirationMonth: cardExpirationMonth,
@ -66,6 +109,7 @@ export function PreferencesDonateFlow({
cardExpirationMonth,
cardExpirationYear,
cardNumber,
currency,
submitDonation,
]);
@ -80,73 +124,277 @@ export function PreferencesDonateFlow({
}, [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>
)}
let innerContent: JSX.Element;
let handleBack: () => void;
<label htmlFor="amount">Amount (USD)</label>
<Input
id="amount"
if (step === 'amount') {
innerContent = (
<AmountPicker
i18n={i18n}
onChange={value => setAmount(value)}
placeholder="5"
value={amount}
initialAmount={amount}
initialCurrency={currency}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
onSubmit={handleAmountPickerResult}
/>
<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>
);
// Dismiss DonateFlow and return to Donations home
handleBack = () => onBack();
} else {
innerContent = (
<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</label>
<pre>
{amount} {currency}
</pre>
<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}
>
{i18n('icu:PreferencesDonations__donate-button-with-amount', {
formattedCurrencyAmount,
})}
</Button>
</div>
);
handleBack = () => {
setStep('amount');
};
}
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBack}
type="button"
/>
);
const content = (
<>
{confirmDiscardModal}
{innerContent}
</>
);
return (
<>
{confirmDiscardModal}
{content}
</>
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={undefined}
/>
);
}
type AmountPickerResult = {
amount: HumanDonationAmount;
currency: string;
};
type AmountPickerProps = {
i18n: LocalizerType;
initialAmount: HumanDonationAmount | undefined;
initialCurrency: string | undefined;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
onSubmit: (result: AmountPickerResult) => void;
};
function AmountPicker({
donationAmountsConfig,
i18n,
initialAmount,
initialCurrency,
validCurrencies,
onSubmit,
}: AmountPickerProps): JSX.Element {
const [currency, setCurrency] = useState(initialCurrency ?? 'usd');
const [presetAmount, setPresetAmount] = useState<
HumanDonationAmount | undefined
>(initialAmount);
const [customAmount, setCustomAmount] = useState<string>();
// Reset amount selections when API donation config or selected currency changes
// Memo here so preset options instantly load when component mounts.
const presetAmountOptions = useMemo(() => {
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
return [];
}
const currencyAmounts = donationAmountsConfig[currency];
const presets = currencyAmounts.oneTime[ONE_TIME_DONATION_CONFIG_ID] ?? [];
return presets;
}, [donationAmountsConfig, currency]);
useEffect(() => {
setCustomAmount(undefined);
setPresetAmount(undefined);
}, [donationAmountsConfig, currency]);
const minimumAmount = useMemo<HumanDonationAmount>(() => {
if (!donationAmountsConfig || !donationAmountsConfig[currency]) {
return brandHumanDonationAmount(0);
}
const currencyAmounts = donationAmountsConfig[currency];
return currencyAmounts.minimum;
}, [donationAmountsConfig, currency]);
const currencyOptionsForSelect = useMemo(() => {
return validCurrencies.map((currencyString: string) => {
return { text: currencyString.toUpperCase(), value: currencyString };
});
}, [validCurrencies]);
const { error, parsedCustomAmount } = useMemo<{
error: 'invalid' | 'amount-below-minimum' | undefined;
parsedCustomAmount: HumanDonationAmount | undefined;
}>(() => {
if (customAmount === '' || customAmount == null) {
return {
error: undefined,
parsedCustomAmount: undefined,
};
}
const parseResult = parseCurrencyString({
currency,
value: customAmount,
});
if (parseResult != null) {
if (parseResult >= minimumAmount) {
// Valid input
return {
error: undefined,
parsedCustomAmount: parseResult,
};
}
return {
error: 'amount-below-minimum',
parsedCustomAmount: undefined,
};
}
return {
error: 'invalid',
parsedCustomAmount: undefined,
};
}, [currency, customAmount, minimumAmount]);
const handleCurrencyChanged = useCallback((value: string) => {
setCurrency(value);
}, []);
const handleCustomAmountChanged = useCallback((value: string) => {
// Custom amount overrides any selected preset amount
setPresetAmount(undefined);
setCustomAmount(value);
}, []);
const amount = parsedCustomAmount ?? presetAmount;
const isContinueEnabled = currency != null && amount != null;
const handleContinueClicked = useCallback(() => {
if (!isContinueEnabled) {
return;
}
onSubmit({ amount, currency });
}, [amount, currency, isContinueEnabled, onSubmit]);
return (
<div>
<Select
id="currency"
options={currencyOptionsForSelect}
onChange={handleCurrencyChanged}
value={currency}
/>
<div>
{presetAmountOptions.map(value => (
<Button
key={value}
onClick={() => {
setCustomAmount(undefined);
setPresetAmount(value);
}}
variant={
presetAmount === value
? ButtonVariant.SecondaryAffirmative
: ButtonVariant.Secondary
}
>
{toHumanCurrencyString({ amount: value, currency })}
</Button>
))}
</div>
<label htmlFor="customAmount">Custom Amount</label>
<div>
<Input
id="customAmount"
i18n={i18n}
onChange={handleCustomAmountChanged}
placeholder="Enter Custom Amount"
value={customAmount}
/>
<span>{currency.toUpperCase()}</span>
</div>
{error && <div>Error: {error}</div>}
<Button
disabled={!isContinueEnabled}
onClick={handleContinueClicked}
variant={ButtonVariant.Primary}
>
Continue
</Button>
</div>
);
}

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { groupBy, sortBy } from 'lodash';
import type { MutableRefObject, ReactNode } from 'react';
@ -12,9 +12,9 @@ import type { LocalizerType } from '../types/Util';
import { Page, PreferencesContent } from './Preferences';
import { PreferencesDonateFlow } from './PreferencesDonateFlow';
import type {
CardDetail,
DonationWorkflow,
DonationReceipt,
OneTimeDonationHumanAmounts,
} from '../types/Donations';
import type { AvatarColorType } from '../types/Colors';
import type { AvatarDataType } from '../types/Avatar';
@ -27,6 +27,8 @@ import { ToastType } from '../types/Toast';
import { createLogger } from '../logging/log';
import { toLogFormat } from '../types/errors';
import { I18n } from './I18n';
import type { SubmitDonationType } from '../state/ducks/donations';
import { getHumanDonationAmount } from '../util/currency';
const log = createLogger('PreferencesDonations');
@ -43,6 +45,8 @@ export type PropsDataType = {
color?: AvatarColorType;
firstName?: string;
profileAvatarUrl?: string;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: {
data: Uint8Array;
@ -59,11 +63,7 @@ export type PropsDataType = {
type PropsActionType = {
clearWorkflow: () => void;
setPage: (page: Page) => void;
submitDonation: (options: {
currencyType: string;
paymentAmount: number;
paymentDetail: CardDetail;
}) => void;
submitDonation: (payload: SubmitDonationType) => void;
};
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
@ -318,7 +318,7 @@ function PreferencesReceiptList({
</div>
<div className="PreferencesDonations--receiptList__receipt-item__amount">
{getCurrencyFormatter(receipt.currencyType).format(
receipt.paymentAmount / 100
getHumanDonationAmount(receipt)
)}
</div>
</ListBoxItem>
@ -366,7 +366,7 @@ function PreferencesReceiptList({
</div>
<div className="PreferencesDonations__ReceiptModal__amount">
{getCurrencyFormatter(selectedReceipt.currencyType).format(
selectedReceipt.paymentAmount / 100
getHumanDonationAmount(selectedReceipt)
)}
</div>
<hr className="PreferencesDonations__ReceiptModal__separator" />
@ -410,30 +410,13 @@ export function PreferencesDonations({
color,
firstName,
profileAvatarUrl,
donationAmountsConfig,
validCurrencies,
donationReceipts,
saveAttachmentToDisk,
generateDonationReceiptBlob,
showToast,
}: PropsType): JSX.Element | null {
const PAGE_CONFIG = useMemo<
Record<DonationPage, { title: string | undefined; goBackTo: Page | null }>
>(() => {
return {
[Page.Donations]: {
title: i18n('icu:Preferences__DonateTitle'),
goBackTo: null,
},
[Page.DonationsReceiptList]: {
title: i18n('icu:PreferencesDonations__receipts'),
goBackTo: Page.Donations,
},
[Page.DonationsDonateFlow]: {
title: undefined,
goBackTo: Page.Donations,
},
} as const;
}, [i18n]);
const navigateToPage = useCallback(
(newPage: Page) => {
setPage(newPage);
@ -441,31 +424,23 @@ export function PreferencesDonations({
[setPage]
);
const handleBack = useCallback(() => {
if (!isDonationPage(page)) {
log.error(
'Donations page back button tried to go to a non-donations page, ignoring'
);
return;
}
const { goBackTo } = PAGE_CONFIG[page];
if (goBackTo) {
setPage(goBackTo);
}
}, [PAGE_CONFIG, page, setPage]);
if (!isDonationPage(page)) {
return null;
}
let content;
if (page === Page.DonationsDonateFlow) {
content = (
// DonateFlow has to control Back button to switch between CC form and Amount picker
return (
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
workflow={workflow}
clearWorkflow={clearWorkflow}
submitDonation={submitDonation}
onBack={() => setPage(Page.Donations)}
/>
);
}
@ -480,6 +455,8 @@ export function PreferencesDonations({
profileAvatarUrl={profileAvatarUrl}
navigateToPage={navigateToPage}
donationReceipts={donationReceipts}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast}
@ -503,22 +480,28 @@ export function PreferencesDonations({
);
}
// Show back button based on page configuration
const backButton = PAGE_CONFIG[page].goBackTo ? (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBack}
type="button"
/>
) : undefined;
let title: string | undefined;
let backButton: JSX.Element | undefined;
if (page === Page.Donations) {
title = i18n('icu:Preferences__DonateTitle');
} else if (page === Page.DonationsReceiptList) {
title = i18n('icu:PreferencesDonations__receipts');
backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => setPage(Page.Donations)}
type="button"
/>
);
}
return (
<PreferencesContent
backButton={backButton}
contents={content}
contentsRef={contentsRef}
title={PAGE_CONFIG[page].title}
title={title}
/>
);
}

View file

@ -18,6 +18,7 @@ import type { MessageAttributesType } from '../model-types';
import type { DonationReceipt } from '../types/Donations';
import { createLogger } from '../logging/log';
import { isStagingServer } from '../util/isStagingServer';
import { getHumanDonationAmount } from '../util/currency';
const log = createLogger('PreferencesInternal');
@ -398,8 +399,7 @@ export function PreferencesInternal({
{new Date(receipt.timestamp).toLocaleDateString()}
</td>
<td style={{ padding: '8px' }}>
${(receipt.paymentAmount / 100).toFixed(2)}{' '}
{receipt.currencyType}
{getHumanDonationAmount(receipt)} {receipt.currencyType}
</td>
<td
style={{

View file

@ -64,6 +64,7 @@ import type {
} from '../textsecure/Types';
import type { ServiceIdString } from '../types/ServiceId';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { getCachedSubscriptionConfiguration } from '../util/subscriptionConfiguration';
const log = createLogger('handleDataMessage');
@ -760,11 +761,7 @@ export async function handleDataMessage(
typeof updatesUrl === 'string',
'getProfile: expected updatesUrl to be a defined string'
);
const { messaging } = window.textsecure;
if (!messaging) {
throw new Error(`${idLog}: messaging is not available`);
}
const response = await messaging.server.getSubscriptionConfiguration();
const response = await getCachedSubscriptionConfiguration();
const boostBadgesByLevel = parseBoostBadgeListFromServer(
response,
updatesUrl

View file

@ -38,6 +38,7 @@ import type {
DonationReceipt,
DonationWorkflow,
ReceiptContext,
StripeDonationAmount,
} from '../types/Donations';
const { createDonationReceipt } = DataWriter;
@ -73,7 +74,7 @@ export async function startDonation({
paymentAmount,
}: {
currencyType: string;
paymentAmount: number;
paymentAmount: StripeDonationAmount;
}): Promise<void> {
const workflow = await _createPaymentIntent({
currencyType,
@ -145,7 +146,7 @@ export async function _internalDoDonation({
paymentDetail,
}: {
currencyType: string;
paymentAmount: number;
paymentAmount: StripeDonationAmount;
paymentDetail: CardDetail;
}): Promise<void> {
if (isInternalDonationInProgress) {
@ -365,7 +366,7 @@ export async function _createPaymentIntent({
workflow,
}: {
currencyType: string;
paymentAmount: number;
paymentAmount: StripeDonationAmount;
workflow: DonationWorkflow | undefined;
}): Promise<DonationWorkflow> {
const id = uuid();

View file

@ -15,6 +15,7 @@ import type {
DonationErrorType,
DonationReceipt,
DonationWorkflow,
StripeDonationAmount,
} from '../../types/Donations';
import type { StateType as RootStateType } from '../reducer';
import { DataWriter } from '../../sql/Client';
@ -44,11 +45,7 @@ export type AddReceiptAction = ReadonlyDeep<{
export type SubmitDonationAction = ReadonlyDeep<{
type: typeof SUBMIT_DONATION;
payload: {
currencyType: string;
amount: number;
paymentDetail: CardDetail;
};
payload: SubmitDonationType;
}>;
export type UpdateLastErrorAction = ReadonlyDeep<{
@ -100,15 +97,22 @@ function internalAddDonationReceipt(
};
}
export type SubmitDonationType = ReadonlyDeep<{
currencyType: string;
paymentAmount: StripeDonationAmount;
paymentDetail: CardDetail;
}>;
function submitDonation({
currencyType,
paymentAmount,
paymentDetail,
}: {
currencyType: string;
paymentAmount: number;
paymentDetail: CardDetail;
}): ThunkAction<void, RootStateType, unknown, UpdateWorkflowAction> {
}: SubmitDonationType): ThunkAction<
void,
RootStateType,
unknown,
UpdateWorkflowAction
> {
return async () => {
if (!isStagingServer()) {
log.error('internalAddDonationReceipt: Only available on staging server');

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import type { MutableRefObject } from 'react';
@ -15,6 +15,9 @@ import type { StateType } from '../reducer';
import { isStagingServer } from '../../util/isStagingServer';
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
import { useToastActions } from '../ducks/toast';
import { getDonationHumanAmounts } from '../../util/subscriptionConfiguration';
import { drop } from '../../util/drop';
import type { OneTimeDonationHumanAmounts } from '../../types/Donations';
export const SmartPreferencesDonations = memo(
function SmartPreferencesDonations({
@ -26,6 +29,12 @@ export const SmartPreferencesDonations = memo(
page: Page;
setPage: (page: Page) => void;
}) {
const [validCurrencies, setValidCurrencies] = useState<
ReadonlyArray<string>
>([]);
const [donationAmountsConfig, setDonationAmountsConfig] =
useState<OneTimeDonationHumanAmounts>();
const isStaging = isStagingServer();
const i18n = useSelector(getIntl);
@ -46,6 +55,18 @@ export const SmartPreferencesDonations = memo(
);
const { saveAttachmentToDisk } = window.Signal.Migrations;
// Eagerly load donation config from API when entering Donations Home so the
// Amount picker loads instantly
useEffect(() => {
async function loadDonationAmounts() {
const amounts = await getDonationHumanAmounts();
setDonationAmountsConfig(amounts);
const currencies = Object.keys(amounts);
setValidCurrencies(currencies);
}
drop(loadDonationAmounts());
}, []);
return (
<PreferencesDonations
i18n={i18n}
@ -56,6 +77,8 @@ export const SmartPreferencesDonations = memo(
donationReceipts={donationReceipts}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
showToast={showToast}
contentsRef={contentsRef}
isStaging={isStaging}

View file

@ -0,0 +1,166 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
brandHumanDonationAmount,
brandStripeDonationAmount,
parseCurrencyString,
toHumanDonationAmount,
toHumanCurrencyString,
toStripeDonationAmount,
} from '../../util/currency';
describe('parseCurrencyString', () => {
function testFn(
{ currency, value }: { currency: string; value: string },
expectedOutput: number | undefined
): void {
const brandedOutput =
expectedOutput == null
? undefined
: brandHumanDonationAmount(expectedOutput);
assert.equal(parseCurrencyString({ currency, value }), brandedOutput);
}
it('handles USD', () => {
testFn({ currency: 'usd', value: '10' }, 10);
testFn({ currency: 'usd', value: '10.0' }, 10);
testFn({ currency: 'usd', value: '10.00' }, 10);
testFn({ currency: 'usd', value: '10.000' }, 10);
testFn({ currency: 'usd', value: '10.50' }, 10.5);
testFn({ currency: 'usd', value: '10.6969' }, 10.69);
testFn({ currency: 'usd', value: '.69' }, 0.69);
testFn({ currency: 'usd', value: '0.69' }, 0.69);
});
it('handles JPY', () => {
testFn({ currency: 'jpy', value: '1000' }, 1000);
testFn({ currency: 'jpy', value: '1000.0' }, 1000);
testFn({ currency: 'jpy', value: '1000.5' }, 1000);
testFn({ currency: 'jpy', value: '1000.5555' }, 1000);
});
it('handles malformed input', () => {
testFn({ currency: 'usd', value: '' }, undefined);
testFn({ currency: 'usd', value: '??' }, undefined);
testFn({ currency: 'usd', value: '-50' }, undefined);
testFn({ currency: 'usd', value: 'abc' }, undefined);
});
});
describe('toHumanDonationAmount', () => {
function testFn(
{ amount, currency }: { amount: number; currency: string },
expectedOutput: number
): void {
const stripeAmount = brandStripeDonationAmount(amount);
const brandedOutput = brandHumanDonationAmount(expectedOutput);
assert.equal(
toHumanDonationAmount({ amount: stripeAmount, currency }),
brandedOutput
);
}
it('handles USD', () => {
testFn({ amount: 1000, currency: 'usd' }, 10);
testFn({ amount: 1000, currency: 'USD' }, 10);
});
it('handles JPY', () => {
testFn({ amount: 1000, currency: 'jpy' }, 1000);
testFn({ amount: 1000, currency: 'JPY' }, 1000);
});
it('handles KRW', () => {
testFn({ amount: 10000, currency: 'krw' }, 10000);
testFn({ amount: 10000, currency: 'KRW' }, 10000);
});
});
describe('toStripeDonationAmount', () => {
function testFn(
{ amount, currency }: { amount: number; currency: string },
expectedOutput: number
): void {
const humanAmount = brandHumanDonationAmount(amount);
const brandedOutput = brandStripeDonationAmount(expectedOutput);
assert.equal(
toStripeDonationAmount({ amount: humanAmount, currency }),
brandedOutput
);
}
it('handles USD', () => {
testFn({ amount: 10, currency: 'usd' }, 1000);
testFn({ amount: 10, currency: 'USD' }, 1000);
});
it('handles JPY', () => {
testFn({ amount: 1000, currency: 'jpy' }, 1000);
testFn({ amount: 1000, currency: 'JPY' }, 1000);
});
it('handles KRW', () => {
testFn({ amount: 10000, currency: 'krw' }, 10000);
testFn({ amount: 10000, currency: 'KRW' }, 10000);
});
});
describe('toHumanCurrencyString', () => {
function testFn(
{
amount,
currency,
showInsignificantFractionDigits = false,
}: {
amount: number;
currency: string;
showInsignificantFractionDigits?: boolean;
},
expectedOutput: string | undefined
): void {
const humanAmount = brandHumanDonationAmount(amount);
assert.equal(
toHumanCurrencyString({
amount: humanAmount,
currency,
showInsignificantFractionDigits,
}),
expectedOutput
);
}
it('handles USD', () => {
testFn(
{ amount: 10, currency: 'usd', showInsignificantFractionDigits: true },
'$10.00'
);
testFn({ amount: 10, currency: 'USD' }, '$10');
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
testFn({ amount: 10.5, currency: 'USD' }, '$10.50');
testFn({ amount: 10.69, currency: 'USD' }, '$10.69');
});
it('handles EUR', () => {
testFn(
{ amount: 10, currency: 'eur', showInsignificantFractionDigits: true },
'€10.00'
);
testFn({ amount: 10, currency: 'eur' }, '€10');
});
it('handles JPY', () => {
testFn({ amount: 1000, currency: 'jpy' }, '¥1,000');
testFn(
{ amount: 1000, currency: 'JPY', showInsignificantFractionDigits: true },
'¥1,000'
);
});
it('returns empty string for bad inputs', () => {
testFn({ amount: 10, currency: '420' }, '');
testFn({ amount: 10, currency: '' }, '');
});
});

View file

@ -92,7 +92,13 @@ 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';
import type { StripeDonationAmount } from '../types/Donations';
import {
subscriptionConfigurationCurrencyZod,
type CardDetail,
} from '../types/Donations';
import { badgeFromServerSchema } from '../badges/parseBadgesFromServer';
import { ZERO_DECIMAL_CURRENCIES } from '../util/currency';
const log = createLogger('WebAPI');
@ -1108,6 +1114,20 @@ const linkDeviceResultZod = z.object({
});
export type LinkDeviceResultType = z.infer<typeof linkDeviceResultZod>;
const subscriptionConfigurationResultZod = z.object({
currencies: z.record(z.string(), subscriptionConfigurationCurrencyZod),
levels: z.record(
z.string(),
z.object({
name: z.string(),
badge: badgeFromServerSchema,
})
),
});
export type SubscriptionConfigurationResultType = z.infer<
typeof subscriptionConfigurationResultZod
>;
export type ReportMessageOptionsType = Readonly<{
senderAci: AciString;
serverGuid: string;
@ -1150,7 +1170,7 @@ export type CreateAccountResultType = Readonly<{
export type CreateBoostOptionsType = Readonly<{
currency: string;
amount: number;
amount: StripeDonationAmount;
level: number;
paymentMethod: string;
}>;
@ -1499,24 +1519,6 @@ const backupFileHeadersSchema = z.object({
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>;
// See: https://docs.stripe.com/currencies?presentment-currency=US
const ZERO_DECIMAL_CURRENCIES = new Set([
'bif',
'clp',
'djf',
'gnf',
'jpy',
'kmf',
'krw',
'mga',
'pyg',
'rwf',
'vnd',
'vuv',
'xaf',
'xof',
'xpf',
]);
const secondsTimestampToDate = z.coerce
.number()
.transform(sec => new Date(sec * 1_000));
@ -1642,7 +1644,7 @@ export type WebAPIType = {
options: ProfileFetchUnauthRequestOptions
) => Promise<ProfileType>;
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
getSubscriptionConfiguration: () => Promise<unknown>;
getSubscriptionConfiguration: () => Promise<SubscriptionConfigurationResultType>;
getSubscription: (
subscriberId: Uint8Array
) => Promise<SubscriptionResponseType>;
@ -2941,14 +2943,13 @@ export function initialize({
);
}
async function getSubscriptionConfiguration(): Promise<unknown> {
async function getSubscriptionConfiguration(): Promise<SubscriptionConfigurationResultType> {
return _ajax({
host: 'chatService',
call: 'subscriptionConfiguration',
httpType: 'GET',
responseType: 'json',
// TODO DESKTOP-8719
zodSchema: z.unknown(),
zodSchema: subscriptionConfigurationResultZod,
});
}

View file

@ -3,6 +3,8 @@
import { z } from 'zod';
export const ONE_TIME_DONATION_CONFIG_ID = '1';
export const donationStateSchema = z.enum([
'INTENT',
'INTENT_METHOD',
@ -156,3 +158,33 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [
]);
export type DonationWorkflow = z.infer<typeof donationWorkflowSchema>;
export const humanDonationAmountSchema = z
.number()
.nonnegative()
.brand('humanAmount');
export type HumanDonationAmount = z.infer<typeof humanDonationAmountSchema>;
// Always in currency minor units e.g. 1000 for 10 USD, 10 for 10 JPY
// https://docs.stripe.com/currencies#minor-units
export const stripeDonationAmountSchema = z
.number()
.nonnegative()
.brand('stripeAmount');
export type StripeDonationAmount = z.infer<typeof stripeDonationAmountSchema>;
export const subscriptionConfigurationCurrencyZod = z.object({
minimum: humanDonationAmountSchema,
oneTime: z.record(z.string(), humanDonationAmountSchema.array()),
});
export const oneTimeDonationAmountsZod = z.record(
z.string(),
subscriptionConfigurationCurrencyZod
);
export type OneTimeDonationHumanAmounts = z.infer<
typeof oneTimeDonationAmountsZod
>;

150
ts/util/currency.ts Normal file
View file

@ -0,0 +1,150 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
HumanDonationAmount,
DonationReceipt,
StripeDonationAmount,
} from '../types/Donations';
import {
humanDonationAmountSchema,
stripeDonationAmountSchema,
} from '../types/Donations';
import { parseStrict, safeParseStrict } from './schemas';
// See: https://docs.stripe.com/currencies?presentment-currency=US
export const ZERO_DECIMAL_CURRENCIES = new Set([
'bif',
'clp',
'djf',
'gnf',
'jpy',
'kmf',
'krw',
'mga',
'pyg',
'rwf',
'vnd',
'vuv',
'xaf',
'xof',
'xpf',
]);
export function parseCurrencyString({
currency,
value,
}: {
currency: string;
value: string;
}): HumanDonationAmount | undefined {
const valueAsFloat = parseFloat(value);
const truncatedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
? Math.trunc(valueAsFloat)
: Math.trunc(valueAsFloat * 100) / 100;
const parsed = safeParseStrict(humanDonationAmountSchema, truncatedAmount);
if (!parsed.success) {
return;
}
return parsed.data;
}
// Takes a donation amount and currency and returns a human readable currency string
// formatted in the locale's format using Intl.NumberFormat. e.g. $10; ¥1000; 10 €
// In case of error, returns empty string.
export function toHumanCurrencyString({
amount,
currency,
showInsignificantFractionDigits = false,
}: {
amount: HumanDonationAmount | undefined;
currency: string | undefined;
showInsignificantFractionDigits?: boolean;
}): string {
if (amount == null || currency == null) {
return '';
}
try {
const preferredSystemLocales =
window.SignalContext.getPreferredSystemLocales();
const localeOverride = window.SignalContext.getLocaleOverride();
const locales =
localeOverride != null ? [localeOverride] : preferredSystemLocales;
const fractionOptions =
showInsignificantFractionDigits || amount % 1 !== 0
? {}
: { minimumFractionDigits: 0 };
const formatter = new Intl.NumberFormat(locales, {
style: 'currency',
currency,
...fractionOptions,
});
return formatter.format(amount);
} catch {
return '';
}
}
/**
* Takes a number and brands as HumanDonationAmount type, which indicates actual
* units (e.g. 10 for 10 USD; 1000 for 1000 JPY).
* Only use this when directly handling amounts from the chat server.
* To convert from stripe to chat server amount, use toHumanDonationAmount().
* @param amount - number expressing value as actual currency units (e.g. 10 for 10 USD)
* @returns HumanDonationAmount - branded number type
*/
export function brandHumanDonationAmount(amount: number): HumanDonationAmount {
return parseStrict(humanDonationAmountSchema, amount);
}
export function toHumanDonationAmount({
amount,
currency,
}: {
amount: StripeDonationAmount;
currency: string;
}): HumanDonationAmount {
const transformedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
? amount
: amount / 100;
return parseStrict(humanDonationAmountSchema, transformedAmount);
}
/**
* Takes a number and brands as StripeDonationAmount type, which is in the currency
* minor unit (e.g. 1000 for 10 USD) and the expected format for the Stripe API.
* Only use this when directly handling amounts from Stripe.
* To convert from chat server to stripe amount, use toStripeDonationAmount().
* @param amount - number expressing value as currency minor units (e.g. 1000 for 10 USD)
* @returns StripeDonationAmount - branded number type
*/
export function brandStripeDonationAmount(
amount: number
): StripeDonationAmount {
return parseStrict(stripeDonationAmountSchema, amount);
}
export function toStripeDonationAmount({
amount,
currency,
}: {
amount: HumanDonationAmount;
currency: string;
}): StripeDonationAmount {
const transformedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
? amount
: amount * 100;
return parseStrict(humanDonationAmountSchema, transformedAmount);
}
export function getHumanDonationAmount(
receipt: DonationReceipt
): HumanDonationAmount {
// We store receipt.paymentAmount as the Stripe value
const { currencyType: currency, paymentAmount } = receipt;
const amount = brandStripeDonationAmount(paymentAmount);
return toHumanDonationAmount({ amount, currency });
}

View file

@ -7,6 +7,7 @@ import type { LocalizerType } from '../types/Util';
import { strictAssert } from './assert';
import { getDateTimeFormatter } from './formatTimestamp';
import { isStagingServer } from './isStagingServer';
import { getHumanDonationAmount, toHumanCurrencyString } from './currency';
const SCALING_FACTOR = 4.17;
@ -185,18 +186,12 @@ export async function generateDonationReceiptBlob(
);
canvas.add(amountLabel);
// Format currency
const preferredSystemLocales =
window.SignalContext.getPreferredSystemLocales();
const localeOverride = window.SignalContext.getLocaleOverride();
const locales =
localeOverride != null ? [localeOverride] : preferredSystemLocales;
const formatter = new Intl.NumberFormat(locales, {
style: 'currency',
const humanAmount = getHumanDonationAmount(receipt);
const amountStr = toHumanCurrencyString({
amount: humanAmount,
currency: receipt.currencyType,
showInsignificantFractionDigits: true,
});
const amountStr = formatter.format(receipt.paymentAmount / 100);
const amountValue = new fabric.Text(amountStr, {
left: width - paddingX,
top: currentY,

View file

@ -0,0 +1,43 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { SubscriptionConfigurationResultType } from '../textsecure/WebAPI';
import type { OneTimeDonationHumanAmounts } from '../types/Donations';
import { HOUR } from './durations';
import { isInPast } from './timestamp';
const SUBSCRIPTION_CONFIG_CACHE_TIME = HOUR;
let cachedSubscriptionConfig: SubscriptionConfigurationResultType | undefined;
let cachedSubscriptionConfigExpiresAt: number | undefined;
export async function getCachedSubscriptionConfiguration(): Promise<SubscriptionConfigurationResultType> {
if (
cachedSubscriptionConfigExpiresAt != null &&
isInPast(cachedSubscriptionConfigExpiresAt)
) {
cachedSubscriptionConfig = undefined;
}
if (cachedSubscriptionConfig != null) {
return cachedSubscriptionConfig;
}
const { server } = window.textsecure;
if (!server) {
throw new Error('getSubscriptionConfiguration: server is not available');
}
const response = await server.getSubscriptionConfiguration();
cachedSubscriptionConfig = response;
cachedSubscriptionConfigExpiresAt =
Date.now() + SUBSCRIPTION_CONFIG_CACHE_TIME;
return response;
}
export async function getDonationHumanAmounts(): Promise<OneTimeDonationHumanAmounts> {
const { currencies } = await getCachedSubscriptionConfiguration();
return currencies;
}