mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- Use historical balances and propagate cash changes to later snapshots - Persist the cash-update flag and exact applied amount - Reconcile edits and single/bulk deletions atomically - Leave legacy and imported transactions untracked - Keep internal cash amounts out of API responses - Add schema migration and regression testspull/8155/head
17 changed files with 1893 additions and 167 deletions
@ -0,0 +1,267 @@ |
|||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { EventEmitter2 } from '@nestjs/event-emitter'; |
||||
|
import { AccountBalance, Prisma } from '@prisma/client'; |
||||
|
|
||||
|
import { AccountBalanceService } from './account-balance.service'; |
||||
|
|
||||
|
describe('AccountBalanceService', () => { |
||||
|
let accountBalanceService: AccountBalanceService; |
||||
|
let accountBalance: AccountBalance; |
||||
|
let emit: jest.Mock; |
||||
|
let transaction: jest.Mock; |
||||
|
let updateMany: jest.Mock; |
||||
|
let upsert: jest.Mock; |
||||
|
|
||||
|
const accountId = 'account-id'; |
||||
|
const userId = 'user-id'; |
||||
|
const normalizedDate = new Date('2024-01-15T00:00:00.000Z'); |
||||
|
const date = new Date(2024, 0, 15, 15, 20, 30).toISOString(); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
accountBalance = { |
||||
|
accountId, |
||||
|
userId, |
||||
|
createdAt: normalizedDate, |
||||
|
date: normalizedDate, |
||||
|
id: 'balance-id', |
||||
|
updatedAt: normalizedDate, |
||||
|
value: 875 |
||||
|
}; |
||||
|
emit = jest.fn(); |
||||
|
upsert = jest.fn().mockResolvedValue(accountBalance); |
||||
|
updateMany = jest.fn().mockResolvedValue({ count: 3 }); |
||||
|
transaction = jest.fn((operations: Promise<unknown>[]) => { |
||||
|
return Promise.all(operations); |
||||
|
}); |
||||
|
|
||||
|
accountBalanceService = new AccountBalanceService( |
||||
|
{ emit } as unknown as EventEmitter2, |
||||
|
null, |
||||
|
{ |
||||
|
$transaction: transaction, |
||||
|
accountBalance: { updateMany, upsert } |
||||
|
} as unknown as PrismaService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('createOrUpdateAccountBalance', () => { |
||||
|
it.each([-125, 125])( |
||||
|
'atomically applies a cash change of %s to later balances of the same account and user', |
||||
|
async (balanceChange) => { |
||||
|
const result = await updateBalance(balanceChange); |
||||
|
|
||||
|
expect(upsert).toHaveBeenCalledWith({ |
||||
|
create: { |
||||
|
account: { |
||||
|
connect: { |
||||
|
id_userId: { id: accountId, userId } |
||||
|
} |
||||
|
}, |
||||
|
date: normalizedDate, |
||||
|
value: 875 |
||||
|
}, |
||||
|
update: { value: 875 }, |
||||
|
where: { |
||||
|
accountId_date: { accountId, date: normalizedDate } |
||||
|
} |
||||
|
}); |
||||
|
expect(updateMany).toHaveBeenCalledWith({ |
||||
|
data: { value: { increment: balanceChange } }, |
||||
|
where: { |
||||
|
accountId, |
||||
|
userId, |
||||
|
date: { gt: normalizedDate } |
||||
|
} |
||||
|
}); |
||||
|
expect(transaction).toHaveBeenCalledTimes(1); |
||||
|
expect(transaction).toHaveBeenCalledWith([ |
||||
|
upsert.mock.results[0].value, |
||||
|
updateMany.mock.results[0].value |
||||
|
]); |
||||
|
expect(result).toBe(accountBalance); |
||||
|
expect(emit).toHaveBeenCalledTimes(1); |
||||
|
expect(emit).toHaveBeenCalledWith( |
||||
|
PortfolioChangedEvent.getName(), |
||||
|
new PortfolioChangedEvent({ userId }) |
||||
|
); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each([undefined, 0])( |
||||
|
'preserves absolute balance edits without a cash change (%s)', |
||||
|
async (balanceChange) => { |
||||
|
const result = await updateBalance(balanceChange); |
||||
|
|
||||
|
expect(upsert).toHaveBeenCalledTimes(1); |
||||
|
expect(upsert).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ |
||||
|
create: expect.objectContaining({ |
||||
|
date: normalizedDate, |
||||
|
value: 875 |
||||
|
}), |
||||
|
update: { value: 875 } |
||||
|
}) |
||||
|
); |
||||
|
expect(updateMany).not.toHaveBeenCalled(); |
||||
|
expect(transaction).not.toHaveBeenCalled(); |
||||
|
expect(result).toBe(accountBalance); |
||||
|
expect(emit).toHaveBeenCalledTimes(1); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('emits the portfolio change only after the entire balance chain transaction succeeds', async () => { |
||||
|
let commit: (results: [AccountBalance, { count: number }]) => void; |
||||
|
transaction.mockReturnValue( |
||||
|
new Promise((resolve) => { |
||||
|
commit = resolve; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const pendingBalance = updateBalance(-125); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(transaction).toHaveBeenCalledTimes(1); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
|
||||
|
commit([accountBalance, { count: 3 }]); |
||||
|
|
||||
|
await expect(pendingBalance).resolves.toBe(accountBalance); |
||||
|
expect(emit).toHaveBeenCalledTimes(1); |
||||
|
expect(emit).toHaveBeenCalledWith( |
||||
|
PortfolioChangedEvent.getName(), |
||||
|
new PortfolioChangedEvent({ userId }) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('does not emit a portfolio change when the balance chain transaction fails', async () => { |
||||
|
const error = new Error('Balance chain update failed'); |
||||
|
transaction.mockRejectedValue(error); |
||||
|
|
||||
|
await expect(updateBalance(-125)).rejects.toBe(error); |
||||
|
|
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('does not emit a portfolio change when an absolute balance edit fails', async () => { |
||||
|
const error = new Error('Balance update failed'); |
||||
|
upsert.mockRejectedValue(error); |
||||
|
|
||||
|
await expect(updateBalance()).rejects.toBe(error); |
||||
|
|
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it.each([-125, 125, undefined, 0])( |
||||
|
'uses the supplied transaction for a cash change of %s without opening another transaction or emitting before commit', |
||||
|
async (balanceChange) => { |
||||
|
const transactionUpsert = jest.fn().mockResolvedValue(accountBalance); |
||||
|
const transactionUpdateMany = jest.fn().mockResolvedValue({ count: 3 }); |
||||
|
const transactionClient = { |
||||
|
accountBalance: { |
||||
|
updateMany: transactionUpdateMany, |
||||
|
upsert: transactionUpsert |
||||
|
} |
||||
|
} as unknown as Prisma.TransactionClient; |
||||
|
|
||||
|
const result = await updateBalance(balanceChange, transactionClient); |
||||
|
|
||||
|
expect(transactionUpsert).toHaveBeenCalledWith({ |
||||
|
create: { |
||||
|
account: { connect: { id_userId: { id: accountId, userId } } }, |
||||
|
date: normalizedDate, |
||||
|
value: 875 |
||||
|
}, |
||||
|
update: { value: 875 }, |
||||
|
where: { accountId_date: { accountId, date: normalizedDate } } |
||||
|
}); |
||||
|
|
||||
|
if (balanceChange) { |
||||
|
expect(transactionUpdateMany).toHaveBeenCalledWith({ |
||||
|
data: { value: { increment: balanceChange } }, |
||||
|
where: { accountId, userId, date: { gt: normalizedDate } } |
||||
|
}); |
||||
|
} else { |
||||
|
expect(transactionUpdateMany).not.toHaveBeenCalled(); |
||||
|
} |
||||
|
|
||||
|
expect(upsert).not.toHaveBeenCalled(); |
||||
|
expect(updateMany).not.toHaveBeenCalled(); |
||||
|
expect(transaction).not.toHaveBeenCalled(); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
expect(result).toBe(accountBalance); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('waits for later balances to update inside the supplied transaction', async () => { |
||||
|
let finishUpdate: (result: { count: number }) => void; |
||||
|
const transactionClient = { |
||||
|
accountBalance: { |
||||
|
upsert: jest.fn().mockResolvedValue(accountBalance), |
||||
|
updateMany: jest.fn().mockReturnValue( |
||||
|
new Promise((resolve) => { |
||||
|
finishUpdate = resolve; |
||||
|
}) |
||||
|
) |
||||
|
} |
||||
|
} as unknown as Prisma.TransactionClient; |
||||
|
const onResolved = jest.fn(); |
||||
|
const pendingBalance = updateBalance(-125, transactionClient).then( |
||||
|
onResolved |
||||
|
); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(onResolved).not.toHaveBeenCalled(); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
|
||||
|
finishUpdate({ count: 3 }); |
||||
|
await pendingBalance; |
||||
|
|
||||
|
expect(onResolved).toHaveBeenCalledWith(accountBalance); |
||||
|
expect(transaction).not.toHaveBeenCalled(); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it.each(['upsert', 'updateMany'])( |
||||
|
'propagates a failed %s from the supplied transaction without emitting a portfolio change', |
||||
|
async (operation) => { |
||||
|
const error = new Error('Balance chain update failed'); |
||||
|
const accountBalanceClient = { |
||||
|
upsert: jest.fn().mockResolvedValue(accountBalance), |
||||
|
updateMany: jest.fn().mockResolvedValue({ count: 3 }) |
||||
|
}; |
||||
|
accountBalanceClient[operation].mockRejectedValue(error); |
||||
|
const transactionClient = { |
||||
|
accountBalance: accountBalanceClient |
||||
|
} as unknown as Prisma.TransactionClient; |
||||
|
|
||||
|
await expect(updateBalance(-125, transactionClient)).rejects.toBe( |
||||
|
error |
||||
|
); |
||||
|
|
||||
|
expect(upsert).not.toHaveBeenCalled(); |
||||
|
expect(updateMany).not.toHaveBeenCalled(); |
||||
|
expect(transaction).not.toHaveBeenCalled(); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
function updateBalance( |
||||
|
balanceChange?: number, |
||||
|
transactionClient?: Prisma.TransactionClient |
||||
|
) { |
||||
|
return accountBalanceService.createOrUpdateAccountBalance({ |
||||
|
accountId, |
||||
|
balanceChange, |
||||
|
date, |
||||
|
userId, |
||||
|
balance: 875, |
||||
|
transaction: transactionClient |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
@ -0,0 +1,413 @@ |
|||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { resetHours } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { BadRequestException } from '@nestjs/common'; |
||||
|
import { AccountBalance, Prisma } from '@prisma/client'; |
||||
|
|
||||
|
import { AccountService } from './account.service'; |
||||
|
|
||||
|
describe('AccountService', () => { |
||||
|
let accountService: AccountService; |
||||
|
let balances: (Pick<AccountBalance, 'date' | 'value'> & |
||||
|
Partial<Pick<AccountBalance, 'accountId' | 'userId'>>)[]; |
||||
|
let createOrUpdateAccountBalance: jest.Mock; |
||||
|
let findUnique: jest.Mock; |
||||
|
let toCurrencyAtDate: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
jest.useFakeTimers(); |
||||
|
jest.setSystemTime(new Date('2024-06-15T12:00:00.000Z')); |
||||
|
balances = []; |
||||
|
|
||||
|
createOrUpdateAccountBalance = jest.fn( |
||||
|
({ |
||||
|
accountId, |
||||
|
balance, |
||||
|
balanceChange = 0, |
||||
|
date, |
||||
|
userId |
||||
|
}: Parameters< |
||||
|
AccountBalanceService['createOrUpdateAccountBalance'] |
||||
|
>[0]) => { |
||||
|
const balanceDate = resetHours(new Date(date)); |
||||
|
const accountBalances = balances.filter((snapshot) => { |
||||
|
return ( |
||||
|
(snapshot.accountId ?? 'account-id') === accountId && |
||||
|
(snapshot.userId ?? 'user-id') === userId |
||||
|
); |
||||
|
}); |
||||
|
const existingBalance = accountBalances.find( |
||||
|
({ date: snapshotDate }) => { |
||||
|
return snapshotDate.getTime() === balanceDate.getTime(); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
if (existingBalance) { |
||||
|
existingBalance.value = balance; |
||||
|
} else { |
||||
|
balances.push({ date: balanceDate, value: balance }); |
||||
|
} |
||||
|
|
||||
|
accountBalances.forEach((snapshot) => { |
||||
|
if (snapshot.date > balanceDate) { |
||||
|
snapshot.value += balanceChange; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
toCurrencyAtDate = jest.fn((amount: number) => { |
||||
|
return Promise.resolve(amount); |
||||
|
}); |
||||
|
|
||||
|
findUnique = jest.fn( |
||||
|
({ include, where: accountWhere }: Prisma.AccountFindUniqueArgs) => { |
||||
|
const { orderBy, take, where } = include.balances as { |
||||
|
orderBy: { date: 'asc' | 'desc' }; |
||||
|
take: number; |
||||
|
where: { date: { lte: Date } }; |
||||
|
}; |
||||
|
|
||||
|
return { |
||||
|
currency: 'EUR', |
||||
|
id: 'account-id', |
||||
|
userId: 'user-id', |
||||
|
balances: balances |
||||
|
.filter( |
||||
|
({ accountId = 'account-id', date, userId = 'user-id' }) => { |
||||
|
return ( |
||||
|
accountId === accountWhere.id_userId.id && |
||||
|
userId === accountWhere.id_userId.userId && |
||||
|
date <= where.date.lte |
||||
|
); |
||||
|
} |
||||
|
) |
||||
|
.sort((a, b) => { |
||||
|
return orderBy.date === 'desc' |
||||
|
? b.date.getTime() - a.date.getTime() |
||||
|
: a.date.getTime() - b.date.getTime(); |
||||
|
}) |
||||
|
.slice(0, take) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
accountService = new AccountService( |
||||
|
{ createOrUpdateAccountBalance } as unknown as AccountBalanceService, |
||||
|
null, |
||||
|
{ toCurrencyAtDate } as unknown as ExchangeRateDataService, |
||||
|
{ account: { findUnique } } as unknown as PrismaService, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
jest.useRealTimers(); |
||||
|
}); |
||||
|
|
||||
|
describe('updateAccountBalance', () => { |
||||
|
it('debits the historical balance and every later snapshot for that account, including today', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 }, |
||||
|
{ date: new Date('2024-06-01'), value: 5000 }, |
||||
|
{ date: new Date('2024-06-15'), value: 6000 }, |
||||
|
{ date: new Date('2024-07-01'), value: 9000 }, |
||||
|
{ |
||||
|
accountId: 'other-account-id', |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 2000 |
||||
|
}, |
||||
|
{ |
||||
|
date: new Date('2024-06-01'), |
||||
|
userId: 'other-user-id', |
||||
|
value: 3000 |
||||
|
} |
||||
|
]; |
||||
|
const date = new Date(2024, 0, 15, 15); |
||||
|
|
||||
|
await updateBalance({ amount: -125, date }); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith({ |
||||
|
accountId: 'account-id', |
||||
|
balance: 875, |
||||
|
balanceChange: -125, |
||||
|
date: date.toISOString(), |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-01'), |
||||
|
value: 1000 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 4875 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-15'), |
||||
|
value: 5875 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-07-01'), |
||||
|
value: 8875 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
accountId: 'other-account-id', |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 2000 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-01'), |
||||
|
userId: 'other-user-id', |
||||
|
value: 3000 |
||||
|
}); |
||||
|
|
||||
|
const account = await accountService.account({ |
||||
|
id_userId: { id: 'account-id', userId: 'user-id' } |
||||
|
}); |
||||
|
|
||||
|
expect(account.balance).toBe(5875); |
||||
|
}); |
||||
|
|
||||
|
it('includes the existing snapshot on the transaction date and accumulates repeated debits', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 }, |
||||
|
{ date: new Date('2024-01-15'), value: 900 }, |
||||
|
{ date: new Date('2024-01-16'), value: 1500 }, |
||||
|
{ date: new Date('2024-06-01'), value: 5000 } |
||||
|
]; |
||||
|
|
||||
|
await updateBalance({ |
||||
|
amount: -100, |
||||
|
date: new Date(2024, 0, 15, 15) |
||||
|
}); |
||||
|
await updateBalance({ |
||||
|
amount: -50, |
||||
|
date: new Date(2024, 0, 15, 20) |
||||
|
}); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenNthCalledWith( |
||||
|
1, |
||||
|
expect.objectContaining({ balance: 800, balanceChange: -100 }) |
||||
|
); |
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenNthCalledWith( |
||||
|
2, |
||||
|
expect.objectContaining({ balance: 750, balanceChange: -50 }) |
||||
|
); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-01'), |
||||
|
value: 1000 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-15'), |
||||
|
value: 750 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-16'), |
||||
|
value: 1350 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 4850 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('starts from zero when no balance exists on or before the transaction date', async () => { |
||||
|
balances = [{ date: new Date('2024-01-10'), value: 1000 }]; |
||||
|
|
||||
|
await updateBalance({ amount: -100, date: new Date('2024-01-05') }); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ balance: -100, balanceChange: -100 }) |
||||
|
); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-10'), |
||||
|
value: 900 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('uses the converted transaction amount for the historical balance and all later snapshots', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 }, |
||||
|
{ date: new Date('2024-06-01'), value: 5000 } |
||||
|
]; |
||||
|
const date = new Date(2024, 0, 15, 18); |
||||
|
toCurrencyAtDate.mockResolvedValue(-90); |
||||
|
|
||||
|
const amountInCurrencyOfAccount = await updateBalance({ |
||||
|
amount: -100, |
||||
|
currency: 'USD', |
||||
|
date |
||||
|
}); |
||||
|
|
||||
|
expect(amountInCurrencyOfAccount).toBe(-90); |
||||
|
expect(toCurrencyAtDate).toHaveBeenCalledWith(-100, 'USD', 'EUR', date); |
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ balance: 910, balanceChange: -90 }) |
||||
|
); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 4910 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('credits the historical balance and all later snapshots', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 }, |
||||
|
{ date: new Date('2024-06-01'), value: 5000 } |
||||
|
]; |
||||
|
|
||||
|
await updateBalance({ amount: 125, date: new Date('2024-01-15') }); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith( |
||||
|
expect.objectContaining({ balance: 1125, balanceChange: 125 }) |
||||
|
); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-01-01'), |
||||
|
value: 1000 |
||||
|
}); |
||||
|
expect(balances).toContainEqual({ |
||||
|
date: new Date('2024-06-01'), |
||||
|
value: 5125 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('uses the current balance cutoff when the transaction date is omitted', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-06-15'), value: 1000 }, |
||||
|
{ date: new Date('2024-06-16'), value: 2000 }, |
||||
|
{ date: new Date('2024-06-17'), value: 3000 } |
||||
|
]; |
||||
|
|
||||
|
await updateBalance({ amount: -100 }); |
||||
|
|
||||
|
expect(toCurrencyAtDate).toHaveBeenCalledWith( |
||||
|
-100, |
||||
|
'EUR', |
||||
|
'EUR', |
||||
|
new Date() |
||||
|
); |
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith({ |
||||
|
accountId: 'account-id', |
||||
|
balance: 1900, |
||||
|
balanceChange: -100, |
||||
|
date: new Date().toISOString(), |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('reads the historical balance and writes the converted cash change using the supplied transaction', async () => { |
||||
|
balances = [{ date: new Date('2024-01-01'), value: 1000 }]; |
||||
|
const date = new Date(2024, 0, 15, 15); |
||||
|
const transactionFindUnique = jest.fn().mockResolvedValue({ |
||||
|
currency: 'EUR', |
||||
|
id: 'account-id', |
||||
|
userId: 'user-id', |
||||
|
balances: [{ date: new Date('2024-01-01'), value: 700 }] |
||||
|
}); |
||||
|
const transaction = { |
||||
|
account: { findUnique: transactionFindUnique } |
||||
|
} as unknown as Prisma.TransactionClient; |
||||
|
toCurrencyAtDate.mockResolvedValue(-91.125); |
||||
|
|
||||
|
const amountInCurrencyOfAccount = await updateBalance({ |
||||
|
date, |
||||
|
transaction, |
||||
|
amount: -100, |
||||
|
currency: 'USD' |
||||
|
}); |
||||
|
|
||||
|
expect(amountInCurrencyOfAccount).toBe(-91.125); |
||||
|
expect(transactionFindUnique).toHaveBeenCalledWith({ |
||||
|
include: { |
||||
|
balances: { |
||||
|
orderBy: { date: 'desc' }, |
||||
|
take: 1, |
||||
|
where: { date: { lte: resetHours(date) } } |
||||
|
} |
||||
|
}, |
||||
|
where: { id_userId: { id: 'account-id', userId: 'user-id' } } |
||||
|
}); |
||||
|
expect(findUnique).not.toHaveBeenCalled(); |
||||
|
expect(toCurrencyAtDate).toHaveBeenCalledWith(-100, 'USD', 'EUR', date); |
||||
|
expect(createOrUpdateAccountBalance).toHaveBeenCalledWith({ |
||||
|
transaction, |
||||
|
accountId: 'account-id', |
||||
|
balance: 608.875, |
||||
|
balanceChange: -91.125, |
||||
|
date: date.toISOString(), |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it.each([undefined, NaN, Infinity, -Infinity])( |
||||
|
'rejects an invalid currency conversion (%s) before writing any balance', |
||||
|
async (amountInCurrencyOfAccount) => { |
||||
|
balances = [{ date: new Date('2024-01-01'), value: 1000 }]; |
||||
|
toCurrencyAtDate.mockResolvedValue(amountInCurrencyOfAccount); |
||||
|
|
||||
|
await expect( |
||||
|
updateBalance({ |
||||
|
amount: -100, |
||||
|
currency: 'USD', |
||||
|
date: new Date('2024-01-15') |
||||
|
}) |
||||
|
).rejects.toBeInstanceOf(BadRequestException); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).not.toHaveBeenCalled(); |
||||
|
expect(balances).toEqual([ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 } |
||||
|
]); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('returns zero without writing balances when the converted cash change is zero', async () => { |
||||
|
balances = [{ date: new Date('2024-01-01'), value: 1000 }]; |
||||
|
toCurrencyAtDate.mockResolvedValue(0); |
||||
|
|
||||
|
await expect( |
||||
|
updateBalance({ amount: 0, date: new Date('2024-01-15') }) |
||||
|
).resolves.toBe(0); |
||||
|
|
||||
|
expect(createOrUpdateAccountBalance).not.toHaveBeenCalled(); |
||||
|
expect(balances).toEqual([{ date: new Date('2024-01-01'), value: 1000 }]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('account', () => { |
||||
|
it('returns the current balance and excludes future balances when no date is specified', async () => { |
||||
|
balances = [ |
||||
|
{ date: new Date('2024-01-01'), value: 1000 }, |
||||
|
{ date: new Date('2024-06-01'), value: 5000 }, |
||||
|
{ date: new Date('2024-07-01'), value: 9000 } |
||||
|
]; |
||||
|
|
||||
|
const account = await accountService.account({ |
||||
|
id_userId: { id: 'account-id', userId: 'user-id' } |
||||
|
}); |
||||
|
|
||||
|
expect(account.balance).toBe(5000); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
async function updateBalance({ |
||||
|
amount, |
||||
|
currency = 'EUR', |
||||
|
date, |
||||
|
transaction |
||||
|
}: { |
||||
|
amount: number; |
||||
|
currency?: string; |
||||
|
date?: Date; |
||||
|
transaction?: Prisma.TransactionClient; |
||||
|
}) { |
||||
|
return accountService.updateAccountBalance({ |
||||
|
amount, |
||||
|
currency, |
||||
|
date, |
||||
|
transaction, |
||||
|
accountId: 'account-id', |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
@ -0,0 +1,678 @@ |
|||||
|
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<typeof orderFixture>[]; |
||||
|
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 } as unknown as PrismaService, |
||||
|
{ getSymbolProfilesByIds } as unknown as SymbolProfileService, |
||||
|
{ validateTagIds: jest.fn() } as unknown as TagService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('create', () => { |
||||
|
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('edit', () => { |
||||
|
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<ActivitiesService['updateActivity']>[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, withAccount = true) { |
||||
|
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: Type.BUY, |
||||
|
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<Prisma.OrderUncheckedCreateInput> = {} |
||||
|
) { |
||||
|
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', |
||||
|
watchedByCount: 0 |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
interface Balance { |
||||
|
accountId: string; |
||||
|
date: Date; |
||||
|
userId: string; |
||||
|
value: number; |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import type { ImpersonationContext } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { DataSource, Order, Type } from '@prisma/client'; |
||||
|
|
||||
|
import { ActivitiesController } from './activities.controller'; |
||||
|
import { ActivitiesService } from './activities.service'; |
||||
|
|
||||
|
describe.each([true, false])( |
||||
|
'ActivitiesController cash balance tracking responses (enabled: %s)', |
||||
|
(updateAccountBalance) => { |
||||
|
const userId = 'user-id'; |
||||
|
const context = { userId } as ImpersonationContext; |
||||
|
|
||||
|
let controller: ActivitiesController; |
||||
|
let activity: Order & { |
||||
|
SymbolProfile: { |
||||
|
currency: string; |
||||
|
dataSource: DataSource; |
||||
|
scraperConfiguration: null; |
||||
|
symbol: string; |
||||
|
}; |
||||
|
}; |
||||
|
let data: CreateOrderDto; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
const date = parseDate('2024-01-01'); |
||||
|
|
||||
|
activity = { |
||||
|
accountBalanceChange: -271.8, |
||||
|
accountId: 'account-id', |
||||
|
accountUserId: userId, |
||||
|
comment: null, |
||||
|
createdAt: date, |
||||
|
currency: 'USD', |
||||
|
date, |
||||
|
fee: 2, |
||||
|
id: 'activity-id', |
||||
|
quantity: 3, |
||||
|
symbolProfileId: 'asset-profile-id', |
||||
|
type: Type.BUY, |
||||
|
unitPrice: 100, |
||||
|
updateAccountBalance, |
||||
|
updatedAt: date, |
||||
|
userId, |
||||
|
SymbolProfile: { |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
scraperConfiguration: null, |
||||
|
symbol: 'Asset' |
||||
|
} |
||||
|
}; |
||||
|
data = { |
||||
|
accountId: activity.accountId, |
||||
|
currency: activity.currency, |
||||
|
dataSource: activity.SymbolProfile.dataSource, |
||||
|
date: date.toISOString(), |
||||
|
fee: activity.fee, |
||||
|
quantity: activity.quantity, |
||||
|
symbol: activity.SymbolProfile.symbol, |
||||
|
type: activity.type, |
||||
|
unitPrice: activity.unitPrice, |
||||
|
updateAccountBalance |
||||
|
}; |
||||
|
|
||||
|
controller = new ActivitiesController( |
||||
|
{ |
||||
|
createActivity: jest.fn().mockResolvedValue(activity), |
||||
|
deleteActivity: jest.fn().mockResolvedValue(activity), |
||||
|
order: jest.fn().mockResolvedValue(activity), |
||||
|
updateActivity: jest.fn().mockResolvedValue(activity) |
||||
|
} as unknown as ActivitiesService, |
||||
|
null, |
||||
|
{ |
||||
|
validateActivities: jest.fn().mockResolvedValue(undefined) |
||||
|
} as unknown as DataProviderService, |
||||
|
null, |
||||
|
{ |
||||
|
getSymbolProfiles: jest |
||||
|
.fn() |
||||
|
.mockResolvedValue([activity.SymbolProfile]) |
||||
|
} as unknown as SymbolProfileService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it.each(['create', 'edit', 'delete'] as const)( |
||||
|
'removes the internal cash amount from the %s response while preserving the preference', |
||||
|
async (operation) => { |
||||
|
const response = |
||||
|
operation === 'create' |
||||
|
? await controller.createActivity(data, context) |
||||
|
: operation === 'edit' |
||||
|
? await controller.updateActivity( |
||||
|
{ ...data, id: activity.id } as UpdateOrderDto, |
||||
|
context, |
||||
|
activity.id |
||||
|
) |
||||
|
: await controller.deleteActivity(context, activity.id); |
||||
|
|
||||
|
expect(response).not.toHaveProperty('accountBalanceChange'); |
||||
|
expect(response).toMatchObject({ |
||||
|
id: activity.id, |
||||
|
updateAccountBalance |
||||
|
}); |
||||
|
expect(activity.accountBalanceChange).toBe(-271.8); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
); |
||||
@ -0,0 +1,3 @@ |
|||||
|
-- AlterTable |
||||
|
ALTER TABLE "Order" ADD COLUMN "accountBalanceChange" DOUBLE PRECISION, |
||||
|
ADD COLUMN "updateAccountBalance" BOOLEAN NOT NULL DEFAULT false; |
||||
Loading…
Reference in new issue