diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index af7c7e20d..4b0947260 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -25,7 +25,8 @@ import { UploadFile } from './UploadFile'; import { UserInput } from './UserInput'; import { CurrencyInput } from '../ui/currency-input'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; -import { currencyConversion } from '~/utils/numbers'; +import { BigMath, currencyConversion } from '~/utils/numbers'; +import { isExpression, safeEvaluateExpression } from '~/utils/expression'; import { CurrencyConversionIcon } from '../ui/categoryIcons'; import { useSession } from 'next-auth/react'; import { isStringQueryParam } from '~/utils/queryParams'; @@ -111,6 +112,21 @@ export const AddOrEditExpensePage: React.FC<{ return; } + let finalAmount = amount; + if (isExpression(amtStr)) { + const evaluated = safeEvaluateExpression(amtStr); + if (null === evaluated) { + return; + } + finalAmount = BigMath.abs( + getCurrencyHelpersCached(currency).expressionResultToBigInt(evaluated), + ); + } + + if (0n === finalAmount) { + return; + } + setMultipleTransactions([]); setIsTransactionLoading(false); @@ -122,7 +138,7 @@ export const AddOrEditExpensePage: React.FC<{ { name: description, currency, - amount: amount * sign, + amount: finalAmount * sign, groupId: group?.id ?? null, splitType, participants: participants.map((p) => ({ @@ -194,6 +210,7 @@ export const AddOrEditExpensePage: React.FC<{ currency, isNegative, amount, + amtStr, participants, category, expenseDate, @@ -212,6 +229,7 @@ export const AddOrEditExpensePage: React.FC<{ setSingleTransaction, t, update, + getCurrencyHelpersCached, ]); const handleDescriptionChange = useCallback( @@ -315,6 +333,7 @@ export const AddOrEditExpensePage: React.FC<{ placeholder={t('expense_details.add_expense_details.amount_placeholder')} currency={currency} strValue={amtStr} + allowExpressions allowNegative hideSymbol onValueChange={onUpdateAmount} diff --git a/src/components/ui/currency-input.tsx b/src/components/ui/currency-input.tsx index 4e0ac4fc1..b7ba9c300 100644 --- a/src/components/ui/currency-input.tsx +++ b/src/components/ui/currency-input.tsx @@ -2,32 +2,73 @@ import React from 'react'; import { Input, InputProps } from './input'; import { cn } from '~/lib/utils'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { isExpression, safeEvaluateExpression, sanitizeExpressionInput } from '~/utils/expression'; const CurrencyInput: React.FC< Omit & { currency: string; strValue: string; onValueChange: (v: { strValue?: string; bigIntValue?: bigint }) => void; + allowExpressions?: boolean; allowNegative?: boolean; hideSymbol?: boolean; } -> = ({ className, currency, allowNegative, strValue, onValueChange, hideSymbol, ...props }) => { +> = ({ + className, + currency, + allowExpressions = false, + allowNegative, + strValue, + onValueChange, + hideSymbol, + ...props +}) => { const { getCurrencyHelpersCached } = useTranslationWithUtils(undefined); - const { format, parseToCleanString, toSafeBigInt, sanitizeInput } = - getCurrencyHelpersCached(currency); + const { + decimalDigits, + decimalSeparator, + alternativeDecimalSeparator, + format, + parseToCleanString, + toSafeBigInt, + expressionResultToBigInt, + sanitizeInput, + } = getCurrencyHelpersCached(currency); return ( onValueChange({ strValue: parseToCleanString(strValue, allowNegative) })} + onFocus={() => { + if (!allowExpressions || !isExpression(strValue)) { + onValueChange({ strValue: parseToCleanString(strValue, allowNegative) }); + } + }} onBlur={() => { + if (allowExpressions && isExpression(strValue)) { + return; + } + const formattedValue = format(strValue, { signed: allowNegative, hideSymbol }); return onValueChange({ strValue: formattedValue }); }} onChange={(e) => { const rawValue = e.target.value; + if (allowExpressions && isExpression(rawValue)) { + const strValue = sanitizeExpressionInput( + rawValue, + decimalDigits, + decimalSeparator, + alternativeDecimalSeparator, + ); + const evaluated = safeEvaluateExpression(strValue); + return onValueChange({ + strValue, + bigIntValue: null === evaluated ? 0n : expressionResultToBigInt(evaluated), + }); + } + const strValue = sanitizeInput(rawValue, allowNegative, true); const bigIntValue = toSafeBigInt(strValue, allowNegative); onValueChange({ strValue, bigIntValue }); diff --git a/src/tests/expression.test.ts b/src/tests/expression.test.ts new file mode 100644 index 000000000..5d50e154d --- /dev/null +++ b/src/tests/expression.test.ts @@ -0,0 +1,55 @@ +import { isExpression, safeEvaluateExpression, sanitizeExpressionInput } from '../utils/expression'; + +describe('isExpression', () => { + it.each([ + ['123.45', false], + ['-123.45', false], + ['-', false], + ['30.5+10.75-12-1.86', true], + ['10*5', true], + ['10/4', true], + ['(2+3)*4', true], + ])('returns %p for %p', (input, expected) => { + expect(isExpression(input)).toBe(expected); + }); +}); + +describe('sanitizeExpressionInput', () => { + it('keeps arithmetic and decimal characters only', () => { + expect(sanitizeExpressionInput('$30.5 + 10,75 xyz', 2)).toBe('30.5+10.75'); + }); + + it('limits each operand to the currency precision', () => { + expect(sanitizeExpressionInput('1.234+3.456/0.789', 2)).toBe('1.23+3.45/0.78'); + }); + + it('normalizes alternate decimal marks to the locale separator', () => { + expect(sanitizeExpressionInput('12,5+3.75', 2, '.', ',')).toBe('12.5+3.75'); + expect(sanitizeExpressionInput('12,5+3.75', 2, ',', '.')).toBe('12,5+3,75'); + }); + + it('drops fractional digits for zero-decimal currencies', () => { + expect(sanitizeExpressionInput('12.34+5.6', 0)).toBe('12+5'); + }); +}); + +describe('safeEvaluateExpression', () => { + it.each([ + ['30.5+10.75-12-1.86', { numerator: 2739n, denominator: 100n }], + ['2+3*4', { numerator: 14n, denominator: 1n }], + ['(2+3)*4', { numerator: 20n, denominator: 1n }], + ['10/4', { numerator: 5n, denominator: 2n }], + ['2++3', { numerator: 5n, denominator: 1n }], + ['0.1+0.2', { numerator: 3n, denominator: 10n }], + ['1,5+2', { numerator: 7n, denominator: 2n }], + ['12,5+3,75', { numerator: 65n, denominator: 4n }], + ['9007199254740992+1', { numerator: 9007199254740993n, denominator: 1n }], + ['-10+5', { numerator: -5n, denominator: 1n }], + ])('evaluates %p exactly', (input, expected) => { + expect(safeEvaluateExpression(input)).toEqual(expected); + }); + + it.each(['', 'abc', '30.5+', '()', '(2+3', '30/0', '1.2.3'])('rejects %p', (input) => { + expect(safeEvaluateExpression(input)).toBeNull(); + }); +}); diff --git a/src/tests/number.test.ts b/src/tests/number.test.ts index 15c14456a..3341b48a9 100644 --- a/src/tests/number.test.ts +++ b/src/tests/number.test.ts @@ -2,6 +2,21 @@ import { currencyConversion, getCurrencyHelpers } from '../utils/numbers'; describe('getCurrencyHelpers', () => { + describe('expressionResultToBigInt', () => { + it('converts exact expression results to minor currency units', () => { + const { expressionResultToBigInt } = getCurrencyHelpers({ currency: 'USD' }); + + expect(expressionResultToBigInt({ numerator: 2739n, denominator: 100n })).toBe(2739n); + expect(expressionResultToBigInt({ numerator: 1n, denominator: 3n })).toBe(33n); + }); + + it('rounds results using the currency precision', () => { + const { expressionResultToBigInt } = getCurrencyHelpers({ currency: 'JPY' }); + + expect(expressionResultToBigInt({ numerator: 7n, denominator: 2n })).toBe(4n); + }); + }); + describe('toUIString', () => { describe('en-US locale', () => { const locale = 'en-US'; diff --git a/src/utils/expression.ts b/src/utils/expression.ts new file mode 100644 index 000000000..b15c83d33 --- /dev/null +++ b/src/utils/expression.ts @@ -0,0 +1,206 @@ +export interface ExpressionResult { + numerator: bigint; + denominator: bigint; +} + +export const isExpression = (input: string): boolean => + /[+*/()]/.test(input) || (input.length > 1 && input.slice(1).includes('-')); + +export const sanitizeExpressionInput = ( + input: string, + decimalDigits: number, + decimalSeparator = '.', + alternativeDecimalSeparator = decimalSeparator === '.' ? ',' : '.', +): string => { + let cleaned = ''; + let hasDecimalSeparator = false; + let fractionalDigits = 0; + let ignoreFraction = false; + + input.split('').forEach((character) => { + if (/[0-9]/.test(character)) { + if (ignoreFraction) { + return; + } + if (hasDecimalSeparator) { + if (fractionalDigits >= decimalDigits) { + return; + } + fractionalDigits += 1; + } + cleaned += character; + return; + } + + if (character === decimalSeparator || character === alternativeDecimalSeparator) { + if (0 === decimalDigits) { + ignoreFraction = true; + } else if (!hasDecimalSeparator) { + cleaned += decimalSeparator; + hasDecimalSeparator = true; + } + return; + } + + if (['+', '-', '*', '/', '(', ')'].includes(character)) { + cleaned += character; + hasDecimalSeparator = false; + fractionalDigits = 0; + ignoreFraction = false; + } + }); + + return cleaned; +}; + +const abs = (value: bigint) => (0n > value ? -value : value); + +const gcd = (a: bigint, b: bigint): bigint => (0n === b ? a : gcd(b, a % b)); + +const reduce = ({ numerator, denominator }: ExpressionResult): ExpressionResult => { + if (0n > denominator) { + numerator = -numerator; + denominator = -denominator; + } + + if (0n === numerator) { + return { numerator: 0n, denominator: 1n }; + } + + const divisor = gcd(abs(numerator), denominator); + return { + numerator: numerator / divisor, + denominator: denominator / divisor, + }; +}; + +const add = (left: ExpressionResult, right: ExpressionResult): ExpressionResult => + reduce({ + numerator: left.numerator * right.denominator + right.numerator * left.denominator, + denominator: left.denominator * right.denominator, + }); + +const multiply = (left: ExpressionResult, right: ExpressionResult): ExpressionResult => + reduce({ + numerator: left.numerator * right.numerator, + denominator: left.denominator * right.denominator, + }); + +const divide = (left: ExpressionResult, right: ExpressionResult): ExpressionResult | null => { + if (0n === right.numerator) { + return null; + } + + return reduce({ + numerator: left.numerator * right.denominator, + denominator: left.denominator * right.numerator, + }); +}; + +const negate = (value: ExpressionResult): ExpressionResult => ({ + numerator: -value.numerator, + denominator: value.denominator, +}); + +/** Evaluates an arithmetic expression exactly, returning null for invalid input or division by zero. */ +export const safeEvaluateExpression = (input: string): ExpressionResult | null => { + const normalized = input.replace(/\s/g, '').replaceAll(',', '.'); + if ('' === normalized) { + return null; + } + + let pos = 0; + + const peek = (): string => (pos < normalized.length ? normalized[pos]! : ''); + const consume = (): string => normalized[pos++]!; + + const parseExpression = (): ExpressionResult | null => { + let left = parseTerm(); + if (null === left) { + return null; + } + + while ('+' === peek() || '-' === peek()) { + const op = consume(); + const right = parseTerm(); + if (null === right) { + return null; + } + left = '+' === op ? add(left, right) : add(left, negate(right)); + } + return left; + }; + + const parseTerm = (): ExpressionResult | null => { + let left = parseFactor(); + if (null === left) { + return null; + } + + while ('*' === peek() || '/' === peek()) { + const op = consume(); + const right = parseFactor(); + if (null === right) { + return null; + } + if ('*' === op) { + left = multiply(left, right); + } else { + left = divide(left, right); + if (null === left) { + return null; + } + } + } + return left; + }; + + const parseFactor = (): ExpressionResult | null => { + if ('+' === peek()) { + consume(); + return parseFactor(); + } + if ('-' === peek()) { + consume(); + const factor = parseFactor(); + return null === factor ? null : negate(factor); + } + return parsePrimary(); + }; + + const parsePrimary = (): ExpressionResult | null => { + if ('(' === peek()) { + consume(); + const result = parseExpression(); + if (null === result || ')' !== peek()) { + return null; + } + consume(); + return result; + } + return parseNumber(); + }; + + const parseNumber = (): ExpressionResult | null => { + let numStr = ''; + while (pos < normalized.length && /[0-9.]/.test(peek())) { + numStr += consume(); + } + if ('' === numStr || '.' === numStr) { + return null; + } + + const [integerPart = '', fractionPart = ''] = numStr.split('.'); + if (numStr.split('.').length > 2) { + return null; + } + + return reduce({ + numerator: BigInt(`${integerPart || '0'}${fractionPart}`), + denominator: 10n ** BigInt(fractionPart.length), + }); + }; + + const result = parseExpression(); + return null === result || pos !== normalized.length ? null : result; +}; diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index b39912e58..a295b4000 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -1,4 +1,5 @@ import { CURRENCIES, type CurrencyCode, isCurrencyCode } from '~/lib/currency'; +import type { ExpressionResult } from '~/utils/expression'; export const getCurrencyHelpers = ({ locale = 'en-US', @@ -37,6 +38,9 @@ export const getCurrencyHelpers = ({ return parseToBigIntBeforeSubmit(stringNumber); }; + const expressionResultToBigInt = ({ numerator, denominator }: ExpressionResult): bigint => + BigMath.roundDiv(numerator * decimalMultiplierN, denominator); + /* Parse sanitized string to number before submit */ const parseToBigIntBeforeSubmit = (stringNumber: string | number | bigint): bigint => { if (typeof stringNumber === 'number') { @@ -231,12 +235,16 @@ export const getCurrencyHelpers = ({ }; return { + decimalDigits, + decimalSeparator, + alternativeDecimalSeparator, parseToCleanString, toUIString, format, formatter, sanitizeInput, toSafeBigInt, + expressionResultToBigInt, }; };