feat(expenses): add beloPrice and usdcEquivalent fields to Expense model and update related logic

This commit is contained in:
Jose Selesan
2026-06-08 21:13:03 -03:00
parent b08399908b
commit 142fd4e33f
6 changed files with 398 additions and 2 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY,
ADD COLUMN "usdcEquivalent" DECIMAL(12,6);

View File

@@ -130,6 +130,8 @@ model Expense {
month Int month Int
amount Decimal @db.Money amount Decimal @db.Money
amountPayed Decimal? @db.Money amountPayed Decimal? @db.Money
beloPrice Decimal? @db.Money
usdcEquivalent Decimal? @db.Decimal(12, 6)
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
dueDate DateTime dueDate DateTime
paymentDate DateTime? paymentDate DateTime?

View File

@@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { PaymentStatus } from "../../generated/prisma/client"; import { PaymentStatus, QuoteType, 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";
export const createPeriodicExpenseSchema = z.object({ export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), 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 }; 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() { export async function listPeriodicExpenses() {
const cached = cache.get("expenses:periodic"); const cached = cache.get("expenses:periodic");
if (cached) return cached; if (cached) return cached;
@@ -168,6 +225,9 @@ export async function createNonPeriodicExpense(
const dueDate = new Date(data.dueDate); const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; const month = dueDate.getUTCMonth() + 1;
const belo = await getBeloSellPriceForDate(dueDate);
const result = await prisma.expense.create({ const result = await prisma.expense.create({
data: { data: {
description: data.description, description: data.description,
@@ -178,6 +238,9 @@ export async function createNonPeriodicExpense(
year, year,
month, month,
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amount / belo, 6) }
: {}),
}, },
}); });
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
@@ -191,12 +254,18 @@ export async function payExpense(
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 result = await prisma.expense.update({ const result = await prisma.expense.update({
where: { id }, where: { id },
data: { data: {
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate, paymentDate,
...(belo !== null
? { beloPrice: belo, usdcEquivalent: roundTo(data.amountPayed / belo, 6) }
: {}),
}, },
}); });
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
@@ -213,7 +282,7 @@ export async function updateExpense(
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; const month = dueDate.getUTCMonth() + 1;
const updateData: Record<string, unknown> = { const updateData: Prisma.ExpenseUncheckedUpdateInput = {
amount: data.amount, amount: data.amount,
dueDate, dueDate,
year, year,
@@ -224,6 +293,8 @@ export async function updateExpense(
updateData.status = PaymentStatus.PENDING; updateData.status = PaymentStatus.PENDING;
updateData.amountPayed = null; updateData.amountPayed = null;
updateData.paymentDate = null; updateData.paymentDate = null;
updateData.beloPrice = null;
updateData.usdcEquivalent = null;
} else if (existing.status === PaymentStatus.PAYED) { } else if (existing.status === PaymentStatus.PAYED) {
if (data.amountPayed !== undefined) { if (data.amountPayed !== undefined) {
updateData.amountPayed = data.amountPayed; updateData.amountPayed = data.amountPayed;
@@ -231,6 +302,21 @@ export async function updateExpense(
if (data.paymentDate !== undefined) { if (data.paymentDate !== undefined) {
updateData.paymentDate = new Date(data.paymentDate); 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({ const result = await prisma.expense.update({

View File

@@ -16,6 +16,12 @@ const mockPrisma = {
update: mock(), update: mock(),
count: mock(), count: mock(),
}, },
quote: {
findFirst: mock(),
},
quoteHistory: {
findFirst: mock(),
},
}; };
mock.module("../src/lib/prisma", () => ({ mock.module("../src/lib/prisma", () => ({
@@ -40,6 +46,7 @@ const {
updateExpenseSchema, updateExpenseSchema,
buildDueDate, buildDueDate,
getCurrentYearMonthUTC, getCurrentYearMonthUTC,
getBeloSellPriceForDate,
listPeriodicExpenses, listPeriodicExpenses,
createPeriodicExpense, createPeriodicExpense,
updatePeriodicExpense, updatePeriodicExpense,
@@ -663,3 +670,271 @@ describe("generateMonthlyExpense", () => {
expect(result).toEqual(existing); 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();
});
});

View File

@@ -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", header: "Vencimiento",
accessorKey: "dueDate", accessorKey: "dueDate",
@@ -232,6 +251,15 @@ export function ExpensesTable({
? `Pagado ${formatExpenseDate(expense.paymentDate)}` ? `Pagado ${formatExpenseDate(expense.paymentDate)}`
: "Sin fecha de pago"} : "Sin fecha de pago"}
</p> </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> </div>
<div className="flex gap-1"> <div className="flex gap-1">

View File

@@ -130,6 +130,8 @@ export type Expense = {
month: number; month: number;
amount: string; amount: string;
amountPayed: string | null; amountPayed: string | null;
beloPrice: string | null;
usdcEquivalent: string | null;
status: "PENDING" | "PAYED"; status: "PENDING" | "PAYED";
dueDate: string; dueDate: string;
paymentDate: string | null; paymentDate: string | null;