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", "messageformat": "Donate",
"description": "Button text to make a donation" "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": { "icu:PreferencesDonations__mobile-info": {
"messageformat": "Badges and monthly donations can be managed on your mobile device.", "messageformat": "Badges and monthly donations can be managed on your mobile device.",
"description": "(Deleted 2025/07/09) Information about donations receipt syncing limitations" "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 MAX_BADGES = 1000;
const badgeFromServerSchema = z.object({ export const badgeFromServerSchema = z.object({
category: z.string(), category: z.string(),
description: z.string(), description: z.string(),
id: z.string(), id: z.string(),
@ -27,7 +27,7 @@ const badgeFromServerSchema = z.object({
}); });
// GET /v1/subscription/configuration // GET /v1/subscription/configuration
const boostBadgesFromServerSchema = z.object({ export const boostBadgesFromServerSchema = z.object({
levels: z.record( levels: z.record(
z z
.object({ .object({

View file

@ -31,7 +31,10 @@ import type { WidthBreakpoint } from './_util';
import type { MessageAttributesType } from '../model-types'; import type { MessageAttributesType } from '../model-types';
import { PreferencesDonations } from './PreferencesDonations'; import { PreferencesDonations } from './PreferencesDonations';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import type { DonationReceipt } from '../types/Donations'; import type {
DonationReceipt,
OneTimeDonationHumanAmounts,
} from '../types/Donations';
import type { AnyToast } from '../types/Toast'; import type { AnyToast } from '../types/Toast';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
@ -106,6 +109,23 @@ const exportLocalBackupResult = {
snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', 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( function renderUpdateDialog(
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
): JSX.Element { ): JSX.Element {
@ -200,6 +220,8 @@ function RenderDonationsPane(props: {
color={props.me.color} color={props.me.color}
firstName={props.me.firstName} firstName={props.me.firstName}
profileAvatarUrl={props.me.profileAvatarUrl} profileAvatarUrl={props.me.profileAvatarUrl}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={Object.keys(donationAmountsConfig)}
donationReceipts={props.donationReceipts} donationReceipts={props.donationReceipts}
saveAttachmentToDisk={props.saveAttachmentToDisk} saveAttachmentToDisk={props.saveAttachmentToDisk}
generateDonationReceiptBlob={props.generateDonationReceiptBlob} generateDonationReceiptBlob={props.generateDonationReceiptBlob}

View file

@ -1,35 +1,63 @@
// 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, useState } from 'react'; import type { MutableRefObject } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { Button, ButtonVariant } from './Button'; 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 { Input } from './Input';
import { PreferencesContent } from './Preferences';
import type { SubmitDonationType } from '../state/ducks/donations';
import { Select } from './Select';
export type PropsDataType = { export type PropsDataType = {
i18n: LocalizerType; i18n: LocalizerType;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
workflow: DonationWorkflow | undefined; workflow: DonationWorkflow | undefined;
}; };
type PropsHousekeepingType = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
};
type PropsActionType = { type PropsActionType = {
clearWorkflow: () => void; clearWorkflow: () => void;
submitDonation: (options: { submitDonation: (payload: SubmitDonationType) => void;
currencyType: string; onBack: () => void;
paymentAmount: number;
paymentDetail: CardDetail;
}) => void;
}; };
export type PropsType = PropsDataType & PropsActionType; export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
export function PreferencesDonateFlow({ export function PreferencesDonateFlow({
contentsRef,
i18n, i18n,
donationAmountsConfig,
validCurrencies,
workflow, workflow,
clearWorkflow, clearWorkflow,
submitDonation, submitDonation,
onBack,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const tryClose = useRef<() => void | undefined>(); const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
@ -38,20 +66,35 @@ export function PreferencesDonateFlow({
tryClose, 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 [cardExpirationMonth, setCardExpirationMonth] = useState('');
const [cardExpirationYear, setCardExpirationYear] = useState(''); const [cardExpirationYear, setCardExpirationYear] = useState('');
const [cardNumber, setCardNumber] = useState(''); const [cardNumber, setCardNumber] = useState('');
const [cardCvc, setCardCvc] = 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 handleDonateClicked = useCallback(() => {
const parsedAmount = parseFloat(amount); if (amount == null || currency == null) {
// Note: Whether to multiply by 100 depends on the specific currency return;
// e.g. JPY is not multipled by 100 }
const paymentAmount = parsedAmount * 100;
const paymentAmount = toStripeDonationAmount({ amount, currency });
submitDonation({ submitDonation({
currencyType: 'USD', currencyType: currency,
paymentAmount, paymentAmount,
paymentDetail: { paymentDetail: {
expirationMonth: cardExpirationMonth, expirationMonth: cardExpirationMonth,
@ -66,6 +109,7 @@ export function PreferencesDonateFlow({
cardExpirationMonth, cardExpirationMonth,
cardExpirationYear, cardExpirationYear,
cardNumber, cardNumber,
currency,
submitDonation, submitDonation,
]); ]);
@ -80,73 +124,277 @@ export function PreferencesDonateFlow({
}, [confirmDiscardIf]); }, [confirmDiscardIf]);
tryClose.current = onTryClose; tryClose.current = onTryClose;
const content = ( let innerContent: JSX.Element;
<div className="PreferencesDonations"> let handleBack: () => void;
{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> if (step === 'amount') {
<Input innerContent = (
id="amount" <AmountPicker
i18n={i18n} i18n={i18n}
onChange={value => setAmount(value)} initialAmount={amount}
placeholder="5" initialCurrency={currency}
value={amount} donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
onSubmit={handleAmountPickerResult}
/> />
<label htmlFor="cardNumber">Card Number</label> );
<Input // Dismiss DonateFlow and return to Donations home
id="cardNumber" handleBack = () => onBack();
i18n={i18n} } else {
onChange={value => setCardNumber(value)} innerContent = (
placeholder="0000000000000000" <div className="PreferencesDonations">
maxLengthCount={16} {workflow && (
value={cardNumber} <div>
/> <h2>Current Workflow</h2>
<label htmlFor="cardExpirationMonth">Expiration Month</label> <blockquote>{JSON.stringify(workflow)}</blockquote>
<Input <Button onClick={clearWorkflow} variant={ButtonVariant.Destructive}>
id="cardExpirationMonth" Reset
i18n={i18n} </Button>
onChange={value => setCardExpirationMonth(value)} </div>
placeholder="MM" )}
value={cardExpirationMonth}
/> <label htmlFor="amount">Amount</label>
<label htmlFor="cardExpirationYear">Expiration Year</label> <pre>
<Input {amount} {currency}
id="cardExpirationYear" </pre>
i18n={i18n} <label htmlFor="cardNumber">Card Number</label>
onChange={value => setCardExpirationYear(value)} <Input
placeholder="YY" id="cardNumber"
value={cardExpirationYear} i18n={i18n}
/> onChange={value => setCardNumber(value)}
<label htmlFor="cardCvc">Cvc</label> placeholder="0000000000000000"
<Input maxLengthCount={16}
id="cardCvc" value={cardNumber}
i18n={i18n} />
onChange={value => setCardCvc(value)} <label htmlFor="cardExpirationMonth">Expiration Month</label>
placeholder="123" <Input
value={cardCvc} id="cardExpirationMonth"
/> i18n={i18n}
<Button onChange={value => setCardExpirationMonth(value)}
disabled={isDonateDisabled} placeholder="MM"
onClick={handleDonateClicked} value={cardExpirationMonth}
variant={ButtonVariant.Primary} />
> <label htmlFor="cardExpirationYear">Expiration Year</label>
Donate $10 <Input
</Button> id="cardExpirationYear"
</div> 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 ( return (
<> <PreferencesContent
{confirmDiscardModal} backButton={backButton}
{content} 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 // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { groupBy, sortBy } from 'lodash';
import type { MutableRefObject, ReactNode } from 'react'; import type { MutableRefObject, ReactNode } from 'react';
@ -12,9 +12,9 @@ import type { LocalizerType } from '../types/Util';
import { Page, PreferencesContent } from './Preferences'; import { Page, PreferencesContent } from './Preferences';
import { PreferencesDonateFlow } from './PreferencesDonateFlow'; import { PreferencesDonateFlow } from './PreferencesDonateFlow';
import type { import type {
CardDetail,
DonationWorkflow, DonationWorkflow,
DonationReceipt, DonationReceipt,
OneTimeDonationHumanAmounts,
} from '../types/Donations'; } from '../types/Donations';
import type { AvatarColorType } from '../types/Colors'; import type { AvatarColorType } from '../types/Colors';
import type { AvatarDataType } from '../types/Avatar'; import type { AvatarDataType } from '../types/Avatar';
@ -27,6 +27,8 @@ import { ToastType } from '../types/Toast';
import { createLogger } from '../logging/log'; import { createLogger } from '../logging/log';
import { toLogFormat } from '../types/errors'; import { toLogFormat } from '../types/errors';
import { I18n } from './I18n'; import { I18n } from './I18n';
import type { SubmitDonationType } from '../state/ducks/donations';
import { getHumanDonationAmount } from '../util/currency';
const log = createLogger('PreferencesDonations'); const log = createLogger('PreferencesDonations');
@ -43,6 +45,8 @@ export type PropsDataType = {
color?: AvatarColorType; color?: AvatarColorType;
firstName?: string; firstName?: string;
profileAvatarUrl?: string; profileAvatarUrl?: string;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
validCurrencies: ReadonlyArray<string>;
donationReceipts: ReadonlyArray<DonationReceipt>; donationReceipts: ReadonlyArray<DonationReceipt>;
saveAttachmentToDisk: (options: { saveAttachmentToDisk: (options: {
data: Uint8Array; data: Uint8Array;
@ -59,11 +63,7 @@ export type PropsDataType = {
type PropsActionType = { type PropsActionType = {
clearWorkflow: () => void; clearWorkflow: () => void;
setPage: (page: Page) => void; setPage: (page: Page) => void;
submitDonation: (options: { submitDonation: (payload: SubmitDonationType) => void;
currencyType: string;
paymentAmount: number;
paymentDetail: CardDetail;
}) => void;
}; };
export type PropsType = PropsDataType & PropsActionType & PropsExternalType; export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
@ -318,7 +318,7 @@ function PreferencesReceiptList({
</div> </div>
<div className="PreferencesDonations--receiptList__receipt-item__amount"> <div className="PreferencesDonations--receiptList__receipt-item__amount">
{getCurrencyFormatter(receipt.currencyType).format( {getCurrencyFormatter(receipt.currencyType).format(
receipt.paymentAmount / 100 getHumanDonationAmount(receipt)
)} )}
</div> </div>
</ListBoxItem> </ListBoxItem>
@ -366,7 +366,7 @@ function PreferencesReceiptList({
</div> </div>
<div className="PreferencesDonations__ReceiptModal__amount"> <div className="PreferencesDonations__ReceiptModal__amount">
{getCurrencyFormatter(selectedReceipt.currencyType).format( {getCurrencyFormatter(selectedReceipt.currencyType).format(
selectedReceipt.paymentAmount / 100 getHumanDonationAmount(selectedReceipt)
)} )}
</div> </div>
<hr className="PreferencesDonations__ReceiptModal__separator" /> <hr className="PreferencesDonations__ReceiptModal__separator" />
@ -410,30 +410,13 @@ export function PreferencesDonations({
color, color,
firstName, firstName,
profileAvatarUrl, profileAvatarUrl,
donationAmountsConfig,
validCurrencies,
donationReceipts, donationReceipts,
saveAttachmentToDisk, saveAttachmentToDisk,
generateDonationReceiptBlob, generateDonationReceiptBlob,
showToast, showToast,
}: PropsType): JSX.Element | null { }: 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( const navigateToPage = useCallback(
(newPage: Page) => { (newPage: Page) => {
setPage(newPage); setPage(newPage);
@ -441,31 +424,23 @@ export function PreferencesDonations({
[setPage] [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)) { if (!isDonationPage(page)) {
return null; return null;
} }
let content; let content;
if (page === Page.DonationsDonateFlow) { if (page === Page.DonationsDonateFlow) {
content = ( // DonateFlow has to control Back button to switch between CC form and Amount picker
return (
<PreferencesDonateFlow <PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n} i18n={i18n}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
workflow={workflow} workflow={workflow}
clearWorkflow={clearWorkflow} clearWorkflow={clearWorkflow}
submitDonation={submitDonation} submitDonation={submitDonation}
onBack={() => setPage(Page.Donations)}
/> />
); );
} }
@ -480,6 +455,8 @@ export function PreferencesDonations({
profileAvatarUrl={profileAvatarUrl} profileAvatarUrl={profileAvatarUrl}
navigateToPage={navigateToPage} navigateToPage={navigateToPage}
donationReceipts={donationReceipts} donationReceipts={donationReceipts}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
saveAttachmentToDisk={saveAttachmentToDisk} saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob} generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast} showToast={showToast}
@ -503,22 +480,28 @@ export function PreferencesDonations({
); );
} }
// Show back button based on page configuration let title: string | undefined;
const backButton = PAGE_CONFIG[page].goBackTo ? ( let backButton: JSX.Element | undefined;
<button if (page === Page.Donations) {
aria-label={i18n('icu:goBack')} title = i18n('icu:Preferences__DonateTitle');
className="Preferences__back-icon" } else if (page === Page.DonationsReceiptList) {
onClick={handleBack} title = i18n('icu:PreferencesDonations__receipts');
type="button" backButton = (
/> <button
) : undefined; aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => setPage(Page.Donations)}
type="button"
/>
);
}
return ( return (
<PreferencesContent <PreferencesContent
backButton={backButton} backButton={backButton}
contents={content} contents={content}
contentsRef={contentsRef} 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 type { DonationReceipt } from '../types/Donations';
import { createLogger } from '../logging/log'; import { createLogger } from '../logging/log';
import { isStagingServer } from '../util/isStagingServer'; import { isStagingServer } from '../util/isStagingServer';
import { getHumanDonationAmount } from '../util/currency';
const log = createLogger('PreferencesInternal'); const log = createLogger('PreferencesInternal');
@ -398,8 +399,7 @@ export function PreferencesInternal({
{new Date(receipt.timestamp).toLocaleDateString()} {new Date(receipt.timestamp).toLocaleDateString()}
</td> </td>
<td style={{ padding: '8px' }}> <td style={{ padding: '8px' }}>
${(receipt.paymentAmount / 100).toFixed(2)}{' '} {getHumanDonationAmount(receipt)} {receipt.currencyType}
{receipt.currencyType}
</td> </td>
<td <td
style={{ style={{

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
// 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, { memo } from 'react'; import React, { memo, useEffect, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
@ -15,6 +15,9 @@ import type { StateType } from '../reducer';
import { isStagingServer } from '../../util/isStagingServer'; import { isStagingServer } from '../../util/isStagingServer';
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt'; import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
import { useToastActions } from '../ducks/toast'; 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( export const SmartPreferencesDonations = memo(
function SmartPreferencesDonations({ function SmartPreferencesDonations({
@ -26,6 +29,12 @@ export const SmartPreferencesDonations = memo(
page: Page; page: Page;
setPage: (page: Page) => void; setPage: (page: Page) => void;
}) { }) {
const [validCurrencies, setValidCurrencies] = useState<
ReadonlyArray<string>
>([]);
const [donationAmountsConfig, setDonationAmountsConfig] =
useState<OneTimeDonationHumanAmounts>();
const isStaging = isStagingServer(); const isStaging = isStagingServer();
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
@ -46,6 +55,18 @@ export const SmartPreferencesDonations = memo(
); );
const { saveAttachmentToDisk } = window.Signal.Migrations; 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 ( return (
<PreferencesDonations <PreferencesDonations
i18n={i18n} i18n={i18n}
@ -56,6 +77,8 @@ export const SmartPreferencesDonations = memo(
donationReceipts={donationReceipts} donationReceipts={donationReceipts}
saveAttachmentToDisk={saveAttachmentToDisk} saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob} generateDonationReceiptBlob={generateDonationReceiptBlob}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
showToast={showToast} showToast={showToast}
contentsRef={contentsRef} contentsRef={contentsRef}
isStaging={isStaging} 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 { 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'; 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'); const log = createLogger('WebAPI');
@ -1108,6 +1114,20 @@ const linkDeviceResultZod = z.object({
}); });
export type LinkDeviceResultType = z.infer<typeof linkDeviceResultZod>; 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<{ export type ReportMessageOptionsType = Readonly<{
senderAci: AciString; senderAci: AciString;
serverGuid: string; serverGuid: string;
@ -1150,7 +1170,7 @@ export type CreateAccountResultType = Readonly<{
export type CreateBoostOptionsType = Readonly<{ export type CreateBoostOptionsType = Readonly<{
currency: string; currency: string;
amount: number; amount: StripeDonationAmount;
level: number; level: number;
paymentMethod: string; paymentMethod: string;
}>; }>;
@ -1499,24 +1519,6 @@ const backupFileHeadersSchema = z.object({
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>; 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 const secondsTimestampToDate = z.coerce
.number() .number()
.transform(sec => new Date(sec * 1_000)); .transform(sec => new Date(sec * 1_000));
@ -1642,7 +1644,7 @@ export type WebAPIType = {
options: ProfileFetchUnauthRequestOptions options: ProfileFetchUnauthRequestOptions
) => Promise<ProfileType>; ) => Promise<ProfileType>;
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>; getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
getSubscriptionConfiguration: () => Promise<unknown>; getSubscriptionConfiguration: () => Promise<SubscriptionConfigurationResultType>;
getSubscription: ( getSubscription: (
subscriberId: Uint8Array subscriberId: Uint8Array
) => Promise<SubscriptionResponseType>; ) => Promise<SubscriptionResponseType>;
@ -2941,14 +2943,13 @@ export function initialize({
); );
} }
async function getSubscriptionConfiguration(): Promise<unknown> { async function getSubscriptionConfiguration(): Promise<SubscriptionConfigurationResultType> {
return _ajax({ return _ajax({
host: 'chatService', host: 'chatService',
call: 'subscriptionConfiguration', call: 'subscriptionConfiguration',
httpType: 'GET', httpType: 'GET',
responseType: 'json', responseType: 'json',
// TODO DESKTOP-8719 zodSchema: subscriptionConfigurationResultZod,
zodSchema: z.unknown(),
}); });
} }

View file

@ -3,6 +3,8 @@
import { z } from 'zod'; import { z } from 'zod';
export const ONE_TIME_DONATION_CONFIG_ID = '1';
export const donationStateSchema = z.enum([ export const donationStateSchema = z.enum([
'INTENT', 'INTENT',
'INTENT_METHOD', 'INTENT_METHOD',
@ -156,3 +158,33 @@ export const donationWorkflowSchema = z.discriminatedUnion('type', [
]); ]);
export type DonationWorkflow = z.infer<typeof donationWorkflowSchema>; 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 { strictAssert } from './assert';
import { getDateTimeFormatter } from './formatTimestamp'; import { getDateTimeFormatter } from './formatTimestamp';
import { isStagingServer } from './isStagingServer'; import { isStagingServer } from './isStagingServer';
import { getHumanDonationAmount, toHumanCurrencyString } from './currency';
const SCALING_FACTOR = 4.17; const SCALING_FACTOR = 4.17;
@ -185,18 +186,12 @@ export async function generateDonationReceiptBlob(
); );
canvas.add(amountLabel); canvas.add(amountLabel);
// Format currency const humanAmount = getHumanDonationAmount(receipt);
const preferredSystemLocales = const amountStr = toHumanCurrencyString({
window.SignalContext.getPreferredSystemLocales(); amount: humanAmount,
const localeOverride = window.SignalContext.getLocaleOverride();
const locales =
localeOverride != null ? [localeOverride] : preferredSystemLocales;
const formatter = new Intl.NumberFormat(locales, {
style: 'currency',
currency: receipt.currencyType, currency: receipt.currencyType,
showInsignificantFractionDigits: true,
}); });
const amountStr = formatter.format(receipt.paymentAmount / 100);
const amountValue = new fabric.Text(amountStr, { const amountValue = new fabric.Text(amountStr, {
left: width - paddingX, left: width - paddingX,
top: currentY, 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;
}