From 33120deed694913f498d3acc273d6434542ba464 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 17 Jun 2026 10:54:43 -0300 Subject: [PATCH] feat(expenses): add walletId to expense model and implement wallet adjustments in expense operations --- .../migration.sql | 8 + apps/backend/prisma/schema.prisma | 4 + .../src/modules/expenses/expenses.service.ts | 64 +++ apps/backend/tests/expenses.test.ts | 494 +++++++++++++++++- 4 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql diff --git a/apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql b/apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql new file mode 100644 index 0000000..d2d6190 --- /dev/null +++ b/apps/backend/prisma/migrations/20260617132653_add_wallet_id_to_expense/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "expenses" ADD COLUMN "walletId" INTEGER; + +-- CreateIndex +CREATE INDEX "expenses_walletId_idx" ON "expenses"("walletId"); + +-- AddForeignKey +ALTER TABLE "expenses" ADD CONSTRAINT "expenses_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7ed6423..0096a3b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -139,6 +139,7 @@ model Wallet { updatedAt DateTime @updatedAt movements WalletMovement[] + expenses Expense[] @@map("wallets") } @@ -187,6 +188,9 @@ model Expense { status PaymentStatus @default(PENDING) dueDate DateTime paymentDate DateTime? + walletId Int? + wallet Wallet? @relation(fields: [walletId], references: [id]) + @@index([walletId]) @@map("expenses") } \ No newline at end of file diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 3175182..f8667cc 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -259,6 +259,7 @@ export async function createNonPeriodicExpense( year, month, status: PaymentStatus.PAYED, + walletId: data.walletId, }; if (isUSDC && data.usdcConversionRate) { @@ -325,6 +326,7 @@ export async function payExpense( status: PaymentStatus.PAYED, amountPayed: data.amountPayed, paymentDate, + walletId: data.walletId, }; if (isUSDC && data.usdcConversionRate) { @@ -372,12 +374,29 @@ export async function updateExpense( month, }; + let walletAdjustment: { + walletId: number; + amount: number; + description: string; + } | null = null; + if (data.status === "PENDING") { updateData.status = PaymentStatus.PENDING; updateData.amountPayed = null; updateData.paymentDate = null; updateData.beloPrice = null; updateData.usdcEquivalent = null; + + if (existing.status === PaymentStatus.PAYED && existing.walletId) { + const refundAmount = existing.amountPayed + ? Number(existing.amountPayed) + : Number(existing.amount); + walletAdjustment = { + walletId: existing.walletId, + amount: refundAmount, + description: "Anulación de gasto", + }; + } } else if (existing.status === PaymentStatus.PAYED) { if (data.amountPayed !== undefined) { updateData.amountPayed = data.amountPayed; @@ -400,6 +419,51 @@ export async function updateExpense( updateData.usdcEquivalent = null; } } + + if (existing.walletId) { + const oldPayed = existing.amountPayed + ? Number(existing.amountPayed) + : Number(existing.amount); + const newPayed = data.amountPayed !== undefined + ? Number(data.amountPayed) + : Number(data.amount); + + if (newPayed !== oldPayed) { + const difference = newPayed - oldPayed; + walletAdjustment = { + walletId: existing.walletId, + amount: -difference, + description: "Ajuste por edición de gasto", + }; + } + } + } + + if (walletAdjustment) { + const wallet = await prisma.wallet.findUnique({ + where: { id: walletAdjustment.walletId }, + }); + if (!wallet) { + throw new Error("Billetera no encontrada"); + } + + const [result] = await prisma.$transaction([ + prisma.expense.update({ where: { id }, data: updateData }), + prisma.wallet.update({ + where: { id: walletAdjustment.walletId }, + data: { balance: { increment: walletAdjustment.amount } }, + }), + prisma.walletMovement.create({ + data: { + walletId: walletAdjustment.walletId, + type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL", + amount: walletAdjustment.amount, + description: walletAdjustment.description, + }, + }), + ]); + cache.invalidateByPrefix("expenses:"); + return result; } const result = await prisma.expense.update({ diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index d64545f..de2c907 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -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.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>, )) { @@ -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", + }, + }); + }); +});