Compare commits

..

3 Commits

18 changed files with 1678 additions and 57 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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,10 +230,27 @@ export async function createNonPeriodicExpense(
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
const belo = await getBeloSellPriceForDate(dueDate);
const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const result = await prisma.expense.create({
data: {
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,
@@ -238,11 +259,36 @@ export async function createNonPeriodicExpense(
year,
month,
status: PaymentStatus.PAYED,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) }
: {}),
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<typeof payExpenseSchema>,
) {
const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
const paymentDate = data.paymentDate
? new Date(data.paymentDate)
: new Date();
const belo = await getBeloSellPriceForDate(paymentDate);
const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const result = await prisma.expense.update({
where: { id },
data: {
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,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) }
: {}),
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({

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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<typeof updateWalletSchema>;
export type DepositInput = z.infer<typeof depositSchema>;
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
export type TransferInput = z.infer<typeof transferSchema>;
export type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
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<string, unknown> = {};
if (walletId !== undefined) {
where.walletId = walletId;
}
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
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;
}

View File

@@ -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<unknown>>) =>
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<string, ReturnType<typeof mock>>,
)) {
@@ -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",
},
});
});
});

View File

@@ -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<typeof newExpenseSchema>;
@@ -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<NewExpenseFormValues>({
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({
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera</label>
<Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha del gasto</span>
<Controller

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react";
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";
@@ -11,12 +11,28 @@ import {
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }),
walletId: z
.number()
.positive("Seleccioná una billetera")
.refine((v) => v > 0, "Seleccioná una billetera"),
usdcConversionRate: z
.number()
.positive("La cotización debe ser mayor a 0")
.optional(),
});
type PayFormValues = z.infer<typeof paySchema>;
@@ -34,6 +50,19 @@ export function PayExpenseDialog({
}: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(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<PayFormValues>({
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({
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera</label>
<Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha de pago</span>
<Controller

View File

@@ -0,0 +1,516 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { subMonths, startOfMonth } from "date-fns";
import {
ChevronsLeftIcon,
ChevronsRightIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSearch } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Wallet, WalletMovement, WalletMovementsFilter } from "@/lib/api";
import { useWalletMovementsFiltered, useWallets } from "@/lib/queries";
const amountFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
const dateFormatter = new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const typeLabels: Record<string, string> = {
DEPOSIT: "Depósito",
WITHDRAWAL: "Extracción",
TRANSFER_IN: "Transferencia recibida",
TRANSFER_OUT: "Transferencia enviada",
ADJUSTMENT: "Ajuste",
};
function TypeBadge({ type }: { type: string }) {
const styles: Record<string, string> = {
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 (
<span
className={
`inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-xs font-medium ` +
(styles[type] ?? "")
}
>
{typeLabels[type] ?? type}
</span>
);
}
export function WalletMovementsPage() {
const { walletId: initialWalletId } = useSearch({
from: "/_authenticated/wallets/movements",
});
const { data: wallets } = useWallets();
const [walletId, setWalletId] = useState<number | undefined>(initialWalletId);
const today = new Date();
const [startDate, setStartDate] = useState<Date | undefined>(
startOfMonth(subMonths(today, 1)),
);
const [endDate, setEndDate] = useState<Date | undefined>(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<ColumnDef<WalletMovement>[]>(
() => [
{
header: "Tipo",
accessorKey: "type",
cell: ({ row }) => <TypeBadge type={row.getValue("type") as string} />,
},
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => {
const desc = row.getValue("description") as string | null;
return (
<span className="text-muted-foreground">
{desc ?? (
<span className="italic text-muted-foreground/50">
Sin descripción
</span>
)}
</span>
);
},
},
{
header: "Billetera",
id: "walletName",
cell: ({ row }) => {
const w = wallets?.find(
(w) => w.id === row.original.walletId,
);
return w ? (
<span className="text-xs text-muted-foreground">{w.name}</span>
) : null;
},
},
{
header: "Monto",
accessorKey: "amount",
cell: ({ row }) => {
const amount = row.getValue("amount") as number;
return (
<span
className={`tabular-nums font-medium ${
amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{amount >= 0 ? "+" : ""}
{amountFormatter.format(amount)}
</span>
);
},
},
{
header: "Fecha",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<span className="text-xs text-muted-foreground tabular-nums">
{dateFormatter.format(
new Date(row.getValue("createdAt") as string),
)}
</span>
);
},
},
],
[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 ? (
<div className="rounded-lg border bg-card p-2 sm:hidden">
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Movimientos</h1>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Billetera
</label>
<Select
value={walletId !== undefined ? String(walletId) : "all"}
onValueChange={(v) => {
setWalletId(v === "all" ? undefined : Number(v));
setPage(1);
}}
>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Todas las billeteras" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas las billeteras
</SelectItem>
{wallets?.map((w: Wallet) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Desde
</label>
<DatePicker
value={startDate}
onChange={(d) => {
setStartDate(d);
setPage(1);
}}
placeholder="Fecha inicial"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Hasta
</label>
<DatePicker
value={endDate}
onChange={(d) => {
setEndDate(d);
setPage(1);
}}
placeholder="Fecha final"
/>
</div>
{(walletId || startDate || endDate) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setWalletId(undefined);
setStartDate(undefined);
setEndDate(undefined);
setPage(1);
}}
>
Limpiar filtros
</Button>
)}
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
{mobilePagination}
<div className="space-y-2 sm:hidden">
{movements.map((m) => {
const wallet = wallets?.find((w) => w.id === m.walletId);
return (
<div
key={m.id}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<TypeBadge type={m.type} />
<span
className={`tabular-nums font-medium ${
m.amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{m.amount >= 0 ? "+" : ""}
{amountFormatter.format(m.amount)}
</span>
</div>
<div className="mt-2 space-y-1">
{m.description && (
<p className="text-muted-foreground">{m.description}</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{wallet && <span>{wallet.name}</span>}
<span>&middot;</span>
<span>{dateFormatter.format(new Date(m.createdAt))}</span>
</div>
</div>
</div>
);
})}
{movements.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
No hay movimientos para mostrar.
</div>
)}
</div>
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td
colSpan={columns.length}
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
No hay movimientos para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</div>
{mobilePagination}
{totalPages > 1 && (
<Pagination className="hidden sm:flex">
<PaginationContent>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(1);
}}
href="#"
aria-label="Ir a la primera página"
className={
page <= 1 ? "pointer-events-none opacity-50" : ""
}
>
<SkipBackIcon className="size-4" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={(e) => {
e.preventDefault();
if (page > 1) setPage(page - 1);
}}
href="#"
className={
page <= 1 ? "pointer-events-none opacity-50" : ""
}
text="Anterior"
/>
</PaginationItem>
{currentGroup > 0 && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(startPage - 1);
}}
href="#"
aria-label="Ir al grupo anterior"
>
<ChevronsLeftIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
{Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i,
).map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
onClick={(e) => {
e.preventDefault();
setPage(p);
}}
href="#"
>
{p}
</PaginationLink>
</PaginationItem>
))}
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(endPage + 1);
}}
href="#"
aria-label="Ir al siguiente grupo"
>
<ChevronsRightIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(page + 1);
}}
href="#"
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
text="Siguiente"
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(totalPages);
}}
href="#"
aria-label="Ir a la última página"
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
>
<SkipForwardIcon className="size-4" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
);
}

View File

@@ -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,20 +32,51 @@ 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 (
<>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{wallet.name}</p>
<p className="text-xs text-muted-foreground">{wallet.currency}</p>
{wallet.isDefault && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
Default
</span>
)}
</div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setDefaultWallet.mutate(wallet.id)}
className={`transition-colors cursor-pointer ${
wallet.isDefault
? "text-yellow-500 hover:text-yellow-600"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Star
className="size-4"
fill={wallet.isDefault ? "currentColor" : "none"}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{wallet.isDefault
? "Billetera predeterminada"
: "Marcar como predeterminada"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
onClick={() => setEditOpen(true)}
@@ -52,6 +85,7 @@ export function WalletCard({ wallet }: WalletCardProps) {
<PencilLine className="size-4" />
</button>
</div>
</div>
<p className="text-2xl font-bold tabular-nums">
{balanceFormatter.format(wallet.balance)}
@@ -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 } })
}
>
<History className="size-4" />
</Button>
@@ -139,11 +175,6 @@ export function WalletCard({ wallet }: WalletCardProps) {
open={transferOpen}
onOpenChange={setTransferOpen}
/>
<WalletMovementsDialog
wallet={wallet}
open={movementsOpen}
onOpenChange={setMovementsOpen}
/>
<EditWalletDialog
wallet={wallet}
open={editOpen}

View File

@@ -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,12 +414,39 @@ export function transferWallet(
);
}
export function setDefaultWallet(id: number): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT");
}
export function getWalletMovements(
id: number,
): Promise<WalletMovement[]> {
return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`);
}
export type WalletMovementsFilter = {
walletId?: number;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
};
export function getWalletMovementsFiltered(
filter: WalletMovementsFilter,
): Promise<PaginatedResponse<WalletMovement>> {
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<PaginatedResponse<WalletMovement>>(
`/api/wallets/movements${qs ? `?${qs}` : ""}`,
);
}
// Telegram
export type TelegramStatus = {

View File

@@ -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() {

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { WalletsPage } from "@/features/wallets/WalletsPage";
export const Route = createFileRoute("/_authenticated/wallets/")({
component: WalletsPage,
});

View File

@@ -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,
});

View File

@@ -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 <Outlet />;
}