feat(expenses): add beloPrice and usdcEquivalent fields to Expense model and update related logic
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY,
|
||||
ADD COLUMN "usdcEquivalent" DECIMAL(12,6);
|
||||
@@ -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?
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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<string, unknown> = {
|
||||
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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -232,6 +251,15 @@ 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>
|
||||
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user