feat(expenses): add walletId to expense model and implement wallet adjustments in expense operations

This commit is contained in:
Jose Selesan
2026-06-17 10:54:43 -03:00
parent 5671b4a14b
commit 33120deed6
4 changed files with 568 additions and 2 deletions

View File

@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client";
const mockWalletFindUnique = mock();
const mockWalletUpdate = mock();
const mockMovementCreate = mock();
const mockTransaction = mock(async (operations: Array<Promise<unknown>>) =>
Promise.all(operations),
);
const mockPrisma = {
$transaction: mockTransaction,
periodicExpense: {
findMany: mock(),
findUniqueOrThrow: mock(),
@@ -16,6 +24,13 @@ const mockPrisma = {
update: mock(),
count: mock(),
},
wallet: {
findUnique: mockWalletFindUnique,
update: mockWalletUpdate,
},
walletMovement: {
create: mockMovementCreate,
},
quote: {
findFirst: mock(),
},
@@ -30,6 +45,7 @@ mock.module("../src/lib/prisma", () => ({
beforeEach(() => {
for (const model of Object.values(mockPrisma)) {
if (typeof model === "function") continue;
for (const fn of Object.values(
model as Record<string, ReturnType<typeof mock>>,
)) {
@@ -155,6 +171,7 @@ describe("schemas", () => {
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
});
expect(result.description).toBe("Ropa");
});
@@ -175,12 +192,16 @@ describe("schemas", () => {
const result = payExpenseSchema.parse({
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
});
expect(result.amountPayed).toBe(1400);
});
test("allows missing paymentDate", () => {
const result = payExpenseSchema.parse({ amountPayed: 1400 });
const result = payExpenseSchema.parse({
amountPayed: 1400,
walletId: 1,
});
expect(result.amountPayed).toBe(1400);
expect(result.paymentDate).toBeUndefined();
});
@@ -407,6 +428,7 @@ describe("createNonPeriodicExpense", () => {
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
};
const created = {
id: 1,
@@ -417,7 +439,11 @@ describe("createNonPeriodicExpense", () => {
year: 2026,
month: 6,
};
const wallet = { id: 1, balance: 10000 };
mockPrisma.expense.create.mockResolvedValueOnce(created);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 7500 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense(input);
expect(result.status).toBe(PaymentStatus.PAYED);
@@ -436,23 +462,48 @@ describe("createNonPeriodicExpense", () => {
describe("payExpense", () => {
test("updates status to PAYED with amountPayed and paymentDate", async () => {
const expense = {
id: 1,
description: "Ropa",
status: PaymentStatus.PENDING,
amount: 1400,
amountPayed: null,
paymentDate: null,
};
const paidExpense = {
id: 1,
status: PaymentStatus.PAYED,
amountPayed: 1400,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
};
const wallet = { id: 1, balance: 10000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 8600 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, {
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
});
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(1400);
});
test("defaults paymentDate to now when not provided", async () => {
const expense = {
id: 1,
description: "Ropa",
status: PaymentStatus.PENDING,
amount: 1500,
amountPayed: null,
paymentDate: null,
};
const wallet = { id: 1, balance: 10000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({
id: where.id,
@@ -462,8 +513,10 @@ describe("payExpense", () => {
paymentDate: data.paymentDate,
}),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 8500 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, { amountPayed: 1500 });
const result = await payExpense(1, { amountPayed: 1500, walletId: 1 });
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.paymentDate).toBeInstanceOf(Date);
});
@@ -739,7 +792,18 @@ describe("getBeloSellPriceForDate", () => {
describe("payExpense with BELO", () => {
test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => {
const expense = {
id: 1,
description: "Ropa",
status: PaymentStatus.PENDING,
amount: 15000,
amountPayed: null,
paymentDate: null,
};
const paymentDate = new Date("2026-06-10T00:00:00.000Z");
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce({
sell: "1500.00",
@@ -757,10 +821,13 @@ describe("payExpense with BELO", () => {
usdcEquivalent: data.usdcEquivalent,
}),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, {
amountPayed: 15000,
paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
});
expect(result.beloPrice).toBe(1500);
@@ -768,6 +835,17 @@ describe("payExpense with BELO", () => {
});
test("does not set BELO fields when no quote is available", async () => {
const expense = {
id: 1,
description: "Ropa",
status: PaymentStatus.PENDING,
amount: 15000,
amountPayed: null,
paymentDate: null,
};
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(expense);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
@@ -780,10 +858,13 @@ describe("payExpense with BELO", () => {
paymentDate: data.paymentDate,
}),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await payExpense(1, {
amountPayed: 15000,
paymentDate: "2026-06-10T00:00:00.000Z",
walletId: 1,
});
expect(result.beloPrice).toBeUndefined();
@@ -794,6 +875,8 @@ describe("payExpense with BELO", () => {
describe("createNonPeriodicExpense with BELO", () => {
test("sets beloPrice and usdcEquivalent when BELO quote is available", async () => {
const dueDate = "2026-06-15T00:00:00.000Z";
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce({
sell: "1500.00",
@@ -812,11 +895,14 @@ describe("createNonPeriodicExpense with BELO", () => {
usdcEquivalent: data.usdcEquivalent,
}),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 70000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense({
description: "Ropa",
amount: 30000,
dueDate,
walletId: 1,
});
expect(result.beloPrice).toBe(1500);
@@ -824,6 +910,8 @@ describe("createNonPeriodicExpense with BELO", () => {
});
test("does not set BELO fields when no quote is available", async () => {
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.quoteHistory.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);
@@ -837,11 +925,14 @@ describe("createNonPeriodicExpense with BELO", () => {
status: PaymentStatus.PAYED,
}),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 97500 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await createNonPeriodicExpense({
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
walletId: 1,
});
expect(result.beloPrice).toBeUndefined();
@@ -938,3 +1029,402 @@ describe("updateExpense with BELO", () => {
expect(mockPrisma.quoteHistory.findFirst).not.toHaveBeenCalled();
});
});
describe("updateExpense wallet adjustments", () => {
test("creates WITHDRAWAL when amount increases on a PAYED expense", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 100000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 90000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 35000,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.amount).toBe(35000);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: -10000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "WITHDRAWAL",
amount: -10000,
description: "Ajuste por edición de gasto",
},
});
});
test("creates DEPOSIT when amount decreases on a PAYED expense", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 35000,
amountPayed: 35000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 85000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 25000,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.amount).toBe(25000);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: 10000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "DEPOSIT",
amount: 10000,
description: "Ajuste por edición de gasto",
},
});
});
test("does not create wallet adjustment when amount is unchanged", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
const result = await updateExpense(1, {
amount: 25000,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.amount).toBe(25000);
expect(mockWalletUpdate).not.toHaveBeenCalled();
expect(mockMovementCreate).not.toHaveBeenCalled();
});
test("does not create wallet adjustment when existing expense has no walletId", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
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.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
const result = await updateExpense(1, {
amount: 35000,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.amount).toBe(35000);
expect(mockWalletUpdate).not.toHaveBeenCalled();
expect(mockMovementCreate).not.toHaveBeenCalled();
});
test("creates DEPOSIT refund when reverting PAYED expense to PENDING with walletId", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 25000,
dueDate: "2026-06-15T00:00:00.000Z",
status: "PENDING",
});
expect(result.status).toBe(PaymentStatus.PENDING);
expect(result.amountPayed).toBeNull();
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: 25000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "DEPOSIT",
amount: 25000,
description: "Anulación de gasto",
},
});
});
test("does not create refund when existing expense has no walletId for PENDING revert", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
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.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
const result = await updateExpense(1, {
amount: 25000,
dueDate: "2026-06-15T00:00:00.000Z",
status: "PENDING",
});
expect(result.status).toBe(PaymentStatus.PENDING);
expect(mockWalletUpdate).not.toHaveBeenCalled();
expect(mockMovementCreate).not.toHaveBeenCalled();
});
test("throws when wallet is not found during adjustment", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(null);
expect(
updateExpense(1, {
amount: 35000,
dueDate: "2026-06-15T00:00:00.000Z",
}),
).rejects.toThrow("Billetera no encontrada");
});
test("only creates refund (not amount adjustment) when both amount and status change", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 100000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 35000,
dueDate: "2026-06-15T00:00:00.000Z",
status: "PENDING",
});
expect(result.status).toBe(PaymentStatus.PENDING);
expect(mockWalletUpdate).toHaveBeenCalledTimes(1);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: 25000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "DEPOSIT",
amount: 25000,
description: "Anulación de gasto",
},
});
});
test("uses amountPayed for refund when expense has different amountPayed and amount", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 30000,
amountPayed: 28000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 103000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 30000,
dueDate: "2026-06-15T00:00:00.000Z",
status: "PENDING",
});
expect(result.status).toBe(PaymentStatus.PENDING);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: 28000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "DEPOSIT",
amount: 28000,
description: "Anulación de gasto",
},
});
});
test("creates WITHDRAWAL when amountPayed increases while amount stays the same", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 65000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 25000,
dueDate: "2026-06-15T00:00:00.000Z",
amountPayed: 35000,
});
expect(result.amount).toBe(25000);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: -10000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "WITHDRAWAL",
amount: -10000,
description: "Ajuste por edición de gasto",
},
});
});
test("creates adjustment based on amountPayed when both amount and amountPayed change", async () => {
const existing = {
id: 1,
status: PaymentStatus.PAYED,
amount: 25000,
amountPayed: 25000,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
dueDate: new Date("2026-06-15T00:00:00.000Z"),
year: 2026,
month: 6,
walletId: 1,
};
const wallet = { id: 1, balance: 75000, currency: "ARS" };
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
mockWalletFindUnique.mockResolvedValueOnce(wallet);
mockPrisma.expense.update.mockImplementationOnce(
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
);
mockWalletUpdate.mockResolvedValueOnce({ ...wallet, balance: 72000 });
mockMovementCreate.mockResolvedValueOnce({ id: 1 });
const result = await updateExpense(1, {
amount: 30000,
dueDate: "2026-06-15T00:00:00.000Z",
amountPayed: 28000,
});
expect(result.amount).toBe(30000);
expect(mockWalletUpdate).toHaveBeenCalledWith({
where: { id: 1 },
data: { balance: { increment: -3000 } },
});
expect(mockMovementCreate).toHaveBeenCalledWith({
data: {
walletId: 1,
type: "WITHDRAWAL",
amount: -3000,
description: "Ajuste por edición de gasto",
},
});
});
});