From 250b8368b9d90afb96f399f1aa741646b4e08c95 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:37:44 +0200 Subject: [PATCH 1/2] feat(realunit): add cron-cached admin KPI stats endpoint Add GET /v1/realunit/admin/stats (REALUNIT/ADMIN role) returning aggregated RealUnit KPIs from an hourly cron-refreshed cache, mirroring the existing StatisticService pattern. - New RealUnitStatsService computes growth, KYC funnel, registration and trading KPIs over total/30d/7d windows via Promise.all - New RealUnitStatsDto contract with Period (total/last30Days/last7Days) - Add generic aggregate query helpers: KycAdminService.getKycStepCounts, UserDataService.getNewUserDataCount, UserService.getNewUserCount, TransactionService.getAssetTradingStats - Add Process.UPDATE_REALUNIT_STATS for cron disable support - Full unit coverage on new service, DTO and helper methods --- src/shared/services/process.service.ts | 1 + .../kyc/__tests__/kyc-admin.service.spec.ts | 93 ++++++++ .../generic/kyc/services/kyc-admin.service.ts | 24 ++ .../__tests__/user-data.service.spec.ts | 33 +++ .../models/user-data/user-data.service.ts | 7 + .../models/user/tests/user.service.spec.ts | 33 +++ .../generic/user/models/user/user.service.ts | 7 + .../__tests__/transaction.service.spec.ts | 101 ++++++++ .../payment/services/transaction.service.ts | 36 +++ .../__tests__/realunit-stats.service.spec.ts | 225 ++++++++++++++++++ .../controllers/realunit.controller.ts | 16 ++ .../realunit/dto/realunit-stats.dto.ts | 74 ++++++ .../realunit/realunit-stats.service.ts | 168 +++++++++++++ .../supporting/realunit/realunit.module.ts | 5 +- 14 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts create mode 100644 src/subdomains/supporting/payment/__tests__/transaction.service.spec.ts create mode 100644 src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts create mode 100644 src/subdomains/supporting/realunit/dto/realunit-stats.dto.ts create mode 100644 src/subdomains/supporting/realunit/realunit-stats.service.ts diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index f16da5d8c0..27b9362941 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -23,6 +23,7 @@ export enum Process { MONITORING = 'Monitoring', MONITOR_CONNECTION_POOL = 'MonitorConnectionPool', UPDATE_STATISTIC = 'UpdateStatistic', + UPDATE_REALUNIT_STATS = 'UpdateRealunitStats', KYC = 'Kyc', KYC_IDENT_REVIEW = 'KycIdentReview', KYC_NATIONALITY_REVIEW = 'KycNationalityReview', diff --git a/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts b/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts new file mode 100644 index 0000000000..c572caa77e --- /dev/null +++ b/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts @@ -0,0 +1,93 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDataService } from '../../user/models/user-data/user-data.service'; +import { WebhookService } from '../../user/services/webhook/webhook.service'; +import { KycStepName } from '../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../enums/review-status.enum'; +import { KycStepRepository } from '../repositories/kyc-step.repository'; +import { KycAdminService } from '../services/kyc-admin.service'; +import { KycNotificationService } from '../services/kyc-notification.service'; +import { KycService } from '../services/kyc.service'; +import { NameCheckService } from '../services/name-check.service'; + +describe('KycAdminService', () => { + let service: KycAdminService; + let kycStepRepo: jest.Mocked; + + beforeEach(async () => { + kycStepRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KycAdminService, + { provide: KycStepRepository, useValue: kycStepRepo }, + { provide: WebhookService, useValue: createMock() }, + { provide: KycService, useValue: createMock() }, + { provide: KycNotificationService, useValue: createMock() }, + { provide: UserDataService, useValue: createMock() }, + { provide: NameCheckService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(KycAdminService); + }); + + describe('getKycStepCounts', () => { + function mockQueryBuilder(rows: { name: KycStepName; status: ReviewStatus; count: string }[]): { + select: jest.Mock; + addSelect: jest.Mock; + where: jest.Mock; + groupBy: jest.Mock; + addGroupBy: jest.Mock; + andWhere: jest.Mock; + getRawMany: jest.Mock; + } { + const query = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rows), + }; + kycStepRepo.createQueryBuilder.mockReturnValue(query as never); + return query; + } + + it('returns an empty array and skips querying when names is empty', async () => { + const result = await service.getKycStepCounts([]); + + expect(result).toEqual([]); + expect(kycStepRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('maps raw rows and coerces count to number, without date filters', async () => { + const query = mockQueryBuilder([ + { name: KycStepName.IDENT, status: ReviewStatus.COMPLETED, count: '5' }, + { name: KycStepName.IDENT, status: ReviewStatus.IN_PROGRESS, count: '2' }, + ]); + + const result = await service.getKycStepCounts([KycStepName.IDENT]); + + expect(result).toEqual([ + { name: KycStepName.IDENT, status: ReviewStatus.COMPLETED, count: 5 }, + { name: KycStepName.IDENT, status: ReviewStatus.IN_PROGRESS, count: 2 }, + ]); + expect(query.where).toHaveBeenCalledWith('kycStep.name IN (:...names)', { names: [KycStepName.IDENT] }); + expect(query.andWhere).not.toHaveBeenCalled(); + }); + + it('applies from and to date filters when provided', async () => { + const query = mockQueryBuilder([]); + const from = new Date('2024-01-01'); + const to = new Date('2024-02-01'); + + const result = await service.getKycStepCounts([KycStepName.CONTACT_DATA], from, to); + + expect(result).toEqual([]); + expect(query.andWhere).toHaveBeenCalledWith('kycStep.created >= :from', { from }); + expect(query.andWhere).toHaveBeenCalledWith('kycStep.created <= :to', { to }); + }); + }); +}); diff --git a/src/subdomains/generic/kyc/services/kyc-admin.service.ts b/src/subdomains/generic/kyc/services/kyc-admin.service.ts index 92e184b604..5808e8240e 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -36,6 +36,30 @@ export class KycAdminService { return this.kycStepRepo.find({ where: { userData: { id: userDataId } }, relations }); } + async getKycStepCounts( + names: KycStepName[], + from?: Date, + to?: Date, + ): Promise<{ name: KycStepName; status: ReviewStatus; count: number }[]> { + if (!names.length) return []; + + const query = this.kycStepRepo + .createQueryBuilder('kycStep') + .select('kycStep.name', 'name') + .addSelect('kycStep.status', 'status') + .addSelect('COUNT(kycStep.id)', 'count') + .where('kycStep.name IN (:...names)', { names }) + .groupBy('kycStep.name') + .addGroupBy('kycStep.status'); + + if (from) query.andWhere('kycStep.created >= :from', { from }); + if (to) query.andWhere('kycStep.created <= :to', { to }); + + return query + .getRawMany<{ name: KycStepName; status: ReviewStatus; count: string }>() + .then((rows) => rows.map((r) => ({ name: r.name, status: r.status, count: Number(r.count) }))); + } + async updateKycStep(stepId: number, dto: UpdateKycStepDto): Promise { const kycStep = await this.kycStepRepo.findOne({ where: { id: stepId }, diff --git a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts index ad7edb0767..8d4b82d69b 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts @@ -87,4 +87,37 @@ describe('UserDataService', () => { expect(savedArg.id).toBe(1); }); }); + + describe('getNewUserDataCount', () => { + function mockQueryBuilder(count: number): { andWhere: jest.Mock; getCount: jest.Mock } { + const query = { + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(count), + }; + userDataRepo.createQueryBuilder.mockReturnValue(query as never); + return query; + } + + it('returns the count without any date filter', async () => { + const query = mockQueryBuilder(42); + + const result = await service.getNewUserDataCount(); + + expect(result).toBe(42); + expect(query.andWhere).not.toHaveBeenCalled(); + expect(query.getCount).toHaveBeenCalledTimes(1); + }); + + it('applies from and to date filters when provided', async () => { + const query = mockQueryBuilder(7); + const from = new Date('2024-01-01'); + const to = new Date('2024-02-01'); + + const result = await service.getNewUserDataCount(from, to); + + expect(result).toBe(7); + expect(query.andWhere).toHaveBeenCalledWith('userData.created >= :from', { from }); + expect(query.andWhere).toHaveBeenCalledWith('userData.created <= :to', { to }); + }); + }); }); diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 8035b831ee..08ba6ed895 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -121,6 +121,13 @@ export class UserDataService { return this.phoneCallCompletedSubject.asObservable(); } + async getNewUserDataCount(from?: Date, to?: Date): Promise { + const query = this.userDataRepo.createQueryBuilder('userData'); + if (from) query.andWhere('userData.created >= :from', { from }); + if (to) query.andWhere('userData.created <= :to', { to }); + return query.getCount(); + } + async getUserDataByUser(userId: number): Promise { return this.userDataRepo .createQueryBuilder('userData') diff --git a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts index f9025101c4..cb7289e82c 100644 --- a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts +++ b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts @@ -82,4 +82,37 @@ describe('UserService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getNewUserCount', () => { + function mockQueryBuilder(count: number): { andWhere: jest.Mock; getCount: jest.Mock } { + const query = { + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(count), + }; + (userRepo.createQueryBuilder as jest.Mock).mockReturnValue(query); + return query; + } + + it('returns the count without any date filter', async () => { + const query = mockQueryBuilder(100); + + const result = await service.getNewUserCount(); + + expect(result).toBe(100); + expect(query.andWhere).not.toHaveBeenCalled(); + expect(query.getCount).toHaveBeenCalledTimes(1); + }); + + it('applies from and to date filters when provided', async () => { + const query = mockQueryBuilder(12); + const from = new Date('2024-01-01'); + const to = new Date('2024-02-01'); + + const result = await service.getNewUserCount(from, to); + + expect(result).toBe(12); + expect(query.andWhere).toHaveBeenCalledWith('user.created >= :from', { from }); + expect(query.andWhere).toHaveBeenCalledWith('user.created <= :to', { to }); + }); + }); }); diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 59f313cfe5..3c3b6d022a 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -92,6 +92,13 @@ export class UserService { return this.userRepo.findOne({ where: { address }, relations }); } + async getNewUserCount(from?: Date, to?: Date): Promise { + const query = this.userRepo.createQueryBuilder('user'); + if (from) query.andWhere('user.created >= :from', { from }); + if (to) query.andWhere('user.created <= :to', { to }); + return query.getCount(); + } + async getUserByKey(key: string, value: any, onlyDefaultRelation = false): Promise { const query = this.userRepo .createQueryBuilder('user') diff --git a/src/subdomains/supporting/payment/__tests__/transaction.service.spec.ts b/src/subdomains/supporting/payment/__tests__/transaction.service.spec.ts new file mode 100644 index 0000000000..62ca5b8112 --- /dev/null +++ b/src/subdomains/supporting/payment/__tests__/transaction.service.spec.ts @@ -0,0 +1,101 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { TransactionRequestType } from '../entities/transaction-request.entity'; +import { TransactionRepository } from '../repositories/transaction.repository'; +import { SpecialExternalAccountService } from '../services/special-external-account.service'; +import { TransactionService } from '../services/transaction.service'; + +describe('TransactionService', () => { + let service: TransactionService; + let repo: jest.Mocked; + + beforeEach(async () => { + repo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionService, + { provide: TransactionRepository, useValue: repo }, + { provide: UserDataService, useValue: createMock() }, + { provide: BankDataService, useValue: createMock() }, + { provide: SpecialExternalAccountService, useValue: createMock() }, + { provide: BuyCryptoRepository, useValue: createMock() }, + ], + }).compile(); + + service = module.get(TransactionService); + }); + + describe('getAssetTradingStats', () => { + function mockQueryBuilder(rows: { type: TransactionRequestType; volume: string; count: string }[]): { + select: jest.Mock; + addSelect: jest.Mock; + innerJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + groupBy: jest.Mock; + getRawMany: jest.Mock; + } { + const subQuery = { where: jest.fn().mockReturnThis(), orWhere: jest.fn().mockReturnThis() }; + const query = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockImplementation(function (this: unknown, arg: unknown) { + // Execute the Brackets where-factory so its callback body is exercised. + if (arg && typeof (arg as { whereFactory?: unknown }).whereFactory === 'function') { + (arg as { whereFactory: (qb: unknown) => void }).whereFactory(subQuery); + } + return this; + }), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rows), + }; + repo.createQueryBuilder.mockReturnValue(query as never); + return query; + } + + it('maps raw rows to numbers without date filters', async () => { + const query = mockQueryBuilder([ + { type: TransactionRequestType.BUY, volume: '1234.5', count: '10' }, + { type: TransactionRequestType.SELL, volume: '500', count: '3' }, + ]); + + const result = await service.getAssetTradingStats(408); + + expect(result).toEqual([ + { type: TransactionRequestType.BUY, volume: 1234.5, count: 10 }, + { type: TransactionRequestType.SELL, volume: 500, count: 3 }, + ]); + expect(query.groupBy).toHaveBeenCalledWith('request.type'); + expect(query.andWhere).not.toHaveBeenCalledWith('transaction.created >= :from', expect.anything()); + expect(query.andWhere).not.toHaveBeenCalledWith('transaction.created <= :to', expect.anything()); + }); + + it('coerces null/NaN volume and count to 0', async () => { + mockQueryBuilder([ + { type: TransactionRequestType.BUY, volume: null as unknown as string, count: undefined as unknown as string }, + ]); + + const result = await service.getAssetTradingStats(408); + + expect(result).toEqual([{ type: TransactionRequestType.BUY, volume: 0, count: 0 }]); + }); + + it('applies from and to date filters when provided', async () => { + const query = mockQueryBuilder([]); + const from = new Date('2024-01-01'); + const to = new Date('2024-02-01'); + + const result = await service.getAssetTradingStats(408, from, to); + + expect(result).toEqual([]); + expect(query.andWhere).toHaveBeenCalledWith('transaction.created >= :from', { from }); + expect(query.andWhere).toHaveBeenCalledWith('transaction.created <= :to', { to }); + }); + }); +}); diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 8538e29723..9f8b2eac46 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -322,6 +322,42 @@ export class TransactionService { }); } + async getAssetTradingStats( + assetId: number, + from?: Date, + to?: Date, + ): Promise<{ type: TransactionRequestType; volume: number; count: number }[]> { + const query = this.repo + .createQueryBuilder('transaction') + .select('request.type', 'type') + .addSelect('SUM(transaction.amountInChf)', 'volume') + .addSelect('COUNT(transaction.id)', 'count') + .innerJoin('transaction.request', 'request') + .where('transaction.type IS NOT NULL') + .andWhere('transaction.amountInChf IS NOT NULL') + .andWhere( + new Brackets((qb) => + qb + .where('(request.type = :buy AND request.targetId = :assetId)', { + buy: TransactionRequestType.BUY, + assetId, + }) + .orWhere('(request.type = :sell AND request.sourceId = :assetId)', { + sell: TransactionRequestType.SELL, + assetId, + }), + ), + ) + .groupBy('request.type'); + + if (from) query.andWhere('transaction.created >= :from', { from }); + if (to) query.andWhere('transaction.created <= :to', { to }); + + return query + .getRawMany<{ type: TransactionRequestType; volume: string; count: string }>() + .then((rows) => rows.map((r) => ({ type: r.type, volume: Number(r.volume) || 0, count: Number(r.count) || 0 }))); + } + async getTransactionByKey(key: string, value: any): Promise { return this.repo .createQueryBuilder('transaction') diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts new file mode 100644 index 0000000000..acb6a4338f --- /dev/null +++ b/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts @@ -0,0 +1,225 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import * as KycEnum from 'src/subdomains/generic/kyc/enums/kyc.enum'; +import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { KycAdminService } from 'src/subdomains/generic/kyc/services/kyc-admin.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequestType } from '../../payment/entities/transaction-request.entity'; +import { TransactionService } from '../../payment/services/transaction.service'; +import { + RealUnitGrowthStats, + RealUnitKycFunnelStep, + RealUnitRegistrationStats, + RealUnitStatsDto, + RealUnitStatsPeriod, + RealUnitTradingStats, +} from '../dto/realunit-stats.dto'; +import { RealUnitStatsService } from '../realunit-stats.service'; +import { RealUnitService } from '../realunit.service'; + +describe('RealUnitStatsService', () => { + let service: RealUnitStatsService; + let realUnitService: jest.Mocked; + let userDataService: jest.Mocked; + let userService: jest.Mocked; + let kycAdminService: jest.Mocked; + let transactionService: jest.Mocked; + + const realuAsset = createCustomAsset({ + id: 408, + name: 'REALU', + blockchain: Blockchain.ETHEREUM, + type: AssetType.TOKEN, + }); + + beforeEach(async () => { + realUnitService = createMock(); + userDataService = createMock(); + userService = createMock(); + kycAdminService = createMock(); + transactionService = createMock(); + + realUnitService.getRealuAsset.mockResolvedValue(realuAsset); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RealUnitStatsService, + { provide: RealUnitService, useValue: realUnitService }, + { provide: UserDataService, useValue: userDataService }, + { provide: UserService, useValue: userService }, + { provide: KycAdminService, useValue: kycAdminService }, + { provide: TransactionService, useValue: transactionService }, + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(RealUnitStatsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('doUpdate / getStats (populated)', () => { + beforeEach(() => { + // growth counts: total / 30d / 7d + userDataService.getNewUserDataCount + .mockResolvedValueOnce(1000) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(20); + userService.getNewUserCount.mockResolvedValueOnce(1500).mockResolvedValueOnce(150).mockResolvedValueOnce(30); + + // KYC step counts: total / 30d / 7d + kycAdminService.getKycStepCounts + .mockResolvedValueOnce([ + { name: KycStepName.CONTACT_DATA, status: ReviewStatus.COMPLETED, count: 80 }, + { name: KycStepName.CONTACT_DATA, status: ReviewStatus.IN_PROGRESS, count: 20 }, + { name: KycStepName.IDENT, status: ReviewStatus.COMPLETED, count: 40 }, + { name: KycStepName.IDENT, status: ReviewStatus.FAILED, count: 10 }, + { name: KycStepName.REALUNIT_REGISTRATION, status: ReviewStatus.IN_PROGRESS, count: 5 }, + { name: KycStepName.REALUNIT_REGISTRATION, status: ReviewStatus.INTERNAL_REVIEW, count: 3 }, + { name: KycStepName.REALUNIT_REGISTRATION, status: ReviewStatus.COMPLETED, count: 12 }, + ]) + .mockResolvedValueOnce([ + { name: KycStepName.CONTACT_DATA, status: ReviewStatus.COMPLETED, count: 8 }, + { name: KycStepName.REALUNIT_REGISTRATION, status: ReviewStatus.COMPLETED, count: 2 }, + ]) + .mockResolvedValueOnce([{ name: KycStepName.CONTACT_DATA, status: ReviewStatus.COMPLETED, count: 1 }]); + + // Trading: total / 30d / 7d + transactionService.getAssetTradingStats + .mockResolvedValueOnce([ + { type: TransactionRequestType.BUY, volume: 12345.678, count: 50 }, + { type: TransactionRequestType.SELL, volume: 6789.123, count: 20 }, + ]) + .mockResolvedValueOnce([ + { type: TransactionRequestType.BUY, volume: 1234.5, count: 5 }, + { type: TransactionRequestType.SELL, volume: 678.9, count: 2 }, + ]) + .mockResolvedValueOnce([{ type: TransactionRequestType.BUY, volume: 100, count: 1 }]); + }); + + it('computes and caches the full stats DTO', async () => { + await service.doUpdate(); + const stats = service.getStats(); + + expect(stats.updated).toBeInstanceOf(Date); + + // growth + expect(stats.growth.accounts).toEqual({ total: 1000, last30Days: 100, last7Days: 20 }); + expect(stats.growth.wallets).toEqual({ total: 1500, last30Days: 150, last7Days: 30 }); + + // kyc funnel: one entry per REALUNIT_BUY required step (6 steps), registration step excluded + expect(stats.kycFunnel).toHaveLength(6); + const contactStep = stats.kycFunnel.find((s) => s.step === KycStepName.CONTACT_DATA); + expect(contactStep?.reached).toEqual({ total: 100, last30Days: 8, last7Days: 1 }); + expect(contactStep?.completed).toEqual({ total: 80, last30Days: 8, last7Days: 1 }); + + const identStep = stats.kycFunnel.find((s) => s.step === KycStepName.IDENT); + expect(identStep?.reached).toEqual({ total: 50, last30Days: 0, last7Days: 0 }); + expect(identStep?.completed).toEqual({ total: 40, last30Days: 0, last7Days: 0 }); + + // registration is the REALUNIT_REGISTRATION step, never part of the funnel array + expect(stats.kycFunnel.some((s) => s.step === KycStepName.REALUNIT_REGISTRATION)).toBe(false); + expect(stats.registration.started).toEqual({ total: 20, last30Days: 2, last7Days: 0 }); + expect(stats.registration.inReview).toEqual({ total: 8, last30Days: 0, last7Days: 0 }); + expect(stats.registration.completed).toEqual({ total: 12, last30Days: 2, last7Days: 0 }); + + // trading (volume rounded to 2 decimals) + expect(stats.trading.buyVolumeChf).toEqual({ total: 12345.68, last30Days: 1234.5, last7Days: 100 }); + expect(stats.trading.buyCount).toEqual({ total: 50, last30Days: 5, last7Days: 1 }); + expect(stats.trading.sellVolumeChf).toEqual({ total: 6789.12, last30Days: 678.9, last7Days: 0 }); + expect(stats.trading.sellCount).toEqual({ total: 20, last30Days: 2, last7Days: 0 }); + }); + }); + + describe('doUpdate / getStats (empty / zero)', () => { + beforeEach(() => { + userDataService.getNewUserDataCount.mockResolvedValue(0); + userService.getNewUserCount.mockResolvedValue(0); + kycAdminService.getKycStepCounts.mockResolvedValue([]); + transactionService.getAssetTradingStats.mockResolvedValue([]); + }); + + it('falls back to zero for all KPIs when no data exists', async () => { + await service.doUpdate(); + const stats = service.getStats(); + + expect(stats.growth.accounts).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.growth.wallets).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.kycFunnel).toHaveLength(6); + stats.kycFunnel.forEach((step) => { + expect(step.reached).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(step.completed).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + }); + expect(stats.registration.started).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.registration.inReview).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.registration.completed).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.trading.buyVolumeChf).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.trading.buyCount).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.trading.sellVolumeChf).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + expect(stats.trading.sellCount).toEqual({ total: 0, last30Days: 0, last7Days: 0 }); + }); + + it('produces an empty funnel when no required steps are defined for the context', async () => { + const spy = jest.spyOn(KycEnum, 'contextRequiredSteps').mockReturnValue(undefined); + + await service.doUpdate(); + const stats = service.getStats(); + + expect(stats.kycFunnel).toEqual([]); + spy.mockRestore(); + }); + }); + + describe('onModuleInit', () => { + it('triggers a cache update', () => { + const spy = jest.spyOn(service, 'doUpdate').mockResolvedValue(); + + service.onModuleInit(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('RealUnitStatsDto', () => { + it('instantiates all DTO classes with the contract shape', () => { + const period = Object.assign(new RealUnitStatsPeriod(), { total: 1, last30Days: 2, last7Days: 3 }); + const growth = Object.assign(new RealUnitGrowthStats(), { accounts: period, wallets: period }); + const funnelStep = Object.assign(new RealUnitKycFunnelStep(), { + step: KycStepName.CONTACT_DATA, + reached: period, + completed: period, + }); + const registration = Object.assign(new RealUnitRegistrationStats(), { + started: period, + inReview: period, + completed: period, + }); + const trading = Object.assign(new RealUnitTradingStats(), { + buyVolumeChf: period, + buyCount: period, + sellVolumeChf: period, + sellCount: period, + }); + const dto = Object.assign(new RealUnitStatsDto(), { + updated: new Date(), + growth, + kycFunnel: [funnelStep], + registration, + trading, + }); + + expect(dto.growth.accounts.total).toBe(1); + expect(dto.kycFunnel[0].step).toBe(KycStepName.CONTACT_DATA); + expect(dto.registration.completed.last7Days).toBe(3); + expect(dto.trading.sellCount.last30Days).toBe(2); + }); + }); +}); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index ea66d6c19b..50990f0edb 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -74,6 +74,8 @@ import { TimeFrame, TokenInfoDto, } from '../dto/realunit.dto'; +import { RealUnitStatsDto } from '../dto/realunit-stats.dto'; +import { RealUnitStatsService } from '../realunit-stats.service'; import { RealUnitService } from '../realunit.service'; @ApiTags('Realunit') @@ -85,6 +87,7 @@ export class RealUnitController { private readonly userService: UserService, private readonly swissQrService: SwissQRService, private readonly pricingService: PricingService, + private readonly realUnitStatsService: RealUnitStatsService, ) {} @Get('account/:address') @@ -734,6 +737,19 @@ export class RealUnitController { // --- Admin Endpoints --- + @Get('admin/stats') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Get RealUnit KPI statistics' }) + @ApiOkResponse({ + type: RealUnitStatsDto, + description: 'Aggregated RealUnit growth, KYC funnel, registration and trading KPIs', + }) + @UseGuards(AuthGuard(), RoleGuard(UserRole.REALUNIT), UserActiveGuard()) + async getStats(): Promise { + return this.realUnitStatsService.getStats(); + } + @Get('admin/quotes') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/supporting/realunit/dto/realunit-stats.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-stats.dto.ts new file mode 100644 index 0000000000..f5033c975b --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-stats.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; + +export class RealUnitStatsPeriod { + @ApiProperty({ type: Number, description: 'Total count over all time' }) + total: number; + + @ApiProperty({ type: Number, description: 'Count within the last 30 days' }) + last30Days: number; + + @ApiProperty({ type: Number, description: 'Count within the last 7 days' }) + last7Days: number; +} + +export class RealUnitGrowthStats { + @ApiProperty({ type: RealUnitStatsPeriod, description: 'New accounts (user data) growth' }) + accounts: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'New wallets (users) growth' }) + wallets: RealUnitStatsPeriod; +} + +export class RealUnitKycFunnelStep { + @ApiProperty({ enum: KycStepName, description: 'KYC step name' }) + step: KycStepName; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Number of times the step was reached (created)' }) + reached: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Number of times the step was completed' }) + completed: RealUnitStatsPeriod; +} + +export class RealUnitRegistrationStats { + @ApiProperty({ type: RealUnitStatsPeriod, description: 'RealUnit registrations started' }) + started: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'RealUnit registrations currently in review' }) + inReview: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'RealUnit registrations completed' }) + completed: RealUnitStatsPeriod; +} + +export class RealUnitTradingStats { + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Buy volume in CHF' }) + buyVolumeChf: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Number of buy transactions' }) + buyCount: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Sell volume in CHF' }) + sellVolumeChf: RealUnitStatsPeriod; + + @ApiProperty({ type: RealUnitStatsPeriod, description: 'Number of sell transactions' }) + sellCount: RealUnitStatsPeriod; +} + +export class RealUnitStatsDto { + @ApiProperty({ type: Date, description: 'Timestamp of the last cache update' }) + updated: Date; + + @ApiProperty({ type: RealUnitGrowthStats, description: 'Account and wallet growth KPIs' }) + growth: RealUnitGrowthStats; + + @ApiProperty({ type: RealUnitKycFunnelStep, isArray: true, description: 'KYC funnel step KPIs' }) + kycFunnel: RealUnitKycFunnelStep[]; + + @ApiProperty({ type: RealUnitRegistrationStats, description: 'RealUnit registration KPIs' }) + registration: RealUnitRegistrationStats; + + @ApiProperty({ type: RealUnitTradingStats, description: 'RealUnit trading KPIs' }) + trading: RealUnitTradingStats; +} diff --git a/src/subdomains/supporting/realunit/realunit-stats.service.ts b/src/subdomains/supporting/realunit/realunit-stats.service.ts new file mode 100644 index 0000000000..19961c7736 --- /dev/null +++ b/src/subdomains/supporting/realunit/realunit-stats.service.ts @@ -0,0 +1,168 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Util } from 'src/shared/utils/util'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import { KycContext, contextRequiredSteps } from 'src/subdomains/generic/kyc/enums/kyc.enum'; +import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; +import { KycAdminService } from 'src/subdomains/generic/kyc/services/kyc-admin.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { TransactionRequestType } from '../payment/entities/transaction-request.entity'; +import { TransactionService } from '../payment/services/transaction.service'; +import { RealUnitKycFunnelStep, RealUnitStatsDto, RealUnitStatsPeriod } from './dto/realunit-stats.dto'; +import { RealUnitService } from './realunit.service'; + +type KycStepCount = { name: KycStepName; status: ReviewStatus; count: number }; +type TradingStat = { type: TransactionRequestType; volume: number; count: number }; + +const REGISTRATION_IN_REVIEW_STATUSES = [ + ReviewStatus.IN_PROGRESS, + ReviewStatus.FINISHED, + ReviewStatus.INTERNAL_REVIEW, + ReviewStatus.EXTERNAL_REVIEW, + ReviewStatus.MANUAL_REVIEW, +]; +const COMPLETED_STATUSES = [ReviewStatus.COMPLETED]; + +@Injectable() +export class RealUnitStatsService implements OnModuleInit { + private stats: RealUnitStatsDto; + + constructor( + private readonly realUnitService: RealUnitService, + private readonly userDataService: UserDataService, + private readonly userService: UserService, + private readonly kycAdminService: KycAdminService, + private readonly transactionService: TransactionService, + ) {} + + onModuleInit() { + void this.doUpdate(); + } + + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.UPDATE_REALUNIT_STATS, timeout: 7200 }) + async doUpdate(): Promise { + const now = new Date(); + const d30 = Util.daysBefore(30, now); + const d7 = Util.daysBefore(7, now); + + const asset = await this.realUnitService.getRealuAsset(); + const funnelSteps = Array.from(contextRequiredSteps(KycContext.REALUNIT_BUY) ?? []); + const stepNames = [...funnelSteps, KycStepName.REALUNIT_REGISTRATION]; + + const [ + accountsTotal, + accounts30, + accounts7, + walletsTotal, + wallets30, + wallets7, + stepsTotal, + steps30, + steps7, + tradingTotal, + trading30, + trading7, + ] = await Promise.all([ + this.userDataService.getNewUserDataCount(), + this.userDataService.getNewUserDataCount(d30, now), + this.userDataService.getNewUserDataCount(d7, now), + this.userService.getNewUserCount(), + this.userService.getNewUserCount(d30, now), + this.userService.getNewUserCount(d7, now), + this.kycAdminService.getKycStepCounts(stepNames), + this.kycAdminService.getKycStepCounts(stepNames, d30, now), + this.kycAdminService.getKycStepCounts(stepNames, d7, now), + this.transactionService.getAssetTradingStats(asset.id), + this.transactionService.getAssetTradingStats(asset.id, d30, now), + this.transactionService.getAssetTradingStats(asset.id, d7, now), + ]); + + this.stats = { + updated: now, + growth: { + accounts: { total: accountsTotal, last30Days: accounts30, last7Days: accounts7 }, + wallets: { total: walletsTotal, last30Days: wallets30, last7Days: wallets7 }, + }, + kycFunnel: funnelSteps.map((step) => this.mapFunnelStep(step, stepsTotal, steps30, steps7)), + registration: { + started: this.stepPeriod(KycStepName.REALUNIT_REGISTRATION, stepsTotal, steps30, steps7), + inReview: this.stepPeriod( + KycStepName.REALUNIT_REGISTRATION, + stepsTotal, + steps30, + steps7, + REGISTRATION_IN_REVIEW_STATUSES, + ), + completed: this.stepPeriod(KycStepName.REALUNIT_REGISTRATION, stepsTotal, steps30, steps7, COMPLETED_STATUSES), + }, + trading: { + buyVolumeChf: this.tradingPeriod(TransactionRequestType.BUY, 'volume', tradingTotal, trading30, trading7), + buyCount: this.tradingPeriod(TransactionRequestType.BUY, 'count', tradingTotal, trading30, trading7), + sellVolumeChf: this.tradingPeriod(TransactionRequestType.SELL, 'volume', tradingTotal, trading30, trading7), + sellCount: this.tradingPeriod(TransactionRequestType.SELL, 'count', tradingTotal, trading30, trading7), + }, + }; + } + + getStats(): RealUnitStatsDto { + return this.stats; + } + + // --- Helpers --- + + private mapFunnelStep( + step: KycStepName, + total: KycStepCount[], + last30: KycStepCount[], + last7: KycStepCount[], + ): RealUnitKycFunnelStep { + return { + step, + reached: this.stepPeriod(step, total, last30, last7), + completed: this.stepPeriod(step, total, last30, last7, COMPLETED_STATUSES), + }; + } + + private stepPeriod( + name: KycStepName, + total: KycStepCount[], + last30: KycStepCount[], + last7: KycStepCount[], + statuses?: ReviewStatus[], + ): RealUnitStatsPeriod { + return { + total: this.sumStep(total, name, statuses), + last30Days: this.sumStep(last30, name, statuses), + last7Days: this.sumStep(last7, name, statuses), + }; + } + + private sumStep(rows: KycStepCount[], name: KycStepName, statuses?: ReviewStatus[]): number { + return rows + .filter((r) => r.name === name && (!statuses || statuses.includes(r.status))) + .reduce((sum, r) => sum + r.count, 0); + } + + private tradingPeriod( + type: TransactionRequestType, + field: 'volume' | 'count', + total: TradingStat[], + last30: TradingStat[], + last7: TradingStat[], + ): RealUnitStatsPeriod { + return { + total: this.tradingValue(total, type, field), + last30Days: this.tradingValue(last30, type, field), + last7Days: this.tradingValue(last7, type, field), + }; + } + + private tradingValue(rows: TradingStat[], type: TransactionRequestType, field: 'volume' | 'count'): number { + const value = rows.find((r) => r.type === type)?.[field] ?? 0; + return field === 'volume' ? Util.round(value, Config.defaultVolumeDecimal) : value; + } +} diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index a7d7a20cd6..56b4a5328c 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -17,6 +17,7 @@ import { TransactionModule } from '../payment/transaction.module'; import { PricingModule } from '../pricing/pricing.module'; import { RealUnitController } from './controllers/realunit.controller'; import { RealUnitDevService } from './realunit-dev.service'; +import { RealUnitStatsService } from './realunit-stats.service'; import { RealUnitService } from './realunit.service'; @Module({ @@ -39,7 +40,7 @@ import { RealUnitService } from './realunit.service'; FaucetRequestModule, ], controllers: [RealUnitController], - providers: [RealUnitService, RealUnitDevService], - exports: [RealUnitService], + providers: [RealUnitService, RealUnitDevService, RealUnitStatsService], + exports: [RealUnitService, RealUnitStatsService], }) export class RealUnitModule {} From d1718f9e8849e553cd009c7a9204c9ec539e40fc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:48:59 +0200 Subject: [PATCH 2/2] refactor(realunit): use absolute import paths in new stats specs --- .../generic/kyc/__tests__/kyc-admin.service.spec.ts | 4 ++-- .../realunit/__tests__/realunit-stats.service.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts b/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts index c572caa77e..82a997ecd7 100644 --- a/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts +++ b/src/subdomains/generic/kyc/__tests__/kyc-admin.service.spec.ts @@ -1,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { UserDataService } from '../../user/models/user-data/user-data.service'; -import { WebhookService } from '../../user/services/webhook/webhook.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; import { KycStepName } from '../enums/kyc-step-name.enum'; import { ReviewStatus } from '../enums/review-status.enum'; import { KycStepRepository } from '../repositories/kyc-step.repository'; diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts index acb6a4338f..040b0e5daa 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-stats.service.spec.ts @@ -10,8 +10,8 @@ import { TestUtil } from 'src/shared/utils/test.util'; import { KycAdminService } from 'src/subdomains/generic/kyc/services/kyc-admin.service'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; -import { TransactionRequestType } from '../../payment/entities/transaction-request.entity'; -import { TransactionService } from '../../payment/services/transaction.service'; +import { TransactionRequestType } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { RealUnitGrowthStats, RealUnitKycFunnelStep,