From ab9a683c38a9536e9ea5165c6205d060ef453777 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:19:38 +0100 Subject: [PATCH 1/6] feat: add sync-prod-logs script for local development (#3439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract production log sync script into its own branch. Remove hardcoded default password — SQL_PASSWORD must be set in .env. --- scripts/sync-prod-logs.js | 194 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 scripts/sync-prod-logs.js diff --git a/scripts/sync-prod-logs.js b/scripts/sync-prod-logs.js new file mode 100644 index 0000000000..81bd55a3d6 --- /dev/null +++ b/scripts/sync-prod-logs.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Syncs log entries from production API to local MSSQL database. +// Usage: node scripts/sync-prod-logs.js [--since DATE] [--batch-size N] + +const sql = require('mssql'); +const https = require('https'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// --- Config --- +const SINCE = process.argv.find((a, i) => process.argv[i - 1] === '--since') || '2026-03-01'; +const BATCH_SIZE = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--batch-size') || '100'); + +// Load .env +const envPath = path.join(__dirname, '..', '.env'); +const envVars = {}; +fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => { + const match = line.match(/^([A-Z_]+)=(.*)$/); + if (match) envVars[match[1]] = match[2]; +}); + +const API_URL = envVars.DEBUG_API_URL || 'https://api.dfx.swiss/v1'; +const DEBUG_ADDRESS = envVars.DEBUG_ADDRESS; +const DEBUG_SIGNATURE = envVars.DEBUG_SIGNATURE; + +const LOCAL_DB = { + server: envVars.SQL_HOST || 'localhost', + port: parseInt(envVars.SQL_PORT || '1433'), + user: envVars.SQL_USERNAME || 'sa', + password: envVars.SQL_PASSWORD, + database: envVars.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true }, +}; + +if (!LOCAL_DB.password) { + console.error('Error: SQL_PASSWORD must be set in .env'); + process.exit(1); +} + +// --- HTTP helper --- +function apiRequest(urlPath, method, body) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, API_URL.endsWith('/') ? API_URL : API_URL + '/'); + const lib = url.protocol === 'https:' ? https : http; + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (apiRequest.token) { + options.headers['Authorization'] = `Bearer ${apiRequest.token}`; + } + const req = lib.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch { resolve(data); } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function authenticate() { + console.log(`Authenticating to ${API_URL}...`); + const res = await apiRequest('auth', 'POST', { + address: DEBUG_ADDRESS, + signature: DEBUG_SIGNATURE, + }); + if (!res.accessToken) throw new Error('Auth failed: ' + JSON.stringify(res)); + apiRequest.token = res.accessToken; + console.log('Authenticated.'); +} + +async function execSql(query) { + return apiRequest('gs/debug', 'POST', { sql: query }); +} + +async function main() { + // Authenticate + await authenticate(); + + // Get total count + const countResult = await execSql(`SELECT COUNT(*) as cnt FROM log WHERE created >= '${SINCE}'`); + const total = countResult[0].cnt; + console.log(`Total log entries since ${SINCE}: ${total}`); + + // Connect to local DB + console.log('Connecting to local DB...'); + const pool = await sql.connect(LOCAL_DB); + + // Check if identity insert needed + const localCount = await pool.request().query('SELECT COUNT(*) as cnt FROM log'); + console.log(`Local log entries before sync: ${localCount.recordset[0].cnt}`); + + // Enable identity insert + await pool.request().query('SET IDENTITY_INSERT log ON'); + + let lastId = 0; + let inserted = 0; + let errors = 0; + let batchNum = 0; + const totalBatches = Math.ceil(total / BATCH_SIZE); + + console.log(`Fetching in batches of ${BATCH_SIZE} (using id cursor)...`); + + while (true) { + batchNum++; + const query = `SELECT TOP ${BATCH_SIZE} id, updated, created, system, subsystem, severity, message, category, valid FROM log WHERE created >= '${SINCE}' AND id > ${lastId} ORDER BY id ASC`; + + process.stdout.write(`\r Batch ${batchNum}/${totalBatches} (inserted: ${inserted}/${total}, lastId: ${lastId})...`); + + let rows; + let retries = 3; + while (retries > 0) { + try { + rows = await execSql(query); + break; + } catch (e) { + retries--; + if (retries > 0) { + process.stdout.write(`\n Retry (${3 - retries}/3) after error: ${e.message}\n`); + // Re-authenticate in case token expired + try { await authenticate(); } catch {} + await new Promise(r => setTimeout(r, 2000)); + } else { + console.error(`\n Failed batch ${batchNum} after 3 retries (lastId=${lastId}): ${e.message}`); + errors++; + rows = null; + } + } + } + if (!rows) { + // Skip this batch range and try next + lastId += 100; + continue; + } + + if (!Array.isArray(rows) || rows.length === 0) { + break; + } + + // Insert batch + for (const row of rows) { + try { + const req = pool.request(); + req.input('id', sql.Int, row.id); + req.input('updated', sql.DateTime2, row.updated); + req.input('created', sql.DateTime2, row.created); + req.input('system', sql.NVarChar(256), row.system); + req.input('subsystem', sql.NVarChar(256), row.subsystem); + req.input('severity', sql.NVarChar(256), row.severity); + req.input('message', sql.NVarChar(sql.MAX), typeof row.message === 'string' ? row.message : JSON.stringify(row.message)); + req.input('category', sql.NVarChar(256), row.category || null); + req.input('valid', sql.Bit, row.valid != null ? row.valid : null); + + await req.query(` + SET IDENTITY_INSERT log ON; + INSERT INTO log (id, updated, created, system, subsystem, severity, message, category, valid) + VALUES (@id, @updated, @created, @system, @subsystem, @severity, @message, @category, @valid) + `); + inserted++; + } catch (e) { + if (e.message.includes('duplicate key') || e.message.includes('UNIQUE')) { + // Skip duplicates + } else { + if (errors < 5) console.error(`\n Insert error (id=${row.id}): ${e.message}`); + errors++; + } + } + } + + lastId = rows[rows.length - 1].id; + } + + console.log(`\n\nDone!`); + console.log(` Inserted: ${inserted}`); + console.log(` Errors: ${errors}`); + console.log(` Total in prod: ${total}`); + + // Verify + const finalCount = await pool.request().query('SELECT COUNT(*) as cnt FROM log'); + console.log(` Local log entries after sync: ${finalCount.recordset[0].cnt}`); + + await pool.close(); +} + +main().catch(e => { console.error(e); process.exit(1); }); From 1efb6ec1bbc10d097a0a22793250ce3da3333266 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:08:02 +0100 Subject: [PATCH 2/6] feat: add financial dashboard API endpoint (#3324) * feat: add financial dashboard API endpoint Add admin-only GET /dashboard/financial/log endpoint that queries FinancialDataLog entries from the log table, parses the JSON message field, and returns structured financial data including total balances, plus/minus breakdowns, BTC price, and balances by financial type. Supports daily sampling and date filtering via query parameters. * feat: extend financial dashboard with changes, latest balance, and expenses endpoints - Add /changes and /changes/latest endpoints for fee income tracking - Add /latest endpoint with balance by type and blockchain (stacked) - Add getLatestFinancialLog and getLatestFinancialChangesLog for fast queries - Aggregate small providers/assets (<5k CHF) under "Other" - Return detailed expense breakdowns (ref, binance, blockchain sub-categories) * chore: add test-results to gitignore * feat: add referral reward recipients endpoint to financial dashboard * feat: add script to sync production log entries to local database * refactor: extract sync-prod-logs script to separate PR #3439 * fix: harden financial dashboard for production - Extract BTC price by asset ID lookup instead of >50k heuristic - Add try/catch to getLatestBalance for corrupt JSON messages - Replace `where: any` with FindOptionsWhere in repository * style: fix prettier formatting * fix: bind subquery parameters explicitly and validate from date - Bind :system/:subsystem/:severity on subquery and propagate via setParameters() instead of relying on outer query parameter names - Validate from query param: reject invalid dates with 400 * fix: return 404 instead of empty body when no financial data exists --- .gitignore | 3 + .../core/referral/referral.module.ts | 2 +- .../dashboard-financial.controller.ts | 82 ++++++ .../dashboard/dashboard-financial.service.ts | 247 ++++++++++++++++++ .../supporting/dashboard/dashboard.module.ts | 13 + .../dashboard/dto/financial-log.dto.ts | 54 ++++ .../supporting/log/log.repository.ts | 86 +++++- src/subdomains/supporting/log/log.service.ts | 16 ++ .../supporting/supporting.module.ts | 2 + 9 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/supporting/dashboard/dashboard-financial.controller.ts create mode 100644 src/subdomains/supporting/dashboard/dashboard-financial.service.ts create mode 100644 src/subdomains/supporting/dashboard/dashboard.module.ts create mode 100644 src/subdomains/supporting/dashboard/dto/financial-log.dto.ts diff --git a/.gitignore b/.gitignore index 172c9306b1..bf7280964a 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ CLAUDE.md # Forge build artifacts test-contracts/ + +# Test results +test-results/ diff --git a/src/subdomains/core/referral/referral.module.ts b/src/subdomains/core/referral/referral.module.ts index 265bc34db0..7cd4d6e019 100644 --- a/src/subdomains/core/referral/referral.module.ts +++ b/src/subdomains/core/referral/referral.module.ts @@ -46,6 +46,6 @@ import { RefRewardService } from './reward/services/ref-reward.service'; RefRewardOutService, RefRewardJobService, ], - exports: [RefService, RefRewardService], + exports: [RefService, RefRewardService, RefRewardRepository], }) export class ReferralModule {} diff --git a/src/subdomains/supporting/dashboard/dashboard-financial.controller.ts b/src/subdomains/supporting/dashboard/dashboard-financial.controller.ts new file mode 100644 index 0000000000..550afd1341 --- /dev/null +++ b/src/subdomains/supporting/dashboard/dashboard-financial.controller.ts @@ -0,0 +1,82 @@ +import { BadRequestException, Controller, Get, NotFoundException, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { DashboardFinancialService } from './dashboard-financial.service'; +import { + FinancialChangesEntryDto, + FinancialChangesResponseDto, + FinancialLogResponseDto, + LatestBalanceResponseDto, + RefRewardRecipientDto, +} from './dto/financial-log.dto'; + +@ApiTags('dashboard') +@Controller('dashboard/financial') +export class DashboardFinancialController { + constructor(private readonly dashboardFinancialService: DashboardFinancialService) {} + + @Get('log') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getFinancialLog( + @Query('from') from?: string, + @Query('dailySample') dailySample?: string, + ): Promise { + const fromDate = this.parseDate(from); + const sample = dailySample !== 'false'; + + return this.dashboardFinancialService.getFinancialLog(fromDate, sample); + } + + @Get('latest') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getLatestBalance(): Promise { + const result = await this.dashboardFinancialService.getLatestBalance(); + if (!result) throw new NotFoundException('No financial data available'); + return result; + } + + @Get('changes/latest') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getLatestChanges(): Promise { + const result = await this.dashboardFinancialService.getLatestFinancialChanges(); + if (!result) throw new NotFoundException('No financial changes available'); + return result; + } + + @Get('ref-recipients') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getRefRewardRecipients(@Query('from') from?: string): Promise { + return this.dashboardFinancialService.getRefRewardRecipients(this.parseDate(from)); + } + + @Get('changes') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getFinancialChanges( + @Query('from') from?: string, + @Query('dailySample') dailySample?: string, + ): Promise { + const sample = dailySample !== 'false'; + + return this.dashboardFinancialService.getFinancialChanges(this.parseDate(from), sample); + } + + private parseDate(value?: string): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + if (isNaN(date.getTime())) throw new BadRequestException(`Invalid date: ${value}`); + return date; + } +} diff --git a/src/subdomains/supporting/dashboard/dashboard-financial.service.ts b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts new file mode 100644 index 0000000000..eac3357b88 --- /dev/null +++ b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts @@ -0,0 +1,247 @@ +import { Injectable } from '@nestjs/common'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { RefRewardRepository } from '../../core/referral/reward/ref-reward.repository'; +import { Log } from '../log/log.entity'; +import { LogService } from '../log/log.service'; +import { FinanceLog } from '../log/dto/log.dto'; +import { + BalanceByGroupDto, + FinancialChangesEntryDto, + FinancialChangesResponseDto, + FinancialLogEntryDto, + FinancialLogResponseDto, + LatestBalanceResponseDto, + RefRewardRecipientDto, +} from './dto/financial-log.dto'; + +@Injectable() +export class DashboardFinancialService { + constructor( + private readonly logService: LogService, + private readonly assetService: AssetService, + private readonly refRewardRepo: RefRewardRepository, + ) {} + + async getFinancialLog(from?: Date, dailySample?: boolean): Promise { + const [logs, btcAsset] = await Promise.all([ + this.logService.getFinancialLogs(from, dailySample), + this.assetService.getBtcCoin(), + ]); + + const btcAssetId = btcAsset?.id; + const entries = logs + .map((log) => this.mapLogToEntry(log, btcAssetId)) + .filter((e): e is FinancialLogEntryDto => e != null); + + return { entries }; + } + + async getRefRewardRecipients(from?: Date): Promise { + const query = this.refRewardRepo + .createQueryBuilder('r') + .innerJoin('r.user', 'u') + .select('u.userDataId', 'userDataId') + .addSelect('COUNT(*)', 'count') + .addSelect('ROUND(SUM(r.amountInChf), 0)', 'totalChf') + .where('r.status != :excluded', { excluded: 'UserSwitch' }) + .groupBy('u.userDataId') + .orderBy('totalChf', 'DESC'); + + if (from) { + query.andWhere('r.created >= :from', { from }); + } + + return query.getRawMany(); + } + + async getLatestFinancialChanges(): Promise { + const latest = await this.logService.getLatestFinancialChangesLog(); + if (!latest) return undefined; + return this.mapChangesLogToEntry(latest); + } + + async getFinancialChanges(from?: Date, dailySample?: boolean): Promise { + const logs = await this.logService.getFinancialChangesLogs(from, dailySample); + + const entries = logs + .map((log) => this.mapChangesLogToEntry(log)) + .filter((e): e is FinancialChangesEntryDto => e != null); + + return { entries }; + } + + private mapChangesLogToEntry(log: Log): FinancialChangesEntryDto | undefined { + try { + const data = JSON.parse(log.message); + const changes = data.changes; + + return { + timestamp: log.created, + total: changes.total ?? 0, + plus: { + total: changes.plus?.total ?? 0, + buyCrypto: changes.plus?.buyCrypto ?? 0, + buyFiat: changes.plus?.buyFiat ?? 0, + paymentLink: changes.plus?.paymentLink ?? 0, + trading: changes.plus?.trading ?? 0, + }, + minus: { + total: changes.minus?.total ?? 0, + ref: { + total: changes.minus?.ref?.total ?? 0, + amount: changes.minus?.ref?.amount ?? 0, + fee: changes.minus?.ref?.fee ?? 0, + }, + binance: { + total: changes.minus?.binance?.total ?? 0, + withdraw: changes.minus?.binance?.withdraw ?? 0, + trading: changes.minus?.binance?.trading ?? 0, + }, + blockchain: { + total: changes.minus?.blockchain?.total ?? 0, + txIn: changes.minus?.blockchain?.tx?.in ?? 0, + txOut: changes.minus?.blockchain?.tx?.out ?? 0, + trading: changes.minus?.blockchain?.trading ?? 0, + }, + }, + }; + } catch { + return undefined; + } + } + + async getLatestBalance(): Promise { + const latest = await this.logService.getLatestFinancialLog(); + if (!latest) return undefined; + + let financeLog: FinanceLog; + try { + financeLog = JSON.parse(latest.message); + } catch { + return undefined; + } + + // By type (from existing balancesByFinancialType) + const byType: BalanceByGroupDto[] = []; + if (financeLog.balancesByFinancialType) { + for (const [type, data] of Object.entries(financeLog.balancesByFinancialType)) { + byType.push({ + name: type, + plusBalanceChf: data.plusBalanceChf, + minusBalanceChf: data.minusBalanceChf, + netBalanceChf: data.plusBalanceChf - data.minusBalanceChf, + }); + } + } + byType.sort((a, b) => b.netBalanceChf - a.netBalanceChf); + + // By blockchain (aggregate assets) + const blockchainTotals: Record }> = {}; + if (financeLog.assets) { + const assetIds = Object.keys(financeLog.assets).map(Number); + const assets = await this.assetService.getAssetsById(assetIds); + const assetMap = new Map(assets.map((a) => [a.id, a])); + + for (const [idStr, assetData] of Object.entries(financeLog.assets)) { + const asset = assetMap.get(Number(idStr)); + const blockchain = asset?.blockchain ?? 'Unknown'; + const assetName = asset?.name ?? 'Unknown'; + const plusChf = (assetData.plusBalance?.total ?? 0) * assetData.priceChf; + + if (!blockchainTotals[blockchain]) blockchainTotals[blockchain] = { plus: 0, assets: {} }; + blockchainTotals[blockchain].plus += plusChf; + blockchainTotals[blockchain].assets[assetName] = + (blockchainTotals[blockchain].assets[assetName] ?? 0) + Math.round(plusChf); + } + } + + const THRESHOLD = 5000; + let otherTotal = 0; + const otherAssets: Record = {}; + const byBlockchain: BalanceByGroupDto[] = []; + + for (const [name, { plus, assets: assetBreakdown }] of Object.entries(blockchainTotals)) { + const rounded = Math.round(plus); + if (rounded <= 0) continue; + if (rounded < THRESHOLD) { + otherTotal += rounded; + for (const [a, v] of Object.entries(assetBreakdown)) { + otherAssets[a] = (otherAssets[a] ?? 0) + v; + } + } else { + // Filter out small assets within a blockchain + const filteredAssets: Record = {}; + let assetOther = 0; + for (const [a, v] of Object.entries(assetBreakdown)) { + if (v >= THRESHOLD) filteredAssets[a] = v; + else assetOther += v; + } + if (assetOther > 0) filteredAssets['Other'] = assetOther; + + byBlockchain.push({ + name, + plusBalanceChf: rounded, + minusBalanceChf: 0, + netBalanceChf: rounded, + assets: filteredAssets, + }); + } + } + + byBlockchain.sort((a, b) => b.netBalanceChf - a.netBalanceChf); + + if (otherTotal > 0) { + const filteredOtherAssets: Record = {}; + let otherAssetOther = 0; + for (const [a, v] of Object.entries(otherAssets)) { + if (v >= THRESHOLD) filteredOtherAssets[a] = v; + else otherAssetOther += v; + } + if (otherAssetOther > 0) filteredOtherAssets['Other'] = otherAssetOther; + byBlockchain.push({ + name: 'Other', + plusBalanceChf: otherTotal, + minusBalanceChf: 0, + netBalanceChf: otherTotal, + assets: filteredOtherAssets, + }); + } + + return { timestamp: latest.created, byType, byBlockchain }; + } + + private mapLogToEntry(log: Log, btcAssetId?: number): FinancialLogEntryDto | undefined { + try { + const financeLog: FinanceLog = JSON.parse(log.message); + + const btcPriceChf = this.extractBtcPrice(financeLog, btcAssetId); + + const balancesByType: Record = {}; + if (financeLog.balancesByFinancialType) { + for (const [type, data] of Object.entries(financeLog.balancesByFinancialType)) { + balancesByType[type] = { + plusBalanceChf: data.plusBalanceChf, + minusBalanceChf: data.minusBalanceChf, + }; + } + } + + return { + timestamp: log.created, + totalBalanceChf: financeLog.balancesTotal?.totalBalanceChf ?? 0, + plusBalanceChf: financeLog.balancesTotal?.plusBalanceChf ?? 0, + minusBalanceChf: financeLog.balancesTotal?.minusBalanceChf ?? 0, + btcPriceChf, + balancesByType, + }; + } catch { + return undefined; + } + } + + private extractBtcPrice(financeLog: FinanceLog, btcAssetId?: number): number { + if (!financeLog.assets || !btcAssetId) return 0; + + return financeLog.assets[btcAssetId]?.priceChf ?? 0; + } +} diff --git a/src/subdomains/supporting/dashboard/dashboard.module.ts b/src/subdomains/supporting/dashboard/dashboard.module.ts new file mode 100644 index 0000000000..3fd80cc1cd --- /dev/null +++ b/src/subdomains/supporting/dashboard/dashboard.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { ReferralModule } from '../../core/referral/referral.module'; +import { LogModule } from '../log/log.module'; +import { DashboardFinancialController } from './dashboard-financial.controller'; +import { DashboardFinancialService } from './dashboard-financial.service'; + +@Module({ + imports: [SharedModule, LogModule, ReferralModule], + controllers: [DashboardFinancialController], + providers: [DashboardFinancialService], +}) +export class DashboardModule {} diff --git a/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts new file mode 100644 index 0000000000..e29e99bfa2 --- /dev/null +++ b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts @@ -0,0 +1,54 @@ +export class FinancialLogEntryDto { + timestamp: Date; + totalBalanceChf: number; + plusBalanceChf: number; + minusBalanceChf: number; + btcPriceChf: number; + balancesByType: Record; +} + +export class FinancialLogResponseDto { + entries: FinancialLogEntryDto[]; +} + +export class FinancialChangesEntryDto { + timestamp: Date; + total: number; + plus: { + total: number; + buyCrypto: number; + buyFiat: number; + paymentLink: number; + trading: number; + }; + minus: { + total: number; + ref: { total: number; amount: number; fee: number }; + binance: { total: number; withdraw: number; trading: number }; + blockchain: { total: number; txIn: number; txOut: number; trading: number }; + }; +} + +export class FinancialChangesResponseDto { + entries: FinancialChangesEntryDto[]; +} + +export class BalanceByGroupDto { + name: string; + plusBalanceChf: number; + minusBalanceChf: number; + netBalanceChf: number; + assets?: Record; +} + +export class RefRewardRecipientDto { + userDataId: number; + count: number; + totalChf: number; +} + +export class LatestBalanceResponseDto { + timestamp: Date; + byType: BalanceByGroupDto[]; + byBlockchain: BalanceByGroupDto[]; +} diff --git a/src/subdomains/supporting/log/log.repository.ts b/src/subdomains/supporting/log/log.repository.ts index 23d77dba5f..f26c6a61d4 100644 --- a/src/subdomains/supporting/log/log.repository.ts +++ b/src/subdomains/supporting/log/log.repository.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Util } from 'src/shared/utils/util'; -import { EntityManager } from 'typeorm'; +import { EntityManager, FindOptionsWhere, MoreThanOrEqual } from 'typeorm'; import { LogCleanupSetting } from './dto/create-log.dto'; -import { Log } from './log.entity'; +import { Log, LogSeverity } from './log.entity'; @Injectable() export class LogRepository extends BaseRepository { @@ -39,4 +39,86 @@ export class LogRepository extends BaseRepository { await Util.doInBatches(logIdsToBeDeleted, async (batch: number[]) => this.delete(batch), 100); } + + async getLatestFinancialLog(): Promise { + return this.findOne({ + where: { system: 'LogService', subsystem: 'FinancialDataLog', severity: LogSeverity.INFO }, + order: { id: 'DESC' }, + }); + } + + async getLatestFinancialChangesLog(): Promise { + return this.findOne({ + where: { system: 'LogService', subsystem: 'FinancialChangesLog', severity: LogSeverity.INFO }, + order: { id: 'DESC' }, + }); + } + + async getFinancialChangesLogs(from?: Date, dailySample?: boolean): Promise { + if (dailySample) { + const subQuery = this.createQueryBuilder('subLog') + .select('MAX(subLog.id)', 'max_id') + .where('subLog.system = :system', { system: 'LogService' }) + .andWhere('subLog.subsystem = :subsystem', { subsystem: 'FinancialChangesLog' }) + .andWhere('subLog.severity = :severity', { severity: LogSeverity.INFO }) + .groupBy('CAST(subLog.created AS DATE)'); + + let query = this.createQueryBuilder('log') + .where(`log.id IN (${subQuery.getQuery()})`) + .setParameters(subQuery.getParameters()) + .orderBy('log.created', 'ASC'); + + if (from) { + query = query.andWhere('log.created >= :from', { from }); + } + + return query.getMany(); + } + + const where: FindOptionsWhere = { + system: 'LogService', + subsystem: 'FinancialChangesLog', + severity: LogSeverity.INFO, + }; + + if (from) { + where.created = MoreThanOrEqual(from); + } + + return this.find({ where, order: { created: 'ASC' } }); + } + + async getFinancialLogs(from?: Date, dailySample?: boolean): Promise { + if (dailySample) { + const subQuery = this.createQueryBuilder('subLog') + .select('MAX(subLog.id)', 'max_id') + .where('subLog.system = :system', { system: 'LogService' }) + .andWhere('subLog.subsystem = :subsystem', { subsystem: 'FinancialDataLog' }) + .andWhere('subLog.severity = :severity', { severity: LogSeverity.INFO }) + .groupBy('CAST(subLog.created AS DATE)'); + + let query = this.createQueryBuilder('log') + .where(`log.id IN (${subQuery.getQuery()})`) + .setParameters(subQuery.getParameters()) + .orderBy('log.created', 'ASC'); + + if (from) { + query = query.andWhere('log.created >= :from', { from }); + } + + return query.getMany(); + } + + const where: FindOptionsWhere = { + system: 'LogService', + subsystem: 'FinancialDataLog', + severity: LogSeverity.INFO, + }; + + if (from) { + where.created = MoreThanOrEqual(from); + } + + return this.find({ where, order: { created: 'ASC' } }); + } } diff --git a/src/subdomains/supporting/log/log.service.ts b/src/subdomains/supporting/log/log.service.ts index dcc1e9f08c..3d05f8f9dd 100644 --- a/src/subdomains/supporting/log/log.service.ts +++ b/src/subdomains/supporting/log/log.service.ts @@ -43,6 +43,22 @@ export class LogService { return this.logRepo.findOne({ where: { system, subsystem, severity, valid }, order: { id: 'DESC' } }); } + async getFinancialLogs(from?: Date, dailySample?: boolean): Promise { + return this.logRepo.getFinancialLogs(from, dailySample); + } + + async getLatestFinancialLog(): Promise { + return this.logRepo.getLatestFinancialLog(); + } + + async getLatestFinancialChangesLog(): Promise { + return this.logRepo.getLatestFinancialChangesLog(); + } + + async getFinancialChangesLogs(from?: Date, dailySample?: boolean): Promise { + return this.logRepo.getFinancialChangesLogs(from, dailySample); + } + async getBankLog(batchId: string): Promise { return this.logRepo .createQueryBuilder('log') diff --git a/src/subdomains/supporting/supporting.module.ts b/src/subdomains/supporting/supporting.module.ts index dd8e68e690..5f2650bebb 100644 --- a/src/subdomains/supporting/supporting.module.ts +++ b/src/subdomains/supporting/supporting.module.ts @@ -3,6 +3,7 @@ import { AddressPoolModule } from './address-pool/address-pool.module'; import { BalanceModule } from './balance/balance.module'; import { BankTxModule } from './bank-tx/bank-tx.module'; import { BankModule } from './bank/bank.module'; +import { DashboardModule } from './dashboard/dashboard.module'; import { DexModule } from './dex/dex.module'; import { FiatOutputModule } from './fiat-output/fiat-output.module'; import { FiatPayInModule } from './fiat-payin/fiat-payin.module'; @@ -21,6 +22,7 @@ import { SupportIssueModule } from './support-issue/support-issue.module'; BalanceModule, BankModule, BankTxModule, + DashboardModule, DexModule, LogModule, NotificationModule, From 08fa1bdf907301d1f1dc0ef454e060e7a0e0b740 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:35:17 +0100 Subject: [PATCH 3/6] feat: add KYC log data to compliance support endpoint (#3436) Add kycLogs to the /support/:id response so the compliance UI can display the full KYC audit trail per user. Uses QueryBuilder to bypass TypeORM table-inheritance discriminator filtering. Also adds KYC step and log test data to the local seed script. --- scripts/testdata.js | 291 +++++++++++++++--- .../generic/kyc/services/kyc-log.service.ts | 9 + .../support/dto/user-data-support.dto.ts | 8 + .../generic/support/support.service.ts | 17 +- 4 files changed, 282 insertions(+), 43 deletions(-) diff --git a/scripts/testdata.js b/scripts/testdata.js index 78a3baabe6..8b957fcbc4 100644 --- a/scripts/testdata.js +++ b/scripts/testdata.js @@ -14,7 +14,7 @@ const config = { server: 'localhost', port: parseInt(process.env.SQL_PORT) || 1433, database: process.env.SQL_DB || 'dfx', - options: { encrypt: false, trustServerCertificate: true } + options: { encrypt: false, trustServerCertificate: true }, }; // Test addresses (not real wallets) @@ -25,10 +25,7 @@ const TEST_ADDRESSES = { '0xTestUser4000000000000000000000000000004', '0xTestUser5000000000000000000000000000005', ], - BITCOIN: [ - 'bc1qTestBtcUser2000000000000000000002', - 'bc1qTestBtcUser3000000000000000000003', - ] + BITCOIN: ['bc1qTestBtcUser2000000000000000000002', 'bc1qTestBtcUser3000000000000000000003'], }; function uuid() { @@ -89,16 +86,79 @@ async function main() { console.log('Creating UserData entries...'); const userDataConfigs = [ - { mail: 'kyc0@test.local', kycLevel: 0, kycStatus: 'NA', status: 'Active', firstname: 'Test', surname: 'NoKYC', countryId }, - { mail: 'kyc10@test.local', kycLevel: 10, kycStatus: 'NA', status: 'Active', firstname: 'Hans', surname: 'Muster', countryId, birthday: '1985-03-15', street: 'Bahnhofstrasse', houseNumber: '12', zip: '8001', location: 'Zürich' }, - { mail: 'kyc20@test.local', kycLevel: 20, kycStatus: 'NA', status: 'Active', firstname: 'Anna', surname: 'Schmidt', countryId: deCountryId, birthday: '1990-07-22', street: 'Hauptstrasse', houseNumber: '45a', zip: '10115', location: 'Berlin' }, - { mail: 'kyc30@test.local', kycLevel: 30, kycStatus: 'Completed', status: 'Active', firstname: 'Max', surname: 'Mueller', countryId, birthday: '1978-11-30', accountType: 'Personal', street: 'Limmatquai', houseNumber: '78', zip: '8001', location: 'Zürich' }, - { mail: 'kyc50@test.local', kycLevel: 50, kycStatus: 'Completed', status: 'Active', firstname: 'Lisa', surname: 'Weber', countryId, birthday: '1982-05-10', accountType: 'Personal', street: 'Paradeplatz', houseNumber: '1', zip: '8001', location: 'Zürich' }, + { + mail: 'kyc0@test.local', + kycLevel: 0, + kycStatus: 'NA', + status: 'Active', + firstname: 'Test', + surname: 'NoKYC', + countryId, + }, + { + mail: 'kyc10@test.local', + kycLevel: 10, + kycStatus: 'NA', + status: 'Active', + firstname: 'Hans', + surname: 'Muster', + countryId, + birthday: '1985-03-15', + street: 'Bahnhofstrasse', + houseNumber: '12', + zip: '8001', + location: 'Zürich', + }, + { + mail: 'kyc20@test.local', + kycLevel: 20, + kycStatus: 'NA', + status: 'Active', + firstname: 'Anna', + surname: 'Schmidt', + countryId: deCountryId, + birthday: '1990-07-22', + street: 'Hauptstrasse', + houseNumber: '45a', + zip: '10115', + location: 'Berlin', + }, + { + mail: 'kyc30@test.local', + kycLevel: 30, + kycStatus: 'Completed', + status: 'Active', + firstname: 'Max', + surname: 'Mueller', + countryId, + birthday: '1978-11-30', + accountType: 'Personal', + street: 'Limmatquai', + houseNumber: '78', + zip: '8001', + location: 'Zürich', + }, + { + mail: 'kyc50@test.local', + kycLevel: 50, + kycStatus: 'Completed', + status: 'Active', + firstname: 'Lisa', + surname: 'Weber', + countryId, + birthday: '1982-05-10', + accountType: 'Personal', + street: 'Paradeplatz', + houseNumber: '1', + zip: '8001', + location: 'Zürich', + }, ]; const userDataIds = []; for (const ud of userDataConfigs) { - const existing = await pool.request() + const existing = await pool + .request() .input('mail', mssql.NVarChar, ud.mail) .query('SELECT id FROM user_data WHERE mail = @mail'); @@ -109,7 +169,8 @@ async function main() { } const kycHash = uuid(); - const result = await pool.request() + const result = await pool + .request() .input('mail', mssql.NVarChar, ud.mail) .input('firstname', mssql.NVarChar, ud.firstname) .input('surname', mssql.NVarChar, ud.surname) @@ -129,8 +190,7 @@ async function main() { .input('currencyId', mssql.Int, chfId) .input('walletId', mssql.Int, walletId) .input('accountType', mssql.NVarChar, ud.accountType || null) - .input('birthday', mssql.Date, ud.birthday || null) - .query(` + .input('birthday', mssql.Date, ud.birthday || null).query(` INSERT INTO user_data (mail, firstname, surname, street, houseNumber, zip, location, kycHash, kycLevel, kycStatus, kycType, status, riskStatus, countryId, nationalityId, languageId, currencyId, walletId, accountType, birthday, created, updated) OUTPUT INSERTED.id @@ -157,7 +217,8 @@ async function main() { const userIds = []; for (const u of userConfigs) { - const existing = await pool.request() + const existing = await pool + .request() .input('address', mssql.NVarChar, u.address) .query('SELECT id FROM [user] WHERE address = @address'); @@ -167,7 +228,8 @@ async function main() { continue; } - const result = await pool.request() + const result = await pool + .request() .input('address', mssql.NVarChar, u.address) .input('addressType', mssql.NVarChar, u.addressType) .input('role', mssql.NVarChar, u.role) @@ -175,8 +237,7 @@ async function main() { .input('usedRef', mssql.NVarChar, '000-000') .input('walletId', mssql.Int, walletId) .input('userDataId', mssql.Int, userDataIds[u.userDataIdx]) - .input('refFeePercent', mssql.Float, 0.25) - .query(` + .input('refFeePercent', mssql.Float, 0.25).query(` INSERT INTO [user] (address, addressType, role, status, usedRef, walletId, userDataId, refFeePercent, buyVolume, annualBuyVolume, monthlyBuyVolume, sellVolume, annualSellVolume, monthlySellVolume, cryptoVolume, annualCryptoVolume, monthlyCryptoVolume, refVolume, refCredit, paidRefCredit, created, updated) @@ -196,9 +257,7 @@ async function main() { const routeIds = []; for (let i = 0; i < 4; i++) { - const result = await pool.request() - .input('label', mssql.NVarChar, `TestRoute${i + 2}`) - .query(` + const result = await pool.request().input('label', mssql.NVarChar, `TestRoute${i + 2}`).query(` INSERT INTO route (label, created, updated) OUTPUT INSERTED.id VALUES (@label, GETUTCDATE(), GETUTCDATE()) @@ -224,7 +283,8 @@ async function main() { if (!b.assetId) continue; const usage = bankUsage(); - const existing = await pool.request() + const existing = await pool + .request() .input('userId', mssql.Int, b.userId) .input('assetId', mssql.Int, b.assetId) .query('SELECT id FROM buy WHERE userId = @userId AND assetId = @assetId'); @@ -234,13 +294,13 @@ async function main() { continue; } - await pool.request() + await pool + .request() .input('bankUsage', mssql.NVarChar, usage) .input('userId', mssql.Int, b.userId) .input('assetId', mssql.Int, b.assetId) .input('routeId', mssql.Int, routeIds[i]) - .input('active', mssql.Bit, true) - .query(` + .input('active', mssql.Bit, true).query(` INSERT INTO buy (bankUsage, userId, assetId, routeId, active, volume, annualVolume, monthlyVolume, created, updated) VALUES (@bankUsage, @userId, @assetId, @routeId, @active, 0, 0, 0, GETUTCDATE(), GETUTCDATE()) `); @@ -262,7 +322,8 @@ async function main() { const bankDataIds = []; for (const bd of bankDataConfigs) { const cleanIban = bd.iban.replace(/\s/g, ''); - const existing = await pool.request() + const existing = await pool + .request() .input('iban', mssql.NVarChar, cleanIban) .query('SELECT id FROM bank_data WHERE iban = @iban'); @@ -272,12 +333,12 @@ async function main() { continue; } - const result = await pool.request() + const result = await pool + .request() .input('iban', mssql.NVarChar, cleanIban) .input('name', mssql.NVarChar, bd.name) .input('userDataId', mssql.Int, bd.userDataId) - .input('approved', mssql.Bit, true) - .query(` + .input('approved', mssql.Bit, true).query(` INSERT INTO bank_data (iban, name, userDataId, approved, created, updated) OUTPUT INSERTED.id VALUES (@iban, @name, @userDataId, @approved, GETUTCDATE(), GETUTCDATE()) @@ -299,7 +360,8 @@ async function main() { ]; for (const d of depositConfigs) { - const existing = await pool.request() + const existing = await pool + .request() .input('address', mssql.NVarChar, d.address) .query('SELECT id FROM deposit WHERE address = @address'); @@ -308,9 +370,7 @@ async function main() { continue; } - await pool.request() - .input('address', mssql.NVarChar, d.address) - .input('blockchains', mssql.NVarChar, d.blockchains) + await pool.request().input('address', mssql.NVarChar, d.address).input('blockchains', mssql.NVarChar, d.blockchains) .query(` INSERT INTO deposit (address, blockchains, created, updated) VALUES (@address, @blockchains, GETUTCDATE(), GETUTCDATE()) @@ -336,15 +396,15 @@ async function main() { for (const tx of txConfigs) { const uid = uuid(); - await pool.request() + await pool + .request() .input('uid', mssql.NVarChar, uid) .input('sourceType', mssql.NVarChar, tx.sourceType) .input('userId', mssql.Int, tx.userId) .input('userDataId', mssql.Int, tx.userDataId) .input('amountInChf', mssql.Float, tx.amountInChf) .input('amlCheck', mssql.NVarChar, tx.amlCheck) - .input('eventDate', mssql.DateTime2, new Date()) - .query(` + .input('eventDate', mssql.DateTime2, new Date()).query(` INSERT INTO [transaction] (uid, sourceType, userId, userDataId, amountInChf, amlCheck, eventDate, created, updated) VALUES (@uid, @sourceType, @userId, @userDataId, @amountInChf, @amlCheck, @eventDate, GETUTCDATE(), GETUTCDATE()) `); @@ -357,7 +417,7 @@ async function main() { // ============================================================ console.log('\nCreating BankTx entries...'); - const bankResult = await pool.request().query("SELECT TOP 1 id FROM bank WHERE receive = 1"); + const bankResult = await pool.request().query('SELECT TOP 1 id FROM bank WHERE receive = 1'); const bankId = bankResult.recordset[0]?.id; if (bankId) { @@ -368,15 +428,15 @@ async function main() { ]; for (const btx of bankTxConfigs) { - await pool.request() + await pool + .request() .input('bankId', mssql.Int, bankId) .input('accountIban', mssql.NVarChar, btx.accountIban) .input('name', mssql.NVarChar, btx.name) .input('amount', mssql.Float, btx.amount) .input('currency', mssql.NVarChar, btx.currency) .input('type', mssql.NVarChar, btx.type) - .input('creditDebitIndicator', mssql.NVarChar, 'CRDT') - .query(` + .input('creditDebitIndicator', mssql.NVarChar, 'CRDT').query(` INSERT INTO bank_tx (bankId, accountIban, name, amount, currency, type, creditDebitIndicator, created, updated) VALUES (@bankId, @accountIban, @name, @amount, @currency, @type, @creditDebitIndicator, GETUTCDATE(), GETUTCDATE()) `); @@ -385,6 +445,143 @@ async function main() { } } + // ============================================================ + // Create KYC Steps + // ============================================================ + console.log('\nCreating KYC Steps...'); + + const kycStepConfigs = [ + // User Hans Muster (kyc10) - basic steps + { userDataIdx: 1, name: 'ContactData', status: 'Completed', sequenceNumber: 1 }, + { userDataIdx: 1, name: 'PersonalData', status: 'Completed', sequenceNumber: 2 }, + { userDataIdx: 1, name: 'NationalityData', status: 'InProgress', sequenceNumber: 3 }, + // User Anna Schmidt (kyc20) - further along + { userDataIdx: 2, name: 'ContactData', status: 'Completed', sequenceNumber: 1 }, + { userDataIdx: 2, name: 'PersonalData', status: 'Completed', sequenceNumber: 2 }, + { userDataIdx: 2, name: 'NationalityData', status: 'Completed', sequenceNumber: 3 }, + { userDataIdx: 2, name: 'Ident', type: 'SumsubAuto', status: 'InProgress', sequenceNumber: 4 }, + // User Max Mueller (kyc30) - completed KYC + { userDataIdx: 3, name: 'ContactData', status: 'Completed', sequenceNumber: 1 }, + { userDataIdx: 3, name: 'PersonalData', status: 'Completed', sequenceNumber: 2 }, + { userDataIdx: 3, name: 'NationalityData', status: 'Completed', sequenceNumber: 3 }, + { userDataIdx: 3, name: 'Ident', type: 'Video', status: 'Completed', sequenceNumber: 4 }, + { userDataIdx: 3, name: 'FinancialData', status: 'Completed', sequenceNumber: 5 }, + { userDataIdx: 3, name: 'DfxApproval', status: 'Completed', sequenceNumber: 6 }, + // User Lisa Weber (kyc50) - full KYC with recommendation + { userDataIdx: 4, name: 'ContactData', status: 'Completed', sequenceNumber: 1 }, + { userDataIdx: 4, name: 'PersonalData', status: 'Completed', sequenceNumber: 2 }, + { userDataIdx: 4, name: 'NationalityData', status: 'Completed', sequenceNumber: 3 }, + { userDataIdx: 4, name: 'Recommendation', status: 'Completed', sequenceNumber: 4 }, + { userDataIdx: 4, name: 'Ident', type: 'SumsubVideo', status: 'Completed', sequenceNumber: 5 }, + { userDataIdx: 4, name: 'FinancialData', status: 'Completed', sequenceNumber: 6 }, + { userDataIdx: 4, name: 'DfxApproval', status: 'Completed', sequenceNumber: 7 }, + ]; + + const kycStepIds = []; + for (const step of kycStepConfigs) { + const result = await pool + .request() + .input('name', mssql.NVarChar, step.name) + .input('type', mssql.NVarChar, step.type || null) + .input('status', mssql.NVarChar, step.status) + .input('sequenceNumber', mssql.Int, step.sequenceNumber) + .input('userDataId', mssql.Int, userDataIds[step.userDataIdx]).query(` + INSERT INTO kyc_step (name, type, status, sequenceNumber, userDataId, created, updated) + OUTPUT INSERTED.id + VALUES (@name, @type, @status, @sequenceNumber, @userDataId, GETUTCDATE(), GETUTCDATE()) + `); + + kycStepIds.push(result.recordset[0].id); + console.log(` Created KycStep: ${step.name} (${step.status}) for userDataId=${userDataIds[step.userDataIdx]}`); + } + + // ============================================================ + // Create KYC Logs + // ============================================================ + console.log('\nCreating KYC Logs...'); + + const now = new Date(); + const daysAgo = (d) => new Date(now.getTime() - d * 24 * 60 * 60 * 1000); + + const kycLogConfigs = [ + // Hans Muster - basic logs + { userDataIdx: 1, type: 'KycLog', comment: 'KYC process started', eventDate: daysAgo(30) }, + { userDataIdx: 1, type: 'StepLog', comment: 'Contact data submitted', eventDate: daysAgo(29), kycStepIdx: 0 }, + { userDataIdx: 1, type: 'StepLog', comment: 'Personal data submitted', eventDate: daysAgo(28), kycStepIdx: 1 }, + { userDataIdx: 1, type: 'NameCheckLog', comment: 'Name check passed - no matches found', eventDate: daysAgo(28) }, + // Anna Schmidt - more activity + { userDataIdx: 2, type: 'KycLog', comment: 'KYC process started', eventDate: daysAgo(20) }, + { userDataIdx: 2, type: 'StepLog', comment: 'Contact data submitted', eventDate: daysAgo(19), kycStepIdx: 3 }, + { userDataIdx: 2, type: 'StepLog', comment: 'Personal data submitted', eventDate: daysAgo(18), kycStepIdx: 4 }, + { userDataIdx: 2, type: 'NameCheckLog', comment: 'Name check passed', eventDate: daysAgo(18) }, + { userDataIdx: 2, type: 'StepLog', comment: 'Nationality data submitted', eventDate: daysAgo(17), kycStepIdx: 5 }, + { + userDataIdx: 2, + type: 'StepLog', + comment: 'SumSub identification started', + eventDate: daysAgo(16), + kycStepIdx: 6, + }, + { userDataIdx: 2, type: 'ManualLog', comment: 'Agent review: waiting for better ID photo', eventDate: daysAgo(15) }, + // Max Mueller - completed KYC + { userDataIdx: 3, type: 'KycLog', comment: 'KYC process started', eventDate: daysAgo(60) }, + { userDataIdx: 3, type: 'StepLog', comment: 'Contact data submitted', eventDate: daysAgo(59), kycStepIdx: 7 }, + { userDataIdx: 3, type: 'StepLog', comment: 'Personal data submitted', eventDate: daysAgo(58), kycStepIdx: 8 }, + { userDataIdx: 3, type: 'NameCheckLog', comment: 'Name check passed', eventDate: daysAgo(58) }, + { + userDataIdx: 3, + type: 'StepLog', + comment: 'Video ident completed successfully', + eventDate: daysAgo(55), + kycStepIdx: 10, + }, + { userDataIdx: 3, type: 'StepLog', comment: 'Financial data submitted', eventDate: daysAgo(54), kycStepIdx: 11 }, + { userDataIdx: 3, type: 'StepLog', comment: 'DFX approval granted', eventDate: daysAgo(50), kycStepIdx: 12 }, + { userDataIdx: 3, type: 'KycLog', comment: 'KYC level upgraded to 30', eventDate: daysAgo(50) }, + { userDataIdx: 3, type: 'RiskStatusLog', comment: 'Risk assessment: LOW', eventDate: daysAgo(50) }, + // Lisa Weber - full history + { userDataIdx: 4, type: 'KycLog', comment: 'KYC process started', eventDate: daysAgo(90) }, + { userDataIdx: 4, type: 'StepLog', comment: 'Contact data submitted', eventDate: daysAgo(89), kycStepIdx: 13 }, + { userDataIdx: 4, type: 'StepLog', comment: 'Personal data submitted', eventDate: daysAgo(88), kycStepIdx: 14 }, + { userDataIdx: 4, type: 'NameCheckLog', comment: 'Name check passed', eventDate: daysAgo(88) }, + { userDataIdx: 4, type: 'StepLog', comment: 'Recommendation confirmed', eventDate: daysAgo(85), kycStepIdx: 16 }, + { + userDataIdx: 4, + type: 'StepLog', + comment: 'SumSub video ident completed', + eventDate: daysAgo(80), + kycStepIdx: 17, + }, + { userDataIdx: 4, type: 'StepLog', comment: 'Financial data submitted', eventDate: daysAgo(78), kycStepIdx: 18 }, + { userDataIdx: 4, type: 'StepLog', comment: 'DFX approval granted', eventDate: daysAgo(75), kycStepIdx: 19 }, + { userDataIdx: 4, type: 'KycLog', comment: 'KYC level upgraded to 50', eventDate: daysAgo(75) }, + { userDataIdx: 4, type: 'RiskStatusLog', comment: 'Risk assessment: LOW', eventDate: daysAgo(75) }, + { userDataIdx: 4, type: 'ManualLog', comment: 'Manual review: all documents verified', eventDate: daysAgo(74) }, + { + userDataIdx: 4, + type: 'MailChangeLog', + comment: 'Email changed from old@test.local to kyc50@test.local', + eventDate: daysAgo(40), + }, + ]; + + for (const log of kycLogConfigs) { + await pool + .request() + .input('type', mssql.NVarChar, log.type) + .input('comment', mssql.NVarChar, log.comment) + .input('eventDate', mssql.DateTime2, log.eventDate) + .input('userDataId', mssql.Int, userDataIds[log.userDataIdx]) + .input('kycStepId', mssql.Int, log.kycStepIdx != null ? kycStepIds[log.kycStepIdx] : null).query(` + INSERT INTO kyc_log (type, comment, eventDate, userDataId, kycStepId, created, updated) + VALUES (@type, @comment, @eventDate, @userDataId, @kycStepId, GETUTCDATE(), GETUTCDATE()) + `); + + console.log( + ` Created KycLog: ${log.type} - "${log.comment.substring(0, 40)}..." for userDataId=${userDataIds[log.userDataIdx]}`, + ); + } + // ============================================================ // Summary // ============================================================ @@ -393,7 +590,17 @@ async function main() { console.log('========================================\n'); // Show counts - const tables = ['user_data', '[user]', 'buy', 'bank_data', 'deposit', '[transaction]', 'bank_tx']; + const tables = [ + 'user_data', + '[user]', + 'buy', + 'bank_data', + 'deposit', + '[transaction]', + 'bank_tx', + 'kyc_step', + 'kyc_log', + ]; for (const t of tables) { const count = await pool.request().query(`SELECT COUNT(*) as c FROM ${t}`); console.log(` ${t.replace('[', '').replace(']', '')}: ${count.recordset[0].c} rows`); @@ -402,7 +609,7 @@ async function main() { await pool.close(); } -main().catch(e => { +main().catch((e) => { console.error('Error:', e.message); process.exit(1); }); diff --git a/src/subdomains/generic/kyc/services/kyc-log.service.ts b/src/subdomains/generic/kyc/services/kyc-log.service.ts index 1eb63aaaaf..0e5877285c 100644 --- a/src/subdomains/generic/kyc/services/kyc-log.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-log.service.ts @@ -5,6 +5,7 @@ import { UserDataService } from '../../user/models/user-data/user-data.service'; import { CreateKycLogDto, UpdateKycLogDto } from '../dto/input/create-kyc-log.dto'; import { FileType } from '../dto/kyc-file.dto'; import { ContentType } from '../enums/content-type.enum'; +import { KycLog } from '../entities/kyc-log.entity'; import { KycLogType } from '../enums/kyc.enum'; import { KycLogRepository } from '../repositories/kyc-log.repository'; import { KycDocumentService } from './integration/kyc-document.service'; @@ -89,6 +90,14 @@ export class KycLogService { await this.kycLogRepo.save(entity); } + async getLogsByUserDataId(userDataId: number): Promise { + return this.kycLogRepo + .createQueryBuilder('log') + .where('log.userDataId = :userDataId', { userDataId }) + .orderBy('log.created', 'DESC') + .getMany(); + } + async createKycFileLog(log: string, user?: UserData) { const entity = this.kycLogRepo.create({ type: KycLogType.FILE, diff --git a/src/subdomains/generic/support/dto/user-data-support.dto.ts b/src/subdomains/generic/support/dto/user-data-support.dto.ts index db52de21d8..f0e75eeb91 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -76,6 +76,13 @@ export class KycStepSupportInfo { created: Date; } +export class KycLogSupportInfo { + id: number; + type: string; + comment?: string; + created: Date; +} + export class BankDataSupportInfo { id: number; iban: string; @@ -174,6 +181,7 @@ export class UserDataSupportInfoDetails { userData: UserData; kycFiles: KycFile[]; kycSteps: KycStepSupportInfo[]; + kycLogs: KycLogSupportInfo[]; transactions: TransactionSupportInfo[]; users: UserSupportInfo[]; bankDatas: BankDataSupportInfo[]; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 1b6e7d2249..6ad4779616 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -26,9 +26,11 @@ import { PayInService } from 'src/subdomains/supporting/payin/services/payin.ser import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { KycLog } from '../kyc/entities/kyc-log.entity'; import { KycStep } from '../kyc/entities/kyc-step.entity'; import { KycStepName } from '../kyc/enums/kyc-step-name.enum'; import { KycFileService } from '../kyc/services/kyc-file.service'; +import { KycLogService } from '../kyc/services/kyc-log.service'; import { KycService } from '../kyc/services/kyc.service'; import { BankData } from '../user/models/bank-data/bank-data.entity'; import { BankDataService } from '../user/models/bank-data/bank-data.service'; @@ -46,6 +48,7 @@ import { ComplianceSearchType, KycFileListEntry, KycFileYearlyStats, + KycLogSupportInfo, KycStepSupportInfo, RecommendationEntry, RecommendationGraph, @@ -82,6 +85,7 @@ export class SupportService { private readonly bankTxService: BankTxService, private readonly payInService: PayInService, private readonly kycFileService: KycFileService, + private readonly kycLogService: KycLogService, private readonly kycService: KycService, private readonly bankDataService: BankDataService, private readonly bankTxReturnService: BankTxReturnService, @@ -99,9 +103,10 @@ export class SupportService { if (!userData) throw new NotFoundException(`User not found`); // Load all related data in parallel - const [kycFiles, kycSteps, transactions, users, bankDatas, buyRoutes, sellRoutes] = await Promise.all([ + const [kycFiles, kycSteps, kycLogs, transactions, users, bankDatas, buyRoutes, sellRoutes] = await Promise.all([ this.kycFileService.getUserDataKycFiles(id), this.kycService.getStepsByUserData(id), + this.kycLogService.getLogsByUserDataId(id), this.transactionService.getTransactionsByUserDataId(id), this.userService.getAllUserDataUsers(id), this.bankDataService.getBankDatasByUserData(id), @@ -137,6 +142,7 @@ export class SupportService { s.name === KycStepName.RECOMMENDATION ? allByRecommender : undefined, ), ), + kycLogs: kycLogs.map((l) => this.toKycLogSupportInfo(l)), transactions: transactions.map((t) => this.toTransactionSupportInfo(t)), users: users.map((u) => this.toUserSupportInfo(u)), bankDatas: bankDatas.map((b) => this.toBankDataSupportInfo(b)), @@ -271,6 +277,15 @@ export class SupportService { }; } + private toKycLogSupportInfo(log: KycLog): KycLogSupportInfo { + return { + id: log.id, + type: log.type, + comment: log.comment, + created: log.created, + }; + } + private toTransactionSupportInfo(tx: Transaction): TransactionSupportInfo { return { id: tx.id, From 542500f0207ba032bb7a49dc1f3459b90bae8239 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:59:25 +0100 Subject: [PATCH 4/6] feat: add custody order approval admin endpoints (#3441) * feat: add custody order list and approval endpoints for admin * fix: remove fallback values from CustodyOrderListEntry DTO --- .../custody/controllers/custody.controller.ts | 9 ++++++ .../output/custody-order-list-entry.dto.ts | 30 +++++++++++++++++++ .../custody/services/custody-order.service.ts | 18 +++++++++-- 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts diff --git a/src/subdomains/core/custody/controllers/custody.controller.ts b/src/subdomains/core/custody/controllers/custody.controller.ts index 419c56f2e4..5c34fe44b8 100644 --- a/src/subdomains/core/custody/controllers/custody.controller.ts +++ b/src/subdomains/core/custody/controllers/custody.controller.ts @@ -16,6 +16,7 @@ import { GetCustodyPdfDto } from '../dto/input/get-custody-pdf.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; import { CustodyBalanceDto, CustodyHistoryDto } from '../dto/output/custody-balance.dto'; import { CustodyOrderDto } from '../dto/output/custody-order.dto'; +import { CustodyOrderListEntry } from '../dto/output/custody-order-list-entry.dto'; import { CustodyOrderService } from '../services/custody-order.service'; import { CustodyPdfService } from '../services/custody-pdf.service'; import { CustodyService } from '../services/custody.service'; @@ -105,6 +106,14 @@ export class CustodyAdminController { return this.service.updateCustodyBalance(asset, user); } + @Get('orders') + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getOrders(): Promise { + return this.custodyOrderService + .getOrdersForSupport() + .then((orders) => orders.map(CustodyOrderListEntry.fromEntity)); + } + @Post('order/:id/approve') @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) async approveOrder(@Param('id') id: string): Promise { diff --git a/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts b/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts new file mode 100644 index 0000000000..37f563538d --- /dev/null +++ b/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts @@ -0,0 +1,30 @@ +import { CustodyOrderStatus, CustodyOrderType } from '../../enums/custody'; +import { CustodyOrder } from '../../entities/custody-order.entity'; + +export class CustodyOrderListEntry { + id: number; + type: CustodyOrderType; + status: CustodyOrderStatus; + inputAmount?: number; + inputAsset?: string; + outputAmount?: number; + outputAsset?: string; + userId?: number; + userName?: string; + created: Date; + + static fromEntity(order: CustodyOrder): CustodyOrderListEntry { + return { + id: order.id, + type: order.type, + status: order.status, + inputAmount: order.inputAmount, + inputAsset: order.inputAsset?.name, + outputAmount: order.outputAmount, + outputAsset: order.outputAsset?.name, + userId: order.user?.userData?.id, + userName: order.user?.userData?.verifiedName, + created: order.created, + }; + } +} diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index a39d52e981..3978be4d6d 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -13,7 +13,7 @@ import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; -import { Equal } from 'typeorm'; +import { Equal, Not } from 'typeorm'; import { BuyCrypto } from '../../buy-crypto/process/entities/buy-crypto.entity'; import { BuyService } from '../../buy-crypto/routes/buy/buy.service'; import { SwapService } from '../../buy-crypto/routes/swap/swap.service'; @@ -28,7 +28,12 @@ import { CustodyOrderDto } from '../dto/output/custody-order.dto'; import { CustodyBalance } from '../entities/custody-balance.entity'; import { CustodyOrderStep } from '../entities/custody-order-step.entity'; import { CustodyOrder } from '../entities/custody-order.entity'; -import { CustodyOrderStepCommand, CustodyOrderStepContext, CustodyOrderType } from '../enums/custody'; +import { + CustodyOrderStatus, + CustodyOrderStepCommand, + CustodyOrderStepContext, + CustodyOrderType, +} from '../enums/custody'; import { CustodyOrderResponseDtoMapper } from '../mappers/custody-order-response-dto.mapper'; import { GetCustodyOrderDtoMapper } from '../mappers/get-custody-order-dto.mapper'; import { CustodyOrderStepRepository } from '../repositories/custody-order-step.repository'; @@ -221,6 +226,15 @@ export class CustodyOrderService { await this.custodyOrderRepo.update(...order.confirm()); } + async getOrdersForSupport(): Promise { + return this.custodyOrderRepo.find({ + where: { status: Not(CustodyOrderStatus.CREATED) }, + relations: { user: { userData: true }, inputAsset: true, outputAsset: true }, + order: { created: 'DESC' }, + take: 20, + }); + } + async approveOrder(orderId: number): Promise { const order = await this.custodyOrderRepo.findOne({ where: { id: orderId }, From e840db0006d2ba8952e58474746bf3c0c94951ad Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:18:10 +0100 Subject: [PATCH 5/6] fix: include totalBlockchainFee in totalMinus calculation (#3442) The getChangeLog() method excluded blockchain fees (cryptoInputFee, payoutOrderFee, tradingOrderFee) from totalMinus, causing the net total on the financial dashboard to be overstated. Also expose bank and kraken expense categories in the changes DTO for full transparency. Refs #3438 --- .../supporting/dashboard/dashboard-financial.service.ts | 6 ++++++ .../supporting/dashboard/dto/financial-log.dto.ts | 2 ++ src/subdomains/supporting/log/log-job.service.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/subdomains/supporting/dashboard/dashboard-financial.service.ts b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts index eac3357b88..ef6f4f17ba 100644 --- a/src/subdomains/supporting/dashboard/dashboard-financial.service.ts +++ b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts @@ -87,6 +87,12 @@ export class DashboardFinancialService { }, minus: { total: changes.minus?.total ?? 0, + bank: changes.minus?.bank ?? 0, + kraken: { + total: changes.minus?.kraken?.total ?? 0, + withdraw: changes.minus?.kraken?.withdraw ?? 0, + trading: changes.minus?.kraken?.trading ?? 0, + }, ref: { total: changes.minus?.ref?.total ?? 0, amount: changes.minus?.ref?.amount ?? 0, diff --git a/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts index e29e99bfa2..c0e07f7224 100644 --- a/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts +++ b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts @@ -23,6 +23,8 @@ export class FinancialChangesEntryDto { }; minus: { total: number; + bank: number; + kraken: { total: number; withdraw: number; trading: number }; ref: { total: number; amount: number; fee: number }; binance: { total: number; withdraw: number; trading: number }; blockchain: { total: number; txIn: number; txOut: number; trading: number }; diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 989b256596..089c5fbe6b 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -995,7 +995,7 @@ export class LogJobService { // total amounts const totalPlus = buyCryptoFee + buyFiatFee + paymentLinkFee + tradingOrderProfit; - const totalMinus = bankTxFee + totalKrakenFee + totalBinanceFee + totalRefReward; + const totalMinus = bankTxFee + totalKrakenFee + totalBinanceFee + totalRefReward + totalBlockchainFee; return { total: totalPlus - totalMinus, From d4df0ceda321d04a0500040e421724f355f271bd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:57:21 +0100 Subject: [PATCH 6/6] Update chargeback bank fee rate from 0.1% to 1% (#3444) * Update chargeback bank fee rate from 0.1% to 1% * Fix migration code style to match prettier config * Fix migration timestamp to run after all existing migrations --- ...73700300000-UpdateChargebackBankFeeRate.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 migration/1773700300000-UpdateChargebackBankFeeRate.js diff --git a/migration/1773700300000-UpdateChargebackBankFeeRate.js b/migration/1773700300000-UpdateChargebackBankFeeRate.js new file mode 100644 index 0000000000..bdfc641a21 --- /dev/null +++ b/migration/1773700300000-UpdateChargebackBankFeeRate.js @@ -0,0 +1,19 @@ +module.exports = class UpdateChargebackBankFeeRate1773700300000 { + name = 'UpdateChargebackBankFeeRate1773700300000'; + + async up(queryRunner) { + await queryRunner.query(` + UPDATE "dbo"."fee" + SET "rate" = 0.01, "label" = 'Chargeback Bank Fee 1%' + WHERE "id" = 112 + `); + } + + async down(queryRunner) { + await queryRunner.query(` + UPDATE "dbo"."fee" + SET "rate" = 0.001, "label" = 'Chargeback Bank Fee 0.1%' + WHERE "id" = 112 + `); + } +};