You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

898 lines
29 KiB

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