signal-desktop/ts/util/currency.ts

211 lines
5.7 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import parseCurrency from 'parsecurrency';
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 {
// Known issues with parseCurrency:
// Triple decimal interpreted as a thousands group separator e.g. 1.000 -> 1000
// Decimals must have leading 0 or else are parsed as integers e.g. .42 -> 42
const { value: parsedCurrencyValue } = parseCurrency(value) ?? {};
if (!parsedCurrencyValue) {
return;
}
const truncatedAmount = ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase())
? Math.trunc(parsedCurrencyValue)
: Math.trunc(parsedCurrencyValue * 100) / 100;
const parsed = safeParseStrict(humanDonationAmountSchema, truncatedAmount);
if (!parsed.success) {
return;
}
return parsed.data;
}
function getLocales(): Intl.LocalesArgument {
const preferredSystemLocales =
window.SignalContext.getPreferredSystemLocales();
const localeOverride = window.SignalContext.getLocaleOverride();
return localeOverride != null ? [localeOverride] : preferredSystemLocales;
}
// 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 fractionOptions =
showInsignificantFractionDigits || amount % 1 !== 0
? {}
: { minimumFractionDigits: 0 };
const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency',
currency,
...fractionOptions,
});
return formatter.format(amount);
} catch {
return '';
}
}
export type CurrencyFormatResult = {
decimal: string | undefined;
group: string | undefined;
symbol: string;
symbolPrefix: string;
symbolSuffix: string;
};
export function getCurrencyFormat(
currency: string
): CurrencyFormatResult | undefined {
if (currency == null) {
return;
}
try {
const formatter = new Intl.NumberFormat(getLocales(), {
style: 'currency',
currency,
currencyDisplay: 'narrowSymbol',
});
let symbol = '';
let symbolPrefix = '';
let symbolSuffix = '';
let group;
let decimal;
const parts = formatter.formatToParts(123456);
for (const [index, part] of parts.entries()) {
const { type, value } = part;
if (type === 'currency') {
symbol += value;
if (index === 0) {
symbolPrefix = part.value;
} else {
symbolSuffix = part.value;
}
} else if (type === 'group') {
group = value;
} else if (type === 'decimal') {
decimal = value;
}
}
return { decimal, group, symbol, symbolPrefix, symbolSuffix };
} catch {
return undefined;
}
}
/**
* 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 });
}