Compare commits

..

5 Commits

24 changed files with 1882 additions and 85 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

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
);
-- Seed
INSERT INTO "settings" ("key", "value", "createdAt", "updatedAt")
VALUES ('salary_multiplier', '4500', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);

View File

@@ -122,6 +122,15 @@ model QuoteHistory {
@@map("quotes_history") @@map("quotes_history")
} }
model Setting {
key String @id
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("settings")
}
// Expenses // Expenses
enum PaymentStatus { enum PaymentStatus {
@@ -134,10 +143,12 @@ model Wallet {
name String @db.VarChar(100) name String @db.VarChar(100)
currency String @db.VarChar(10) currency String @db.VarChar(10)
balance Decimal @db.Decimal(14, 4) balance Decimal @db.Decimal(14, 4)
isDefault Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
movements WalletMovement[] movements WalletMovement[]
expenses Expense[]
@@map("wallets") @@map("wallets")
} }
@@ -186,6 +197,9 @@ model Expense {
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
dueDate DateTime dueDate DateTime
paymentDate DateTime? paymentDate DateTime?
walletId Int?
wallet Wallet? @relation(fields: [walletId], references: [id])
@@index([walletId])
@@map("expenses") @@map("expenses")
} }

View File

@@ -11,6 +11,7 @@ import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes"; import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import settingsRouter from "./modules/settings/settings.routes";
import telegramRouter from "./modules/telegram/telegram.router"; import telegramRouter from "./modules/telegram/telegram.router";
import walletsRouter from "./modules/wallets/wallets.routes"; import walletsRouter from "./modules/wallets/wallets.routes";
@@ -32,6 +33,7 @@ app.use("/api/*", async (c, next) => {
}); });
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api/settings", settingsRouter);
app.route("/api", expensesRouter); app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter); app.route("/api/telegram", telegramRouter);
app.route("/api/wallets", walletsRouter); app.route("/api/wallets", walletsRouter);

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; 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 { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { roundTo } from "../../lib/utils"; import { roundTo } from "../../lib/utils";
@@ -17,11 +17,15 @@ export const createNonPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), description: z.string().min(1).max(50),
amount: z.number().positive(), amount: z.number().positive(),
dueDate: z.string().datetime(), dueDate: z.string().datetime(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
}); });
export const payExpenseSchema = z.object({ export const payExpenseSchema = z.object({
amountPayed: z.number().positive(), amountPayed: z.number().positive(),
paymentDate: z.string().datetime().optional(), paymentDate: z.string().datetime().optional(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
}); });
export const updateExpenseSchema = z.object({ export const updateExpenseSchema = z.object({
@@ -226,10 +230,27 @@ export async function createNonPeriodicExpense(
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; 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({ const isUSDC = wallet.currency === "USDC";
data: {
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, description: data.description,
amount: data.amount, amount: data.amount,
amountPayed: data.amount, amountPayed: data.amount,
@@ -238,11 +259,36 @@ export async function createNonPeriodicExpense(
year, year,
month, month,
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
...(belo !== null walletId: data.walletId,
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) } };
: {}),
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:"); cache.invalidateByPrefix("expenses:");
return result; return result;
} }
@@ -251,23 +297,62 @@ export async function payExpense(
id: number, id: number,
data: z.infer<typeof payExpenseSchema>, data: z.infer<typeof payExpenseSchema>,
) { ) {
const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
const paymentDate = data.paymentDate const paymentDate = data.paymentDate
? new Date(data.paymentDate) ? new Date(data.paymentDate)
: new Date(); : 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({ const isUSDC = wallet.currency === "USDC";
where: { id },
data: { 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, status: PaymentStatus.PAYED,
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate, paymentDate,
...(belo !== null walletId: data.walletId,
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) } };
: {}),
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:"); cache.invalidateByPrefix("expenses:");
return result; return result;
} }
@@ -289,12 +374,29 @@ export async function updateExpense(
month, month,
}; };
let walletAdjustment: {
walletId: number;
amount: number;
description: string;
} | null = null;
if (data.status === "PENDING") { if (data.status === "PENDING") {
updateData.status = PaymentStatus.PENDING; updateData.status = PaymentStatus.PENDING;
updateData.amountPayed = null; updateData.amountPayed = null;
updateData.paymentDate = null; updateData.paymentDate = null;
updateData.beloPrice = null; updateData.beloPrice = null;
updateData.usdcEquivalent = 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) { } else if (existing.status === PaymentStatus.PAYED) {
if (data.amountPayed !== undefined) { if (data.amountPayed !== undefined) {
updateData.amountPayed = data.amountPayed; updateData.amountPayed = data.amountPayed;
@@ -317,6 +419,51 @@ export async function updateExpense(
updateData.usdcEquivalent = null; 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({ const result = await prisma.expense.update({

View File

@@ -0,0 +1,29 @@
import type { Context } from "hono";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
export async function getSettings(c: Context) {
const cached = cache.get("settings:all");
if (cached) return c.json(cached);
const settings = await prisma.setting.findMany();
cache.set("settings:all", settings, 60_000);
return c.json(settings);
}
export async function updateSetting(c: Context) {
const body = await c.req.json<{ key: string; value: string }>();
if (!body.key || body.value === undefined) {
return c.json({ error: "key and value are required" }, 400);
}
const setting = await prisma.setting.upsert({
where: { key: body.key },
update: { value: body.value },
create: { key: body.key, value: body.value },
});
cache.invalidateByPrefix("settings:");
return c.json(setting);
}

View File

@@ -0,0 +1,9 @@
import { Hono } from "hono";
import { getSettings, updateSetting } from "./settings.handler";
const app = new Hono();
app.get("/", getSettings);
app.put("/", updateSetting);
export default app;

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 { createWalletHandler } from "./handlers/createWallet";
import { deleteWalletHandler } from "./handlers/deleteWallet"; import { deleteWalletHandler } from "./handlers/deleteWallet";
import { depositWalletHandler } from "./handlers/depositWallet"; import { depositWalletHandler } from "./handlers/depositWallet";
import { listAllMovementsHandler } from "./handlers/listAllMovements";
import { listMovementsHandler } from "./handlers/listMovements"; import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets"; import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet"; import { transferWalletHandler } from "./handlers/transferWallet";
import { updateWalletHandler } from "./handlers/updateWallet"; import { updateWalletHandler } from "./handlers/updateWallet";
import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
const app = new Hono(); const app = new Hono();
app.get("/movements", listAllMovementsHandler);
app.get("/", listWalletsHandler); app.get("/", listWalletsHandler);
app.post("/", createWalletHandler); app.post("/", createWalletHandler);
app.put("/:id", updateWalletHandler); app.put("/:id", updateWalletHandler);
@@ -17,6 +20,7 @@ app.delete("/:id", deleteWalletHandler);
app.post("/:id/deposit", depositWalletHandler); app.post("/:id/deposit", depositWalletHandler);
app.post("/:id/adjust", adjustBalanceHandler); app.post("/:id/adjust", adjustBalanceHandler);
app.post("/transfer", transferWalletHandler); app.post("/transfer", transferWalletHandler);
app.put("/:id/default", setDefaultWalletHandler);
app.get("/:id/movements", listMovementsHandler); app.get("/:id/movements", listMovementsHandler);
export default app; export default app;

View File

@@ -1,11 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "../../generated/prisma/client"; import { Prisma } from "../../generated/prisma/client";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
export const createWalletSchema = z.object({ export const createWalletSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
currency: z.string().min(1).max(10), currency: z.string().min(1).max(10),
initialBalance: z.number().optional().default(0), initialBalance: z.number().optional().default(0),
isDefault: z.boolean().optional().default(false),
}); });
export const updateWalletSchema = z.object({ export const updateWalletSchema = z.object({
@@ -13,6 +15,10 @@ export const updateWalletSchema = z.object({
currency: z.string().min(1).max(10), currency: z.string().min(1).max(10),
}); });
export const setDefaultWalletSchema = z.object({
walletId: z.number().int().positive(),
});
export const depositSchema = z.object({ export const depositSchema = z.object({
amount: z.number().positive(), amount: z.number().positive(),
description: z.string().max(255).optional(), 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 DepositInput = z.infer<typeof depositSchema>;
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>; export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
export type TransferInput = z.infer<typeof transferSchema>; export type TransferInput = z.infer<typeof transferSchema>;
export type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
export async function listWallets() { 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) })); return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
} }
@@ -48,8 +57,15 @@ export async function createWallet(input: CreateWalletInput) {
name: input.name, name: input.name,
currency: input.currency, currency: input.currency,
balance: input.initialBalance ?? 0, 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) }; 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) { export async function listMovements(walletId: number) {
const movements = await prisma.walletMovement.findMany({ const movements = await prisma.walletMovement.findMany({
where: { walletId }, where: { walletId },
@@ -162,3 +193,51 @@ export async function listMovements(walletId: number) {
}); });
return movements.map((m) => ({ ...m, amount: Number(m.amount) })); 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 { beforeEach, describe, expect, mock, test } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client"; 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 = { const mockPrisma = {
$transaction: mockTransaction,
periodicExpense: { periodicExpense: {
findMany: mock(), findMany: mock(),
findUniqueOrThrow: mock(), findUniqueOrThrow: mock(),
@@ -16,6 +24,13 @@ const mockPrisma = {
update: mock(), update: mock(),
count: mock(), count: mock(),
}, },
wallet: {
findUnique: mockWalletFindUnique,
update: mockWalletUpdate,
},
walletMovement: {
create: mockMovementCreate,
},
quote: { quote: {
findFirst: mock(), findFirst: mock(),
}, },
@@ -30,6 +45,7 @@ mock.module("../src/lib/prisma", () => ({
beforeEach(() => { beforeEach(() => {
for (const model of Object.values(mockPrisma)) { for (const model of Object.values(mockPrisma)) {
if (typeof model === "function") continue;
for (const fn of Object.values( for (const fn of Object.values(
model as Record<string, ReturnType<typeof mock>>, model as Record<string, ReturnType<typeof mock>>,
)) { )) {
@@ -155,6 +171,7 @@ describe("schemas", () => {
description: "Ropa", description: "Ropa",
amount: 2500, amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z", dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
}); });
expect(result.description).toBe("Ropa"); expect(result.description).toBe("Ropa");
}); });
@@ -175,12 +192,16 @@ describe("schemas", () => {
const result = payExpenseSchema.parse({ const result = payExpenseSchema.parse({
amountPayed: 1400, amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z", paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
}); });
expect(result.amountPayed).toBe(1400); expect(result.amountPayed).toBe(1400);
}); });
test("allows missing paymentDate", () => { 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.amountPayed).toBe(1400);
expect(result.paymentDate).toBeUndefined(); expect(result.paymentDate).toBeUndefined();
}); });
@@ -407,6 +428,7 @@ describe("createNonPeriodicExpense", () => {
description: "Ropa", description: "Ropa",
amount: 2500, amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z", dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
}; };
const created = { const created = {
id: 1, id: 1,
@@ -417,7 +439,11 @@ describe("createNonPeriodicExpense", () => {
year: 2026, year: 2026,
month: 6, month: 6,
}; };
const wallet = { id: 1, balance: 10000 };
mockPrisma.expense.create.mockResolvedValueOnce(created); mockPrisma.expense.create.mockResolvedValueOnce(created);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 7500 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense(input); const result = await createNonPeriodicExpense(input);
expect(result.status).toBe(PaymentStatus.PAYED); expect(result.status).toBe(PaymentStatus.PAYED);
@@ -436,23 +462,48 @@ describe("createNonPeriodicExpense", () => {
describe("payExpense", () => { describe("payExpense", () => {
test("updates status to PAYED with amountPayed and paymentDate", async () => { 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 = { const paidExpense = {
id: 1, id: 1,
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
amountPayed: 1400, amountPayed: 1400,
paymentDate: new Date("2026-06-10T00:00:00.000Z"), 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); mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 8600 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, { const result = await payExpense(1, {
amountPayed: 1400, amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z", paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
}); });
expect(result.status).toBe(PaymentStatus.PAYED); expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(1400); expect(result.amountPayed).toBe(1400);
}); });
test("defaults paymentDate to now when not provided", async () => { 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( mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ async ({ where, data }) => ({
id: where.id, id: where.id,
@@ -462,8 +513,10 @@ describe("payExpense", () => {
paymentDate: data.paymentDate, 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.status).toBe(PaymentStatus.PAYED);
expect(result.paymentDate).toBeInstanceOf(Date); expect(result.paymentDate).toBeInstanceOf(Date);
}); });
@@ -739,7 +792,18 @@ describe("getBeloSellPriceForDate", () => {
describe("payExpense with BELO", () => { describe("payExpense with BELO", () => {
test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => { 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 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 mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce({ .mockResolvedValueOnce({
sell: "1500.00", sell: "1500.00",
@@ -757,10 +821,13 @@ describe("payExpense with BELO", () => {
usdcEquivalent: data.usdcEquivalent, usdcEquivalent: data.usdcEquivalent,
}), }),
); );
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, { const result = await payExpense(1, {
amountPayed: 15000, amountPayed: 15000,
paymentDate: "2026-06-10T00:00:00.000Z", paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
}); });
expect(result.beloPrice).toBe(1500); 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 () => { 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 mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce(null) .mockResolvedValueOnce(null)
.mockResolvedValueOnce(null); .mockResolvedValueOnce(null);
@@ -780,10 +858,13 @@ describe("payExpense with BELO", () => {
paymentDate: data.paymentDate, paymentDate: data.paymentDate,
}), }),
); );
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, { const result = await payExpense(1, {
amountPayed: 15000, amountPayed: 15000,
paymentDate: "2026-06-10T00:00:00.000Z", paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
}); });
expect(result.beloPrice).toBeUndefined(); expect(result.beloPrice).toBeUndefined();
@@ -794,6 +875,8 @@ describe("payExpense with BELO", () => {
describe("createNonPeriodicExpense with BELO", () => { describe("createNonPeriodicExpense with BELO", () => {
test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => { test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => {
const dueDate = "2026-06-15T00:00:00.000Z"; const dueDate = "2026-06-15T00:00:00.000Z";
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.quoteHistory.findFirst mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce({ .mockResolvedValueOnce({
sell: "1500.00", sell: "1500.00",
@@ -812,11 +895,14 @@ describe("createNonPeriodicExpense with BELO", () => {
usdcEquivalent: data.usdcEquivalent, usdcEquivalent: data.usdcEquivalent,
}), }),
); );
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 70000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense({ const result = await createNonPeriodicExpense({
description: "Ropa", description: "Ropa",
amount: 30000, amount: 30000,
dueDate, dueDate,
walletId: 1,
}); });
expect(result.beloPrice).toBe(1500); 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 () => { 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 mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce(null) .mockResolvedValueOnce(null)
.mockResolvedValueOnce(null); .mockResolvedValueOnce(null);
@@ -837,11 +925,14 @@ describe("createNonPeriodicExpense with BELO", () => {
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
}), }),
); );
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 97500 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense({ const result = await createNonPeriodicExpense({
description: "Ropa", description: "Ropa",
amount: 2500, amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z", dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
}); });
expect(result.beloPrice).toBeUndefined(); expect(result.beloPrice).toBeUndefined();
@@ -938,3 +1029,402 @@ describe("updateExpense with BELO", () => {
expect(mockPrisma.quoteHistory.findFirst).not.toHaveBeenCalled(); 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

@@ -9,9 +9,9 @@ import {
import type { Quote } from "@/lib/api"; import type { Quote } from "@/lib/api";
import { import {
useFetchQuotes, useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals, useMonthlyTotals,
useQuotes, useQuotes,
useSettings,
useWallets, useWallets,
} from "@/lib/queries"; } from "@/lib/queries";
import { RelativeTime } from "@/lib/time"; import { RelativeTime } from "@/lib/time";
@@ -131,15 +131,15 @@ export function DashboardPage() {
const bna = quotes?.find((q) => q.type === "BNA"); const bna = quotes?.find((q) => q.type === "BNA");
const now = new Date(); const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals( const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(), now.getFullYear(),
now.getMonth() + 1, now.getMonth() + 1,
); );
const { data: settings } = useSettings();
const salaryMultiplier =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const isFetching = isLoading || isPending; const isFetching = isLoading || isPending;
const { data: wallets } = useWallets(); const { data: wallets } = useWallets();
@@ -173,9 +173,12 @@ export function DashboardPage() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p> <p className="text-sm text-muted-foreground">Sueldo</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyExpenses?.total ?? 0)} $
{priceFormatter.format(
(belo ? Number(belo.buy) : 0) * Number(salaryMultiplier),
)}
</p> </p>
</div> </div>
<QuoteCard <QuoteCard
@@ -202,12 +205,11 @@ export function DashboardPage() {
<h2 className="text-lg font-semibold">Billeteras</h2> <h2 className="text-lg font-semibold">Billeteras</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{wallets.map((w) => ( {wallets.map((w) => (
<div <div key={w.id} className="rounded-lg border p-4">
key={w.id}
className="rounded-lg border p-4"
>
<p className="text-sm text-muted-foreground">{w.name}</p> <p className="text-sm text-muted-foreground">{w.name}</p>
<p className="text-sm text-muted-foreground/60 text-xs">{w.currency}</p> <p className="text-sm text-muted-foreground/60 text-xs">
{w.currency}
</p>
<p className="text-2xl font-bold tabular-nums mt-1"> <p className="text-2xl font-bold tabular-nums mt-1">
{walletBalanceFormatter.format(w.balance)} {walletBalanceFormatter.format(w.balance)}
</p> </p>

View File

@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; 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 { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -10,7 +11,15 @@ import {
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useExpensesContext } from "../ExpensesProvider"; import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const newExpenseSchema = z.object({ const newExpenseSchema = z.object({
description: z description: z
@@ -19,6 +28,11 @@ const newExpenseSchema = z.object({
.max(50, "Máximo 50 caracteres"), .max(50, "Máximo 50 caracteres"),
amount: z.number().positive("El monto debe ser mayor a 0"), amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }), 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>; type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
@@ -33,6 +47,19 @@ export function NewExpenseDialog({
onOpenChange, onOpenChange,
}: NewExpenseDialogProps) { }: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext(); 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 { const {
register, register,
@@ -40,15 +67,48 @@ export function NewExpenseDialog({
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<NewExpenseFormValues>({ } = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema), resolver: zodResolver(newExpenseSchema),
defaultValues: { defaultValues: {
description: "", description: "",
amount: 0, amount: 0,
dueDate: new Date(), 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) { function handleClose(open: boolean) {
onOpenChange(open); onOpenChange(open);
if (!open) reset(); if (!open) reset();
@@ -59,6 +119,8 @@ export function NewExpenseDialog({
description: data.description, description: data.description,
amount: data.amount, amount: data.amount,
dueDate: data.dueDate.toISOString(), dueDate: data.dueDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -112,6 +174,62 @@ export function NewExpenseDialog({
)} )}
</div> </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"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha del gasto</span> <span className="text-sm font-medium">Fecha del gasto</span>
<Controller <Controller

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
@@ -11,12 +11,28 @@ import {
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Expense } from "@/lib/api"; import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider"; import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const paySchema = z.object({ const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"), amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }), 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>; type PayFormValues = z.infer<typeof paySchema>;
@@ -34,6 +50,19 @@ export function PayExpenseDialog({
}: PayExpenseDialogProps) { }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext(); const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null); 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 { const {
register, register,
@@ -41,25 +70,54 @@ export function PayExpenseDialog({
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<PayFormValues>({ } = useForm<PayFormValues>({
resolver: zodResolver(paySchema), resolver: zodResolver(paySchema),
defaultValues: { defaultValues: {
amountPayed: 0, amountPayed: 0,
paymentDate: undefined, paymentDate: undefined,
walletId: 0,
usdcConversionRate: undefined,
}, },
}); });
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", { const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
setValueAs: (value) => Number(value), 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(() => { useEffect(() => {
if (expense) { if (expense) {
const defaultId = defaultWallet?.id ?? 0;
reset({ reset({
amountPayed: Number(expense.amount), amountPayed: Number(expense.amount),
paymentDate: new Date(), 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(() => { useEffect(() => {
if (!open || !expense) return; if (!open || !expense) return;
@@ -82,6 +140,8 @@ export function PayExpenseDialog({
await payExpense(expense.id, { await payExpense(expense.id, {
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(), paymentDate: data.paymentDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -133,6 +193,62 @@ export function PayExpenseDialog({
)} )}
</div> </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"> <div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha de pago</span> <span className="text-sm font-medium">Fecha de pago</span>
<Controller <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, ArrowUpDown,
History, History,
PencilLine, PencilLine,
Star,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
@@ -14,10 +16,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { Wallet } from "@/lib/api"; import type { Wallet } from "@/lib/api";
import { useSetDefaultWallet } from "@/lib/queries";
import { DepositDialog } from "./DepositDialog"; import { DepositDialog } from "./DepositDialog";
import { AdjustBalanceDialog } from "./AdjustBalanceDialog"; import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
import { TransferDialog } from "./TransferDialog"; import { TransferDialog } from "./TransferDialog";
import { WalletMovementsDialog } from "./WalletMovementsDialog";
import { EditWalletDialog } from "./EditWalletDialog"; import { EditWalletDialog } from "./EditWalletDialog";
const balanceFormatter = new Intl.NumberFormat("es-AR", { const balanceFormatter = new Intl.NumberFormat("es-AR", {
@@ -30,20 +32,51 @@ interface WalletCardProps {
} }
export function WalletCard({ wallet }: WalletCardProps) { export function WalletCard({ wallet }: WalletCardProps) {
const navigate = useNavigate();
const setDefaultWallet = useSetDefaultWallet();
const [depositOpen, setDepositOpen] = useState(false); const [depositOpen, setDepositOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false); const [adjustOpen, setAdjustOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
const [movementsOpen, setMovementsOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
return ( return (
<> <>
<div className="rounded-lg border p-4 space-y-3"> <div className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="flex items-center gap-2">
<p className="font-medium">{wallet.name}</p> <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>
<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 <button
type="button" type="button"
onClick={() => setEditOpen(true)} onClick={() => setEditOpen(true)}
@@ -52,6 +85,7 @@ export function WalletCard({ wallet }: WalletCardProps) {
<PencilLine className="size-4" /> <PencilLine className="size-4" />
</button> </button>
</div> </div>
</div>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">
{balanceFormatter.format(wallet.balance)} {balanceFormatter.format(wallet.balance)}
@@ -113,7 +147,9 @@ export function WalletCard({ wallet }: WalletCardProps) {
variant="outline" variant="outline"
size="icon" size="icon"
className="size-8" className="size-8"
onClick={() => setMovementsOpen(true)} onClick={() =>
navigate({ to: "/wallets/movements", search: { walletId: wallet.id } })
}
> >
<History className="size-4" /> <History className="size-4" />
</Button> </Button>
@@ -139,11 +175,6 @@ export function WalletCard({ wallet }: WalletCardProps) {
open={transferOpen} open={transferOpen}
onOpenChange={setTransferOpen} onOpenChange={setTransferOpen}
/> />
<WalletMovementsDialog
wallet={wallet}
open={movementsOpen}
onOpenChange={setMovementsOpen}
/>
<EditWalletDialog <EditWalletDialog
wallet={wallet} wallet={wallet}
open={editOpen} open={editOpen}

View File

@@ -149,11 +149,15 @@ export type CreateNonPeriodicExpenseInput = {
description: string; description: string;
amount: number; amount: number;
dueDate: string; dueDate: string;
walletId: number;
usdcConversionRate?: number;
}; };
export type PayExpenseInput = { export type PayExpenseInput = {
amountPayed: number; amountPayed: number;
paymentDate?: string; paymentDate?: string;
walletId: number;
usdcConversionRate?: number;
}; };
export type UpdateExpenseInput = { export type UpdateExpenseInput = {
@@ -327,6 +331,7 @@ export type Wallet = {
name: string; name: string;
currency: string; currency: string;
balance: number; balance: number;
isDefault: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -334,7 +339,12 @@ export type Wallet = {
export type WalletMovement = { export type WalletMovement = {
id: number; id: number;
walletId: number; walletId: number;
type: "DEPOSIT" | "WITHDRAWAL" | "TRANSFER_IN" | "TRANSFER_OUT" | "ADJUSTMENT"; type:
| "DEPOSIT"
| "WITHDRAWAL"
| "TRANSFER_IN"
| "TRANSFER_OUT"
| "ADJUSTMENT";
amount: number; amount: number;
description: string | null; description: string | null;
referenceWalletId: number | null; referenceWalletId: number | null;
@@ -385,10 +395,7 @@ export function deleteWallet(id: number): Promise<void> {
return mutator<void>(`/api/wallets/${id}`, "DELETE"); return mutator<void>(`/api/wallets/${id}`, "DELETE");
} }
export function depositWallet( export function depositWallet(id: number, data: DepositInput): Promise<Wallet> {
id: number,
data: DepositInput,
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data); return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data);
} }
@@ -409,12 +416,52 @@ export function transferWallet(
); );
} }
export function getWalletMovements( export function setDefaultWallet(id: number): Promise<Wallet> {
id: number, return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT");
): Promise<WalletMovement[]> { }
export function getWalletMovements(id: number): Promise<WalletMovement[]> {
return fetcher<WalletMovement[]>(`/api/wallets/${id}/movements`); 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}` : ""}`,
);
}
// Settings
export type Setting = {
key: string;
value: string;
};
export function getSettings(): Promise<Setting[]> {
return fetcher<Setting[]>("/api/settings");
}
export function updateSetting(key: string, value: string): Promise<Setting> {
return mutator<Setting>("/api/settings", "PUT", { key, value });
}
// Telegram // Telegram
export type TelegramStatus = { export type TelegramStatus = {

View File

@@ -5,23 +5,24 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
type AdjustBalanceInput,
adjustBalance as adjustBalanceApi,
type CreateNonPeriodicExpenseInput, type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput, type CreatePeriodicExpenseInput,
type AdjustBalanceInput,
type CreateWalletInput, type CreateWalletInput,
type DepositInput,
type TransferInput,
createNonPeriodicExpense as createNonPeriodicExpenseApi, createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi, createPeriodicExpense as createPeriodicExpenseApi,
createWallet as createWalletApi,
type DepositInput,
deletePeriodicExpense as deletePeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi,
deleteWallet as deleteWalletApi,
depositWallet as depositWalletApi, depositWallet as depositWalletApi,
adjustBalance as adjustBalanceApi,
fetchQuotes, fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi, generateMonthlyExpense as generateMonthlyExpenseApi,
generateTelegramCode,
getDailyMinMax, getDailyMinMax,
getDailyQuotes, getDailyQuotes,
getExpenses, getExpenses,
generateTelegramCode,
getHistoricalMinMax, getHistoricalMinMax,
getMonthlyPayedTotal, getMonthlyPayedTotal,
getMonthlyTotals, getMonthlyTotals,
@@ -29,21 +30,25 @@ import {
getPeriodicExpenses, getPeriodicExpenses,
getQuoteHistory, getQuoteHistory,
getQuotes, getQuotes,
getSettings,
getTelegramStatus, getTelegramStatus,
getTotalPending, getTotalPending,
getWalletMovements, getWalletMovements,
getWalletMovementsFiltered,
getWallets, getWallets,
importExpenses, importExpenses,
importPeriodicExpenses, importPeriodicExpenses,
type PayExpenseInput, type PayExpenseInput,
payExpense as payExpenseApi, payExpense as payExpenseApi,
setDefaultWallet as setDefaultWalletApi,
type TransferInput,
transferWallet as transferWalletApi,
type UpdateExpenseInput, type UpdateExpenseInput,
updateExpense as updateExpenseApi, updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi, updatePeriodicExpense as updatePeriodicExpenseApi,
createWallet as createWalletApi, updateSetting as updateSettingApi,
updateWallet as updateWalletApi, updateWallet as updateWalletApi,
deleteWallet as deleteWalletApi, type WalletMovementsFilter,
transferWallet as transferWalletApi,
} from "./api"; } from "./api";
export const quoteKeys = { export const quoteKeys = {
@@ -261,6 +266,7 @@ export function useCreateNonPeriodicExpense() {
createNonPeriodicExpenseApi(data), createNonPeriodicExpenseApi(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all }); queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
}, },
}); });
} }
@@ -292,6 +298,7 @@ export function usePayExpense() {
payExpenseApi(id, data), payExpenseApi(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all }); queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
}, },
}); });
} }
@@ -312,6 +319,8 @@ export function useUpdateExpense() {
export const walletKeys = { export const walletKeys = {
all: ["wallets"] as const, all: ["wallets"] as const,
movements: (id: number) => ["wallets", id, "movements"] as const, movements: (id: number) => ["wallets", id, "movements"] as const,
movementsFiltered: (filter: WalletMovementsFilter) =>
["wallets", "movements", filter] as const,
}; };
export function useWallets() { export function useWallets() {
@@ -358,6 +367,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() { export function useDepositWallet() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -397,6 +416,39 @@ export function useWalletMovements(id: number) {
}); });
} }
export function useWalletMovementsFiltered(filter: WalletMovementsFilter) {
return useQuery({
queryKey: walletKeys.movementsFiltered(filter),
placeholderData: keepPreviousData,
queryFn: () => getWalletMovementsFiltered(filter),
});
}
// Settings
export const settingKeys = {
all: ["settings"] as const,
};
export function useSettings() {
return useQuery({
queryKey: settingKeys.all,
queryFn: getSettings,
staleTime: 60_000,
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
updateSettingApi(key, value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: settingKeys.all });
},
});
}
// Telegram // Telegram
export function useTelegramStatus() { export function useTelegramStatus() {

View File

@@ -1,12 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react"; import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { import {
useGenerateTelegramCode, useGenerateTelegramCode,
useSettings,
useTelegramStatus, useTelegramStatus,
useUpdateSetting,
} from "@/lib/queries"; } from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({ export const Route = createFileRoute("/_authenticated/settings")({
@@ -126,11 +128,79 @@ function SettingsPage() {
<hr className="border-border" /> <hr className="border-border" />
<SalaryMultiplierSection />
<TelegramSection /> <TelegramSection />
</div> </div>
); );
} }
function SalaryMultiplierSection() {
const { data: settings, isLoading } = useSettings();
const updateSetting = useUpdateSetting();
const current =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const [localValue, setLocalValue] = useState(current);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (settings) {
const val = settings.find((s) => s.key === "salary_multiplier")?.value;
if (val) setLocalValue(val);
}
}, [settings]);
async function handleSave() {
setSuccess(false);
await updateSetting.mutateAsync({
key: "salary_multiplier",
value: localValue,
});
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">
Multiplicador de sueldo
</h2>
<p className="text-sm text-muted-foreground">
Valor para calcular el sueldo (Sueldo = BELO bid x este valor)
</p>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="number"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
className="w-32"
/>
<Button
type="button"
onClick={handleSave}
disabled={updateSetting.isPending || localValue === current}
>
{updateSetting.isPending ? "Guardando..." : "Guardar"}
</Button>
</div>
{success && (
<p className="text-sm text-emerald-600">
Multiplicador actualizado correctamente
</p>
)}
</div>
)}
</div>
);
}
function TelegramSection() { function TelegramSection() {
const { data: status, isLoading } = useTelegramStatus(); const { data: status, isLoading } = useTelegramStatus();
const { const {

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 { createFileRoute, Outlet } from "@tanstack/react-router";
import { WalletsPage } from "@/features/wallets/WalletsPage";
export const Route = createFileRoute("/_authenticated/wallets")({ export const Route = createFileRoute("/_authenticated/wallets")({
component: WalletsPage, component: WalletsLayout,
}); });
function WalletsLayout() {
return <Outlet />;
}