From e5f965f7357369e8b8a9b10841a550b0e0f7624a Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 3 Sep 2025 21:48:15 +0200 Subject: [PATCH 01/44] Add necessary fields in the DB schema --- .../20250903194351_currency_rate_cache/migration.sql | 10 ++++++++++ .../migration.sql | 2 ++ prisma/schema.prisma | 11 +++++++++++ src/types/expense.types.ts | 1 + 4 files changed, 24 insertions(+) create mode 100644 prisma/migrations/20250903194351_currency_rate_cache/migration.sql create mode 100644 prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql diff --git a/prisma/migrations/20250903194351_currency_rate_cache/migration.sql b/prisma/migrations/20250903194351_currency_rate_cache/migration.sql new file mode 100644 index 00000000..c0479a4f --- /dev/null +++ b/prisma/migrations/20250903194351_currency_rate_cache/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE UNLOGGED TABLE "ExchangeRateCache" ( + "currencyFrom" TEXT NOT NULL, + "currencyTo" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + "insertedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExchangeRateCache_pkey" PRIMARY KEY ("currencyFrom","currencyTo","date") +); diff --git a/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql b/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql new file mode 100644 index 00000000..b599cb8a --- /dev/null +++ b/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "SplitType" ADD VALUE 'CURRENCY_EXCHANGE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 438e34e5..f9f2b80a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,6 +135,7 @@ enum SplitType { SHARE ADJUSTMENT SETTLEMENT + CURRENCY_EXCHANGE } model Expense { @@ -186,6 +187,16 @@ model ExpenseNote { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) } +model ExchangeRateCache { + currencyFrom String + currencyTo String + date DateTime + rate Float + insertedAt DateTime @default(now()) + + @@id([currencyFrom, currencyTo, date]) +} + model PushNotification { userId Int @id subscription String diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 11981a6d..245c0520 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -32,6 +32,7 @@ export const createExpenseSchema = z.object({ SplitType.SHARE, SplitType.EXACT, SplitType.SETTLEMENT, + SplitType.CURRENCY_EXCHANGE, ]), currency: z.string(), participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })), From 5f01ba79f2994a371aff558b542809696fa9603e Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 3 Sep 2025 21:49:38 +0200 Subject: [PATCH 02/44] Clean up expense router --- src/server/api/routers/expense.ts | 32 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 49e011ac..c544a821 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -9,7 +9,6 @@ import { db } from '~/server/db'; import { getDocumentUploadUrl } from '~/server/storage'; import { BigMath } from '~/utils/numbers'; -// import { sendExpensePushNotification } from '../services/notificationService'; import { createExpenseSchema } from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; @@ -28,25 +27,22 @@ export const expenseRouter = createTRPCRouter({ }); const balances = balancesRaw - .reduce( - (acc, current) => { - const existing = acc.findIndex((item) => item.friendId === current.friendId); - if (-1 === existing) { - acc.push(current); - } else { - const existingItem = acc[existing]; - if (existingItem) { - if (BigMath.abs(existingItem.amount) > BigMath.abs(current.amount)) { - acc[existing] = { ...existingItem, hasMore: true }; - } else { - acc[existing] = { ...current, hasMore: true }; - } + .reduce<((typeof balancesRaw)[number] & { hasMore?: boolean })[]>((acc, current) => { + const existing = acc.findIndex((item) => item.friendId === current.friendId); + if (-1 === existing) { + acc.push(current); + } else { + const existingItem = acc[existing]; + if (existingItem) { + if (BigMath.abs(existingItem.amount) > BigMath.abs(current.amount)) { + acc[existing] = { ...existingItem, hasMore: true }; + } else { + acc[existing] = { ...current, hasMore: true }; } } - return acc; - }, - [] as ((typeof balancesRaw)[number] & { hasMore?: boolean })[], - ) + } + return acc; + }, []) .sort((a, b) => Number(BigMath.abs(b.amount) - BigMath.abs(a.amount))); const cumulatedBalances = await db.balance.groupBy({ From dc8ae5f6ce5fb864be2226d10db951c13945d43d Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 3 Sep 2025 22:07:25 +0200 Subject: [PATCH 03/44] Add open exchange rates api key env var --- .env.example | 3 +++ src/env.js | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.env.example b/.env.example index bce3b4bc..b0f21bda 100644 --- a/.env.example +++ b/.env.example @@ -90,4 +90,7 @@ FEEDBACK_EMAIL= # Discord webhook for error notifications DISCORD_WEBHOOK_URL= + +# Open Exchange Rates App ID +OPEN_EXCHANGE_RATES_APP_ID= #********* END OF OPTIONAL ENV VARS ********* diff --git a/src/env.js b/src/env.js index 69401217..6e93bdb2 100644 --- a/src/env.js +++ b/src/env.js @@ -51,6 +51,7 @@ export const env = createEnv({ FEEDBACK_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), DEFAULT_HOMEPAGE: z.string().default('/home'), + OPEN_EXCHANGE_RATES_APP_ID: z.string().optional(), OIDC_NAME: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(), @@ -101,6 +102,7 @@ export const env = createEnv({ FEEDBACK_EMAIL: process.env.FEEDBACK_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, DEFAULT_HOMEPAGE: process.env.DEFAULT_HOMEPAGE, + OPEN_EXCHANGE_RATES_APP_ID: process.env.OPEN_EXCHANGE_RATES_APP_ID, OIDC_NAME: process.env.OIDC_NAME, OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID, OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET, From edef85c96ef98ff7be3b244ff7eb2c25decf440e Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 3 Sep 2025 22:54:29 +0200 Subject: [PATCH 04/44] Add currency service working with frankfurter and USD via oxr --- .../api/services/currencyRateService.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/server/api/services/currencyRateService.ts diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts new file mode 100644 index 00000000..2e0cf3b2 --- /dev/null +++ b/src/server/api/services/currencyRateService.ts @@ -0,0 +1,94 @@ +import { format, isToday } from 'date-fns'; +import type { CurrencyCode } from '~/lib/currency'; + +export interface RateResponse { + base: string; + rates: { [key: string]: number }; +} + +class ProviderMissingError extends Error {} + +export const getExchangeRates = async ( + from: CurrencyCode, + to: CurrencyCode, + date?: Date, +): Promise => { + if (frankfurterCurrencies.includes(from) && frankfurterCurrencies.includes(to)) { + return frankfurterFetchRates(from, to, date); + } else if (from === 'USD' || to === 'USD') { + return oxrFetchRates(date); + } else { + throw new ProviderMissingError(`No currency API available for ${from} or ${to}`); + } +}; + +async function frankfurterFetchRates( + from: CurrencyCode, + to: CurrencyCode, + date?: Date, +): Promise { + const key = !date || isToday(date) ? 'latest' : format(date, 'yyyy-MM-dd'); + + const response = await fetch(`https://api.frankfurter.dev/v1/${key}?base=${from}&symbols=${to}`); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); +} + +async function oxrFetchRates(date?: Date): Promise { + if (!process.env.OPEN_EXCHANGE_RATES_APP_ID) { + throw new ProviderMissingError('Open Exchange Rates API key not provided'); + } + const key = !date || isToday(date) ? 'latest' : `hitorical/${format(date, 'yyyy-MM-dd')}`; + + // sadly the free tier supports only USD as base currency + const response = await fetch( + `https://openexchangerates.org/api/${key}.json?app_id=${process.env.OPEN_EXCHANGE_RATES_APP_ID}`, + ); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); +} + +// Check with https://api.frankfurter.dev/v1/currencies +const frankfurterCurrencies = [ + 'AUD', + 'BGN', + 'BRL', + 'CAD', + 'CHF', + 'CNY', + 'CZK', + 'DKK', + 'EUR', + 'GBP', + 'HKD', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'ISK', + 'JPY', + 'KRW', + 'MXN', + 'MYR', + 'NOK', + 'NZD', + 'PHP', + 'PLN', + 'RON', + 'SEK', + 'SGD', + 'THB', + 'TRY', + 'USD', + 'ZAR', +]; From 7f50df662badce9a126b61ab01e932a29bb0a617 Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 3 Sep 2025 22:54:56 +0200 Subject: [PATCH 05/44] Add API route for fetching currencies --- src/server/api/routers/expense.ts | 55 ++++++++++++++++++++++++++++++- src/types/expense.types.ts | 6 ++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index c544a821..7e25b9a7 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -9,8 +9,10 @@ import { db } from '~/server/db'; import { getDocumentUploadUrl } from '~/server/storage'; import { BigMath } from '~/utils/numbers'; -import { createExpenseSchema } from '~/types/expense.types'; +import { createExpenseSchema, getCurrencyRateSchema } from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; +import { getExchangeRates } from '../services/currencyRateService'; +import { isCurrencyCode } from '~/lib/currency'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -314,6 +316,57 @@ export const expenseRouter = createTRPCRouter({ await deleteExpense(input.expenseId, ctx.session.user.id); }), + + getCurrencyRate: protectedProcedure.input(getCurrencyRateSchema).query(async ({ ctx, input }) => { + const { from, to, date } = input; + + if (!isCurrencyCode(from) || !isCurrencyCode(to)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); + } + + const cachedRate = await ctx.db.currencyRateCache.findUnique({ + where: { + from_to_date: { from, to, date }, + }, + }); + + if (cachedRate) { + return { rate: cachedRate.rate }; + } + + const reverseCachedRate = await ctx.db.currencyRateCache.findUnique({ + where: { + from_to_date: { from: to, to: from, date }, + }, + }); + + if (reverseCachedRate) { + return { rate: 1 / reverseCachedRate.rate }; + } + + const data = await getExchangeRates(from, to, date); + + await Promise.all( + Object.entries(data.rates).map(([to, rate]) => + ctx.db.currencyRateCache.upsert({ + where: { + from_to_date: { from: data.base, to, date }, + }, + create: { + from, + to, + date, + rate, + }, + update: { + rate, + }, + }), + ), + ); + + return { rate: data.base === from ? data.rates[to] : 1 / data.rates[from]! }; + }), }); const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => { diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 245c0520..d5dd3cd9 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -40,3 +40,9 @@ export const createExpenseSchema = z.object({ expenseDate: z.date().optional(), expenseId: z.string().optional(), }) satisfies z.ZodType; + +export const getCurrencyRateSchema = z.object({ + from: z.string(), + to: z.string(), + date: z.date().transform((date) => new Date(date.toDateString())), +}); From 787af77f4c43b64c3b975756012847fd5c33494e Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 17:23:17 +0200 Subject: [PATCH 06/44] Update the db schema once again with self-relation --- .../migration.sql | 10 ---- .../migration.sql | 2 - .../migration.sql | 28 +++++++++ prisma/schema.prisma | 59 ++++++++++--------- 4 files changed, 59 insertions(+), 40 deletions(-) delete mode 100644 prisma/migrations/20250903194351_currency_rate_cache/migration.sql delete mode 100644 prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql create mode 100644 prisma/migrations/20250905152203_currency_conversion/migration.sql diff --git a/prisma/migrations/20250903194351_currency_rate_cache/migration.sql b/prisma/migrations/20250903194351_currency_rate_cache/migration.sql deleted file mode 100644 index c0479a4f..00000000 --- a/prisma/migrations/20250903194351_currency_rate_cache/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- CreateTable -CREATE UNLOGGED TABLE "ExchangeRateCache" ( - "currencyFrom" TEXT NOT NULL, - "currencyTo" TEXT NOT NULL, - "date" TIMESTAMP(3) NOT NULL, - "rate" DOUBLE PRECISION NOT NULL, - "insertedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "ExchangeRateCache_pkey" PRIMARY KEY ("currencyFrom","currencyTo","date") -); diff --git a/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql b/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql deleted file mode 100644 index b599cb8a..00000000 --- a/prisma/migrations/20250903194547_add_currency_exchange_transaction/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "SplitType" ADD VALUE 'CURRENCY_EXCHANGE'; diff --git a/prisma/migrations/20250905152203_currency_conversion/migration.sql b/prisma/migrations/20250905152203_currency_conversion/migration.sql new file mode 100644 index 00000000..52e31aa6 --- /dev/null +++ b/prisma/migrations/20250905152203_currency_conversion/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - A unique constraint covering the columns `[otherConversion]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterEnum +ALTER TYPE "SplitType" ADD VALUE 'CURRENCY_CONVERSION'; + +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "otherConversion" TEXT; + +-- CreateTable +CREATE UNLOGGED TABLE "CurrencyRateCache" ( + "from" TEXT NOT NULL, + "to" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + "insertedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CurrencyRateCache_pkey" PRIMARY KEY ("from","to","date") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Expense_otherConversion_key" ON "Expense"("otherConversion"); + +-- AddForeignKey +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9f2b80a..7908ec0c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,33 +135,36 @@ enum SplitType { SHARE ADJUSTMENT SETTLEMENT - CURRENCY_EXCHANGE + CURRENCY_CONVERSION } model Expense { - id String @id @default(cuid()) - paidBy Int - addedBy Int - name String - category String - amount BigInt - splitType SplitType @default(EQUAL) - expenseDate DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - currency String - fileKey String? - groupId Int? - deletedAt DateTime? - deletedBy Int? - updatedBy Int? - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) - paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) - addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) - deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) - updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) - expenseParticipants ExpenseParticipant[] - expenseNotes ExpenseNote[] + id String @id @default(cuid()) + paidBy Int + addedBy Int + name String + category String + amount BigInt + splitType SplitType @default(EQUAL) + expenseDate DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + currency String + fileKey String? + groupId Int? + deletedAt DateTime? + deletedBy Int? + updatedBy Int? + otherConversion String? @unique + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) + addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) + deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) + updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) + conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade) + conversionFrom Expense? @relation(name: "CurrencyConversion") + expenseParticipants ExpenseParticipant[] + expenseNotes ExpenseNote[] @@index([groupId]) @@index([paidBy]) @@ -187,14 +190,14 @@ model ExpenseNote { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) } -model ExchangeRateCache { - currencyFrom String - currencyTo String +model CurrencyRateCache { + from String + to String date DateTime rate Float insertedAt DateTime @default(now()) - @@id([currencyFrom, currencyTo, date]) + @@id([from, to, date]) } model PushNotification { From e57764d8b249efd40ee16a923448a378f6a8647e Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 17:24:44 +0200 Subject: [PATCH 07/44] Update expense schema to account for new field --- src/types/expense.types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index d5dd3cd9..60bda308 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -12,6 +12,7 @@ export type CreateExpense = Omit< | 'deletedBy' | 'expenseDate' | 'fileKey' + | 'otherConversion' > & { expenseDate?: Date; fileKey?: string; @@ -32,7 +33,7 @@ export const createExpenseSchema = z.object({ SplitType.SHARE, SplitType.EXACT, SplitType.SETTLEMENT, - SplitType.CURRENCY_EXCHANGE, + SplitType.CURRENCY_CONVERSION, ]), currency: z.string(), participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })), From b421b5e29daf8eeb1f617e1c53e07f57d7fdc411 Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 17:32:35 +0200 Subject: [PATCH 08/44] Add proper type guard to svg icons in auth --- src/pages/auth/signin.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index f37bb5b3..bb97f091 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -39,6 +39,9 @@ const providerSvgs = { keycloak: , }; +const providerTypeGuard = (providerId: string): providerId is keyof typeof providerSvgs => + providerId in providerSvgs; + const emailSchema = (t: TFunction) => z.object({ email: z @@ -153,7 +156,7 @@ const Home: NextPage<{ onClick={handleProviderSignIn(provider.id)} key={provider.id} > - {providerSvgs[provider.id as keyof typeof providerSvgs]} + {providerTypeGuard(provider.id) && providerSvgs[provider.id]} {t('auth.continue_with', { provider: provider.name })} ))} From 04596ac3e438b7d62a1f9531ceca6cf7409d5a60 Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 17:52:29 +0200 Subject: [PATCH 09/44] Create proper buttons for groups settlements --- src/components/Expense/BalanceList.tsx | 190 +++++++++++++------------ src/components/ui/button.tsx | 9 +- 2 files changed, 101 insertions(+), 98 deletions(-) diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 7c6b5d9f..8125c675 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,6 +1,6 @@ import type { GroupBalance, User } from '@prisma/client'; import { clsx } from 'clsx'; -import { Info } from 'lucide-react'; +import { HandCoins, Info } from 'lucide-react'; import { Fragment, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; import { api } from '~/utils/api'; @@ -9,6 +9,7 @@ import { BigMath, toUIString } from '~/utils/numbers'; import { GroupSettleUp } from '../Friend/GroupSettleup'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { Button } from '../ui/button'; interface UserWithBalance { user: User; @@ -45,113 +46,114 @@ export const BalanceList: React.FC<{ }, [groupBalances, users]); return ( - <> -
- - {t('ui.balance_list.press_balance_info')} -
- - {Object.values(userMap).map(({ user, total, balances }) => { - let totalAmount: [string, bigint] = ['', 0n]; - const isCurrentUser = userQuery.data?.id === user.id; + + {Object.values(userMap).map(({ user, total, balances }) => { + let totalAmount: [string, bigint] = ['', 0n]; + const isCurrentUser = userQuery.data?.id === user.id; - Object.entries(total).forEach(([currency, amount]) => { - if (BigMath.abs(amount) > BigMath.abs(totalAmount[1])) { - totalAmount = [currency, amount]; - } - }); + Object.entries(total).forEach(([currency, amount]) => { + if (BigMath.abs(amount) > BigMath.abs(totalAmount[1])) { + totalAmount = [currency, amount]; + } + }); - return ( - - -
- -
- {displayName(user, userQuery.data?.id)} - {Object.values(total).every((amount) => 0n === amount) ? ( + return ( + + +
+ +
+ {displayName(user, userQuery.data?.id)} + {Object.values(total).every((amount) => 0n === amount) ? ( + + {' '} + {isCurrentUser + ? t('ui.balance_list.are_settled_up') + : t('ui.balance_list.is_settled_up')} + + ) : ( + <> {' '} - {isCurrentUser - ? t('ui.balance_list.are_settled_up') - : t('ui.balance_list.is_settled_up')} + {t( + `ui.expense.${isCurrentUser ? 'you' : 'user'}.${0 < totalAmount[1] ? 'lent' : 'owe'}`, + { ns: 'common' }, + )}{' '} - ) : ( - <> - - {' '} - {t( - `ui.expense.${isCurrentUser ? 'you' : 'user'}.${0 < totalAmount[1] ? 'lent' : 'owe'}`, - { ns: 'common' }, - )}{' '} - - - {toUIString(totalAmount[1])} {totalAmount[0]} - - - )} -
+ + {toUIString(totalAmount[1])} {totalAmount[0]} + + + )}
-
- - {Object.entries(balances).map(([friendId, perFriendBalances]) => { - const friend = userMap[+friendId]!.user; +
+ + + {Object.entries(balances).map(([friendId, perFriendBalances]) => { + const friend = userMap[+friendId]!.user; - return ( - - {Object.entries(perFriendBalances).map(([currency, amount]) => ( + return ( + + {Object.entries(perFriendBalances).map(([currency, amount]) => ( +
+
+ +
+ {displayName(friend, userQuery.data?.id)} + + {' '} + {t( + `ui.expense.${friend.id === userQuery.data?.id ? 'you' : 'user'}.${0 > amount ? 'get' : 'pay'}`, + { ns: 'common' }, + )}{' '} + + + {toUIString(amount)} {currency} + + + {' '} + {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { + ns: 'common', + })}{' '} + + + {displayName(user, userQuery.data?.id, 'accusativus')} + +
+
-
- -
- {displayName(friend, userQuery.data?.id)} - - {' '} - {t( - `ui.expense.${friend.id === userQuery.data?.id ? 'you' : 'user'}.${0 > amount ? 'get' : 'pay'}`, - { ns: 'common' }, - )}{' '} - - - {toUIString(amount)} {currency} - - - {' '} - {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { - ns: 'common', - })}{' '} - - - {displayName(user, userQuery.data?.id, 'accusativus')} - -
-
+
- ))} - - ); - })} - - - ); - })} - - +
+ ))} +
+ ); + })} +
+ + ); + })} + ); }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index fa0d5a49..cdbcd422 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -62,11 +62,12 @@ const Button = React.forwardRef( buttonVariants({ variant, size, - className: - className + - (responsiveIcon + className: cn( + className, + responsiveIcon ? 'responsive-icon xs:gap-1 xs:text-sm xs:w-40 w-auto lg:w-[180px]' - : ''), + : '', + ), }), )} ref={ref} From 36f5dcd15869d90869cd7f4899dc42d081c713a3 Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 17:54:26 +0200 Subject: [PATCH 10/44] Unify naming to currencyRate --- src/server/api/routers/expense.ts | 4 ++-- src/server/api/services/currencyRateService.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 7e25b9a7..92c1bffd 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -11,7 +11,7 @@ import { BigMath } from '~/utils/numbers'; import { createExpenseSchema, getCurrencyRateSchema } from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; -import { getExchangeRates } from '../services/currencyRateService'; +import { getCurrencyRates } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; export const expenseRouter = createTRPCRouter({ @@ -344,7 +344,7 @@ export const expenseRouter = createTRPCRouter({ return { rate: 1 / reverseCachedRate.rate }; } - const data = await getExchangeRates(from, to, date); + const data = await getCurrencyRates(from, to, date); await Promise.all( Object.entries(data.rates).map(([to, rate]) => diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 2e0cf3b2..4ee11003 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -8,7 +8,7 @@ export interface RateResponse { class ProviderMissingError extends Error {} -export const getExchangeRates = async ( +export const getCurrencyRates = async ( from: CurrencyCode, to: CurrencyCode, date?: Date, From fba18d4b45455baadf6f05ea870bd869f636d17e Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 18:45:33 +0200 Subject: [PATCH 11/44] MVP for group balances --- public/locales/en/common.json | 8 +- src/components/AddExpense/AddExpensePage.tsx | 35 ++---- src/components/AddExpense/CurrencyPicker.tsx | 3 + src/components/AddExpense/DateSelector.tsx | 37 +++++++ src/components/Expense/BalanceList.tsx | 38 +++++-- src/components/Friend/CurrencyConversion.tsx | 109 +++++++++++++++++++ src/components/GeneralPicker.tsx | 9 +- src/pages/groups/[groupId].tsx | 1 + 8 files changed, 198 insertions(+), 42 deletions(-) create mode 100644 src/components/AddExpense/DateSelector.tsx create mode 100644 src/components/Friend/CurrencyConversion.tsx diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9f012ac6..58e9d640 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -83,7 +83,13 @@ "expense_details": "Expense details", "select_currency": "Select currency", "outstanding_balances": "Outstanding balances", - "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise" + "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise", + "currency_conversion": { + "title": "Currency conversion", + "description": "Convert the amount to a different currency.", + "amount_to_receive": "Amount to receive", + "exchange_rate": "Exchange rate" + } }, "errors": { "name_required": "Name is required", diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 8dab0a5f..83cab5da 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -22,6 +22,7 @@ import { UploadFile } from './UploadFile'; import { UserInput } from './UserInput'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { DateSelector } from './DateSelector'; export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; @@ -218,34 +219,12 @@ export const AddOrEditExpensePage: React.FC<{
-
- - - - - - - - -
+
{isStorageConfigured ? : null} + + + + + +
+ ); +}; diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 8125c675..b6075fb5 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,6 +1,6 @@ import type { GroupBalance, User } from '@prisma/client'; import { clsx } from 'clsx'; -import { HandCoins, Info } from 'lucide-react'; +import { DollarSign, HandCoins, Info } from 'lucide-react'; import { Fragment, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; import { api } from '~/utils/api'; @@ -10,6 +10,7 @@ import { GroupSettleUp } from '../Friend/GroupSettleup'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { Button } from '../ui/button'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; interface UserWithBalance { user: User; @@ -134,17 +135,30 @@ export const BalanceList: React.FC<{
- - - +
+ + + + + + +
))} diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx new file mode 100644 index 00000000..3def10aa --- /dev/null +++ b/src/components/Friend/CurrencyConversion.tsx @@ -0,0 +1,109 @@ +import { type User } from '@prisma/client'; +import React, { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { api } from '~/utils/api'; +import { BigMath } from '~/utils/numbers'; + +import type { CurrencyCode} from '~/lib/currency'; +import { isCurrencyCode } from '~/lib/currency'; +import { CurrencyPicker } from '../AddExpense/CurrencyPicker'; +import { DateSelector } from '../AddExpense/DateSelector'; +import { AppDrawer } from '../ui/drawer'; +import { Input } from '../ui/input'; + +export const CurrencyConversion: React.FC<{ + amount: bigint; + currency: string; + friend: User; + user: User; + children: ReactNode; + groupId: number; +}> = ({ amount, currency, friend, user, children, groupId }) => { + const { t } = useTranslationWithUtils(); + const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); + const [targetCurrency, setTargetCurrency] = useState( + isCurrencyCode(currency) ? currency : 'USD', + ); + const [rateDate, setRateDate] = useState(new Date()); + const getCurrencyRate = api.expense.getCurrencyRate.useQuery( + { from: currency, to: targetCurrency, date: rateDate }, + { enabled: currency !== targetCurrency }, + ); + + useEffect(() => { + if (getCurrencyRate.data?.rate) { + setRate(getCurrencyRate.data.rate.toString()); + } + }, [getCurrencyRate.data]); + + const [rate, setRate] = useState('1'); + + const onChangeAmount = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + setAmountStr(value); + }, []); + + const onChangeRate = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + setRate(value); + }, []); + + const onChangeTargetCurrency = useCallback((currency: CurrencyCode) => { + setTargetCurrency(currency); + }, []); + + return ( + +
+
+

+ {t('ui.currency_conversion.description')} +

+
+
+ + + +

{currency}

+ + + +
+
+
+ ); +}; diff --git a/src/components/GeneralPicker.tsx b/src/components/GeneralPicker.tsx index bb29a5b7..31d56b6d 100644 --- a/src/components/GeneralPicker.tsx +++ b/src/components/GeneralPicker.tsx @@ -5,6 +5,7 @@ import { AppDrawer, DrawerClose } from '~/components/ui/drawer'; import { cn } from '~/lib/utils'; export const GeneralPicker: React.FC<{ + className?: string; trigger: React.ReactNode; onSelect: (value: string) => void; items: any[]; @@ -16,6 +17,7 @@ export const GeneralPicker: React.FC<{ noOptionsText: string; title: string; }> = ({ + className, trigger, onSelect, items, @@ -27,7 +29,12 @@ export const GeneralPicker: React.FC<{ title, selected, }) => ( - + diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index ace4508d..a33d9537 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -506,6 +506,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { 'groups_details', 'expense_details', 'friend_details', + 'currencies', ])), enableSendingInvites: env.ENABLE_SENDING_INVITES, }, From c322f83cf91d43ab089ccb682584ddf673fef29d Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 19:14:59 +0200 Subject: [PATCH 12/44] Reactivity improvements --- next.config.js | 1 + src/components/AddExpense/CurrencyPicker.tsx | 9 ++- src/components/Friend/CurrencyConversion.tsx | 64 ++++++++++++++++--- src/env.js | 5 +- .../api/services/currencyRateService.ts | 4 +- 5 files changed, 70 insertions(+), 13 deletions(-) diff --git a/next.config.js b/next.config.js index d3700bf7..2aee4695 100644 --- a/next.config.js +++ b/next.config.js @@ -11,6 +11,7 @@ await import('./src/env.js'); const nextConfig = { reactStrictMode: true, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, + transpilePackages: ['@t3-oss/env-nextjs', '@t3-oss/env-core'], /** * If you are using `appDir` then you must comment the below `i18n` config out. * diff --git a/src/components/AddExpense/CurrencyPicker.tsx b/src/components/AddExpense/CurrencyPicker.tsx index a92394b8..beec8e21 100644 --- a/src/components/AddExpense/CurrencyPicker.tsx +++ b/src/components/AddExpense/CurrencyPicker.tsx @@ -4,15 +4,22 @@ import { CURRENCIES, type CurrencyCode, parseCurrencyCode } from '~/lib/currency import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { GeneralPicker } from '../GeneralPicker'; +import { FRANKFURTER_CURRENCIES } from '~/server/api/services/currencyRateService'; + +const FRANKFURTER_FILTERED_CURRENCIES = Object.fromEntries( + Object.entries(CURRENCIES).filter(([code]) => FRANKFURTER_CURRENCIES.includes(code)), +); function CurrencyPickerInner({ className, currentCurrency = 'USD', onCurrencyPick, + showOnlyFrankfurter = false, }: { className?: string; currentCurrency: CurrencyCode; onCurrencyPick: (currency: CurrencyCode) => void; + showOnlyFrankfurter?: boolean; }) { const { t, getCurrencyName } = useTranslationWithUtils(['currencies']); @@ -59,7 +66,7 @@ function CurrencyPickerInner({ placeholderText={t('placeholder')} noOptionsText={t('no_currency_found')} onSelect={onSelect} - items={Object.values(CURRENCIES)} + items={Object.values(showOnlyFrankfurter ? FRANKFURTER_FILTERED_CURRENCIES : CURRENCIES)} extractValue={extractValue} extractKey={extractKey} selected={selected} diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 3def10aa..28d337fb 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -4,12 +4,13 @@ import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { api } from '~/utils/api'; import { BigMath } from '~/utils/numbers'; -import type { CurrencyCode} from '~/lib/currency'; +import type { CurrencyCode } from '~/lib/currency'; import { isCurrencyCode } from '~/lib/currency'; import { CurrencyPicker } from '../AddExpense/CurrencyPicker'; import { DateSelector } from '../AddExpense/DateSelector'; import { AppDrawer } from '../ui/drawer'; import { Input } from '../ui/input'; +import { env } from '~/env'; export const CurrencyConversion: React.FC<{ amount: bigint; @@ -34,14 +35,29 @@ export const CurrencyConversion: React.FC<{ if (getCurrencyRate.data?.rate) { setRate(getCurrencyRate.data.rate.toString()); } - }, [getCurrencyRate.data]); + }, [getCurrencyRate.data, amountStr]); - const [rate, setRate] = useState('1'); + const [rate, setRate] = useState(''); + const [targetAmountStr, setTargetAmountStr] = useState(''); - const onChangeAmount = useCallback((e: React.ChangeEvent) => { - const { value } = e.target; - setAmountStr(value); - }, []); + useEffect(() => { + setTargetAmountStr((Number(amountStr) * Number(rate)).toFixed(2)); + }, [amountStr, rate]); + + const onChangeAmount = useCallback( + (e: React.ChangeEvent) => { + const { value } = e.target; + if ( + Number(value) < 0 || + Number.isNaN(Number(value)) || + Number(value) > Number(BigMath.abs(amount)) / 100 + ) { + return; + } + setAmountStr(value); + }, + [amount], + ); const onChangeRate = useCallback((e: React.ChangeEvent) => { const { value } = e.target; @@ -49,9 +65,21 @@ export const CurrencyConversion: React.FC<{ }, []); const onChangeTargetCurrency = useCallback((currency: CurrencyCode) => { + setRate(''); setTargetCurrency(currency); }, []); + const onChangeTargetAmount = useCallback( + (e: React.ChangeEvent) => { + const { value } = e.target; + if (Number(value) < 0 || Number.isNaN(Number(value))) { + return; + } + setAmountStr((Number(value) / Number(rate)).toFixed(2)); + }, + [rate], + ); + return (

{t('ui.currency_conversion.description')}

+ {targetCurrency === currency && ( +

+ Currency conversion only works for different currencies* +

+ )}

{currency}

@@ -101,6 +146,7 @@ export const CurrencyConversion: React.FC<{ className="mx-auto" currentCurrency={targetCurrency} onCurrencyPick={onChangeTargetCurrency} + showOnlyFrankfurter={currency !== 'USD' || !env.NEXT_PUBLIC_OXR_AVAILABLE} />
diff --git a/src/env.js b/src/env.js index 6e93bdb2..8e1c12ea 100644 --- a/src/env.js +++ b/src/env.js @@ -64,7 +64,9 @@ export const env = createEnv({ * isn't built with invalid env vars. To expose them to the client, prefix them with * `NEXT_PUBLIC_`. */ - client: {}, + client: { + NEXT_PUBLIC_OXR_AVAILABLE: z.boolean().default(false), + }, /** * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. @@ -108,6 +110,7 @@ export const env = createEnv({ OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET, OIDC_WELL_KNOWN_URL: process.env.OIDC_WELL_KNOWN_URL, OIDC_ALLOW_DANGEROUS_EMAIL_LINKING: !!process.env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING, + NEXT_PUBLIC_OXR_AVAILABLE: !!process.env.OPEN_EXCHANGE_RATES_APP_ID, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 4ee11003..50ae0460 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -13,7 +13,7 @@ export const getCurrencyRates = async ( to: CurrencyCode, date?: Date, ): Promise => { - if (frankfurterCurrencies.includes(from) && frankfurterCurrencies.includes(to)) { + if (FRANKFURTER_CURRENCIES.includes(from) && FRANKFURTER_CURRENCIES.includes(to)) { return frankfurterFetchRates(from, to, date); } else if (from === 'USD' || to === 'USD') { return oxrFetchRates(date); @@ -59,7 +59,7 @@ async function oxrFetchRates(date?: Date): Promise { } // Check with https://api.frankfurter.dev/v1/currencies -const frankfurterCurrencies = [ +export const FRANKFURTER_CURRENCIES = [ 'AUD', 'BGN', 'BRL', From b8b1c4ecef028c59fac5fe109903f66ac743dae5 Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 19:26:45 +0200 Subject: [PATCH 13/44] Bit of frontend polish --- src/components/AddExpense/CurrencyPicker.tsx | 5 +++-- src/components/Friend/CurrencyConversion.tsx | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/AddExpense/CurrencyPicker.tsx b/src/components/AddExpense/CurrencyPicker.tsx index beec8e21..1c8e3959 100644 --- a/src/components/AddExpense/CurrencyPicker.tsx +++ b/src/components/AddExpense/CurrencyPicker.tsx @@ -5,6 +5,7 @@ import { CURRENCIES, type CurrencyCode, parseCurrencyCode } from '~/lib/currency import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { GeneralPicker } from '../GeneralPicker'; import { FRANKFURTER_CURRENCIES } from '~/server/api/services/currencyRateService'; +import { Button } from '../ui/button'; const FRANKFURTER_FILTERED_CURRENCIES = Object.fromEntries( Object.entries(CURRENCIES).filter(([code]) => FRANKFURTER_CURRENCIES.includes(code)), @@ -32,9 +33,9 @@ function CurrencyPickerInner({ const trigger = useMemo( () => ( -
+
+ ), [currentCurrency], ); diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 28d337fb..8c2db33a 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -11,6 +11,9 @@ import { DateSelector } from '../AddExpense/DateSelector'; import { AppDrawer } from '../ui/drawer'; import { Input } from '../ui/input'; import { env } from '~/env'; +import { useAppStore } from '~/store/appStore'; +import { useAddExpenseStore } from '~/store/addStore'; +import { Button } from '../ui/button'; export const CurrencyConversion: React.FC<{ amount: bigint; @@ -22,9 +25,8 @@ export const CurrencyConversion: React.FC<{ }> = ({ amount, currency, friend, user, children, groupId }) => { const { t } = useTranslationWithUtils(); const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); - const [targetCurrency, setTargetCurrency] = useState( - isCurrencyCode(currency) ? currency : 'USD', - ); + const preferredCurrency = useAddExpenseStore((state) => state.currency); + const [targetCurrency, setTargetCurrency] = useState(preferredCurrency); const [rateDate, setRateDate] = useState(new Date()); const getCurrencyRate = api.expense.getCurrencyRate.useQuery( { from: currency, to: targetCurrency, date: rateDate }, @@ -133,7 +135,9 @@ export const CurrencyConversion: React.FC<{ onChange={onChangeTargetAmount} disabled={getCurrencyRate.isPending || currency === targetCurrency} /> -

{currency}

+ Date: Fri, 5 Sep 2025 21:23:36 +0200 Subject: [PATCH 14/44] Improve the styling of currency converter --- src/components/Friend/CurrencyConversion.tsx | 221 ++++++++++++++----- 1 file changed, 160 insertions(+), 61 deletions(-) diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 8c2db33a..fee149e5 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -1,17 +1,15 @@ import { type User } from '@prisma/client'; -import React, { type ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { api } from '~/utils/api'; import { BigMath } from '~/utils/numbers'; -import type { CurrencyCode } from '~/lib/currency'; -import { isCurrencyCode } from '~/lib/currency'; +import { type CurrencyCode, isCurrencyCode } from '~/lib/currency'; import { CurrencyPicker } from '../AddExpense/CurrencyPicker'; import { DateSelector } from '../AddExpense/DateSelector'; import { AppDrawer } from '../ui/drawer'; import { Input } from '../ui/input'; import { env } from '~/env'; -import { useAppStore } from '~/store/appStore'; import { useAddExpenseStore } from '~/store/addStore'; import { Button } from '../ui/button'; @@ -22,10 +20,11 @@ export const CurrencyConversion: React.FC<{ user: User; children: ReactNode; groupId: number; -}> = ({ amount, currency, friend, user, children, groupId }) => { +}> = ({ amount, currency, friend: _friend, user: _user, children, groupId: _groupId }) => { const { t } = useTranslationWithUtils(); const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); const preferredCurrency = useAddExpenseStore((state) => state.currency); + const { setCurrency } = useAddExpenseStore((state) => state.actions); const [targetCurrency, setTargetCurrency] = useState(preferredCurrency); const [rateDate, setRateDate] = useState(new Date()); const getCurrencyRate = api.expense.getCurrencyRate.useQuery( @@ -35,12 +34,13 @@ export const CurrencyConversion: React.FC<{ useEffect(() => { if (getCurrencyRate.data?.rate) { - setRate(getCurrencyRate.data.rate.toString()); + setRate(Number(getCurrencyRate.data.rate).toFixed(4)); } }, [getCurrencyRate.data, amountStr]); const [rate, setRate] = useState(''); const [targetAmountStr, setTargetAmountStr] = useState(''); + const dateDisabled = useMemo(() => ({ after: new Date() }), []); useEffect(() => { setTargetAmountStr((Number(amountStr) * Number(rate)).toFixed(2)); @@ -62,14 +62,30 @@ export const CurrencyConversion: React.FC<{ ); const onChangeRate = useCallback((e: React.ChangeEvent) => { - const { value } = e.target; - setRate(value); + const raw = e.target.value.replace(',', '.'); + // Allow empty while typing + if (raw === '') { + setRate(''); + return; + } + // Only digits and optional dot + if (!/^[0-9]*\.?[0-9]*$/.test(raw)) { + return; + } + const [int = '', dec = ''] = raw.split('.'); + const trimmedDec = dec.slice(0, 4); + const normalized = raw.includes('.') ? `${int}.${trimmedDec}` : int; + setRate(normalized); }, []); - const onChangeTargetCurrency = useCallback((currency: CurrencyCode) => { - setRate(''); - setTargetCurrency(currency); - }, []); + const onChangeTargetCurrency = useCallback( + (currency: CurrencyCode) => { + setRate(''); + setTargetCurrency(currency); + setCurrency(currency); + }, + [setCurrency], + ); const onChangeTargetAmount = useCallback( (e: React.ChangeEvent) => { @@ -100,58 +116,141 @@ export const CurrencyConversion: React.FC<{ targetCurrency === currency } > -
-
-

- {t('ui.currency_conversion.description')} -

+
+
+

{t('ui.currency_conversion.description')}

{targetCurrency === currency && ( -

- Currency conversion only works for different currencies* -

+
+ Currency conversion only works for different currencies +
)}
-
- - - - - - - + +
+
+ {/* From amount */} +
+ +
+ + + {currency} + +
+
+ + {/* Rate */} +
+ +
+ + {getCurrencyRate.isPending && ( + + Fetching rate… + + )} + {!!rate && ( + <> + + 1 {currency} = {Number(rate).toFixed(4)} {targetCurrency} + + + 1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency} + + + )} +
+
+ + {/* To amount */} +
+ +
+ + + {targetCurrency} + +
+
+ + {/* From currency (read-only) */} +
+ +
+ +
+
+ + {/* Date */} +
+ +
+ +
+
+ + {/* To currency */} +
+ +
+ +
+
+
From 373d375eb74ae0feb668d6f0aaac00f30a89420f Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 5 Sep 2025 21:34:37 +0200 Subject: [PATCH 15/44] Currency conversion api route stub --- src/server/api/routers/expense.ts | 37 ++++++++++++++++++++++++++++++- src/types/expense.types.ts | 11 +++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 92c1bffd..52881203 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -9,10 +9,15 @@ import { db } from '~/server/db'; import { getDocumentUploadUrl } from '~/server/storage'; import { BigMath } from '~/utils/numbers'; -import { createExpenseSchema, getCurrencyRateSchema } from '~/types/expense.types'; +import { + createCurrencyConversionSchema, + createExpenseSchema, + getCurrencyRateSchema, +} from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; import { getCurrencyRates } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; +import { SplitType } from '@prisma/client'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -78,6 +83,9 @@ export const expenseRouter = createTRPCRouter({ if (input.expenseId) { await validateEditExpensePermission(input.expenseId, ctx.session.user.id); } + if (input.splitType === SplitType.CURRENCY_CONVERSION) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); + } if (input.groupId) { const group = await db.group.findUnique({ @@ -104,6 +112,33 @@ export const expenseRouter = createTRPCRouter({ } }), + addOrEditCurrencyConversion: protectedProcedure + .input(createCurrencyConversionSchema) + .mutation(async ({ input, ctx }) => { + if (input.expenseId) { + await validateEditExpensePermission(input.expenseId, ctx.session.user.id); + } + + if (input.groupId) { + const group = await db.group.findUnique({ + where: { id: input.groupId }, + select: { archivedAt: true }, + }); + if (!group) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + } + if (group.archivedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + } + } + + try { + } catch (error) { + console.error(error); + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create expense' }); + } + }), + getExpensesWithFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 60bda308..4d4e1fad 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -42,6 +42,17 @@ export const createExpenseSchema = z.object({ expenseId: z.string().optional(), }) satisfies z.ZodType; +export const createCurrencyConversionSchema = z.object({ + paidBy: z.number(), + amount: z.bigint(), + from: z.string(), + to: z.string(), + groupId: z.number().nullable(), + participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })), + expenseDate: z.date().optional(), + expenseId: z.string().optional(), +}); + export const getCurrencyRateSchema = z.object({ from: z.string(), to: z.string(), From 748f0097ce583e38552a6afcd5860c040dd9d783 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 18:11:38 +0200 Subject: [PATCH 16/44] Update expense zod schema --- src/server/api/services/splitService.ts | 1 + src/types/expense.types.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 0565d2b2..c073f66b 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -41,6 +41,7 @@ export async function createExpense( participants, expenseDate, fileKey, + otherConversion, }: CreateExpense, currentUserId: number, ) { diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 4d4e1fad..809cd6c2 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -17,6 +17,7 @@ export type CreateExpense = Omit< expenseDate?: Date; fileKey?: string; expenseId?: string; + otherConversion?: string; participants: Omit[]; }; @@ -40,6 +41,7 @@ export const createExpenseSchema = z.object({ fileKey: z.string().optional(), expenseDate: z.date().optional(), expenseId: z.string().optional(), + otherConversion: z.string().optional(), }) satisfies z.ZodType; export const createCurrencyConversionSchema = z.object({ From 613b52ae70d13321c3971158ce8a37091db2d2c8 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 18:35:15 +0200 Subject: [PATCH 17/44] Frontend send api request on save --- src/components/Friend/CurrencyConversion.tsx | 35 +++++++++++++++++++- src/types/expense.types.ts | 6 ++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index fee149e5..441b7636 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -12,6 +12,7 @@ import { Input } from '../ui/input'; import { env } from '~/env'; import { useAddExpenseStore } from '~/store/addStore'; import { Button } from '../ui/button'; +import { toast } from 'sonner'; export const CurrencyConversion: React.FC<{ amount: bigint; @@ -20,8 +21,11 @@ export const CurrencyConversion: React.FC<{ user: User; children: ReactNode; groupId: number; -}> = ({ amount, currency, friend: _friend, user: _user, children, groupId: _groupId }) => { +}> = ({ amount, currency, friend, user, children, groupId }) => { const { t } = useTranslationWithUtils(); + + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); const preferredCurrency = useAddExpenseStore((state) => state.currency); const { setCurrency } = useAddExpenseStore((state) => state.actions); @@ -98,6 +102,34 @@ export const CurrencyConversion: React.FC<{ [rate], ); + const onSave = useCallback(async () => { + try { + await addOrEditCurrencyConversionMutation.mutateAsync({ + amount, + rate: Number(rate), + from: currency, + to: targetCurrency, + friendId: friend.id, + groupId, + submittedBy: user.id, + }); + toast.success(t('ui.currency_conversion.success_toast')); + } catch (error) { + console.error(error); + toast.error(t('ui.currency_conversion.error_toast')); + } + }, [ + addOrEditCurrencyConversionMutation, + targetCurrency, + amount, + rate, + currency, + friend.id, + groupId, + user.id, + t, + ]); + return ( ; export const createCurrencyConversionSchema = z.object({ - paidBy: z.number(), amount: z.bigint(), from: z.string(), to: z.string(), + rate: z.number().positive(), + submittedBy: z.number(), + friendId: z.number().nullable(), groupId: z.number().nullable(), - participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })), - expenseDate: z.date().optional(), expenseId: z.string().optional(), }); From 47803a8bd3a101841317b760b0003ffd34c2d58d Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 19:30:44 +0200 Subject: [PATCH 18/44] Insert currency conversions in the database --- src/components/Expense/BalanceList.tsx | 4 +- src/components/Friend/CurrencyConversion.tsx | 17 +++-- src/server/api/routers/expense.ts | 68 ++++++++++++++------ src/types/expense.types.ts | 4 +- src/utils/numbers.ts | 16 +++++ 5 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index b6075fb5..1acd82c7 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -148,8 +148,8 @@ export const BalanceList: React.FC<{ = ({ amount, currency, friend, user, children, groupId }) => { +}> = ({ amount, currency, sender, receiver, children, groupId }) => { const { t } = useTranslationWithUtils(); const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const utils = api.useUtils(); const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); const preferredCurrency = useAddExpenseStore((state) => state.currency); @@ -109,11 +110,12 @@ export const CurrencyConversion: React.FC<{ rate: Number(rate), from: currency, to: targetCurrency, - friendId: friend.id, + senderId: sender.id, + receiverId: receiver.id, groupId, - submittedBy: user.id, }); toast.success(t('ui.currency_conversion.success_toast')); + utils.invalidate().catch(console.error); } catch (error) { console.error(error); toast.error(t('ui.currency_conversion.error_toast')); @@ -124,10 +126,11 @@ export const CurrencyConversion: React.FC<{ amount, rate, currency, - friend.id, + sender.id, + receiver.id, groupId, - user.id, t, + utils, ]); return ( diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 52881203..ad18cecf 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -18,6 +18,7 @@ import { createExpense, deleteExpense, editExpense } from '../services/splitServ import { getCurrencyRates } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; +import { DEFAULT_CATEGORY } from '~/lib/category'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -115,28 +116,58 @@ export const expenseRouter = createTRPCRouter({ addOrEditCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) .mutation(async ({ input, ctx }) => { - if (input.expenseId) { - await validateEditExpensePermission(input.expenseId, ctx.session.user.id); - } + const { amount, rate, from, to, senderId, receiverId, groupId } = input; + + const amountTo = BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); + + const expenseFrom = await createExpense( + { + name: `1: ${from} → ${to} @ ${rate}`, + currency: from, + amount, + paidBy: receiverId, + splitType: SplitType.CURRENCY_CONVERSION, + category: DEFAULT_CATEGORY, + participants: [ + { userId: senderId, amount: -amount }, + { userId: receiverId, amount: amount }, + ], + groupId, + expenseDate: new Date(), + }, + ctx.session.user.id, + ); - if (input.groupId) { - const group = await db.group.findUnique({ - where: { id: input.groupId }, - select: { archivedAt: true }, + if (!expenseFrom) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create currency conversion record', }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } } - try { - } catch (error) { - console.error(error); - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create expense' }); - } + const expenseTo = await createExpense( + { + name: `2: ${from} → ${to} @ ${rate}`, + currency: to, + amount: amountTo, + paidBy: receiverId, + splitType: SplitType.CURRENCY_CONVERSION, + category: DEFAULT_CATEGORY, + participants: [ + { userId: senderId, amount: amountTo }, + { userId: receiverId, amount: -amountTo }, + ], + groupId, + expenseDate: new Date(), + otherConversion: expenseFrom.id, + }, + ctx.session.user.id, + ); + + return { + ...expenseFrom, + otherConversion: expenseTo?.id, + }; }), getExpensesWithFriend: protectedProcedure @@ -209,6 +240,7 @@ export const expenseRouter = createTRPCRouter({ }, }); + console.log(expenses.at(-1)?.name, expenses.at(-1)?.expenseParticipants); return expenses; }), diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 09af1c05..c041b9f1 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -49,8 +49,8 @@ export const createCurrencyConversionSchema = z.object({ from: z.string(), to: z.string(), rate: z.number().positive(), - submittedBy: z.number(), - friendId: z.number().nullable(), + senderId: z.number(), + receiverId: z.number(), groupId: z.number().nullable(), expenseId: z.string().optional(), }); diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 9d495d5c..6d9812b6 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -89,6 +89,22 @@ export const BigMath = { return value; } }, + roundDiv(x: bigint, y: bigint) { + if (0n === y) { + throw new Error('Division by zero'); + } + const absRemainderDoubled = (BigMath.abs(x) % BigMath.abs(y)) * 2n; + const q = x / y; + return ( + q + + (absRemainderDoubled < BigMath.abs(y) || + (absRemainderDoubled === BigMath.abs(y) && q % 2n === 0n) + ? 0n + : x > 0n === y > 0n + ? 1n + : -1n) + ); + }, }; export const bigIntReplacer = (key: string, value: any): any => From 0f826f314d4d4eb1a6c006642ad853c6d6d62023 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 20:47:15 +0200 Subject: [PATCH 19/44] Display currency conversions in expense list --- src/components/Expense/BalanceList.tsx | 5 +- src/components/Expense/ExpenseList.tsx | 192 ++++++++++++++++-------- src/components/ui/categoryIcons.tsx | 9 +- src/server/api/routers/expense.ts | 29 +++- src/server/api/services/splitService.ts | 7 + 5 files changed, 173 insertions(+), 69 deletions(-) diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 1acd82c7..7b655c0f 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -11,6 +11,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '.. import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { Button } from '../ui/button'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; +import { CURRENCY_CONVERSION_ICON, SETTLEUP_ICON } from '../ui/categoryIcons'; interface UserWithBalance { user: User; @@ -144,7 +145,7 @@ export const BalanceList: React.FC<{ groupId={groupBalances[0]!.groupId} >
diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index 44c62600..fe73dc03 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -3,33 +3,43 @@ import { type inferRouterOutputs } from '@trpc/server'; import Image from 'next/image'; import Link from 'next/link'; import React from 'react'; -import { CategoryIcon } from '~/components/ui/categoryIcons'; +import { + CURRENCY_CONVERSION_ICON, + CategoryIcon, + SETTLEUP_ICON, +} from '~/components/ui/categoryIcons'; import type { ExpenseRouter } from '~/server/api/routers/expense'; import { toUIString } from '~/utils/numbers'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { api } from '~/utils/api'; + +type ExpensesOutput = + | inferRouterOutputs['getGroupExpenses'] + | inferRouterOutputs['getExpensesWithFriend']; + +type SingleExpenseOutput = ExpensesOutput[number]; + +type ExpenseComponent = React.FC<{ + e: SingleExpenseOutput; + userId: number; +}>; export const ExpenseList: React.FC<{ userId: number; - expenses?: - | inferRouterOutputs['getGroupExpenses'] - | inferRouterOutputs['getExpensesWithFriend']; + expenses?: ExpensesOutput; contactId: number; isGroup?: boolean; isLoading?: boolean; }> = ({ userId, isGroup = false, expenses = [], contactId, isLoading }) => { - const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + if (!isLoading && expenses.length === 0) { + return ; + } return ( <> {expenses.map((e) => { - const youPaid = e.paidBy === userId && e.amount >= 0n; - const yourExpense = e.expenseParticipants.find( - (partecipant) => partecipant.userId === userId, - ); const isSettlement = e.splitType === SplitType.SETTLEMENT; - const yourExpenseAmount = youPaid - ? (yourExpense?.amount ?? 0n) - : -(yourExpense?.amount ?? 0n); + const isCurrencyConversion = e.splitType === SplitType.CURRENCY_CONVERSION; return ( -
-
- {toUIDate(e.expenseDate)} -
-
- -
-
- {!isSettlement ? ( -

- {e.name} -

- ) : null} -

- {isSettlement ? ' 🎉 ' : null} - {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} - {e.currency} {toUIString(e.amount)} -

-
-
- {isSettlement ? null : ( -
- {youPaid || 0n !== yourExpenseAmount ? ( - <> -
- {t('ui.actors.you', { ns: 'common' })}{' '} - {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`, { ns: 'common' })} -
-
- {e.currency}{' '} - {toUIString(yourExpenseAmount)} -
- - ) : ( -
-

- {t('ui.not_involved', { ns: 'common' })} -

-
- )} -
- )} + {isSettlement && } + {isCurrencyConversion && } + {!isSettlement && !isCurrencyConversion && } ); })} - {0 === expenses.length && !isLoading ? ( -
- Empty + + ); +}; + +const Expense: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const youPaid = e.paidBy === userId && e.amount >= 0n; + const yourExpense = e.expenseParticipants.find((partecipant) => partecipant.userId === userId); + const yourExpenseAmount = youPaid ? (yourExpense?.amount ?? 0n) : -(yourExpense?.amount ?? 0n); + + return ( + <> +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

{e.name}

+

+ {displayName(e.paidByUser, userId)}{' '} + {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} + {e.currency} {toUIString(e.amount)} +

- ) : null} +
+
+ {youPaid || 0n !== yourExpenseAmount ? ( + <> +
+ {t('ui.actors.you', { ns: 'common' })}{' '} + {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`, { ns: 'common' })} +
+
+ {e.currency} {toUIString(yourExpenseAmount)} +
+ + ) : ( +
+

{t('ui.not_involved', { ns: 'common' })}

+
+ )} +
); }; + +const Settlement: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const receiverId = e.expenseParticipants.find((p) => p.userId !== e.paidBy)?.userId; + const userDetails = api.user.getUserDetails.useQuery({ userId: receiverId! }); + + return ( +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

+ {displayName(e.paidByUser, userId)}{' '} + {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} + {e.currency} {toUIString(e.amount)} {t('common:ui.expense.to')}{' '} + {displayName(userDetails.data, userId)} +

+
+
+ ); +}; + +const CurrencyConversion: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const receiverId = e.expenseParticipants.find((p) => p.userId !== e.paidBy)?.userId; + const userDetails = api.user.getUserDetails.useQuery({ userId: receiverId! }); + + return ( +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

+ {/* @ts-ignore */} + {e.currency} {toUIString(e.amount)} ➡️ {e.conversionTo.currency} {/* @ts-ignore */} + {toUIString(e.conversionTo.amount)} +

+

+ {t('common:ui.expense.for')} {displayName(e.paidByUser, userId)} {t('common:ui.and')}{' '} + {displayName(userDetails.data, userId)} +

+
+
+ ); +}; + +const NoExpenses = () => ( +
+ Empty +
+); diff --git a/src/components/ui/categoryIcons.tsx b/src/components/ui/categoryIcons.tsx index dce5c54b..d6c65db6 100644 --- a/src/components/ui/categoryIcons.tsx +++ b/src/components/ui/categoryIcons.tsx @@ -1,3 +1,4 @@ +import { SplitType } from '@prisma/client'; import { Baby, Backpack, @@ -8,6 +9,7 @@ import { Car, CarTaxiFront, Construction, + DollarSign, DoorOpen, FerrisWheel, Flame, @@ -18,6 +20,7 @@ import { Globe, GraduationCap, Hammer, + HandCoins, HandIcon, Home, Hotel, @@ -98,9 +101,13 @@ export const CategoryIcons: Record = { hotel: Hotel, }; +export const CURRENCY_CONVERSION_ICON = DollarSign; + +export const SETTLEUP_ICON = HandCoins; + export const DEFAULT_CATEGORY_ICON = CategoryIcons[DEFAULT_CATEGORY]; -export const CategoryIcon: React.FC<{ category?: string } & LucideProps> = ({ +export const CategoryIcon: React.FC<{ category?: string; splitType?: SplitType } & LucideProps> = ({ category = DEFAULT_CATEGORY, ...props }) => { diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index ad18cecf..3d130c7a 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -197,6 +197,20 @@ export const expenseRouter = createTRPCRouter({ { deletedBy: null, }, + { + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + otherConversion: null, + }, + }, + ], + }, ], }, orderBy: { @@ -216,6 +230,7 @@ export const expenseRouter = createTRPCRouter({ }, }, paidByUser: true, + conversionFrom: true, }, }); @@ -229,6 +244,18 @@ export const expenseRouter = createTRPCRouter({ where: { groupId: input.groupId, deletedBy: null, + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + otherConversion: null, + }, + }, + ], }, orderBy: { expenseDate: 'desc', @@ -237,10 +264,10 @@ export const expenseRouter = createTRPCRouter({ expenseParticipants: true, paidByUser: true, deletedByUser: true, + conversionTo: true, }, }); - console.log(expenses.at(-1)?.name, expenses.at(-1)?.expenseParticipants); return expenses; }), diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index c073f66b..d91996ae 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -67,6 +67,13 @@ export async function createExpense( fileKey, addedBy: currentUserId, expenseDate, + conversionFrom: otherConversion + ? { + connect: { + id: otherConversion ?? null, + }, + } + : undefined, }, }), ); From 996dcde3f5ce3bd940f96569afeca4782f036d96 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 20:48:52 +0200 Subject: [PATCH 20/44] Add english locale to toasts --- public/locales/en/common.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 58e9d640..28c4085f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -88,7 +88,9 @@ "title": "Currency conversion", "description": "Convert the amount to a different currency.", "amount_to_receive": "Amount to receive", - "exchange_rate": "Exchange rate" + "exchange_rate": "Exchange rate", + "success_toast": "Currency conversion set successfully", + "error_toast": "Error while setting currency conversion" } }, "errors": { From ac27c5349aedc39d90ceb862e83a16cf6c71d9ee Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 14 Sep 2025 21:07:40 +0200 Subject: [PATCH 21/44] Properly display currency conversion details --- src/components/Expense/ExpenseDetails.tsx | 98 +++++++++++--------- src/components/Friend/CurrencyConversion.tsx | 2 +- src/server/api/routers/expense.ts | 16 +++- 3 files changed, 67 insertions(+), 49 deletions(-) diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 367fac90..9c3cb768 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -1,30 +1,28 @@ -import { type Expense, type ExpenseParticipant, type User } from '@prisma/client'; import { isSameDay } from 'date-fns'; import { type User as NextUser } from 'next-auth'; import { toUIString } from '~/utils/numbers'; +import type { inferRouterOutputs } from '@trpc/server'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import type { ExpenseRouter } from '~/server/api/routers/expense'; import { EntityAvatar } from '../ui/avatar'; +import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; -import type { FC } from 'react'; -import { CategoryIcon } from '../ui/categoryIcons'; +import React from 'react'; + +type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; interface ExpenseDetailsProps { user: NextUser; - expense: Expense & { - expenseParticipants: (ExpenseParticipant & { user: User })[]; - addedByUser: User; - paidByUser: User; - deletedByUser: User | null; - updatedByUser: User | null; - }; + expense: ExpenseDetailsOutput; storagePublicUrl?: string; } -const ExpenseDetails: FC = ({ user, expense, storagePublicUrl }) => { +const ExpenseDetails: React.FC = ({ user, expense, storagePublicUrl }) => { const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + return ( <>
@@ -73,16 +71,6 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU
- {/* */}

{displayName(expense.paidByUser, user.id)}{' '} @@ -95,33 +83,53 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU

{expense.expenseParticipants - .filter( - (partecipant) => - (expense.paidBy === partecipant.userId ? (expense.amount ?? 0n) : 0n) !== - partecipant.amount, - ) - .map((partecipant) => ( -
- -

- {displayName(partecipant.user, user.id)}{' '} - {t( - `ui.expense.${user.id === partecipant.userId ? 'you' : 'user'}.${expense.amount < 0 ? 'received' : 'owe'}`, - { - ns: 'common', - }, - )}{' '} - {expense.currency}{' '} - {toUIString( - (expense.paidBy === partecipant.userId ? (expense.amount ?? 0n) : 0n) - - partecipant.amount, - )} -

-
+ .filter((participant) => 0n !== participant.amount) + .map((participant) => ( + ))} + {expense.conversionTo && ( + <> + {expense.conversionTo.expenseParticipants + .filter((participant) => 0n !== participant.amount) + .map((participant) => ( + + ))} + + )}
); }; +const ExpenseParticipantEntry: React.FC<{ + participant: ExpenseDetailsOutput['expenseParticipants'][number]; + userId: number; + currency: string; +}> = ({ participant, userId, currency }) => { + const { displayName, t } = useTranslationWithUtils(); + + return ( +
+ +

+ {displayName(participant.user, userId)}{' '} + {t( + `ui.expense.${userId === participant.userId ? 'you' : 'user'}.${participant.amount < 0 ? 'received' : 'owe'}`, + )}{' '} + {currency} {toUIString(participant.amount)} +

+
+ ); +}; + export default ExpenseDetails; diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 22a6929b..09d80c57 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -24,7 +24,7 @@ export const CurrencyConversion: React.FC<{ }> = ({ amount, currency, sender, receiver, children, groupId }) => { const { t } = useTranslationWithUtils(); - const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const addOrEditCurrencyConversionMutation = api.expense.addCurrencyConversion.useMutation(); const utils = api.useUtils(); const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 3d130c7a..080372b1 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -113,16 +113,17 @@ export const expenseRouter = createTRPCRouter({ } }), - addOrEditCurrencyConversion: protectedProcedure + addCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) .mutation(async ({ input, ctx }) => { const { amount, rate, from, to, senderId, receiverId, groupId } = input; const amountTo = BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); + const name = `${from} → ${to} @ ${rate}`; const expenseFrom = await createExpense( { - name: `1: ${from} → ${to} @ ${rate}`, + name, currency: from, amount, paidBy: receiverId, @@ -147,7 +148,7 @@ export const expenseRouter = createTRPCRouter({ const expenseTo = await createExpense( { - name: `2: ${from} → ${to} @ ${rate}`, + name, currency: to, amount: amountTo, paidBy: receiverId, @@ -290,6 +291,15 @@ export const expenseRouter = createTRPCRouter({ deletedByUser: true, updatedByUser: true, group: true, + conversionTo: { + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + }, + }, }, }); From c83e9690ab0e418fe2956864b6a984a34dcfc302 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 15 Sep 2025 21:35:10 +0200 Subject: [PATCH 22/44] Allow editing and fix deleting expense --- src/components/Expense/ExpenseDetails.tsx | 42 +++++++++- src/components/Friend/CurrencyConversion.tsx | 50 ++++++------ .../[friendId]/expenses/[expenseId].tsx | 17 ++-- src/pages/expenses/[expenseId].tsx | 21 +++-- .../groups/[groupId]/expenses/[expenseId].tsx | 17 ++-- src/server/api/routers/expense.ts | 80 ++++++++++++------- src/server/api/services/splitService.ts | 4 + src/types/expense.types.ts | 1 + 8 files changed, 158 insertions(+), 74 deletions(-) diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 9c3cb768..b5609e3d 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -10,7 +10,12 @@ import { EntityAvatar } from '../ui/avatar'; import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; +import { Button } from '../ui/button'; +import { PencilIcon } from 'lucide-react'; +import { useAddExpenseStore } from '~/store/addStore'; +import { isCurrencyCode } from '~/lib/currency'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -132,4 +137,39 @@ const ExpenseParticipantEntry: React.FC<{ ); }; +export const EditCurrencyConversion: React.FC<{ expense: ExpenseDetailsOutput }> = ({ + expense, +}) => { + const { setCurrency } = useAddExpenseStore((s) => s.actions); + + const onClick = useCallback(() => { + if (expense.conversionTo && isCurrencyCode(expense.conversionTo.currency)) { + setCurrency(expense.conversionTo.currency); + } + }, [expense, setCurrency]); + + const sender = expense.paidByUser; + const receiver = expense.expenseParticipants.find((p) => p.userId !== expense.paidBy)?.user; + + if (!sender || !receiver) { + return null; + } + + return ( + + + + ); +}; + export default ExpenseDetails; diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 09d80c57..ca9afe21 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -2,7 +2,7 @@ import { type User } from '@prisma/client'; import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { api } from '~/utils/api'; -import { BigMath } from '~/utils/numbers'; +import { BigMath, toSafeBigInt } from '~/utils/numbers'; import { type CurrencyCode, isCurrencyCode } from '~/lib/currency'; import { CurrencyPicker } from '../AddExpense/CurrencyPicker'; @@ -16,18 +16,22 @@ import { toast } from 'sonner'; export const CurrencyConversion: React.FC<{ amount: bigint; + editingRate?: number; currency: string; sender: User; receiver: User; children: ReactNode; - groupId: number; -}> = ({ amount, currency, sender, receiver, children, groupId }) => { + expenseId?: string; + groupId: number | null; +}> = ({ amount, editingRate, currency, sender, receiver, children, groupId, expenseId }) => { const { t } = useTranslationWithUtils(); - const addOrEditCurrencyConversionMutation = api.expense.addCurrencyConversion.useMutation(); + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); const utils = api.useUtils(); - const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); + const [amountStr, setAmountStr] = useState(''); + const [rate, setRate] = useState(''); + const [targetAmountStr, setTargetAmountStr] = useState(''); const preferredCurrency = useAddExpenseStore((state) => state.currency); const { setCurrency } = useAddExpenseStore((state) => state.actions); const [targetCurrency, setTargetCurrency] = useState(preferredCurrency); @@ -37,34 +41,30 @@ export const CurrencyConversion: React.FC<{ { enabled: currency !== targetCurrency }, ); + useEffect(() => { + setAmountStr((Number(BigMath.abs(amount)) / 100).toString()); + setRate(editingRate ? editingRate.toFixed(4) : ''); + }, [amount, editingRate]); + useEffect(() => { if (getCurrencyRate.data?.rate) { - setRate(Number(getCurrencyRate.data.rate).toFixed(4)); + setRate(getCurrencyRate.data.rate.toFixed(4)); } }, [getCurrencyRate.data, amountStr]); - const [rate, setRate] = useState(''); - const [targetAmountStr, setTargetAmountStr] = useState(''); const dateDisabled = useMemo(() => ({ after: new Date() }), []); useEffect(() => { setTargetAmountStr((Number(amountStr) * Number(rate)).toFixed(2)); }, [amountStr, rate]); - const onChangeAmount = useCallback( - (e: React.ChangeEvent) => { - const { value } = e.target; - if ( - Number(value) < 0 || - Number.isNaN(Number(value)) || - Number(value) > Number(BigMath.abs(amount)) / 100 - ) { - return; - } - setAmountStr(value); - }, - [amount], - ); + const onChangeAmount = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + if (Number(value) < 0 || Number.isNaN(Number(value))) { + return; + } + setAmountStr(value); + }, []); const onChangeRate = useCallback((e: React.ChangeEvent) => { const raw = e.target.value.replace(',', '.'); @@ -106,13 +106,14 @@ export const CurrencyConversion: React.FC<{ const onSave = useCallback(async () => { try { await addOrEditCurrencyConversionMutation.mutateAsync({ - amount, + amount: toSafeBigInt(amountStr), rate: Number(rate), from: currency, to: targetCurrency, senderId: sender.id, receiverId: receiver.id, groupId, + expenseId, }); toast.success(t('ui.currency_conversion.success_toast')); utils.invalidate().catch(console.error); @@ -123,11 +124,12 @@ export const CurrencyConversion: React.FC<{ }, [ addOrEditCurrencyConversionMutation, targetCurrency, - amount, + amountStr, rate, currency, sender.id, receiver.id, + expenseId, groupId, t, utils, diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx index 7e6cf822..c99cabbe 100644 --- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx +++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { env } from '~/env'; @@ -12,6 +12,7 @@ import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { SplitType } from '@prisma/client'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -45,11 +46,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ friendId={friendId} groupId={expenseQuery.data?.groupId ?? undefined} /> - - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
} > diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx index d0e98239..efb6e64a 100644 --- a/src/pages/expenses/[expenseId].tsx +++ b/src/pages/expenses/[expenseId].tsx @@ -1,13 +1,14 @@ import { env } from 'process'; +import { SplitType } from '@prisma/client'; import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; +import { type GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useTranslation } from 'next-i18next'; -import { type GetServerSideProps } from 'next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { type NextPageWithUser } from '~/types'; @@ -43,11 +44,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ {!expenseQuery.data?.deletedBy ? (
- - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
) : null}
diff --git a/src/pages/groups/[groupId]/expenses/[expenseId].tsx b/src/pages/groups/[groupId]/expenses/[expenseId].tsx index 10a214a5..aca1af5c 100644 --- a/src/pages/groups/[groupId]/expenses/[expenseId].tsx +++ b/src/pages/groups/[groupId]/expenses/[expenseId].tsx @@ -6,13 +6,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { SplitType } from '@prisma/client'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -48,11 +49,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ expenseId={expenseId} groupId={expenseQuery.data?.groupId ?? undefined} /> - - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
} loading={expenseQuery.isPending} diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 080372b1..6b5ffaca 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -113,25 +113,26 @@ export const expenseRouter = createTRPCRouter({ } }), - addCurrencyConversion: protectedProcedure + addOrEditCurrencyConversion: protectedProcedure .input(createCurrencyConversionSchema) .mutation(async ({ input, ctx }) => { - const { amount, rate, from, to, senderId, receiverId, groupId } = input; + const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; const amountTo = BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); const name = `${from} → ${to} @ ${rate}`; - const expenseFrom = await createExpense( + const expenseFrom = await (expenseId ? editExpense : createExpense)( { + expenseId, name, currency: from, amount, - paidBy: receiverId, + paidBy: senderId, splitType: SplitType.CURRENCY_CONVERSION, category: DEFAULT_CATEGORY, participants: [ - { userId: senderId, amount: -amount }, - { userId: receiverId, amount: amount }, + { userId: senderId, amount: amount }, + { userId: receiverId, amount: -amount }, ], groupId, expenseDate: new Date(), @@ -142,33 +143,54 @@ export const expenseRouter = createTRPCRouter({ if (!expenseFrom) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create currency conversion record', + message: 'Failed to upsert currency conversion record', }); } - const expenseTo = await createExpense( - { - name, - currency: to, - amount: amountTo, - paidBy: receiverId, - splitType: SplitType.CURRENCY_CONVERSION, - category: DEFAULT_CATEGORY, - participants: [ - { userId: senderId, amount: amountTo }, - { userId: receiverId, amount: -amountTo }, - ], - groupId, - expenseDate: new Date(), - otherConversion: expenseFrom.id, - }, - ctx.session.user.id, - ); - - return { - ...expenseFrom, - otherConversion: expenseTo?.id, + const otherConversionParams = { + name, + currency: to, + amount: amountTo, + paidBy: receiverId, + splitType: SplitType.CURRENCY_CONVERSION, + category: DEFAULT_CATEGORY, + participants: [ + { userId: senderId, amount: -amountTo }, + { userId: receiverId, amount: amountTo }, + ], + groupId, + expenseDate: new Date(), }; + + if (expenseId) { + const expense = await db.expense.findFirst({ + select: { otherConversion: true }, + where: { + id: expenseId, + }, + }); + + if (expense?.otherConversion) { + await editExpense( + { + expenseId: expense.otherConversion, + ...otherConversionParams, + }, + ctx.session.user.id, + ); + return { + ...expenseFrom, + }; + } + } else { + await createExpense( + { + ...otherConversionParams, + otherConversion: expenseFrom.id, + }, + ctx.session.user.id, + ); + } }), getExpensesWithFriend: protectedProcedure diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index d91996ae..cc742257 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -215,6 +215,10 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { throw new Error('Expense not found'); } + if (expense.otherConversion) { + await deleteExpense(expense.otherConversion, deletedBy); + } + expense.expenseParticipants .filter(({ userId }) => userId !== expense.paidBy) .forEach((participant) => { diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index c041b9f1..9537c59f 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -53,6 +53,7 @@ export const createCurrencyConversionSchema = z.object({ receiverId: z.number(), groupId: z.number().nullable(), expenseId: z.string().optional(), + otherExpenseId: z.string().optional(), }); export const getCurrencyRateSchema = z.object({ From 9bbc291bd72f4432316eaa131ebfab382bbd6b64 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 15 Sep 2025 21:47:10 +0200 Subject: [PATCH 23/44] Run prettier on all file types --- .lintstagedrc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 0595c4cd..f80dbd70 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -1,6 +1,6 @@ const config = { // Format all supported file types with prettier - '*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml,html}': ['prettier --write'], + '*': ['prettier --write'], // Run oxlint on JavaScript/TypeScript files '*.{js,jsx,ts,tsx}': ['oxlint --type-aware --fix'], From f518b0d3cb5449f00abc92790b6367676d89c5c2 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 15 Sep 2025 22:43:06 +0200 Subject: [PATCH 24/44] Refactor currency rate providers and fetch intermediate currencies --- .env.example | 4 + next.config.js | 5 +- pnpm-lock.yaml | 112 +++++++-- src/components/AddExpense/CurrencyPicker.tsx | 8 +- src/components/Friend/CurrencyConversion.tsx | 3 +- src/{env.js => env.ts} | 6 +- src/lib/currency.ts | 35 +++ src/server/api/routers/expense.ts | 47 +--- .../api/services/currencyRateService.ts | 230 ++++++++++++------ 9 files changed, 311 insertions(+), 139 deletions(-) rename src/{env.js => env.ts} (94%) diff --git a/.env.example b/.env.example index b0f21bda..a2a1bf19 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,10 @@ FEEDBACK_EMAIL= # Discord webhook for error notifications DISCORD_WEBHOOK_URL= +# Currency rate provider, currently supported: 'frankfurter' (default), 'openexchangerates' +# Note: Frankfurter is free and does not require an API key, but has a limited set of supported currencies. +CURRENCY_RATE_PROVIDER=frankfurter + # Open Exchange Rates App ID OPEN_EXCHANGE_RATES_APP_ID= #********* END OF OPTIONAL ENV VARS ********* diff --git a/next.config.js b/next.config.js index 2aee4695..3e04f6f4 100644 --- a/next.config.js +++ b/next.config.js @@ -1,11 +1,14 @@ import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from 'next/constants.js'; import i18nConfig from './next-i18next.config.js'; +import { fileURLToPath } from 'node:url'; +import { createJiti } from 'jiti'; +const jiti = createJiti(fileURLToPath(import.meta.url)); /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * for Docker builds. */ -await import('./src/env.js'); +await jiti.import('./src/env'); /** @type {import("next").NextConfig} */ const nextConfig = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b26376a3..e379af88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1465,6 +1465,9 @@ packages: '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1477,8 +1480,8 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.10': - resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} @@ -1489,12 +1492,18 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -2877,6 +2886,9 @@ packages: '@types/node@22.16.5': resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} + '@types/node@22.18.4': + resolution: {integrity: sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==} + '@types/nodemailer@6.4.17': resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} @@ -3204,6 +3216,10 @@ packages: resolution: {integrity: sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==} engines: {node: '>= 16'} + baseline-browser-mapping@2.8.4: + resolution: {integrity: sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==} + hasBin: true + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -3226,6 +3242,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.26.0: + resolution: {integrity: sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -3266,6 +3287,9 @@ packages: caniuse-lite@1.0.30001718: resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3492,6 +3516,9 @@ packages: electron-to-chromium@1.5.157: resolution: {integrity: sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==} + electron-to-chromium@1.5.218: + resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -3509,8 +3536,8 @@ packages: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.18.2: - resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} entities@6.0.0: @@ -4551,6 +4578,9 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.21: + resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + nodemailer@6.10.1: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} @@ -5227,6 +5257,10 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} + tapable@2.2.3: + resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} + engines: {node: '>=6'} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -5260,8 +5294,8 @@ packages: engines: {node: '>=10'} hasBin: true - terser@5.43.1: - resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} engines: {node: '>=10'} hasBin: true @@ -7449,6 +7483,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -7459,10 +7498,10 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.10': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/source-map@0.3.6': dependencies: @@ -7473,6 +7512,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7483,6 +7524,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -8983,6 +9029,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.18.4': + dependencies: + undici-types: 6.21.0 + '@types/nodemailer@6.4.17': dependencies: '@types/node': 18.19.103 @@ -9327,6 +9377,8 @@ snapshots: balanced-match@3.0.1: {} + baseline-browser-mapping@2.8.4: {} + bn.js@4.12.2: {} boring-avatars@1.11.2: {} @@ -9348,6 +9400,14 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.5) + browserslist@4.26.0: + dependencies: + baseline-browser-mapping: 2.8.4 + caniuse-lite: 1.0.30001741 + electron-to-chromium: 1.5.218 + node-releases: 2.0.21 + update-browserslist-db: 1.1.3(browserslist@4.26.0) + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -9385,6 +9445,8 @@ snapshots: caniuse-lite@1.0.30001718: {} + caniuse-lite@1.0.30001741: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9585,6 +9647,8 @@ snapshots: electron-to-chromium@1.5.157: {} + electron-to-chromium@1.5.218: {} + emittery@0.13.1: {} emoji-regex@10.4.0: {} @@ -9598,10 +9662,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 - enhanced-resolve@5.18.2: + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.2 + tapable: 2.2.3 entities@6.0.0: {} @@ -10586,7 +10650,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.16.5 + '@types/node': 22.18.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -10934,6 +10998,8 @@ snapshots: node-releases@2.0.19: {} + node-releases@2.0.21: {} + nodemailer@6.10.1: {} normalize-path@3.0.0: {} @@ -11633,6 +11699,8 @@ snapshots: tapable@2.2.2: {} + tapable@2.2.3: {} + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -11653,11 +11721,11 @@ snapshots: terser-webpack-plugin@5.3.14(webpack@5.99.9): dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 - terser: 5.43.1 + terser: 5.44.0 webpack: 5.99.9 terser@5.39.2: @@ -11667,9 +11735,9 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - terser@5.43.1: + terser@5.44.0: dependencies: - '@jridgewell/source-map': 0.3.10 + '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -11834,6 +11902,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.1.3(browserslist@4.26.0): + dependencies: + browserslist: 4.26.0 + escalade: 3.2.0 + picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@19.1.8)(react@19.1.0): dependencies: react: 19.1.0 @@ -11921,9 +11995,9 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 - browserslist: 4.24.5 + browserslist: 4.26.0 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.2 + enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -11934,7 +12008,7 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 4.3.2 - tapable: 2.2.2 + tapable: 2.2.3 terser-webpack-plugin: 5.3.14(webpack@5.99.9) watchpack: 2.4.4 webpack-sources: 3.3.3 diff --git a/src/components/AddExpense/CurrencyPicker.tsx b/src/components/AddExpense/CurrencyPicker.tsx index 1c8e3959..f95b324d 100644 --- a/src/components/AddExpense/CurrencyPicker.tsx +++ b/src/components/AddExpense/CurrencyPicker.tsx @@ -1,10 +1,14 @@ import { memo, useCallback, useMemo } from 'react'; -import { CURRENCIES, type CurrencyCode, parseCurrencyCode } from '~/lib/currency'; +import { + CURRENCIES, + type CurrencyCode, + FRANKFURTER_CURRENCIES, + parseCurrencyCode, +} from '~/lib/currency'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { GeneralPicker } from '../GeneralPicker'; -import { FRANKFURTER_CURRENCIES } from '~/server/api/services/currencyRateService'; import { Button } from '../ui/button'; const FRANKFURTER_FILTERED_CURRENCIES = Object.fromEntries( diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index ca9afe21..6aab3735 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -284,7 +284,8 @@ export const CurrencyConversion: React.FC<{ className="mx-auto" currentCurrency={targetCurrency} onCurrencyPick={onChangeTargetCurrency} - showOnlyFrankfurter={currency !== 'USD' || !env.NEXT_PUBLIC_OXR_AVAILABLE} + // Client env vars with pages router only work after next build :/ + showOnlyFrankfurter={env.NEXT_PUBLIC_FRANKFURTER_USED} /> diff --git a/src/env.js b/src/env.ts similarity index 94% rename from src/env.js rename to src/env.ts index 8e1c12ea..ce92c96f 100644 --- a/src/env.js +++ b/src/env.ts @@ -51,6 +51,7 @@ export const env = createEnv({ FEEDBACK_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), DEFAULT_HOMEPAGE: z.string().default('/home'), + CURRENCY_RATE_PROVIDER: z.enum(['frankfurter', 'openexchangerates']).default('frankfurter'), OPEN_EXCHANGE_RATES_APP_ID: z.string().optional(), OIDC_NAME: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(), @@ -65,7 +66,7 @@ export const env = createEnv({ * `NEXT_PUBLIC_`. */ client: { - NEXT_PUBLIC_OXR_AVAILABLE: z.boolean().default(false), + NEXT_PUBLIC_FRANKFURTER_USED: z.boolean().default(false), }, /** @@ -104,13 +105,14 @@ export const env = createEnv({ FEEDBACK_EMAIL: process.env.FEEDBACK_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, DEFAULT_HOMEPAGE: process.env.DEFAULT_HOMEPAGE, + CURRENCY_RATE_PROVIDER: process.env.CURRENCY_RATE_PROVIDER, OPEN_EXCHANGE_RATES_APP_ID: process.env.OPEN_EXCHANGE_RATES_APP_ID, OIDC_NAME: process.env.OIDC_NAME, OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID, OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET, OIDC_WELL_KNOWN_URL: process.env.OIDC_WELL_KNOWN_URL, OIDC_ALLOW_DANGEROUS_EMAIL_LINKING: !!process.env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING, - NEXT_PUBLIC_OXR_AVAILABLE: !!process.env.OPEN_EXCHANGE_RATES_APP_ID, + NEXT_PUBLIC_FRANKFURTER_USED: process.env.CURRENCY_RATE_PROVIDER === 'frankfurter', }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/lib/currency.ts b/src/lib/currency.ts index 5c5ead36..d0807170 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -840,3 +840,38 @@ export const isCurrencyCode = (value: string): value is CurrencyCode => value in export const parseCurrencyCode = (code: string): CurrencyCode => isCurrencyCode(code) ? code : 'USD'; + +// Check with https://api.frankfurter.dev/v1/currencies +export const FRANKFURTER_CURRENCIES = [ + 'AUD', + 'BGN', + 'BRL', + 'CAD', + 'CHF', + 'CNY', + 'CZK', + 'DKK', + 'EUR', + 'GBP', + 'HKD', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'ISK', + 'JPY', + 'KRW', + 'MXN', + 'MYR', + 'NOK', + 'NZD', + 'PHP', + 'PLN', + 'RON', + 'SEK', + 'SGD', + 'THB', + 'TRY', + 'USD', + 'ZAR', +]; diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 6b5ffaca..a8fc207c 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -15,7 +15,7 @@ import { getCurrencyRateSchema, } from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; -import { getCurrencyRates } from '../services/currencyRateService'; +import { currencyRateProvider } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; @@ -443,55 +443,16 @@ export const expenseRouter = createTRPCRouter({ await deleteExpense(input.expenseId, ctx.session.user.id); }), - getCurrencyRate: protectedProcedure.input(getCurrencyRateSchema).query(async ({ ctx, input }) => { + getCurrencyRate: protectedProcedure.input(getCurrencyRateSchema).query(async ({ input }) => { const { from, to, date } = input; if (!isCurrencyCode(from) || !isCurrencyCode(to)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); } - const cachedRate = await ctx.db.currencyRateCache.findUnique({ - where: { - from_to_date: { from, to, date }, - }, - }); - - if (cachedRate) { - return { rate: cachedRate.rate }; - } - - const reverseCachedRate = await ctx.db.currencyRateCache.findUnique({ - where: { - from_to_date: { from: to, to: from, date }, - }, - }); - - if (reverseCachedRate) { - return { rate: 1 / reverseCachedRate.rate }; - } - - const data = await getCurrencyRates(from, to, date); - - await Promise.all( - Object.entries(data.rates).map(([to, rate]) => - ctx.db.currencyRateCache.upsert({ - where: { - from_to_date: { from: data.base, to, date }, - }, - create: { - from, - to, - date, - rate, - }, - update: { - rate, - }, - }), - ), - ); + const rate = await currencyRateProvider.getCurrencyRate(from, to, date); - return { rate: data.base === from ? data.rates[to] : 1 / data.rates[from]! }; + return { rate }; }), }); diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 50ae0460..1d9832ee 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -1,5 +1,7 @@ import { format, isToday } from 'date-fns'; +import { env } from '~/env'; import type { CurrencyCode } from '~/lib/currency'; +import { db } from '~/server/db'; export interface RateResponse { base: string; @@ -8,87 +10,173 @@ export interface RateResponse { class ProviderMissingError extends Error {} -export const getCurrencyRates = async ( - from: CurrencyCode, - to: CurrencyCode, - date?: Date, -): Promise => { - if (FRANKFURTER_CURRENCIES.includes(from) && FRANKFURTER_CURRENCIES.includes(to)) { - return frankfurterFetchRates(from, to, date); - } else if (from === 'USD' || to === 'USD') { - return oxrFetchRates(date); - } else { - throw new ProviderMissingError(`No currency API available for ${from} or ${to}`); - } -}; +abstract class CurrencyRateProvider { + intermediateBase: CurrencyCode | null = null; + abstract providerName: string; + + abstract fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise; + + public async getCurrencyRate( + from: CurrencyCode, + to: CurrencyCode, + date: Date = new Date(), + ): Promise { + if (from === to) { + return 1; + } -async function frankfurterFetchRates( - from: CurrencyCode, - to: CurrencyCode, - date?: Date, -): Promise { - const key = !date || isToday(date) ? 'latest' : format(date, 'yyyy-MM-dd'); + const cachedRate = await this.checkCache(from, to, date); + if (cachedRate) { + return cachedRate; + } - const response = await fetch(`https://api.frankfurter.dev/v1/${key}?base=${from}&symbols=${to}`); - const data: RateResponse = await response.json(); + const data = await this.fetchRates(from, to, date); - if (response.ok) { - return data; + await Promise.all( + Object.entries(data.rates).map(([to, rate]) => + db.currencyRateCache.upsert({ + where: { + from_to_date: { from: data.base, to, date }, + }, + create: { + from, + to, + date, + rate, + }, + update: { + rate, + }, + }), + ), + ); + + const res = await this.checkCache(from, to, date); + if (res) { + return res; + } + + throw new Error('Failed to retrieve currency rate'); } - throw new Error(response.statusText || 'Failed to fetch exchange rates'); -} + protected async checkCache( + from: CurrencyCode, + to: CurrencyCode, + date: Date = new Date(), + ): Promise { + const cachedRate = await db.currencyRateCache.findUnique({ + where: { + from_to_date: { from, to, date }, + }, + }); -async function oxrFetchRates(date?: Date): Promise { - if (!process.env.OPEN_EXCHANGE_RATES_APP_ID) { - throw new ProviderMissingError('Open Exchange Rates API key not provided'); + if (cachedRate) { + void db.currencyRateCache.update({ + where: { + from_to_date: { from, to, date }, + }, + data: { + insertedAt: new Date(), + }, + }); + return cachedRate.rate; + } + + const reverseCachedRate = await db.currencyRateCache.findUnique({ + where: { + from_to_date: { from: to, to: from, date }, + }, + }); + + if (reverseCachedRate) { + void db.currencyRateCache.update({ + where: { + from_to_date: { from: to, to: from, date }, + }, + data: { + insertedAt: new Date(), + }, + }); + return 1 / reverseCachedRate.rate; + } + + if ([null, from, to].includes(this.intermediateBase)) { + return undefined; + } + + // try with intermediate base currency + const rateFromIntermediate = await this.checkCache(this.intermediateBase!, from, date); + const rateToIntermediate = await this.checkCache(this.intermediateBase!, to, date); + + if (rateFromIntermediate && rateToIntermediate) { + void db.currencyRateCache.update({ + where: { + from_to_date: { from: this.intermediateBase!, to: from, date }, + }, + data: { + insertedAt: new Date(), + }, + }); + void db.currencyRateCache.update({ + where: { + from_to_date: { from: this.intermediateBase!, to, date }, + }, + data: { + insertedAt: new Date(), + }, + }); + return rateFromIntermediate / rateToIntermediate; + } } - const key = !date || isToday(date) ? 'latest' : `hitorical/${format(date, 'yyyy-MM-dd')}`; +} - // sadly the free tier supports only USD as base currency - const response = await fetch( - `https://openexchangerates.org/api/${key}.json?app_id=${process.env.OPEN_EXCHANGE_RATES_APP_ID}`, - ); - const data: RateResponse = await response.json(); +class FrankfurterProvider extends CurrencyRateProvider { + providerName = 'frankfurter'; - if (response.ok) { - return data; + async fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise { + const key = !date || isToday(date) ? 'latest' : format(date, 'yyyy-MM-dd'); + + const response = await fetch( + `https://api.frankfurter.dev/v1/${key}?base=${from}&symbols=${to}`, + ); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); } +} + +class OpenExchangeRatesProvider extends CurrencyRateProvider { + providerName = 'openexchangerates'; + intermediateBase: CurrencyCode = 'USD'; + + async fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise { + if (!process.env.OPEN_EXCHANGE_RATES_APP_ID) { + throw new ProviderMissingError('Open Exchange Rates API key not provided'); + } + const key = !date || isToday(date) ? 'latest' : `hitorical/${format(date, 'yyyy-MM-dd')}`; - throw new Error(response.statusText || 'Failed to fetch exchange rates'); + // sadly the free tier supports only USD as base currency + const response = await fetch( + `https://openexchangerates.org/api/${key}.json?app_id=${process.env.OPEN_EXCHANGE_RATES_APP_ID}`, + ); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); + } } -// Check with https://api.frankfurter.dev/v1/currencies -export const FRANKFURTER_CURRENCIES = [ - 'AUD', - 'BGN', - 'BRL', - 'CAD', - 'CHF', - 'CNY', - 'CZK', - 'DKK', - 'EUR', - 'GBP', - 'HKD', - 'HUF', - 'IDR', - 'ILS', - 'INR', - 'ISK', - 'JPY', - 'KRW', - 'MXN', - 'MYR', - 'NOK', - 'NZD', - 'PHP', - 'PLN', - 'RON', - 'SEK', - 'SGD', - 'THB', - 'TRY', - 'USD', - 'ZAR', -]; +export const currencyRateProvider = (() => { + if (env.CURRENCY_RATE_PROVIDER === 'openexchangerates' && env.OPEN_EXCHANGE_RATES_APP_ID) { + return new OpenExchangeRatesProvider(); + } + + return new FrankfurterProvider(); +})(); From 5a3384eaa59b5d74ffd7e86de60faaee7bfef2f0 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 15 Sep 2025 22:43:26 +0200 Subject: [PATCH 25/44] Fix prettier issues --- .lintstagedrc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lintstagedrc.js b/.lintstagedrc.js index f80dbd70..d916607d 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -1,6 +1,6 @@ const config = { // Format all supported file types with prettier - '*': ['prettier --write'], + '*': ['prettier --write -u'], // Run oxlint on JavaScript/TypeScript files '*.{js,jsx,ts,tsx}': ['oxlint --type-aware --fix'], From 87c565a7f93325e03835489e6fbbc29b5504b494 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 18:50:01 +0200 Subject: [PATCH 26/44] Fix oxr flow --- .../api/services/currencyRateService.ts | 75 +++++++++---------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 1d9832ee..81cf24b1 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -39,7 +39,7 @@ abstract class CurrencyRateProvider { from_to_date: { from: data.base, to, date }, }, create: { - from, + from: data.base, to, date, rate, @@ -64,39 +64,16 @@ abstract class CurrencyRateProvider { to: CurrencyCode, date: Date = new Date(), ): Promise { - const cachedRate = await db.currencyRateCache.findUnique({ - where: { - from_to_date: { from, to, date }, - }, - }); + const cachedRate = await this.getCache(from, to, date); if (cachedRate) { - void db.currencyRateCache.update({ - where: { - from_to_date: { from, to, date }, - }, - data: { - insertedAt: new Date(), - }, - }); return cachedRate.rate; } - const reverseCachedRate = await db.currencyRateCache.findUnique({ - where: { - from_to_date: { from: to, to: from, date }, - }, - }); + const reverseCachedRate = await this.getCache(to, from, date); if (reverseCachedRate) { - void db.currencyRateCache.update({ - where: { - from_to_date: { from: to, to: from, date }, - }, - data: { - insertedAt: new Date(), - }, - }); + void this.upsertCache(from, to, date, 1 / reverseCachedRate.rate); return 1 / reverseCachedRate.rate; } @@ -109,24 +86,46 @@ abstract class CurrencyRateProvider { const rateToIntermediate = await this.checkCache(this.intermediateBase!, to, date); if (rateFromIntermediate && rateToIntermediate) { + const rate = rateToIntermediate / rateFromIntermediate; + void this.upsertCache(from, to, date, rate); + return rate; + } + } + + private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, rate: number) { + return db.currencyRateCache.upsert({ + where: { + from_to_date: { from, to, date }, + }, + create: { + from, + to, + date, + rate, + }, + update: { + rate, + }, + }); + } + + private async getCache(from: CurrencyCode, to: CurrencyCode, date: Date) { + const result = await db.currencyRateCache.findUnique({ + where: { + from_to_date: { from, to, date }, + }, + }); + if (result) { void db.currencyRateCache.update({ where: { - from_to_date: { from: this.intermediateBase!, to: from, date }, - }, - data: { - insertedAt: new Date(), - }, - }); - void db.currencyRateCache.update({ - where: { - from_to_date: { from: this.intermediateBase!, to, date }, + from_to_date: { from, to, date }, }, data: { insertedAt: new Date(), }, }); - return rateFromIntermediate / rateToIntermediate; } + return result; } } @@ -157,7 +156,7 @@ class OpenExchangeRatesProvider extends CurrencyRateProvider { if (!process.env.OPEN_EXCHANGE_RATES_APP_ID) { throw new ProviderMissingError('Open Exchange Rates API key not provided'); } - const key = !date || isToday(date) ? 'latest' : `hitorical/${format(date, 'yyyy-MM-dd')}`; + const key = !date || isToday(date) ? 'latest' : `historical/${format(date, 'yyyy-MM-dd')}`; // sadly the free tier supports only USD as base currency const response = await fetch( From 95eccf81e22217328832f5626777463b7bdb93ed Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 19:02:17 +0200 Subject: [PATCH 27/44] Frontend improvements --- src/components/Friend/CurrencyConversion.tsx | 9 ++- src/components/GeneralPicker.tsx | 65 ++++++++++++-------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 6aab3735..28f97f3f 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -41,6 +41,13 @@ export const CurrencyConversion: React.FC<{ { enabled: currency !== targetCurrency }, ); + useEffect(() => { + if (getCurrencyRate.isPending) { + setRate(''); + setTargetAmountStr(''); + } + }, [getCurrencyRate.isPending]); + useEffect(() => { setAmountStr((Number(BigMath.abs(amount)) / 100).toString()); setRate(editingRate ? editingRate.toFixed(4) : ''); @@ -261,7 +268,7 @@ export const CurrencyConversion: React.FC<{ {/* Date */}
( - - - - - {noOptionsText} - {items.map((item) => ( - - +}) => { + const [open, setOpen] = React.useState(false); + + const onSelectAndClose: typeof onSelect = useCallback( + (value) => { + setOpen(false); + onSelect(value); + }, + [onSelect], + ); + + return ( + + + + + {noOptionsText} + {items.map((item) => ( +
{render(item)}
-
-
- ))} -
-
-
-); + + ))} + + + + ); +}; From 5123eb94a6faf5addeb535c447803dc7e90c38dc Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 19:24:14 +0200 Subject: [PATCH 28/44] Update oxlint --- package.json | 4 +- pnpm-lock.yaml | 141 +++++++++++++++++++++++++------------------------ 2 files changed, 75 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index 34209a42..6ba7cd12 100644 --- a/package.json +++ b/package.json @@ -81,8 +81,8 @@ "jest": "30.0.5", "jest-environment-jsdom": "30.0.5", "lint-staged": "^16.1.5", - "oxlint": "^1.12.0", - "oxlint-tsgolint": "^0.0.4", + "oxlint": "^1.15.0", + "oxlint-tsgolint": "^0.2.0", "postcss": "^8.4.31", "prettier": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e379af88..947155cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,11 +173,11 @@ importers: specifier: ^16.1.5 version: 16.1.5 oxlint: - specifier: ^1.12.0 - version: 1.12.0 + specifier: ^1.15.0 + version: 1.15.0(oxlint-tsgolint@0.2.0) oxlint-tsgolint: - specifier: ^0.0.4 - version: 0.0.4 + specifier: ^0.2.0 + version: 0.2.0 postcss: specifier: ^8.4.31 version: 8.5.3 @@ -1579,73 +1579,73 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxlint-tsgolint/darwin-arm64@0.0.4': - resolution: {integrity: sha512-qL0zqIYdYrXl6ghTIHnhJkvyYy1eKz0P8YIEp59MjY3/zNiyk/gtyp8LkwZdqb9ezbcX9UDQhSuSO1wURJsq8g==} + '@oxlint-tsgolint/darwin-arm64@0.2.0': + resolution: {integrity: sha512-ayJO9SmiJ15oV3+svIw8bqun0ySdjiD7L+ddwNB4vOAgUX/rdX1KTBnDb/ZEk6MOFBnFgbbiEiLRJLlGtuFYVQ==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.0.4': - resolution: {integrity: sha512-c3nSjqmDSKzemChAEUv/zy2e9cwgkkO/7rz4Y447+8pSbeZNHi3RrNpVHdrKL/Qep4pt6nFZE+6PoczZxHNQjg==} + '@oxlint-tsgolint/darwin-x64@0.2.0': + resolution: {integrity: sha512-iCrcjkqqyy3zq+yXOmNsjt/DiSU4u9yJ00hEr4oGSrrk7V0ju6eqrmsh8VGS74YLY3MCskTjeTyVDRR7huc3WQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.0.4': - resolution: {integrity: sha512-P2BA54c/Ej5AGkChH1/7zMd6PwZfa+jnw8juB/JWops+BX+lbhbbBHz0cYduDBgWYjRo4e3OVJOTskqcpuMfNw==} + '@oxlint-tsgolint/linux-arm64@0.2.0': + resolution: {integrity: sha512-Kne4mrJGCZ0O+/ukcvWftCmDAEFUpMQ4q4wZdWVlnmNdTbtICIay3ofk/rzX0QoZmEZh0jy/G7p+5P0t9Bg5Sg==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.0.4': - resolution: {integrity: sha512-hbgLpnDNicPrbHOAQ9nNfLOSrUrdWANP/umR7P/cwCc1sv66eEs7bm4G3mrhRU8aXFBJmbhdNqiDSUkYYvHWJQ==} + '@oxlint-tsgolint/linux-x64@0.2.0': + resolution: {integrity: sha512-DKGFSR71fnExWpJXvN32SqWuEcT1XXeI1CKpO63jgXTAUpVl9H/BXG3+gNptSoZqzqeFTj8jOgiaX6VkOABqGA==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.0.4': - resolution: {integrity: sha512-ozKEppmwZhC5LMedClBEat6cXgBGUvxGOgsKK2ZZNE6zSScX7QbvJAOt3nWMGs8GQshHy/6ndMB33+uRloglQA==} + '@oxlint-tsgolint/win32-arm64@0.2.0': + resolution: {integrity: sha512-Grbkva1YH0eTRtv3MkVTFAycVwQSytcl8N52zNs1YresWwOlnNvNZ5EeLIaQaudcxwsbpZWR2Bdsfa467zDJTw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.0.4': - resolution: {integrity: sha512-gLfx+qogW21QcaRKFg6ARgra7tSPqyn+Ems3FgTUyxV4OpJYn7KsQroygxOWElqv6JUobtvHBrxdB6YhlvERbQ==} + '@oxlint-tsgolint/win32-x64@0.2.0': + resolution: {integrity: sha512-asfPqgu7r1H8NmBNxfMpZER6WvrUTH8BDMPIcChBhrUjuSmt4UMyiyul3CEPZLPQcPlaWCeGnConTu3BabK4Fw==} cpu: [x64] os: [win32] - '@oxlint/darwin-arm64@1.12.0': - resolution: {integrity: sha512-Pv+Ho1uq2ny8g2P6JgQpaIUF1FHPL32DfOlZhKqmzDT3PydtFvZp/7zNyJE3BIXeTOOOG1Eg12hjZHMLsWxyNw==} + '@oxlint/darwin-arm64@1.15.0': + resolution: {integrity: sha512-fwYg7WDKI6eAErREBGMXkIAOqBuBFN0LWbQJvVNXCGjywGxsisdwkHnNu4UG8IpHo4P71mUxf3l2xm+5Xiy+TA==} cpu: [arm64] os: [darwin] - '@oxlint/darwin-x64@1.12.0': - resolution: {integrity: sha512-kNXPH/7jXjX4pawrEWXQHOasOdOsrYKhskA1qYwLYcv/COVSoxOSElkQtQa+KxN5zzt3F02kBdWDndLpgJLbLQ==} + '@oxlint/darwin-x64@1.15.0': + resolution: {integrity: sha512-RtaAmB6NZZx4hvjCg6w35shzRY5fLclbMsToC92MTZ9lMDF9LotzcbyNHCZ1tvZb1tNPObpIsuX16BFeElF8nw==} cpu: [x64] os: [darwin] - '@oxlint/linux-arm64-gnu@1.12.0': - resolution: {integrity: sha512-U7NETs02K55ZyDlgdhx4lWeFYbkUKcL+YcG+Ak70EyEt/BKIIVt4B84VdV1JzC71FErUipDYAwPJmxMREXr4Sg==} + '@oxlint/linux-arm64-gnu@1.15.0': + resolution: {integrity: sha512-8uV0lAbmqp93KTBlJWyCdQWuxTzLn+QrDRidUaCLJjn65uvv8KlRhZJoZoyLh17X6U/cgezYktWTMiMhxX56BA==} cpu: [arm64] os: [linux] - '@oxlint/linux-arm64-musl@1.12.0': - resolution: {integrity: sha512-e4Pb2eZu3V2BsiX4t4gyv9iJ8+KRT6bkoWM5uC9BLX7edsVchwLwL6LB2vPYusYdPPrxdjlFCg6ni+9wlw7FbQ==} + '@oxlint/linux-arm64-musl@1.15.0': + resolution: {integrity: sha512-/+hTqh1J29+2GitKrWUHIYjQBM1szWSJ1U7OzQlgL+Uvf8jxg4sn1nV79LcPMXhC2t8lZy5EOXOgwIh92DsdhQ==} cpu: [arm64] os: [linux] - '@oxlint/linux-x64-gnu@1.12.0': - resolution: {integrity: sha512-qJK98Dj/z7Nbm0xoz0nCCMFGy0W/kLewPzOK5QENxuUoQQ6ymt7/75rXOuTwAZJ6JFTarqfSuMAA0pka6Tmytw==} + '@oxlint/linux-x64-gnu@1.15.0': + resolution: {integrity: sha512-GzeY3AhUd49yV+/76Gw0pjpwUJwxCkwYAJTNe7fFTdWjEQ6M6g8ZzJg5FKtUvgA5sMgmfzHhvSXxvT57YhcXnA==} cpu: [x64] os: [linux] - '@oxlint/linux-x64-musl@1.12.0': - resolution: {integrity: sha512-jNeltpHc1eonSev/bWKipJ7FI6+Rc7EXh6Y7E0pm8e95sc1klFA29FFVs3FjMA6CCa+SRT0u0nnNTTAtf2QOiQ==} + '@oxlint/linux-x64-musl@1.15.0': + resolution: {integrity: sha512-p/7+juizUOCpGYreFmdfmIOSSSE3+JfsgnXnOHuP8mqlZfiOeXyevyajuXpPNRM60+k0reGvlV7ezp1iFitF7w==} cpu: [x64] os: [linux] - '@oxlint/win32-arm64@1.12.0': - resolution: {integrity: sha512-T3fpNZJ3Q9YGgJTKc1YyvGoomSXnrV5mREz0QACE06zUzfS8EWyaYc/GN17FhHvQ4uQk/1xLgnM6FPsuLMeRhw==} + '@oxlint/win32-arm64@1.15.0': + resolution: {integrity: sha512-2LaDLOtCMq+lzIQ63Eir3UJV/hQNlw01xtsij2L8sSxt4gA+zWvubOQJQIOPGMDxEKFcWT1lo/6YEXX/sNnZDA==} cpu: [arm64] os: [win32] - '@oxlint/win32-x64@1.12.0': - resolution: {integrity: sha512-2eC4XQ1SMM2z7bCDG+Ifrn5GrvP6fkL0FGi4ZwDCrx6fwb1byFrXgSUNIPiqiiqBBrFRMKlXzU9zD6IjuFlUOg==} + '@oxlint/win32-x64@1.15.0': + resolution: {integrity: sha512-+jgRPpZrFIcrNxCVsDIy6HVCRpKVDN0DHD8VJodjrsDv6heqhq/qCTa2IXY3R4glWe1nWQ5JgdFKLn3Bl+aLNg==} cpu: [x64] os: [win32] @@ -4637,14 +4637,19 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxlint-tsgolint@0.0.4: - resolution: {integrity: sha512-KFWVP+VU3ymgK/Dtuf6iRkqjo+aN42lS1YThY6JWlNi1GQqm7wtio/kAwssqDhm8kP+CVXbgZAtu1wgsK4XeTg==} + oxlint-tsgolint@0.2.0: + resolution: {integrity: sha512-37Hy+FT1sz8hHUo31JIgFDA8NcFndexrg5okutWRPXNejJwB9hKN+pyInaQQIv4XDsgNcQsSR2VJoq99eaGk9g==} hasBin: true - oxlint@1.12.0: - resolution: {integrity: sha512-tBQ9aB00aYLlGXE21WJHnKQAI8xoi2V6Eiz/WvGV7FwU9YLYuNOurEEVbfoS5u0ODX8GLvGWj1fdHh5Rb74Kkw==} + oxlint@1.15.0: + resolution: {integrity: sha512-GZngkdF2FabM0pp0/l5OOhIQg+9L6LmOrmS8V8Vg+Swv9/VLJd/oc/LtAkv4HO45BNWL3EVaXzswI0CmGokVzw==} engines: {node: '>=8.*'} hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.2.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} @@ -7584,46 +7589,46 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@oxlint-tsgolint/darwin-arm64@0.0.4': + '@oxlint-tsgolint/darwin-arm64@0.2.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.0.4': + '@oxlint-tsgolint/darwin-x64@0.2.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.0.4': + '@oxlint-tsgolint/linux-arm64@0.2.0': optional: true - '@oxlint-tsgolint/linux-x64@0.0.4': + '@oxlint-tsgolint/linux-x64@0.2.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.0.4': + '@oxlint-tsgolint/win32-arm64@0.2.0': optional: true - '@oxlint-tsgolint/win32-x64@0.0.4': + '@oxlint-tsgolint/win32-x64@0.2.0': optional: true - '@oxlint/darwin-arm64@1.12.0': + '@oxlint/darwin-arm64@1.15.0': optional: true - '@oxlint/darwin-x64@1.12.0': + '@oxlint/darwin-x64@1.15.0': optional: true - '@oxlint/linux-arm64-gnu@1.12.0': + '@oxlint/linux-arm64-gnu@1.15.0': optional: true - '@oxlint/linux-arm64-musl@1.12.0': + '@oxlint/linux-arm64-musl@1.15.0': optional: true - '@oxlint/linux-x64-gnu@1.12.0': + '@oxlint/linux-x64-gnu@1.15.0': optional: true - '@oxlint/linux-x64-musl@1.12.0': + '@oxlint/linux-x64-musl@1.15.0': optional: true - '@oxlint/win32-arm64@1.12.0': + '@oxlint/win32-arm64@1.15.0': optional: true - '@oxlint/win32-x64@1.12.0': + '@oxlint/win32-x64@1.15.0': optional: true '@panva/hkdf@1.2.1': {} @@ -11054,26 +11059,26 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxlint-tsgolint@0.0.4: + oxlint-tsgolint@0.2.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.0.4 - '@oxlint-tsgolint/darwin-x64': 0.0.4 - '@oxlint-tsgolint/linux-arm64': 0.0.4 - '@oxlint-tsgolint/linux-x64': 0.0.4 - '@oxlint-tsgolint/win32-arm64': 0.0.4 - '@oxlint-tsgolint/win32-x64': 0.0.4 - - oxlint@1.12.0: + '@oxlint-tsgolint/darwin-arm64': 0.2.0 + '@oxlint-tsgolint/darwin-x64': 0.2.0 + '@oxlint-tsgolint/linux-arm64': 0.2.0 + '@oxlint-tsgolint/linux-x64': 0.2.0 + '@oxlint-tsgolint/win32-arm64': 0.2.0 + '@oxlint-tsgolint/win32-x64': 0.2.0 + + oxlint@1.15.0(oxlint-tsgolint@0.2.0): optionalDependencies: - '@oxlint/darwin-arm64': 1.12.0 - '@oxlint/darwin-x64': 1.12.0 - '@oxlint/linux-arm64-gnu': 1.12.0 - '@oxlint/linux-arm64-musl': 1.12.0 - '@oxlint/linux-x64-gnu': 1.12.0 - '@oxlint/linux-x64-musl': 1.12.0 - '@oxlint/win32-arm64': 1.12.0 - '@oxlint/win32-x64': 1.12.0 - oxlint-tsgolint: 0.0.4 + '@oxlint/darwin-arm64': 1.15.0 + '@oxlint/darwin-x64': 1.15.0 + '@oxlint/linux-arm64-gnu': 1.15.0 + '@oxlint/linux-arm64-musl': 1.15.0 + '@oxlint/linux-x64-gnu': 1.15.0 + '@oxlint/linux-x64-musl': 1.15.0 + '@oxlint/win32-arm64': 1.15.0 + '@oxlint/win32-x64': 1.15.0 + oxlint-tsgolint: 0.2.0 p-limit@2.3.0: dependencies: From 71324352fd59382567db7a2961bf8a4c560f1bef Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 19:24:41 +0200 Subject: [PATCH 29/44] Move submission logic out of currency component --- src/components/Expense/BalanceList.tsx | 147 +++++++++++-------- src/components/Expense/ExpenseDetails.tsx | 44 ++++-- src/components/Friend/CurrencyConversion.tsx | 47 ++---- 3 files changed, 132 insertions(+), 106 deletions(-) diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 7b655c0f..932bb914 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,16 +1,15 @@ import type { GroupBalance, User } from '@prisma/client'; import { clsx } from 'clsx'; -import { DollarSign, HandCoins, Info } from 'lucide-react'; -import { Fragment, useMemo } from 'react'; +import { type ComponentProps, Fragment, useCallback, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; import { api } from '~/utils/api'; import { BigMath, toUIString } from '~/utils/numbers'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { GroupSettleUp } from '../Friend/GroupSettleup'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { Button } from '../ui/button'; -import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { CURRENCY_CONVERSION_ICON, SETTLEUP_ICON } from '../ui/categoryIcons'; interface UserWithBalance { @@ -26,6 +25,9 @@ export const BalanceList: React.FC<{ const { displayName, t } = useTranslationWithUtils(['expense_details']); const userQuery = api.user.me.useQuery(); + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const apiUtils = api.useUtils(); + const userMap = useMemo(() => { const res = users.reduce>((acc, user) => { acc[user.id] = { user, balances: {}, total: {} }; @@ -101,67 +103,88 @@ export const BalanceList: React.FC<{ return ( - {Object.entries(perFriendBalances).map(([currency, amount]) => ( -
-
- -
- {displayName(friend, userQuery.data?.id)} - - {' '} - {t( - `ui.expense.${friend.id === userQuery.data?.id ? 'you' : 'user'}.${0 > amount ? 'get' : 'pay'}`, - { ns: 'common' }, - )}{' '} - - { + if (0n === amount) { + return null; + } + + const sender = 0 < amount ? friend : user; + const receiver = 0 < amount ? user : friend; + + const onSubmit: ComponentProps['onSubmit'] = + useCallback( + async (data) => { + await addOrEditCurrencyConversionMutation.mutateAsync({ + ...data, + senderId: sender.id, + receiverId: receiver.id, + groupId: groupBalances[0]!.groupId, + }); + await apiUtils.invalidate(); + }, + [sender.id, receiver.id], + ); + + return ( +
+
+ +
+ {displayName(friend, userQuery.data?.id)} + + {' '} + {t( + `ui.expense.${friend.id === userQuery.data?.id ? 'you' : 'user'}.${0 > amount ? 'get' : 'pay'}`, + { ns: 'common' }, + )}{' '} + + + {toUIString(amount)} {currency} + + + {' '} + {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { + ns: 'common', + })}{' '} + + + {displayName(user, userQuery.data?.id, 'accusativus')} + +
+
+
+ + + + - {toUIString(amount)} {currency} - - - {' '} - {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { - ns: 'common', - })}{' '} - - - {displayName(user, userQuery.data?.id, 'accusativus')} - + +
-
- - - - - - -
-
- ))} + ); + })} ); })} diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index b5609e3d..67529cf4 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -4,18 +4,19 @@ import { type User as NextUser } from 'next-auth'; import { toUIString } from '~/utils/numbers'; import type { inferRouterOutputs } from '@trpc/server'; +import { PencilIcon } from 'lucide-react'; +import React, { type ComponentProps, useCallback } from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { isCurrencyCode } from '~/lib/currency'; import type { ExpenseRouter } from '~/server/api/routers/expense'; +import { useAddExpenseStore } from '~/store/addStore'; +import { api } from '~/utils/api'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { EntityAvatar } from '../ui/avatar'; +import { Button } from '../ui/button'; import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; -import React, { useCallback } from 'react'; -import { CurrencyConversion } from '../Friend/CurrencyConversion'; -import { Button } from '../ui/button'; -import { PencilIcon } from 'lucide-react'; -import { useAddExpenseStore } from '~/store/addStore'; -import { isCurrencyCode } from '~/lib/currency'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -142,6 +143,9 @@ export const EditCurrencyConversion: React.FC<{ expense: ExpenseDetailsOutput }> }) => { const { setCurrency } = useAddExpenseStore((s) => s.actions); + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const apiUtils = api.useUtils(); + const onClick = useCallback(() => { if (expense.conversionTo && isCurrencyCode(expense.conversionTo.currency)) { setCurrency(expense.conversionTo.currency); @@ -151,18 +155,36 @@ export const EditCurrencyConversion: React.FC<{ expense: ExpenseDetailsOutput }> const sender = expense.paidByUser; const receiver = expense.expenseParticipants.find((p) => p.userId !== expense.paidBy)?.user; - if (!sender || !receiver) { + if (!sender || !receiver || !isCurrencyCode(expense.currency)) { return null; } + const onSubmit: ComponentProps['onSubmit'] = useCallback( + async (data) => { + await addOrEditCurrencyConversionMutation.mutateAsync({ + ...data, + senderId: sender.id, + receiverId: receiver.id, + groupId: expense.groupId, + expenseId: expense.id, + }); + await apiUtils.invalidate(); + }, + [ + addOrEditCurrencyConversionMutation, + sender.id, + receiver.id, + expense.groupId, + expense.id, + apiUtils, + ], + ); + return ( + + ); + }, [amount, currency, onConvertAmount]); + return (
@@ -211,6 +247,7 @@ export const AddOrEditExpensePage: React.FC<{ inputMode="decimal" value={amtStr} onChange={onAmountChange} + rightIcon={currencyConversionComponent} />
diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 14472dac..1a274fe1 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -16,6 +16,7 @@ import { Input } from '../ui/input'; export const CurrencyConversion: React.FC<{ amount: bigint; editingRate?: number; + editingTargetCurrency?: CurrencyCode; currency: string; children: ReactNode; onSubmit: (data: { @@ -24,7 +25,7 @@ export const CurrencyConversion: React.FC<{ amount: bigint; rate: number; }) => Promise | void; -}> = ({ amount, editingRate, currency, children, onSubmit }) => { +}> = ({ amount, editingRate, editingTargetCurrency, currency, children, onSubmit }) => { const { t } = useTranslationWithUtils(); const [amountStr, setAmountStr] = useState(''); @@ -49,7 +50,10 @@ export const CurrencyConversion: React.FC<{ useEffect(() => { setAmountStr((Number(BigMath.abs(amount)) / 100).toString()); setRate(editingRate ? editingRate.toFixed(4) : ''); - }, [amount, editingRate]); + if (editingTargetCurrency) { + setTargetCurrency(editingTargetCurrency); + } + }, [amount, editingRate, editingTargetCurrency]); useEffect(() => { if (getCurrencyRate.data?.rate) { @@ -110,10 +114,15 @@ export const CurrencyConversion: React.FC<{ const onSave = useCallback(async () => { try { + if (!isCurrencyCode(currency)) { + toast.error(t('ui.currency_conversion.select_target_currency_toast')); + return; + } + await onSubmit({ amount: toSafeBigInt(amountStr), rate: Number(rate), - from: currency as CurrencyCode, + from: currency, to: targetCurrency, }); toast.success(t('ui.currency_conversion.success_toast')); @@ -268,13 +277,19 @@ export const CurrencyConversion: React.FC<{ To
- + {editingTargetCurrency ? ( + + ) : ( + + )}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 07f4ada0..193eef89 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -4,21 +4,28 @@ import { cn } from '~/lib/utils'; export type InputProps = React.InputHTMLAttributes; -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( +function Input({ + className, + type, + rightIcon, + ...props +}: React.ComponentProps<'input'> & { rightIcon?: React.ReactNode }) { + return ( +
- ); - }, -); -Input.displayName = 'Input'; + {rightIcon && {rightIcon}} +
+ ); +} export { Input }; diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index a8fc207c..d6f26374 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -7,7 +7,7 @@ import { FILE_SIZE_LIMIT } from '~/lib/constants'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { getDocumentUploadUrl } from '~/server/storage'; -import { BigMath } from '~/utils/numbers'; +import { BigMath, currencyConversion } from '~/utils/numbers'; import { createCurrencyConversionSchema, @@ -118,7 +118,7 @@ export const expenseRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; - const amountTo = BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); + const amountTo = currencyConversion(amount, rate); const name = `${from} → ${to} @ ${rate}`; const expenseFrom = await (expenseId ? editExpense : createExpense)( diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 6d9812b6..da0d3e7a 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -50,6 +50,10 @@ export function removeTrailingZeros(num: string) { return num; } +export function currencyConversion(amount: bigint, rate: number) { + return BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); +} + export const BigMath = { abs(x: bigint) { return 0n > x ? -x : x; From aa2a4f5e19b73913d1379c44754e6d5651c6b906 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 21:00:33 +0200 Subject: [PATCH 31/44] Responsive currency conversion styling --- src/components/Friend/CurrencyConversion.tsx | 188 ++++++++----------- 1 file changed, 76 insertions(+), 112 deletions(-) diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 1a274fe1..624c5501 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -12,6 +12,7 @@ import { DateSelector } from '../AddExpense/DateSelector'; import { Button } from '../ui/button'; import { AppDrawer } from '../ui/drawer'; import { Input } from '../ui/input'; +import { Label } from '../ui/label'; export const CurrencyConversion: React.FC<{ amount: bigint; @@ -151,9 +152,8 @@ export const CurrencyConversion: React.FC<{ targetCurrency === currency } > -
+
-

{t('ui.currency_conversion.description')}

{targetCurrency === currency && (
Currency conversion only works for different currencies @@ -162,123 +162,31 @@ export const CurrencyConversion: React.FC<{
-
+
{/* From amount */} -
- -
- - - {currency} - -
-
- - {/* Rate */} -
- -
- - {getCurrencyRate.isPending && ( - - Fetching rate… - - )} - {!!rate && ( - <> - - 1 {currency} = {Number(rate).toFixed(4)} {targetCurrency} - - - 1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency} - - - )} -
-
- - {/* To amount */} -
- -
- - - {targetCurrency} - -
-
- - {/* From currency (read-only) */} -
- -
-
+
- {/* Date */} -
- -
- -
-
- - {/* To currency */} -
- -
+
+
+ {editingTargetCurrency ? ( - ) : ( @@ -291,6 +199,62 @@ export const CurrencyConversion: React.FC<{ /> )}
+ +
+ + {/* Rate */} +
+
+ +
+ + {getCurrencyRate.isPending && ( + + Fetching rate… + + )} + {!!rate && ( + <> + + 1 {currency} = {Number(rate).toFixed(4)} {targetCurrency} + + + 1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency} + + + )} +
+
+
+ +
+ +
+
From 0bad60a59b1439da7ae10aa090aa81a8036d6894 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 21:02:35 +0200 Subject: [PATCH 32/44] Fix some spotted regressions --- src/components/AddExpense/CategoryPicker.tsx | 2 +- src/components/AddExpense/DateSelector.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AddExpense/CategoryPicker.tsx b/src/components/AddExpense/CategoryPicker.tsx index 6d263876..92b28693 100644 --- a/src/components/AddExpense/CategoryPicker.tsx +++ b/src/components/AddExpense/CategoryPicker.tsx @@ -14,7 +14,7 @@ export const CategoryPicker: React.FC<{ const trigger = useMemo( () => ( -
+
), diff --git a/src/components/AddExpense/DateSelector.tsx b/src/components/AddExpense/DateSelector.tsx index e7aae677..c3ebf096 100644 --- a/src/components/AddExpense/DateSelector.tsx +++ b/src/components/AddExpense/DateSelector.tsx @@ -7,7 +7,7 @@ import { Calendar } from '../ui/calendar'; import type { DayPickerProps, PropsSingleRequired } from 'react-day-picker'; export const DateSelector: React.FC = (calendarProps) => { - const { t, toUIDate } = useTranslationWithUtils(); + const { t, toUIDate } = useTranslationWithUtils(['expense_details']); return (
From d12dce3d8d81fbfab54ae5ed5890e7aac011e9b6 Mon Sep 17 00:00:00 2001 From: krokosik Date: Tue, 16 Sep 2025 21:07:24 +0200 Subject: [PATCH 33/44] Add English locale --- public/locales/en/common.json | 6 ++++- src/components/Friend/CurrencyConversion.tsx | 25 +++++++------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 28c4085f..acc763b1 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -36,6 +36,7 @@ "invite": "Invite", "export": "Export", "import": "Import", + "fetch": "Fetch", "add_expense": "Add expense", "edit_expense": "Edit expense", "settle_up": "Settle up" @@ -89,6 +90,8 @@ "description": "Convert the amount to a different currency.", "amount_to_receive": "Amount to receive", "exchange_rate": "Exchange rate", + "fetching_rate": "Fetching rate...", + "rate": "Rate", "success_toast": "Currency conversion set successfully", "error_toast": "Error while setting currency conversion" } @@ -97,6 +100,7 @@ "name_required": "Name is required", "saving_expense": "Error while saving expense", "something_went_wrong": "Something went wrong", - "valid_email": "Enter valid email" + "valid_email": "Enter valid email", + "invalid_currency_code": "Invalid currency code {{code}}" } } diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index 624c5501..5a4b4efc 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -116,7 +116,7 @@ export const CurrencyConversion: React.FC<{ const onSave = useCallback(async () => { try { if (!isCurrencyCode(currency)) { - toast.error(t('ui.currency_conversion.select_target_currency_toast')); + toast.error(t('errors.invalid_currency_code', { code: currency })); return; } @@ -148,25 +148,16 @@ export const CurrencyConversion: React.FC<{ !rate || Number(rate) <= 0 || Number(amountStr) <= 0 || - Number(targetAmountStr) <= 0 || - targetCurrency === currency + Number(targetAmountStr) <= 0 } >
-
- {targetCurrency === currency && ( -
- Currency conversion only works for different currencies -
- )} -
-
{/* From amount */}
- + @@ -184,7 +175,7 @@ export const CurrencyConversion: React.FC<{
- + {editingTargetCurrency ? (