Browse Source

fix: reconcile cash balance chains across the activity lifecycle

- 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 tests
pull/8155/head
sokol.biba 2 days ago
parent
commit
8a185f8264
  1. 267
      apps/api/src/app/account-balance/account-balance.service.spec.ts
  2. 56
      apps/api/src/app/account-balance/account-balance.service.ts
  3. 413
      apps/api/src/app/account/account.service.spec.ts
  4. 61
      apps/api/src/app/account/account.service.ts
  5. 678
      apps/api/src/app/activities/activities.cash-balance.spec.ts
  6. 112
      apps/api/src/app/activities/activities.controller.spec.ts
  7. 15
      apps/api/src/app/activities/activities.controller.ts
  8. 86
      apps/api/src/app/activities/activities.service.spec.ts
  9. 241
      apps/api/src/app/activities/activities.service.ts
  10. 2
      apps/api/src/app/import/import.service.ts
  11. 5
      libs/common/src/lib/dtos/update-order.dto.ts
  12. 5
      libs/common/src/lib/interfaces/activities.interface.ts
  13. 5
      libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts
  14. 2
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  15. 71
      package-lock.json
  16. 3
      prisma/migrations/20261012000000_added_account_balance_change_to_order/migration.sql
  17. 38
      prisma/schema.prisma

267
apps/api/src/app/account-balance/account-balance.service.spec.ts

@ -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
});
}
});

56
apps/api/src/app/account-balance/account-balance.service.ts

@ -48,12 +48,18 @@ export class AccountBalanceService {
public async createOrUpdateAccountBalance({
accountId,
balance,
balanceChange,
date,
transaction,
userId
}: CreateAccountBalanceDto & {
balanceChange?: number;
transaction?: Prisma.TransactionClient;
userId: string;
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
const balanceDate = resetHours(parseISO(date));
const prisma = transaction ?? this.prismaService;
const upsert = prisma.accountBalance.upsert({
create: {
account: {
connect: {
@ -63,7 +69,7 @@ export class AccountBalanceService {
}
}
},
date: resetHours(parseISO(date)),
date: balanceDate,
value: balance
},
update: {
@ -72,17 +78,49 @@ export class AccountBalanceService {
where: {
accountId_date: {
accountId,
date: resetHours(parseISO(date))
date: balanceDate
}
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
let accountBalance: AccountBalance;
const updateLaterBalances = balanceChange
? prisma.accountBalance.updateMany({
data: {
value: { increment: balanceChange }
},
where: {
accountId,
userId,
date: { gt: balanceDate }
}
})
: undefined;
if (transaction) {
accountBalance = await upsert;
if (updateLaterBalances !== undefined) {
await updateLaterBalances;
}
} else if (updateLaterBalances !== undefined) {
[accountBalance] = await this.prismaService.$transaction([
upsert,
updateLaterBalances
]);
} else {
accountBalance = await upsert;
}
if (!transaction) {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
}
return accountBalance;
}

413
apps/api/src/app/account/account.service.spec.ts

@ -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'
});
}
});

61
apps/api/src/app/account/account.service.ts

@ -10,12 +10,13 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DATE_FORMAT,
getStartOfUtcDateOfTomorrow
getStartOfUtcDateOfTomorrow,
resetHours
} from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
Account,
@ -42,16 +43,25 @@ export class AccountService {
private readonly tagService: TagService
) {}
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({
public async account(
{
date,
id_userId
}: Prisma.AccountWhereUniqueInput & {
date?: Date;
},
transaction?: Prisma.TransactionClient
): Promise<AccountWithBalance | null> {
const account = await (
transaction ?? this.prismaService
).account.findUnique({
include: {
balances: {
orderBy: { date: 'desc' },
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
where: date
? { date: { lte: resetHours(date) } }
: getWhereAccountBalanceNotInFuture()
}
},
where: { id_userId }
@ -363,37 +373,54 @@ export class AccountService {
accountId,
amount,
currency,
date = new Date(),
date,
transaction,
userId
}: {
accountId: string;
amount: number;
currency: string;
date?: Date;
transaction?: Prisma.TransactionClient;
userId: string;
}) {
const { balance, currency: currencyOfAccount } = await this.account({
id_userId: {
userId,
id: accountId
}
});
const balanceDate = date ?? new Date();
const { balance, currency: currencyOfAccount } = await this.account(
{
date,
id_userId: {
userId,
id: accountId
}
},
transaction
);
const amountInCurrencyOfAccount =
await this.exchangeRateDataService.toCurrencyAtDate(
amount,
currency,
currencyOfAccount,
date
balanceDate
);
if (!Number.isFinite(amountInCurrencyOfAccount)) {
throw new BadRequestException(
'Unable to convert the activity amount to the account currency'
);
}
if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId,
userId,
...(transaction ? { transaction } : {}),
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
date: date.toISOString()
balanceChange: amountInCurrencyOfAccount,
date: balanceDate.toISOString()
});
}
return amountInCurrencyOfAccount;
}
}

678
apps/api/src/app/activities/activities.cash-balance.spec.ts

@ -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;
}

112
apps/api/src/app/activities/activities.controller.spec.ts

@ -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);
}
);
}
);

15
apps/api/src/app/activities/activities.controller.ts

@ -38,6 +38,7 @@ import {
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { omit } from 'lodash-es';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service';
@ -103,7 +104,7 @@ export class ActivitiesController {
public async deleteActivity(
@Impersonation() { userId }: ImpersonationContext,
@Param('id') id: string
): Promise<Order> {
): Promise<Omit<Order, 'accountBalanceChange'>> {
const activity = await this.activitiesService.order({
id,
userId
@ -116,9 +117,11 @@ export class ActivitiesController {
);
}
return this.activitiesService.deleteActivity({
const deletedActivity = await this.activitiesService.deleteActivity({
id
});
return omit(deletedActivity, ['accountBalanceChange']);
}
@Get()
@ -314,7 +317,7 @@ export class ActivitiesController {
}
]);
return { ...activity, assetProfile };
return { ...omit(activity, ['accountBalanceChange']), assetProfile };
}
@HasPermission(permissions.updateActivity)
@ -326,7 +329,7 @@ export class ActivitiesController {
@Body() data: UpdateOrderDto,
@Impersonation() { userId }: ImpersonationContext,
@Param('id') id: string
) {
): Promise<Omit<Order, 'accountBalanceChange'>> {
const originalActivity = await this.activitiesService.order({
id,
userId
@ -355,7 +358,7 @@ export class ActivitiesController {
delete data.dataSource;
return this.activitiesService.updateActivity({
const updatedActivity = await this.activitiesService.updateActivity({
userId,
data: {
...data,
@ -390,5 +393,7 @@ export class ActivitiesController {
id
}
});
return omit(updatedActivity, ['accountBalanceChange']);
}
}

86
apps/api/src/app/activities/activities.service.spec.ts

@ -54,6 +54,87 @@ describe('ActivitiesService', () => {
});
describe('createActivity', () => {
it('updates the cash balance at the date of a backdated buy, including its fee', async () => {
const date = parseDate('2024-01-01');
const updateAccountBalance = jest.fn().mockResolvedValue(-302);
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
return {
...data,
accountId: data.account?.connect?.id_userId.id,
id: 'activity-id',
userId: data.user.connect.id,
SymbolProfile: data.SymbolProfile.connectOrCreate.create
};
});
const transaction = {
order: {
create,
update: jest.fn(({ data }) => {
return { ...create.mock.results[0].value, ...data };
})
}
};
const service = new ActivitiesService(
null,
{ updateAccountBalance } as unknown as AccountService,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
$transaction: jest.fn(async (operation) => operation(transaction))
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.createActivity({
date,
accountId: 'account-id',
currency: 'USD',
fee: 2,
quantity: 3,
SymbolProfile: {
connectOrCreate: {
create: {
currency: 'USD',
dataSource: DataSource.MANUAL,
symbol: 'Asset'
},
where: {
dataSource_symbol: {
dataSource: DataSource.MANUAL,
symbol: 'Asset'
}
}
}
},
type: 'BUY',
unitPrice: 100,
updateAccountBalance: true,
user: { connect: { id: 'user-id' } },
userId: 'user-id'
});
expect(updateAccountBalance).toHaveBeenCalledWith({
date,
accountId: 'account-id',
amount: -302,
currency: 'USD',
transaction,
userId: 'user-id'
});
expect(transaction.order.update).toHaveBeenCalledWith({
data: { accountBalanceChange: -302 },
include: { SymbolProfile: true },
where: { id: 'activity-id' }
});
});
it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' });
@ -103,6 +184,9 @@ describe('ActivitiesService', () => {
null,
null,
{
$transaction: jest.fn(async (operation) => {
return operation({ order: { create } });
}),
order: { create },
symbolProfile: {
findUnique: jest
@ -158,6 +242,7 @@ describe('ActivitiesService', () => {
const findMany = jest.fn().mockResolvedValue([
{
...activityDummyData,
accountBalanceChange: -302,
account: null,
currency: 'USD',
date: parseDate('2021-01-01'),
@ -219,6 +304,7 @@ describe('ActivitiesService', () => {
})
);
expect(activities).toHaveLength(1);
expect(activities[0]).not.toHaveProperty('accountBalanceChange');
expect(activities[0]).not.toHaveProperty('SymbolProfile');
expect(activities[0].assetProfile).toBe(assetProfile);
});

241
apps/api/src/app/activities/activities.service.ts

@ -38,7 +38,8 @@ import {
getAssetProfileIdentifier,
getStartOfUtcDateOfTomorrow,
hasGhostfolioPrefix,
isDraftActivity
isDraftActivity,
resetHours
} from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
@ -49,7 +50,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AssetClass,
@ -297,34 +298,38 @@ export class ActivitiesService {
type: data.type
});
const activity = await this.prismaService.order.create({
data: {
...orderData,
account,
tags: {
connect: tagsToConnect
}
},
include: { SymbolProfile: true }
});
if (accountId && updateAccountBalance === true) {
let amount = new Big(data.unitPrice).mul(data.quantity);
const activity = await this.prismaService.$transaction(
async (transaction) => {
const createdActivity = await transaction.order.create({
data: {
...orderData,
account,
accountBalanceChange: null,
updateAccountBalance,
tags: {
connect: tagsToConnect
}
},
include: { SymbolProfile: true }
});
if (['BUY', 'FEE'].includes(data.type)) {
amount = amount.mul(-1);
}
if (accountId && updateAccountBalance) {
const accountBalanceChange = await this.applyAccountBalanceChange(
createdActivity,
transaction
);
amount = amount.minus(data.fee);
return transaction.order.update({
data: { accountBalanceChange },
include: { SymbolProfile: true },
where: { id: createdActivity.id }
});
}
await this.accountService.updateAccountBalance({
accountId,
userId,
amount: amount.toNumber(),
currency: activity.currency ?? activity.SymbolProfile.currency,
date: data.date as Date
});
}
return createdActivity;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
this.eventEmitter.emit(
AssetProfileChangedEvent.getName(),
@ -348,9 +353,16 @@ export class ActivitiesService {
public async deleteActivity(
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
const activity = await this.prismaService.order.delete({
where
});
const activity = await this.prismaService.$transaction(
async (transaction) => {
const deletedActivity = await transaction.order.delete({ where });
await this.reverseAccountBalanceChange(deletedActivity, transaction);
return deletedActivity;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
@ -413,18 +425,62 @@ export class ActivitiesService {
withExcludedAccountsAndActivities: true
});
const activities = await this.prismaService.order.findMany({
where,
distinct: ['symbolProfileId'],
select: { symbolProfileId: true }
});
const { activities, count } = await this.prismaService.$transaction(
async (transaction) => {
const deletedActivities = await transaction.order.findMany({ where });
const result = await transaction.order.deleteMany({
where: {
userId,
id: {
in: deletedActivities.map(({ id }) => {
return id;
})
}
}
});
const activitiesByAccountAndDate = groupBy(
deletedActivities,
({ accountId, accountUserId, date, userId: activityUserId }) => {
return JSON.stringify([
accountId,
accountUserId ?? activityUserId,
resetHours(date).toISOString()
]);
}
);
for (const activitiesOnDate of Object.values(
activitiesByAccountAndDate
)) {
const accountBalanceChange = activitiesOnDate.reduce(
(amount, activity) => {
return amount.plus(activity.accountBalanceChange ?? 0);
},
new Big(0)
);
await this.reverseAccountBalanceChange(
{
...activitiesOnDate[0],
accountBalanceChange: accountBalanceChange.toNumber()
},
transaction
);
}
const { count } = await this.prismaService.order.deleteMany({ where });
return { activities: deletedActivities, count: result.count };
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 60_000
}
);
const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => {
uniqBy(activities, 'symbolProfileId').map(({ symbolProfileId }) => {
return symbolProfileId;
})
)
@ -753,7 +809,7 @@ export class ActivitiesService {
]);
return {
...omit(order, ['SymbolProfile']),
...omit(order, ['accountBalanceChange', 'SymbolProfile']),
assetProfile,
feeInAssetProfileCurrency,
feeInBaseCurrency,
@ -908,6 +964,7 @@ export class ActivitiesService {
symbol?: string;
tags?: { id: string }[];
type?: ActivityType;
updateAccountBalance?: boolean;
};
originalDate: Date;
userId: string;
@ -915,6 +972,9 @@ export class ActivitiesService {
}): Promise<Order> {
const areTagsProvided = data.tags !== undefined;
const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance;
delete data.updateAccountBalance;
await this.tagService.validateTagIds({
userId,
@ -983,13 +1043,66 @@ export class ActivitiesService {
tagsToUpdate = { connect: { id: TAG_ID_DRAFT } };
}
const activity = await this.prismaService.order.update({
where,
data: {
...data,
tags: tagsToUpdate
}
});
const activity = await this.prismaService.$transaction(
async (transaction) => {
const originalActivity = await transaction.order.findUnique({
include: { SymbolProfile: true },
where: { ...where, userId }
});
if (!originalActivity) {
throw new NotFoundException();
}
const shouldUpdateAccountBalance =
updateAccountBalance ?? originalActivity.updateAccountBalance;
const updatedActivity = await transaction.order.update({
where: { ...where, userId },
data: {
...data,
accountBalanceChange: originalActivity.accountBalanceChange,
tags: tagsToUpdate,
updateAccountBalance: shouldUpdateAccountBalance
},
include: { SymbolProfile: true }
});
const hasCashEffectChanged =
originalActivity.accountId !== updatedActivity.accountId ||
resetHours(originalActivity.date).getTime() !==
resetHours(updatedActivity.date).getTime() ||
originalActivity.fee !== updatedActivity.fee ||
originalActivity.quantity !== updatedActivity.quantity ||
originalActivity.type !== updatedActivity.type ||
originalActivity.unitPrice !== updatedActivity.unitPrice ||
(originalActivity.currency ??
originalActivity.SymbolProfile.currency) !==
(updatedActivity.currency ??
updatedActivity.SymbolProfile.currency);
if (
shouldUpdateAccountBalance ===
originalActivity.updateAccountBalance &&
(!shouldUpdateAccountBalance || !hasCashEffectChanged)
) {
return updatedActivity;
}
await this.reverseAccountBalanceChange(originalActivity, transaction);
const accountBalanceChange =
shouldUpdateAccountBalance && updatedActivity.accountId
? await this.applyAccountBalanceChange(updatedActivity, transaction)
: null;
return transaction.order.update({
data: { accountBalanceChange },
include: { SymbolProfile: true },
where: { ...where, userId }
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
);
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
@ -1001,6 +1114,44 @@ export class ActivitiesService {
return activity;
}
private applyAccountBalanceChange(
activity: Order & { SymbolProfile: { currency: string } },
transaction: Prisma.TransactionClient
) {
let amount = new Big(activity.unitPrice).mul(activity.quantity);
if (['BUY', 'FEE'].includes(activity.type)) {
amount = amount.mul(-1);
}
return this.accountService.updateAccountBalance({
transaction,
accountId: activity.accountId,
amount: amount.minus(activity.fee).toNumber(),
currency: activity.currency ?? activity.SymbolProfile.currency,
date: activity.date,
userId: activity.accountUserId ?? activity.userId
});
}
private async reverseAccountBalanceChange(
activity: Order,
transaction: Prisma.TransactionClient
) {
if (activity.accountId && activity.accountBalanceChange) {
await transaction.accountBalance.updateMany({
data: {
value: { increment: -activity.accountBalanceChange }
},
where: {
accountId: activity.accountId,
userId: activity.accountUserId ?? activity.userId,
date: { gte: resetHours(activity.date) }
}
});
}
}
private getWhereClause({
endDate,
filters,

2
apps/api/src/app/import/import.service.ts

@ -909,6 +909,7 @@ export class ImportService {
type,
unitPrice,
account: validatedAccount,
accountBalanceChange: null,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
@ -916,6 +917,7 @@ export class ImportService {
symbolProfileId: undefined,
tags: previewTags,
updatedAt: new Date(),
updateAccountBalance: false,
userId: user.id
};
} else {

5
libs/common/src/lib/dtos/update-order.dto.ts

@ -15,6 +15,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsISO8601,
IsNotEmpty,
@ -93,4 +94,8 @@ export class UpdateOrderDto {
@IsNumber()
@Min(0)
unitPrice: number;
@IsBoolean()
@IsOptional()
updateAccountBalance?: boolean;
}

5
libs/common/src/lib/interfaces/activities.interface.ts

@ -2,7 +2,10 @@ import { EnhancedAssetProfile } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import type { Order, Tag } from '@ghostfolio/prisma/browser';
export interface Activity extends Order {
export interface Activity extends Omit<
Order,
'accountBalanceChange' | 'updateAccountBalance'
> {
account?: AccountWithPlatform;
assetProfile: EnhancedAssetProfile;
error?: ActivityError;

5
libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts

@ -2,7 +2,10 @@ import type { Order, SymbolProfile } from '@ghostfolio/prisma/browser';
import { EnhancedAssetProfile } from '../enhanced-asset-profile.interface';
export interface CreateActivityResponse extends Order {
export interface CreateActivityResponse extends Omit<
Order,
'accountBalanceChange'
> {
assetProfile: EnhancedAssetProfile;
/**

2
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -18,11 +18,13 @@ export interface ExportResponse {
})[];
activities: (Omit<
Order,
| 'accountBalanceChange'
| 'accountUserId'
| 'createdAt'
| 'date'
| 'symbolProfileId'
| 'updatedAt'
| 'updateAccountBalance'
| 'userId'
> & { date: string } & AssetProfileIdentifier)[];
assetProfiles: (Omit<

71
package-lock.json

@ -9929,24 +9929,6 @@
"yarn": ">= 1.13.0"
}
},
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@ -10034,22 +10016,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@nestjs/schematics/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nestjs/schematics/node_modules/restore-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
@ -11803,7 +11769,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11824,7 +11789,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11845,7 +11809,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11866,7 +11829,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11887,7 +11849,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11908,7 +11869,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11929,7 +11889,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11950,7 +11909,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11971,7 +11929,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -11992,7 +11949,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -12013,7 +11969,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -12034,7 +11989,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -12055,7 +12009,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -30968,7 +30921,6 @@
"!riscv64",
"!x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -30979,7 +30931,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -30996,7 +30947,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@ -31011,7 +30961,6 @@
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -31036,7 +30985,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31053,7 +31001,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31070,7 +31017,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31087,7 +31033,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31104,7 +31049,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31121,7 +31065,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31138,7 +31081,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31155,7 +31097,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31172,7 +31113,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31189,7 +31129,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31206,7 +31145,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31223,7 +31161,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31240,7 +31177,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31257,7 +31193,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31271,7 +31206,6 @@
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.99.0.tgz",
"integrity": "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31288,7 +31222,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -31305,7 +31238,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@ -31320,7 +31252,6 @@
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -31345,7 +31276,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -31362,7 +31292,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [

3
prisma/migrations/20261012000000_added_account_balance_change_to_order/migration.sql

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "accountBalanceChange" DOUBLE PRECISION,
ADD COLUMN "updateAccountBalance" BOOLEAN NOT NULL DEFAULT false;

38
prisma/schema.prisma

@ -181,24 +181,26 @@ model MarketData {
}
model Order {
account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountId String?
accountUserId String?
comment String?
createdAt DateTime @default(now())
currency String?
date DateTime
fee Float
id String @id @default(uuid())
quantity Float
symbolProfileId String
tags Tag[]
type Type
unitPrice Float
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
accountBalanceChange Float?
accountId String?
accountUserId String?
comment String?
createdAt DateTime @default(now())
currency String?
date DateTime
fee Float
id String @id @default(uuid())
quantity Float
symbolProfileId String
tags Tag[]
type Type
unitPrice Float
updateAccountBalance Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
@@index([accountId])
@@index([date])

Loading…
Cancel
Save