diff --git a/apps/backend/prisma/migrations/20260608223915_add_belo_quote_to_expenses/migration.sql b/apps/backend/prisma/migrations/20260608223915_add_belo_quote_to_expenses/migration.sql new file mode 100644 index 0000000..894b9b7 --- /dev/null +++ b/apps/backend/prisma/migrations/20260608223915_add_belo_quote_to_expenses/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY, +ADD COLUMN "usdcEquivalent" DECIMAL(12,6); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 961c15f..117d7a5 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -130,6 +130,8 @@ 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? diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 6eb9572..37db528 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -import { PaymentStatus } from "../../generated/prisma/client"; +import { PaymentStatus, QuoteType, 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), @@ -57,6 +58,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 { + 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; @@ -168,6 +225,9 @@ export async function createNonPeriodicExpense( const dueDate = new Date(data.dueDate); const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; + + const belo = await getBeloSellPriceForDate(dueDate); + const result = await prisma.expense.create({ data: { description: data.description, @@ -178,6 +238,9 @@ export async function createNonPeriodicExpense( year, month, status: PaymentStatus.PAYED, + ...(belo !== null + ? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) } + : {}), }, }); cache.invalidateByPrefix("expenses:"); @@ -191,12 +254,18 @@ export async function payExpense( const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); + + const belo = await getBeloSellPriceForDate(paymentDate); + const result = await prisma.expense.update({ where: { id }, data: { status: PaymentStatus.PAYED, amountPayed: data.amountPayed, paymentDate, + ...(belo !== null + ? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) } + : {}), }, }); cache.invalidateByPrefix("expenses:"); @@ -213,7 +282,7 @@ export async function updateExpense( const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; - const updateData: Record = { + const updateData: Prisma.ExpenseUncheckedUpdateInput = { amount: data.amount, dueDate, year, @@ -224,6 +293,8 @@ export async function updateExpense( updateData.status = PaymentStatus.PENDING; updateData.amountPayed = null; updateData.paymentDate = null; + updateData.beloPrice = null; + updateData.usdcEquivalent = null; } else if (existing.status === PaymentStatus.PAYED) { if (data.amountPayed !== undefined) { updateData.amountPayed = data.amountPayed; @@ -231,6 +302,21 @@ export async function updateExpense( 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; + } + } } const result = await prisma.expense.update({ diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index 43667ae..d64545f 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -16,6 +16,12 @@ const mockPrisma = { update: mock(), count: mock(), }, + quote: { + findFirst: mock(), + }, + quoteHistory: { + findFirst: mock(), + }, }; mock.module("../src/lib/prisma", () => ({ @@ -40,6 +46,7 @@ const { updateExpenseSchema, buildDueDate, getCurrentYearMonthUTC, + getBeloSellPriceForDate, listPeriodicExpenses, createPeriodicExpense, updatePeriodicExpense, @@ -663,3 +670,271 @@ 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 paymentDate = new Date("2026-06-10T00:00:00.000Z"); + 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, + }), + ); + + const result = await payExpense(1, { + amountPayed: 15000, + paymentDate: "2026-06-10T00:00:00.000Z", + }); + + expect(result.beloPrice).toBe(1500); + expect(result.usdcEquivalent).toBe(10); + }); + + test("does not set BELO fields when no quote is available", async () => { + 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, + }), + ); + + const result = await payExpense(1, { + amountPayed: 15000, + paymentDate: "2026-06-10T00:00:00.000Z", + }); + + 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"; + 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, + }), + ); + + const result = await createNonPeriodicExpense({ + description: "Ropa", + amount: 30000, + dueDate, + }); + + expect(result.beloPrice).toBe(1500); + expect(result.usdcEquivalent).toBe(20); + }); + + test("does not set BELO fields when no quote is available", async () => { + 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, + }), + ); + + const result = await createNonPeriodicExpense({ + description: "Ropa", + amount: 2500, + dueDate: "2026-06-15T00:00:00.000Z", + }); + + 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(); + }); +}); diff --git a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx index b0c36ea..39b4572 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTable.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTable.tsx @@ -97,6 +97,25 @@ export function ExpensesTable({ ); }, }, + { + header: "USDC", + accessorKey: "usdcEquivalent", + cell: ({ row }) => { + const value = row.getValue("usdcEquivalent"); + if (!value) { + return --; + } + return ( + + {Number(value).toLocaleString("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + USDC + + ); + }, + }, { header: "Vencimiento", accessorKey: "dueDate", @@ -232,6 +251,15 @@ export function ExpensesTable({ ? `Pagado ${formatExpenseDate(expense.paymentDate)}` : "Sin fecha de pago"}

+ {expense.usdcEquivalent && ( +

+ {Number(expense.usdcEquivalent).toLocaleString("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + USDC +

+ )}
diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 62884c2..d3d9f1a 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -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;