import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, Prisma, Type } from '@prisma/client'; import { ActivitiesService } from './activities.service'; describe('ActivitiesService cash balance lifecycle', () => { const date = parseDate('2024-01-01'); const today = resetHours(new Date()); const userId = 'user-id'; const accountId = 'account-id'; const where = { id: 'activity-id', userId }; let balances: Balance[]; let orders: ReturnType[]; let service: ActivitiesService; let emit: jest.Mock; let getSymbolProfilesByIds: jest.Mock; let transaction: jest.Mock; let updateAccountBalance: jest.Mock; let transactionClient: { accountBalance: { updateMany: jest.Mock }; order: { create: jest.Mock; delete: jest.Mock; deleteMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; }; beforeEach(() => { orders = [orderFixture()]; balances = [ balanceFixture('2023-12-01', 1000), balanceFixture('2024-01-01', 698), balanceFixture('2024-02-01', 898), { accountId, userId, date: today, value: 1198 }, balanceFixture('2024-01-01', 2000, 'other-account'), balanceFixture('2024-02-01', 2200, 'other-account'), { accountId: 'other-account', userId, date: today, value: 2500 }, balanceFixture('2024-01-01', 3000, accountId, 'other-user') ]; transactionClient = { accountBalance: { updateMany: jest.fn(async ({ data, where }) => { const matchingBalances = balances.filter((balance) => { return ( balance.accountId === where.accountId && balance.userId === where.userId && balance.date >= where.date.gte ); }); matchingBalances.forEach((balance) => { balance.value += data.value.increment; }); return { count: matchingBalances.length }; }) }, order: { create: jest.fn(async ({ data }) => { const order = { ...orderFixture({ accountBalanceChange: null }), ...data, accountId: data.account?.connect?.id_userId.id ?? null, userId: data.user.connect.id, SymbolProfile: data.SymbolProfile.connectOrCreate.create }; orders.push(order); return { ...order }; }), delete: jest.fn(async ({ where }) => { const id = where.id ?? where.id_userId.id; const index = orders.findIndex((order) => order.id === id); const [deletedOrder] = orders.splice(index, 1); return deletedOrder; }), deleteMany: jest.fn(async ({ where }) => { const previousCount = orders.length; orders = orders.filter((order) => { return !(where.id?.in ?? []).includes(order.id); }); return { count: previousCount - orders.length }; }), findMany: jest.fn(async () => orders.map((order) => ({ ...order }))), findUnique: jest.fn(async ({ where }) => { const id = where.id ?? where.id_userId.id; const order = orders.find((order) => order.id === id); return { ...order, SymbolProfile: { ...order.SymbolProfile } }; }), update: jest.fn(async ({ data, where }) => { const id = where.id ?? where.id_userId.id; const order = orders.find((order) => order.id === id); const { account, SymbolProfile, ...fields } = data; delete fields.tags; Object.assign(order, fields); if (account?.connect) { order.accountId = account.connect.id_userId.id; } else if (account?.disconnect) { order.accountId = null; } if (SymbolProfile?.update) { Object.assign(order.SymbolProfile, SymbolProfile.update); } return { ...order, SymbolProfile: { ...order.SymbolProfile } }; }) } }; // Simulate commit and rollback while keeping the cash balance service a // boundary mock; its own tests verify historical lookup and chain updates. transaction = jest.fn(async (operation) => { const originalOrders = structuredClone(orders); const originalBalances = structuredClone(balances); try { return await operation(transactionClient); } catch (error) { orders = originalOrders; balances = originalBalances; throw error; } }); updateAccountBalance = jest.fn( async ({ accountId: id, amount, date: activityDate, userId: owner }) => { const normalizedDate = resetHours(activityDate); const accountBalances = balances .filter((balance) => { return balance.accountId === id && balance.userId === owner; }) .sort((left, right) => left.date.getTime() - right.date.getTime()); const previousBalance = accountBalances .filter((balance) => balance.date <= normalizedDate) .at(-1); const sameDayBalance = accountBalances.find((balance) => { return balance.date.getTime() === normalizedDate.getTime(); }); if (!sameDayBalance) { balances.push({ accountId: id, userId: owner, date: normalizedDate, value: (previousBalance?.value ?? 0) + amount }); } accountBalances .filter((balance) => balance.date >= normalizedDate) .forEach((balance) => { balance.value += amount; }); return amount; } ); emit = jest.fn(); getSymbolProfilesByIds = jest .fn() .mockResolvedValue([orderFixture().SymbolProfile]); service = new ActivitiesService( null, { updateAccountBalance } as unknown as AccountService, null, { getBenchmarkAssetProfiles: jest .fn() .mockResolvedValue([{ id: 'asset-profile-id' }]) } as unknown as BenchmarkService, { gatherSymbols: jest.fn() } as unknown as DataGatheringService, null, { emit } as unknown as EventEmitter2, null, null, { $transaction: transaction, order: transactionClient.order } as unknown as PrismaService, { getSymbolProfilesByIds } as unknown as SymbolProfileService, { validateTagIds: jest.fn() } as unknown as TagService ); }); describe('create', () => { it.each([ { type: Type.DEPOSIT, amount: 298 }, { type: Type.WITHDRAWAL, amount: -302 } ])( 'updates historical cash balances by default for $type', async ({ amount, type }) => { await create(undefined, true, type); expect(orders[0].updateAccountBalance).toBe(true); expect(orders[0].accountBalanceChange).toBe(amount); expect(accountValues()).toEqual([ 1000, 698 + amount, 898 + amount, 1198 + amount ]); expect(updateAccountBalance).toHaveBeenCalledWith( expect.objectContaining({ amount, currency: 'USD', date }) ); } ); it.each([Type.DEPOSIT, Type.WITHDRAWAL])( 'honors opting out of cash updates for %s', async (type) => { await create(false, true, type); expect(orders[0].updateAccountBalance).toBe(false); expect(orders[0].accountBalanceChange).toBeNull(); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(updateAccountBalance).not.toHaveBeenCalled(); } ); it('keeps cash updates disabled by default for other activity types', async () => { await create(undefined); expect(orders[0].updateAccountBalance).toBe(false); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it.each([Type.DEPOSIT, Type.WITHDRAWAL])( 'requires an account when cash updates are enabled for %s', async (type) => { await expect(create(true, false, type)).rejects.toThrow( 'Select an account' ); expect(orders).toEqual([]); expect(updateAccountBalance).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); } ); it('stores an untracked cash activity when no account is selected', async () => { await create(undefined, false, Type.DEPOSIT); expect(orders[0].updateAccountBalance).toBe(false); expect(orders[0].accountBalanceChange).toBeNull(); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('persists the cash balance preference and exact change together with the balance chain', async () => { await create(true); expect(orders[0].updateAccountBalance).toBe(true); expect(orders[0].accountBalanceChange).toBe(-302); expect(accountValues()).toEqual([1000, 396, 596, 896]); expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }); it('records disabled cash updates without changing balances', async () => { await create(false); expect(orders[0].updateAccountBalance).toBe(false); expect(orders[0].accountBalanceChange).toBeNull(); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('retains the enabled preference without an account and applies cash when an account is attached later', async () => { await create(true, false); expect(orders[0].updateAccountBalance).toBe(true); expect(orders[0].accountBalanceChange).toBeNull(); expect(updateAccountBalance).not.toHaveBeenCalled(); await update({ account: { connect: { id_userId: { id: accountId, userId } } } }); expect(orders[0].accountBalanceChange).toBe(-302); expect(accountValues()).toEqual([1000, 396, 596, 896]); }); it('rolls back creation and skips events when the cash chain update fails', async () => { updateAccountBalance.mockRejectedValue(new Error('Cash update failed')); await expect(create(true)).rejects.toThrow('Cash update failed'); expect(orders).toEqual([]); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(emit).not.toHaveBeenCalled(); }); it('rolls back the applied balance chain when persisting the exact cash change fails', async () => { transactionClient.order.update.mockRejectedValue( new Error('Cash tracking failed') ); await expect(create(true)).rejects.toThrow('Cash tracking failed'); expect(updateAccountBalance).toHaveBeenCalledTimes(1); expect(orders).toEqual([]); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(emit).not.toHaveBeenCalled(); }); }); describe('mixed bank statement', () => { it('includes purchases, their fees and interest in historical cash balances even when rows arrive out of date order', async () => { orders = []; balances = []; const statement = [ { date: '2024-09-21', fee: 1, type: Type.BUY, unitPrice: 2890.4 }, { date: '2024-09-30', fee: 0, type: Type.INTEREST, unitPrice: 4.5 }, { date: '2024-09-17', fee: 0, type: Type.DEPOSIT, unitPrice: 5000 }, { date: '2024-09-18', fee: 1, type: Type.BUY, unitPrice: 201.89 } ]; for (const { date: dateString, fee, type, unitPrice } of statement) { await service.createActivity({ accountId, fee, type, unitPrice, currency: 'USD', date: parseDate(dateString), id: `${type}-${dateString}`, quantity: 1, SymbolProfile: { connectOrCreate: { create: { currency: 'USD', dataSource: DataSource.MANUAL, symbol: 'Bank activity' }, where: { dataSource_symbol: { dataSource: DataSource.MANUAL, symbol: 'Bank activity' } } } }, updateAccountBalance: true, user: { connect: { id: userId } }, userId }); } expect(accountValues()).toEqual([5000, 4797.11, 1905.71, 1910.21]); expect( orders.map(({ accountBalanceChange }) => accountBalanceChange) ).toEqual([-2891.4, 4.5, 5000, -202.89]); expect( orders.every(({ updateAccountBalance }) => updateAccountBalance) ).toBe(true); }); }); describe('edit', () => { it('reverses a deposit before changing it to a withdrawal', async () => { await create(undefined, true, Type.DEPOSIT); updateAccountBalance.mockClear(); await update({ type: Type.WITHDRAWAL }); expect(orders[0].accountBalanceChange).toBe(-302); expect(accountValues()).toEqual([1000, 396, 596, 896]); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledWith({ data: { value: { increment: -298 } }, where: { accountId, userId, date: { gte: resetHours(date) } } }); expect(updateAccountBalance).toHaveBeenCalledWith( expect.objectContaining({ amount: -302 }) ); }); it('moves a deposit to another account and backdated balance chain', async () => { await create(undefined, true, Type.DEPOSIT); await update({ account: { connect: { id_userId: { id: 'other-account', userId } } }, date: parseDate('2024-02-01'), quantity: 1, unitPrice: 400 }); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(accountValues('other-account')).toEqual([2000, 2598, 2898]); expect(orders[0].accountBalanceChange).toBe(398); }); it('keeps the converted cash change when editing a withdrawal note and reverses it exactly on deletion', async () => { await create(undefined, true, Type.WITHDRAWAL); orders[0].accountBalanceChange = -271.8; updateAccountBalance.mockClear(); await update({ comment: 'Bank transfer' }); expect(orders[0].accountBalanceChange).toBe(-271.8); expect(updateAccountBalance).not.toHaveBeenCalled(); await service.deleteActivity(where); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledWith({ data: { value: { increment: 271.8 } }, where: { accountId, userId, date: { gte: resetHours(date) } } }); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('rolls back detaching a tracked cash activity without disabling cash updates', async () => { await create(undefined, true, Type.DEPOSIT); emit.mockClear(); await expect(update({ account: { disconnect: true } })).rejects.toThrow( 'Select an account' ); expect(orders[0].accountId).toBe(accountId); expect(orders[0].accountBalanceChange).toBe(298); expect(accountValues()).toEqual([1000, 996, 1196, 1496]); expect(emit).not.toHaveBeenCalled(); }); it('can detach a deposit while explicitly disabling cash updates', async () => { await create(undefined, true, Type.DEPOSIT); await update({ account: { disconnect: true }, updateAccountBalance: false }); expect(orders[0].accountId).toBeNull(); expect(orders[0].accountBalanceChange).toBeNull(); expect(accountValues()).toEqual([1000, 698, 898, 1198]); }); it('reverses the original cash change and applies the new amount to every later balance', async () => { await update({ quantity: 4 }); expect(accountValues()).toEqual([1000, 598, 798, 1098]); expect(accountValues('other-account')).toEqual([2000, 2200, 2500]); expect(balances.at(-1).value).toBe(3000); expect(orders[0].accountBalanceChange).toBe(-402); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledWith({ data: { value: { increment: 302 } }, where: { accountId, userId, date: { gte: resetHours(date) } } }); expect(updateAccountBalance).toHaveBeenCalledWith({ accountId, amount: -402, currency: 'USD', date, transaction: transactionClient, userId }); expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }); it('moves a backdated cash change from its original date to its edited date', async () => { await update({ date: parseDate('2024-03-01') }); expect(accountValues()).toEqual([1000, 1000, 1200, 898, 1198]); expect(orders[0].date).toEqual(parseDate('2024-03-01')); }); it('restores the original account chain when a transaction moves to another account', async () => { await update({ account: { connect: { id_userId: { id: 'other-account', userId } } } }); expect(accountValues()).toEqual([1000, 1000, 1200, 1500]); expect(accountValues('other-account')).toEqual([1698, 1898, 2198]); expect(orders[0].accountBalanceChange).toBe(-302); }); it.each([ { fee: 12 }, { unitPrice: 110 }, { type: Type.SELL }, { currency: 'EUR' } ])('reapplies cash when an economic field changes: %j', async (data) => { await update(data); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledTimes( 1 ); expect(updateAccountBalance).toHaveBeenCalledTimes(1); }); it('preserves the exact recorded cash change for a metadata-only edit without reconverting currency', async () => { orders[0].accountBalanceChange = -271.8; await update({ comment: 'Corrected note' }); expect(orders[0].accountBalanceChange).toBe(-271.8); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect( transactionClient.accountBalance.updateMany ).not.toHaveBeenCalled(); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('reverses the recorded converted amount rather than recomputing the original amount with current exchange rates', async () => { orders[0].accountBalanceChange = -271.8; updateAccountBalance.mockResolvedValue(-321.6); await update({ quantity: 4 }); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledWith({ data: { value: { increment: 271.8 } }, where: { accountId, userId, date: { gte: resetHours(date) } } }); expect(orders[0].accountBalanceChange).toBe(-321.6); }); it('leaves imported or previously untracked transactions out of the cash balance chain', async () => { orders[0].accountBalanceChange = null; orders[0].updateAccountBalance = false; await update({ quantity: 4 }); expect(orders[0].accountBalanceChange).toBeNull(); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect( transactionClient.accountBalance.updateMany ).not.toHaveBeenCalled(); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('can opt an untracked transaction into cash balance updates', async () => { orders[0].accountBalanceChange = null; orders[0].updateAccountBalance = false; await update({ updateAccountBalance: true, quantity: 4 }); expect( transactionClient.accountBalance.updateMany ).not.toHaveBeenCalled(); expect(accountValues()).toEqual([1000, 296, 496, 796]); expect(orders[0].accountBalanceChange).toBe(-402); expect(orders[0].updateAccountBalance).toBe(true); }); it('reverses and clears tracking when cash balance updates are explicitly disabled', async () => { await update({ updateAccountBalance: false }); expect(accountValues()).toEqual([1000, 1000, 1200, 1500]); expect(orders[0].accountBalanceChange).toBeNull(); expect(orders[0].updateAccountBalance).toBe(false); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('reverses and clears tracking when the transaction is detached from its account', async () => { await update({ account: { disconnect: true } }); expect(accountValues()).toEqual([1000, 1000, 1200, 1500]); expect(orders[0].accountBalanceChange).toBeNull(); expect(orders[0].updateAccountBalance).toBe(true); expect(updateAccountBalance).not.toHaveBeenCalled(); }); it('preserves tracking for a zero cash change so a later edit updates the chain', async () => { orders[0] = orderFixture({ accountBalanceChange: 0, fee: 0, quantity: 0 }); await update({ quantity: 1 }); expect(updateAccountBalance).toHaveBeenCalledWith( expect.objectContaining({ amount: -100 }) ); expect(orders[0].accountBalanceChange).toBe(-100); expect(accountValues()).toEqual([1000, 598, 798, 1098]); }); it('rolls back the activity edit and original balance reversal when applying the new cash change fails', async () => { updateAccountBalance.mockRejectedValue(new Error('Cash update failed')); await expect(update({ quantity: 4 })).rejects.toThrow( 'Cash update failed' ); expect(orders[0].quantity).toBe(3); expect(orders[0].accountBalanceChange).toBe(-302); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(emit).not.toHaveBeenCalled(); }); }); describe('delete', () => { it('reverses the original cash change through today when deleting a tracked transaction', async () => { await service.deleteActivity(where); expect(orders).toEqual([]); expect(accountValues()).toEqual([1000, 1000, 1200, 1500]); expect(accountValues('other-account')).toEqual([2000, 2200, 2500]); expect(updateAccountBalance).not.toHaveBeenCalled(); expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); }); it.each([null, 0])( 'does not change balances when deleting a transaction with recorded cash change %s', async (accountBalanceChange) => { orders[0].accountBalanceChange = accountBalanceChange; await service.deleteActivity(where); expect(orders).toEqual([]); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect( transactionClient.accountBalance.updateMany ).not.toHaveBeenCalled(); expect(updateAccountBalance).not.toHaveBeenCalled(); } ); it('rolls back deletion and skips cleanup and events when reversing the balance chain fails', async () => { transactionClient.accountBalance.updateMany.mockRejectedValue( new Error('Balance reversal failed') ); await expect(service.deleteActivity(where)).rejects.toThrow( 'Balance reversal failed' ); expect(orders).toHaveLength(1); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(getSymbolProfilesByIds).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }); }); describe('bulk delete', () => { it('reverses every matching tracked transaction even when they share an asset profile', async () => { orders.push( orderFixture({ accountBalanceChange: -100, date: parseDate('2024-02-01'), id: 'second-activity', quantity: 1, fee: 0 }), orderFixture({ accountBalanceChange: null, id: 'imported-activity', updateAccountBalance: false }) ); const result = await service.deleteActivities({ userId }); expect(result).toBe(3); expect(orders).toEqual([]); expect(accountValues()).toEqual([1000, 1000, 1300, 1600]); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledTimes( 2 ); expect(transactionClient.order.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['activity-id', 'second-activity', 'imported-activity'] }, userId } }); expect(getSymbolProfilesByIds).toHaveBeenCalledWith(['asset-profile-id']); expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 60000 }); }); it('combines exact cash reversals for transactions from the same account and date', async () => { orders[0].accountBalanceChange = -0.1; orders.push( orderFixture({ accountBalanceChange: -0.2, id: 'second-activity' }) ); await service.deleteActivities({ userId }); expect(orders).toEqual([]); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledTimes( 1 ); expect(transactionClient.accountBalance.updateMany).toHaveBeenCalledWith({ data: { value: { increment: 0.3 } }, where: { accountId, userId, date: { gte: resetHours(date) } } }); expect(accountValues()).toEqual([1000, 698.3, 898.3, 1198.3]); }); it('rolls back the entire bulk delete when one balance reversal fails', async () => { orders.push( orderFixture({ date: parseDate('2024-02-01'), id: 'second-activity' }) ); transactionClient.accountBalance.updateMany .mockImplementationOnce( transactionClient.accountBalance.updateMany.getMockImplementation() ) .mockRejectedValueOnce(new Error('Balance reversal failed')); await expect(service.deleteActivities({ userId })).rejects.toThrow( 'Balance reversal failed' ); expect(orders).toHaveLength(2); expect(accountValues()).toEqual([1000, 698, 898, 1198]); expect(getSymbolProfilesByIds).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }); }); function update( data: Parameters[0]['data'] & { updateAccountBalance?: boolean; } ) { const original = orders[0]; return service.updateActivity({ data: { currency: original.currency, date: original.date, fee: original.fee, quantity: original.quantity, SymbolProfile: { connect: { dataSource_symbol: { dataSource: original.SymbolProfile.dataSource, symbol: original.SymbolProfile.symbol } }, update: { currency: original.SymbolProfile.currency, name: original.SymbolProfile.symbol } }, type: original.type, unitPrice: original.unitPrice, ...data }, originalDate: original.date as Date, userId, where }); } function create( updateAccountBalance: boolean | undefined, withAccount = true, type: Type = Type.BUY ) { orders = []; return service.createActivity({ accountId: withAccount ? accountId : undefined, currency: 'USD', date, fee: 2, quantity: 3, SymbolProfile: { connectOrCreate: { create: { currency: 'USD', dataSource: DataSource.MANUAL, symbol: 'Asset' }, where: { dataSource_symbol: { dataSource: DataSource.MANUAL, symbol: 'Asset' } } } }, type, unitPrice: 100, updateAccountBalance, user: { connect: { id: userId } }, userId }); } function accountValues(id = accountId) { return balances .filter( (balance) => balance.accountId === id && balance.userId === userId ) .sort((left, right) => left.date.getTime() - right.date.getTime()) .map(({ value }) => value); } function balanceFixture( dateString: string, value: number, id = accountId, owner = userId ): Balance { return { value, accountId: id, userId: owner, date: resetHours(parseDate(dateString)) }; } function orderFixture( overrides: Partial = {} ) { return { accountBalanceChange: -302, accountId, accountUserId: userId, comment: null, createdAt: date, currency: 'USD', date, fee: 2, id: 'activity-id', quantity: 3, symbolProfileId: 'asset-profile-id', tags: [], type: Type.BUY, unitPrice: 100, updateAccountBalance: true, updatedAt: date, userId, ...overrides, SymbolProfile: { activitiesCount: 0, currency: 'USD', dataSource: DataSource.MANUAL, id: 'asset-profile-id', symbol: 'Asset', userId, watchedByCount: 0 } }; } }); interface Balance { accountId: string; date: Date; userId: string; value: number; }