diff --git a/app/components/money-display.tsx b/app/components/money-display.tsx index 25bc078c0..6d744c13f 100644 --- a/app/components/money-display.tsx +++ b/app/components/money-display.tsx @@ -52,7 +52,7 @@ interface MoneyInputDisplayProps { /** Raw input value from user (e.g., "1", "1.", "1.0") */ inputValue: string; currency: C; - unit: CurrencyUnit; + unit?: CurrencyUnit; locale?: string; } diff --git a/app/configuration.ts b/app/configuration.ts new file mode 100644 index 000000000..ad6b433ae --- /dev/null +++ b/app/configuration.ts @@ -0,0 +1,15 @@ +import { Money } from '~/lib/money'; + +/** + * Configures the Money class default settings for the Agicash wallet app. + */ +export function configureMoney() { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', // Override BTC base unit from 'btc' to 'sat' + }, + // USD not specified - uses all defaults + }, + }); +} diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 53cfb961f..fd5a1a599 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -8,8 +8,9 @@ import * as Sentry from '@sentry/react-router'; import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; +import { configureMoney } from './configuration'; import { getEnvironment, isServedLocally } from './environment'; -import { Money } from './lib/money/money'; +import { Money } from './lib/money'; // Register Chrome DevTools custom formatter for Money class (dev only) if (process.env.NODE_ENV === 'development') { @@ -31,6 +32,8 @@ configure({ clientId: openSecretClientId, }); +configureMoney(); + const sentryDsn = import.meta.env.VITE_SENTRY_DSN ?? ''; if (!sentryDsn) { throw new Error('VITE_SENTRY_DSN is not set'); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index ca15f9668..8c2eb4786 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,6 +1,5 @@ import './instrument.server'; import { PassThrough } from 'node:stream'; - import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; import { @@ -15,9 +14,12 @@ import type { unstable_RouterContextProvider, } from 'react-router'; import { ServerRouter } from 'react-router'; +import { configureMoney } from './configuration'; export const streamTimeout = 5_000; +configureMoney(); + function handleRequest( request: Request, responseStatusCode: number, diff --git a/app/features/receive/receive-cashu.tsx b/app/features/receive/receive-cashu.tsx index 6e0987f98..86c01ba96 100644 --- a/app/features/receive/receive-cashu.tsx +++ b/app/features/receive/receive-cashu.tsx @@ -19,7 +19,6 @@ import { LinkWithViewTransition, useNavigateWithViewTransition, } from '~/lib/transitions'; -import { getDefaultUnit } from '../shared/currencies'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import type { CashuReceiveQuote } from './cashu-receive-quote'; import { @@ -78,22 +77,14 @@ const AmountBreakdownCard = ({

Receive

- +

Fee

- +
diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index 36f7c4f69..2c1b4aa9f 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -16,7 +16,6 @@ import { toAccountSelectorOption, } from '~/features/accounts/account-selector'; import { accountOfflineToast } from '~/features/accounts/utils'; -import { getDefaultUnit } from '~/features/shared/currencies'; import useAnimation from '~/hooks/use-animation'; import { useMoneyInput } from '~/hooks/use-money-input'; import { useToast } from '~/hooks/use-toast'; @@ -49,12 +48,7 @@ const ConvertedMoneySwitcher = ({ className="flex items-center gap-1" onClick={onSwitchInputCurrency} > - + ); @@ -69,7 +63,6 @@ export default function ReceiveInput() { const receiveAccountId = useReceiveStore((s) => s.accountId); const receiveAccount = useAccount(receiveAccountId); const receiveAmount = useReceiveStore((s) => s.amount); - const receiveCurrencyUnit = getDefaultUnit(receiveAccount.currency); const setReceiveAccount = useReceiveStore((s) => s.setAccount); const setReceiveAmount = useReceiveStore((s) => s.setAmount); const { data: accounts } = useAccounts(); @@ -83,7 +76,7 @@ export default function ReceiveInput() { handleNumberInput, switchInputCurrency, } = useMoneyInput({ - initialRawInputValue: receiveAmount?.toString(receiveCurrencyUnit) || '0', + initialRawInputValue: receiveAmount?.toString() || '0', initialInputCurrency: receiveAccount.currency, initialOtherCurrency: receiveAccount.currency === 'BTC' ? 'USD' : 'BTC', }); @@ -161,7 +154,6 @@ export default function ReceiveInput() {
diff --git a/app/features/send/cashu-send-quote-service.ts b/app/features/send/cashu-send-quote-service.ts index 6a88cb52f..06dcd1587 100644 --- a/app/features/send/cashu-send-quote-service.ts +++ b/app/features/send/cashu-send-quote-service.ts @@ -10,7 +10,6 @@ import { getCashuUnit, sumProofs } from '~/lib/cashu'; import { type Currency, Money } from '~/lib/money'; import type { CashuAccount } from '../accounts/account'; import { type CashuProof, toProof } from '../accounts/cashu-account'; -import { getDefaultUnit } from '../shared/currencies'; import { DomainError } from '../shared/error'; import type { CashuSendQuote, DestinationDetails } from './cashu-send-quote'; import { @@ -167,12 +166,10 @@ export class CashuSendQuoteService { unit: cashuUnit, }); - const unit = getDefaultUnit(account.currency); - const sumOfSendProofs = sumProofs(proofs); if (sumOfSendProofs < amountWithLightningFee) { throw new DomainError( - `Insufficient balance. Estimated total including fee is ${amountToReceive.add(lightningFeeReserve).toLocaleString({ unit })}.`, + `Insufficient balance. Estimated total including fee is ${amountToReceive.add(lightningFeeReserve).toLocaleString()}.`, ); } @@ -264,11 +261,10 @@ export class CashuSendQuoteService { unit: cashuUnit, }); const estimatedTotalFee = lightningFeeReserve.add(cashuFee); - const unit = getDefaultUnit(account.currency); if (proofsToSendSum < totalAmountToSend) { throw new DomainError( - `Insufficient balance. Estimated total including fee is ${amountToReceive.add(estimatedTotalFee).toLocaleString({ unit })}.`, + `Insufficient balance. Estimated total including fee is ${amountToReceive.add(estimatedTotalFee).toLocaleString()}.`, ); } diff --git a/app/features/send/cashu-send-swap-service.ts b/app/features/send/cashu-send-swap-service.ts index 6e3be9c67..c2cbaef44 100644 --- a/app/features/send/cashu-send-swap-service.ts +++ b/app/features/send/cashu-send-swap-service.ts @@ -20,7 +20,6 @@ import { useCashuTokenSwapService, } from '../receive/cashu-token-swap-service'; import { getTokenHash } from '../shared/cashu'; -import { getDefaultUnit } from '../shared/currencies'; import { DomainError } from '../shared/error'; import type { CashuSendSwap } from './cashu-send-swap'; import { @@ -367,10 +366,8 @@ export class CashuSendSwapService { currency: currency, unit: cashuUnit, }); - const unit = getDefaultUnit(currency); - throw new DomainError( - `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString({ unit })}.`, + `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString()}.`, ); } @@ -393,10 +390,8 @@ export class CashuSendSwapService { currency: currency, unit: cashuUnit, }); - const unit = getDefaultUnit(currency); - throw new DomainError( - `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString({ unit })}.`, + `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString()}.`, ); } diff --git a/app/features/send/send-confirmation.tsx b/app/features/send/send-confirmation.tsx index 08c810a24..4fd38c359 100644 --- a/app/features/send/send-confirmation.tsx +++ b/app/features/send/send-confirmation.tsx @@ -14,7 +14,6 @@ import { useToast } from '~/hooks/use-toast'; import { decodeBolt11 } from '~/lib/bolt11'; import type { Money } from '~/lib/money'; import { useNavigateWithViewTransition } from '~/lib/transitions'; -import { getDefaultUnit } from '../shared/currencies'; import { DomainError } from '../shared/error'; import type { DestinationDetails } from './cashu-send-quote'; import { useInitiateCashuSendQuote } from './cashu-send-quote-hooks'; @@ -214,22 +213,12 @@ export const PayBolt11Confirmation = ({ {[ { label: 'Recipient gets', - value: ( - - ), + value: , }, { label: 'Estimated fee', value: ( - + ), }, { label: 'From', value: account.name }, @@ -304,23 +293,11 @@ export const CreateCashuTokenConfirmation = ({ {[ { label: 'Recipient gets', - value: ( - - ), + value: , }, { label: 'Estimated fee', - value: ( - - ), + value: , }, { label: 'From', value: account.name }, { label: 'Sending', value: 'ecash' }, diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index c6015c8b4..3c1ef64b2 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -45,7 +45,6 @@ import { } from '~/lib/transitions'; import { AddContactDrawer, ContactsList } from '../contacts'; import type { Contact } from '../contacts/contact'; -import { getDefaultUnit } from '../shared/currencies'; import { DomainError, getErrorMessage } from '../shared/error'; import { useSendStore } from './send-provider'; @@ -70,12 +69,7 @@ const ConvertedMoneySwitcher = ({ onSwitchInputCurrency(); }} > - + ); @@ -99,9 +93,6 @@ export function SendInput() { const continueSend = useSendStore((s) => s.proceedWithSend); const status = useSendStore((s) => s.status); - const sendAmountCurrencyUnit = sendAmount - ? getDefaultUnit(sendAmount.currency) - : undefined; const initialInputCurrency = sendAmount?.currency ?? sendAccount.currency; const { @@ -114,7 +105,7 @@ export function SendInput() { switchInputCurrency, setInputValue, } = useMoneyInput({ - initialRawInputValue: sendAmount?.toString(sendAmountCurrencyUnit) || '0', + initialRawInputValue: sendAmount?.toString() || '0', initialInputCurrency: initialInputCurrency, initialOtherCurrency: initialInputCurrency === 'BTC' ? 'USD' : 'BTC', }); @@ -180,11 +171,10 @@ export function SendInput() { let latestConvertedValue = convertedValue; if (amount) { - const defaultUnit = getDefaultUnit(amount.currency); ({ newInputValue: latestInputValue, newConvertedValue: latestConvertedValue, - } = setInputValue(amount.toString(defaultUnit), amount.currency)); + } = setInputValue(amount.toString(), amount.currency)); } await handleContinue(latestInputValue, latestConvertedValue); @@ -214,7 +204,6 @@ export function SendInput() { diff --git a/app/features/shared/currencies.ts b/app/features/shared/currencies.ts deleted file mode 100644 index c8a038929..000000000 --- a/app/features/shared/currencies.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Currency, CurrencyUnit } from '~/lib/money'; - -const currencyToDefaultUnit: { - [K in Currency]: CurrencyUnit; -} = { - BTC: 'sat', - USD: 'usd', -}; - -export const getDefaultUnit = (currency: Currency) => { - return currencyToDefaultUnit[currency]; -}; diff --git a/app/features/shared/money-with-converted-amount.tsx b/app/features/shared/money-with-converted-amount.tsx index f7e25eee3..156b85fb6 100644 --- a/app/features/shared/money-with-converted-amount.tsx +++ b/app/features/shared/money-with-converted-amount.tsx @@ -2,7 +2,6 @@ import { MoneyDisplay } from '~/components/money-display'; import { Skeleton } from '~/components/ui/skeleton'; import { useExchangeRate } from '~/hooks/use-exchange-rate'; import type { Currency, Money } from '~/lib/money'; -import { getDefaultUnit } from './currencies'; const defaultFiatCurrency = 'USD'; @@ -55,14 +54,11 @@ export const MoneyWithConvertedAmount = ({ `${money.currency}-${currencyToConvertTo}`, ); - const unit = getDefaultUnit(money.currency); - const conversionData = money.currency !== currencyToConvertTo ? { rate: exchangeRateQuery.data, loading: exchangeRateQuery.isLoading, - unit: getDefaultUnit(currencyToConvertTo), convertedMoney: exchangeRateQuery.data ? money.convert(currencyToConvertTo, exchangeRateQuery.data) : null, @@ -71,14 +67,13 @@ export const MoneyWithConvertedAmount = ({ return variant === 'default' ? (
- + {conversionData && ( <> {conversionData.loading && } {conversionData.convertedMoney && ( @@ -88,16 +83,14 @@ export const MoneyWithConvertedAmount = ({
) : ( - {money.toLocaleString({ unit })} + {money.toLocaleString()} {conversionData && ( <> {conversionData.loading && ( )} {conversionData.convertedMoney && - ` (~${conversionData.convertedMoney.toLocaleString({ - unit: conversionData.unit, - })})`} + ` (~${conversionData.convertedMoney.toLocaleString()})`} )} diff --git a/app/features/shared/spark.ts b/app/features/shared/spark.ts index 6687bca82..e26820ae6 100644 --- a/app/features/shared/spark.ts +++ b/app/features/shared/spark.ts @@ -15,7 +15,6 @@ import { } from '~/lib/spark'; import { getSeedPhraseDerivationPath } from '../accounts/account-cryptography'; import { useAccounts, useAccountsCache } from '../accounts/account-hooks'; -import { getDefaultUnit } from './currencies'; const seedDerivationPath = getSeedPhraseDerivationPath('spark', 12); @@ -111,7 +110,6 @@ export function useTrackAndUpdateSparkAccountBalances() { balance: new Money({ amount: Number(balance), currency: account.currency as Currency, - unit: getDefaultUnit(account.currency), }), }); diff --git a/app/features/transactions/transaction-details.tsx b/app/features/transactions/transaction-details.tsx index cf36013ef..60b823984 100644 --- a/app/features/transactions/transaction-details.tsx +++ b/app/features/transactions/transaction-details.tsx @@ -15,7 +15,6 @@ import { useToast } from '~/hooks/use-toast'; import { LinkWithViewTransition } from '~/lib/transitions'; import { useAccount } from '../accounts/account-hooks'; import { AccountTypeIcon } from '../accounts/account-icons'; -import { getDefaultUnit } from '../shared/currencies'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import { @@ -138,14 +137,10 @@ export function TransactionDetails({ // Log transaction details with proper formatting for each type const { type, direction, state, details } = transaction; - const unit = getDefaultUnit(transaction.amount.currency); console.debug( `TX ${transaction.id.slice(0, 8)} [${type}_${direction}_${state}]:`, - { - unit, - details, - }, + { details }, ); return ( diff --git a/app/features/transactions/transaction-list.tsx b/app/features/transactions/transaction-list.tsx index 70640992f..8373e1da9 100644 --- a/app/features/transactions/transaction-list.tsx +++ b/app/features/transactions/transaction-list.tsx @@ -17,7 +17,6 @@ import { VIEW_TRANSITION_DURATION_MS, } from '~/lib/transitions'; import { useLatest } from '~/lib/use-latest'; -import { getDefaultUnit } from '../shared/currencies'; import type { Transaction } from './transaction'; import { useAcknowledgeTransaction, @@ -184,9 +183,7 @@ function TransactionRow({

{transaction.direction === 'RECEIVE' && '+'} - {transaction.amount.toLocaleString({ - unit: getDefaultUnit(transaction.amount.currency), - })} + {transaction.amount.toLocaleString()}

diff --git a/app/hooks/use-money-input.ts b/app/hooks/use-money-input.ts index 42927b09c..8bf896002 100644 --- a/app/hooks/use-money-input.ts +++ b/app/hooks/use-money-input.ts @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import type { NumpadButton } from '~/components/numpad'; -import { getDefaultUnit } from '~/features/shared/currencies'; import type { Ticker } from '~/lib/exchange-rate'; import { getLocaleDecimalSeparator } from '~/lib/locale'; import type { Currency } from '~/lib/money'; @@ -41,7 +40,6 @@ const toMoney = ({ return new Money({ amount: value, currency, - unit: getDefaultUnit(currency), }); }; @@ -106,7 +104,7 @@ export function useMoneyInput({ const newConvertedValue = toMoney(current.input) .convert(current.converted.currency, rate) - .toString(getDefaultUnit(current.converted.currency)); + .toString(); return { ...current, @@ -119,9 +117,7 @@ export function useMoneyInput({ } }, [rates]); - const maxInputDecimals = inputMoney.getMaxDecimals( - getDefaultUnit(state.input.currency), - ); + const maxInputDecimals = inputMoney.getMaxDecimals(); const decimalSeparator = getLocaleDecimalSeparator(); @@ -157,7 +153,7 @@ export function useMoneyInput({ newConvertedValue = rate ? toMoney({ value: newValue, currency: state.input.currency }) .convert(state.converted.currency, rate) - .toString(getDefaultUnit(state.converted.currency)) + .toString() : undefined; } else if (currency === state.converted.currency) { // Converted currency input, need reverse conversion @@ -176,9 +172,7 @@ export function useMoneyInput({ newInputMoney = Money.createMinAmount(state.input.currency); } - newInputValue = newInputMoney.toString( - getDefaultUnit(state.input.currency), - ); + newInputValue = newInputMoney.toString(); newConvertedValue = newValue; } else { throw new Error(`Currency does not exist in input state: ${currency}`); diff --git a/app/lib/money/README.md b/app/lib/money/README.md new file mode 100644 index 000000000..0dcad750b --- /dev/null +++ b/app/lib/money/README.md @@ -0,0 +1,561 @@ +# Money Library + +A type-safe, immutable money handling library for JavaScript/TypeScript with multi-currency support, configurable default units, and precise arithmetic operations. + +## Overview + +The Money library provides: +- **Type-safe currency handling** with built-in support for USD and BTC +- **Configurable default units** - set satoshis as the default unit for BTC +- **Extensible currency system** - add custom currencies with TypeScript module augmentation +- **Precise arithmetic** - uses `big.js` to avoid floating-point errors +- **Immutable operations** - all operations return new Money instances +- **Localization support** - format money amounts for different locales + +## Quick Start + +### Basic Usage + +```typescript +import { Money } from '~/lib/money'; + +// Create money with default unit (USD defaults to 'usd', BTC defaults to 'btc') +const dollars = new Money({ amount: 100, currency: 'USD' }); +console.log(dollars.toLocaleString()); // "$100.00" + +// Create money with specific unit +const sats = new Money({ amount: 10000, currency: 'BTC', unit: 'sat' }); +console.log(sats.toLocaleString()); // "₿10,000" + +// Arithmetic operations +const total = dollars.add(new Money({ amount: 50, currency: 'USD' })); +console.log(total.toLocaleString()); // "$150.00" +``` + +### Configuring Default Units + +In Agicash, we configure BTC to use satoshis as the default unit: + +```typescript +// app/entry.client.tsx +import { Money } from '~/lib/money'; + +Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', // Override default from 'btc' to 'sat' + }, + }, +}); + +// Now BTC amounts default to satoshis +const btc = new Money({ amount: 100000, currency: 'BTC' }); +console.log(btc.toString()); // "100000" (in sats) +console.log(btc.toLocaleString()); // "₿100,000" +``` + +## Core Concepts + +### Currency Units + +Each currency has multiple units with different precision levels: + +**USD**: +- `usd` (base unit): 2 decimals, symbol: $ +- `cent`: 0 decimals, symbol: ¢ + +**BTC**: +- `btc` (default base unit): 8 decimals, symbol: ₿ +- `sat`: 0 decimals, symbol: ₿ +- `msat`: 0 decimals, symbol: msat + +### Internal Storage + +Money amounts are stored internally in the **smallest unit** for maximum precision: +- USD amounts are stored as cents +- BTC amounts are stored as millisatoshis (msat) + +This ensures no precision loss during calculations. + +### Base Unit + +The **base unit** is the default unit used when: +- No unit is specified in Money constructor +- Calling `amount()` without arguments +- Calling `toString()` without arguments +- Converting between currencies + +By default, BTC's base unit is 'btc', but Agicash configures it to 'sat'. + +## API Reference + +### Static Methods + +#### `Money.configure(config: MoneyConfiguration)` + +Configure currency settings. Should be called once at app initialization. + +```typescript +Money.configure({ + currencies: { + BTC: { baseUnit: 'sat' }, // Override base unit + EUR: { // Add new currency (requires complete configuration) + baseUnit: 'eur', + units: [/* ... */] + } + } +}); +``` + +#### `Money.sum(moneys: Money[])` + +Sum an array of money amounts: + +```typescript +const amounts = [ + new Money({ amount: 100, currency: 'USD' }), + new Money({ amount: 200, currency: 'USD' }), +]; +const total = Money.sum(amounts); +console.log(total.toString()); // "300.00" +``` + +#### `Money.max(moneys: Money[])` / `Money.min(moneys: Money[])` + +Find maximum or minimum amount: + +```typescript +const max = Money.max([ + new Money({ amount: 100, currency: 'USD' }), + new Money({ amount: 50, currency: 'USD' }), +]); +console.log(max.toString()); // "100.00" +``` + +#### `Money.compare(money1: Money, money2: Money)` + +Compare two money amounts (returns -1, 0, or 1): + +```typescript +const amounts = [ + new Money({ amount: 100, currency: 'USD' }), + new Money({ amount: 50, currency: 'USD' }), +]; +amounts.sort(Money.compare); // Ascending order +``` + +#### `Money.zero(currency: Currency)` + +Create a zero amount: + +```typescript +const zero = Money.zero('USD'); +console.log(zero.toString()); // "0.00" +``` + +#### `Money.createMinAmount(currency, unit?)` + +Create the minimum representable amount for a currency: + +```typescript +const minSat = Money.createMinAmount('BTC', 'sat'); +console.log(minSat.toString('sat')); // "1" +``` + +### Instance Methods + +#### Arithmetic Operations + +All operations return new Money instances (immutable): + +```typescript +const a = new Money({ amount: 100, currency: 'USD' }); +const b = new Money({ amount: 50, currency: 'USD' }); + +a.add(b); // $150.00 +a.subtract(b); // $50.00 +a.multiply(2); // $200.00 +a.divide(4); // $25.00 +a.abs(); // Absolute value +``` + +#### Comparison Operations + +```typescript +const a = new Money({ amount: 100, currency: 'USD' }); +const b = new Money({ amount: 50, currency: 'USD' }); + +a.equals(b); // false +a.greaterThan(b); // true +a.greaterThanOrEqual(b); // true +a.lessThan(b); // false +a.lessThanOrEqual(b); // false +a.isZero(); // false +a.isPositive(); // true +a.isNegative(); // false +``` + +#### Formatting Methods + +```typescript +const money = new Money({ amount: 1234.56, currency: 'USD' }); + +// Get raw amount as Big number +money.amount(); // Big(1234.56) +money.amount('cent'); // Big(123456) + +// Convert to string (number only, no currency) +money.toString(); // "1234.56" +money.toString('cent'); // "123456" + +// Convert to localized string (with currency) +money.toLocaleString(); // "$1,234.56" +money.toLocaleString({ + locale: 'de-DE', + unit: 'cent', + minimumFractionDigits: 2 +}); // "123.456,00¢" + +// Get formatted parts (for custom rendering) +const parts = money.toLocalizedStringParts(); +console.log(parts.integer); // "1,234" +console.log(parts.fraction); // "56" +console.log(parts.currencySymbol); // "$" + +// Convert to number (throws if precision would be lost) +money.toNumber(); // 1234.56 +``` + +#### Currency Conversion + +```typescript +const usd = new Money({ amount: 50000, currency: 'USD' }); +const rate = new Big(1).div(50000); // 1 BTC = $50,000 +const btc = usd.convert('BTC', rate); +console.log(btc.toString()); // "1.00000000" +``` + +#### Utility Methods + +```typescript +const money = new Money({ amount: 100, currency: 'BTC', unit: 'sat' }); + +money.currency; // "BTC" +money.getMaxDecimals('sat'); // 0 +money.getCurrencySymbol('sat'); // "₿" +``` + +## Adding Custom Currencies + +### Option 1: TypeScript Module Augmentation (Recommended) + +For type-safe custom currencies, use module augmentation: + +```typescript +// types/money.d.ts +import { Big } from 'big.js'; + +declare global { + interface CustomCurrencies { + EUR: { units: 'eur' | 'cent' }; + } +} + +// Then configure at runtime +import { Money } from '~/lib/money'; + +Money.configure({ + currencies: { + EUR: { + baseUnit: 'eur', + units: [ + { + name: 'eur', + decimals: 2, + symbol: '€', + factor: new Big(1), + formatToParts: function(value, options = {}) { + // Formatting logic + }, + format: function(value, options = {}) { + return this.formatToParts(value, options) + .map(({ value }) => value) + .join(''); + }, + }, + { + name: 'cent', + decimals: 0, + symbol: 'c', + factor: new Big(10 ** -2), + // ... formatting functions + }, + ], + }, + }, +}); + +// Now you have full type safety: +const euros = new Money({ amount: 100, currency: 'EUR' }); +const inCents = euros.amount('cent'); // Type-safe unit! +``` + +### Option 2: Runtime Registration Only + +For dynamic currencies without TypeScript types: + +```typescript +Money.configure({ + currencies: { + JPY: { + baseUnit: 'yen', + units: [{ + name: 'yen', + decimals: 0, + symbol: '¥', + factor: new Big(1), + formatToParts: /* ... */, + format: /* ... */, + }], + }, + }, +}); + +// Works at runtime but with looser types: +const yen = new Money({ amount: 1000, currency: 'JPY' as any }); +``` + +### Overriding Existing Currency Settings + +You can partially override existing currencies: + +```typescript +Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', // Only override base unit + // units are inherited from defaults + }, + USD: { + units: [ + { + name: 'cent', + symbol: '¢¢', // Override just the symbol + // Other properties inherited + } + ] + } + }, +}); +``` + +## Architecture + +### Files + +- **`money.ts`** - Main `Money` class with immutable operations +- **`currency-registry.ts`** - Singleton managing currency configuration +- **`currency-data.ts`** - Default currency definitions (USD, BTC) +- **`types.ts`** - TypeScript types and interfaces + +### CurrencyRegistry + +The `CurrencyRegistry` is a singleton that: +1. Stores default currency data (USD, BTC) +2. Merges custom configuration at runtime +3. Validates new currencies have complete data +4. Allows partial overrides of existing currencies + +```typescript +// Internal API (accessed via Money class) +const registry = CurrencyRegistry.getInstance(); + +registry.configure({ /* ... */ }); +registry.getCurrencyData('BTC'); +registry.getRegisteredCurrencies(); // ['USD', 'BTC', ...] +registry.isCurrencyRegistered('EUR'); // false +registry.reset(); // Useful for testing +``` + +### Type System + +The library uses a sophisticated type system to provide: +- Autocomplete for currency codes ('USD', 'BTC', custom currencies) +- Type-safe unit names per currency (`BtcUnit = 'btc' | 'sat' | 'msat'`) +- Support for both predefined and runtime-registered currencies + +## Best Practices + +### ✅ Do + +```typescript +// Use Money class for all currency operations +const total = Money.sum([ + new Money({ amount: 100, currency: 'USD' }), + new Money({ amount: 50, currency: 'USD' }), +]); + +// Configure once at app initialization +Money.configure({ currencies: { BTC: { baseUnit: 'sat' } } }); + +// Use Big.js for exchange rates +import { Big } from 'big.js'; +const rate = new Big(0.000020); // USD/BTC rate +``` + +### ❌ Don't + +```typescript +// Never use raw arithmetic on money amounts +const total = 100.10 + 200.20; // ❌ Floating point errors! + +// Don't configure multiple times (warns but works) +Money.configure({ /* ... */ }); +Money.configure({ /* ... */ }); // ⚠️ Warns about multiple configs + +// Don't mix currencies without conversion +const btc = new Money({ amount: 1, currency: 'BTC' }); +const usd = new Money({ amount: 100, currency: 'USD' }); +btc.add(usd); // ❌ Throws error +``` + +## Examples + +### Example 1: Transaction History Display + +```typescript +import { Money } from '~/lib/money'; + +function TransactionItem({ amount }: { amount: Money }) { + return ( +
+ {amount.toLocaleString()} + + (${amount.convert('USD', exchangeRate).toLocaleString()}) + +
+ ); +} +``` + +### Example 2: Input Component with Money + +```typescript +function MoneyInput({ value, onChange }: { + value: Money, + onChange: (money: Money) => void +}) { + const handleChange = (input: string) => { + const amount = parseFloat(input) || 0; + onChange(new Money({ + amount, + currency: value.currency + })); + }; + + return ( +
+ handleChange(e.target.value)} + step={1 / Math.pow(10, value.getMaxDecimals())} + /> + {value.getCurrencySymbol()} +
+ ); +} +``` + +### Example 3: Fee Calculation + +```typescript +function calculateFeeWithMinimum( + amount: Money<'BTC'>, + feeRate: number +): Money<'BTC'> { + const calculatedFee = amount.multiply(feeRate); + const minimumFee = Money.createMinAmount('BTC', 'sat'); + + return calculatedFee.greaterThan(minimumFee) + ? calculatedFee + : minimumFee; +} +``` + +## Testing + +The Money library includes comprehensive tests. Run them with: + +```bash +bun test app/lib/money/money.test.ts +``` + +When testing code that uses Money: + +```typescript +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { Money } from '~/lib/money'; +import { CurrencyRegistry } from '~/lib/money/currency-registry'; + +describe('MyFeature', () => { + beforeEach(() => { + // Configure Money for tests + Money.configure({ + currencies: { BTC: { baseUnit: 'sat' } } + }); + }); + + afterEach(() => { + // Reset to avoid test pollution + CurrencyRegistry.getInstance().reset(); + }); + + it('handles payments', () => { + const amount = new Money({ amount: 1000, currency: 'BTC' }); + expect(amount.toString()).toBe('1000'); + }); +}); +``` + +## Troubleshooting + +### Error: "Unsupported currency" + +Make sure the currency is registered: + +```typescript +Money.getRegisteredCurrencies(); // ['USD', 'BTC'] +Money.isCurrencyRegistered('EUR'); // false + +// Register it: +Money.configure({ + currencies: { + EUR: { /* complete config */ } + } +}); +``` + +### Error: "Currencies must be the same" + +You tried to add/subtract different currencies: + +```typescript +const btc = new Money({ amount: 1, currency: 'BTC' }); +const usd = new Money({ amount: 100, currency: 'USD' }); +btc.add(usd); // ❌ Error + +// Convert first: +const btcEquivalent = usd.convert('BTC', exchangeRate); +btc.add(btcEquivalent); // ✅ OK +``` + +### Warning: "configure() called multiple times" + +`Money.configure()` should only be called once at app initialization. Multiple calls override each other. + +## Further Reading + +- [big.js Documentation](https://mikemcl.github.io/big.js/) - The library used for precise arithmetic +- [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) - Used for localization +- [TypeScript Module Augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) - For adding custom currencies diff --git a/app/lib/money/currency-data.ts b/app/lib/money/currency-data.ts new file mode 100644 index 000000000..8e3e29f2b --- /dev/null +++ b/app/lib/money/currency-data.ts @@ -0,0 +1,200 @@ +import { Big } from 'big.js'; +import type { + BaseFormatOptions, + CurrencyDataMap, + FormatOptions, +} from './types'; + +export function getCurrencyFormatter(options: BaseFormatOptions) { + const { locale, minimumFractionDigits, maximumFractionDigits, currency } = + options; + const formatOptions: Parameters[1] = { + minimumFractionDigits: + minimumFractionDigits === 'max' + ? maximumFractionDigits + : minimumFractionDigits, + maximumFractionDigits, + }; + if (currency) { + formatOptions.style = 'currency'; + formatOptions.currency = currency; + formatOptions.currencyDisplay = 'narrowSymbol'; + } + return Intl.NumberFormat(locale, formatOptions); +} + +export const trimWhitespaceFromEnds = ( + parts: Intl.NumberFormatPart[], +): Intl.NumberFormatPart[] => { + if (parts.length === 0) { + return []; + } + + let result = parts; + + const firstPart = result[0]; + if (firstPart.type === 'literal' && firstPart.value.trim() === '') { + result = result.slice(1); + } + + const lastPart = result[result.length - 1]; + if (lastPart.type === 'literal' && lastPart.value.trim() === '') { + result = result.slice(0, -1); + } + + return result; +}; + +export const defaultCurrencyDataMap: CurrencyDataMap = { + USD: { + baseUnit: 'usd', + units: [ + { + name: 'usd', + decimals: 2, + symbol: '$', + factor: new Big(1), + formatToParts: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + return formatter.formatToParts(value); + }, + format: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + return formatter.format(value); + }, + }, + { + name: 'cent', + decimals: 0, + symbol: '¢', + factor: new Big(10 ** -2), + formatToParts: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + + const parts = formatter.formatToParts(value); + const partsWithoutSymbol = parts.filter( + ({ type }) => type !== 'currency', + ); + const trimmedPartsWithoutSymbol = + trimWhitespaceFromEnds(partsWithoutSymbol); + const partsWithNewSymbolAppended = [ + ...trimmedPartsWithoutSymbol, + { type: 'currency' as const, value: this.symbol }, + ]; + + return partsWithNewSymbolAppended; + }, + format: function (value: number, options: FormatOptions = {}) { + return this.formatToParts(value, options) + .map(({ value }) => value) + .join(''); + }, + }, + ], + }, + BTC: { + baseUnit: 'btc', + units: [ + { + name: 'btc', + decimals: 8, + symbol: '₿', + factor: new Big(1), + formatToParts: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + + const parts = formatter.formatToParts(value); + const partsWithoutSymbol = parts.filter( + ({ type }) => type !== 'currency', + ); + const trimmedPartsWithoutSymbol = + trimWhitespaceFromEnds(partsWithoutSymbol); + const partsWithNewSymbolPrepended = [ + { type: 'currency' as const, value: this.symbol }, + ...trimmedPartsWithoutSymbol, + ]; + + return partsWithNewSymbolPrepended; + }, + format: function (value: number, options: FormatOptions = {}) { + return this.formatToParts(value, options) + .map(({ value }) => value) + .join(''); + }, + }, + { + name: 'sat', + decimals: 0, + symbol: '₿', + factor: new Big(10 ** -8), + formatToParts: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + + const parts = formatter.formatToParts(value); + const partsWithoutSymbol = parts.filter( + ({ type }) => type !== 'currency', + ); + const trimmedPartsWithoutSymbol = + trimWhitespaceFromEnds(partsWithoutSymbol); + const partsWithNewSymbolPrepended = [ + { type: 'currency' as const, value: this.symbol }, + ...trimmedPartsWithoutSymbol, + ]; + + return partsWithNewSymbolPrepended; + }, + format: function (value: number, options: FormatOptions = {}) { + return this.formatToParts(value, options) + .map(({ value }) => value) + .join(''); + }, + }, + { + name: 'msat', + decimals: 0, + symbol: 'msat', + factor: new Big(10 ** -11), + formatToParts: function (value: number, options: FormatOptions = {}) { + const formatter = getCurrencyFormatter({ + ...options, + maximumFractionDigits: this.decimals, + }); + + const parts = formatter.formatToParts(value); + const partsWithoutSymbol = parts.filter( + ({ type }) => type !== 'currency', + ); + const trimmedPartsWithoutSymbol = + trimWhitespaceFromEnds(partsWithoutSymbol); + const partsWithNewSymbolAppended = [ + ...trimmedPartsWithoutSymbol, + { type: 'literal' as const, value: ' ' }, + { type: 'currency' as const, value: this.symbol }, + ]; + + return partsWithNewSymbolAppended; + }, + format: function (value: number, options: FormatOptions = {}) { + return this.formatToParts(value, options) + .map(({ value }) => value) + .join(''); + }, + }, + ], + }, +}; diff --git a/app/lib/money/currency-registry.test.ts b/app/lib/money/currency-registry.test.ts new file mode 100644 index 000000000..6ffcd0143 --- /dev/null +++ b/app/lib/money/currency-registry.test.ts @@ -0,0 +1,417 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { Big } from 'big.js'; +import { CurrencyRegistry } from './currency-registry'; +import type { CompleteCurrencyData } from './types'; + +describe('CurrencyRegistry', () => { + let registry: CurrencyRegistry; + + beforeEach(() => { + registry = CurrencyRegistry.getInstance(); + }); + + afterEach(() => { + registry.reset(); + }); + + describe('getInstance', () => { + it('returns the same instance (singleton)', () => { + const instance1 = CurrencyRegistry.getInstance(); + const instance2 = CurrencyRegistry.getInstance(); + expect(instance1).toBe(instance2); + }); + }); + + describe('getCurrencyData', () => { + it('returns default USD data', () => { + const usdData = registry.getCurrencyData('USD'); + expect(usdData.baseUnit).toBe('usd'); + expect(usdData.units).toHaveLength(2); + expect(usdData.units[0].name).toBe('usd'); + expect(usdData.units[1].name).toBe('cent'); + }); + + it('returns default BTC data', () => { + const btcData = registry.getCurrencyData('BTC'); + expect(btcData.baseUnit).toBe('btc'); + expect(btcData.units).toHaveLength(3); + expect(btcData.units[0].name).toBe('btc'); + expect(btcData.units[1].name).toBe('sat'); + expect(btcData.units[2].name).toBe('msat'); + }); + + it('throws error for unsupported currency', () => { + // biome-ignore lint/suspicious/noExplicitAny: Testing runtime error for unsupported currency + expect(() => registry.getCurrencyData('EUR' as any)).toThrow( + 'Unsupported currency: "EUR"', + ); + }); + }); + + describe('getRegisteredCurrencies', () => { + it('returns default currencies', () => { + const currencies = registry.getRegisteredCurrencies(); + expect(currencies).toContain('USD'); + expect(currencies).toContain('BTC'); + }); + + it('includes custom currencies after configuration', () => { + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const eurData: CompleteCurrencyData = { + baseUnit: 'eur', + units: [ + { + name: 'eur', + decimals: 2, + symbol: '€', + factor: new Big(1), + formatToParts: (value) => [ + { type: 'currency', value: '€' }, + { type: 'integer', value: value.toString() }, + ], + format: (value) => `€${value}`, + }, + ], + }; + + registry.configure({ + currencies: { + EUR: eurData, + }, + }); + + const currencies = registry.getRegisteredCurrencies(); + expect(currencies).toContain('USD'); + expect(currencies).toContain('BTC'); + expect(currencies).toContain('EUR'); + }); + }); + + describe('isCurrencyRegistered', () => { + it('returns true for default currencies', () => { + expect(registry.isCurrencyRegistered('USD')).toBe(true); + expect(registry.isCurrencyRegistered('BTC')).toBe(true); + }); + + it('returns false for unregistered currency', () => { + expect(registry.isCurrencyRegistered('EUR')).toBe(false); + }); + + it('returns true after registering custom currency', () => { + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const jpyData: CompleteCurrencyData = { + baseUnit: 'yen', + units: [ + { + name: 'yen', + decimals: 0, + symbol: '¥', + factor: new Big(1), + formatToParts: (value) => [ + { type: 'currency', value: '¥' }, + { type: 'integer', value: value.toString() }, + ], + format: (value) => `¥${value}`, + }, + ], + }; + + registry.configure({ + currencies: { + JPY: jpyData, + }, + }); + + expect(registry.isCurrencyRegistered('JPY')).toBe(true); + }); + }); + + describe('configure', () => { + it('allows overriding base unit of existing currency', () => { + registry.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const btcData = registry.getCurrencyData('BTC'); + expect(btcData.baseUnit).toBe('sat'); + // Units should still be present + expect(btcData.units).toHaveLength(3); + }); + + it('allows adding new currency with complete data', () => { + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const gbpData: CompleteCurrencyData = { + baseUnit: 'gbp', + units: [ + { + name: 'gbp', + decimals: 2, + symbol: '£', + factor: new Big(1), + formatToParts: (value) => [ + { type: 'currency', value: '£' }, + { type: 'integer', value: value.toString() }, + ], + format: (value) => `£${value}`, + }, + { + name: 'pence', + decimals: 0, + symbol: 'p', + factor: new Big(10 ** -2), + formatToParts: (value) => [ + { type: 'integer', value: value.toString() }, + { type: 'currency', value: 'p' }, + ], + format: (value) => `${value}p`, + }, + ], + }; + + registry.configure({ + currencies: { + GBP: gbpData, + }, + }); + + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const data = registry.getCurrencyData('GBP' as any); + expect(data.baseUnit).toBe('gbp'); + expect(data.units).toHaveLength(2); + }); + + it('throws error when adding incomplete new currency', () => { + expect(() => { + registry.configure({ + currencies: { + EUR: { + baseUnit: 'eur', + // Missing units array + }, + }, + }); + }).toThrow('Incomplete currency data for "EUR"'); + }); + + it('allows overriding existing unit properties', () => { + registry.configure({ + currencies: { + USD: { + units: [ + { + name: 'cent', + symbol: '¢¢', // Override symbol + }, + ], + }, + }, + }); + + const usdData = registry.getCurrencyData('USD'); + const centUnit = usdData.units.find((u) => u.name === 'cent'); + expect(centUnit?.symbol).toBe('¢¢'); + // Other properties should be preserved + expect(centUnit?.decimals).toBe(0); + }); + + it('allows adding new unit to existing currency', () => { + registry.configure({ + currencies: { + BTC: { + units: [ + { + // biome-ignore lint/suspicious/noExplicitAny: Test unit doesn't have predefined types + name: 'ksat' as any, + decimals: 0, + symbol: 'ksat', + factor: new Big(10 ** -5), // 1 ksat = 1000 sats = 0.00001 BTC + formatToParts: (value) => [ + { type: 'integer', value: value.toString() }, + { type: 'currency', value: ' ksat' }, + ], + format: (value) => `${value} ksat`, + }, + ], + }, + }, + }); + + const btcData = registry.getCurrencyData('BTC'); + expect(btcData.units).toHaveLength(4); // btc, sat, msat, ksat + // @ts-expect-error - 'ksat' is a custom unit added at runtime, not in BtcUnit type + const ksatUnit = btcData.units.find((u) => u.name === 'ksat'); + expect(ksatUnit).toBeDefined(); + expect(ksatUnit?.symbol).toBe('ksat'); + }); + + it('throws error when adding incomplete new unit', () => { + expect(() => { + registry.configure({ + currencies: { + BTC: { + units: [ + { + // biome-ignore lint/suspicious/noExplicitAny: Test unit doesn't have predefined types + name: 'newunit' as any, + symbol: 'nu', + // Missing other required fields + }, + ], + }, + }, + }); + }).toThrow('Incomplete unit data for new unit "newunit"'); + }); + + it('warns when called multiple times', () => { + const consoleSpy = { + // biome-ignore lint/suspicious/noExplicitAny: Spy for console.warn arguments + calls: [] as any[], + }; + const originalWarn = console.warn; + // biome-ignore lint/suspicious/noExplicitAny: Spy for console.warn arguments + console.warn = (...args: any[]) => { + consoleSpy.calls.push(args); + }; + + registry.configure({ + currencies: { + BTC: { baseUnit: 'sat' }, + }, + }); + + registry.configure({ + currencies: { + BTC: { baseUnit: 'btc' }, + }, + }); + + expect(consoleSpy.calls).toHaveLength(1); + expect(consoleSpy.calls[0][0]).toContain( + 'configure() called multiple times', + ); + + console.warn = originalWarn; + }); + + it('merges multiple currencies in one call', () => { + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const eurData: CompleteCurrencyData = { + baseUnit: 'eur', + units: [ + { + name: 'eur', + decimals: 2, + symbol: '€', + factor: new Big(1), + formatToParts: (value) => [ + { type: 'currency', value: '€' }, + { type: 'integer', value: value.toString() }, + ], + format: (value) => `€${value}`, + }, + ], + }; + + registry.configure({ + currencies: { + BTC: { baseUnit: 'sat' }, + EUR: eurData, + }, + }); + + expect(registry.getCurrencyData('BTC').baseUnit).toBe('sat'); + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + expect(registry.getCurrencyData('EUR' as any).baseUnit).toBe('eur'); + }); + }); + + describe('reset', () => { + it('clears custom configuration', () => { + registry.configure({ + currencies: { + BTC: { baseUnit: 'sat' }, + }, + }); + + const btcData = registry.getCurrencyData('BTC'); + expect(btcData.baseUnit).toBe('sat'); + + registry.reset(); + + const btcDataAfterReset = registry.getCurrencyData('BTC'); + expect(btcDataAfterReset.baseUnit).toBe('btc'); // Back to default + }); + + it('removes custom currencies', () => { + // biome-ignore lint/suspicious/noExplicitAny: Test currency doesn't have predefined types + const eurData: CompleteCurrencyData = { + baseUnit: 'eur', + units: [ + { + name: 'eur', + decimals: 2, + symbol: '€', + factor: new Big(1), + formatToParts: (value) => [ + { type: 'currency', value: '€' }, + { type: 'integer', value: value.toString() }, + ], + format: (value) => `€${value}`, + }, + ], + }; + + registry.configure({ + currencies: { + EUR: eurData, + }, + }); + + expect(registry.isCurrencyRegistered('EUR')).toBe(true); + + registry.reset(); + + expect(registry.isCurrencyRegistered('EUR')).toBe(false); + }); + }); + + describe('integration with default currency data', () => { + it('preserves all default USD units when overriding', () => { + registry.configure({ + currencies: { + USD: { + baseUnit: 'cent', // Change base unit + }, + }, + }); + + const usdData = registry.getCurrencyData('USD'); + expect(usdData.baseUnit).toBe('cent'); + expect(usdData.units).toHaveLength(2); + expect(usdData.units.find((u) => u.name === 'usd')).toBeDefined(); + expect(usdData.units.find((u) => u.name === 'cent')).toBeDefined(); + }); + + it('preserves all default BTC units when overriding', () => { + registry.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const btcData = registry.getCurrencyData('BTC'); + expect(btcData.baseUnit).toBe('sat'); + expect(btcData.units).toHaveLength(3); + expect(btcData.units.find((u) => u.name === 'btc')).toBeDefined(); + expect(btcData.units.find((u) => u.name === 'sat')).toBeDefined(); + expect(btcData.units.find((u) => u.name === 'msat')).toBeDefined(); + }); + }); +}); diff --git a/app/lib/money/currency-registry.ts b/app/lib/money/currency-registry.ts new file mode 100644 index 000000000..187a105f6 --- /dev/null +++ b/app/lib/money/currency-registry.ts @@ -0,0 +1,207 @@ +import { defaultCurrencyDataMap } from './currency-data'; +import type { + CompleteCurrencyData, + CompleteUnitData, + Currency, + CurrencyData, + CurrencyDataMap, + MoneyConfiguration, + PartialCurrencyData, + PartialUnitData, + UnitData, +} from './types'; + +/** + * Registry for currency configuration. + * Manages currency data and allows runtime configuration. + */ +export class CurrencyRegistry { + private static instance: CurrencyRegistry | null = null; + private customCurrencyDataMap: CurrencyDataMap = {}; + private isConfigured = false; + + private constructor() {} + + /** + * Get the singleton instance + */ + static getInstance(): CurrencyRegistry { + if (!CurrencyRegistry.instance) { + CurrencyRegistry.instance = new CurrencyRegistry(); + } + return CurrencyRegistry.instance; + } + + /** + * Configure currency data. Should be called once at app initialization. + */ + configure(config: MoneyConfiguration): void { + if (this.isConfigured) { + console.warn( + 'CurrencyRegistry.configure() called multiple times. Later calls override earlier ones.', + ); + } + + this.customCurrencyDataMap = this.buildCurrencyDataMap(config); + this.isConfigured = true; + } + + /** + * Reset configuration (useful for testing) + */ + reset(): void { + this.customCurrencyDataMap = {}; + this.isConfigured = false; + } + + /** + * Get currency data for a specific currency + */ + getCurrencyData(currency: T): CurrencyData { + const effectiveMap = this.getEffectiveCurrencyDataMap(); + const data = effectiveMap[currency]; + + if (!data) { + throw new Error( + `Unsupported currency: "${currency}". Register it using Money.configure().`, + ); + } + + return data; + } + + /** + * Get list of all registered currencies + */ + getRegisteredCurrencies(): string[] { + return Object.keys(this.getEffectiveCurrencyDataMap()); + } + + /** + * Check if a currency is registered + */ + isCurrencyRegistered(currency: string): boolean { + return currency in this.getEffectiveCurrencyDataMap(); + } + + /** + * Get effective currency data map (custom overrides + defaults) + */ + private getEffectiveCurrencyDataMap(): CurrencyDataMap { + return { + ...defaultCurrencyDataMap, + ...this.customCurrencyDataMap, + }; + } + + /** + * Build currency data map from configuration + */ + private buildCurrencyDataMap(config: MoneyConfiguration): CurrencyDataMap { + const customMap: CurrencyDataMap = {}; + + if (!config.currencies) { + return customMap; + } + + for (const [currency, partialData] of Object.entries(config.currencies)) { + const defaultData = defaultCurrencyDataMap[currency]; + + if (defaultData && partialData) { + // Merge with default data + customMap[currency] = this.mergeCurrencyData(defaultData, partialData); + } else if (partialData) { + // New currency - validate it's complete + if (!this.isCompleteCurrencyData(partialData)) { + throw new Error( + `Incomplete currency data for "${currency}". New currencies require complete baseUnit and units configuration.`, + ); + } + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + customMap[currency] = partialData as CurrencyData; + } + } + + return customMap; + } + + /** + * Deep merge currency data + */ + private mergeCurrencyData( + defaultData: CurrencyData, + partialData: PartialCurrencyData, + ): CurrencyData { + const mergedUnits = this.mergeUnits( + defaultData.units, + partialData.units || [], + ); + + return { + baseUnit: partialData.baseUnit ?? defaultData.baseUnit, + units: mergedUnits, + }; + } + + /** + * Merge unit arrays + */ + private mergeUnits( + defaultUnits: Array>, + configUnits: Array>, + ): Array> { + const merged = [...defaultUnits]; + + for (const configUnit of configUnits) { + const index = merged.findIndex((u) => u.name === configUnit.name); + + if (index !== -1) { + // Merge with existing unit + merged[index] = { + ...merged[index], + ...configUnit, + formatToParts: + configUnit.formatToParts ?? merged[index].formatToParts, + format: configUnit.format ?? merged[index].format, + } as UnitData; + } else { + // New unit - must be complete + if (!this.isCompleteUnitData(configUnit)) { + throw new Error( + `Incomplete unit data for new unit "${configUnit.name}". All properties required for new units.`, + ); + } + merged.push(configUnit as UnitData); + } + } + + return merged; + } + + private isCompleteCurrencyData( + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + data: PartialCurrencyData, + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + ): data is CompleteCurrencyData { + return ( + data.baseUnit !== undefined && + data.units !== undefined && + data.units.length > 0 && + data.units.every((unit) => this.isCompleteUnitData(unit)) + ); + } + + private isCompleteUnitData( + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + unit: PartialUnitData, + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + ): unit is CompleteUnitData { + return ( + unit.decimals !== undefined && + unit.symbol !== undefined && + unit.factor !== undefined && + unit.formatToParts !== undefined && + unit.format !== undefined + ); + } +} diff --git a/app/lib/money/money.test.ts b/app/lib/money/money.test.ts index c109078ef..f06303d9b 100644 --- a/app/lib/money/money.test.ts +++ b/app/lib/money/money.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, describe, expect, it } from 'bun:test'; import { Big } from 'big.js'; import { Money } from '.'; +import { CurrencyRegistry } from './currency-registry'; describe('Money', () => { + afterEach(() => { + // Reset registry after each test to avoid test pollution + CurrencyRegistry.getInstance().reset(); + }); + describe('USD', () => { it('handles basic dollar amounts', () => { const money = new Money({ amount: 1000, currency: 'USD' }); @@ -149,4 +155,155 @@ describe('Money', () => { expect(result.amount().toString()).toBe('50'); }); }); + + describe('Money.configure', () => { + it('allows changing BTC base unit to satoshis', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + units: [ + { + name: 'sat', + symbol: 'sat', + }, + ], + }, + }, + }); + + // Without unit specified, should use configured base unit (sat) + const money = new Money({ amount: 1, currency: 'BTC' }); + expect(money.amount().toString()).toBe('1'); // In sats + expect(money.toLocaleString()).toBe('sat1'); // In sats + expect(money.toLocaleString({ unit: 'btc' })).toBe('₿0.00000001'); // Can still convert to btc + }); + + it('affects default unit used in toString and amount', () => { + // First without configuration (default is 'btc') + const moneyBeforeConfig = new Money({ + amount: 1, + currency: 'BTC', + }); + expect(moneyBeforeConfig.toString()).toBe('1.00000000'); // 1 BTC + + // Reset and configure + CurrencyRegistry.getInstance().reset(); + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + // After configuration (default is 'sat') + const moneyAfterConfig = new Money({ + amount: 100000, + currency: 'BTC', + }); + expect(moneyAfterConfig.toString()).toBe('100000'); // 100000 sats + }); + + it('preserves all existing units when overriding base unit', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const money = new Money({ amount: 100000, currency: 'BTC' }); + + // All units should still be accessible + expect(money.amount('btc').toString()).toBe('0.001'); + expect(money.amount('sat').toString()).toBe('100000'); + expect(money.amount('msat').toString()).toBe('100000000'); + }); + + it('works with Money.zero using configured base unit', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const zero = Money.zero('BTC'); + expect(zero.toString()).toBe('0'); // In sats (0 decimals) + expect(zero.amount().toString()).toBe('0'); + }); + + it('works with Money.sum using configured base unit', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const amounts = [ + new Money({ amount: 1000, currency: 'BTC' }), // 1000 sats + new Money({ amount: 2000, currency: 'BTC' }), // 2000 sats + ]; + + const total = Money.sum(amounts); + expect(total.toString()).toBe('3000'); // In sats + }); + + it('affects conversion target currency', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const usd = new Money({ amount: 100, currency: 'USD' }); + const rate = new Big(0.000002); // 1 USD = 0.000002 BTC = 200 sats + const btc = usd.convert('BTC', rate); + + // Result should be in configured base unit (sats) + expect(btc.toString()).toBe('20000'); // 20000 sats + expect(btc.amount().toString()).toBe('20000'); + }); + + it('affects conversion source currency', () => { + Money.configure({ + currencies: { + BTC: { + baseUnit: 'sat', + }, + }, + }); + + const btc = new Money({ amount: 25, currency: 'BTC' }); // 25 sats + const rate = new Big(89168); // 1 BTC = 89168 USD + const usd = btc.convert('USD', rate); + + // 25 sats = 25 * 1e-8 BTC = 0.00000025 BTC + // 0.00000025 BTC * 89168 USD/BTC = 0.022292 USD + // Rounded to 2 decimals = 0.02 USD + expect(usd.toString()).toBe('0.02'); + expect(usd.amount().toString()).toBe('0.02'); + }); + }); + + describe('Money static methods', () => { + it('getRegisteredCurrencies returns default currencies', () => { + const currencies = Money.getRegisteredCurrencies(); + expect(currencies).toContain('USD'); + expect(currencies).toContain('BTC'); + }); + + it('isCurrencyRegistered checks for currency', () => { + expect(Money.isCurrencyRegistered('USD')).toBe(true); + expect(Money.isCurrencyRegistered('BTC')).toBe(true); + expect(Money.isCurrencyRegistered('EUR')).toBe(false); + }); + }); }); diff --git a/app/lib/money/money.ts b/app/lib/money/money.ts index c3aa550fd..2477c3fcc 100644 --- a/app/lib/money/money.ts +++ b/app/lib/money/money.ts @@ -1,217 +1,20 @@ import { Big } from 'big.js'; +import { CurrencyRegistry } from './currency-registry'; import type { - BaseFormatOptions, Currency, CurrencyData, - CurrencyDataMap, CurrencyUnit, FormatOptions, LocalizedStringParts, + MoneyConfiguration, MoneyData, MoneyInput, NumberInput, UnitData, } from './types'; -function getCurrencyFormatter(options: BaseFormatOptions) { - const { locale, minimumFractionDigits, maximumFractionDigits, currency } = - options; - const formatOptions: Parameters[1] = { - minimumFractionDigits: - minimumFractionDigits === 'max' - ? maximumFractionDigits - : minimumFractionDigits, - maximumFractionDigits, - }; - if (currency) { - formatOptions.style = 'currency'; - formatOptions.currency = currency; - formatOptions.currencyDisplay = 'narrowSymbol'; - } - return Intl.NumberFormat(locale, formatOptions); -} -const trimWhitespaceFromEnds = ( - parts: Intl.NumberFormatPart[], -): Intl.NumberFormatPart[] => { - if (parts.length === 0) { - return []; - } - - let result = parts; - - const firstPart = result[0]; - if (firstPart.type === 'literal' && firstPart.value.trim() === '') { - result = result.slice(1); - } - - const lastPart = result[result.length - 1]; - if (lastPart.type === 'literal' && lastPart.value.trim() === '') { - result = result.slice(0, -1); - } - - return result; -}; - -const currencyDataMap: CurrencyDataMap = { - USD: { - baseUnit: 'usd', - units: [ - { - name: 'usd', - decimals: 2, - symbol: '$', - factor: new Big(1), - formatToParts: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - return formatter.formatToParts(value); - }, - format: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - return formatter.format(value); - }, - }, - { - name: 'cent', - decimals: 0, - symbol: '¢', - factor: new Big(10 ** -2), - formatToParts: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - - const parts = formatter.formatToParts(value); - const partsWithoutSymbol = parts.filter( - ({ type }) => type !== 'currency', - ); - const trimmedPartsWithoutSymbol = - trimWhitespaceFromEnds(partsWithoutSymbol); - const partsWithNewSymbolAppended = [ - ...trimmedPartsWithoutSymbol, - { type: 'currency' as const, value: this.symbol }, - ]; - - return partsWithNewSymbolAppended; - }, - format: function (value: number, options: FormatOptions = {}) { - return this.formatToParts(value, options) - .map(({ value }) => value) - .join(''); - }, - }, - ], - }, - BTC: { - baseUnit: 'btc', - units: [ - { - name: 'btc', - decimals: 8, - symbol: '₿', - factor: new Big(1), - formatToParts: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - - const parts = formatter.formatToParts(value); - const partsWithoutSymbol = parts.filter( - ({ type }) => type !== 'currency', - ); - const trimmedPartsWithoutSymbol = - trimWhitespaceFromEnds(partsWithoutSymbol); - const partsWithNewSymbolPrepended = [ - { type: 'currency' as const, value: this.symbol }, - ...trimmedPartsWithoutSymbol, - ]; - - return partsWithNewSymbolPrepended; - }, - format: function (value: number, options: FormatOptions = {}) { - return this.formatToParts(value, options) - .map(({ value }) => value) - .join(''); - }, - }, - { - name: 'sat', - decimals: 0, - symbol: '₿', - factor: new Big(10 ** -8), - formatToParts: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - - const parts = formatter.formatToParts(value); - const partsWithoutSymbol = parts.filter( - ({ type }) => type !== 'currency', - ); - const trimmedPartsWithoutSymbol = - trimWhitespaceFromEnds(partsWithoutSymbol); - const partsWithNewSymbolPrepended = [ - { type: 'currency' as const, value: this.symbol }, - ...trimmedPartsWithoutSymbol, - ]; - - return partsWithNewSymbolPrepended; - }, - format: function (value: number, options: FormatOptions = {}) { - return this.formatToParts(value, options) - .map(({ value }) => value) - .join(''); - }, - }, - { - name: 'msat', - decimals: 0, - symbol: 'msat', - factor: new Big(10 ** -11), - formatToParts: function (value: number, options: FormatOptions = {}) { - const formatter = getCurrencyFormatter({ - ...options, - maximumFractionDigits: this.decimals, - }); - - const parts = formatter.formatToParts(value); - const partsWithoutSymbol = parts.filter( - ({ type }) => type !== 'currency', - ); - const trimmedPartsWithoutSymbol = - trimWhitespaceFromEnds(partsWithoutSymbol); - const partsWithNewSymbolAppended = [ - ...trimmedPartsWithoutSymbol, - { type: 'literal' as const, value: ' ' }, - { type: 'currency' as const, value: this.symbol }, - ]; - - return partsWithNewSymbolAppended; - }, - format: function (value: number, options: FormatOptions = {}) { - return this.formatToParts(value, options) - .map(({ value }) => value) - .join(''); - }, - }, - ], - }, -}; - const getCurrencyData = (currency: T) => { - const currencyData = currencyDataMap[currency]; - if (!currencyData) { - throw new Error(`Unsupported currency: ${currency}`); - } - return currencyData; + return CurrencyRegistry.getInstance().getCurrencyData(currency); }; const getCurrencyBaseUnit = (currency: T) => { @@ -235,90 +38,9 @@ const getCurrencyMinUnit = (currency: T) => { }; export class Money { + private static registry = CurrencyRegistry.getInstance(); private readonly _data: MoneyData; - /** - * Returns the class tag for better console output. - * Shows as "Money" in Object.prototype.toString.call() - */ - get [Symbol.toStringTag](): string { - return 'Money'; - } - - /** - * Returns a formatted string representation for debugging. - * Visible when expanding the object in browser DevTools. - */ - get formatted(): string { - return this.toLocaleString(); - } - - /** - * Custom inspect method for Node.js console output. - * Shows formatted value instead of internal structure. - */ - [Symbol.for('nodejs.util.inspect.custom')](): string { - return `Money { ${this.toLocaleString()} }`; - } - - /** - * Registers a Chrome DevTools custom formatter for Money instances. - * Call this once at app startup to enable pretty console output. - * - * To enable custom formatters in Chrome DevTools: - * 1. Open DevTools (F12) - * 2. Click Settings (gear icon) or press F1 - * 3. Under "Console", check "Custom formatters" - * - * After enabling, Money instances will display as: Money ₿1,234.00 - */ - static registerDevToolsFormatter(): void { - if (typeof window === 'undefined') return; - - const formatter = { - header: (obj: unknown) => { - if (!(obj instanceof Money)) return null; - return [ - 'div', - { style: 'font-weight: bold; color: #9c27b0;' }, - `Money ${obj.toLocaleString()}`, - ]; - }, - hasBody: (obj: unknown) => obj instanceof Money, - body: (obj: unknown) => { - if (!(obj instanceof Money)) return null; - const money = obj as Money; - return [ - 'div', - { style: 'margin-left: 12px;' }, - [ - 'div', - {}, - ['span', { style: 'color: #888;' }, 'currency: '], - money.currency, - ], - [ - 'div', - {}, - ['span', { style: 'color: #888;' }, 'amount: '], - money.amount().toString(), - ], - [ - 'div', - {}, - ['span', { style: 'color: #888;' }, 'formatted: '], - money.toLocaleString(), - ], - ]; - }, - }; - - // @ts-expect-error - devtoolsFormatters is a non-standard Chrome API - window.devtoolsFormatters = window.devtoolsFormatters || []; - // @ts-expect-error - devtoolsFormatters is a non-standard Chrome API - window.devtoolsFormatters.push(formatter); - } - constructor(data: MoneyInput) { const { baseUnit, minUnit, selectedUnit } = Money.getCurrencyDataForInput(data); @@ -338,6 +60,40 @@ export class Money { Object.freeze(this._data); } + /** + * Configure Money class. Delegates to CurrencyRegistry. + * + * @example + * ```typescript + * Money.configure({ + * currencies: { + * BTC: { baseUnit: 'sat' }, // Override BTC base unit + * EUR: { // Add new currency + * baseUnit: 'eur', + * units: [...] + * } + * } + * }); + * ``` + */ + static configure(config: MoneyConfiguration): void { + Money.registry.configure(config); + } + + /** + * Get registered currencies + */ + static getRegisteredCurrencies(): string[] { + return Money.registry.getRegisteredCurrencies(); + } + + /** + * Check if a currency is registered + */ + static isCurrencyRegistered(currency: string): boolean { + return Money.registry.isCurrencyRegistered(currency); + } + /** * Sum an array of moneys. * @@ -651,32 +407,140 @@ export class Money { }; }; - toJSON = () => { - return { - amount: this.toNumber(), - currency: this.currency, - }; - }; - /** * Converts the money to the provided currency based on the provided exchange rate. + * + * The exchange rate must be expressed in standard currency units (factor=1), not in + * sub-units like sats or cents. For example, use the BTC/USD rate (e.g., 89168), not + * a sat/USD rate. The source amount is automatically converted from its configured + * base unit to standard units before applying the rate, and the result is automatically + * converted to the configured base unit of the target currency. + * * @param currency Currency to convert the money to (target currency) - * @param exchangeRate Exchange rate to apply. The rate has to be in source/target currency format. E.g. if converting - * USD to BTC, the rate should be in USD/BTC format. If converting BTC to usd it should be in USD/BTC format. + * @param exchangeRate Exchange rate in source/target currency format using standard units. + * E.g. if converting BTC to USD, the rate should be the BTC/USD rate (how many USD per 1 BTC). */ convert = ( currency: U, exchangeRate: NumberInput, ): Money => { + const sourceCurrencyBaseUnit = getCurrencyBaseUnit(this.currency); const destinationCurrencyBaseUnit = getCurrencyBaseUnit(currency); - const amount = this.amount() - .mul(exchangeRate) + const amountInStandardUnit = this.amount() + .mul(sourceCurrencyBaseUnit.factor) + .mul(exchangeRate); + const amount = amountInStandardUnit + .div(destinationCurrencyBaseUnit.factor) .round(destinationCurrencyBaseUnit.decimals, Big.roundHalfUp); - return new Money({ amount, currency }); + return new Money({ + amount, + currency, + unit: destinationCurrencyBaseUnit.name, + }); }; + /** + * Returns a JSON representation of the money object. + * @returns {Object} A JSON object with the amount, currency, and unit. + */ + toJSON = () => { + return { + amount: this.toNumber(), + currency: this.currency, + unit: this.getCurrencyUnit().name, + }; + }; + + /** + * Returns the class tag for better console output. + * Shows as "Money" in Object.prototype.toString.call() + */ + get [Symbol.toStringTag](): string { + return 'Money'; + } + + /** + * Returns a formatted string representation for debugging. + * Visible when expanding the object in browser DevTools. + */ + get formatted(): string { + return this.toLocaleString(); + } + + /** + * Custom inspect method for Node.js console output. + * Shows formatted value instead of internal structure. + */ + [Symbol.for('nodejs.util.inspect.custom')](): string { + return `Money { ${this.toLocaleString()} }`; + } + + /** + * Registers a Chrome DevTools custom formatter for Money instances. + * Call this once at app startup to enable pretty console output. + * + * To enable custom formatters in Chrome DevTools: + * 1. Open DevTools (F12) + * 2. Click Settings (gear icon) or press F1 + * 3. Under "Console", check "Custom formatters" + * + * After enabling, Money instances will display as: Money ₿1,234.00 + */ + static registerDevToolsFormatter(): void { + if (typeof window === 'undefined') return; + + const formatter = { + header: (obj: unknown) => { + if (!(obj instanceof Money)) return null; + return [ + 'div', + { style: 'font-weight: bold; color: #9c27b0;' }, + `Money ${obj.toLocaleString()}`, + ]; + }, + hasBody: (obj: unknown) => obj instanceof Money, + body: (obj: unknown) => { + if (!(obj instanceof Money)) return null; + const money = obj as Money; + return [ + 'div', + { style: 'margin-left: 12px;' }, + [ + 'div', + {}, + ['span', { style: 'color: #888;' }, 'currency: '], + money.currency, + ], + [ + 'div', + {}, + ['span', { style: 'color: #888;' }, 'unit: '], + money.getCurrencyUnit().name, + ], + [ + 'div', + {}, + ['span', { style: 'color: #888;' }, 'amount: '], + money.amount().toString(), + ], + [ + 'div', + {}, + ['span', { style: 'color: #888;' }, 'formatted: '], + money.toLocaleString(), + ], + ]; + }, + }; + + // @ts-expect-error - devtoolsFormatters is a non-standard Chrome API + window.devtoolsFormatters = window.devtoolsFormatters || []; + // @ts-expect-error - devtoolsFormatters is a non-standard Chrome API + window.devtoolsFormatters.push(formatter); + } + private get currencyData(): CurrencyData { - return currencyDataMap[this.currency]; + return Money.registry.getCurrencyData(this.currency); } private static getCurrencyDataForInput( @@ -686,10 +550,7 @@ export class Money { minUnit: UnitData; selectedUnit: UnitData | undefined; } { - const currencyData = currencyDataMap[data.currency]; - if (!currencyData) { - throw new Error(`Unsupported currency: ${data.currency}`); - } + const currencyData = Money.registry.getCurrencyData(data.currency); const minUnit = currencyData.units.reduce((minItem, currentItem) => { return currentItem.factor.lt(minItem.factor) ? currentItem : minItem; diff --git a/app/lib/money/types.ts b/app/lib/money/types.ts index f6ec98ed7..a174a7077 100644 --- a/app/lib/money/types.ts +++ b/app/lib/money/types.ts @@ -2,19 +2,49 @@ import type { Big } from 'big.js'; export type NumberInput = number | string | Big; -/** supported currencies */ -export type Currency = 'USD' | 'BTC'; +declare global { + /** + * Extend this interface to add type-safe custom currencies. + * + * Example: + * ```typescript + * declare global { + * interface CustomCurrencies { + * EUR: { units: 'eur' | 'cent' }; + * } + * } + * ``` + */ + interface CustomCurrencies { + // Apps will augment this interface with their custom currencies + } +} + +/** Built-in currencies with full type support */ +export type KnownCurrency = 'USD' | 'BTC'; + +/** All supported currencies: known currencies, custom currencies, and runtime strings */ +export type Currency = KnownCurrency | keyof CustomCurrencies | (string & {}); export type UsdUnit = 'usd' | 'cent'; export type BtcUnit = 'btc' | 'sat' | 'msat'; -/** Unit to denominate the given currency */ -export type CurrencyUnit = T extends 'USD' +type KnownCurrencyUnit = T extends 'USD' ? UsdUnit : T extends 'BTC' ? BtcUnit : never; +/** Unit to denominate the given currency */ +export type CurrencyUnit = + T extends KnownCurrency + ? KnownCurrencyUnit + : T extends keyof CustomCurrencies + ? CustomCurrencies[T] extends { units: infer U } + ? U + : string + : string; + export type MoneyInput = { /** * Money amount @@ -80,7 +110,8 @@ export type MoneyData = { }; export type CurrencyDataMap = { - [K in Currency]: CurrencyData; + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + [key: string]: CurrencyData; }; export interface LocalizedStringParts { @@ -107,3 +138,59 @@ export interface LocalizedStringParts { /** Whether the currency symbol appears at the start or end */ currencySymbolPosition: 'prefix' | 'suffix'; } + +/** + * Partial unit data for configuration (functions optional for overrides) + */ +export type PartialUnitData = { + name: CurrencyUnit; + decimals?: number; + symbol?: string; + factor?: Big; + formatToParts?: ( + value: number, + options?: FormatOptions, + ) => Intl.NumberFormatPart[]; + format?: (value: number, options?: FormatOptions) => string; +}; + +/** + * Complete unit data (all fields required for new units) + */ +export type CompleteUnitData = { + name: CurrencyUnit; + decimals: number; + symbol: string; + factor: Big; + formatToParts: ( + value: number, + options?: FormatOptions, + ) => Intl.NumberFormatPart[]; + format: (value: number, options?: FormatOptions) => string; +}; + +/** + * Partial currency data for configuration + */ +export type PartialCurrencyData = { + baseUnit?: CurrencyUnit; + units?: Array>; +}; + +/** + * Complete currency data (all fields required for new currencies) + */ +export type CompleteCurrencyData = { + baseUnit: CurrencyUnit; + units: Array>; +}; + +/** + * Configuration for Money class + */ +export type MoneyConfiguration = { + currencies?: { + // biome-ignore lint/suspicious/noExplicitAny: Dynamic currency support requires any + [key: string]: PartialCurrencyData; + }; +};