diff --git a/CHANGELOG.md b/CHANGELOG.md index eb67cd23b..d1d66e91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the Korean (`ko`) language to the footer + ### Changed - Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` diff --git a/README.md b/README.md index 270b65126..de4b4d828 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r ## Contributing -Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. +Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 46ab25e96..f08fb6a37 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -240,6 +240,24 @@ export class AdminService { throw new NotFoundException(`User with ID ${id} not found`); } + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + const subscriptions = await this.prismaService.subscription.findMany({ + orderBy: { + expiresAt: 'desc' + }, + where: { + userId: id + } + }); + + user.subscriptions = subscriptions.map((subscription) => { + return { + ...subscription, + price: subscription.price ?? 0 + }; + }); + } + return user; } diff --git a/apps/client/src/app/components/footer/footer.component.html b/apps/client/src/app/components/footer/footer.component.html index 45626620e..c839db8f1 100644 --- a/apps/client/src/app/components/footer/footer.component.html +++ b/apps/client/src/app/components/footer/footer.component.html @@ -141,13 +141,11 @@
  • Italiano
  • - +
  • + Korean (한국어) +
  • Nederlands
  • diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts index 05001a6bb..7b008786d 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts @@ -1,5 +1,6 @@ +import { getSum } from '@ghostfolio/common/helper'; import { AdminUserResponse } from '@ghostfolio/common/interfaces'; -import { AdminService } from '@ghostfolio/ui/services'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { @@ -16,7 +17,11 @@ import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { IonIcon } from '@ionic/angular/standalone'; +import { Subscription } from '@prisma/client'; +import { Big } from 'big.js'; +import { differenceInDays } from 'date-fns'; import { addIcons } from 'ionicons'; import { ellipsisVertical } from 'ionicons/icons'; import { EMPTY } from 'rxjs'; @@ -35,7 +40,8 @@ import { IonIcon, MatButtonModule, MatDialogModule, - MatMenuModule + MatMenuModule, + MatTableModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-user-detail-dialog', @@ -43,18 +49,29 @@ import { templateUrl: './user-detail-dialog.html' }) export class GfUserDetailDialogComponent implements OnInit { + public baseCurrency: string; + public subscriptionsDataSource = new MatTableDataSource(); + public subscriptionsDisplayedColumns = [ + 'createdAt', + 'type', + 'price', + 'expiresAt' + ]; public user: AdminUserResponse; public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, + private dataService: DataService, private destroyRef: DestroyRef, public dialogRef: MatDialogRef< GfUserDetailDialogComponent, UserDetailDialogResult > ) { + this.baseCurrency = this.dataService.fetchInfo().baseCurrency; + addIcons({ ellipsisVertical }); @@ -74,6 +91,8 @@ export class GfUserDetailDialogComponent implements OnInit { .subscribe((user) => { this.user = user; + this.subscriptionsDataSource.data = this.user.subscriptions ?? []; + this.changeDetectorRef.markForCheck(); }); } @@ -85,6 +104,24 @@ export class GfUserDetailDialogComponent implements OnInit { }); } + public getSum() { + return getSum( + this.subscriptionsDataSource.data.map(({ price }) => { + return new Big(price); + }) + ).toNumber(); + } + + public getType({ createdAt, expiresAt, price }: Subscription) { + if (price) { + return $localize`Paid`; + } + + return differenceInDays(expiresAt, createdAt) <= 90 + ? $localize`Trial` + : $localize`Coupon`; + } + public onClose() { this.dialogRef.close(); } diff --git a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html index caf1679eb..df935c780 100644 --- a/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html +++ b/apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -124,6 +124,102 @@ > + + @if (subscriptionsDataSource.data.length > 0) { +
    +
    +

    Subscriptions

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Created + + + + Total + + Type + + {{ getType(element) }} + + Price + + + + + + Expires + + +
    +
    +
    +
    + } } diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 55b5c44d5..f5cc74106 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -8,12 +8,11 @@ import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; import { Activity } from '@ghostfolio/common/interfaces'; import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Account, DataSource, Type as ActivityType } from '@prisma/client'; -import { isFinite } from 'lodash'; +import { isFinite, isNumber, isString } from 'lodash'; import { parse as csvToJson } from 'papaparse'; -import { EMPTY } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { firstValueFrom } from 'rxjs'; @Injectable({ providedIn: 'root' @@ -35,7 +34,7 @@ export class ImportActivitiesService { 'value' ]; - public constructor(private http: HttpClient) {} + private readonly http = inject(HttpClient); public async importCsv({ fileContent, @@ -49,7 +48,7 @@ export class ImportActivitiesService { activities: Activity[]; assetProfiles: CreateAssetProfileWithMarketDataDto[]; }> { - const content = csvToJson(fileContent, { + const content = csvToJson>(fileContent, { dynamicTyping: true, header: true, skipEmptyLines: true @@ -83,22 +82,22 @@ export class ImportActivitiesService { assetProfiles.push({ currency, symbol, - assetClass: null, - assetSubClass: null, - comment: null, + assetClass: undefined, + assetSubClass: undefined, + comment: undefined, countries: [], - cusip: null, + cusip: undefined, dataSource: DataSource.MANUAL, - figi: null, - figiComposite: null, - figiShareClass: null, + figi: undefined, + figiComposite: undefined, + figiShareClass: undefined, holdings: [], isActive: true, - isin: null, + isin: undefined, marketData: [], name: symbol, sectors: [], - url: null + url: undefined }); } } @@ -126,7 +125,7 @@ export class ImportActivitiesService { }): Promise<{ activities: Activity[]; }> { - return new Promise((resolve, reject) => { + return firstValueFrom( this.postImport( { accounts, @@ -136,18 +135,7 @@ export class ImportActivitiesService { }, isDryRun ) - .pipe( - catchError((error) => { - reject(error); - return EMPTY; - }) - ) - .subscribe({ - next: (data) => { - resolve(data); - } - }); - }); + ); } public importSelectedActivities({ @@ -163,11 +151,9 @@ export class ImportActivitiesService { }): Promise<{ activities: Activity[]; }> { - const importData: CreateOrderDto[] = []; - - for (const activity of activities) { - importData.push(this.convertToCreateOrderDto(activity)); - } + const importData = activities.map((activity) => + this.convertToCreateOrderDto(activity) + ); return this.importJson({ accounts, @@ -191,14 +177,14 @@ export class ImportActivitiesService { updateAccountBalance }: Activity): CreateOrderDto { return { - accountId, - comment, fee, quantity, type, unitPrice, updateAccountBalance, - currency: currency ?? SymbolProfile.currency, + accountId: accountId ?? undefined, + comment: comment ?? undefined, + currency: currency ?? SymbolProfile.currency ?? '', dataSource: SymbolProfile.dataSource, date: date.toString(), symbol: SymbolProfile.symbol, @@ -208,28 +194,32 @@ export class ImportActivitiesService { }; } - private lowercaseKeys(aObject: any) { - return Object.keys(aObject).reduce((acc, key) => { - acc[key.toLowerCase()] = aObject[key]; - return acc; - }, {}); + private lowercaseKeys( + aObject: Record + ): Record { + return Object.fromEntries( + Object.entries(aObject).map(([key, val]) => { + return [key.toLowerCase(), val]; + }) + ); } private parseAccount({ item, userAccounts }: { - item: any; + item: Record; userAccounts: Account[]; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.ACCOUNT_KEYS) { - if (item[key]) { - return userAccounts.find((account) => { + const value = item[key]; + + if (isNumber(value) || isString(value)) { + return userAccounts.find(({ id, name }) => { return ( - account.id === item[key] || - account.name.toLowerCase() === item[key].toLowerCase() + id === value || name?.toLowerCase() === String(value).toLowerCase() ); })?.id; } @@ -238,12 +228,14 @@ export class ImportActivitiesService { return undefined; } - private parseComment({ item }: { item: any }) { + private parseComment({ item }: { item: Record }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.COMMENT_KEYS) { - if (item[key]) { - return item[key]; + const value = item[key]; + + if (isNumber(value) || isString(value)) { + return String(value); } } @@ -255,15 +247,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.CURRENCY_KEYS) { - if (item[key]) { - return item[key]; + const value = item[key]; + + if (isString(value)) { + return value; } } @@ -273,12 +267,14 @@ export class ImportActivitiesService { }; } - private parseDataSource({ item }: { item: any }) { + private parseDataSource({ item }: { item: Record }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) { - if (item[key]) { - return DataSource[item[key].toUpperCase()]; + const value = item[key]; + + if (isString(value)) { + return DataSource[value.toUpperCase() as keyof typeof DataSource]; } } @@ -290,16 +286,22 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.DATE_KEYS) { - if (item[key]) { + const value = item[key]; + + if (isNumber(value) || isString(value)) { try { - return parseDateHelper(item[key].toString()).toISOString(); + const parsedDate = parseDateHelper(String(value)); + + if (parsedDate) { + return parsedDate.toISOString(); + } } catch {} } } @@ -315,15 +317,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.FEE_KEYS) { - if (isFinite(item[key])) { - return Math.abs(item[key]); + const value = item[key]; + + if (isNumber(value) && isFinite(value)) { + return Math.abs(value); } } @@ -338,15 +342,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.QUANTITY_KEYS) { - if (isFinite(item[key])) { - return Math.abs(item[key]); + const value = item[key]; + + if (isNumber(value) && isFinite(value)) { + return Math.abs(value); } } @@ -361,15 +367,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.SYMBOL_KEYS) { - if (item[key]) { - return item[key]; + const value = item[key]; + + if (isNumber(value) || isString(value)) { + return String(value); } } @@ -384,15 +392,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }): ActivityType { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.TYPE_KEYS) { - if (item[key]) { - switch (item[key].toLowerCase()) { + const value = item[key]; + + if (isString(value)) { + switch (value.toLowerCase()) { case 'buy': return 'BUY'; case 'dividend': @@ -422,15 +432,17 @@ export class ImportActivitiesService { index, item }: { - content: any[]; + content: Record[]; index: number; - item: any; + item: Record; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) { - if (isFinite(item[key])) { - return Math.abs(item[key]); + const value = item[key]; + + if (isNumber(value) && isFinite(value)) { + return Math.abs(value); } } diff --git a/libs/common/src/lib/interfaces/admin-user.interface.ts b/libs/common/src/lib/interfaces/admin-user.interface.ts index 4cb02b16e..a7cda68a8 100644 --- a/libs/common/src/lib/interfaces/admin-user.interface.ts +++ b/libs/common/src/lib/interfaces/admin-user.interface.ts @@ -12,4 +12,5 @@ export interface AdminUser { provider: Provider; role: Role; subscription?: Subscription; + subscriptions?: Subscription[]; }