Browse Source

Merge remote-tracking branch 'origin/main' into feat/manual-fx-rate

# Conflicts:
#	CHANGELOG.md
pull/8046/head
Wai Cheng 2 weeks ago
parent
commit
556d683b27
  1. 14
      CHANGELOG.md
  2. 4
      Dockerfile
  3. 24
      README.md
  4. 16
      apps/api/src/app/health/health.controller.ts
  5. 366
      apps/api/src/app/import/import.service.spec.ts
  6. 37
      apps/api/src/app/import/import.service.ts
  7. 9
      apps/api/src/main.ts
  8. 12
      apps/api/src/services/prisma/prisma.service.ts
  9. 1
      docker/docker-compose.yml
  10. 2
      libs/common/src/lib/interfaces/index.ts
  11. 3
      libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts
  12. 42
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

14
CHANGELOG.md

@ -10,12 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for a manual exchange rate in the create or update activity dialog
- Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental)
- Added the graceful shutdown of the server on `SIGINT` and `SIGTERM`
- Added `stop_grace_period` to the _Ghostfolio_ service in the `docker-compose` file (`docker-compose.yml`)
### Changed
- Localized the number formatting in the chart of the holdings tab on the home page
- Changed the installation of the dependencies from `npm install` to `npm ci` in the `Dockerfile`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0`
- Upgraded `nestjs` from version `11.2.3` to `11.2.6`
### Fixed
- Fixed an issue with the algebraic sign in the tooltip of the chart of the holdings tab on the home page
- Fixed the asset profile of the activities after a fee, an interest or a liability with the same symbol in the activities import
### Todo
- Add `stop_grace_period: 1m` to the _Ghostfolio_ service in your `docker-compose` file (see `docker-compose.yml`)
## 3.78.0 - 2026-10-03
### Added

4
Dockerfile

@ -20,7 +20,7 @@ COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/
RUN npm install
RUN npm ci
COPY ./apps apps/
COPY ./libs libs/
@ -39,7 +39,7 @@ WORKDIR /ghostfolio/dist/apps/api
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install
RUN npm ci
COPY .config /ghostfolio/dist/apps/api/.config/
COPY prisma /ghostfolio/dist/apps/api/prisma/

24
README.md

@ -193,6 +193,8 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony
### Health Check (experimental)
The endpoint checks the connection to the database and to _Redis_. It is suitable as the readiness probe of an orchestrator (e.g. `readinessProbe` in _Kubernetes_) and as the `healthcheck` in _Docker Compose_. The check of _Redis_ can take up to 5 seconds. Thus, set the timeout of the probe to at least 5 seconds (e.g. `timeoutSeconds: 5` in _Kubernetes_).
#### Request
`GET http://localhost:3333/api/v1/health`
@ -211,6 +213,28 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony
}
```
### Liveness Probe (experimental)
The endpoint answers without a check of the database or _Redis_. It is suitable as the liveness probe of an orchestrator (e.g. `livenessProbe` in _Kubernetes_), so that an outage of a dependency after the start does not restart the container. The database migrations run before the server starts. Thus, also use the endpoint as the startup probe (e.g. `startupProbe` in _Kubernetes_), so that a long migration does not restart the container.
#### Request
`GET http://localhost:3333/api/v1/health/liveness`
**Info:** No Bearer Token is required for liveness probe
#### Response
##### Success
`200 OK`
```
{
"status": "OK"
}
```
### Import Portfolio Data
#### Prerequisites

16
apps/api/src/app/health/health.controller.ts

@ -3,7 +3,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import {
AiServiceHealthResponse,
DataEnhancerHealthResponse,
DataProviderHealthResponse
DataProviderHealthResponse,
LivenessHealthResponse
} from '@ghostfolio/common/interfaces';
import {
@ -33,9 +34,11 @@ export class HealthController {
@Get()
public async getHealth(@Res() response: Response) {
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
const redisCacheServiceHealthy =
await this.healthService.isRedisCacheHealthy();
const [databaseServiceHealthy, redisCacheServiceHealthy] =
await Promise.all([
this.healthService.isDatabaseHealthy(),
this.healthService.isRedisCacheHealthy()
]);
if (databaseServiceHealthy && redisCacheServiceHealthy) {
return response
@ -116,4 +119,9 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
}
@Get('liveness')
public getLiveness(): LivenessHealthResponse {
return { status: getReasonPhrase(StatusCodes.OK) };
}
}

366
apps/api/src/app/import/import.service.spec.ts

@ -0,0 +1,366 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client';
import { parseISO } from 'date-fns';
import { ImportService } from './import.service';
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
describe('ImportService', () => {
let createActivity: jest.Mock;
let gatherSymbols: jest.Mock;
let importService: ImportService;
beforeEach(() => {
const configuration = {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [],
ENABLE_FEATURE_SUBSCRIPTION: false,
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER
};
createActivity = jest.fn();
gatherSymbols = jest.fn();
const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
for (const { currency, dataSource, symbol } of activitiesDto) {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= {
currency,
dataSource,
symbol,
name: symbol
};
}
return assetProfiles;
}
);
importService = new ImportService(
{
getAccounts: jest.fn().mockResolvedValue([])
} as unknown as AccountService,
{
createActivity,
getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 })
} as unknown as ActivitiesService,
null,
{
get: (key: keyof typeof configuration) => {
return configuration[key];
}
} as unknown as ConfigurationService,
{ gatherSymbols } as unknown as DataGatheringService,
{
validateActivities,
getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL)
} as unknown as DataProviderService,
{
toCurrencyAtDate: jest.fn().mockResolvedValue(0),
toCurrencyAtDateWithExchangeRate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService,
null,
null,
null,
null,
{
getTagsForUser: jest.fn().mockResolvedValue([])
} as unknown as TagService
);
});
describe('import', () => {
it('keeps the asset profile of an investment activity after a non-investment activity', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' });
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([
{
dataSource: DataSource.MANUAL,
date: parseISO('2024-01-01T00:00:00.000Z'),
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
},
{
dataSource: DataSource.YAHOO,
date: parseISO('2024-01-02T00:00:00.000Z'),
symbol: 'AAPL'
}
]);
});
it('shows the custom asset profile of a non-investment activity after an investment activity', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' });
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
});
it.each(NON_INVESTMENT_ACTIVITY_TYPES)(
'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created',
async (type) => {
mockCreatedAssetProfiles([
{
dataSource: DataSource.MANUAL,
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
},
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
type,
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL'
}),
createActivityDto({
type,
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL });
}
);
it('refers the next activities without a data source to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
date: '2024-01-01T00:00:00.000Z',
symbol: 'Broker fee',
type: 'FEE'
}),
createActivityDto({
date: '2024-01-02T00:00:00.000Z',
symbol: 'Broker fee',
type: 'FEE'
})
]
});
expect(
createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create
).toMatchObject({
dataSource: DataSource.MANUAL,
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
});
});
it('refers the next investment activities without a data source to the custom asset profile which createActivity() has created', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
date: '2024-01-01T00:00:00.000Z',
symbol: 'Gold',
type: 'BUY'
}),
createActivityDto({
date: '2024-01-02T00:00:00.000Z',
symbol: 'Gold',
type: 'BUY'
}),
createActivityDto({
date: '2024-01-03T00:00:00.000Z',
symbol: 'Gold',
type: 'SELL'
})
]
});
expect(
createActivity.mock.calls.slice(1).map(([activity]) => {
return activity.SymbolProfile.connectOrCreate.create;
})
).toMatchObject([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([
{
dataSource: DataSource.MANUAL,
date: parseISO('2024-01-01T00:00:00.000Z'),
symbol: CUSTOM_ASSET_PROFILE_SYMBOL
}
]);
});
it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'FEE'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-02T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
})
],
isDryRun: true
});
expect(createActivity).not.toHaveBeenCalled();
expect(gatherSymbols).not.toHaveBeenCalled();
expect(getAssetProfileIdentifiers(activities)).toEqual([
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' },
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]);
});
});
function importActivities({
activitiesDto,
isDryRun
}: {
activitiesDto: CreateOrderDto[];
isDryRun?: boolean;
}) {
return importService.import({
activitiesDto,
isDryRun,
accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [],
tagsDto: [],
user: {
id: 'user-id',
permissions: [],
settings: { settings: { baseCurrency: 'USD' } }
} as unknown as UserWithSettings
});
}
function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) {
for (const assetProfile of assetProfiles) {
createActivity.mockImplementationOnce(
({
date,
type
}: Parameters<ActivitiesService['createActivity']>[0]) => {
return { date, type, SymbolProfile: assetProfile };
}
);
}
}
});
function createActivityDto({
dataSource,
date,
symbol,
type
}: Pick<CreateOrderDto, 'dataSource' | 'date' | 'symbol' | 'type'>) {
return {
dataSource,
date,
symbol,
type,
currency: 'USD',
fee: 0,
quantity: 1,
unitPrice: 100
} as CreateOrderDto;
}
function getAssetProfileIdentifiers(activities: Activity[]) {
return activities.map(({ assetProfile: { dataSource, symbol } }) => {
return { dataSource, symbol };
});
}

37
apps/api/src/app/import/import.service.ts

@ -837,6 +837,10 @@ export class ImportService {
const activities: Activity[] = [];
const customAssetProfileSymbols: {
[assetProfileIdentifier: string]: string;
} = {};
for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId;
const comment = activity.comment;
@ -850,12 +854,12 @@ export class ImportService {
const type = activity.type;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
})
] ?? {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
});
let assetProfile = assetProfiles[assetProfileIdentifier] ?? {
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
};
@ -907,7 +911,14 @@ export class ImportService {
continue;
}
const { dataSource, name, symbol } = assetProfile;
const customAssetProfileSymbol =
NON_INVESTMENT_ACTIVITY_TYPES.includes(type) ||
assetProfile.dataSource === DataSource.MANUAL
? customAssetProfileSymbols[assetProfileIdentifier]
: undefined;
const { dataSource, name } = assetProfile;
const symbol = customAssetProfileSymbol ?? assetProfile.symbol;
const createdActivity = await this.activitiesService.createActivity({
comment,
@ -944,9 +955,15 @@ export class ImportService {
userId: user.id
});
if (createdActivity.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = createdActivity.SymbolProfile.symbol;
if (createdActivity.SymbolProfile.dataSource === DataSource.MANUAL) {
customAssetProfileSymbols[assetProfileIdentifier] =
createdActivity.SymbolProfile.symbol;
assetProfile = {
...assetProfile,
dataSource: DataSource.MANUAL,
symbol: createdActivity.SymbolProfile.symbol
};
}
order = createdActivity;

9
apps/api/src/main.ts

@ -14,6 +14,7 @@ import {
import {
Logger,
LogLevel,
ShutdownSignal,
ValidationPipe,
VersioningType
} from '@nestjs/common';
@ -65,6 +66,14 @@ async function bootstrap() {
});
app.enableCors();
// Let the open requests and the active queue jobs finish on SIGINT and
// SIGTERM before the process stops. Exit explicitly because Linux ignores
// the re-raised signal if the process runs as PID 1
app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM], {
useProcessExit: true
});
app.enableVersioning({
defaultVersion: '1',
type: VersioningType.URI

12
apps/api/src/services/prisma/prisma.service.ts

@ -2,7 +2,7 @@ import {
Injectable,
Logger,
LogLevel,
OnModuleDestroy,
OnApplicationShutdown,
OnModuleInit
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@ -12,7 +12,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
implements OnApplicationShutdown, OnModuleInit
{
private readonly logger = new Logger(PrismaService.name);
@ -41,6 +41,10 @@ export class PrismaService
});
}
public async onApplicationShutdown() {
await this.$disconnect();
}
public async onModuleInit() {
try {
await this.$connect();
@ -48,8 +52,4 @@ export class PrismaService
this.logger.error(error);
}
}
public async onModuleDestroy() {
await this.$disconnect();
}
}

1
docker/docker-compose.yml

@ -5,6 +5,7 @@ services:
container_name: ghostfolio
restart: unless-stopped
init: true
stop_grace_period: 1m
cap_drop:
- ALL
security_opt:

2
libs/common/src/lib/interfaces/index.ts

@ -68,6 +68,7 @@ import type { ExportResponse } from './responses/export-response.interface';
import type { HistoricalResponse } from './responses/historical-response.interface';
import type { ImportResponse } from './responses/import-response.interface';
import type { InfoResponse } from './responses/info-response.interface';
import type { LivenessHealthResponse } from './responses/liveness-health-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
@ -156,6 +157,7 @@ export {
InfoResponse,
InvestmentItem,
LineChartItem,
LivenessHealthResponse,
LookupItem,
LookupResponse,
MarketData,

3
libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts

@ -0,0 +1,3 @@
export interface LivenessHealthResponse {
status: string;
}

42
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -314,23 +314,18 @@ export class GfTreemapChartComponent
formatter: (context: GfTreemapScriptableContext) => {
const raw = context.raw as GfTreemapDataPoint;
let netPerformancePercentWithCurrencyEffect = round(
raw._data.netPerformancePercentWithCurrencyEffect,
4
);
if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) {
netPerformancePercentWithCurrencyEffect = Math.abs(
netPerformancePercentWithCurrencyEffect
);
}
const name = raw._data.assetProfile.name;
const symbol = raw._data.assetProfile.symbol;
return [
isUUID(symbol) ? (name ?? symbol) : symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
`${(
raw._data.netPerformancePercentWithCurrencyEffect * 100
).toLocaleString(this.locale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
signDisplay: 'exceptZero'
})}%`
];
},
hoverColor: undefined,
@ -402,13 +397,21 @@ export class GfTreemapChartComponent
label: (context: TooltipItem<'treemap'>) => {
const raw = context.raw as GfTreemapDataPoint;
const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`;
const allocationInPercentage = `${(
raw._data.allocationInPercentage * 100
).toLocaleString(this.locale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})}%`;
const name = getHoldingName(raw._data.assetProfile);
const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
const netPerformanceInPercentageWithSign = `${(
raw._data.netPerformancePercentWithCurrencyEffect * 100
).toLocaleString(this.locale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
signDisplay: 'exceptZero'
})}%`;
if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency;
@ -421,11 +424,12 @@ export class GfTreemapChartComponent
})} ${this.baseCurrency()}`,
'',
$localize`Change` + ' (' + $localize`Performance` + ')',
`${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString(
`${raw._data.netPerformanceWithCurrencyEffect.toLocaleString(
this.locale(),
{
maximumFractionDigits: 2,
minimumFractionDigits: 2
minimumFractionDigits: 2,
signDisplay: 'exceptZero'
}
)} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})`
];

Loading…
Cancel
Save