diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b470c122..59dc50f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Broken down the emergency fund by cash and assets +- Added support for account balance time series ### Changed diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index f37ed34ee..26ace47c2 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,6 +1,7 @@ import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -15,6 +16,7 @@ import { AccountService } from './account.service'; controllers: [AccountController], exports: [AccountService], imports: [ + AccountBalanceModule, ConfigurationModule, DataProviderModule, ExchangeRateDataModule, diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index c6da815e4..dc049108c 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,3 +1,4 @@ +import { AccountBalanceService } from '@ghostfolio/api/services/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 { Filter } from '@ghostfolio/common/interfaces'; @@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface'; @Injectable() export class AccountService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} - public async account( - accountWhereUniqueInput: Prisma.AccountWhereUniqueInput - ): Promise { - return this.prismaService.account.findUnique({ - where: accountWhereUniqueInput + public async account({ + id_userId + }: Prisma.AccountWhereUniqueInput): Promise { + const { id, userId } = id_userId; + + const [account] = await this.accounts({ + where: { id, userId } }); + + return account; } public async accountWithOrders( @@ -50,9 +56,11 @@ export class AccountService { Platform?: Platform; })[] > { - const { include, skip, take, cursor, where, orderBy } = params; + const { include = {}, skip, take, cursor, where, orderBy } = params; - return this.prismaService.account.findMany({ + include.balances = { orderBy: { date: 'desc' }, take: 1 }; + + const accounts = await this.prismaService.account.findMany({ cursor, include, orderBy, @@ -60,15 +68,36 @@ export class AccountService { take, where }); + + return accounts.map((account) => { + account = { ...account, balance: account.balances[0]?.value ?? 0 }; + + delete account.balances; + + return account; + }); } public async createAccount( data: Prisma.AccountCreateInput, aUserId: string ): Promise { - return this.prismaService.account.create({ + const account = await this.prismaService.account.create({ data }); + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: { id: account.id, userId: aUserId } + } + }, + value: data.balance + } + }); + + return account; } public async deleteAccount( @@ -167,6 +196,18 @@ export class AccountService { aUserId: string ): Promise { const { data, where } = params; + + await this.prismaService.accountBalance.create({ + data: { + Account: { + connect: { + id_userId: where.id_userId + } + }, + value: data.balance + } + }); + return this.prismaService.account.update({ data, where @@ -202,16 +243,17 @@ export class AccountService { ); if (amountInCurrencyOfAccount) { - await this.prismaService.account.update({ - data: { - balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() - }, - where: { - id_userId: { - userId, - id: accountId + await this.accountBalanceService.createAccountBalance({ + date, + Account: { + connect: { + id_userId: { + userId, + id: accountId + } } - } + }, + value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() }); } } diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts index 186e8dc59..ca4588925 100644 --- a/apps/api/src/app/export/export.module.ts +++ b/apps/api/src/app/export/export.module.ts @@ -1,8 +1,9 @@ +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; import { ExportController } from './export.controller'; @@ -10,10 +11,11 @@ import { ExportService } from './export.service'; @Module({ imports: [ + AccountModule, ConfigurationModule, DataGatheringModule, DataProviderModule, - PrismaModule, + OrderModule, RedisCacheModule ], controllers: [ExportController], diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index eaeea0f07..abeaf389d 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -1,11 +1,15 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { environment } from '@ghostfolio/api/environments/environment'; -import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { Export } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; @Injectable() export class ExportService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly accountService: AccountService, + private readonly orderService: OrderService + ) {} public async export({ activityIds, @@ -14,36 +18,40 @@ export class ExportService { activityIds?: string[]; userId: string; }): Promise { - const accounts = await this.prismaService.account.findMany({ - orderBy: { - name: 'asc' - }, - select: { - accountType: true, - balance: true, - comment: true, - currency: true, - id: true, - isExcluded: true, - name: true, - platformId: true - }, - where: { userId } - }); + const accounts = ( + await this.accountService.accounts({ + orderBy: { + name: 'asc' + }, + where: { userId } + }) + ).map( + ({ + accountType, + balance, + comment, + currency, + id, + isExcluded, + name, + platformId + }) => { + return { + accountType, + balance, + comment, + currency, + id, + isExcluded, + name, + platformId + }; + } + ); - let activities = await this.prismaService.order.findMany({ + let activities = await this.orderService.orders({ + include: { SymbolProfile: true }, orderBy: { date: 'desc' }, - select: { - accountId: true, - comment: true, - date: true, - fee: true, - id: true, - quantity: true, - SymbolProfile: true, - type: true, - unitPrice: true - }, where: { userId } }); diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index c8742f9d2..8f033058d 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -31,6 +32,6 @@ import { OrderService } from './order.service'; SymbolProfileModule, UserModule ], - providers: [AccountService, OrderService] + providers: [AccountBalanceService, AccountService, OrderService] }) export class OrderModule {} diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index fa11476ac..3b4ee5d76 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -36,6 +37,7 @@ import { RulesService } from './rules.service'; UserModule ], providers: [ + AccountBalanceService, AccountService, CurrentRateService, PortfolioService, diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/services/account-balance/account-balance.module.ts new file mode 100644 index 000000000..53c695b5f --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.module.ts @@ -0,0 +1,10 @@ +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +@Module({ + exports: [AccountBalanceService], + imports: [PrismaModule], + providers: [AccountBalanceService] +}) +export class AccountBalanceModule {} diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/services/account-balance/account-balance.service.ts new file mode 100644 index 000000000..9cd2d31ac --- /dev/null +++ b/apps/api/src/services/account-balance/account-balance.service.ts @@ -0,0 +1,16 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AccountBalance, Prisma } from '@prisma/client'; + +@Injectable() +export class AccountBalanceService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createAccountBalance( + data: Prisma.AccountBalanceCreateInput + ): Promise { + return this.prismaService.accountBalance.create({ + data + }); + } +} diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 9e1daa224..d94037530 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -125,9 +125,11 @@ export class ExchangeRateDataService { return 0; } - let factor = 1; + let factor: number; - if (aFromCurrency !== aToCurrency) { + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; } else { @@ -171,7 +173,9 @@ export class ExchangeRateDataService { let factor: number; - if (aFromCurrency !== aToCurrency) { + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); const symbol = `${aFromCurrency}${aToCurrency}`; diff --git a/prisma/migrations/20230723104112_added_account_balances_to_account/migration.sql b/prisma/migrations/20230723104112_added_account_balances_to_account/migration.sql new file mode 100644 index 000000000..d13327514 --- /dev/null +++ b/prisma/migrations/20230723104112_added_account_balances_to_account/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "AccountBalance" ( + "accountId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + + CONSTRAINT "AccountBalance_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "AccountBalance" ADD CONSTRAINT "AccountBalance_accountId_userId_fkey" FOREIGN KEY ("accountId", "userId") REFERENCES "Account"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Migrate current account balance to time series (AccountBalance[]) +INSERT INTO "AccountBalance" ("accountId", "createdAt", "date", "id", "updatedAt", "userId", "value") +SELECT + "id", + "updatedAt", + "updatedAt", + "id", + "updatedAt", + "userId", + "balance" +FROM "Account"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f46272a54..b8e6064a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,25 +21,37 @@ model Access { } model Account { - accountType AccountType @default(SECURITIES) - balance Float @default(0) + accountType AccountType @default(SECURITIES) + balance Float @default(0) + balances AccountBalance[] comment String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) currency String? - id String @default(uuid()) - isDefault Boolean @default(false) - isExcluded Boolean @default(false) + id String @default(uuid()) + isDefault Boolean @default(false) + isExcluded Boolean @default(false) name String? platformId String? - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt userId String - Platform Platform? @relation(fields: [platformId], references: [id]) - User User @relation(fields: [userId], references: [id]) + Platform Platform? @relation(fields: [platformId], references: [id]) + User User @relation(fields: [userId], references: [id]) Order Order[] @@id([id, userId]) } +model AccountBalance { + accountId String + createdAt DateTime @default(now()) + date DateTime @default(now()) + id String @id @default(uuid()) + updatedAt DateTime @updatedAt + userId String + value Float + Account Account @relation(fields: [accountId, userId], onDelete: Cascade, references: [id, userId]) +} + model Analytics { activityCount Int @default(0) country String?