From 5671b4a14bbb17c886cda82dbb93f89e232fe51f Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 17 Jun 2026 10:15:44 -0300 Subject: [PATCH] feat(wallets): add isDefault field to wallet model and implement setDefaultWallet functionality --- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + .../src/modules/expenses/expenses.service.ts | 141 ++++++++++++++---- .../wallets/handlers/setDefaultWallet.ts | 8 + .../src/modules/wallets/wallets.routes.ts | 2 + .../src/modules/wallets/wallets.service.ts | 32 +++- .../expenses/components/NewExpenseDialog.tsx | 120 ++++++++++++++- .../expenses/components/PayExpenseDialog.tsx | 122 ++++++++++++++- .../wallets/components/WalletCard.tsx | 52 +++++-- apps/frontend/src/lib/api.ts | 9 ++ apps/frontend/src/lib/queries.ts | 13 ++ 11 files changed, 459 insertions(+), 43 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260617131039_add_wallet_is_default/migration.sql create mode 100644 apps/backend/src/modules/wallets/handlers/setDefaultWallet.ts diff --git a/apps/backend/prisma/migrations/20260617131039_add_wallet_is_default/migration.sql b/apps/backend/prisma/migrations/20260617131039_add_wallet_is_default/migration.sql new file mode 100644 index 0000000..4be5a4e --- /dev/null +++ b/apps/backend/prisma/migrations/20260617131039_add_wallet_is_default/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 78feb51..7ed6423 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -134,6 +134,7 @@ model Wallet { name String @db.VarChar(100) currency String @db.VarChar(10) balance Decimal @db.Decimal(14, 4) + isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 37db528..3175182 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { PaymentStatus, QuoteType, Prisma } from "../../generated/prisma/client"; +import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client"; import { cache } from "../../lib/cache"; import { prisma } from "../../lib/prisma"; import { roundTo } from "../../lib/utils"; @@ -17,11 +17,15 @@ export const createNonPeriodicExpenseSchema = z.object({ description: z.string().min(1).max(50), amount: z.number().positive(), dueDate: z.string().datetime(), + walletId: z.number().int().positive(), + usdcConversionRate: z.number().positive().optional(), }); export const payExpenseSchema = z.object({ amountPayed: z.number().positive(), paymentDate: z.string().datetime().optional(), + walletId: z.number().int().positive(), + usdcConversionRate: z.number().positive().optional(), }); export const updateExpenseSchema = z.object({ @@ -226,23 +230,64 @@ export async function createNonPeriodicExpense( const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; - const belo = await getBeloSellPriceForDate(dueDate); - - const result = await prisma.expense.create({ - data: { - description: data.description, - amount: data.amount, - amountPayed: data.amount, - dueDate, - paymentDate: dueDate, - year, - month, - status: PaymentStatus.PAYED, - ...(belo !== null - ? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) } - : {}), - }, + const wallet = await prisma.wallet.findUnique({ + where: { id: data.walletId }, }); + if (!wallet) { + throw new Error("Billetera no encontrada"); + } + + const isUSDC = wallet.currency === "USDC"; + + if (isUSDC && !data.usdcConversionRate) { + throw new Error("La tasa de conversión es obligatoria para billeteras USDC"); + } + + const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate); + + const movementAmount = + isUSDC && data.usdcConversionRate + ? -roundTo(data.amount / data.usdcConversionRate, 4) + : -data.amount; + + const expenseData: Prisma.ExpenseUncheckedCreateInput = { + description: data.description, + amount: data.amount, + amountPayed: data.amount, + dueDate, + paymentDate: dueDate, + year, + month, + status: PaymentStatus.PAYED, + }; + + if (isUSDC && data.usdcConversionRate) { + expenseData.beloPrice = data.usdcConversionRate; + expenseData.usdcEquivalent = roundTo( + data.amount / data.usdcConversionRate, + 6, + ); + } else if (belo !== null) { + expenseData.beloPrice = belo; + expenseData.usdcEquivalent = roundTo(data.amount / belo, 6); + } + + const [result] = await prisma.$transaction([ + prisma.expense.create({ data: expenseData }), + prisma.wallet.update({ + where: { id: data.walletId }, + data: { balance: { increment: movementAmount } }, + }), + prisma.walletMovement.create({ + data: { + walletId: data.walletId, + type: "WITHDRAWAL", + amount: movementAmount, + description: `Pago: ${data.description}`, + }, + }), + ]); + cache.invalidateByPrefix("expenses:"); return result; } @@ -251,23 +296,61 @@ export async function payExpense( id: number, data: z.infer, ) { + const expense = await prisma.expense.findUniqueOrThrow({ where: { id } }); + const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); - const belo = await getBeloSellPriceForDate(paymentDate); - - const result = await prisma.expense.update({ - where: { id }, - data: { - status: PaymentStatus.PAYED, - amountPayed: data.amountPayed, - paymentDate, - ...(belo !== null - ? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) } - : {}), - }, + const wallet = await prisma.wallet.findUnique({ + where: { id: data.walletId }, }); + if (!wallet) { + throw new Error("Billetera no encontrada"); + } + + const isUSDC = wallet.currency === "USDC"; + + if (isUSDC && !data.usdcConversionRate) { + throw new Error("La tasa de conversión es obligatoria para billeteras USDC"); + } + + const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate); + + const movementAmount = isUSDC && data.usdcConversionRate + ? -roundTo(data.amountPayed / data.usdcConversionRate, 4) + : -data.amountPayed; + + const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = { + status: PaymentStatus.PAYED, + amountPayed: data.amountPayed, + paymentDate, + }; + + if (isUSDC && data.usdcConversionRate) { + expenseUpdateData.beloPrice = data.usdcConversionRate; + expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6); + } else if (belo !== null) { + expenseUpdateData.beloPrice = belo; + expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6); + } + + const [result] = await prisma.$transaction([ + prisma.expense.update({ where: { id }, data: expenseUpdateData }), + prisma.wallet.update({ + where: { id: data.walletId }, + data: { balance: { increment: movementAmount } }, + }), + prisma.walletMovement.create({ + data: { + walletId: data.walletId, + type: "WITHDRAWAL", + amount: movementAmount, + description: `Pago: ${expense.description}`, + }, + }), + ]); + cache.invalidateByPrefix("expenses:"); return result; } diff --git a/apps/backend/src/modules/wallets/handlers/setDefaultWallet.ts b/apps/backend/src/modules/wallets/handlers/setDefaultWallet.ts new file mode 100644 index 0000000..3d6c735 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/setDefaultWallet.ts @@ -0,0 +1,8 @@ +import type { Context } from "hono"; +import { setDefaultWallet } from "../wallets.service"; + +export async function setDefaultWalletHandler(c: Context) { + const id = Number(c.req.param("id")); + const result = await setDefaultWallet(id); + return c.json(result); +} diff --git a/apps/backend/src/modules/wallets/wallets.routes.ts b/apps/backend/src/modules/wallets/wallets.routes.ts index 0915689..98b72b2 100644 --- a/apps/backend/src/modules/wallets/wallets.routes.ts +++ b/apps/backend/src/modules/wallets/wallets.routes.ts @@ -7,6 +7,7 @@ import { listMovementsHandler } from "./handlers/listMovements"; import { listWalletsHandler } from "./handlers/listWallets"; import { transferWalletHandler } from "./handlers/transferWallet"; import { updateWalletHandler } from "./handlers/updateWallet"; +import { setDefaultWalletHandler } from "./handlers/setDefaultWallet"; const app = new Hono(); @@ -17,6 +18,7 @@ app.delete("/:id", deleteWalletHandler); app.post("/:id/deposit", depositWalletHandler); app.post("/:id/adjust", adjustBalanceHandler); app.post("/transfer", transferWalletHandler); +app.put("/:id/default", setDefaultWalletHandler); app.get("/:id/movements", listMovementsHandler); export default app; diff --git a/apps/backend/src/modules/wallets/wallets.service.ts b/apps/backend/src/modules/wallets/wallets.service.ts index 26ef894..8fc03d4 100644 --- a/apps/backend/src/modules/wallets/wallets.service.ts +++ b/apps/backend/src/modules/wallets/wallets.service.ts @@ -6,6 +6,7 @@ export const createWalletSchema = z.object({ name: z.string().min(1).max(100), currency: z.string().min(1).max(10), initialBalance: z.number().optional().default(0), + isDefault: z.boolean().optional().default(false), }); export const updateWalletSchema = z.object({ @@ -13,6 +14,10 @@ export const updateWalletSchema = z.object({ currency: z.string().min(1).max(10), }); +export const setDefaultWalletSchema = z.object({ + walletId: z.number().int().positive(), +}); + export const depositSchema = z.object({ amount: z.number().positive(), description: z.string().max(255).optional(), @@ -36,9 +41,12 @@ export type UpdateWalletInput = z.infer; export type DepositInput = z.infer; export type AdjustBalanceInput = z.infer; export type TransferInput = z.infer; +export type SetDefaultWalletInput = z.infer; export async function listWallets() { - const wallets = await prisma.wallet.findMany({ orderBy: { createdAt: "desc" } }); + const wallets = await prisma.wallet.findMany({ + orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }], + }); return wallets.map((w) => ({ ...w, balance: Number(w.balance) })); } @@ -48,8 +56,15 @@ export async function createWallet(input: CreateWalletInput) { name: input.name, currency: input.currency, balance: input.initialBalance ?? 0, + isDefault: input.isDefault ?? false, }, }); + if (wallet.isDefault) { + await prisma.wallet.updateMany({ + where: { id: { not: wallet.id }, isDefault: true }, + data: { isDefault: false }, + }); + } return { ...wallet, balance: Number(wallet.balance) }; } @@ -155,6 +170,21 @@ export async function transferWallet(input: TransferInput) { }; } +export async function setDefaultWallet(walletId: number) { + await prisma.$transaction([ + prisma.wallet.updateMany({ + where: { isDefault: true }, + data: { isDefault: false }, + }), + prisma.wallet.update({ + where: { id: walletId }, + data: { isDefault: true }, + }), + ]); + const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id: walletId } }); + return { ...wallet, balance: Number(wallet.balance) }; +} + export async function listMovements(walletId: number) { const movements = await prisma.walletMovement.findMany({ where: { walletId }, diff --git a/apps/frontend/src/features/expenses/components/NewExpenseDialog.tsx b/apps/frontend/src/features/expenses/components/NewExpenseDialog.tsx index fae87c8..fdec47d 100644 --- a/apps/frontend/src/features/expenses/components/NewExpenseDialog.tsx +++ b/apps/frontend/src/features/expenses/components/NewExpenseDialog.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Controller, useForm } from "react-hook-form"; +import { useEffect, useMemo, useRef } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { DatePicker } from "@/components/ui/date-picker"; @@ -10,7 +11,15 @@ import { ResponsiveDialogHeader, ResponsiveDialogTitle, } from "@/components/ui/responsive-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useExpensesContext } from "../ExpensesProvider"; +import { useQuotes, useWallets } from "@/lib/queries"; const newExpenseSchema = z.object({ description: z @@ -19,6 +28,11 @@ const newExpenseSchema = z.object({ .max(50, "Máximo 50 caracteres"), amount: z.number().positive("El monto debe ser mayor a 0"), dueDate: z.date({ message: "La fecha es obligatoria" }), + walletId: z.number().positive("Seleccioná una billetera"), + usdcConversionRate: z + .number() + .positive("La cotización debe ser mayor a 0") + .optional(), }); type NewExpenseFormValues = z.infer; @@ -33,6 +47,19 @@ export function NewExpenseDialog({ onOpenChange, }: NewExpenseDialogProps) { const { createNonPeriodicExpense } = useExpensesContext(); + const prevWalletRef = useRef(0); + const { data: wallets } = useWallets(); + const { data: quotes } = useQuotes(); + const defaultWallet = useMemo( + () => wallets?.find((w) => w.isDefault), + [wallets], + ); + + const beloSell = useMemo(() => { + if (!quotes) return null; + const belo = quotes.find((q) => q.type === "BELO"); + return belo ? Number(belo.sell) : null; + }, [quotes]); const { register, @@ -40,15 +67,48 @@ export function NewExpenseDialog({ control, formState: { errors, isSubmitting }, reset, + setValue, + trigger, } = useForm({ resolver: zodResolver(newExpenseSchema), defaultValues: { description: "", amount: 0, dueDate: new Date(), + walletId: defaultWallet?.id ?? 0, + usdcConversionRate: undefined, }, }); + useEffect(() => { + if (defaultWallet && !open) return; + if (defaultWallet) { + setValue("walletId", defaultWallet.id); + prevWalletRef.current = defaultWallet.id; + } + }, [defaultWallet, setValue, open]); + + const usdcConversionRateField = register("usdcConversionRate", { + setValueAs: (value) => (value === "" ? undefined : Number(value)), + }); + + const selectedWalletId = useWatch({ control, name: "walletId" }); + const selectedWallet = wallets?.find((w) => w.id === selectedWalletId); + const isUSDC = selectedWallet?.currency === "USDC"; + + useEffect(() => { + if ( + isUSDC && + beloSell && + selectedWalletId && + selectedWalletId !== prevWalletRef.current + ) { + setValue("usdcConversionRate", beloSell); + trigger("usdcConversionRate"); + prevWalletRef.current = selectedWalletId; + } + }, [isUSDC, beloSell, selectedWalletId, setValue, trigger]); + function handleClose(open: boolean) { onOpenChange(open); if (!open) reset(); @@ -59,6 +119,8 @@ export function NewExpenseDialog({ description: data.description, amount: data.amount, dueDate: data.dueDate.toISOString(), + walletId: data.walletId, + usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined, }); handleClose(false); } @@ -112,6 +174,62 @@ export function NewExpenseDialog({ )} +
+ + ( + + )} + /> + {errors.walletId && ( + + {errors.walletId.message} + + )} +
+ + {isUSDC && ( +
+ + + {errors.usdcConversionRate && ( + + {errors.usdcConversionRate.message} + + )} + + Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO) + +
+ )} +
Fecha del gasto v > 0, "Seleccioná una billetera"), + usdcConversionRate: z + .number() + .positive("La cotización debe ser mayor a 0") + .optional(), }); type PayFormValues = z.infer; @@ -34,6 +50,19 @@ export function PayExpenseDialog({ }: PayExpenseDialogProps) { const { payExpense } = useExpensesContext(); const amountInputRef = useRef(null); + const prevWalletRef = useRef(0); + const { data: wallets } = useWallets(); + const { data: quotes } = useQuotes(); + const defaultWallet = useMemo( + () => wallets?.find((w) => w.isDefault), + [wallets], + ); + + const beloSell = useMemo(() => { + if (!quotes) return null; + const belo = quotes.find((q) => q.type === "BELO"); + return belo ? Number(belo.sell) : null; + }, [quotes]); const { register, @@ -41,25 +70,54 @@ export function PayExpenseDialog({ control, formState: { errors, isSubmitting }, reset, + setValue, + trigger, } = useForm({ resolver: zodResolver(paySchema), defaultValues: { amountPayed: 0, paymentDate: undefined, + walletId: 0, + usdcConversionRate: undefined, }, }); const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", { setValueAs: (value) => Number(value), }); + const usdcConversionRateField = register("usdcConversionRate", { + setValueAs: (value) => (value === "" ? undefined : Number(value)), + }); + + const selectedWalletId = useWatch({ control, name: "walletId" }); + const selectedWallet = wallets?.find((w) => w.id === selectedWalletId); + const isUSDC = selectedWallet?.currency === "USDC"; + useEffect(() => { if (expense) { + const defaultId = defaultWallet?.id ?? 0; reset({ amountPayed: Number(expense.amount), paymentDate: new Date(), + walletId: defaultId, + usdcConversionRate: undefined, }); + prevWalletRef.current = defaultId; } - }, [expense, reset]); + }, [expense, defaultWallet, reset]); + + useEffect(() => { + if ( + isUSDC && + beloSell && + selectedWalletId && + selectedWalletId !== prevWalletRef.current + ) { + setValue("usdcConversionRate", beloSell); + trigger("usdcConversionRate"); + prevWalletRef.current = selectedWalletId; + } + }, [isUSDC, beloSell, selectedWalletId, setValue, trigger]); useEffect(() => { if (!open || !expense) return; @@ -82,6 +140,8 @@ export function PayExpenseDialog({ await payExpense(expense.id, { amountPayed: data.amountPayed, paymentDate: data.paymentDate.toISOString(), + walletId: data.walletId, + usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined, }); handleClose(false); } @@ -133,6 +193,62 @@ export function PayExpenseDialog({ )}
+
+ + ( + + )} + /> + {errors.walletId && ( + + {errors.walletId.message} + + )} +
+ + {isUSDC && ( +
+ + + {errors.usdcConversionRate && ( + + {errors.usdcConversionRate.message} + + )} + + Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO) + +
+ )} +
Fecha de pago
-
+

{wallet.name}

-

{wallet.currency}

+ {wallet.isDefault && ( + + Default + + )} +
+
+ + + + + + + {wallet.isDefault + ? "Billetera predeterminada" + : "Marcar como predeterminada"} + + + +
-

diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index b2ef702..8ac1279 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -149,11 +149,15 @@ export type CreateNonPeriodicExpenseInput = { description: string; amount: number; dueDate: string; + walletId: number; + usdcConversionRate?: number; }; export type PayExpenseInput = { amountPayed: number; paymentDate?: string; + walletId: number; + usdcConversionRate?: number; }; export type UpdateExpenseInput = { @@ -327,6 +331,7 @@ export type Wallet = { name: string; currency: string; balance: number; + isDefault: boolean; createdAt: string; updatedAt: string; }; @@ -409,6 +414,10 @@ export function transferWallet( ); } +export function setDefaultWallet(id: number): Promise { + return mutator(`/api/wallets/${id}/default`, "PUT"); +} + export function getWalletMovements( id: number, ): Promise { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 0a6ba25..3da0e1f 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -33,6 +33,7 @@ import { getTotalPending, getWalletMovements, getWallets, + setDefaultWallet as setDefaultWalletApi, importExpenses, importPeriodicExpenses, type PayExpenseInput, @@ -261,6 +262,7 @@ export function useCreateNonPeriodicExpense() { createNonPeriodicExpenseApi(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + queryClient.invalidateQueries({ queryKey: walletKeys.all }); }, }); } @@ -292,6 +294,7 @@ export function usePayExpense() { payExpenseApi(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + queryClient.invalidateQueries({ queryKey: walletKeys.all }); }, }); } @@ -358,6 +361,16 @@ export function useDeleteWallet() { }); } +export function useSetDefaultWallet() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => setDefaultWalletApi(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: walletKeys.all }); + }, + }); +} + export function useDepositWallet() { const queryClient = useQueryClient(); return useMutation({