Compare commits
14 Commits
7c864053bb
...
feat/payme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b3f87aca | ||
|
|
33120deed6 | ||
|
|
5671b4a14b | ||
| 798d657869 | |||
|
|
1d954217b7 | ||
|
|
1ff2abc16a | ||
|
|
b8d8f16f4e | ||
|
|
c0c8bf0945 | ||
|
|
142fd4e33f | ||
|
|
b08399908b | ||
|
|
6f06ee18da | ||
|
|
b4f4030ee8 | ||
|
|
824f3a18ab | ||
|
|
0915ad1223 |
@@ -14,11 +14,12 @@
|
||||
"lint:fix": "biome check --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "^1.6.11",
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@personal-admin/common": "workspace:*",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"better-auth": "^1.6.11",
|
||||
"grammy": "^1.44.0",
|
||||
"hono": "^4.7.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.21.0",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY,
|
||||
ADD COLUMN "usdcEquivalent" DECIMAL(12,6);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "telegram_chats" (
|
||||
"id" TEXT NOT NULL,
|
||||
"chatId" BIGINT,
|
||||
"code" TEXT NOT NULL,
|
||||
"validatedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "telegram_chats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "telegram_daily_max_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"quoteType" "QuoteType" NOT NULL,
|
||||
"maxValue" MONEY NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "telegram_daily_max_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "telegram_chats_chatId_key" ON "telegram_chats"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "telegram_chats_code_key" ON "telegram_chats"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "telegram_daily_max_notifications_date_quoteType_key" ON "telegram_daily_max_notifications"("date", "quoteType");
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -69,6 +69,28 @@ model Verification {
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
model TelegramChat {
|
||||
id String @id @default(cuid())
|
||||
chatId BigInt? @unique
|
||||
code String @unique
|
||||
validatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("telegram_chats")
|
||||
}
|
||||
|
||||
model TelegramDailyMaxNotification {
|
||||
id String @id @default(cuid())
|
||||
date DateTime
|
||||
quoteType QuoteType
|
||||
maxValue Decimal @db.Money
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([date, quoteType])
|
||||
@@map("telegram_daily_max_notifications")
|
||||
}
|
||||
|
||||
enum QuoteType {
|
||||
BLUE
|
||||
BNA
|
||||
@@ -107,6 +129,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)
|
||||
@@ -130,9 +183,14 @@ model Expense {
|
||||
month Int
|
||||
amount Decimal @db.Money
|
||||
amountPayed Decimal? @db.Money
|
||||
beloPrice Decimal? @db.Money
|
||||
usdcEquivalent Decimal? @db.Decimal(12, 6)
|
||||
status PaymentStatus @default(PENDING)
|
||||
dueDate DateTime
|
||||
paymentDate DateTime?
|
||||
walletId Int?
|
||||
wallet Wallet? @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@index([walletId])
|
||||
@@map("expenses")
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import { cache } from "./lib/cache";
|
||||
import { eventBus } from "./lib/event-bus";
|
||||
import { logger } from "./lib/logger";
|
||||
import { requestLogger } from "./lib/request-logger";
|
||||
import { startBot } from "./lib/telegram";
|
||||
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 telegramRouter from "./modules/telegram/telegram.router";
|
||||
import walletsRouter from "./modules/wallets/wallets.routes";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -30,6 +33,8 @@ app.use("/api/*", async (c, next) => {
|
||||
|
||||
app.route("/api/quotes", quotesRouter);
|
||||
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" }));
|
||||
@@ -40,6 +45,7 @@ eventBus.on("quotes-updated", () => {
|
||||
|
||||
startQuoteJob();
|
||||
startExpenseJob();
|
||||
startBot();
|
||||
|
||||
logger.info("Backend started");
|
||||
|
||||
|
||||
55
apps/backend/src/lib/telegram.ts
Normal file
55
apps/backend/src/lib/telegram.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Bot } from "grammy";
|
||||
import { logger } from "./logger";
|
||||
import { validateCode } from "../modules/telegram/telegram.service";
|
||||
|
||||
const token = process.env.BOT_TOKEN;
|
||||
|
||||
let botInstance: Bot | null = null;
|
||||
let botUsername: string | null = null;
|
||||
|
||||
export function getBot(): Bot | null {
|
||||
return botInstance;
|
||||
}
|
||||
|
||||
export function getBotUsername(): string | null {
|
||||
return botUsername;
|
||||
}
|
||||
|
||||
export async function startBot(): Promise<void> {
|
||||
if (!token) {
|
||||
logger.warn("BOT_TOKEN not set, Telegram bot not started");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bot = new Bot(token);
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
await ctx.reply(
|
||||
"🔗 Enviá el código de verificación que aparece en la Configuración de la app para conectar tu cuenta de Telegram.\n\nUna vez conectado, recibirás notificaciones cuando BELO alcance el máximo del día.",
|
||||
);
|
||||
});
|
||||
|
||||
bot.on("message:text", async (ctx) => {
|
||||
const text = ctx.message.text.trim();
|
||||
if (text.startsWith("/")) return;
|
||||
|
||||
const success = await validateCode(BigInt(ctx.chat.id), text);
|
||||
if (success) {
|
||||
await ctx.reply("✅ ¡Conectado! Recibirás notificaciones de BELO aquí.");
|
||||
} else {
|
||||
await ctx.reply("❌ Código inválido o ya utilizado.");
|
||||
}
|
||||
});
|
||||
|
||||
const botInfo = await bot.api.getMe();
|
||||
botUsername = botInfo.username;
|
||||
|
||||
bot.start();
|
||||
botInstance = bot;
|
||||
|
||||
logger.info({ botUsername }, "Telegram bot started");
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to start Telegram bot");
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,13 @@ export function startOfToday(): Date {
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function parseLocalDate(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function startOfNextLocalDay(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, m - 1, d + 1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { PaymentStatus } 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";
|
||||
|
||||
export const createPeriodicExpenseSchema = z.object({
|
||||
description: z.string().min(1).max(50),
|
||||
@@ -16,16 +17,31 @@ 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({
|
||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||
dueDate: z.string().datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
||||
dueDate: z
|
||||
.string()
|
||||
.datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
||||
status: z.enum(["PENDING"]).optional(),
|
||||
amountPayed: z
|
||||
.number()
|
||||
.positive("El monto pagado debe ser mayor a 0")
|
||||
.optional(),
|
||||
paymentDate: z
|
||||
.string()
|
||||
.datetime({ message: "La fecha debe ser una fecha ISO válida" })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function lastDayOfMonth(year: number, month: number): number {
|
||||
@@ -46,6 +62,62 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } {
|
||||
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getUTCFullYear() === b.getUTCFullYear() &&
|
||||
a.getUTCMonth() === b.getUTCMonth() &&
|
||||
a.getUTCDate() === b.getUTCDate()
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBeloSellPriceForDate(
|
||||
paymentDate: Date,
|
||||
): Promise<number | null> {
|
||||
const now = new Date();
|
||||
|
||||
if (isSameDay(paymentDate, now)) {
|
||||
const quote = await prisma.quote.findFirst({
|
||||
where: { type: QuoteType.BELO },
|
||||
});
|
||||
return quote ? Number(quote.sell) : null;
|
||||
}
|
||||
|
||||
const target = new Date(
|
||||
Date.UTC(
|
||||
paymentDate.getUTCFullYear(),
|
||||
paymentDate.getUTCMonth(),
|
||||
paymentDate.getUTCDate(),
|
||||
17, 0, 0, 0,
|
||||
),
|
||||
);
|
||||
|
||||
const before = await prisma.quoteHistory.findFirst({
|
||||
where: {
|
||||
type: QuoteType.BELO,
|
||||
timeStamp: { lte: target },
|
||||
},
|
||||
orderBy: { timeStamp: "desc" },
|
||||
});
|
||||
|
||||
const after = await prisma.quoteHistory.findFirst({
|
||||
where: {
|
||||
type: QuoteType.BELO,
|
||||
timeStamp: { gte: target },
|
||||
},
|
||||
orderBy: { timeStamp: "asc" },
|
||||
});
|
||||
|
||||
if (before && after) {
|
||||
const beforeDiff = Math.abs(before.timeStamp.getTime() - target.getTime());
|
||||
const afterDiff = Math.abs(after.timeStamp.getTime() - target.getTime());
|
||||
return beforeDiff <= afterDiff ? Number(before.sell) : Number(after.sell);
|
||||
}
|
||||
|
||||
if (before) return Number(before.sell);
|
||||
if (after) return Number(after.sell);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listPeriodicExpenses() {
|
||||
const cached = cache.get("expenses:periodic");
|
||||
if (cached) return cached;
|
||||
@@ -157,8 +229,28 @@ export async function createNonPeriodicExpense(
|
||||
const dueDate = new Date(data.dueDate);
|
||||
const year = dueDate.getUTCFullYear();
|
||||
const month = dueDate.getUTCMonth() + 1;
|
||||
const result = await prisma.expense.create({
|
||||
data: {
|
||||
|
||||
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,
|
||||
@@ -167,8 +259,36 @@ export async function createNonPeriodicExpense(
|
||||
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;
|
||||
}
|
||||
@@ -177,17 +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 result = await prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -197,22 +362,113 @@ export async function updateExpense(
|
||||
data: z.infer<typeof updateExpenseSchema>,
|
||||
) {
|
||||
const existing = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
||||
if (existing.status !== PaymentStatus.PENDING) {
|
||||
throw new Error("Solo se pueden editar gastos pendientes");
|
||||
}
|
||||
|
||||
const dueDate = new Date(data.dueDate);
|
||||
const year = dueDate.getUTCFullYear();
|
||||
const month = dueDate.getUTCMonth() + 1;
|
||||
|
||||
const result = await prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const updateData: Prisma.ExpenseUncheckedUpdateInput = {
|
||||
amount: data.amount,
|
||||
dueDate,
|
||||
year,
|
||||
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;
|
||||
}
|
||||
if (data.paymentDate !== undefined) {
|
||||
updateData.paymentDate = new Date(data.paymentDate);
|
||||
}
|
||||
|
||||
const resolvedPaymentDate = updateData.paymentDate instanceof Date
|
||||
? updateData.paymentDate
|
||||
: (existing.paymentDate ?? undefined);
|
||||
const amountPayed = updateData.amountPayed ?? existing.amountPayed;
|
||||
if (resolvedPaymentDate && amountPayed !== undefined && amountPayed !== null) {
|
||||
const belo = await getBeloSellPriceForDate(resolvedPaymentDate);
|
||||
if (belo !== null) {
|
||||
updateData.beloPrice = belo;
|
||||
updateData.usdcEquivalent = roundTo(Number(amountPayed) / belo, 6);
|
||||
} else {
|
||||
updateData.beloPrice = null;
|
||||
updateData.usdcEquivalent = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.walletId) {
|
||||
const oldPayed = existing.amountPayed
|
||||
? Number(existing.amountPayed)
|
||||
: Number(existing.amount);
|
||||
const newPayed = data.amountPayed !== undefined
|
||||
? Number(data.amountPayed)
|
||||
: Number(data.amount);
|
||||
|
||||
if (newPayed !== oldPayed) {
|
||||
const difference = newPayed - oldPayed;
|
||||
walletAdjustment = {
|
||||
walletId: existing.walletId,
|
||||
amount: -difference,
|
||||
description: "Ajuste por edición de gasto",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (walletAdjustment) {
|
||||
const wallet = await prisma.wallet.findUnique({
|
||||
where: { id: walletAdjustment.walletId },
|
||||
});
|
||||
if (!wallet) {
|
||||
throw new Error("Billetera no encontrada");
|
||||
}
|
||||
|
||||
const [result] = await prisma.$transaction([
|
||||
prisma.expense.update({ where: { id }, data: updateData }),
|
||||
prisma.wallet.update({
|
||||
where: { id: walletAdjustment.walletId },
|
||||
data: { balance: { increment: walletAdjustment.amount } },
|
||||
}),
|
||||
prisma.walletMovement.create({
|
||||
data: {
|
||||
walletId: walletAdjustment.walletId,
|
||||
type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL",
|
||||
amount: walletAdjustment.amount,
|
||||
description: walletAdjustment.description,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
cache.invalidateByPrefix("expenses:");
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = await prisma.expense.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
cache.invalidateByPrefix("expenses:");
|
||||
return result;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Context } from "hono";
|
||||
import type { QuoteType } from "../../../generated/prisma/client";
|
||||
import { cache } from "../../../lib/cache";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
|
||||
|
||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||
|
||||
@@ -22,11 +23,9 @@ export async function getDailyMinMax(c: Context) {
|
||||
|
||||
const now = new Date();
|
||||
const startDate = startDateParam
|
||||
? new Date(startDateParam)
|
||||
? parseLocalDate(startDateParam)
|
||||
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endDate = endDateParam
|
||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||
: now;
|
||||
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
|
||||
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Context } from "hono";
|
||||
import { cache } from "../../../lib/cache";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
|
||||
|
||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||
|
||||
@@ -21,11 +22,9 @@ export async function getDailyQuotes(c: Context) {
|
||||
|
||||
const now = new Date();
|
||||
const startDate = startDateParam
|
||||
? new Date(startDateParam)
|
||||
? parseLocalDate(startDateParam)
|
||||
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endDate = endDateParam
|
||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||
: now;
|
||||
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
|
||||
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Context } from "hono";
|
||||
import type { QuoteType } from "../../../generated/prisma/client";
|
||||
import { cache } from "../../../lib/cache";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
|
||||
|
||||
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||
|
||||
@@ -23,11 +24,9 @@ export async function getQuoteHistory(c: Context) {
|
||||
|
||||
const now = new Date();
|
||||
const startDate = startDateParam
|
||||
? new Date(startDateParam)
|
||||
? parseLocalDate(startDateParam)
|
||||
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endDate = endDateParam
|
||||
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||
: now;
|
||||
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
|
||||
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import cron from "node-cron";
|
||||
import { eventBus } from "../../lib/event-bus";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { checkAndNotifyBeloMax } from "../telegram/telegram.service";
|
||||
import { processBeloQuote } from "./belo.service";
|
||||
import { processBlueQuote } from "./blue.service";
|
||||
import { processBnaQuote } from "./bna.service";
|
||||
@@ -12,6 +13,9 @@ async function safeProcess(
|
||||
try {
|
||||
await fn();
|
||||
logger.info(`${name} quote processed successfully`);
|
||||
if (name === "BELO") {
|
||||
await checkAndNotifyBeloMax();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Error processing ${name} quote`);
|
||||
}
|
||||
|
||||
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal file
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Context } from "hono";
|
||||
import { generateCode, getTelegramStatus } from "./telegram.service";
|
||||
|
||||
export async function generateCodeHandler(c: Context) {
|
||||
try {
|
||||
const result = await generateCode();
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
return c.json({ error: "Failed to generate code" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function telegramStatusHandler(c: Context) {
|
||||
try {
|
||||
const status = await getTelegramStatus();
|
||||
return c.json(status);
|
||||
} catch (error) {
|
||||
return c.json({ connected: false, botUsername: null }, 500);
|
||||
}
|
||||
}
|
||||
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal file
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { generateCodeHandler, telegramStatusHandler } from "./telegram.handler";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/code", generateCodeHandler);
|
||||
app.get("/status", telegramStatusHandler);
|
||||
|
||||
export default app;
|
||||
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal file
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import crypto from "crypto";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { getBot, getBotUsername } from "../../lib/telegram";
|
||||
|
||||
function generateRandomCode(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const bytes = crypto.randomBytes(8);
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return price.toLocaleString("es-AR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateCode(): Promise<{
|
||||
code: string;
|
||||
botUsername: string | null;
|
||||
}> {
|
||||
const code = generateRandomCode();
|
||||
|
||||
await prisma.telegramChat.create({
|
||||
data: { code },
|
||||
});
|
||||
|
||||
return { code, botUsername: getBotUsername() };
|
||||
}
|
||||
|
||||
export async function validateCode(
|
||||
chatId: bigint,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const record = await prisma.telegramChat.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!record || record.validatedAt !== null) return false;
|
||||
|
||||
await prisma.telegramChat.deleteMany({
|
||||
where: { chatId, validatedAt: { not: null } },
|
||||
});
|
||||
|
||||
await prisma.telegramChat.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
chatId,
|
||||
validatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getTelegramStatus(): Promise<{
|
||||
botUsername: string | null;
|
||||
connected: boolean;
|
||||
}> {
|
||||
const validated = await prisma.telegramChat.findFirst({
|
||||
where: { validatedAt: { not: null } },
|
||||
});
|
||||
|
||||
return {
|
||||
botUsername: getBotUsername(),
|
||||
connected: validated !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkAndNotifyBeloMax(): Promise<void> {
|
||||
const chat = await prisma.telegramChat.findFirst({
|
||||
where: { validatedAt: { not: null } },
|
||||
orderBy: { validatedAt: "desc" },
|
||||
});
|
||||
|
||||
if (!chat?.chatId) return;
|
||||
|
||||
const bot = getBot();
|
||||
if (!bot) return;
|
||||
|
||||
const now = new Date();
|
||||
const artHour = ((now.getUTCHours() - 3) % 24 + 24) % 24;
|
||||
if (artHour < 8 || artHour >= 20) return;
|
||||
|
||||
const currentQuote = await prisma.quote.findFirst({
|
||||
where: { type: "BELO" },
|
||||
orderBy: { timeStamp: "desc" },
|
||||
});
|
||||
if (!currentQuote) return;
|
||||
|
||||
const currentBuy = Number(currentQuote.buy);
|
||||
|
||||
const artOffset = -3;
|
||||
const artMs = now.getTime() + artOffset * 3600000;
|
||||
const artDate = new Date(artMs);
|
||||
const todayStart = new Date(
|
||||
Date.UTC(
|
||||
artDate.getUTCFullYear(),
|
||||
artDate.getUTCMonth(),
|
||||
artDate.getUTCDate(),
|
||||
),
|
||||
);
|
||||
|
||||
const lastNotified = await prisma.telegramDailyMaxNotification.findUnique({
|
||||
where: {
|
||||
date_quoteType: {
|
||||
date: todayStart,
|
||||
quoteType: "BELO",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lastNotifiedValue = lastNotified ? Number(lastNotified.maxValue) : 0;
|
||||
|
||||
if (currentBuy <= lastNotifiedValue) return;
|
||||
|
||||
const formattedPrice = formatPrice(currentBuy);
|
||||
const message = `BELO alcanzó el máximo del día, la cotización actual es de $${formattedPrice}`;
|
||||
|
||||
try {
|
||||
await bot.api.sendMessage(Number(chat.chatId), message);
|
||||
|
||||
await prisma.telegramDailyMaxNotification.upsert({
|
||||
where: {
|
||||
date_quoteType: {
|
||||
date: todayStart,
|
||||
quoteType: "BELO",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
date: todayStart,
|
||||
quoteType: "BELO",
|
||||
maxValue: currentBuy,
|
||||
},
|
||||
update: {
|
||||
maxValue: currentBuy,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Telegram BELO max notification sent: $${formattedPrice}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to send Telegram BELO max notification");
|
||||
}
|
||||
}
|
||||
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal 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);
|
||||
}
|
||||
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal 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);
|
||||
}
|
||||
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal 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);
|
||||
}
|
||||
26
apps/backend/src/modules/wallets/wallets.routes.ts
Normal file
26
apps/backend/src/modules/wallets/wallets.routes.ts
Normal 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;
|
||||
243
apps/backend/src/modules/wallets/wallets.service.ts
Normal file
243
apps/backend/src/modules/wallets/wallets.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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,19 @@ const mockPrisma = {
|
||||
update: mock(),
|
||||
count: mock(),
|
||||
},
|
||||
wallet: {
|
||||
findUnique: mockWalletFindUnique,
|
||||
update: mockWalletUpdate,
|
||||
},
|
||||
walletMovement: {
|
||||
create: mockMovementCreate,
|
||||
},
|
||||
quote: {
|
||||
findFirst: mock(),
|
||||
},
|
||||
quoteHistory: {
|
||||
findFirst: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("../src/lib/prisma", () => ({
|
||||
@@ -24,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>>,
|
||||
)) {
|
||||
@@ -40,6 +62,7 @@ const {
|
||||
updateExpenseSchema,
|
||||
buildDueDate,
|
||||
getCurrentYearMonthUTC,
|
||||
getBeloSellPriceForDate,
|
||||
listPeriodicExpenses,
|
||||
createPeriodicExpense,
|
||||
updatePeriodicExpense,
|
||||
@@ -148,6 +171,7 @@ describe("schemas", () => {
|
||||
description: "Ropa",
|
||||
amount: 2500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
walletId: 1,
|
||||
});
|
||||
expect(result.description).toBe("Ropa");
|
||||
});
|
||||
@@ -168,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();
|
||||
});
|
||||
@@ -400,6 +428,7 @@ describe("createNonPeriodicExpense", () => {
|
||||
description: "Ropa",
|
||||
amount: 2500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
walletId: 1,
|
||||
};
|
||||
const created = {
|
||||
id: 1,
|
||||
@@ -410,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);
|
||||
@@ -429,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,
|
||||
@@ -455,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);
|
||||
});
|
||||
@@ -471,6 +531,8 @@ describe("updateExpense", () => {
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
amountPayed: null,
|
||||
paymentDate: null,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
@@ -486,18 +548,82 @@ describe("updateExpense", () => {
|
||||
expect(result.month).toBe(7);
|
||||
});
|
||||
|
||||
test("rejects update for PAYED expense", async () => {
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
test("allows updating a PAYED expense", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 1500,
|
||||
amountPayed: 1500,
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
paymentDate: new Date("2026-06-10T00: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: 2000,
|
||||
dueDate: "2026-07-20T00:00:00.000Z",
|
||||
amountPayed: 1800,
|
||||
});
|
||||
expect(result.amount).toBe(2000);
|
||||
expect(result.year).toBe(2026);
|
||||
expect(result.month).toBe(7);
|
||||
expect(result.amountPayed).toBe(1800);
|
||||
});
|
||||
|
||||
expect(
|
||||
updateExpense(1, {
|
||||
amount: 100,
|
||||
dueDate: "2026-07-15T00:00:00.000Z",
|
||||
}),
|
||||
).rejects.toThrow("Solo se pueden editar gastos pendientes");
|
||||
test("reverting a PAYED expense to PENDING clears payment info", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 1500,
|
||||
amountPayed: 1500,
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
paymentDate: new Date("2026-06-10T00: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: 1500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||
});
|
||||
|
||||
test("sets amountPayed and paymentDate to null when reverting to PENDING", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 1500,
|
||||
amountPayed: 1500,
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
paymentDate: new Date("2026-06-10T00: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: 1500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.amountPayed).toBeNull();
|
||||
expect(result.paymentDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -597,3 +723,708 @@ describe("generateMonthlyExpense", () => {
|
||||
expect(result).toEqual(existing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBeloSellPriceForDate", () => {
|
||||
test("returns current BELO sell price when paymentDate is today", async () => {
|
||||
const today = new Date();
|
||||
mockPrisma.quote.findFirst.mockResolvedValueOnce({
|
||||
sell: "1500.00",
|
||||
});
|
||||
|
||||
const result = await getBeloSellPriceForDate(today);
|
||||
expect(result).toBe(1500);
|
||||
expect(mockPrisma.quote.findFirst).toHaveBeenCalledWith({
|
||||
where: { type: "BELO" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when no current BELO quote exists for today", async () => {
|
||||
mockPrisma.quote.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getBeloSellPriceForDate(new Date());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns historical BELO sell price closest to 17:00 for a past date", async () => {
|
||||
const pastDate = new Date("2026-06-05T10:00:00.000Z");
|
||||
const before = {
|
||||
sell: "1400.50",
|
||||
timeStamp: new Date("2026-06-05T16:50:00.000Z"),
|
||||
};
|
||||
const after = {
|
||||
sell: "1410.00",
|
||||
timeStamp: new Date("2026-06-05T17:05:00.000Z"),
|
||||
};
|
||||
mockPrisma.quoteHistory.findFirst
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(after);
|
||||
|
||||
const result = await getBeloSellPriceForDate(pastDate);
|
||||
expect(result).toBe(1410);
|
||||
expect(mockPrisma.quoteHistory.findFirst).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("returns the only available historical entry if only one exists", async () => {
|
||||
const pastDate = new Date("2026-06-05T10:00:00.000Z");
|
||||
const before = {
|
||||
sell: "1400.50",
|
||||
timeStamp: new Date("2026-06-05T16:50:00.000Z"),
|
||||
};
|
||||
mockPrisma.quoteHistory.findFirst
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getBeloSellPriceForDate(pastDate);
|
||||
expect(result).toBe(1400.5);
|
||||
});
|
||||
|
||||
test("returns null when no historical data exists for a past date", async () => {
|
||||
mockPrisma.quoteHistory.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getBeloSellPriceForDate(
|
||||
new Date("2026-06-05T10:00:00.000Z"),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
timeStamp: new Date("2026-06-10T17:00:00.000Z"),
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({
|
||||
id: where.id,
|
||||
status: PaymentStatus.PAYED,
|
||||
amountPayed: data.amountPayed,
|
||||
paymentDate: data.paymentDate,
|
||||
beloPrice: data.beloPrice,
|
||||
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);
|
||||
expect(result.usdcEquivalent).toBe(10);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({
|
||||
id: where.id,
|
||||
status: PaymentStatus.PAYED,
|
||||
amountPayed: data.amountPayed,
|
||||
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();
|
||||
expect(result.usdcEquivalent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
timeStamp: new Date("2026-06-15T17:00:00.000Z"),
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockPrisma.expense.create.mockImplementationOnce(
|
||||
async ({ data }) => ({
|
||||
id: 1,
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
amountPayed: data.amountPayed,
|
||||
status: PaymentStatus.PAYED,
|
||||
beloPrice: data.beloPrice,
|
||||
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);
|
||||
expect(result.usdcEquivalent).toBe(20);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
mockPrisma.expense.create.mockImplementationOnce(
|
||||
async ({ data }) => ({
|
||||
id: 1,
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
amountPayed: data.amountPayed,
|
||||
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();
|
||||
expect(result.usdcEquivalent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateExpense with BELO", () => {
|
||||
test("recalculates BELO when amountPayed changes on a PAYED expense", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 1500,
|
||||
amountPayed: 1500,
|
||||
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.quoteHistory.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
sell: "1600.00",
|
||||
timeStamp: new Date("2026-06-10T17:00:00.000Z"),
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 1500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
amountPayed: 1600,
|
||||
});
|
||||
|
||||
expect(result.beloPrice).toBe(1600);
|
||||
expect(result.usdcEquivalent).toBe(1);
|
||||
});
|
||||
|
||||
test("clears BELO fields when reverting a PAYED expense to PENDING", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 1500,
|
||||
amountPayed: 1500,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
beloPrice: 1500,
|
||||
usdcEquivalent: 1,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 1500,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.beloPrice).toBeNull();
|
||||
expect(result.usdcEquivalent).toBeNull();
|
||||
});
|
||||
|
||||
test("does not query BELO when updating a PENDING expense", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PENDING,
|
||||
amount: 1500,
|
||||
amountPayed: null,
|
||||
paymentDate: null,
|
||||
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: 2000,
|
||||
dueDate: "2026-07-20T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.beloPrice).toBeUndefined();
|
||||
expect(mockPrisma.quote.findFirst).not.toHaveBeenCalled();
|
||||
expect(mockPrisma.quoteHistory.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateExpense wallet adjustments", () => {
|
||||
test("creates WITHDRAWAL when amount increases on a PAYED expense", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 100000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 90000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 35000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(35000);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: -10000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "WITHDRAWAL",
|
||||
amount: -10000,
|
||||
description: "Ajuste por edición de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates DEPOSIT when amount decreases on a PAYED expense", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 35000,
|
||||
amountPayed: 35000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 25000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(25000);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: 10000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "DEPOSIT",
|
||||
amount: 10000,
|
||||
description: "Ajuste por edición de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create wallet adjustment when amount is unchanged", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 25000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(25000);
|
||||
expect(mockWalletUpdate).not.toHaveBeenCalled();
|
||||
expect(mockMovementCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not create wallet adjustment when existing expense has no walletId", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 35000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(35000);
|
||||
expect(mockWalletUpdate).not.toHaveBeenCalled();
|
||||
expect(mockMovementCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates DEPOSIT refund when reverting PAYED expense to PENDING with walletId", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 25000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||
expect(result.amountPayed).toBeNull();
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: 25000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "DEPOSIT",
|
||||
amount: 25000,
|
||||
description: "Anulación de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create refund when existing expense has no walletId for PENDING revert", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 25000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||
expect(mockWalletUpdate).not.toHaveBeenCalled();
|
||||
expect(mockMovementCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws when wallet is not found during adjustment", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(null);
|
||||
|
||||
expect(
|
||||
updateExpense(1, {
|
||||
amount: 35000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
}),
|
||||
).rejects.toThrow("Billetera no encontrada");
|
||||
});
|
||||
|
||||
test("only creates refund (not amount adjustment) when both amount and status change", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 35000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: 25000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "DEPOSIT",
|
||||
amount: 25000,
|
||||
description: "Anulación de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("uses amountPayed for refund when expense has different amountPayed and amount", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 30000,
|
||||
amountPayed: 28000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 103000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 30000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PaymentStatus.PENDING);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: 28000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "DEPOSIT",
|
||||
amount: 28000,
|
||||
description: "Anulación de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates WITHDRAWAL when amountPayed increases while amount stays the same", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 65000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 25000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
amountPayed: 35000,
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(25000);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: -10000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "WITHDRAWAL",
|
||||
amount: -10000,
|
||||
description: "Ajuste por edición de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates adjustment based on amountPayed when both amount and amountPayed change", async () => {
|
||||
const existing = {
|
||||
id: 1,
|
||||
status: PaymentStatus.PAYED,
|
||||
amount: 25000,
|
||||
amountPayed: 25000,
|
||||
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
||||
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
||||
year: 2026,
|
||||
month: 6,
|
||||
walletId: 1,
|
||||
};
|
||||
const wallet = { id: 1, balance: 75000, currency: "ARS" };
|
||||
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
||||
mockWalletFindUnique.mockResolvedValueOnce(wallet);
|
||||
mockPrisma.expense.update.mockImplementationOnce(
|
||||
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
||||
);
|
||||
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 72000 });
|
||||
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
|
||||
|
||||
const result = await updateExpense(1, {
|
||||
amount: 30000,
|
||||
dueDate: "2026-06-15T00:00:00.000Z",
|
||||
amountPayed: 28000,
|
||||
});
|
||||
|
||||
expect(result.amount).toBe(30000);
|
||||
expect(mockWalletUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { balance: { increment: -3000 } },
|
||||
});
|
||||
expect(mockMovementCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
walletId: 1,
|
||||
type: "WITHDRAWAL",
|
||||
amount: -3000,
|
||||
description: "Ajuste por edición de gasto",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { LogOut, Menu, Monitor, Moon, Sun } from "lucide-react";
|
||||
|
||||
function TelegramIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useSseStatus } from "@/lib/sse-context";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { useTelegramStatus } from "@/lib/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -13,6 +28,7 @@ export function Header({ onMenuClick }: HeaderProps) {
|
||||
const sseStatus = useSseStatus();
|
||||
const { theme, cycleTheme } = useTheme();
|
||||
const auth = useAuth();
|
||||
const { data: telegramStatus } = useTelegramStatus();
|
||||
|
||||
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
|
||||
const themeLabel =
|
||||
@@ -24,7 +40,7 @@ export function Header({ onMenuClick }: HeaderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -58,6 +74,18 @@ export function Header({ onMenuClick }: HeaderProps) {
|
||||
>
|
||||
<ThemeIcon className="size-4" />
|
||||
</Button>
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md p-1.5 transition-colors",
|
||||
telegramStatus?.connected
|
||||
? "text-emerald-500 hover:text-emerald-400"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={telegramStatus?.connected ? "Telegram conectado" : "Conectar Telegram"}
|
||||
>
|
||||
<TelegramIcon className="size-4" />
|
||||
</Link>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { subDays } from "date-fns";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { useDailyQuotes, useHistoricalMinMax, useQuotes } from "@/lib/queries";
|
||||
|
||||
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
|
||||
|
||||
const CURRENCY_LABELS: Record<string, string> = {
|
||||
BELO: "BELO",
|
||||
BLUE: "Dólar Blue",
|
||||
BNA: "Dólar Oficial",
|
||||
};
|
||||
|
||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -27,15 +35,20 @@ function formatDate(d: Date): string {
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatShortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("es-AR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
const dateFormatter = new Intl.DateTimeFormat("es-AR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function formatShortDate(iso: string): string {
|
||||
const datePart = iso.split(" ")[0];
|
||||
const [y, m, d] = datePart.split("-").map(Number);
|
||||
return dateFormatter.format(new Date(y, m - 1, d));
|
||||
}
|
||||
|
||||
export function BeloAnalysisPage() {
|
||||
export function AnalysisPage({ currency }: { currency: string }) {
|
||||
const navigate = useNavigate();
|
||||
const today = new Date();
|
||||
const defaultStart = subDays(today, 30);
|
||||
|
||||
@@ -46,10 +59,10 @@ export function BeloAnalysisPage() {
|
||||
const endDateStr = formatDate(endDate);
|
||||
|
||||
const { data: historical, isLoading: historicalLoading } =
|
||||
useHistoricalMinMax("BELO");
|
||||
useHistoricalMinMax(currency);
|
||||
const { data: quotes } = useQuotes();
|
||||
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
|
||||
"BELO",
|
||||
currency,
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
);
|
||||
@@ -60,12 +73,12 @@ export function BeloAnalysisPage() {
|
||||
const firstOfMonthStr = formatDate(firstOfMonth);
|
||||
|
||||
const { data: yesterdayQuote } = useDailyQuotes(
|
||||
"BELO",
|
||||
currency,
|
||||
yesterdayStr,
|
||||
yesterdayStr,
|
||||
);
|
||||
const { data: firstDayQuote } = useDailyQuotes(
|
||||
"BELO",
|
||||
currency,
|
||||
firstOfMonthStr,
|
||||
firstOfMonthStr,
|
||||
);
|
||||
@@ -103,8 +116,8 @@ export function BeloAnalysisPage() {
|
||||
};
|
||||
}, [chartData]);
|
||||
|
||||
const beloQuote = quotes?.find((q) => q.type === "BELO");
|
||||
const currentBuy = beloQuote ? Number(beloQuote.buy) : 0;
|
||||
const currentQuote = quotes?.find((q) => q.type === currency);
|
||||
const currentBuy = currentQuote ? Number(currentQuote.buy) : 0;
|
||||
|
||||
const yesterdayBuy = yesterdayQuote?.[0]?.buy ?? null;
|
||||
const prevDayDiff = yesterdayBuy !== null ? currentBuy - yesterdayBuy : null;
|
||||
@@ -120,6 +133,9 @@ export function BeloAnalysisPage() {
|
||||
? (monthDiff / firstMonthBuy) * 100
|
||||
: null;
|
||||
|
||||
const backTo = currency === "BELO" ? "/quotes/belo" : "/quotes";
|
||||
const backLabel = currency === "BELO" ? "BELO" : "Cotizaciones";
|
||||
|
||||
function VariationValue({
|
||||
value,
|
||||
pct,
|
||||
@@ -148,17 +164,43 @@ export function BeloAnalysisPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/quotes/belo"
|
||||
to={backTo}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
BELO
|
||||
{backLabel}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">Análisis</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-0.5">
|
||||
{CURRENCIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({ to: "/quotes/analysis/$currency", params: { currency: c } })
|
||||
}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
|
||||
currency === c
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{CURRENCY_LABELS[c]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Cotización actual (compra)
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
${priceFormatter.format(currentBuy)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Mínimo histórico (compra)
|
||||
@@ -172,7 +214,7 @@ export function BeloAnalysisPage() {
|
||||
</p>
|
||||
{historical?.minBuyDate && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatShortDate(historical.minBuyDate)}
|
||||
el {formatShortDate(historical.minBuyDate)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -191,7 +233,7 @@ export function BeloAnalysisPage() {
|
||||
</p>
|
||||
{historical?.maxBuyDate && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatShortDate(historical.maxBuyDate)}
|
||||
el {formatShortDate(historical.maxBuyDate)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -315,7 +357,7 @@ export function BeloAnalysisPage() {
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
type="linear"
|
||||
dataKey="buy"
|
||||
name="Compra"
|
||||
stroke="var(--chart-1)"
|
||||
@@ -46,11 +46,8 @@ function formatTime(iso: string): string {
|
||||
}
|
||||
|
||||
function formatShortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("es-AR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
const [, m, d] = iso.split("-");
|
||||
return `${d}/${m}`;
|
||||
}
|
||||
|
||||
function getGaugeColor(percentage: number | null): string {
|
||||
@@ -226,16 +223,17 @@ export function BeloPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-semibold">BELO</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{belo && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Últ. actualización: <RelativeTime date={belo.timeStamp} />
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/quotes/belo/analysis"
|
||||
to="/quotes/analysis/$currency"
|
||||
params={{ currency: "BELO" }}
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
@@ -395,7 +393,7 @@ export function BeloPage() {
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
type="linear"
|
||||
dataKey="buy"
|
||||
name="Compra"
|
||||
stroke="var(--chart-1)"
|
||||
@@ -405,7 +403,7 @@ export function BeloPage() {
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
type="linear"
|
||||
dataKey="sell"
|
||||
name="Venta"
|
||||
stroke="var(--chart-2)"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useMonthlyPayedTotal,
|
||||
useMonthlyTotals,
|
||||
useQuotes,
|
||||
useWallets,
|
||||
} from "@/lib/queries";
|
||||
import { RelativeTime } from "@/lib/time";
|
||||
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
|
||||
@@ -141,6 +142,13 @@ export function DashboardPage() {
|
||||
|
||||
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">
|
||||
@@ -173,12 +181,41 @@ export function DashboardPage() {
|
||||
<QuoteCard
|
||||
quote={belo}
|
||||
title="BELO"
|
||||
to="/quotes/belo"
|
||||
to="/quotes/analysis/BELO"
|
||||
variant="minimal"
|
||||
/>
|
||||
<QuoteCard
|
||||
quote={blue}
|
||||
title="BLUE"
|
||||
to="/quotes/analysis/BLUE"
|
||||
variant="minimal"
|
||||
/>
|
||||
<QuoteCard
|
||||
quote={bna}
|
||||
title="Dolar Oficial"
|
||||
to="/quotes/analysis/BNA"
|
||||
variant="minimal"
|
||||
/>
|
||||
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
||||
<QuoteCard quote={bna} title="Dolar Oficial" 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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
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";
|
||||
@@ -17,6 +17,9 @@ import { useExpensesContext } from "../ExpensesProvider";
|
||||
const editSchema = z.object({
|
||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
||||
amountPayed: z.number().positive("El monto pagado debe ser mayor a 0").optional(),
|
||||
paymentDate: z.date().optional(),
|
||||
markAsPending: z.boolean(),
|
||||
});
|
||||
|
||||
type EditFormValues = z.infer<typeof editSchema>;
|
||||
@@ -35,6 +38,8 @@ export function EditExpenseDialog({
|
||||
const { updateExpense } = useExpensesContext();
|
||||
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const isPayed = expense?.status === "PAYED";
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -46,17 +51,29 @@ export function EditExpenseDialog({
|
||||
defaultValues: {
|
||||
amount: 0,
|
||||
dueDate: undefined,
|
||||
amountPayed: undefined,
|
||||
paymentDate: undefined,
|
||||
markAsPending: false,
|
||||
},
|
||||
});
|
||||
const { ref: amountRef, ...amountField } = register("amount", {
|
||||
setValueAs: (value) => Number(value),
|
||||
});
|
||||
|
||||
const markAsPending = useWatch({ control, name: "markAsPending" });
|
||||
|
||||
useEffect(() => {
|
||||
if (expense) {
|
||||
reset({
|
||||
amount: Number(expense.amount),
|
||||
dueDate: new Date(expense.dueDate),
|
||||
amountPayed: expense.amountPayed
|
||||
? Number(expense.amountPayed)
|
||||
: undefined,
|
||||
paymentDate: expense.paymentDate
|
||||
? new Date(expense.paymentDate)
|
||||
: undefined,
|
||||
markAsPending: false,
|
||||
});
|
||||
}
|
||||
}, [expense, reset]);
|
||||
@@ -79,10 +96,27 @@ export function EditExpenseDialog({
|
||||
|
||||
async function onSubmit(data: EditFormValues) {
|
||||
if (!expense) return;
|
||||
|
||||
if (isPayed && data.markAsPending) {
|
||||
await updateExpense(expense.id, {
|
||||
amount: data.amount,
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
status: "PENDING",
|
||||
});
|
||||
} else if (isPayed) {
|
||||
await updateExpense(expense.id, {
|
||||
amount: data.amount,
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
amountPayed: data.amountPayed!,
|
||||
paymentDate: data.paymentDate!.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await updateExpense(expense.id, {
|
||||
amount: data.amount,
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
handleClose(false);
|
||||
}
|
||||
|
||||
@@ -146,6 +180,111 @@ export function EditExpenseDialog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPayed && (
|
||||
<>
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
Información del pago
|
||||
</span>
|
||||
|
||||
{!markAsPending && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="amountPayed" className="text-sm font-medium">
|
||||
Monto pagado
|
||||
</label>
|
||||
<input
|
||||
id="amountPayed"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
{...register("amountPayed", {
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
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.amountPayed && (
|
||||
<span className="text-xs text-destructive">
|
||||
{errors.amountPayed.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Fecha de pago
|
||||
</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="paymentDate"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Seleccionar fecha"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.paymentDate && (
|
||||
<span className="text-xs text-destructive">
|
||||
{errors.paymentDate.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Controller
|
||||
control={control}
|
||||
name="markAsPending"
|
||||
render={({ field }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={field.value}
|
||||
onClick={() => field.onChange(!field.value)}
|
||||
className={
|
||||
field.value
|
||||
? "flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-primary text-primary-foreground ring-offset-background transition-colors"
|
||||
: "flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background ring-offset-background transition-colors hover:border-ring"
|
||||
}
|
||||
>
|
||||
{field.value && (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 5.5L4 7.5L8 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<span>Marcar como pendiente</span>
|
||||
</label>
|
||||
|
||||
{markAsPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Al marcar como pendiente, se borrarán el monto pagado y la
|
||||
fecha de pago.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
|
||||
@@ -97,6 +97,25 @@ export function ExpensesTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "USDC",
|
||||
accessorKey: "usdcEquivalent",
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("usdcEquivalent");
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
}
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{Number(value).toLocaleString("es-AR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
USDC
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Vencimiento",
|
||||
accessorKey: "dueDate",
|
||||
@@ -138,13 +157,13 @@ export function ExpensesTable({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const expense = row.original;
|
||||
if (expense.status === "PAYED") return null;
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
|
||||
<Pencil className="size-3.5" />
|
||||
Editar
|
||||
</Button>
|
||||
{expense.status !== "PAYED" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -153,6 +172,7 @@ export function ExpensesTable({
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -231,9 +251,17 @@ export function ExpensesTable({
|
||||
? `Pagado ${formatExpenseDate(expense.paymentDate)}`
|
||||
: "Sin fecha de pago"}
|
||||
</p>
|
||||
{expense.usdcEquivalent && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{Number(expense.usdcEquivalent).toLocaleString("es-AR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{" "}
|
||||
USDC
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expense.status !== "PAYED" && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -243,6 +271,7 @@ export function ExpensesTable({
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{expense.status !== "PAYED" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -252,10 +281,10 @@ export function ExpensesTable({
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
516
apps/frontend/src/features/wallets/WalletMovementsPage.tsx
Normal file
516
apps/frontend/src/features/wallets/WalletMovementsPage.tsx
Normal 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>·</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>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/src/features/wallets/WalletsPage.tsx
Normal file
42
apps/frontend/src/features/wallets/WalletsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
128
apps/frontend/src/features/wallets/components/DepositDialog.tsx
Normal file
128
apps/frontend/src/features/wallets/components/DepositDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
211
apps/frontend/src/features/wallets/components/TransferDialog.tsx
Normal file
211
apps/frontend/src/features/wallets/components/TransferDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/frontend/src/features/wallets/components/WalletCard.tsx
Normal file
185
apps/frontend/src/features/wallets/components/WalletCard.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,8 @@ export type Expense = {
|
||||
month: number;
|
||||
amount: string;
|
||||
amountPayed: string | null;
|
||||
beloPrice: string | null;
|
||||
usdcEquivalent: string | null;
|
||||
status: "PENDING" | "PAYED";
|
||||
dueDate: string;
|
||||
paymentDate: string | null;
|
||||
@@ -147,16 +149,23 @@ 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 = {
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
status?: "PENDING";
|
||||
amountPayed?: number;
|
||||
paymentDate?: string;
|
||||
};
|
||||
|
||||
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
||||
@@ -314,3 +323,146 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
// 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}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Telegram
|
||||
|
||||
export type TelegramStatus = {
|
||||
botUsername: string | null;
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
export type TelegramCode = {
|
||||
code: string;
|
||||
botUsername: string | null;
|
||||
};
|
||||
|
||||
export function getTelegramStatus(): Promise<TelegramStatus> {
|
||||
return fetcher<TelegramStatus>("/api/telegram/status");
|
||||
}
|
||||
|
||||
export function generateTelegramCode(): Promise<TelegramCode> {
|
||||
return mutator<TelegramCode>("/api/telegram/code", "POST");
|
||||
}
|
||||
|
||||
@@ -5,16 +5,24 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
type AdjustBalanceInput,
|
||||
type CreateNonPeriodicExpenseInput,
|
||||
type CreatePeriodicExpenseInput,
|
||||
type CreateWalletInput,
|
||||
type DepositInput,
|
||||
type TransferInput,
|
||||
type WalletMovementsFilter,
|
||||
createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
||||
createPeriodicExpense as createPeriodicExpenseApi,
|
||||
deletePeriodicExpense as deletePeriodicExpenseApi,
|
||||
depositWallet as depositWalletApi,
|
||||
adjustBalance as adjustBalanceApi,
|
||||
fetchQuotes,
|
||||
generateMonthlyExpense as generateMonthlyExpenseApi,
|
||||
getDailyMinMax,
|
||||
getDailyQuotes,
|
||||
getExpenses,
|
||||
generateTelegramCode,
|
||||
getHistoricalMinMax,
|
||||
getMonthlyPayedTotal,
|
||||
getMonthlyTotals,
|
||||
@@ -22,7 +30,12 @@ import {
|
||||
getPeriodicExpenses,
|
||||
getQuoteHistory,
|
||||
getQuotes,
|
||||
getTelegramStatus,
|
||||
getTotalPending,
|
||||
getWalletMovements,
|
||||
getWalletMovementsFiltered,
|
||||
getWallets,
|
||||
setDefaultWallet as setDefaultWalletApi,
|
||||
importExpenses,
|
||||
importPeriodicExpenses,
|
||||
type PayExpenseInput,
|
||||
@@ -30,6 +43,10 @@ import {
|
||||
type UpdateExpenseInput,
|
||||
updateExpense as updateExpenseApi,
|
||||
updatePeriodicExpense as updatePeriodicExpenseApi,
|
||||
createWallet as createWalletApi,
|
||||
updateWallet as updateWalletApi,
|
||||
deleteWallet as deleteWalletApi,
|
||||
transferWallet as transferWalletApi,
|
||||
} from "./api";
|
||||
|
||||
export const quoteKeys = {
|
||||
@@ -247,6 +264,7 @@ export function useCreateNonPeriodicExpense() {
|
||||
createNonPeriodicExpenseApi(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -278,6 +296,7 @@ export function usePayExpense() {
|
||||
payExpenseApi(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -292,3 +311,133 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram
|
||||
|
||||
export function useTelegramStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["telegram", "status"],
|
||||
queryFn: getTelegramStatus,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateTelegramCode() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: generateTelegramCode,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["telegram", "status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { AnalysisPage } from "@/features/analysis/AnalysisPage";
|
||||
|
||||
const VALID_CURRENCIES = ["BELO", "BLUE", "BNA"];
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticated/quotes/analysis/$currency",
|
||||
)({
|
||||
component: RouteComponent,
|
||||
loader: ({ params }) => {
|
||||
if (!VALID_CURRENCIES.includes(params.currency)) {
|
||||
throw redirect({ to: "/quotes" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { currency } = Route.useParams();
|
||||
return <AnalysisPage currency={currency} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { BeloAnalysisPage } from "@/features/belo/BeloAnalysisPage";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/quotes/belo/analysis")({
|
||||
component: BeloAnalysisPage,
|
||||
loader: () => {
|
||||
throw redirect({ to: "/quotes/analysis/$currency", params: { currency: "BELO" } });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Check, Copy, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import {
|
||||
useGenerateTelegramCode,
|
||||
useTelegramStatus,
|
||||
} from "@/lib/queries";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/settings")({
|
||||
component: SettingsPage,
|
||||
@@ -48,7 +53,7 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<div className="mx-auto max-w-md space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
|
||||
<p className="text-sm text-muted-foreground">Cambiar contraseña</p>
|
||||
@@ -118,6 +123,124 @@ function SettingsPage() {
|
||||
{loading ? "Guardando..." : "Cambiar contraseña"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<TelegramSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramSection() {
|
||||
const { data: status, isLoading } = useTelegramStatus();
|
||||
const {
|
||||
mutate: generateCode,
|
||||
data: newCode,
|
||||
isPending: generating,
|
||||
reset: resetCode,
|
||||
} = useGenerateTelegramCode();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const code = newCode?.code;
|
||||
|
||||
async function handleCopy() {
|
||||
if (!code) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
const el = document.createElement("textarea");
|
||||
el.value = code;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
resetCode();
|
||||
generateCode();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Telegram</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Conectá tu cuenta de Telegram para recibir notificaciones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : status?.connected ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-950/30">
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
|
||||
✅ Conectado a Telegram
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-emerald-600 dark:text-emerald-500">
|
||||
Recibirás notificaciones de BELO aquí.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status?.botUsername ? (
|
||||
<>
|
||||
Enviá el código a{" "}
|
||||
<strong className="font-medium text-foreground">
|
||||
@{status.botUsername}
|
||||
</strong>{" "}
|
||||
en Telegram para conectar.
|
||||
</>
|
||||
) : (
|
||||
"El bot no está disponible."
|
||||
)}
|
||||
</p>
|
||||
|
||||
{code ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border bg-muted px-3 py-2 text-sm font-mono tracking-widest">
|
||||
{code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !status?.botUsername}
|
||||
className="w-full"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
"Generar código"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { WalletsPage } from "@/features/wallets/WalletsPage";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/wallets/")({
|
||||
component: WalletsPage,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
9
apps/frontend/src/routes/_authenticated/wallets.tsx
Normal file
9
apps/frontend/src/routes/_authenticated/wallets.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/wallets")({
|
||||
component: WalletsLayout,
|
||||
});
|
||||
|
||||
function WalletsLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
Reference in New Issue
Block a user