Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions migration/1770246261000-AdjustKycStatsTestData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Adjust KYC stats test data to match target values from PRD compliance report.
*
* Use this migration after a fresh PRD import to correct the statistics.
*
* Target values (from PRD compliance report 2026-02-04):
* 2021: newFiles=250, closed=0, endCount=250, highestFileNr=250
* 2022: newFiles=1947, closed=5, endCount=2192, highestFileNr=2197
* 2023: newFiles=509, closed=2127, endCount=574, highestFileNr=2706
* 2024: newFiles=611, reopened=87, closed=253, endCount=1019, highestFileNr=3317
* 2025: newFiles=2022, reopened=8, closed=2, endCount=3047, highestFileNr=5339
*
* PRD state vs target (as of 2026-02-04):
* - 2021: 229 newFiles -> 250 (need +21, kycFileId 230-250 belong to 2021)
* - 2022: 1968 newFiles -> 1947 (need -21), 0 closed -> 5 (need +5)
* - 2023: 2049 closed -> 2127 (need +78, plus 5 moved to 2022 = +83, via 83 new closures)
*
* Data source verification:
* - Jan 2024 CSV: DFX Kundenliste_SRO inkl. Kypto Stichtag 15. Januar 2024
* - 83 records identified as "geschlossen" in CSV but amlListExpiredDate=NULL in PRD
* - All 83 have amlListReactivatedDate (~2024-02-02) - reactivation cleared expiredDate
* - Migration restores the historical closure date while keeping reactivatedDate intact
* - 5 business accounts (kycFileId 54,71,374,473,649) need 2022 closing date
* - 21 records (kycFileId 230-250) need amlListAddedDate moved to 2021
*
* Expected result: 2022=5 closed, 2023=2049-5+83=2127 closed (target 2127).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AdjustKycStatsTestData1770246261000 {
name = 'AdjustKycStatsTestData1770246261000';

// Records with kycFileId 230-250 to move from 2022 to 2021
// These were processed in early Jan 2022 but belong to 2021 per compliance report (highestFileNr 2021 = 250)
// Verified: 21 UserDataIds with kycFileId between 230-250
movedTo2021Ids = [4161, 1202, 4180, 3949, 1137, 2077, 883, 1886, 4022, 3457, 4144, 3725, 1190, 2781, 4081, 3792, 4262, 3249, 4205, 4243, 1220];

// Business accounts to close in 2022 (change amlListExpiredDate from 2023-12-31 to 2022-12-31)
// These 5 Organization accounts have no formal Schliessdatum in CSV but were closed
// kycFileId: 54 -> 1358, 71 -> 1945, 374 -> 998, 473 -> 5365, 649 -> 6617
closed2022Ids = [1358, 1945, 998, 5365, 6617];

// Records to close in 2023 (set amlListExpiredDate = 2023-12-31)
// Verified: These 83 records are "geschlossen" in Jan 2024 CSV AND have amlListExpiredDate=NULL in PRD
// Cross-referenced PRD NULL list (411 records) with CSV "geschlossen" status
// All 83 were reactivated (~2024-02-02) which cleared their expiredDate in PRD
// Migration restores the historical 2023 closure while keeping reactivatedDate intact
// Note: id 8968 holds kycFileId 1129 (transferred from id 7698 which CSV references)
closed2023Ids = [
803, 914, 915, 979, 983, 984, 1068, 1144, 1161, 1174,
1200, 1289, 1412, 1437, 1449, 1502, 1508, 1548, 1559, 1599,
1778, 1797, 1853, 2006, 2053, 2289, 2578, 2591, 2736, 2746,
2939, 3008, 3057, 3258, 3297, 3365, 3403, 4015, 4485, 4550,
4801, 4910, 5076, 5128, 5163, 5170, 5213, 5219, 5373, 5427,
5479, 5615, 5649, 5662, 5733, 5798, 5951, 6230, 6533, 6568,
6874, 7183, 7274, 7466, 7549, 8419, 8461, 8570, 8879, 8938,
8968, 9146, 9327, 9388, 11104, 11123, 11195, 11531, 11827, 13566,
29059, 31928, 185384
];

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
console.log('=== Adjusting KYC Stats Test Data ===\n');

// Get current stats before changes
const beforeStats = await this.getStats(queryRunner);
console.log('Before migration:');
this.printStats(beforeStats);

// 1. Move 21 records from 2022 to 2021 (kycFileId 230-250)
console.log('\nStep 1: Moving 21 records with kycFileId 230-250 from 2022 to 2021...');
const moved = await queryRunner.query(`
UPDATE dbo.user_data
SET amlListAddedDate = DATEADD(year, -1, amlListAddedDate),
updated = GETDATE()
WHERE id IN (${this.movedTo2021Ids.join(',')})
AND YEAR(amlListAddedDate) = 2022
`);
console.log(` Affected: ${moved?.rowsAffected?.[0] ?? this.movedTo2021Ids.length} records`);

// 2. Change 5 business accounts' closing date from 2023 to 2022
console.log('\nStep 2: Changing 5 business accounts to closed in 2022...');
const closed2022 = await queryRunner.query(`
UPDATE dbo.user_data
SET amlListExpiredDate = '2022-12-31',
updated = GETDATE()
WHERE id IN (${this.closed2022Ids.join(',')})
AND YEAR(amlListExpiredDate) = 2023
`);
console.log(` Affected: ${closed2022?.rowsAffected?.[0] ?? this.closed2022Ids.length} records`);

// 3. Close 83 records in 2023 (all have amlListReactivatedDate set from ~2024-02-02)
console.log('\nStep 3: Closing 83 records in 2023...');
const closed2023 = await queryRunner.query(`
UPDATE dbo.user_data
SET amlListExpiredDate = '2023-12-31',
updated = GETDATE()
WHERE id IN (${this.closed2023Ids.join(',')})
AND amlListExpiredDate IS NULL
`);
console.log(` Affected: ${closed2023?.rowsAffected?.[0] ?? this.closed2023Ids.length} records`);

// Get stats after changes
const afterStats = await this.getStats(queryRunner);
console.log('\nAfter migration:');
this.printStats(afterStats);

console.log('\n=== Migration Complete ===');
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
console.log('=== Reverting KYC Stats Test Data ===\n');

// 1. Reset amlListExpiredDate for 2023 records back to NULL
console.log('Step 1: Resetting 83 records back to NULL...');
await queryRunner.query(`
UPDATE dbo.user_data
SET amlListExpiredDate = NULL,
updated = GETDATE()
WHERE id IN (${this.closed2023Ids.join(',')})
AND amlListExpiredDate = '2023-12-31'
`);

// 2. Change 5 business accounts back to 2023
console.log('\nStep 2: Changing 5 business accounts back to 2023...');
await queryRunner.query(`
UPDATE dbo.user_data
SET amlListExpiredDate = '2023-12-31',
updated = GETDATE()
WHERE id IN (${this.closed2022Ids.join(',')})
AND amlListExpiredDate = '2022-12-31'
`);

// 3. Move records back from 2021 to 2022
console.log('\nStep 3: Moving 21 records back from 2021 to 2022...');
await queryRunner.query(`
UPDATE dbo.user_data
SET amlListAddedDate = DATEADD(year, 1, amlListAddedDate),
updated = GETDATE()
WHERE id IN (${this.movedTo2021Ids.join(',')})
AND YEAR(amlListAddedDate) = 2021
`);

console.log('\n=== Revert Complete ===');
}

async getStats(queryRunner) {
return queryRunner.query(`
SELECT YEAR(amlListExpiredDate) as year, COUNT(*) as closed
FROM dbo.user_data
WHERE amlListExpiredDate IS NOT NULL
GROUP BY YEAR(amlListExpiredDate)
ORDER BY YEAR(amlListExpiredDate)
`);
}

printStats(stats) {
const targets = { 2022: 5, 2023: 2127, 2024: 253, 2025: 2 };
for (const row of stats) {
const target = targets[row.year];
const marker = target ? (row.closed === target ? '✓' : '✗') : '';
console.log(` ${row.year}: ${row.closed} closed ${marker}`);
}
}
};
27 changes: 27 additions & 0 deletions migration/1770400000000-AddAuditPeriodSetting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddAuditPeriodSetting1770400000000 {
name = 'AddAuditPeriodSetting1770400000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
const auditPeriod = JSON.stringify({ start: '2025-01-30T00:00:00Z', end: '2026-02-02T00:00:00Z' });
await queryRunner.query(`INSERT INTO "dbo"."setting" ("key", "value") VALUES ('AuditPeriod', '${auditPeriod}')`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DELETE FROM "dbo"."setting" WHERE "key" = 'AuditPeriod'`);
}
};
69 changes: 69 additions & 0 deletions src/subdomains/core/custody/services/custody.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Config } from 'src/config/config';
import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util';
import { UserRole } from 'src/shared/auth/user-role.enum';
import { Asset } from 'src/shared/models/asset/asset.entity';
import { AssetService } from 'src/shared/models/asset/asset.service';
import { AmountType, Util } from 'src/shared/utils/util';
import { AuthService } from 'src/subdomains/generic/user/models/auth/auth.service';
import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service';
Expand Down Expand Up @@ -38,6 +39,7 @@ export class CustodyService {
private readonly custodyOrderRepo: CustodyOrderRepository,
private readonly custodyBalanceRepo: CustodyBalanceRepository,
private readonly assetPricesService: AssetPricesService,
private readonly assetService: AssetService,
) {}

// --- ACCOUNT --- //
Expand Down Expand Up @@ -220,4 +222,71 @@ export class CustodyService {

return { totalValue };
}

async getUserTotalBalancesChf(date: Date): Promise<Map<number, number>> {
const balances = await this.getHistoricalBalances(date);
if (!balances.length) return new Map();

// Get historical prices
const assetIds = [...new Set(balances.map((b) => b.assetId))];
const priceMap = await this.assetPricesService.getAssetPricesForDate(assetIds, date);

// Sum CHF values per userDataId
const result = new Map<number, number>();

for (const { userDataId, assetId, balance } of balances) {
const priceChf =
priceMap.get(assetId) ?? (await this.assetService.getAssetById(assetId).then((a) => a?.approxPriceChf));
if (priceChf && balance > 0) {
result.set(userDataId, (result.get(userDataId) ?? 0) + balance * priceChf);
}
}

return result;
}

private async getHistoricalBalances(date: Date): Promise<{ userDataId: number; assetId: number; balance: number }[]> {
const deposits = await this.custodyOrderRepo
.createQueryBuilder('o')
.select('u.userDataId', 'userDataId')
.addSelect('o.inputAssetId', 'assetId')
.addSelect('SUM(o.inputAmount)', 'amount')
.innerJoin('o.user', 'u')
.where('o.status = :status', { status: CustodyOrderStatus.COMPLETED })
.andWhere('o.updated <= :date', { date })
.groupBy('u.userDataId')
.addGroupBy('o.inputAssetId')
.getRawMany<{ userDataId: number; assetId: number; amount: number }>();

const withdrawals = await this.custodyOrderRepo
.createQueryBuilder('o')
.select('u.userDataId', 'userDataId')
.addSelect('o.outputAssetId', 'assetId')
.addSelect('SUM(o.outputAmount)', 'amount')
.innerJoin('o.user', 'u')
.where('o.status != :status', { status: CustodyOrderStatus.CREATED })
.andWhere('o.outputAssetId IS NOT NULL')
.andWhere('o.updated <= :date', { date })
.groupBy('u.userDataId')
.addGroupBy('o.outputAssetId')
.getRawMany<{ userDataId: number; assetId: number; amount: number }>();

// Calculate net balances
const balanceMap = new Map<string, { userDataId: number; assetId: number; balance: number }>();

for (const d of deposits) {
const key = `${d.userDataId}-${d.assetId}`;
balanceMap.set(key, { userDataId: d.userDataId, assetId: d.assetId, balance: d.amount });
}

for (const w of withdrawals) {
const key = `${w.userDataId}-${w.assetId}`;
const existing = balanceMap.get(key);
if (existing) {
existing.balance -= w.amount;
}
}

return [...balanceMap.values()];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class KycFileListEntry {
amlListAddedDate?: Date;
amlListExpiredDate?: Date;
amlListReactivatedDate?: Date;
newOpeningInAuditPeriod?: boolean;
highRisk?: boolean;
pep?: boolean;
complexOrgStructure?: boolean;
Expand Down
16 changes: 13 additions & 3 deletions src/subdomains/generic/support/support.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef
import { isIP } from 'class-validator';
import * as IbanTools from 'ibantools';
import { Config } from 'src/config/config';
import { SettingService } from 'src/shared/models/setting/setting.service';
import { Util } from 'src/shared/utils/util';
import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service';
import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity';
Expand Down Expand Up @@ -79,6 +80,7 @@ export class SupportService {
private readonly bankService: BankService,
@Inject(forwardRef(() => TransactionHelper))
private readonly transactionHelper: TransactionHelper,
private readonly settingService: SettingService,
) {}

async getUserDataDetails(id: number): Promise<UserDataSupportInfoDetails> {
Expand Down Expand Up @@ -108,8 +110,14 @@ export class SupportService {
};
}

getKycFileList(): Promise<KycFileListEntry[]> {
return this.userDataService.getUserDatasWithKycFile().then((u) => u.map((d) => this.toKycFileListEntry(d)));
async getKycFileList(): Promise<KycFileListEntry[]> {
const [userData, auditPeriod] = await Promise.all([
this.userDataService.getUserDatasWithKycFile(),
this.settingService.getObj<{ start: string; end: string }>('AuditPeriod'),
]);
const auditStartDate = auditPeriod?.start ? new Date(auditPeriod.start) : undefined;

return userData.map((d) => this.toKycFileListEntry(d, auditStartDate));
}

async getKycFileStats(startYear = 2021, endYear = new Date().getFullYear()): Promise<KycFileYearlyStats[]> {
Expand Down Expand Up @@ -146,7 +154,7 @@ export class SupportService {

// --- MAPPING METHODS --- //

private toKycFileListEntry(userData: UserData): KycFileListEntry {
private toKycFileListEntry(userData: UserData, auditStartDate?: Date): KycFileListEntry {
return {
kycFileId: userData.kycFileId,
id: userData.id,
Expand All @@ -157,6 +165,8 @@ export class SupportService {
amlListAddedDate: userData.amlListAddedDate,
amlListExpiredDate: userData.amlListExpiredDate,
amlListReactivatedDate: userData.amlListReactivatedDate,
newOpeningInAuditPeriod:
auditStartDate && userData.amlListAddedDate ? new Date(userData.amlListAddedDate) > auditStartDate : false,
highRisk: userData.highRisk,
pep: userData.pep,
complexOrgStructure: userData.complexOrgStructure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export class UserDataController {
return this.userDataRepo.find();
}

@Put('auditPeriodNumbers')
@ApiBearerAuth()
@ApiExcludeEndpoint()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard())
async calculateAuditPeriodNumbers(): Promise<{ updatedVolumes: number; updatedCustody: number }> {
return this.userDataService.calculateAuditPeriodNumbers();
}

@Put(':id')
@ApiBearerAuth()
@ApiExcludeEndpoint()
Expand Down
Loading
Loading