Browse Source

Support account balance time series (#2166)

* Initial setup

* Support account balance in export

* Handle account balance update

* Add schema migration

* Update changelog
pull/2168/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
c9353d0a39
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 2
      apps/api/src/app/account/account.module.ts
  3. 76
      apps/api/src/app/account/account.service.ts
  4. 6
      apps/api/src/app/export/export.module.ts
  5. 68
      apps/api/src/app/export/export.service.ts
  6. 3
      apps/api/src/app/order/order.module.ts
  7. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  8. 10
      apps/api/src/services/account-balance/account-balance.module.ts
  9. 16
      apps/api/src/services/account-balance/account-balance.service.ts
  10. 10
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  11. 27
      prisma/migrations/20230723104112_added_account_balances_to_account/migration.sql
  12. 30
      prisma/schema.prisma

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Broken down the emergency fund by cash and assets - Broken down the emergency fund by cash and assets
- Added support for account balance time series
### Changed ### Changed

2
apps/api/src/app/account/account.module.ts

@ -1,6 +1,7 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
controllers: [AccountController], controllers: [AccountController],
exports: [AccountService], exports: [AccountService],
imports: [ imports: [
AccountBalanceModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,

76
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async account( public async account({
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput id_userId
): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
return this.prismaService.account.findUnique({ const { id, userId } = id_userId;
where: accountWhereUniqueInput
const [account] = await this.accounts({
where: { id, userId }
}); });
return account;
} }
public async accountWithOrders( public async accountWithOrders(
@ -50,9 +56,11 @@ export class AccountService {
Platform?: Platform; 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, cursor,
include, include,
orderBy, orderBy,
@ -60,15 +68,36 @@ export class AccountService {
take, take,
where where
}); });
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
return account;
});
} }
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prismaService.account.create({ const account = await this.prismaService.account.create({
data data
}); });
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
});
return account;
} }
public async deleteAccount( public async deleteAccount(
@ -167,6 +196,18 @@ export class AccountService {
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
});
return this.prismaService.account.update({ return this.prismaService.account.update({
data, data,
where where
@ -202,16 +243,17 @@ export class AccountService {
); );
if (amountInCurrencyOfAccount) { if (amountInCurrencyOfAccount) {
await this.prismaService.account.update({ await this.accountBalanceService.createAccountBalance({
data: { date,
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() Account: {
}, connect: {
where: { id_userId: {
id_userId: { userId,
userId, id: accountId
id: accountId }
} }
} },
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
}); });
} }
} }

6
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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { Module } from '@nestjs/common';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
@Module({ @Module({
imports: [ imports: [
AccountModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
PrismaModule, OrderModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ExportController], controllers: [ExportController],

68
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 { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
) {}
public async export({ public async export({
activityIds, activityIds,
@ -14,36 +18,40 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = await this.prismaService.account.findMany({ const accounts = (
orderBy: { await this.accountService.accounts({
name: 'asc' orderBy: {
}, name: 'asc'
select: { },
accountType: true, where: { userId }
balance: true, })
comment: true, ).map(
currency: true, ({
id: true, accountType,
isExcluded: true, balance,
name: true, comment,
platformId: true currency,
}, id,
where: { userId } 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' }, orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
quantity: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId } where: { userId }
}); });

3
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 { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
providers: [AccountService, OrderService] providers: [AccountBalanceService, AccountService, OrderService]
}) })
export class OrderModule {} export class OrderModule {}

2
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 { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
UserModule UserModule
], ],
providers: [ providers: [
AccountBalanceService,
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioService, PortfolioService,

10
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 {}

16
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<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
}

10
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -125,9 +125,11 @@ export class ExchangeRateDataService {
return 0; return 0;
} }
let factor = 1; let factor: number;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else { } else {
@ -171,7 +173,9 @@ export class ExchangeRateDataService {
let factor: number; let factor: number;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
const dataSource = const dataSource =
this.dataProviderService.getDataSourceForExchangeRates(); this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${aFromCurrency}${aToCurrency}`; const symbol = `${aFromCurrency}${aToCurrency}`;

27
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";

30
prisma/schema.prisma

@ -21,25 +21,37 @@ model Access {
} }
model Account { model Account {
accountType AccountType @default(SECURITIES) accountType AccountType @default(SECURITIES)
balance Float @default(0) balance Float @default(0)
balances AccountBalance[]
comment String? comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency String? currency String?
id String @default(uuid()) id String @default(uuid())
isDefault Boolean @default(false) isDefault Boolean @default(false)
isExcluded Boolean @default(false) isExcluded Boolean @default(false)
name String? name String?
platformId String? platformId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userId String userId String
Platform Platform? @relation(fields: [platformId], references: [id]) Platform Platform? @relation(fields: [platformId], references: [id])
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
Order Order[] Order Order[]
@@id([id, userId]) @@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 { model Analytics {
activityCount Int @default(0) activityCount Int @default(0)
country String? country String?

Loading…
Cancel
Save