From 7e7bb94d3b5d5e2e263c2d6f22f1c2edfc365c8b Mon Sep 17 00:00:00 2001 From: Steven Sengchanh <91092101+whimcomp@users.noreply.github.com> Date: Thu, 17 Apr 2025 01:14:01 +0200 Subject: [PATCH] Add currency and exchange rate with Frankfurter per expense --- messages/en-US.json | 30 + package-lock.json | 24 +- package.json | 1 + .../migration.sql | 4 + prisma/schema.prisma | 51 +- .../[groupId]/expenses/expense-form.tsx | 605 ++++++++++++++---- .../[groupId]/expenses/export/csv/route.ts | 44 +- .../[groupId]/expenses/export/json/route.ts | 3 + src/lib/api.ts | 6 + src/lib/hooks.ts | 61 ++ src/lib/schemas.ts | 36 +- src/trpc/client.tsx | 10 + src/trpc/init.ts | 10 + 13 files changed, 716 insertions(+), 169 deletions(-) create mode 100644 prisma/migrations/20250414213819_add_currency_conversion_in_expense/migration.sql 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 (
@@ -406,11 +512,175 @@ export function ExpenseForm({ )} /> + ( + + {t(`${sExpense}.currencyField.label`)} + + {group.currencyCode ? ( + onChange(v)} + /> + ) : ( + + )} + + + {t(`${sExpense}.currencyField.description`)}{' '} + {!group.currencyCode && t('conversionUnavailable')} + + + + )} + /> + +
+ ( + + {t('originalAmountField.label')} +
+ {originalCurrency.symbol} + + { + const v = enforceCurrencyPattern(event.target.value) + onChange(v) + }} + {...field} + onFocus={(e) => { + const target = e.currentTarget + setTimeout(() => target.select(), 1) + }} + /> + +
+ + {isNaN(form.getValues('expenseDate').getTime()) ? ( + t('conversionRateState.noDate') + ) : form.getValues('expenseDate') && + !usingCustomConversionRate ? ( + <> + {conversionRateMessage} + {!exchangeRate.isLoading && ( + + )} + + ) : ( + t('conversionRateState.customRate') + )} + + +
+ )} + /> + + + + + + ( + + {t('conversionRateField.label')} +
+ + {originalCurrency.symbol} 1 = {group.currency} + + + { + const v = enforceCurrencyPattern( + event.target.value, + ) + onChange(v) + }} + {...field} + onFocus={(e) => { + const target = e.currentTarget + setTimeout(() => target.select(), 1) + }} + /> + +
+ +
+ )} + /> +
+
+
+ ( + + {t('categoryField.label')} + + + {t(`${sExpense}.categoryFieldDescription`)} + + + + )} + /> + ( - + {t('amountField.label')}
{group.currency} @@ -463,28 +733,6 @@ export function ExpenseForm({ )} /> - ( - - {t('categoryField.label')} - - - {t(`${sExpense}.categoryFieldDescription`)} - - - - )} - /> - @@ -707,106 +955,209 @@ export function ExpenseForm({ )} - {form.getValues().splitMode !== 'EVENLY' && ( - participant === id, - )}].shares`} - render={() => { - const sharesLabel = ( - - participant === id, - ), - })} - > - {match(form.getValues().splitMode) - .with('BY_SHARES', () => ( - <>{t('shares')} - )) - .with('BY_PERCENTAGE', () => <>%) - .with('BY_AMOUNT', () => ( - <>{group.currency} - )) - .otherwise(() => ( - <> - ))} - - ) - return ( -
-
- {form.getValues().splitMode === - 'BY_AMOUNT' && sharesLabel} - - - participant === id, - ), - )} - className="text-base w-[80px] -my-2" - type="text" - disabled={ - !field.value?.some( - ({ participant }) => - participant === id, - ) - } - value={ - field.value?.find( - ({ participant }) => - participant === id, - )?.shares - } - onChange={(event) => { - field.onChange( - field.value.map((p) => - p.participant === id - ? { - participant: id, - shares: - enforceCurrencyPattern( - event.target.value, - ), - } - : p, +
+ {form.getValues().splitMode === 'BY_AMOUNT' && + !!conversionRequired && ( + participant === id, + )}].originalAmount`} + render={() => { + const sharesLabel = ( + + participant === id, + ), + })} + > + {originalCurrency.symbol} + + ) + return ( +
+
+ {sharesLabel} + + + participant === id, + ), + )} + className="text-base w-[80px] -my-2" + type="text" + inputMode="decimal" + disabled={ + !field.value?.some( + ({ participant }) => + participant === id, + ) + } + value={ + field.value.find( + ({ participant }) => + participant === id, + )?.originalAmount ?? '' + } + onChange={(event) => { + const originalAmount = Number( + event.target.value, + ) + let convertedAmount = '' + if ( + !Number.isNaN( + originalAmount, + ) && + exchangeRate.data + ) { + convertedAmount = ( + originalAmount * + exchangeRate.data + ).toFixed( + groupCurrency.decimal_digits, + ) + } + field.onChange( + field.value.map((p) => + p.participant === id + ? { + participant: id, + originalAmount: + event.target + .value, + shares: + enforceCurrencyPattern( + convertedAmount, + ), + } + : p, + ), + ) + setManuallyEditedParticipants( + (prev) => + new Set(prev).add(id), + ) + }} + step={ + 10 ** + -originalCurrency.decimal_digits + } + /> + + +
+
+ ) + }} + /> + )} + {form.getValues().splitMode !== 'EVENLY' && ( + participant === id, + )}].shares`} + render={() => { + const sharesLabel = ( + + participant === id, + ), + })} + > + {match(form.getValues().splitMode) + .with('BY_SHARES', () => ( + <>{t('shares')} + )) + .with('BY_PERCENTAGE', () => <>%) + .with('BY_AMOUNT', () => ( + <>{group.currency} + )) + .otherwise(() => ( + <> + ))} + + ) + return ( +
+
+ {form.getValues().splitMode === + 'BY_AMOUNT' && sharesLabel} + + + participant === id, ), - ) - setManuallyEditedParticipants( - (prev) => new Set(prev).add(id), - ) - }} - inputMode={ - form.getValues().splitMode === - 'BY_AMOUNT' - ? 'decimal' - : 'numeric' - } - step={ - form.getValues().splitMode === - 'BY_AMOUNT' - ? 0.01 - : 1 - } - /> - - {[ - 'BY_SHARES', - 'BY_PERCENTAGE', - ].includes( - form.getValues().splitMode, - ) && sharesLabel} + )} + className="text-base w-[80px] -my-2" + type="text" + disabled={ + !field.value?.some( + ({ participant }) => + participant === id, + ) + } + value={ + field.value?.find( + ({ participant }) => + participant === id, + )?.shares + } + onChange={(event) => { + field.onChange( + field.value.map((p) => + p.participant === id + ? { + participant: id, + shares: + enforceCurrencyPattern( + event.target + .value, + ), + } + : p, + ), + ) + setManuallyEditedParticipants( + (prev) => + new Set(prev).add(id), + ) + }} + inputMode={ + form.getValues().splitMode === + 'BY_AMOUNT' + ? 'decimal' + : 'numeric' + } + step={ + form.getValues().splitMode === + 'BY_AMOUNT' + ? 10 ** + -groupCurrency.decimal_digits + : 1 + } + /> + + {[ + 'BY_SHARES', + 'BY_PERCENTAGE', + ].includes( + form.getValues().splitMode, + ) && sharesLabel} +
+
- -
- ) - }} - /> - )} + ) + }} + /> + )} +
) }} diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index c2f5799..2e7cb11 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -1,3 +1,4 @@ +import { getCurrency } from '@/lib/currency' import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils' import { Parser } from '@json2csv/plainjs' import { PrismaClient } from '@prisma/client' @@ -38,6 +39,9 @@ export async function GET( title: true, category: { select: { name: true } }, amount: true, + originalAmount: true, + originalCurrency: true, + conversionRate: true, paidById: true, paidFor: { select: { participantId: true, shares: true } }, isReimbursement: true, @@ -54,30 +58,29 @@ export async function GET( /* - CSV Structure: - - -------------------------------------------------------------- - | Date | Description | Category | Currency | Cost - -------------------------------------------------------------- - | Is Reimbursement | Split mode | UserA | UserB - -------------------------------------------------------------- - - Columns: + CSV Columns: - Date: The date of the expense. - Description: A brief description of the expense. - Category: The category of the expense (e.g., Food, Travel, etc.). - Currency: The currency in which the expense is recorded. - Cost: The amount spent. + - Original cost: The amount spent in the original currency. + - Original currency: The currency the amount was originally spent in. + - Conversion rate: The rate used to convert the amount. - Is Reimbursement: Whether the expense is a reimbursement or not. - Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount). - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user). - Example Row: - ------------------------------------------------------------------------------------------ - | 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane - ------------------------------------------------------------------------------------------ + Example Table: + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ + | Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ + | 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ + | 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+ -*/ + */ const fields = [ { label: 'Date', value: 'date' }, @@ -85,6 +88,9 @@ export async function GET( { label: 'Category', value: 'categoryName' }, { label: 'Currency', value: 'currency' }, { label: 'Cost', value: 'amount' }, + { label: 'Original cost', value: 'originalAmount' }, + { label: 'Original currency', value: 'originalCurrency' }, + { label: 'Conversion rate', value: 'conversionRate' }, { label: 'Is Reimbursement', value: 'isReimbursement' }, { label: 'Split mode', value: 'splitMode' }, ...group.participants.map((participant) => ({ @@ -101,6 +107,16 @@ export async function GET( categoryName: expense.category?.name || '', currency: group.currencyCode ?? group.currency, amount: formatAmountAsDecimal(expense.amount, currency), + originalAmount: expense.originalAmount + ? formatAmountAsDecimal( + expense.originalAmount, + getCurrency(expense.originalCurrency), + ) + : null, + originalCurrency: expense.originalCurrency, + conversionRate: expense.conversionRate + ? expense.conversionRate.toString() + : null, isReimbursement: expense.isReimbursement ? 'Yes' : 'No', splitMode: splitModeLabel[expense.splitMode], ...Object.fromEntries( diff --git a/src/app/groups/[groupId]/expenses/export/json/route.ts b/src/app/groups/[groupId]/expenses/export/json/route.ts index e62447c..2ee54e7 100644 --- a/src/app/groups/[groupId]/expenses/export/json/route.ts +++ b/src/app/groups/[groupId]/expenses/export/json/route.ts @@ -20,6 +20,9 @@ export async function GET( title: true, category: { select: { grouping: true, name: true } }, amount: true, + originalAmount: true, + originalCurrency: true, + conversionRate: true, paidById: true, paidFor: { select: { participantId: true, shares: true } }, isReimbursement: true, diff --git a/src/lib/api.ts b/src/lib/api.ts index 18c7780..43fa5d4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -71,6 +71,9 @@ export async function createExpense( expenseDate: expenseFormValues.expenseDate, categoryId: expenseFormValues.category, amount: expenseFormValues.amount, + originalAmount: expenseFormValues.originalAmount, + originalCurrency: expenseFormValues.originalCurrency, + conversionRate: expenseFormValues.conversionRate, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, splitMode: expenseFormValues.splitMode, @@ -206,6 +209,9 @@ export async function updateExpense( data: { expenseDate: expenseFormValues.expenseDate, amount: expenseFormValues.amount, + originalAmount: expenseFormValues.originalAmount, + originalCurrency: expenseFormValues.originalCurrency, + conversionRate: expenseFormValues.conversionRate, title: expenseFormValues.title, categoryId: expenseFormValues.category, paidById: expenseFormValues.paidBy, diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 56e7744..a8f2369 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,4 +1,6 @@ +import dayjs from 'dayjs' import { useEffect, useState } from 'react' +import useSWR, { Fetcher } from 'swr' export function useMediaQuery(query: string): boolean { const getMatches = (query: string): boolean => { @@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) { return activeUser } + +interface FrankfurterAPIResponse { + base: string + date: string + rates: Record +} + +const fetcher: Fetcher = (url: string) => + fetch(url).then(async (res) => { + if (!res.ok) + throw new TypeError('Unsuccessful response from API', { cause: res }) + return res.json() as Promise + }) + +export function useCurrencyRate( + date: Date, + baseCurrency: string, + targetCurrency: string, +) { + const dateString = dayjs(date).format('YYYY-MM-DD') + + // Only send request if both currency codes are given and not the same + const url = + !isNaN(date.getTime()) && + !!baseCurrency.length && + !!targetCurrency.length && + baseCurrency !== targetCurrency && + `https://api.frankfurter.app/${dateString}?base=${baseCurrency}` + const { data, error, isLoading, mutate } = useSWR( + url, + fetcher, + { shouldRetryOnError: false, revalidateOnFocus: false }, + ) + + if (data) { + let exchangeRate = undefined + let sentError = error + if (!error && data.date !== dateString) { + // this happens if for example, the requested date is in the future. + sentError = new RangeError(data.date) + } + if (data.rates[targetCurrency]) { + exchangeRate = data.rates[targetCurrency] + } + return { + data: exchangeRate, + error: sentError, + isLoading, + refresh: mutate, + } + } + + return { + data, + error, + isLoading, + refresh: mutate, + } +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 63f6998..e9301a4 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -32,6 +32,19 @@ export const groupFormSchema = z export type GroupFormValues = z.infer +const inputCoercedToNumber = z.union([ + z.number(), + z.string().transform((value, ctx) => { + const valueAsNumber = Number(value) + if (Number.isNaN(valueAsNumber)) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'invalidNumber', + }) + return valueAsNumber + }), +]) + export const expenseFormSchema = z .object({ expenseDate: z.coerce.date(), @@ -55,11 +68,27 @@ export const expenseFormSchema = z ) .refine((amount) => amount != 0, 'amountNotZero') .refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'), + originalAmount: z + .union([ + z.literal('').transform(() => undefined), + inputCoercedToNumber + .refine((amount) => amount != 0, 'amountNotZero') + .refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'), + ]) + .optional(), + originalCurrency: z.union([z.string().length(3).nullish(), z.literal('')]), + conversionRate: z + .union([ + z.literal('').transform(() => undefined), + inputCoercedToNumber.refine((amount) => amount > 0, 'ratePositive'), + ]) + .optional(), paidBy: z.string({ required_error: 'paidByRequired' }), paidFor: z .array( z.object({ participant: z.string(), + originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved. shares: z.union([ z.number(), z.string().transform((value, ctx) => { @@ -163,17 +192,18 @@ export const expenseFormSchema = z // Format the share split as a number (if from form submission) return { ...expense, - paidFor: expense.paidFor.map(({ participant, shares }) => { + paidFor: expense.paidFor.map((paidFor) => { + const shares = paidFor.shares if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') { // For splitting not by amount, preserve the previous behaviour of multiplying the share by 100 return { - participant, + ...paidFor, shares: Math.round(Number(shares) * 100), } } // Otherwise, no need as the number will have been formatted according to currency. return { - participant, + ...paidFor, shares: Number(shares), } }), diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx index 7d9065f..99cef69 100644 --- a/src/trpc/client.tsx +++ b/src/trpc/client.tsx @@ -1,4 +1,5 @@ 'use client' // <-- to make sure we can mount the Provider from a server component +import { Prisma } from '@prisma/client' import type { QueryClient } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query' import { httpBatchLink } from '@trpc/client' @@ -8,6 +9,15 @@ import superjson from 'superjson' import { makeQueryClient } from './query-client' import type { AppRouter } from './routers/_app' +superjson.registerCustom( + { + isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v), + serialize: (v) => v.toJSON(), + deserialize: (v) => new Prisma.Decimal(v), + }, + 'decimal.js', +) + export const trpc = createTRPCReact() let clientQueryClientSingleton: QueryClient diff --git a/src/trpc/init.ts b/src/trpc/init.ts index 3011b11..b8d34f6 100644 --- a/src/trpc/init.ts +++ b/src/trpc/init.ts @@ -1,7 +1,17 @@ +import { Prisma } from '@prisma/client' import { initTRPC } from '@trpc/server' import { cache } from 'react' import superjson from 'superjson' +superjson.registerCustom( + { + isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v), + serialize: (v) => v.toJSON(), + deserialize: (v) => new Prisma.Decimal(v), + }, + 'decimal.js', +) + export const createTRPCContext = cache(async () => { /** * @see: https://trpc.io/docs/server/context