From 142fd4e33fb844e0c01424ed21511cc905260b3b Mon Sep 17 00:00:00 2001
From: Jose Selesan
Date: Mon, 8 Jun 2026 21:13:03 -0300
Subject: [PATCH] feat(expenses): add beloPrice and usdcEquivalent fields to
Expense model and update related logic
---
.../migration.sql | 3 +
apps/backend/prisma/schema.prisma | 2 +
.../src/modules/expenses/expenses.service.ts | 90 +++++-
apps/backend/tests/expenses.test.ts | 275 ++++++++++++++++++
.../expenses/components/ExpensesTable.tsx | 28 ++
apps/frontend/src/lib/api.ts | 2 +
6 files changed, 398 insertions(+), 2 deletions(-)
create mode 100644 apps/backend/prisma/migrations/20260608223915_add_belo_quote_to_expenses/migration.sql
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;