diff --git a/messages/en-US.json b/messages/en-US.json index f5ded7c..10f5b74 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -141,6 +141,10 @@ "label": "Income date", "description": "Enter the date the income was received." }, + "currencyField": { + "label": "Currency of income", + "description": "The currency in which the income was received." + }, "categoryFieldDescription": "Select the income category.", "paidByField": { "label": "Received by", @@ -165,6 +169,10 @@ "label": "Expense date", "description": "Enter the date the expense was paid." }, + "currencyField": { + "label": "Currency of expense", + "description": "The currency in which the expense was paid." + }, "categoryFieldDescription": "Select the expense category.", "paidByField": { "label": "Paid by", @@ -190,6 +198,27 @@ "amountField": { "label": "Amount" }, + "conversionUnavailable": "To set a different currency per expense and convert amounts, select a non-custom currency for the group.", + "originalAmountField": { + "label": "Amount to convert" + }, + "conversionRateField": { + "useApi": "Use rates from Frankfurter", + "useCustom": "Use custom rate", + "label": "Exchange rate" + }, + "conversionRateState": { + "loading": "Getting exchange rates…", + "success": "Obtained rates:", + "error": "Oops, we could not get the most recent rates.", + "staleRate": "Using rate:", + "noRate": "Enter a custom rate below.", + "currencyNotFound": "Oops, Frankfurter does not have the rate for this currency at this day.", + "noDate": "Enter the expense date to get a conversion rate.", + "dateMismatch": "Rates from date: {date}", + "refresh": "Refresh", + "customRate": "Using custom rate" + }, "isReimbursementField": { "label": "This is a reimbursement" }, @@ -331,6 +360,7 @@ "amountRequired": "You must enter an amount.", "amountNotZero": "The amount must not be zero.", "amountTenMillion": "The amount must be lower than 10,000,000.", + "ratePositive": "The rate must be strictly greater than zero.", "paidByRequired": "You must select a participant.", "paidForMin1": "The expense must be paid for at least one participant.", "noZeroShares": "All shares must be higher than 0.", diff --git a/package-lock.json b/package-lock.json index 21b75a1..ae08255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "server-only": "^0.0.1", "sharp": "^0.33.2", "superjson": "^2.2.1", + "swr": "^2.3.3", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.0.6", @@ -11040,7 +11041,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -16850,6 +16850,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", + "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -17369,6 +17382,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index a592016..d4b5be9 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "server-only": "^0.0.1", "sharp": "^0.33.2", "superjson": "^2.2.1", + "swr": "^2.3.3", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.0.6", diff --git a/prisma/migrations/20250414213819_add_currency_conversion_in_expense/migration.sql b/prisma/migrations/20250414213819_add_currency_conversion_in_expense/migration.sql new file mode 100644 index 0000000..43bcbaa --- /dev/null +++ b/prisma/migrations/20250414213819_add_currency_conversion_in_expense/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30), +ADD COLUMN "originalAmount" INTEGER, +ADD COLUMN "originalCurrency" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce66675..820609a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,7 +16,7 @@ model Group { name String information String? @db.Text currency String @default("$") - currencyCode String? + currencyCode String? participants Participant[] expenses Expense[] activities Activity[] @@ -40,25 +40,28 @@ model Category { } model Expense { - id String @id - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date - title String - category Category? @relation(fields: [categoryId], references: [id]) - categoryId Int @default(0) - amount Int - paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade) - paidById String - paidFor ExpensePaidFor[] - groupId String - isReimbursement Boolean @default(false) - splitMode SplitMode @default(EVENLY) - createdAt DateTime @default(now()) - documents ExpenseDocument[] - notes String? + id String @id + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date + title String + category Category? @relation(fields: [categoryId], references: [id]) + categoryId Int @default(0) + amount Int + originalAmount Int? + originalCurrency String? + conversionRate Decimal? + paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade) + paidById String + paidFor ExpensePaidFor[] + groupId String + isReimbursement Boolean @default(false) + splitMode SplitMode @default(EVENLY) + createdAt DateTime @default(now()) + documents ExpenseDocument[] + notes String? - recurrenceRule RecurrenceRule? @default(NONE) - recurringExpenseLink RecurringExpenseLink? + recurrenceRule RecurrenceRule? @default(NONE) + recurringExpenseLink RecurringExpenseLink? recurringExpenseLinkId String? } @@ -79,16 +82,16 @@ enum SplitMode { } model RecurringExpenseLink { - id String @id - groupId String - currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade) - currentFrameExpenseId String @unique + id String @id + groupId String + currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade) + currentFrameExpenseId String @unique // Note: We do not want to link to the next expense because once it is created, it should be // treated as it's own independent entity. This means that if a user wants to delete an Expense // and any prior related recurring expenses, they'll need to delete them one by one. nextExpenseCreatedAt DateTime? - nextExpenseDate DateTime + nextExpenseDate DateTime @@index([groupId]) @@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)]) diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index 266657a..8128e27 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -1,4 +1,5 @@ import { CategorySelector } from '@/components/category-selector' +import { CurrencySelector } from '@/components/currency-selector' import { ExpenseDocumentsInput } from '@/components/expense-documents-input' import { SubmitButton } from '@/components/submit-button' import { Button } from '@/components/ui/button' @@ -32,9 +33,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Locale } from '@/i18n' import { randomId } from '@/lib/api' +import { defaultCurrencyList, getCurrency } from '@/lib/currency' import { RuntimeFeatureFlags } from '@/lib/featureFlags' -import { useActiveUser } from '@/lib/hooks' +import { useActiveUser, useCurrencyRate } from '@/lib/hooks' import { ExpenseFormValues, SplittingOptions, @@ -51,7 +54,7 @@ import { import { AppRouterOutput } from '@/trpc/routers/_app' import { zodResolver } from '@hookform/resolvers/zod' import { RecurrenceRule } from '@prisma/client' -import { Save } from 'lucide-react' +import { ChevronRight, Save } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { useSearchParams } from 'next/navigation' @@ -161,7 +164,7 @@ export function ExpenseForm({ runtimeFeatureFlags: RuntimeFeatureFlags }) { const t = useTranslations('ExpenseForm') - const locale = useLocale() + const locale = useLocale() as Locale const isCreate = expense === undefined const searchParams = useSearchParams() @@ -187,6 +190,15 @@ export function ExpenseForm({ title: expense.title, expenseDate: expense.expenseDate ?? new Date(), amount: amountAsDecimal(expense.amount, groupCurrency), + originalCurrency: + expense.originalCurrency ?? + group.currencyCode ?? + ('' as unknown as undefined), + originalAmount: + expense.originalAmount ?? ('' as unknown as undefined), + conversionRate: expense.conversionRate + ? expense.conversionRate.toNumber() + : ('' as unknown as undefined), category: expense.categoryId, paidBy: expense.paidById, paidFor: expense.paidFor.map(({ participantId, shares }) => ({ @@ -208,9 +220,12 @@ export function ExpenseForm({ title: t('reimbursement'), expenseDate: new Date(), amount: amountAsDecimal( - Number(searchParams.get('amount')) || 0, - groupCurrency, + Number(searchParams.get('amount')) || 0, + groupCurrency, ), + originalCurrency: group.currencyCode ?? ('' as unknown as undefined), + originalAmount: '' as unknown as undefined, + conversionRate: '' as unknown as undefined, category: 1, // category with Id 1 is Payment paidBy: searchParams.get('from') ?? undefined, paidFor: [ @@ -234,6 +249,9 @@ export function ExpenseForm({ ? new Date(searchParams.get('date') as string) : new Date(), amount: Number(searchParams.get('amount')) || 0, + originalCurrency: group.currencyCode ?? ('' as unknown as undefined), + originalAmount: '' as unknown as undefined, + conversionRate: '' as unknown as undefined, category: searchParams.get('categoryId') ? Number(searchParams.get('categoryId')) : 0, // category with Id 0 is General @@ -272,6 +290,12 @@ export function ExpenseForm({ ? amountAsMinorUnits(shares, groupCurrency) : shares, })) + + // Currency should be blank if same as group currency + if (!conversionRequired) { + delete values.originalAmount + delete values.originalCurrency + } return onSubmit(values, activeUserId ?? undefined) } @@ -282,6 +306,23 @@ export function ExpenseForm({ const sExpense = isIncome ? 'Income' : 'Expense' + const originalCurrency = getCurrency( + form.getValues('originalCurrency'), + locale, + 'Custom', + ) + const exchangeRate = useCurrencyRate( + form.watch('expenseDate'), + form.watch('originalCurrency') ?? '', + groupCurrency.code, + ) + + const conversionRequired = + group.currencyCode && + group.currencyCode.length && + originalCurrency.code.length && + originalCurrency.code !== group.currencyCode + useEffect(() => { setManuallyEditedParticipants(new Set()) }, [form.watch('splitMode'), form.watch('amount')]) @@ -340,6 +381,71 @@ export function ExpenseForm({ form.watch('splitMode'), ]) + const [usingCustomConversionRate, setUsingCustomConversionRate] = useState( + !!form.formState.defaultValues?.conversionRate, + ) + + useEffect(() => { + if (!usingCustomConversionRate && exchangeRate.data) { + form.setValue('conversionRate', exchangeRate.data) + } + }, [exchangeRate.data, usingCustomConversionRate]) + + useEffect(() => { + if (!form.getFieldState('originalAmount').isTouched) return + const originalAmount = form.getValues('originalAmount') ?? 0 + const conversionRate = form.getValues('conversionRate') + + if (conversionRate && originalAmount) { + const rate = Number(conversionRate) + const convertedAmount = originalAmount * rate + if (!Number.isNaN(convertedAmount)) { + const v = enforceCurrencyPattern( + convertedAmount.toFixed(groupCurrency.decimal_digits), + ) + const income = Number(v) < 0 + setIsIncome(income) + if (income) form.setValue('isReimbursement', false) + form.setValue('amount', Number(v)) + } + } + }, [ + form.watch('originalAmount'), + form.watch('conversionRate'), + form.getFieldState('originalAmount').isTouched, + ]) + + let conversionRateMessage = '' + if (exchangeRate.isLoading) { + conversionRateMessage = t('conversionRateState.loading') + } else { + let ratesDisplay = '' + if (exchangeRate.data) { + // non breaking spaces so the rate text is not split with line feeds + ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${ + group.currencyCode + }\xa0${exchangeRate.data}` + } + if (exchangeRate.error) { + if (exchangeRate.error instanceof RangeError && exchangeRate.data) + conversionRateMessage = t('conversionRateState.dateMismatch', { + date: exchangeRate.error.message, + }) + else { + conversionRateMessage = t('conversionRateState.error') + } + conversionRateMessage += + ' ' + + (ratesDisplay.length + ? `${t('conversionRateState.staleRate')} ${ratesDisplay}` + : t('conversionRateState.noRate')) + } else { + conversionRateMessage = ratesDisplay.length + ? `${t('conversionRateState.success')} ${ratesDisplay}` + : t('conversionRateState.currencyNotFound') + } + } + return (