Compare commits

...

7 Commits

Author SHA1 Message Date
Jose Selesan
4784242a35 Added estimaded salary to dashboard 2026-07-22 12:31:58 -03:00
09f00aa3b9 Merge pull request 'feat/payment-wallets' (#5) from feat/payment-wallets into main
Reviewed-on: #5
2026-06-17 14:11:30 +00:00
Jose Selesan
04b3f87aca feat(wallets): implement wallet movements feature with filtering and pagination 2026-06-17 11:10:20 -03:00
Jose Selesan
33120deed6 feat(expenses): add walletId to expense model and implement wallet adjustments in expense operations 2026-06-17 10:54:43 -03:00
Jose Selesan
5671b4a14b feat(wallets): add isDefault field to wallet model and implement setDefaultWallet functionality 2026-06-17 10:15:44 -03:00
798d657869 Merge pull request 'feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements' (#4) from feat/wallets into main
Reviewed-on: #4
2026-06-17 12:37:20 +00:00
Jose Selesan
1d954217b7 feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements
- Added createWallet, updateWallet, deleteWallet, depositWallet, transferWallet, and listMovements handlers.
- Created corresponding routes for wallet operations.
- Developed frontend components for wallet management including dialogs for creating, editing, depositing, adjusting balance, transferring, and viewing movements.
- Integrated wallet management into the authenticated routes.
2026-06-17 09:36:55 -03:00
41 changed files with 3484 additions and 44 deletions

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "wallets" (
"id" SERIAL NOT NULL,
"name" VARCHAR(100) NOT NULL,
"currency" VARCHAR(10) NOT NULL,
"balance" DECIMAL(14,4) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallet_movements" (
"id" SERIAL NOT NULL,
"walletId" INTEGER NOT NULL,
"type" VARCHAR(20) NOT NULL,
"amount" DECIMAL(14,4) NOT NULL,
"description" VARCHAR(255),
"referenceWalletId" INTEGER,
"transferGroupId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wallet_movements_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "wallet_movements_walletId_idx" ON "wallet_movements"("walletId");
-- CreateIndex
CREATE INDEX "wallet_movements_transferGroupId_idx" ON "wallet_movements"("transferGroupId");
-- AddForeignKey
ALTER TABLE "wallet_movements" ADD CONSTRAINT "wallet_movements_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

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")
}
model Setting {
key String @id
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("settings")
}
// Expenses
enum PaymentStatus {
@@ -129,6 +138,37 @@ enum PaymentStatus {
PAYED
}
model Wallet {
id Int @id @default(autoincrement())
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")
}
model WalletMovement {
id Int @id @default(autoincrement())
walletId Int
wallet Wallet @relation(fields: [walletId], references: [id])
type String @db.VarChar(20)
amount Decimal @db.Decimal(14, 4)
description String? @db.VarChar(255)
referenceWalletId Int?
transferGroupId String? @db.Uuid
createdAt DateTime @default(now())
@@index([walletId])
@@index([transferGroupId])
@@map("wallet_movements")
}
model PeriodicExpense {
id Int @id @default(autoincrement())
description String @db.VarChar(50)
@@ -157,6 +197,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

@@ -11,7 +11,9 @@ import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
import settingsRouter from "./modules/settings/settings.routes";
import telegramRouter from "./modules/telegram/telegram.router";
import walletsRouter from "./modules/wallets/wallets.routes";
const app = new Hono();
@@ -31,8 +33,10 @@ app.use("/api/*", async (c, next) => {
});
app.route("/api/quotes", quotesRouter);
app.route("/api/settings", settingsRouter);
app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter);
app.route("/api/wallets", walletsRouter);
app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" }));

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,23 +230,65 @@ export async function createNonPeriodicExpense(
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
const belo = await getBeloSellPriceForDate(dueDate);
const result = await prisma.expense.create({
data: {
description: data.description,
amount: data.amount,
amountPayed: data.amount,
dueDate,
paymentDate: dueDate,
year,
month,
status: PaymentStatus.PAYED,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) }
: {}),
},
const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate);
const movementAmount =
isUSDC && data.usdcConversionRate
? -roundTo(data.amount / data.usdcConversionRate, 4)
: -data.amount;
const expenseData: Prisma.ExpenseUncheckedCreateInput = {
description: data.description,
amount: data.amount,
amountPayed: data.amount,
dueDate,
paymentDate: dueDate,
year,
month,
status: PaymentStatus.PAYED,
walletId: data.walletId,
};
if (isUSDC && data.usdcConversionRate) {
expenseData.beloPrice = data.usdcConversionRate;
expenseData.usdcEquivalent = roundTo(
data.amount / data.usdcConversionRate,
6,
);
} else if (belo !== null) {
expenseData.beloPrice = belo;
expenseData.usdcEquivalent = roundTo(data.amount / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.create({ data: expenseData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${data.description}`,
},
}),
]);
cache.invalidateByPrefix("expenses:");
return result;
}
@@ -251,23 +297,62 @@ export async function payExpense(
id: number,
data: z.infer<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 result = await prisma.expense.update({
where: { id },
data: {
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) }
: {}),
},
const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate);
const movementAmount = isUSDC && data.usdcConversionRate
? -roundTo(data.amountPayed / data.usdcConversionRate, 4)
: -data.amountPayed;
const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = {
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate,
walletId: data.walletId,
};
if (isUSDC && data.usdcConversionRate) {
expenseUpdateData.beloPrice = data.usdcConversionRate;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6);
} else if (belo !== null) {
expenseUpdateData.beloPrice = belo;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.update({ where: { id }, data: expenseUpdateData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${expense.description}`,
},
}),
]);
cache.invalidateByPrefix("expenses:");
return result;
}
@@ -289,12 +374,29 @@ export async function updateExpense(
month,
};
let walletAdjustment: {
walletId: number;
amount: number;
description: string;
} | null = null;
if (data.status === "PENDING") {
updateData.status = PaymentStatus.PENDING;
updateData.amountPayed = null;
updateData.paymentDate = null;
updateData.beloPrice = null;
updateData.usdcEquivalent = null;
if (existing.status === PaymentStatus.PAYED && existing.walletId) {
const refundAmount = existing.amountPayed
? Number(existing.amountPayed)
: Number(existing.amount);
walletAdjustment = {
walletId: existing.walletId,
amount: refundAmount,
description: "Anulación de gasto",
};
}
} else if (existing.status === PaymentStatus.PAYED) {
if (data.amountPayed !== undefined) {
updateData.amountPayed = data.amountPayed;
@@ -317,6 +419,51 @@ export async function updateExpense(
updateData.usdcEquivalent = null;
}
}
if (existing.walletId) {
const oldPayed = existing.amountPayed
? Number(existing.amountPayed)
: Number(existing.amount);
const newPayed = data.amountPayed !== undefined
? Number(data.amountPayed)
: Number(data.amount);
if (newPayed !== oldPayed) {
const difference = newPayed - oldPayed;
walletAdjustment = {
walletId: existing.walletId,
amount: -difference,
description: "Ajuste por edición de gasto",
};
}
}
}
if (walletAdjustment) {
const wallet = await prisma.wallet.findUnique({
where: { id: walletAdjustment.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const [result] = await prisma.$transaction([
prisma.expense.update({ where: { id }, data: updateData }),
prisma.wallet.update({
where: { id: walletAdjustment.walletId },
data: { balance: { increment: walletAdjustment.amount } },
}),
prisma.walletMovement.create({
data: {
walletId: walletAdjustment.walletId,
type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL",
amount: walletAdjustment.amount,
description: walletAdjustment.description,
},
}),
]);
cache.invalidateByPrefix("expenses:");
return result;
}
const result = await prisma.expense.update({

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,13 @@
import type { Context } from "hono";
import {
adjustBalance,
adjustBalanceSchema,
} from "../wallets.service";
export async function adjustBalanceHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = adjustBalanceSchema.parse(body);
const result = await adjustBalance(id, parsed);
return c.json(result);
}

View File

@@ -0,0 +1,12 @@
import type { Context } from "hono";
import {
createWallet,
createWalletSchema,
} from "../wallets.service";
export async function createWalletHandler(c: Context) {
const body = await c.req.json();
const parsed = createWalletSchema.parse(body);
const result = await createWallet(parsed);
return c.json(result, 201);
}

View File

@@ -0,0 +1,8 @@
import type { Context } from "hono";
import { deleteWallet } from "../wallets.service";
export async function deleteWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
await deleteWallet(id);
return c.json({ success: true });
}

View File

@@ -0,0 +1,13 @@
import type { Context } from "hono";
import {
depositWallet,
depositSchema,
} from "../wallets.service";
export async function depositWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = depositSchema.parse(body);
const result = await depositWallet(id, parsed);
return c.json(result);
}

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 { listMovements } from "../wallets.service";
export async function listMovementsHandler(c: Context) {
const id = Number(c.req.param("id"));
const result = await listMovements(id);
return c.json(result);
}

View File

@@ -0,0 +1,7 @@
import type { Context } from "hono";
import { listWallets } from "../wallets.service";
export async function listWalletsHandler(c: Context) {
const result = await listWallets();
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

@@ -0,0 +1,12 @@
import type { Context } from "hono";
import {
transferSchema,
transferWallet,
} from "../wallets.service";
export async function transferWalletHandler(c: Context) {
const body = await c.req.json();
const parsed = transferSchema.parse(body);
const result = await transferWallet(parsed);
return c.json(result);
}

View File

@@ -0,0 +1,13 @@
import type { Context } from "hono";
import {
updateWallet,
updateWalletSchema,
} from "../wallets.service";
export async function updateWalletHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = updateWalletSchema.parse(body);
const result = await updateWallet(id, parsed);
return c.json(result);
}

View File

@@ -0,0 +1,26 @@
import { Hono } from "hono";
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);
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

@@ -0,0 +1,243 @@
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({
name: z.string().min(1).max(100),
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(),
});
export const adjustBalanceSchema = z.object({
newBalance: z.number().min(0),
description: z.string().max(255).optional(),
});
export const transferSchema = z.object({
fromWalletId: z.number().int().positive(),
toWalletId: z.number().int().positive(),
outgoingAmount: z.number().positive(),
incomingAmount: z.number().positive(),
description: z.string().max(255).optional(),
});
export type CreateWalletInput = z.infer<typeof createWalletSchema>;
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: [{ isDefault: "desc" }, { createdAt: "desc" }],
});
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
}
export async function createWallet(input: CreateWalletInput) {
const wallet = await prisma.wallet.create({
data: {
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) };
}
export async function updateWallet(id: number, input: UpdateWalletInput) {
const wallet = await prisma.wallet.update({
where: { id },
data: { name: input.name, currency: input.currency },
});
return { ...wallet, balance: Number(wallet.balance) };
}
export async function deleteWallet(id: number) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
if (Number(wallet.balance) !== 0) {
throw new Error("No se puede eliminar una billetera con saldo distinto de cero");
}
await prisma.walletMovement.deleteMany({ where: { walletId: id } });
await prisma.wallet.delete({ where: { id } });
}
export async function depositWallet(id: number, input: DepositInput) {
const [wallet] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: { increment: input.amount } },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "DEPOSIT",
amount: input.amount,
description: input.description ?? null,
},
}),
]);
return { ...wallet, balance: Number(wallet.balance) };
}
export async function adjustBalance(id: number, input: AdjustBalanceInput) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
const currentBalance = Number(wallet.balance);
const difference = input.newBalance - currentBalance;
const [updated] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: input.newBalance },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "ADJUSTMENT",
amount: difference,
description: input.description ?? null,
},
}),
]);
return { ...updated, balance: Number(updated.balance) };
}
export async function transferWallet(input: TransferInput) {
const groupId = crypto.randomUUID();
const [fromWallet, toWallet] = await prisma.$transaction(async (tx) => {
const from = await tx.wallet.update({
where: { id: input.fromWalletId },
data: { balance: { decrement: input.outgoingAmount } },
});
const to = await tx.wallet.update({
where: { id: input.toWalletId },
data: { balance: { increment: input.incomingAmount } },
});
await tx.walletMovement.create({
data: {
walletId: input.fromWalletId,
type: "TRANSFER_OUT",
amount: -input.outgoingAmount,
description: input.description ?? null,
referenceWalletId: input.toWalletId,
transferGroupId: groupId,
},
});
await tx.walletMovement.create({
data: {
walletId: input.toWalletId,
type: "TRANSFER_IN",
amount: input.incomingAmount,
description: input.description ?? null,
referenceWalletId: input.fromWalletId,
transferGroupId: groupId,
},
});
return [from, to];
});
return {
from: { ...fromWallet, balance: Number(fromWallet.balance) },
to: { ...toWallet, balance: Number(toWallet.balance) },
};
}
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 },
orderBy: { createdAt: "desc" },
});
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,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import {
FileText,
Landmark,
LayoutDashboard,
Settings,
TrendingUp,
@@ -11,6 +12,7 @@ import { cn } from "@/lib/utils";
const navItems = [
{ to: "/", label: "Panel", icon: LayoutDashboard },
{ to: "/expenses", label: "Gastos", icon: Wallet },
{ to: "/wallets", label: "Billeteras", icon: Landmark },
{ to: "/quotes", label: "Cotizaciones", icon: FileText },
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp },
];

View File

@@ -9,9 +9,10 @@ import {
import type { Quote } from "@/lib/api";
import {
useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals,
useQuotes,
useSettings,
useWallets,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
@@ -130,17 +131,24 @@ export function DashboardPage() {
const bna = quotes?.find((q) => q.type === "BNA");
const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: settings } = useSettings();
const salaryMultiplier =
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
const isFetching = isLoading || isPending;
const { data: wallets } = useWallets();
const walletBalanceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -165,9 +173,12 @@ export function DashboardPage() {
</p>
</div>
<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">
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
$
{priceFormatter.format(
(belo ? Number(belo.buy) : 0) * Number(salaryMultiplier),
)}
</p>
</div>
<QuoteCard
@@ -189,6 +200,24 @@ export function DashboardPage() {
variant="minimal"
/>
</div>
{wallets && wallets.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold">Billeteras</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{wallets.map((w) => (
<div 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/60 text-xs">
{w.currency}
</p>
<p className="text-2xl font-bold tabular-nums mt-1">
{walletBalanceFormatter.format(w.balance)}
</p>
</div>
))}
</div>
</div>
)}
<DashboardExpensesTable />
</div>
);

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

@@ -0,0 +1,42 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { useWallets } from "@/lib/queries";
import { WalletCard } from "./components/WalletCard";
import { CreateWalletDialog } from "./components/CreateWalletDialog";
export function WalletsPage() {
const { data: wallets, isLoading } = useWallets();
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Billeteras</h1>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
>
<Plus className="size-4" />
Nueva billetera
</button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : wallets && wallets.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{wallets.map((wallet) => (
<WalletCard key={wallet.id} wallet={wallet} />
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No hay billeteras. Creá una para empezar.
</p>
)}
<CreateWalletDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { useAdjustBalance } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const adjustSchema = z.object({
newBalance: z.number().min(0, "El saldo no puede ser negativo"),
description: z.string().max(255).optional(),
});
type AdjustFormValues = z.infer<typeof adjustSchema>;
interface AdjustBalanceDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AdjustBalanceDialog({
wallet,
open,
onOpenChange,
}: AdjustBalanceDialogProps) {
const adjust = useAdjustBalance();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<AdjustFormValues>({
resolver: zodResolver(adjustSchema),
defaultValues: { newBalance: wallet.balance, description: "" },
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: AdjustFormValues) {
await adjust.mutateAsync({
id: wallet.id,
data: {
newBalance: data.newBalance,
description: data.description || undefined,
},
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Ajustar saldo de {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<p className="text-sm text-muted-foreground">
Saldo actual: {wallet.balance}
</p>
<form
id="adjust-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="adjust-balance" className="text-sm font-medium">
Nuevo saldo
</label>
<input
id="adjust-balance"
type="number"
step="0.01"
min="0"
{...register("newBalance", { valueAsNumber: true })}
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.newBalance && (
<span className="text-xs text-destructive">
{errors.newBalance.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="adjust-desc" className="text-sm font-medium">
Motivo (opcional)
</label>
<input
id="adjust-desc"
{...register("description")}
placeholder="Ej: Corrección"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="adjust-form" disabled={isSubmitting}>
Ajustar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,165 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCreateWallet } from "@/lib/queries";
const createWalletSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio").max(100),
currency: z.string().min(1, "La moneda es obligatoria"),
initialBalance: z.number().nonnegative(),
});
type CreateWalletFormValues = z.infer<typeof createWalletSchema>;
interface CreateWalletDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const commonCurrencies = ["ARS", "USD", "USDC", "EUR", "BRL", "USDT"];
export function CreateWalletDialog({
open,
onOpenChange,
}: CreateWalletDialogProps) {
const createWallet = useCreateWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm<CreateWalletFormValues>({
resolver: zodResolver(createWalletSchema),
defaultValues: { name: "", currency: "", initialBalance: 0 },
});
const currency = watch("currency");
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: CreateWalletFormValues) {
await createWallet.mutateAsync({
name: data.name,
currency: data.currency,
initialBalance: data.initialBalance,
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Nueva billetera</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="create-wallet-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="name" className="text-sm font-medium">
Nombre
</label>
<input
id="name"
{...register("name")}
placeholder="Ej: Mercado Pago"
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.name && (
<span className="text-xs text-destructive">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="currency" className="text-sm font-medium">
Moneda
</label>
<Select
value={currency}
onValueChange={(v) => setValue("currency", v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Seleccionar moneda" />
</SelectTrigger>
<SelectContent>
{commonCurrencies.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency && (
<span className="text-xs text-destructive">
{errors.currency.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="initialBalance" className="text-sm font-medium">
Saldo inicial
</label>
<input
id="initialBalance"
type="number"
step="0.01"
min="0"
{...register("initialBalance", { valueAsNumber: true })}
placeholder="0"
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.initialBalance && (
<span className="text-xs text-destructive">
{errors.initialBalance.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="create-wallet-form"
disabled={isSubmitting}
>
Crear
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,128 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { useDepositWallet } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const depositSchema = z.object({
amount: z.number().positive("El monto debe ser mayor a 0"),
description: z.string().max(255).optional(),
});
type DepositFormValues = z.infer<typeof depositSchema>;
interface DepositDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DepositDialog({
wallet,
open,
onOpenChange,
}: DepositDialogProps) {
const deposit = useDepositWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<DepositFormValues>({
resolver: zodResolver(depositSchema),
defaultValues: { amount: 0, description: "" },
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: DepositFormValues) {
await deposit.mutateAsync({
id: wallet.id,
data: {
amount: data.amount,
description: data.description || undefined,
},
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Depositar en {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="deposit-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="deposit-amount" className="text-sm font-medium">
Monto
</label>
<input
id="deposit-amount"
type="number"
step="0.01"
min="0"
{...register("amount", { valueAsNumber: true })}
placeholder="1000"
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.amount && (
<span className="text-xs text-destructive">
{errors.amount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="deposit-desc" className="text-sm font-medium">
Descripción (opcional)
</label>
<input
id="deposit-desc"
{...register("description")}
placeholder="Ej: Sueldo"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="deposit-form" disabled={isSubmitting}>
Depositar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,142 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUpdateWallet } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const editWalletSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio").max(100),
currency: z.string().min(1, "La moneda es obligatoria"),
});
type EditWalletFormValues = z.infer<typeof editWalletSchema>;
interface EditWalletDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const commonCurrencies = ["ARS", "USD", "USDC", "EUR", "BRL", "USDT"];
export function EditWalletDialog({
wallet,
open,
onOpenChange,
}: EditWalletDialogProps) {
const updateWallet = useUpdateWallet();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm<EditWalletFormValues>({
resolver: zodResolver(editWalletSchema),
defaultValues: { name: wallet.name, currency: wallet.currency },
});
const currency = watch("currency");
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: EditWalletFormValues) {
await updateWallet.mutateAsync({ id: wallet.id, data });
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Editar billetera</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form
id="edit-wallet-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-name" className="text-sm font-medium">
Nombre
</label>
<input
id="edit-name"
{...register("name")}
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.name && (
<span className="text-xs text-destructive">
{errors.name.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-currency" className="text-sm font-medium">
Moneda
</label>
<Select
value={currency}
onValueChange={(v) => setValue("currency", v)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{commonCurrencies.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.currency && (
<span className="text-xs text-destructive">
{errors.currency.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="edit-wallet-form"
disabled={isSubmitting}
>
Guardar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,211 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTransferWallet, useWallets } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
const transferSchema = z.object({
toWalletId: z.number().positive("Seleccioná una billetera destino"),
outgoingAmount: z.number().positive("El monto saliente debe ser mayor a 0"),
incomingAmount: z.number().positive("El monto entrante debe ser mayor a 0"),
description: z.string().max(255).optional(),
});
type TransferFormValues = z.infer<typeof transferSchema>;
interface TransferDialogProps {
sourceWallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TransferDialog({
sourceWallet,
open,
onOpenChange,
}: TransferDialogProps) {
const { data: wallets } = useWallets();
const transfer = useTransferWallet();
const otherWallets =
wallets?.filter((w) => w.id !== sourceWallet.id) ?? [];
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<TransferFormValues>({
resolver: zodResolver(transferSchema),
defaultValues: {
toWalletId: 0,
outgoingAmount: 0,
incomingAmount: 0,
description: "",
},
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: TransferFormValues) {
await transfer.mutateAsync({
fromWalletId: sourceWallet.id,
toWalletId: data.toWalletId,
outgoingAmount: data.outgoingAmount,
incomingAmount: data.incomingAmount,
description: data.description || undefined,
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Transferir desde {sourceWallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<p className="text-sm text-muted-foreground">
Saldo disponible: {sourceWallet.balance}
</p>
<form
id="transfer-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Billetera destino</label>
<Controller
control={control}
name="toWalletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="Seleccionar destino" />
</SelectTrigger>
<SelectContent>
{otherWallets.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.toWalletId && (
<span className="text-xs text-destructive">
{errors.toWalletId.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="transfer-outgoing"
className="text-sm font-medium"
>
Monto saliente (de {sourceWallet.name})
</label>
<input
id="transfer-outgoing"
type="number"
step="0.01"
min="0"
{...register("outgoingAmount", { valueAsNumber: true })}
placeholder="1000"
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.outgoingAmount && (
<span className="text-xs text-destructive">
{errors.outgoingAmount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="transfer-incoming"
className="text-sm font-medium"
>
Monto entrante (en destino)
</label>
<input
id="transfer-incoming"
type="number"
step="0.01"
min="0"
{...register("incomingAmount", { valueAsNumber: true })}
placeholder="950"
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.incomingAmount && (
<span className="text-xs text-destructive">
{errors.incomingAmount.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Si hay comisiones o diferencia de cotización, el monto entrante
puede ser menor al saliente.
</span>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="transfer-desc" className="text-sm font-medium">
Descripción (opcional)
</label>
<input
id="transfer-desc"
{...register("description")}
placeholder="Ej: Transferencia a Binance"
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.description && (
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="transfer-form" disabled={isSubmitting}>
Transferir
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,185 @@
import { useState } from "react";
import {
ArrowDownToLine,
ArrowLeftRight,
ArrowUpDown,
History,
PencilLine,
Star,
} from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
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 { EditWalletDialog } from "./EditWalletDialog";
const balanceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
interface WalletCardProps {
wallet: Wallet;
}
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 [editOpen, setEditOpen] = useState(false);
return (
<>
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<p className="font-medium">{wallet.name}</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)}
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<PencilLine className="size-4" />
</button>
</div>
</div>
<p className="text-2xl font-bold tabular-nums">
{balanceFormatter.format(wallet.balance)}
</p>
<div className="flex gap-1.5 flex-wrap">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Depositar</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setAdjustOpen(true)}
>
<ArrowUpDown className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Ajustar saldo</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setTransferOpen(true)}
>
<ArrowLeftRight className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Transferir</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() =>
navigate({ to: "/wallets/movements", search: { walletId: wallet.id } })
}
>
<History className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Movimientos</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<DepositDialog
wallet={wallet}
open={depositOpen}
onOpenChange={setDepositOpen}
/>
<AdjustBalanceDialog
wallet={wallet}
open={adjustOpen}
onOpenChange={setAdjustOpen}
/>
<TransferDialog
sourceWallet={wallet}
open={transferOpen}
onOpenChange={setTransferOpen}
/>
<EditWalletDialog
wallet={wallet}
open={editOpen}
onOpenChange={setEditOpen}
/>
</>
);
}

View File

@@ -0,0 +1,101 @@
import { useWalletMovements } from "@/lib/queries";
import type { Wallet } from "@/lib/api";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
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",
};
interface WalletMovementsDialogProps {
wallet: Wallet;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WalletMovementsDialog({
wallet,
open,
onOpenChange,
}: WalletMovementsDialogProps) {
const { data: movements, isLoading } = useWalletMovements(
open ? wallet.id : 0,
);
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
Movimientos de {wallet.name}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : movements && movements.length > 0 ? (
<div className="max-h-96 space-y-1 overflow-y-auto">
{movements.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md p-2 text-sm hover:bg-muted/50"
>
<div className="flex flex-col">
<span className="font-medium">{typeLabels[m.type] ?? m.type}</span>
{m.description && (
<span className="text-xs text-muted-foreground">
{m.description}
</span>
)}
{m.transferGroupId && (
<span className="text-xs text-muted-foreground font-mono">
ID: {m.transferGroupId.slice(0, 8)}...
</span>
)}
<span className="text-xs text-muted-foreground">
{dateFormatter.format(new Date(m.createdAt))}
</span>
</div>
<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>
) : (
<p className="text-sm text-muted-foreground">
Sin movimientos registrados.
</p>
)}
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

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 = {
@@ -320,6 +324,144 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
});
}
// Wallets
export type Wallet = {
id: number;
name: string;
currency: string;
balance: number;
isDefault: boolean;
createdAt: string;
updatedAt: string;
};
export type WalletMovement = {
id: number;
walletId: number;
type:
| "DEPOSIT"
| "WITHDRAWAL"
| "TRANSFER_IN"
| "TRANSFER_OUT"
| "ADJUSTMENT";
amount: number;
description: string | null;
referenceWalletId: number | null;
transferGroupId: string | null;
createdAt: string;
};
export type CreateWalletInput = {
name: string;
currency: string;
initialBalance?: number;
};
export type DepositInput = {
amount: number;
description?: string;
};
export type AdjustBalanceInput = {
newBalance: number;
description?: string;
};
export type TransferInput = {
fromWalletId: number;
toWalletId: number;
outgoingAmount: number;
incomingAmount: number;
description?: string;
};
export function getWallets(): Promise<Wallet[]> {
return fetcher<Wallet[]>("/api/wallets");
}
export function createWallet(data: CreateWalletInput): Promise<Wallet> {
return mutator<Wallet>("/api/wallets", "POST", data);
}
export function updateWallet(
id: number,
data: { name: string; currency: string },
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}`, "PUT", data);
}
export function deleteWallet(id: number): Promise<void> {
return mutator<void>(`/api/wallets/${id}`, "DELETE");
}
export function depositWallet(id: number, data: DepositInput): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/deposit`, "POST", data);
}
export function adjustBalance(
id: number,
data: AdjustBalanceInput,
): Promise<Wallet> {
return mutator<Wallet>(`/api/wallets/${id}/adjust`, "POST", data);
}
export function transferWallet(
data: TransferInput,
): Promise<{ from: Wallet; to: Wallet }> {
return mutator<{ from: Wallet; to: Wallet }>(
"/api/wallets/transfer",
"POST",
data,
);
}
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}` : ""}`,
);
}
// 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
export type TelegramStatus = {

View File

@@ -5,17 +5,24 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
type AdjustBalanceInput,
adjustBalance as adjustBalanceApi,
type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput,
type CreateWalletInput,
createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi,
createWallet as createWalletApi,
type DepositInput,
deletePeriodicExpense as deletePeriodicExpenseApi,
deleteWallet as deleteWalletApi,
depositWallet as depositWalletApi,
fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi,
generateTelegramCode,
getDailyMinMax,
getDailyQuotes,
getExpenses,
generateTelegramCode,
getHistoricalMinMax,
getMonthlyPayedTotal,
getMonthlyTotals,
@@ -23,15 +30,25 @@ import {
getPeriodicExpenses,
getQuoteHistory,
getQuotes,
getSettings,
getTelegramStatus,
getTotalPending,
getWalletMovements,
getWalletMovementsFiltered,
getWallets,
importExpenses,
importPeriodicExpenses,
type PayExpenseInput,
payExpense as payExpenseApi,
setDefaultWallet as setDefaultWalletApi,
type TransferInput,
transferWallet as transferWalletApi,
type UpdateExpenseInput,
updateExpense as updateExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
updateSetting as updateSettingApi,
updateWallet as updateWalletApi,
type WalletMovementsFilter,
} from "./api";
export const quoteKeys = {
@@ -249,6 +266,7 @@ export function useCreateNonPeriodicExpense() {
createNonPeriodicExpenseApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
@@ -280,6 +298,7 @@ export function usePayExpense() {
payExpenseApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
@@ -295,6 +314,141 @@ export function useUpdateExpense() {
});
}
// Wallets
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() {
return useQuery({
queryKey: walletKeys.all,
queryFn: getWallets,
staleTime: 30_000,
});
}
export function useCreateWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWalletInput) => createWalletApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: { name: string; currency: string };
}) => updateWalletApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useDeleteWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteWalletApi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
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({
mutationFn: ({ id, data }: { id: number; data: DepositInput }) =>
depositWalletApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useAdjustBalance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: AdjustBalanceInput }) =>
adjustBalanceApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useTransferWallet() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TransferInput) => transferWalletApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useWalletMovements(id: number) {
return useQuery({
queryKey: walletKeys.movements(id),
queryFn: () => getWalletMovements(id),
});
}
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
export function useTelegramStatus() {

View File

@@ -1,12 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
import {
useGenerateTelegramCode,
useSettings,
useTelegramStatus,
useUpdateSetting,
} from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({
@@ -126,11 +128,79 @@ function SettingsPage() {
<hr className="border-border" />
<SalaryMultiplierSection />
<TelegramSection />
</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() {
const { data: status, isLoading } = useTelegramStatus();
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

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated/wallets")({
component: WalletsLayout,
});
function WalletsLayout() {
return <Outlet />;
}