From 8a185f8264e7c51e31424197298f44625fe26912 Mon Sep 17 00:00:00 2001 From: "sokol.biba" Date: Tue, 13 Oct 2026 09:25:15 +0200 Subject: [PATCH] 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 --- .../account-balance.service.spec.ts | 267 +++++++ .../account-balance.service.ts | 56 +- .../src/app/account/account.service.spec.ts | 413 +++++++++++ apps/api/src/app/account/account.service.ts | 61 +- .../activities.cash-balance.spec.ts | 678 ++++++++++++++++++ .../activities/activities.controller.spec.ts | 112 +++ .../app/activities/activities.controller.ts | 15 +- .../app/activities/activities.service.spec.ts | 86 +++ .../src/app/activities/activities.service.ts | 241 +++++-- apps/api/src/app/import/import.service.ts | 2 + libs/common/src/lib/dtos/update-order.dto.ts | 5 + .../lib/interfaces/activities.interface.ts | 5 +- .../create-activity-response.interface.ts | 5 +- .../responses/export-response.interface.ts | 2 + package-lock.json | 71 -- .../migration.sql | 3 + prisma/schema.prisma | 38 +- 17 files changed, 1893 insertions(+), 167 deletions(-) create mode 100644 apps/api/src/app/account-balance/account-balance.service.spec.ts create mode 100644 apps/api/src/app/account/account.service.spec.ts create mode 100644 apps/api/src/app/activities/activities.cash-balance.spec.ts create mode 100644 apps/api/src/app/activities/activities.controller.spec.ts create mode 100644 prisma/migrations/20261012000000_added_account_balance_change_to_order/migration.sql diff --git a/apps/api/src/app/account-balance/account-balance.service.spec.ts b/apps/api/src/app/account-balance/account-balance.service.spec.ts new file mode 100644 index 0000000000..b8bea2159b --- /dev/null +++ b/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[]) => { + 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 + }); + } +}); diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index ad990c66bd..7cd132510b 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/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 { - 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; } diff --git a/apps/api/src/app/account/account.service.spec.ts b/apps/api/src/app/account/account.service.spec.ts new file mode 100644 index 0000000000..c976092f9b --- /dev/null +++ b/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 & + Partial>)[]; + 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' + }); + } +}); diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index d1a895a5a3..fac36f4a23 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/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 { - const account = await this.prismaService.account.findUnique({ + public async account( + { + date, + id_userId + }: Prisma.AccountWhereUniqueInput & { + date?: Date; + }, + transaction?: Prisma.TransactionClient + ): Promise { + 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; } } diff --git a/apps/api/src/app/activities/activities.cash-balance.spec.ts b/apps/api/src/app/activities/activities.cash-balance.spec.ts new file mode 100644 index 0000000000..d002311a8c --- /dev/null +++ b/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[]; + 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[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 = {} + ) { + 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; +} diff --git a/apps/api/src/app/activities/activities.controller.spec.ts b/apps/api/src/app/activities/activities.controller.spec.ts new file mode 100644 index 0000000000..76fb676158 --- /dev/null +++ b/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); + } + ); + } +); diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 28ce890449..06fb5cd0db 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/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 { + ): Promise> { 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> { 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']); } } diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index fb2c68ff40..2bde98ce86 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/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); }); diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 0de3f7c3c4..9b030121db 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/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 { - 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 { 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, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 7616cd927b..77ea18437b 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/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 { diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index 77e4a42286..946b3259ac 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/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; } diff --git a/libs/common/src/lib/interfaces/activities.interface.ts b/libs/common/src/lib/interfaces/activities.interface.ts index 2e9be921e3..e6775b666b 100644 --- a/libs/common/src/lib/interfaces/activities.interface.ts +++ b/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; diff --git a/libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts b/libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts index 198a2cef79..57b890ef3c 100644 --- a/libs/common/src/lib/interfaces/responses/create-activity-response.interface.ts +++ b/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; /** diff --git a/libs/common/src/lib/interfaces/responses/export-response.interface.ts b/libs/common/src/lib/interfaces/responses/export-response.interface.ts index 6f643ec1a1..2e58890654 100644 --- a/libs/common/src/lib/interfaces/responses/export-response.interface.ts +++ b/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< diff --git a/package-lock.json b/package-lock.json index d9e320d790..12aa73b291 100644 --- a/package-lock.json +++ b/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": [ diff --git a/prisma/migrations/20261012000000_added_account_balance_change_to_order/migration.sql b/prisma/migrations/20261012000000_added_account_balance_change_to_order/migration.sql new file mode 100644 index 0000000000..e0fe4a2241 --- /dev/null +++ b/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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e21fc10e8c..4dfd11dac2 100644 --- a/prisma/schema.prisma +++ b/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])