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