Initial donation amount picker
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
parent
2579dfd9d9
commit
26933bf8d7
16 changed files with 855 additions and 186 deletions
|
@ -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"
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}
|
||||
|
|
166
ts/test-node/util/currency_test.ts
Normal file
166
ts/test-node/util/currency_test.ts
Normal 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: '' }, '');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
150
ts/util/currency.ts
Normal 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 });
|
||||
}
|
|
@ -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,
|
||||
|
|
43
ts/util/subscriptionConfiguration.ts
Normal file
43
ts/util/subscriptionConfiguration.ts
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue