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/migrations/20260617132653_add_wallet_id_to_expense/migration.sql b/apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql new file mode 100644 index 0000000..d2d6190 --- /dev/null +++ b/apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "expenses" ADD COLUMN "walletId" INTEGER; + +-- CreateIndex +CREATE INDEX "expenses_walletId_idx" ON "expenses"("walletId"); + +-- AddForeignKey +ALTER TABLE "expenses" ADD CONSTRAINT "expenses_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 78feb51..0096a3b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -134,10 +134,12 @@ 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 movements WalletMovement[] + expenses Expense[] @@map("wallets") } @@ -186,6 +188,9 @@ model Expense { status PaymentStatus @default(PENDING) dueDate DateTime paymentDate DateTime? + walletId Int? + wallet Wallet? @relation(fields: [walletId], references: [id]) + @@index([walletId]) @@map("expenses") } \ No newline at end of file diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 37db528..f8667cc 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,65 @@ 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, + walletId: data.walletId, + }; + + 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 +297,62 @@ 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, + walletId: data.walletId, + }; + + 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; } @@ -289,12 +374,29 @@ export async function updateExpense( month, }; + let walletAdjustment: { + walletId: number; + amount: number; + description: string; + } | null = null; + if (data.status === "PENDING") { updateData.status = PaymentStatus.PENDING; updateData.amountPayed = null; updateData.paymentDate = null; updateData.beloPrice = null; updateData.usdcEquivalent = null; + + if (existing.status === PaymentStatus.PAYED && existing.walletId) { + const refundAmount = existing.amountPayed + ? Number(existing.amountPayed) + : Number(existing.amount); + walletAdjustment = { + walletId: existing.walletId, + amount: refundAmount, + description: "Anulación de gasto", + }; + } } else if (existing.status === PaymentStatus.PAYED) { if (data.amountPayed !== undefined) { updateData.amountPayed = data.amountPayed; @@ -317,6 +419,51 @@ export async function updateExpense( updateData.usdcEquivalent = null; } } + + if (existing.walletId) { + const oldPayed = existing.amountPayed + ? Number(existing.amountPayed) + : Number(existing.amount); + const newPayed = data.amountPayed !== undefined + ? Number(data.amountPayed) + : Number(data.amount); + + if (newPayed !== oldPayed) { + const difference = newPayed - oldPayed; + walletAdjustment = { + walletId: existing.walletId, + amount: -difference, + description: "Ajuste por edición de gasto", + }; + } + } + } + + if (walletAdjustment) { + const wallet = await prisma.wallet.findUnique({ + where: { id: walletAdjustment.walletId }, + }); + if (!wallet) { + throw new Error("Billetera no encontrada"); + } + + const [result] = await prisma.$transaction([ + prisma.expense.update({ where: { id }, data: updateData }), + prisma.wallet.update({ + where: { id: walletAdjustment.walletId }, + data: { balance: { increment: walletAdjustment.amount } }, + }), + prisma.walletMovement.create({ + data: { + walletId: walletAdjustment.walletId, + type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL", + amount: walletAdjustment.amount, + description: walletAdjustment.description, + }, + }), + ]); + cache.invalidateByPrefix("expenses:"); + return result; } const result = await prisma.expense.update({ diff --git a/apps/backend/src/modules/wallets/handlers/listAllMovements.ts b/apps/backend/src/modules/wallets/handlers/listAllMovements.ts new file mode 100644 index 0000000..05802d0 --- /dev/null +++ b/apps/backend/src/modules/wallets/handlers/listAllMovements.ts @@ -0,0 +1,21 @@ +import type { Context } from "hono"; +import { listAllMovements } from "../wallets.service"; + +export async function listAllMovementsHandler(c: Context) { + const walletId = c.req.query("walletId") + ? Number(c.req.query("walletId")) + : undefined; + const startDate = c.req.query("startDate") || undefined; + const endDate = c.req.query("endDate") || undefined; + const page = parseInt(c.req.query("page") ?? "1", 10); + const pageSize = parseInt(c.req.query("pageSize") ?? "20", 10); + + const result = await listAllMovements({ + walletId, + startDate, + endDate, + page, + pageSize, + }); + return c.json(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..7acc856 100644 --- a/apps/backend/src/modules/wallets/wallets.routes.ts +++ b/apps/backend/src/modules/wallets/wallets.routes.ts @@ -3,13 +3,16 @@ import { adjustBalanceHandler } from "./handlers/adjustBalance"; import { createWalletHandler } from "./handlers/createWallet"; import { deleteWalletHandler } from "./handlers/deleteWallet"; import { depositWalletHandler } from "./handlers/depositWallet"; +import { listAllMovementsHandler } from "./handlers/listAllMovements"; 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(); +app.get("/movements", listAllMovementsHandler); app.get("/", listWalletsHandler); app.post("/", createWalletHandler); app.put("/:id", updateWalletHandler); @@ -17,6 +20,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..48bc6b4 100644 --- a/apps/backend/src/modules/wallets/wallets.service.ts +++ b/apps/backend/src/modules/wallets/wallets.service.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { Prisma } from "../../generated/prisma/client"; import { prisma } from "../../lib/prisma"; +import { cache } from "../../lib/cache"; 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 +15,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 +42,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 +57,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 +171,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 }, @@ -162,3 +193,51 @@ export async function listMovements(walletId: number) { }); return movements.map((m) => ({ ...m, amount: Number(m.amount) })); } + +export async function listAllMovements(params: { + walletId?: number; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +}) { + const { walletId, startDate, endDate, page = 1, pageSize = 20 } = params; + + const cacheKey = `movements:all:${walletId ?? "all"}:${startDate ?? ""}:${endDate ?? ""}:${page}:${pageSize}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + + const where: Record = {}; + if (walletId !== undefined) { + where.walletId = walletId; + } + if (startDate || endDate) { + const createdAt: Record = {}; + if (startDate) createdAt.gte = new Date(startDate); + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + createdAt.lte = end; + } + where.createdAt = createdAt; + } + + const [data, total] = await Promise.all([ + prisma.walletMovement.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.walletMovement.count({ where }), + ]); + + const result = { + data: data.map((m) => ({ ...m, amount: Number(m.amount) })), + total, + page, + pageSize, + }; + cache.set(cacheKey, result); + return result; +} diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index d64545f..de2c907 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { PaymentStatus } from "../src/generated/prisma/client"; +const mockWalletFindUnique = mock(); +const mockWalletUpdate = mock(); +const mockMovementCreate = mock(); +const mockTransaction = mock(async (operations: Array>) => + Promise.all(operations), +); + const mockPrisma = { + $transaction: mockTransaction, periodicExpense: { findMany: mock(), findUniqueOrThrow: mock(), @@ -16,6 +24,13 @@ const mockPrisma = { update: mock(), count: mock(), }, + wallet: { + findUnique: mockWalletFindUnique, + update: mockWalletUpdate, + }, + walletMovement: { + create: mockMovementCreate, + }, quote: { findFirst: mock(), }, @@ -30,6 +45,7 @@ mock.module("../src/lib/prisma", () => ({ beforeEach(() => { for (const model of Object.values(mockPrisma)) { + if (typeof model === "function") continue; for (const fn of Object.values( model as Record>, )) { @@ -155,6 +171,7 @@ describe("schemas", () => { description: "Ropa", amount: 2500, dueDate: "2026-06-15T00:00:00.000Z", + walletId: 1, }); expect(result.description).toBe("Ropa"); }); @@ -175,12 +192,16 @@ describe("schemas", () => { const result = payExpenseSchema.parse({ amountPayed: 1400, paymentDate: "2026-06-10T00:00:00.000Z", + walletId: 1, }); expect(result.amountPayed).toBe(1400); }); test("allows missing paymentDate", () => { - const result = payExpenseSchema.parse({ amountPayed: 1400 }); + const result = payExpenseSchema.parse({ + amountPayed: 1400, + walletId: 1, + }); expect(result.amountPayed).toBe(1400); expect(result.paymentDate).toBeUndefined(); }); @@ -407,6 +428,7 @@ describe("createNonPeriodicExpense", () => { description: "Ropa", amount: 2500, dueDate: "2026-06-15T00:00:00.000Z", + walletId: 1, }; const created = { id: 1, @@ -417,7 +439,11 @@ describe("createNonPeriodicExpense", () => { year: 2026, month: 6, }; + const wallet = { id: 1, balance: 10000 }; mockPrisma.expense.create.mockResolvedValueOnce(created); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 7500 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await createNonPeriodicExpense(input); expect(result.status).toBe(PaymentStatus.PAYED); @@ -436,23 +462,48 @@ describe("createNonPeriodicExpense", () => { describe("payExpense", () => { test("updates status to PAYED with amountPayed and paymentDate", async () => { + const expense = { + id: 1, + description: "Ropa", + status: PaymentStatus.PENDING, + amount: 1400, + amountPayed: null, + paymentDate: null, + }; const paidExpense = { id: 1, status: PaymentStatus.PAYED, amountPayed: 1400, paymentDate: new Date("2026-06-10T00:00:00.000Z"), }; + const wallet = { id: 1, balance: 10000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense); + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.expense.update.mockResolvedValueOnce(paidExpense); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 8600 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await payExpense(1, { amountPayed: 1400, paymentDate: "2026-06-10T00:00:00.000Z", + walletId: 1, }); expect(result.status).toBe(PaymentStatus.PAYED); expect(result.amountPayed).toBe(1400); }); test("defaults paymentDate to now when not provided", async () => { + const expense = { + id: 1, + description: "Ropa", + status: PaymentStatus.PENDING, + amount: 1500, + amountPayed: null, + paymentDate: null, + }; + const wallet = { id: 1, balance: 10000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense); + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.expense.update.mockImplementationOnce( async ({ where, data }) => ({ id: where.id, @@ -462,8 +513,10 @@ describe("payExpense", () => { paymentDate: data.paymentDate, }), ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 8500 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); - const result = await payExpense(1, { amountPayed: 1500 }); + const result = await payExpense(1, { amountPayed: 1500, walletId: 1 }); expect(result.status).toBe(PaymentStatus.PAYED); expect(result.paymentDate).toBeInstanceOf(Date); }); @@ -739,7 +792,18 @@ describe("getBeloSellPriceForDate", () => { describe("payExpense with BELO", () => { test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => { + const expense = { + id: 1, + description: "Ropa", + status: PaymentStatus.PENDING, + amount: 15000, + amountPayed: null, + paymentDate: null, + }; const paymentDate = new Date("2026-06-10T00:00:00.000Z"); + const wallet = { id: 1, balance: 100000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense); + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.quoteHistory.findFirst .mockResolvedValueOnce({ sell: "1500.00", @@ -757,10 +821,13 @@ describe("payExpense with BELO", () => { usdcEquivalent: data.usdcEquivalent, }), ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await payExpense(1, { amountPayed: 15000, paymentDate: "2026-06-10T00:00:00.000Z", + walletId: 1, }); expect(result.beloPrice).toBe(1500); @@ -768,6 +835,17 @@ describe("payExpense with BELO", () => { }); test("does not set BELO fields when no quote is available", async () => { + const expense = { + id: 1, + description: "Ropa", + status: PaymentStatus.PENDING, + amount: 15000, + amountPayed: null, + paymentDate: null, + }; + const wallet = { id: 1, balance: 100000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense); + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.quoteHistory.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce(null); @@ -780,10 +858,13 @@ describe("payExpense with BELO", () => { paymentDate: data.paymentDate, }), ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await payExpense(1, { amountPayed: 15000, paymentDate: "2026-06-10T00:00:00.000Z", + walletId: 1, }); expect(result.beloPrice).toBeUndefined(); @@ -794,6 +875,8 @@ describe("payExpense with BELO", () => { describe("createNonPeriodicExpense with BELO", () => { test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => { const dueDate = "2026-06-15T00:00:00.000Z"; + const wallet = { id: 1, balance: 100000, currency: "ARS" }; + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.quoteHistory.findFirst .mockResolvedValueOnce({ sell: "1500.00", @@ -812,11 +895,14 @@ describe("createNonPeriodicExpense with BELO", () => { usdcEquivalent: data.usdcEquivalent, }), ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 70000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await createNonPeriodicExpense({ description: "Ropa", amount: 30000, dueDate, + walletId: 1, }); expect(result.beloPrice).toBe(1500); @@ -824,6 +910,8 @@ describe("createNonPeriodicExpense with BELO", () => { }); test("does not set BELO fields when no quote is available", async () => { + const wallet = { id: 1, balance: 100000, currency: "ARS" }; + mockWalletFindUnique.mockResolvedValueOnce(wallet); mockPrisma.quoteHistory.findFirst .mockResolvedValueOnce(null) .mockResolvedValueOnce(null); @@ -837,11 +925,14 @@ describe("createNonPeriodicExpense with BELO", () => { status: PaymentStatus.PAYED, }), ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 97500 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); const result = await createNonPeriodicExpense({ description: "Ropa", amount: 2500, dueDate: "2026-06-15T00:00:00.000Z", + walletId: 1, }); expect(result.beloPrice).toBeUndefined(); @@ -938,3 +1029,402 @@ describe("updateExpense with BELO", () => { expect(mockPrisma.quoteHistory.findFirst).not.toHaveBeenCalled(); }); }); + +describe("updateExpense wallet adjustments", () => { + test("creates WITHDRAWAL when amount increases on a PAYED expense", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 100000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 90000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 35000, + dueDate: "2026-06-15T00:00:00.000Z", + }); + + expect(result.amount).toBe(35000); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: -10000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "WITHDRAWAL", + amount: -10000, + description: "Ajuste por edición de gasto", + }, + }); + }); + + test("creates DEPOSIT when amount decreases on a PAYED expense", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 35000, + amountPayed: 35000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 25000, + dueDate: "2026-06-15T00:00:00.000Z", + }); + + expect(result.amount).toBe(25000); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: 10000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "DEPOSIT", + amount: 10000, + description: "Ajuste por edición de gasto", + }, + }); + }); + + test("does not create wallet adjustment when amount is unchanged", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + + const result = await updateExpense(1, { + amount: 25000, + dueDate: "2026-06-15T00:00:00.000Z", + }); + + expect(result.amount).toBe(25000); + expect(mockWalletUpdate).not.toHaveBeenCalled(); + expect(mockMovementCreate).not.toHaveBeenCalled(); + }); + + test("does not create wallet adjustment when existing expense has no walletId", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + + const result = await updateExpense(1, { + amount: 35000, + dueDate: "2026-06-15T00:00:00.000Z", + }); + + expect(result.amount).toBe(35000); + expect(mockWalletUpdate).not.toHaveBeenCalled(); + expect(mockMovementCreate).not.toHaveBeenCalled(); + }); + + test("creates DEPOSIT refund when reverting PAYED expense to PENDING with walletId", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 25000, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", + }); + + expect(result.status).toBe(PaymentStatus.PENDING); + expect(result.amountPayed).toBeNull(); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: 25000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "DEPOSIT", + amount: 25000, + description: "Anulación de gasto", + }, + }); + }); + + test("does not create refund when existing expense has no walletId for PENDING revert", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + + const result = await updateExpense(1, { + amount: 25000, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", + }); + + expect(result.status).toBe(PaymentStatus.PENDING); + expect(mockWalletUpdate).not.toHaveBeenCalled(); + expect(mockMovementCreate).not.toHaveBeenCalled(); + }); + + test("throws when wallet is not found during adjustment", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(null); + + expect( + updateExpense(1, { + amount: 35000, + dueDate: "2026-06-15T00:00:00.000Z", + }), + ).rejects.toThrow("Billetera no encontrada"); + }); + + test("only creates refund (not amount adjustment) when both amount and status change", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 35000, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", + }); + + expect(result.status).toBe(PaymentStatus.PENDING); + expect(mockWalletUpdate).toHaveBeenCalledTimes(1); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: 25000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "DEPOSIT", + amount: 25000, + description: "Anulación de gasto", + }, + }); + }); + + test("uses amountPayed for refund when expense has different amountPayed and amount", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 30000, + amountPayed: 28000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 103000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 30000, + dueDate: "2026-06-15T00:00:00.000Z", + status: "PENDING", + }); + + expect(result.status).toBe(PaymentStatus.PENDING); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: 28000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "DEPOSIT", + amount: 28000, + description: "Anulación de gasto", + }, + }); + }); + + test("creates WITHDRAWAL when amountPayed increases while amount stays the same", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 65000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 25000, + dueDate: "2026-06-15T00:00:00.000Z", + amountPayed: 35000, + }); + + expect(result.amount).toBe(25000); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: -10000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "WITHDRAWAL", + amount: -10000, + description: "Ajuste por edición de gasto", + }, + }); + }); + + test("creates adjustment based on amountPayed when both amount and amountPayed change", async () => { + const existing = { + id: 1, + status: PaymentStatus.PAYED, + amount: 25000, + amountPayed: 25000, + paymentDate: new Date("2026-06-10T00:00:00.000Z"), + dueDate: new Date("2026-06-15T00:00:00.000Z"), + year: 2026, + month: 6, + walletId: 1, + }; + const wallet = { id: 1, balance: 75000, currency: "ARS" }; + mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing); + mockWalletFindUnique.mockResolvedValueOnce(wallet); + mockPrisma.expense.update.mockImplementationOnce( + async ({ where, data }) => ({ id: where.id, ...existing, ...data }), + ); + mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 72000 }); + mockMovementCreate.mockResolvedValueOnce({ id: 1 }); + + const result = await updateExpense(1, { + amount: 30000, + dueDate: "2026-06-15T00:00:00.000Z", + amountPayed: 28000, + }); + + expect(result.amount).toBe(30000); + expect(mockWalletUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { balance: { increment: -3000 } }, + }); + expect(mockMovementCreate).toHaveBeenCalledWith({ + data: { + walletId: 1, + type: "WITHDRAWAL", + amount: -3000, + description: "Ajuste por edición de gasto", + }, + }); + }); +}); 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 = { + DEPOSIT: "Depósito", + WITHDRAWAL: "Extracción", + TRANSFER_IN: "Transferencia recibida", + TRANSFER_OUT: "Transferencia enviada", + ADJUSTMENT: "Ajuste", +}; + +function TypeBadge({ type }: { type: string }) { + const styles: Record = { + DEPOSIT: + "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400", + WITHDRAWAL: + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + TRANSFER_IN: + "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", + TRANSFER_OUT: + "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", + ADJUSTMENT: + "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", + }; + + return ( + + {typeLabels[type] ?? type} + + ); +} + +export function WalletMovementsPage() { + const { walletId: initialWalletId } = useSearch({ + from: "/_authenticated/wallets/movements", + }); + const { data: wallets } = useWallets(); + + const [walletId, setWalletId] = useState(initialWalletId); + + const today = new Date(); + const [startDate, setStartDate] = useState( + startOfMonth(subMonths(today, 1)), + ); + const [endDate, setEndDate] = useState(today); + + const [page, setPage] = useState(1); + const pageSize = 20; + + const filter: WalletMovementsFilter = useMemo( + () => ({ + walletId, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + page, + pageSize, + }), + [walletId, startDate, endDate, page, pageSize], + ); + + const { data: result, isLoading } = useWalletMovementsFiltered(filter); + + const movements = result?.data ?? []; + const total = result?.total ?? 0; + + const columns = useMemo[]>( + () => [ + { + header: "Tipo", + accessorKey: "type", + cell: ({ row }) => , + }, + { + header: "Descripción", + accessorKey: "description", + cell: ({ row }) => { + const desc = row.getValue("description") as string | null; + return ( + + {desc ?? ( + + Sin descripción + + )} + + ); + }, + }, + { + header: "Billetera", + id: "walletName", + cell: ({ row }) => { + const w = wallets?.find( + (w) => w.id === row.original.walletId, + ); + return w ? ( + {w.name} + ) : null; + }, + }, + { + header: "Monto", + accessorKey: "amount", + cell: ({ row }) => { + const amount = row.getValue("amount") as number; + return ( + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400" + }`} + > + {amount >= 0 ? "+" : ""} + {amountFormatter.format(amount)} + + ); + }, + }, + { + header: "Fecha", + accessorKey: "createdAt", + cell: ({ row }) => { + return ( + + {dateFormatter.format( + new Date(row.getValue("createdAt") as string), + )} + + ); + }, + }, + ], + [wallets], + ); + + const table = useReactTable({ + data: movements, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const GROUP_SIZE = 3; + const currentGroup = Math.floor((page - 1) / GROUP_SIZE); + const startPage = currentGroup * GROUP_SIZE + 1; + const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages); + const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1; + const lastItem = Math.min(page * pageSize, total); + + const mobilePagination = + total > 0 ? ( +
+
+ {firstItem}-{lastItem} de {total} · Página {page} de {totalPages} +
+
+ + +
+
+ ) : null; + + return ( +
+

Movimientos

+ +
+
+ + +
+ +
+ + { + setStartDate(d); + setPage(1); + }} + placeholder="Fecha inicial" + /> +
+ +
+ + { + setEndDate(d); + setPage(1); + }} + placeholder="Fecha final" + /> +
+ + {(walletId || startDate || endDate) && ( + + )} +
+ + {isLoading ? ( +

Cargando...

+ ) : ( + <> + {mobilePagination} + +
+ {movements.map((m) => { + const wallet = wallets?.find((w) => w.id === m.walletId); + return ( +
+
+ + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-red-600 dark:text-red-400" + }`} + > + {m.amount >= 0 ? "+" : ""} + {amountFormatter.format(m.amount)} + +
+ +
+ {m.description && ( +

{m.description}

+ )} +
+ {wallet && {wallet.name}} + · + {dateFormatter.format(new Date(m.createdAt))} +
+
+
+ ); + })} + {movements.length === 0 && ( +
+ No hay movimientos para mostrar. +
+ )} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 && ( + + + + )} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ No hay movimientos para mostrar. +
+
+ + {mobilePagination} + + {totalPages > 1 && ( + + + + { + e.preventDefault(); + setPage(1); + }} + href="#" + aria-label="Ir a la primera página" + className={ + page <= 1 ? "pointer-events-none opacity-50" : "" + } + > + + + + + { + e.preventDefault(); + if (page > 1) setPage(page - 1); + }} + href="#" + className={ + page <= 1 ? "pointer-events-none opacity-50" : "" + } + text="Anterior" + /> + + {currentGroup > 0 && ( + + { + e.preventDefault(); + setPage(startPage - 1); + }} + href="#" + aria-label="Ir al grupo anterior" + > + + + + )} + {Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i, + ).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + href="#" + > + {p} + + + ))} + {(currentGroup + 1) * GROUP_SIZE < totalPages && ( + + { + e.preventDefault(); + setPage(endPage + 1); + }} + href="#" + aria-label="Ir al siguiente grupo" + > + + + + )} + + { + e.preventDefault(); + if (page < totalPages) setPage(page + 1); + }} + href="#" + className={ + page >= totalPages ? "pointer-events-none opacity-50" : "" + } + text="Siguiente" + /> + + + { + e.preventDefault(); + setPage(totalPages); + }} + href="#" + aria-label="Ir a la última página" + className={ + page >= totalPages ? "pointer-events-none opacity-50" : "" + } + > + + + + + + )} + + )} +
+ ); +} diff --git a/apps/frontend/src/features/wallets/components/WalletCard.tsx b/apps/frontend/src/features/wallets/components/WalletCard.tsx index 04cea92..056ede8 100644 --- a/apps/frontend/src/features/wallets/components/WalletCard.tsx +++ b/apps/frontend/src/features/wallets/components/WalletCard.tsx @@ -5,7 +5,9 @@ import { ArrowUpDown, History, PencilLine, + Star, } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -14,10 +16,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import type { Wallet } from "@/lib/api"; +import { useSetDefaultWallet } from "@/lib/queries"; import { DepositDialog } from "./DepositDialog"; import { AdjustBalanceDialog } from "./AdjustBalanceDialog"; import { TransferDialog } from "./TransferDialog"; -import { WalletMovementsDialog } from "./WalletMovementsDialog"; import { EditWalletDialog } from "./EditWalletDialog"; const balanceFormatter = new Intl.NumberFormat("es-AR", { @@ -30,27 +32,59 @@ interface WalletCardProps { } export function WalletCard({ wallet }: WalletCardProps) { + const navigate = useNavigate(); + const setDefaultWallet = useSetDefaultWallet(); const [depositOpen, setDepositOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); - const [movementsOpen, setMovementsOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); return ( <>
-
+

{wallet.name}

-

{wallet.currency}

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

@@ -113,7 +147,9 @@ export function WalletCard({ wallet }: WalletCardProps) { variant="outline" size="icon" className="size-8" - onClick={() => setMovementsOpen(true)} + onClick={() => + navigate({ to: "/wallets/movements", search: { walletId: wallet.id } }) + } > @@ -139,11 +175,6 @@ export function WalletCard({ wallet }: WalletCardProps) { open={transferOpen} onOpenChange={setTransferOpen} /> - { + return mutator(`/api/wallets/${id}/default`, "PUT"); +} + export function getWalletMovements( id: number, ): Promise { return fetcher(`/api/wallets/${id}/movements`); } +export type WalletMovementsFilter = { + walletId?: number; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +}; + +export function getWalletMovementsFiltered( + filter: WalletMovementsFilter, +): Promise> { + const params = new URLSearchParams(); + if (filter.walletId) params.set("walletId", String(filter.walletId)); + if (filter.startDate) params.set("startDate", filter.startDate); + if (filter.endDate) params.set("endDate", filter.endDate); + if (filter.page) params.set("page", String(filter.page)); + if (filter.pageSize) params.set("pageSize", String(filter.pageSize)); + const qs = params.toString(); + return fetcher>( + `/api/wallets/movements${qs ? `?${qs}` : ""}`, + ); +} + // Telegram export type TelegramStatus = { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 0a6ba25..478524b 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -5,12 +5,13 @@ import { useQueryClient, } from "@tanstack/react-query"; import { + type AdjustBalanceInput, type CreateNonPeriodicExpenseInput, type CreatePeriodicExpenseInput, - type AdjustBalanceInput, type CreateWalletInput, type DepositInput, type TransferInput, + type WalletMovementsFilter, createNonPeriodicExpense as createNonPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi, @@ -32,7 +33,9 @@ import { getTelegramStatus, getTotalPending, getWalletMovements, + getWalletMovementsFiltered, getWallets, + setDefaultWallet as setDefaultWalletApi, importExpenses, importPeriodicExpenses, type PayExpenseInput, @@ -261,6 +264,7 @@ export function useCreateNonPeriodicExpense() { createNonPeriodicExpenseApi(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + queryClient.invalidateQueries({ queryKey: walletKeys.all }); }, }); } @@ -292,6 +296,7 @@ export function usePayExpense() { payExpenseApi(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: expenseKeys.all }); + queryClient.invalidateQueries({ queryKey: walletKeys.all }); }, }); } @@ -312,6 +317,8 @@ export function useUpdateExpense() { export const walletKeys = { all: ["wallets"] as const, movements: (id: number) => ["wallets", id, "movements"] as const, + movementsFiltered: (filter: WalletMovementsFilter) => + ["wallets", "movements", filter] as const, }; export function useWallets() { @@ -358,6 +365,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({ @@ -397,6 +414,14 @@ export function useWalletMovements(id: number) { }); } +export function useWalletMovementsFiltered(filter: WalletMovementsFilter) { + return useQuery({ + queryKey: walletKeys.movementsFiltered(filter), + placeholderData: keepPreviousData, + queryFn: () => getWalletMovementsFiltered(filter), + }); +} + // Telegram export function useTelegramStatus() { diff --git a/apps/frontend/src/routes/_authenticated/wallets.index.tsx b/apps/frontend/src/routes/_authenticated/wallets.index.tsx new file mode 100644 index 0000000..9f6b98e --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/wallets.index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { WalletsPage } from "@/features/wallets/WalletsPage"; + +export const Route = createFileRoute("/_authenticated/wallets/")({ + component: WalletsPage, +}); diff --git a/apps/frontend/src/routes/_authenticated/wallets.movements.tsx b/apps/frontend/src/routes/_authenticated/wallets.movements.tsx new file mode 100644 index 0000000..c9f8687 --- /dev/null +++ b/apps/frontend/src/routes/_authenticated/wallets.movements.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { WalletMovementsPage } from "@/features/wallets/WalletMovementsPage"; + +export const Route = createFileRoute("/_authenticated/wallets/movements")({ + validateSearch: z.object({ + walletId: z.coerce.number().optional(), + }), + component: WalletMovementsPage, +}); diff --git a/apps/frontend/src/routes/_authenticated/wallets.tsx b/apps/frontend/src/routes/_authenticated/wallets.tsx index fe59da6..31102bc 100644 --- a/apps/frontend/src/routes/_authenticated/wallets.tsx +++ b/apps/frontend/src/routes/_authenticated/wallets.tsx @@ -1,6 +1,9 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { WalletsPage } from "@/features/wallets/WalletsPage"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated/wallets")({ - component: WalletsPage, + component: WalletsLayout, }); + +function WalletsLayout() { + return ; +}