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[];
}