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/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 + `); + } +}; 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); }); 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/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 }, 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/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, 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..ef6f4f17ba --- /dev/null +++ b/src/subdomains/supporting/dashboard/dashboard-financial.service.ts @@ -0,0 +1,253 @@ +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, + 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, + 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..c0e07f7224 --- /dev/null +++ b/src/subdomains/supporting/dashboard/dto/financial-log.dto.ts @@ -0,0 +1,56 @@ +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; + 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 }; + }; +} + +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-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, 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,