feat/payment-wallets #5
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -134,6 +134,7 @@ model Wallet {
|
|||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
currency String @db.VarChar(10)
|
currency String @db.VarChar(10)
|
||||||
balance Decimal @db.Decimal(14, 4)
|
balance Decimal @db.Decimal(14, 4)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PaymentStatus, QuoteType, Prisma } from "../../generated/prisma/client";
|
import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
|
||||||
import { cache } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { prisma } from "../../lib/prisma";
|
import { prisma } from "../../lib/prisma";
|
||||||
import { roundTo } from "../../lib/utils";
|
import { roundTo } from "../../lib/utils";
|
||||||
@@ -17,11 +17,15 @@ export const createNonPeriodicExpenseSchema = z.object({
|
|||||||
description: z.string().min(1).max(50),
|
description: z.string().min(1).max(50),
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive(),
|
||||||
dueDate: z.string().datetime(),
|
dueDate: z.string().datetime(),
|
||||||
|
walletId: z.number().int().positive(),
|
||||||
|
usdcConversionRate: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const payExpenseSchema = z.object({
|
export const payExpenseSchema = z.object({
|
||||||
amountPayed: z.number().positive(),
|
amountPayed: z.number().positive(),
|
||||||
paymentDate: z.string().datetime().optional(),
|
paymentDate: z.string().datetime().optional(),
|
||||||
|
walletId: z.number().int().positive(),
|
||||||
|
usdcConversionRate: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateExpenseSchema = z.object({
|
export const updateExpenseSchema = z.object({
|
||||||
@@ -226,10 +230,27 @@ export async function createNonPeriodicExpense(
|
|||||||
const year = dueDate.getUTCFullYear();
|
const year = dueDate.getUTCFullYear();
|
||||||
const month = dueDate.getUTCMonth() + 1;
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
|
|
||||||
const belo = await getBeloSellPriceForDate(dueDate);
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error("Billetera no encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
const result = await prisma.expense.create({
|
const isUSDC = wallet.currency === "USDC";
|
||||||
data: {
|
|
||||||
|
if (isUSDC && !data.usdcConversionRate) {
|
||||||
|
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
|
||||||
|
}
|
||||||
|
|
||||||
|
const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate);
|
||||||
|
|
||||||
|
const movementAmount =
|
||||||
|
isUSDC && data.usdcConversionRate
|
||||||
|
? -roundTo(data.amount / data.usdcConversionRate, 4)
|
||||||
|
: -data.amount;
|
||||||
|
|
||||||
|
const expenseData: Prisma.ExpenseUncheckedCreateInput = {
|
||||||
description: data.description,
|
description: data.description,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
amountPayed: data.amount,
|
amountPayed: data.amount,
|
||||||
@@ -238,11 +259,35 @@ export async function createNonPeriodicExpense(
|
|||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
...(belo !== null
|
};
|
||||||
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) }
|
|
||||||
: {}),
|
if (isUSDC && data.usdcConversionRate) {
|
||||||
|
expenseData.beloPrice = data.usdcConversionRate;
|
||||||
|
expenseData.usdcEquivalent = roundTo(
|
||||||
|
data.amount / data.usdcConversionRate,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
} else if (belo !== null) {
|
||||||
|
expenseData.beloPrice = belo;
|
||||||
|
expenseData.usdcEquivalent = roundTo(data.amount / belo, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await prisma.$transaction([
|
||||||
|
prisma.expense.create({ data: expenseData }),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
data: { balance: { increment: movementAmount } },
|
||||||
|
}),
|
||||||
|
prisma.walletMovement.create({
|
||||||
|
data: {
|
||||||
|
walletId: data.walletId,
|
||||||
|
type: "WITHDRAWAL",
|
||||||
|
amount: movementAmount,
|
||||||
|
description: `Pago: ${data.description}`,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
cache.invalidateByPrefix("expenses:");
|
cache.invalidateByPrefix("expenses:");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -251,23 +296,61 @@ export async function payExpense(
|
|||||||
id: number,
|
id: number,
|
||||||
data: z.infer<typeof payExpenseSchema>,
|
data: z.infer<typeof payExpenseSchema>,
|
||||||
) {
|
) {
|
||||||
|
const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
||||||
|
|
||||||
const paymentDate = data.paymentDate
|
const paymentDate = data.paymentDate
|
||||||
? new Date(data.paymentDate)
|
? new Date(data.paymentDate)
|
||||||
: new Date();
|
: new Date();
|
||||||
|
|
||||||
const belo = await getBeloSellPriceForDate(paymentDate);
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error("Billetera no encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
const result = await prisma.expense.update({
|
const isUSDC = wallet.currency === "USDC";
|
||||||
where: { id },
|
|
||||||
data: {
|
if (isUSDC && !data.usdcConversionRate) {
|
||||||
|
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
|
||||||
|
}
|
||||||
|
|
||||||
|
const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate);
|
||||||
|
|
||||||
|
const movementAmount = isUSDC && data.usdcConversionRate
|
||||||
|
? -roundTo(data.amountPayed / data.usdcConversionRate, 4)
|
||||||
|
: -data.amountPayed;
|
||||||
|
|
||||||
|
const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = {
|
||||||
status: PaymentStatus.PAYED,
|
status: PaymentStatus.PAYED,
|
||||||
amountPayed: data.amountPayed,
|
amountPayed: data.amountPayed,
|
||||||
paymentDate,
|
paymentDate,
|
||||||
...(belo !== null
|
};
|
||||||
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) }
|
|
||||||
: {}),
|
if (isUSDC && data.usdcConversionRate) {
|
||||||
|
expenseUpdateData.beloPrice = data.usdcConversionRate;
|
||||||
|
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6);
|
||||||
|
} else if (belo !== null) {
|
||||||
|
expenseUpdateData.beloPrice = belo;
|
||||||
|
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await prisma.$transaction([
|
||||||
|
prisma.expense.update({ where: { id }, data: expenseUpdateData }),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
data: { balance: { increment: movementAmount } },
|
||||||
|
}),
|
||||||
|
prisma.walletMovement.create({
|
||||||
|
data: {
|
||||||
|
walletId: data.walletId,
|
||||||
|
type: "WITHDRAWAL",
|
||||||
|
amount: movementAmount,
|
||||||
|
description: `Pago: ${expense.description}`,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
cache.invalidateByPrefix("expenses:");
|
cache.invalidateByPrefix("expenses:");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { listMovementsHandler } from "./handlers/listMovements";
|
|||||||
import { listWalletsHandler } from "./handlers/listWallets";
|
import { listWalletsHandler } from "./handlers/listWallets";
|
||||||
import { transferWalletHandler } from "./handlers/transferWallet";
|
import { transferWalletHandler } from "./handlers/transferWallet";
|
||||||
import { updateWalletHandler } from "./handlers/updateWallet";
|
import { updateWalletHandler } from "./handlers/updateWallet";
|
||||||
|
import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ app.delete("/:id", deleteWalletHandler);
|
|||||||
app.post("/:id/deposit", depositWalletHandler);
|
app.post("/:id/deposit", depositWalletHandler);
|
||||||
app.post("/:id/adjust", adjustBalanceHandler);
|
app.post("/:id/adjust", adjustBalanceHandler);
|
||||||
app.post("/transfer", transferWalletHandler);
|
app.post("/transfer", transferWalletHandler);
|
||||||
|
app.put("/:id/default", setDefaultWalletHandler);
|
||||||
app.get("/:id/movements", listMovementsHandler);
|
app.get("/:id/movements", listMovementsHandler);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const createWalletSchema = z.object({
|
|||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
currency: z.string().min(1).max(10),
|
currency: z.string().min(1).max(10),
|
||||||
initialBalance: z.number().optional().default(0),
|
initialBalance: z.number().optional().default(0),
|
||||||
|
isDefault: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateWalletSchema = z.object({
|
export const updateWalletSchema = z.object({
|
||||||
@@ -13,6 +14,10 @@ export const updateWalletSchema = z.object({
|
|||||||
currency: z.string().min(1).max(10),
|
currency: z.string().min(1).max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setDefaultWalletSchema = z.object({
|
||||||
|
walletId: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
export const depositSchema = z.object({
|
export const depositSchema = z.object({
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive(),
|
||||||
description: z.string().max(255).optional(),
|
description: z.string().max(255).optional(),
|
||||||
@@ -36,9 +41,12 @@ export type UpdateWalletInput = z.infer<typeof updateWalletSchema>;
|
|||||||
export type DepositInput = z.infer<typeof depositSchema>;
|
export type DepositInput = z.infer<typeof depositSchema>;
|
||||||
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
|
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
|
||||||
export type TransferInput = z.infer<typeof transferSchema>;
|
export type TransferInput = z.infer<typeof transferSchema>;
|
||||||
|
export type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
|
||||||
|
|
||||||
export async function listWallets() {
|
export async function listWallets() {
|
||||||
const wallets = await prisma.wallet.findMany({ orderBy: { createdAt: "desc" } });
|
const wallets = await prisma.wallet.findMany({
|
||||||
|
orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
|
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +56,15 @@ export async function createWallet(input: CreateWalletInput) {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
balance: input.initialBalance ?? 0,
|
balance: input.initialBalance ?? 0,
|
||||||
|
isDefault: input.isDefault ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (wallet.isDefault) {
|
||||||
|
await prisma.wallet.updateMany({
|
||||||
|
where: { id: { not: wallet.id }, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
return { ...wallet, balance: Number(wallet.balance) };
|
return { ...wallet, balance: Number(wallet.balance) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +170,21 @@ export async function transferWallet(input: TransferInput) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDefaultWallet(walletId: number) {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.wallet.updateMany({
|
||||||
|
where: { isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: walletId },
|
||||||
|
data: { isDefault: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id: walletId } });
|
||||||
|
return { ...wallet, balance: Number(wallet.balance) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listMovements(walletId: number) {
|
export async function listMovements(walletId: number) {
|
||||||
const movements = await prisma.walletMovement.findMany({
|
const movements = await prisma.walletMovement.findMany({
|
||||||
where: { walletId },
|
where: { walletId },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
@@ -10,7 +11,15 @@ import {
|
|||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
import { useQuotes, useWallets } from "@/lib/queries";
|
||||||
|
|
||||||
const newExpenseSchema = z.object({
|
const newExpenseSchema = z.object({
|
||||||
description: z
|
description: z
|
||||||
@@ -19,6 +28,11 @@ const newExpenseSchema = z.object({
|
|||||||
.max(50, "Máximo 50 caracteres"),
|
.max(50, "Máximo 50 caracteres"),
|
||||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
||||||
|
walletId: z.number().positive("Seleccioná una billetera"),
|
||||||
|
usdcConversionRate: z
|
||||||
|
.number()
|
||||||
|
.positive("La cotización debe ser mayor a 0")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
|
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
|
||||||
@@ -33,6 +47,19 @@ export function NewExpenseDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NewExpenseDialogProps) {
|
}: NewExpenseDialogProps) {
|
||||||
const { createNonPeriodicExpense } = useExpensesContext();
|
const { createNonPeriodicExpense } = useExpensesContext();
|
||||||
|
const prevWalletRef = useRef(0);
|
||||||
|
const { data: wallets } = useWallets();
|
||||||
|
const { data: quotes } = useQuotes();
|
||||||
|
const defaultWallet = useMemo(
|
||||||
|
() => wallets?.find((w) => w.isDefault),
|
||||||
|
[wallets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const beloSell = useMemo(() => {
|
||||||
|
if (!quotes) return null;
|
||||||
|
const belo = quotes.find((q) => q.type === "BELO");
|
||||||
|
return belo ? Number(belo.sell) : null;
|
||||||
|
}, [quotes]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -40,15 +67,48 @@ export function NewExpenseDialog({
|
|||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
} = useForm<NewExpenseFormValues>({
|
} = useForm<NewExpenseFormValues>({
|
||||||
resolver: zodResolver(newExpenseSchema),
|
resolver: zodResolver(newExpenseSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
description: "",
|
description: "",
|
||||||
amount: 0,
|
amount: 0,
|
||||||
dueDate: new Date(),
|
dueDate: new Date(),
|
||||||
|
walletId: defaultWallet?.id ?? 0,
|
||||||
|
usdcConversionRate: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultWallet && !open) return;
|
||||||
|
if (defaultWallet) {
|
||||||
|
setValue("walletId", defaultWallet.id);
|
||||||
|
prevWalletRef.current = defaultWallet.id;
|
||||||
|
}
|
||||||
|
}, [defaultWallet, setValue, open]);
|
||||||
|
|
||||||
|
const usdcConversionRateField = register("usdcConversionRate", {
|
||||||
|
setValueAs: (value) => (value === "" ? undefined : Number(value)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedWalletId = useWatch({ control, name: "walletId" });
|
||||||
|
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
|
||||||
|
const isUSDC = selectedWallet?.currency === "USDC";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isUSDC &&
|
||||||
|
beloSell &&
|
||||||
|
selectedWalletId &&
|
||||||
|
selectedWalletId !== prevWalletRef.current
|
||||||
|
) {
|
||||||
|
setValue("usdcConversionRate", beloSell);
|
||||||
|
trigger("usdcConversionRate");
|
||||||
|
prevWalletRef.current = selectedWalletId;
|
||||||
|
}
|
||||||
|
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
|
||||||
|
|
||||||
function handleClose(open: boolean) {
|
function handleClose(open: boolean) {
|
||||||
onOpenChange(open);
|
onOpenChange(open);
|
||||||
if (!open) reset();
|
if (!open) reset();
|
||||||
@@ -59,6 +119,8 @@ export function NewExpenseDialog({
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
dueDate: data.dueDate.toISOString(),
|
dueDate: data.dueDate.toISOString(),
|
||||||
|
walletId: data.walletId,
|
||||||
|
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
|
||||||
});
|
});
|
||||||
handleClose(false);
|
handleClose(false);
|
||||||
}
|
}
|
||||||
@@ -112,6 +174,62 @@ export function NewExpenseDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">Billetera</label>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="walletId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value ? String(field.value) : ""}
|
||||||
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<SelectValue placeholder="Seleccionar billetera" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wallets?.map((w) => (
|
||||||
|
<SelectItem key={w.id} value={String(w.id)}>
|
||||||
|
{w.name} ({w.currency})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.walletId && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.walletId.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUSDC && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="usdcConversionRate"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cotización USDC (ARS por USDC)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="usdcConversionRate"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
{...usdcConversionRateField}
|
||||||
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
{errors.usdcConversionRate && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.usdcConversionRate.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-sm font-medium">Fecha del gasto</span>
|
<span className="text-sm font-medium">Fecha del gasto</span>
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
@@ -11,12 +11,28 @@ import {
|
|||||||
ResponsiveDialogHeader,
|
ResponsiveDialogHeader,
|
||||||
ResponsiveDialogTitle,
|
ResponsiveDialogTitle,
|
||||||
} from "@/components/ui/responsive-dialog";
|
} from "@/components/ui/responsive-dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import type { Expense } from "@/lib/api";
|
import type { Expense } from "@/lib/api";
|
||||||
import { useExpensesContext } from "../ExpensesProvider";
|
import { useExpensesContext } from "../ExpensesProvider";
|
||||||
|
import { useQuotes, useWallets } from "@/lib/queries";
|
||||||
|
|
||||||
const paySchema = z.object({
|
const paySchema = z.object({
|
||||||
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
paymentDate: z.date({ message: "La fecha es obligatoria" }),
|
paymentDate: z.date({ message: "La fecha es obligatoria" }),
|
||||||
|
walletId: z
|
||||||
|
.number()
|
||||||
|
.positive("Seleccioná una billetera")
|
||||||
|
.refine((v) => v > 0, "Seleccioná una billetera"),
|
||||||
|
usdcConversionRate: z
|
||||||
|
.number()
|
||||||
|
.positive("La cotización debe ser mayor a 0")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type PayFormValues = z.infer<typeof paySchema>;
|
type PayFormValues = z.infer<typeof paySchema>;
|
||||||
@@ -34,6 +50,19 @@ export function PayExpenseDialog({
|
|||||||
}: PayExpenseDialogProps) {
|
}: PayExpenseDialogProps) {
|
||||||
const { payExpense } = useExpensesContext();
|
const { payExpense } = useExpensesContext();
|
||||||
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
const amountInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const prevWalletRef = useRef(0);
|
||||||
|
const { data: wallets } = useWallets();
|
||||||
|
const { data: quotes } = useQuotes();
|
||||||
|
const defaultWallet = useMemo(
|
||||||
|
() => wallets?.find((w) => w.isDefault),
|
||||||
|
[wallets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const beloSell = useMemo(() => {
|
||||||
|
if (!quotes) return null;
|
||||||
|
const belo = quotes.find((q) => q.type === "BELO");
|
||||||
|
return belo ? Number(belo.sell) : null;
|
||||||
|
}, [quotes]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -41,25 +70,54 @@ export function PayExpenseDialog({
|
|||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
} = useForm<PayFormValues>({
|
} = useForm<PayFormValues>({
|
||||||
resolver: zodResolver(paySchema),
|
resolver: zodResolver(paySchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
amountPayed: 0,
|
amountPayed: 0,
|
||||||
paymentDate: undefined,
|
paymentDate: undefined,
|
||||||
|
walletId: 0,
|
||||||
|
usdcConversionRate: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
|
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
|
||||||
setValueAs: (value) => Number(value),
|
setValueAs: (value) => Number(value),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const usdcConversionRateField = register("usdcConversionRate", {
|
||||||
|
setValueAs: (value) => (value === "" ? undefined : Number(value)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedWalletId = useWatch({ control, name: "walletId" });
|
||||||
|
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
|
||||||
|
const isUSDC = selectedWallet?.currency === "USDC";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expense) {
|
if (expense) {
|
||||||
|
const defaultId = defaultWallet?.id ?? 0;
|
||||||
reset({
|
reset({
|
||||||
amountPayed: Number(expense.amount),
|
amountPayed: Number(expense.amount),
|
||||||
paymentDate: new Date(),
|
paymentDate: new Date(),
|
||||||
|
walletId: defaultId,
|
||||||
|
usdcConversionRate: undefined,
|
||||||
});
|
});
|
||||||
|
prevWalletRef.current = defaultId;
|
||||||
}
|
}
|
||||||
}, [expense, reset]);
|
}, [expense, defaultWallet, reset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isUSDC &&
|
||||||
|
beloSell &&
|
||||||
|
selectedWalletId &&
|
||||||
|
selectedWalletId !== prevWalletRef.current
|
||||||
|
) {
|
||||||
|
setValue("usdcConversionRate", beloSell);
|
||||||
|
trigger("usdcConversionRate");
|
||||||
|
prevWalletRef.current = selectedWalletId;
|
||||||
|
}
|
||||||
|
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !expense) return;
|
if (!open || !expense) return;
|
||||||
@@ -82,6 +140,8 @@ export function PayExpenseDialog({
|
|||||||
await payExpense(expense.id, {
|
await payExpense(expense.id, {
|
||||||
amountPayed: data.amountPayed,
|
amountPayed: data.amountPayed,
|
||||||
paymentDate: data.paymentDate.toISOString(),
|
paymentDate: data.paymentDate.toISOString(),
|
||||||
|
walletId: data.walletId,
|
||||||
|
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
|
||||||
});
|
});
|
||||||
handleClose(false);
|
handleClose(false);
|
||||||
}
|
}
|
||||||
@@ -133,6 +193,62 @@ export function PayExpenseDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium">Billetera</label>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="walletId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value ? String(field.value) : ""}
|
||||||
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<SelectValue placeholder="Seleccionar billetera" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wallets?.map((w) => (
|
||||||
|
<SelectItem key={w.id} value={String(w.id)}>
|
||||||
|
{w.name} ({w.currency})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.walletId && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.walletId.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUSDC && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="usdcConversionRate"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cotización USDC (ARS por USDC)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="usdcConversionRate"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
{...usdcConversionRateField}
|
||||||
|
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
{errors.usdcConversionRate && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{errors.usdcConversionRate.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-sm font-medium">Fecha de pago</span>
|
<span className="text-sm font-medium">Fecha de pago</span>
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
History,
|
History,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { Wallet } from "@/lib/api";
|
import type { Wallet } from "@/lib/api";
|
||||||
|
import { useSetDefaultWallet } from "@/lib/queries";
|
||||||
import { DepositDialog } from "./DepositDialog";
|
import { DepositDialog } from "./DepositDialog";
|
||||||
import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
|
import { AdjustBalanceDialog } from "./AdjustBalanceDialog";
|
||||||
import { TransferDialog } from "./TransferDialog";
|
import { TransferDialog } from "./TransferDialog";
|
||||||
@@ -30,6 +32,7 @@ interface WalletCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WalletCard({ wallet }: WalletCardProps) {
|
export function WalletCard({ wallet }: WalletCardProps) {
|
||||||
|
const setDefaultWallet = useSetDefaultWallet();
|
||||||
const [depositOpen, setDepositOpen] = useState(false);
|
const [depositOpen, setDepositOpen] = useState(false);
|
||||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
@@ -40,10 +43,40 @@ export function WalletCard({ wallet }: WalletCardProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="rounded-lg border p-4 space-y-3">
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium">{wallet.name}</p>
|
<p className="font-medium">{wallet.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{wallet.currency}</p>
|
{wallet.isDefault && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDefaultWallet.mutate(wallet.id)}
|
||||||
|
className={`transition-colors cursor-pointer ${
|
||||||
|
wallet.isDefault
|
||||||
|
? "text-yellow-500 hover:text-yellow-600"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="size-4"
|
||||||
|
fill={wallet.isDefault ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{wallet.isDefault
|
||||||
|
? "Billetera predeterminada"
|
||||||
|
: "Marcar como predeterminada"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditOpen(true)}
|
onClick={() => setEditOpen(true)}
|
||||||
@@ -52,6 +85,7 @@ export function WalletCard({ wallet }: WalletCardProps) {
|
|||||||
<PencilLine className="size-4" />
|
<PencilLine className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-2xl font-bold tabular-nums">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
{balanceFormatter.format(wallet.balance)}
|
{balanceFormatter.format(wallet.balance)}
|
||||||
|
|||||||
@@ -149,11 +149,15 @@ export type CreateNonPeriodicExpenseInput = {
|
|||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
|
walletId: number;
|
||||||
|
usdcConversionRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PayExpenseInput = {
|
export type PayExpenseInput = {
|
||||||
amountPayed: number;
|
amountPayed: number;
|
||||||
paymentDate?: string;
|
paymentDate?: string;
|
||||||
|
walletId: number;
|
||||||
|
usdcConversionRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateExpenseInput = {
|
export type UpdateExpenseInput = {
|
||||||
@@ -327,6 +331,7 @@ export type Wallet = {
|
|||||||
name: string;
|
name: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
isDefault: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -409,6 +414,10 @@ export function transferWallet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setDefaultWallet(id: number): Promise<Wallet> {
|
||||||
|
return mutator<Wallet>(`/api/wallets/${id}/default`, "PUT");
|
||||||
|
}
|
||||||
|
|
||||||
export function getWalletMovements(
|
export function getWalletMovements(
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<WalletMovement[]> {
|
): Promise<WalletMovement[]> {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getTotalPending,
|
getTotalPending,
|
||||||
getWalletMovements,
|
getWalletMovements,
|
||||||
getWallets,
|
getWallets,
|
||||||
|
setDefaultWallet as setDefaultWalletApi,
|
||||||
importExpenses,
|
importExpenses,
|
||||||
importPeriodicExpenses,
|
importPeriodicExpenses,
|
||||||
type PayExpenseInput,
|
type PayExpenseInput,
|
||||||
@@ -261,6 +262,7 @@ export function useCreateNonPeriodicExpense() {
|
|||||||
createNonPeriodicExpenseApi(data),
|
createNonPeriodicExpenseApi(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -292,6 +294,7 @@ export function usePayExpense() {
|
|||||||
payExpenseApi(id, data),
|
payExpenseApi(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,6 +361,16 @@ export function useDeleteWallet() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetDefaultWallet() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => setDefaultWalletApi(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: walletKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDepositWallet() {
|
export function useDepositWallet() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user