From bd313057dd164d8a7e754da3c7ee8960d2e298bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:34:52 +0100 Subject: [PATCH 1/5] Add financial analysis queries to db-debug script (#2997) - Add --anomalies flag to show invalid FinancialDataLog entries - Add --balance flag to show recent total balance history - Add --stats flag to show log statistics by system/subsystem - Add --asset-history flag with Blockchain/Name lookup support (e.g. --asset-history Yapeal/EUR 10) - Add --help flag with usage examples - Use SQL Server JSON_VALUE for efficient balance queries - Client-side jq parsing for asset-specific history --- scripts/db-debug.sh | 151 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 10 deletions(-) diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index dcab08236b..0c608bded4 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -5,6 +5,9 @@ # Usage: # ./scripts/db-debug.sh # Default query (assets) # ./scripts/db-debug.sh "SELECT TOP 10 id FROM asset" # Custom SQL query +# ./scripts/db-debug.sh --anomalies # Show invalid FinancialDataLog entries +# ./scripts/db-debug.sh --balance # Show recent balance history +# ./scripts/db-debug.sh --asset-history Yapeal/EUR 10 # Show asset balance history # # Environment: # Copy .env.db-debug.sample to .env.db-debug and fill in your credentials @@ -15,10 +18,92 @@ set -e +# --- Help (before auth) --- +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + echo "DFX API Debug Database Access Script" + echo "" + echo "Usage:" + echo " ./scripts/db-debug.sh [OPTIONS] [SQL_QUERY]" + echo "" + echo "Options:" + echo " -h, --help Show this help" + echo " -a, --anomalies [N] Show invalid FinancialDataLog entries (default: 20)" + echo " -b, --balance [N] Show recent total balance history (default: 20)" + echo " -s, --stats Show log statistics by system/subsystem" + echo " -A, --asset-history [N]" + echo " Show balance history for asset (default: 10)" + echo "" + echo "Examples:" + echo " ./scripts/db-debug.sh --anomalies 50" + echo " ./scripts/db-debug.sh --balance 10" + echo " ./scripts/db-debug.sh --asset-history 405 20" + echo " ./scripts/db-debug.sh --asset-history Yapeal/EUR 20" + echo " ./scripts/db-debug.sh --asset-history MaerkiBaumann/CHF 10" + echo " ./scripts/db-debug.sh \"SELECT TOP 10 * FROM asset\"" + exit 0 +fi + +# --- Predefined Queries --- +query_anomalies() { + local limit="${1:-20}" + echo "SELECT TOP $limit id, created, JSON_VALUE(message, '\$.balancesTotal.totalBalanceChf') as totalBalanceChf, JSON_VALUE(message, '\$.balancesTotal.plusBalanceChf') as plusBalanceChf, JSON_VALUE(message, '\$.balancesTotal.minusBalanceChf') as minusBalanceChf, valid FROM log WHERE subsystem = 'FinancialDataLog' AND valid = 0 ORDER BY id DESC" +} + +query_stats() { + echo "SELECT system, subsystem, severity, COUNT(*) as count FROM log GROUP BY system, subsystem, severity ORDER BY count DESC" +} + +query_balance() { + local limit="${1:-20}" + echo "SELECT TOP $limit id, created, JSON_VALUE(message, '\$.balancesTotal.totalBalanceChf') as totalBalanceChf, JSON_VALUE(message, '\$.balancesTotal.plusBalanceChf') as plusBalanceChf, JSON_VALUE(message, '\$.balancesTotal.minusBalanceChf') as minusBalanceChf, valid FROM log WHERE subsystem = 'FinancialDataLog' ORDER BY id DESC" +} + +query_asset_raw() { + local limit="${1:-10}" + echo "SELECT TOP $limit id, created, message FROM log WHERE subsystem = 'FinancialDataLog' ORDER BY id DESC" +} + +# --- Parse arguments FIRST --- +SQL="" +ASSET_HISTORY_MODE="" +ASSET_ID="" +ASSET_INPUT="" +ASSET_LIMIT="10" + +case "${1:-}" in + -a|--anomalies) + SQL=$(query_anomalies "${2:-20}") + ;; + -s|--stats) + SQL=$(query_stats) + ;; + -b|--balance) + SQL=$(query_balance "${2:-20}") + ;; + -A|--asset-history) + if [ -z "${2:-}" ]; then + echo "Error: --asset-history requires an asset ID or name" + echo "Usage: ./scripts/db-debug.sh --asset-history [LIMIT]" + echo "" + echo "Examples:" + echo " ./scripts/db-debug.sh --asset-history 405 20" + echo " ./scripts/db-debug.sh --asset-history Yapeal/EUR 20" + echo " ./scripts/db-debug.sh --asset-history MaerkiBaumann/CHF 10" + exit 1 + fi + ASSET_HISTORY_MODE="1" + ASSET_INPUT="$2" + ASSET_LIMIT="${3:-10}" + ;; + *) + SQL="${1:-SELECT TOP 5 id, name, blockchain FROM asset ORDER BY id DESC}" + ;; +esac + +# --- Load environment --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$SCRIPT_DIR/.env.db-debug" -# Load environment variables if [ -f "$ENV_FILE" ]; then source "$ENV_FILE" else @@ -27,7 +112,6 @@ else exit 1 fi -# Validate required variables if [ -z "$DEBUG_ADDRESS" ] || [ -z "$DEBUG_SIGNATURE" ]; then echo "Error: DEBUG_ADDRESS and DEBUG_SIGNATURE must be set in $ENV_FILE" exit 1 @@ -35,7 +119,7 @@ fi API_URL="${DEBUG_API_URL:-https://api.dfx.swiss/v1}" -# Get JWT Token +# --- Authenticate --- echo "=== Authenticating to $API_URL ===" TOKEN_RESPONSE=$(curl -s -X POST "$API_URL/auth/signIn" \ -H "Content-Type: application/json" \ @@ -49,27 +133,74 @@ if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then exit 1 fi -# Decode and show role ROLE=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq -r '.role' 2>/dev/null || echo "unknown") echo "Authenticated with role: $ROLE" echo "" -# Default SQL query if none provided -SQL="${1:-SELECT TOP 5 id, name, blockchain FROM asset ORDER BY id DESC}" +# --- Resolve asset ID if needed --- +if [ -n "$ASSET_HISTORY_MODE" ]; then + if [[ "$ASSET_INPUT" =~ ^[0-9]+$ ]]; then + ASSET_ID="$ASSET_INPUT" + else + # Parse Blockchain/Name format + BLOCKCHAIN=$(echo "$ASSET_INPUT" | cut -d'/' -f1) + ASSET_NAME=$(echo "$ASSET_INPUT" | cut -d'/' -f2) + + echo "=== Resolving Asset: $BLOCKCHAIN/$ASSET_NAME ===" + ASSET_QUERY="SELECT id, name, blockchain FROM asset WHERE blockchain = '$BLOCKCHAIN' AND name = '$ASSET_NAME'" + ASSET_RESULT=$(curl -s -X POST "$API_URL/gs/debug" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sql\":\"$ASSET_QUERY\"}") + ASSET_ID=$(echo "$ASSET_RESULT" | jq -r '.[0].id // empty' 2>/dev/null) + + if [ -z "$ASSET_ID" ]; then + echo "Error: Asset '$ASSET_INPUT' not found" + echo "$ASSET_RESULT" | jq . 2>/dev/null + exit 1 + fi + echo "Found: Asset ID $ASSET_ID" + echo "" + fi + SQL=$(query_asset_raw "$ASSET_LIMIT") +fi + +# --- Execute query --- echo "=== Executing SQL Query ===" echo "Query: $SQL" echo "" -# Execute debug query RESULT=$(curl -s -X POST "$API_URL/gs/debug" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"sql\":\"$SQL\"}") echo "=== Result ===" -if command -v jq &> /dev/null; then - echo "$RESULT" | jq . + +# Special handling for asset history mode (client-side JSON parsing) +if [ -n "$ASSET_HISTORY_MODE" ]; then + if ! command -v jq &> /dev/null; then + echo "Error: jq is required for --asset-history" + exit 1 + fi + + echo "Asset ID: $ASSET_ID" + echo "" + echo "$RESULT" | jq -r --arg aid "$ASSET_ID" ' + .[] | + (.message | fromjson) as $msg | + $msg.assets[$aid] as $asset | + if $asset then + "[\(.id)] \(.created | split("T") | .[0]) \(.created | split("T") | .[1] | split(".") | .[0]) plus: \($asset.plusBalance.total // 0 | tostring | .[0:12]) minus: \($asset.minusBalance.total // 0 | tostring | .[0:12]) price: \($asset.priceChf // 0 | tostring | .[0:10])" + else + "[\(.id)] Asset \($aid) not found in this log entry" + end + ' 2>/dev/null || echo "$RESULT" | jq . else - echo "$RESULT" + if command -v jq &> /dev/null; then + echo "$RESULT" | jq . + else + echo "$RESULT" + fi fi From 39bfce22a9c63c86dae00e66881c793673c81da1 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:35:01 +0100 Subject: [PATCH 2/5] fix: fixed Scrypt trade output amount calculation (#2996) --- .../adapters/actions/scrypt.adapter.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index dcc8d903e9..88e8831dfb 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; +import { ScryptOrderInfo, ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; import { TradeChangedException } from 'src/integration/exchange/exceptions/trade-changed.exception'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -260,14 +260,19 @@ export class ScryptAdapter extends LiquidityActionAdapter { throw new OrderFailedException(`Failed to fetch any orders for order ${order.id}`); } - // For SELL: output is the proceeds (filledQuantity * avgPrice) - return orders.reduce((sum, o) => { - if (o.filledQuantity > 0) { - const output = o.avgPrice ? o.filledQuantity * o.avgPrice : o.filledQuantity; - return sum + output; - } - return sum; - }, 0); + return orders.reduce((sum, o) => sum + this.calculateOrderOutput(o), 0); + } + + private calculateOrderOutput(order: ScryptOrderInfo): number { + if (order.filledQuantity <= 0) return 0; + + if (order.side === ScryptOrderSide.BUY) { + // BUY: output is base currency = filledQuantity + return order.filledQuantity; + } else { + // SELL: output is quote currency = filledQuantity * avgPrice + return order.avgPrice ? order.filledQuantity * order.avgPrice : order.filledQuantity; + } } // --- PARAM VALIDATION --- // From 12c3117dd8222c5bfc0f19cd2487ca49a29f5799 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:43:21 +0100 Subject: [PATCH 3/5] fix: sync pending exchange transactions beyond 12h window (#2995) * fix: sync pending exchange transactions beyond 12h window Exchange transactions (deposits/withdrawals) that remain in 'pending' status for longer than 12 hours were never updated because the sync job only fetches transactions from the last 12 hours (exchangeTxSyncLimit). This adds a new job that runs every 30 minutes to check all pending transactions and update their status by querying the exchange directly. Changes: - Add getDeposit() method to ExchangeService (mirrors existing getWithdraw) - Add syncPendingTransactions() method to ExchangeTxService - Add EXCHANGE_TX_PENDING_SYNC cron job (every 30 minutes) - Scrypt is excluded as it handles pending status differently * Refactor pending transaction sync for efficiency Group pending transactions by exchange/type/currency and make one API call per group instead of one call per transaction. This reduces API calls significantly when multiple pending transactions exist. Also removes unused getDeposit method (getDeposits is sufficient). * fix: improved sync logic --------- Co-authored-by: David May --- .../exchange/services/exchange-tx.service.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index 70fa6a1aa4..8c8a581f71 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -44,9 +44,8 @@ export class ExchangeTxService { async syncExchanges(from?: Date, exchange?: ExchangeName) { const syncs = ExchangeSyncs.filter((s) => !exchange || s.exchange === exchange); - const since = from ?? Util.minutesBefore(Config.exchangeTxSyncLimit); - const transactions = await Promise.all(syncs.map((s) => this.getTransactionsFor(s, since))).then((tx) => tx.flat()); + const transactions = await Promise.all(syncs.map((s) => this.getTransactionsFor(s, from))).then((tx) => tx.flat()); // sort by date transactions.sort((a, b) => a.externalCreated.getTime() - b.externalCreated.getTime()); @@ -120,8 +119,25 @@ export class ExchangeTxService { }); } - private async getTransactionsFor(sync: ExchangeSync, since: Date): Promise { + private async getSyncSinceDate(exchange: ExchangeName): Promise { + const defaultSince = Util.minutesBefore(Config.exchangeTxSyncLimit); + + const oldestPending = await this.exchangeTxRepo.findOne({ + where: { exchange, status: 'pending' }, + order: { externalCreated: 'ASC' }, + }); + + if (!oldestPending?.externalCreated) return defaultSince; + + // Add 1 hour buffer to account for timing differences + const pendingSince = Util.hoursBefore(1, oldestPending.externalCreated); + + return pendingSince < defaultSince ? pendingSince : defaultSince; + } + + private async getTransactionsFor(sync: ExchangeSync, from?: Date): Promise { try { + const since = from ?? (await this.getSyncSinceDate(sync.exchange)); const exchangeService = this.registryService.getExchange(sync.exchange); // Scrypt special case From 01a179dd0bc83205853263965cfe189905a477a3 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:18:11 +0100 Subject: [PATCH 4/5] [NOTASK] Incorrect response kyc error (#2985) * [NOTASK] Incorrect response kyc error * [NOTASK] Renaming --- src/subdomains/generic/kyc/dto/kyc-error.enum.ts | 6 ++++-- src/subdomains/generic/kyc/services/kyc.service.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts index af8b7bb8cf..d77a5f076f 100644 --- a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts +++ b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts @@ -34,8 +34,9 @@ export enum KycError { RECOMMENDER_BLOCKED = 'RecommenderBlocked', // FinancialData errors - MISSING_RESPONSE = 'MissingResponse', + MISSING_INFO = 'MissingInfo', RISKY_BUSINESS = 'RiskyBusiness', + INCORRECT_INFO = 'IncorrectInfo', // NationalityData errors NATIONALITY_NOT_MATCHING = 'NationalityNotMatching', @@ -79,12 +80,13 @@ export const KycErrorMap: Record = { [KycError.USER_DATA_DEACTIVATED]: 'Account deactivated', [KycError.IP_COUNTRY_MISMATCH]: 'Regulatory requirements not met', [KycError.COUNTRY_IP_COUNTRY_MISMATCH]: 'Regulatory requirements not met', - [KycError.MISSING_RESPONSE]: 'Missing data', + [KycError.MISSING_INFO]: 'Missing data', [KycError.RISKY_BUSINESS]: 'Your business is involved in risky business', [KycError.DENIED_RECOMMENDATION]: 'Your recommendation request was denied', [KycError.EXPIRED_RECOMMENDATION]: 'Your recommendation request is expired', [KycError.RECOMMENDER_BLOCKED]: 'Unknown error', [KycError.BANK_RECALL_FEE_NOT_PAID]: 'Recall fee not paid', + [KycError.INCORRECT_INFO]: 'Incorrect response', }; export const KycReasonMap: { [e in KycError]?: KycStepReason } = { diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 6006338b92..6895caab3e 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -297,7 +297,7 @@ export class KycService { if (errors.some((e) => KycStepIgnoringErrors.includes(e))) { await this.kycStepRepo.update(...entity.ignored(comment)); - } else if (errors.includes(KycError.MISSING_RESPONSE)) { + } else if (errors.includes(KycError.MISSING_INFO)) { await this.kycStepRepo.update(...entity.inProgress()); await this.kycNotificationService.kycStepMissingData( entity.userData, @@ -1348,7 +1348,7 @@ export class KycService { const financialStepResult = entity.getResult(); if (!FinancialService.isComplete(financialStepResult, entity.userData.accountType)) - errors.push(KycError.MISSING_RESPONSE); + errors.push(KycError.MISSING_INFO); if (!financialStepResult.some((f) => f.key === 'risky_business' && f.value.includes('no'))) errors.push(KycError.RISKY_BUSINESS); From 2eb8177b1b11624a92a0f14562f216a8052a0516 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:56:43 +0100 Subject: [PATCH 5/5] feat(support): extend compliance user details endpoint (#2960) * feat(support): extend compliance user details endpoint - Add service methods for fetching user-related data by userData ID - Add KycService.getStepsByUserData() - Add BankDataService.getBankDatasByUserData() - Add SellService.getSellsByUserDataId() - Add TransactionService.getTransactionsByUserDataId() - Extend UserDataSupportInfoDetails with kycSteps, users, bankDatas, buyRoutes, sellRoutes, transactions - Add mock mode for azure storage to load dummy KYC files from filesystem - Add KYC test data scripts for local development * fix(scripts): use crypto module for secure random generation - Replace Math.random() with crypto.randomUUID() and crypto.randomInt() - Remove unused variables and functions from kyc-storage.js * fix(scripts): remove remaining security issues - Replace Math.random() with crypto.randomUUID() in kyc-testdata.js - Remove unused fs and path imports from upload-kyc-files.js * refactor(integration): remove unused AzureService * refactor(integration): simplify AzureStorageService by removing unused fallback constants --- .../kyc/dummy-files/additional_document.pdf | Bin 0 -> 698 bytes scripts/kyc/dummy-files/bank_statement.pdf | Bin 0 -> 693 bytes .../kyc/dummy-files/commercial_register.pdf | Bin 0 -> 706 bytes scripts/kyc/dummy-files/id_back.png | Bin 0 -> 70 bytes scripts/kyc/dummy-files/id_front.png | Bin 0 -> 70 bytes scripts/kyc/dummy-files/passport.png | Bin 0 -> 70 bytes scripts/kyc/dummy-files/proof_of_address.pdf | Bin 0 -> 695 bytes scripts/kyc/dummy-files/residence_permit.png | Bin 0 -> 70 bytes scripts/kyc/dummy-files/selfie.jpg | Bin 0 -> 39704 bytes scripts/kyc/dummy-files/source_of_funds.pdf | Bin 0 -> 706 bytes scripts/kyc/kyc-storage.js | 63 +++ scripts/kyc/kyc-testdata.js | 330 ++++++++++++++ scripts/kyc/upload-kyc-files.js | 142 ++++++ scripts/kyc/upload-kyc-files.sh | 47 ++ scripts/setup.sh | 22 + scripts/testdata.js | 408 ++++++++++++++++++ .../infrastructure/azure-service.ts | 63 --- .../infrastructure/azure-storage.service.ts | 61 ++- src/integration/integration.module.ts | 4 +- .../core/sell-crypto/route/sell.service.ts | 7 + .../generic/kyc/services/kyc.service.ts | 7 + .../support/dto/user-data-support.dto.ts | 56 +++ .../generic/support/support.service.ts | 100 ++++- .../models/bank-data/bank-data.service.ts | 6 + .../payment/services/transaction.service.ts | 8 + 25 files changed, 1253 insertions(+), 71 deletions(-) create mode 100644 scripts/kyc/dummy-files/additional_document.pdf create mode 100644 scripts/kyc/dummy-files/bank_statement.pdf create mode 100644 scripts/kyc/dummy-files/commercial_register.pdf create mode 100644 scripts/kyc/dummy-files/id_back.png create mode 100644 scripts/kyc/dummy-files/id_front.png create mode 100644 scripts/kyc/dummy-files/passport.png create mode 100644 scripts/kyc/dummy-files/proof_of_address.pdf create mode 100644 scripts/kyc/dummy-files/residence_permit.png create mode 100644 scripts/kyc/dummy-files/selfie.jpg create mode 100644 scripts/kyc/dummy-files/source_of_funds.pdf create mode 100644 scripts/kyc/kyc-storage.js create mode 100644 scripts/kyc/kyc-testdata.js create mode 100644 scripts/kyc/upload-kyc-files.js create mode 100755 scripts/kyc/upload-kyc-files.sh create mode 100644 scripts/testdata.js delete mode 100644 src/integration/infrastructure/azure-service.ts diff --git a/scripts/kyc/dummy-files/additional_document.pdf b/scripts/kyc/dummy-files/additional_document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..47cd93b2ef55f52b6e309d362ed45ad85e4fc71b GIT binary patch literal 698 zcmZXS+e!m55Qgvb6mwJ1i<-lBw_2oNTdSa=*hNtBB6g>BrEVmfiu&}t-}=>w}Kapuj!9isNID`PVyw9NJ(PZVvLs&annVz%BIq0;_^S{{Jm#IC)VP zH9RJaKT#F8tODpAA@BSbavP6?Hhtt37Ns4yPk2@C06tB0@^4B>fq77i9s&pScB<6J`-9g=+c)LV(+9V z%22w>E@0%Q%Qted5BY1P$sW-P+DXIaSceS-Gw0#reh7Ey%UL?2t=cmSj53J({9)jBgaL+(=+3=(1npA zIpgo2`R#G<>Ui46{eeO-;O`&Q;UVbq^@%_qx#(7Y3Hr({sRhfxHH^oKno286%$K+o3qF%40Y2zugL z>UMt7>X{-t*}0RKKyrdYnU8=X&$Apjy^RA%D`-Aa`OmK@L^dSxN!gz!Oq-vNeIw>kg- literal 0 HcmV?d00001 diff --git a/scripts/kyc/dummy-files/commercial_register.pdf b/scripts/kyc/dummy-files/commercial_register.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4710a3239d7bb64f193fdf4a81edefc6ec4ff687 GIT binary patch literal 706 zcmZWnO>f&U487}D@DgAe)L=$XuZ30$#`M*# zcsKXE9fro)Ev#_kV!{B6LkhM@@1cIoEV4-~1;e7})EE<7`DVZ4*4942_4hg4VQ_2L z*yMbdsYD88z73Ois z+xOu?#i*L(+dH=jc literal 0 HcmV?d00001 diff --git a/scripts/kyc/dummy-files/id_back.png b/scripts/kyc/dummy-files/id_back.png new file mode 100644 index 0000000000000000000000000000000000000000..a27a2d07f020262c5f5b61a0f4bf749f2aa39dfa GIT binary patch literal 70 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 Gu>S$MPMF#N literal 0 HcmV?d00001 diff --git a/scripts/kyc/dummy-files/source_of_funds.pdf b/scripts/kyc/dummy-files/source_of_funds.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8e04791f5765f6899c95dde9501b0583118ceda9 GIT binary patch literal 706 zcmZWn!EW0y487|scnPo_GKq5RtU-}OntB7eVMSvU*oqz0n8{qE)*#D6*RLNbCr#E8 zFd$R!@jZ$hT`$WqewZi(1OEO&ou7lQ{{AM=7cRP;--5n&Tk60v;0ES%MQtrW`Tw6V z;Ph2fcW{>p{3loOeH#H!h;s9mC^CTr3E#<%aGdPpp`$t(loY~WPXMOgZE|0u0(e1NO(7jR3V*);1T zl?m)YL_SeVT$Ab=8EhUaAQM-scNWDa}F`-w=yht`G>rsvSB{ywIO zN)kaYTt~yrXWIRt$WBfk=qZqNP$=^fP~>@*1E&vh2I&OtODg~PI)%uFB#tOs6t7d0 m?3jwdH9HDv2UpEALBaF&Ue;&7dtG!PCWkp@YBak1R{jJ0!MpVU literal 0 HcmV?d00001 diff --git a/scripts/kyc/kyc-storage.js b/scripts/kyc/kyc-storage.js new file mode 100644 index 0000000000..ba8fa2280a --- /dev/null +++ b/scripts/kyc/kyc-storage.js @@ -0,0 +1,63 @@ +/** + * This script shows KYC files stored in the database. + * In local development mode, KYC files are loaded from scripts/kyc/dummy-files/. + * + * Usage: node scripts/kyc/kyc-storage.js + */ + +const mssql = require('mssql'); + +const dbConfig = { + user: process.env.SQL_USERNAME || 'sa', + password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL', + server: 'localhost', + port: parseInt(process.env.SQL_PORT) || 1433, + database: process.env.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true } +}; + +async function main() { + console.log('KYC Storage Seeder'); + console.log('==================\n'); + + console.log('Note: In local development mode, KYC files are stored in-memory.'); + console.log('They need to be uploaded via the KYC flow or manually inserted.\n'); + + // Get KYC file info from database + const pool = await mssql.connect(dbConfig); + + const files = await pool.request().query(` + SELECT kf.id, kf.uid, kf.name, kf.type, kf.userDataId, ud.mail + FROM kyc_file kf + JOIN user_data ud ON kf.userDataId = ud.id + ORDER BY kf.id + `); + + console.log('KYC Files in database:'); + console.log('======================'); + + for (const file of files.recordset) { + const ext = file.name.split('.').pop().toLowerCase(); + const contentType = ext === 'pdf' ? 'application/pdf' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png'; + + console.log(` ${file.name}`); + console.log(` UID: ${file.uid}`); + console.log(` Type: ${file.type}`); + console.log(` User: ${file.mail}`); + console.log(` Content-Type: ${contentType}`); + console.log(''); + } + + console.log('\n========================================'); + console.log('Note:'); + console.log('========================================'); + console.log(''); + console.log('In local development mode, KYC files are automatically'); + console.log('loaded from scripts/kyc/dummy-files/ by the azure-storage'); + console.log('service when the requested file is not in memory storage.'); + console.log(''); + + await pool.close(); +} + +main().catch(console.error); diff --git a/scripts/kyc/kyc-testdata.js b/scripts/kyc/kyc-testdata.js new file mode 100644 index 0000000000..e5fc7de398 --- /dev/null +++ b/scripts/kyc/kyc-testdata.js @@ -0,0 +1,330 @@ +const mssql = require('mssql'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Safety check - only local +const dbHost = process.env.SQL_HOST || 'localhost'; +if (!['localhost', '127.0.0.1'].includes(dbHost)) { + console.error('This script only runs on localhost!'); + process.exit(1); +} + +const config = { + user: process.env.SQL_USERNAME || 'sa', + password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL', + server: 'localhost', + port: parseInt(process.env.SQL_PORT) || 1433, + database: process.env.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true } +}; + +function uuid() { + return crypto.randomUUID().toUpperCase(); +} + +// Create dummy files directory +const dummyDir = path.join(__dirname, 'dummy-files'); +if (!fs.existsSync(dummyDir)) { + fs.mkdirSync(dummyDir, { recursive: true }); +} + +// Create a minimal valid PNG (1x1 pixel, red) +function createDummyPng(filename) { + const pngData = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixel + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x18, 0xDD, + 0x8D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 + ]); + const filepath = path.join(dummyDir, filename); + fs.writeFileSync(filepath, pngData); + return filepath; +} + +// Create a minimal PDF +function createDummyPdf(filename, title) { + const pdfContent = `%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 120 >> +stream +BT +/F1 24 Tf +100 700 Td +(${title}) Tj +/F1 12 Tf +0 -30 Td +(Test Document for KYC Verification) Tj +0 -20 Td +(Generated: ${new Date().toISOString()}) Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000266 00000 n +0000000436 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +513 +%%EOF`; + const filepath = path.join(dummyDir, filename); + fs.writeFileSync(filepath, pdfContent); + return filepath; +} + +// Create a JPEG-like file (minimal valid structure) +function createDummyJpg(filename) { + // Minimal JPEG: SOI + APP0 + minimal data + EOI + const jpgData = Buffer.from([ + 0xFF, 0xD8, // SOI + 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // APP0 + 0xFF, 0xDB, 0x00, 0x43, 0x00, // DQT + 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, + 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, + 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, + 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, + 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, // SOF0 + 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, // DHT + 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7F, 0xFF, // SOS + minimal scan data + 0xFF, 0xD9 // EOI + ]); + const filepath = path.join(dummyDir, filename); + fs.writeFileSync(filepath, jpgData); + return filepath; +} + +async function main() { + console.log('Creating dummy files...'); + + // Create dummy files + const files = { + idFront: createDummyPng('id_front.png'), + idBack: createDummyPng('id_back.png'), + selfie: createDummyJpg('selfie.jpg'), + passport: createDummyPng('passport.png'), + proofOfAddress: createDummyPdf('proof_of_address.pdf', 'Proof of Address'), + commercialRegister: createDummyPdf('commercial_register.pdf', 'Commercial Register Extract'), + bankStatement: createDummyPdf('bank_statement.pdf', 'Bank Statement'), + residencePermit: createDummyPng('residence_permit.png'), + sourceOfFunds: createDummyPdf('source_of_funds.pdf', 'Source of Funds Declaration'), + additionalDoc: createDummyPdf('additional_document.pdf', 'Additional Document'), + }; + + console.log(' Created dummy files in:', dummyDir); + Object.entries(files).forEach(([name, filepath]) => { + console.log(` - ${name}: ${path.basename(filepath)}`); + }); + + console.log('\nConnecting to database...'); + const pool = await mssql.connect(config); + + // Get user_data entries + const userDataResult = await pool.request().query(` + SELECT id, mail, kycLevel, firstname, surname + FROM user_data + WHERE mail LIKE '%@test.local' OR mail = 'bernd@dfx.swiss' + ORDER BY id + `); + + if (userDataResult.recordset.length === 0) { + console.log('No test user_data found. Please run scripts/testdata.js first.'); + await pool.close(); + return; + } + + console.log(`\nFound ${userDataResult.recordset.length} user_data entries for KYC test data.\n`); + + // KYC Step configurations for different KYC levels + const kycStepConfigs = { + // KYC Level 10: Contact + Personal data + 10: [ + { name: 'ContactData', status: 'Completed', result: JSON.stringify({ email: 'test@example.com', phone: '+41791234567' }) }, + { name: 'PersonalData', status: 'Completed', result: JSON.stringify({ address: 'Teststrasse 1', city: 'Zurich', zip: '8000' }) }, + ], + // KYC Level 20: + Nationality + 20: [ + { name: 'ContactData', status: 'Completed', result: JSON.stringify({ email: 'test@example.com', phone: '+41791234567' }) }, + { name: 'PersonalData', status: 'Completed', result: JSON.stringify({ address: 'Teststrasse 1', city: 'Zurich', zip: '8000' }) }, + { name: 'NationalityData', status: 'Completed', result: JSON.stringify({ nationality: 'CH' }) }, + ], + // KYC Level 30: + Ident + 30: [ + { name: 'ContactData', status: 'Completed', result: JSON.stringify({ email: 'test@example.com', phone: '+41791234567' }) }, + { name: 'PersonalData', status: 'Completed', result: JSON.stringify({ address: 'Teststrasse 1', city: 'Zurich', zip: '8000' }) }, + { name: 'NationalityData', status: 'Completed', result: JSON.stringify({ nationality: 'CH' }) }, + { name: 'Ident', type: 'Manual', status: 'Completed', result: JSON.stringify({ firstName: 'Max', lastName: 'Mueller', birthday: '1978-11-30', nationality: { symbol: 'CH' }, documentType: 'Passport', documentNumber: 'X1234567' }) }, + ], + // KYC Level 50: Full KYC + Financial + 50: [ + { name: 'ContactData', status: 'Completed', result: JSON.stringify({ email: 'test@example.com', phone: '+41791234567' }) }, + { name: 'PersonalData', status: 'Completed', result: JSON.stringify({ address: 'Teststrasse 1', city: 'Zurich', zip: '8000' }) }, + { name: 'NationalityData', status: 'Completed', result: JSON.stringify({ nationality: 'CH' }) }, + { name: 'Ident', type: 'Manual', status: 'Completed', result: JSON.stringify({ firstName: 'Lisa', lastName: 'Weber', birthday: '1982-05-10', nationality: { symbol: 'CH' }, documentType: 'IdCard', documentNumber: 'C9876543' }) }, + { name: 'FinancialData', status: 'Completed', result: JSON.stringify({ annualIncome: '100000-200000', sourceOfFunds: 'Salary', occupation: 'Engineer' }) }, + { name: 'DfxApproval', status: 'Completed', result: JSON.stringify({ approved: true, approvedBy: 'system' }) }, + ], + }; + + // File configurations for different KYC steps + const fileConfigs = { + 'Ident': [ + { name: 'id_front.png', type: 'Identification', subType: 'IdentificationForm', protected: true }, + { name: 'id_back.png', type: 'Identification', subType: null, protected: true }, + { name: 'selfie.jpg', type: 'Identification', subType: null, protected: true }, + ], + 'FinancialData': [ + { name: 'source_of_funds.pdf', type: 'UserInformation', subType: 'RiskProfile', protected: false }, + { name: 'bank_statement.pdf', type: 'UserInformation', subType: 'BankTransactionVerification', protected: false }, + ], + 'DfxApproval': [ + { name: 'additional_document.pdf', type: 'AdditionalDocuments', subType: null, protected: false }, + ], + }; + + console.log('Creating KYC Steps and Files...\n'); + + for (const userData of userDataResult.recordset) { + const kycLevel = userData.kycLevel || 0; + const steps = kycStepConfigs[kycLevel]; + + if (!steps) { + console.log(` UserData ${userData.id} (${userData.mail}): KYC Level ${kycLevel} - no steps defined`); + continue; + } + + console.log(` UserData ${userData.id} (${userData.mail}): KYC Level ${kycLevel}`); + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + // Check if step already exists + const existingStep = await pool.request() + .input('userDataId', mssql.Int, userData.id) + .input('name', mssql.NVarChar, step.name) + .input('seqNum', mssql.Int, i + 1) + .query('SELECT id FROM kyc_step WHERE userDataId = @userDataId AND name = @name AND sequenceNumber = @seqNum'); + + let stepId; + if (existingStep.recordset.length > 0) { + stepId = existingStep.recordset[0].id; + console.log(` - Step ${step.name} already exists (id=${stepId})`); + } else { + const stepResult = await pool.request() + .input('userDataId', mssql.Int, userData.id) + .input('name', mssql.NVarChar, step.name) + .input('type', mssql.NVarChar, step.type || null) + .input('status', mssql.NVarChar, step.status) + .input('sequenceNumber', mssql.Int, i + 1) + .input('result', mssql.NVarChar, step.result || null) + .input('sessionId', mssql.NVarChar, uuid()) + .query(` + INSERT INTO kyc_step (userDataId, name, type, status, sequenceNumber, result, sessionId, created, updated) + OUTPUT INSERTED.id + VALUES (@userDataId, @name, @type, @status, @sequenceNumber, @result, @sessionId, GETUTCDATE(), GETUTCDATE()) + `); + stepId = stepResult.recordset[0].id; + console.log(` - Created Step ${step.name} (id=${stepId})`); + } + + // Create files for this step if configured + const fileConfigsForStep = fileConfigs[step.name]; + if (fileConfigsForStep) { + for (const fileConfig of fileConfigsForStep) { + const fileUid = uuid(); + + const existingFile = await pool.request() + .input('userDataId', mssql.Int, userData.id) + .input('name', mssql.NVarChar, fileConfig.name) + .input('kycStepId', mssql.Int, stepId) + .query('SELECT id FROM kyc_file WHERE userDataId = @userDataId AND name = @name AND kycStepId = @kycStepId'); + + if (existingFile.recordset.length > 0) { + console.log(` - File ${fileConfig.name} already exists`); + } else { + await pool.request() + .input('name', mssql.NVarChar, fileConfig.name) + .input('type', mssql.NVarChar, fileConfig.type) + .input('subType', mssql.NVarChar, fileConfig.subType) + .input('protected', mssql.Bit, fileConfig.protected) + .input('valid', mssql.Bit, true) + .input('uid', mssql.NVarChar, fileUid) + .input('userDataId', mssql.Int, userData.id) + .input('kycStepId', mssql.Int, stepId) + .query(` + INSERT INTO kyc_file (name, type, subType, protected, valid, uid, userDataId, kycStepId, created, updated) + VALUES (@name, @type, @subType, @protected, @valid, @uid, @userDataId, @kycStepId, GETUTCDATE(), GETUTCDATE()) + `); + console.log(` - Created File ${fileConfig.name} (uid=${fileUid.substring(0, 8)}...)`); + } + } + } + } + } + + // Create KYC Log entries + console.log('\nCreating KYC Log entries...'); + + const kycSteps = await pool.request().query('SELECT id, userDataId, name, status FROM kyc_step'); + + for (const step of kycSteps.recordset.slice(0, 5)) { + const existingLog = await pool.request() + .input('kycStepId', mssql.Int, step.id) + .query('SELECT id FROM kyc_log WHERE kycStepId = @kycStepId'); + + if (existingLog.recordset.length === 0) { + await pool.request() + .input('kycStepId', mssql.Int, step.id) + .input('status', mssql.NVarChar, step.status) + .input('result', mssql.NVarChar, 'System: KYC step processed') + .query(` + INSERT INTO kyc_log (kycStepId, status, result, created, updated) + VALUES (@kycStepId, @status, @result, GETUTCDATE(), GETUTCDATE()) + `); + console.log(` - Created log for step ${step.id} (${step.name})`); + } + } + + // Summary + console.log('\n========================================'); + console.log('KYC Test Data Creation Complete!'); + console.log('========================================\n'); + + const stepCount = await pool.request().query('SELECT COUNT(*) as c FROM kyc_step'); + const fileCount = await pool.request().query('SELECT COUNT(*) as c FROM kyc_file'); + const logCount = await pool.request().query('SELECT COUNT(*) as c FROM kyc_log'); + + console.log(` kyc_step: ${stepCount.recordset[0].c} rows`); + console.log(` kyc_file: ${fileCount.recordset[0].c} rows`); + console.log(` kyc_log: ${logCount.recordset[0].c} rows`); + console.log(`\n Dummy files location: ${dummyDir}`); + + await pool.close(); +} + +main().catch(e => { + console.error('Error:', e.message); + process.exit(1); +}); diff --git a/scripts/kyc/upload-kyc-files.js b/scripts/kyc/upload-kyc-files.js new file mode 100644 index 0000000000..3ad2d1910b --- /dev/null +++ b/scripts/kyc/upload-kyc-files.js @@ -0,0 +1,142 @@ +const axios = require('axios'); + +const API_URL = 'http://localhost:3000'; + +// Dummy file data (base64 encoded minimal files) +const DUMMY_FILES = { + // Minimal 1x1 red PNG + 'png': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + // Minimal PDF + 'pdf': 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PgplbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmCjAwMDAwMDAwMDkgMDAwMDAgbgowMDAwMDAwMDU4IDAwMDAwIG4KMDAwMDAwMDExNSAwMDAwMCBuCnRyYWlsZXIKPDwgL1NpemUgNCAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMTk1CiUlRU9G', + // Minimal JPEG (1x1 red pixel) + 'jpg': '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBEQCEAwEPwAB//9k=' +}; + +async function getAdminToken() { + const { ethers } = require('ethers'); + const ADMIN_SEED = 'ignore dish destroy upgrade stem pulse lucky tomato yard baby obvious cool'; + const wallet = ethers.Wallet.fromMnemonic(ADMIN_SEED); + + // Get sign message + const signMsgRes = await axios.get(`${API_URL}/v1/auth/signMessage?address=${wallet.address}`); + const message = signMsgRes.data.message; + + // Sign message + const signature = await wallet.signMessage(message); + + // Authenticate + const authRes = await axios.post(`${API_URL}/v1/auth`, { + address: wallet.address, + signature: signature + }); + + return authRes.data.accessToken; +} + +async function getKycCodes() { + const mssql = require('mssql'); + const config = { + user: process.env.SQL_USERNAME || 'sa', + password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL', + server: 'localhost', + port: parseInt(process.env.SQL_PORT) || 1433, + database: process.env.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true } + }; + + const pool = await mssql.connect(config); + + const result = await pool.request().query(` + SELECT ud.id, ud.kycHash, ud.mail, ud.kycLevel, + ks.id as stepId, ks.name as stepName + FROM user_data ud + LEFT JOIN kyc_step ks ON ks.userDataId = ud.id AND ks.name = 'Ident' + WHERE ud.kycLevel >= 30 + ORDER BY ud.id + `); + + await pool.close(); + return result.recordset; +} + +async function uploadFileViaAPI(token, kycCode, stepId, fileData, fileName, fileType) { + try { + // The manual ident endpoint accepts file data + const response = await axios.put( + `${API_URL}/v2/kyc/ident/manual/${stepId}`, + { + firstName: 'Test', + lastName: 'User', + birthday: '1990-01-01', + nationality: { id: 1 }, + documentType: 'Passport', + documentNumber: 'X1234567', + documentFront: `data:image/${fileType};base64,${fileData}`, + documentBack: `data:image/${fileType};base64,${fileData}`, + selfie: `data:image/jpg;base64,${DUMMY_FILES.jpg}` + }, + { + headers: { + 'x-kyc-code': kycCode, + 'Authorization': `Bearer ${token}` + } + } + ); + return response.data; + } catch (e) { + console.log(` Error: ${e.response?.data?.message || e.message}`); + return null; + } +} + +async function main() { + console.log('KYC File Upload Script'); + console.log('======================\n'); + + try { + // Get admin token + console.log('Getting admin token...'); + const token = await getAdminToken(); + console.log(' Token obtained.\n'); + + // Get KYC codes for users with level >= 30 + console.log('Getting KYC codes...'); + const kycData = await getKycCodes(); + console.log(` Found ${kycData.length} entries.\n`); + + for (const entry of kycData) { + if (!entry.stepId) { + console.log(`Skipping ${entry.mail} - no Ident step`); + continue; + } + + console.log(`Processing ${entry.mail} (KYC Level ${entry.kycLevel}):`); + console.log(` KYC Code: ${entry.kycHash}`); + console.log(` Step ID: ${entry.stepId}`); + + // Try to upload via manual ident + const result = await uploadFileViaAPI( + token, + entry.kycHash, + entry.stepId, + DUMMY_FILES.png, + 'id_document.png', + 'png' + ); + + if (result) { + console.log(` Upload successful!`); + } + } + + console.log('\n========================================'); + console.log('Note: Files are stored in memory and will'); + console.log('be lost when the API restarts.'); + console.log('========================================\n'); + + } catch (e) { + console.error('Error:', e.message); + } +} + +main(); diff --git a/scripts/kyc/upload-kyc-files.sh b/scripts/kyc/upload-kyc-files.sh new file mode 100755 index 0000000000..ba9470a466 --- /dev/null +++ b/scripts/kyc/upload-kyc-files.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -e + +echo "๐Ÿ“ DFX KYC File Uploader" +echo "========================" +echo "" + +API_URL="${API_URL:-http://localhost:3000}" + +# Check if API is running +echo "๐Ÿ” Checking if API is running at $API_URL..." +if ! curl -s "$API_URL/v1/health" > /dev/null 2>&1; then + echo "โŒ API is not running at $API_URL" + echo "" + echo "Please start the API first with:" + echo " npm start" + echo "" + echo "Then run this script again." + exit 1 +fi +echo "โœ… API is running" +echo "" + +# Run kyc-storage.js +echo "๐Ÿ—„๏ธ Running kyc-storage.js..." +if [ -f "scripts/kyc/kyc-storage.js" ]; then + node scripts/kyc/kyc-storage.js + echo "" +else + echo "โš ๏ธ kyc-storage.js not found, skipping" +fi + +# Run upload-kyc-files.js +echo "๐Ÿ“ค Running upload-kyc-files.js..." +if [ -f "scripts/kyc/upload-kyc-files.js" ]; then + node scripts/kyc/upload-kyc-files.js + echo "" +else + echo "โš ๏ธ upload-kyc-files.js not found, skipping" +fi + +echo "" +echo "โœ… KYC file upload complete!" +echo "" +echo "โš ๏ธ Note: Files are stored in memory and will be lost when the API restarts." +echo " The API will return dummy images for missing files in local dev mode." +echo "" diff --git a/scripts/setup.sh b/scripts/setup.sh index ec4ef3147c..08bf88e798 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -94,6 +94,25 @@ for i in {1..30}; do echo -n "." done +# Seed test data +echo "" +echo "๐ŸŒฑ Seeding test data..." +if [ -f "scripts/testdata.js" ]; then + node scripts/testdata.js + echo "โœ… Test data seeded" +else + echo "โš ๏ธ testdata.js not found, skipping" +fi + +echo "" +echo "๐Ÿ” Seeding KYC test data..." +if [ -f "scripts/kyc/kyc-testdata.js" ]; then + node scripts/kyc/kyc-testdata.js + echo "โœ… KYC test data seeded" +else + echo "โš ๏ธ kyc-testdata.js not found, skipping" +fi + echo "" echo "โœ… Setup complete!" echo "" @@ -103,3 +122,6 @@ echo "" echo "๐Ÿ“ The server will be available at: http://localhost:3000" echo "๐Ÿ“ All external services are automatically mocked in local mode" echo "" +echo "๐Ÿ“ To upload KYC files (after API is running), run:" +echo " ./scripts/kyc/upload-kyc-files.sh" +echo "" diff --git a/scripts/testdata.js b/scripts/testdata.js new file mode 100644 index 0000000000..78a3baabe6 --- /dev/null +++ b/scripts/testdata.js @@ -0,0 +1,408 @@ +const mssql = require('mssql'); +const crypto = require('crypto'); + +// Safety check - only local +const dbHost = process.env.SQL_HOST || 'localhost'; +if (!['localhost', '127.0.0.1'].includes(dbHost)) { + console.error('This script only runs on localhost!'); + process.exit(1); +} + +const config = { + user: process.env.SQL_USERNAME || 'sa', + password: process.env.SQL_PASSWORD || 'LocalDev2026@SQL', + server: 'localhost', + port: parseInt(process.env.SQL_PORT) || 1433, + database: process.env.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true } +}; + +// Test addresses (not real wallets) +const TEST_ADDRESSES = { + EVM: [ + '0xTestUser2000000000000000000000000000002', + '0xTestUser3000000000000000000000000000003', + '0xTestUser4000000000000000000000000000004', + '0xTestUser5000000000000000000000000000005', + ], + BITCOIN: [ + 'bc1qTestBtcUser2000000000000000000002', + 'bc1qTestBtcUser3000000000000000000003', + ] +}; + +function uuid() { + return crypto.randomUUID().toUpperCase(); +} + +function bankUsage() { + const chars = 'ABCDEF0123456789'; + let result = ''; + for (let i = 0; i < 12; i++) { + if (i === 4 || i === 8) result += '-'; + result += chars[crypto.randomInt(chars.length)]; + } + return result; +} + +async function main() { + console.log('Connecting to database...'); + const pool = await mssql.connect(config); + + console.log('Creating test data...\n'); + + // Get existing IDs for foreign keys + const walletResult = await pool.request().query('SELECT TOP 1 id FROM wallet'); + const walletId = walletResult.recordset[0]?.id || 1; + + const langResult = await pool.request().query("SELECT id FROM language WHERE symbol = 'EN'"); + const languageId = langResult.recordset[0]?.id || 1; + + const chfResult = await pool.request().query("SELECT id FROM fiat WHERE name = 'CHF'"); + const chfId = chfResult.recordset[0]?.id || 1; + + const eurResult = await pool.request().query("SELECT id FROM fiat WHERE name = 'EUR'"); + const eurId = eurResult.recordset[0]?.id || 2; + + const countryResult = await pool.request().query("SELECT id FROM country WHERE symbol = 'CH'"); + const countryId = countryResult.recordset[0]?.id || 1; + + const deCountryResult = await pool.request().query("SELECT id FROM country WHERE symbol = 'DE'"); + const deCountryId = deCountryResult.recordset[0]?.id || 2; + + // Get some assets + const btcResult = await pool.request().query("SELECT id FROM asset WHERE name = 'BTC' AND blockchain = 'Bitcoin'"); + const btcId = btcResult.recordset[0]?.id; + + const ethResult = await pool.request().query("SELECT id FROM asset WHERE name = 'ETH' AND blockchain = 'Ethereum'"); + const ethId = ethResult.recordset[0]?.id; + + const usdtResult = await pool.request().query("SELECT id FROM asset WHERE name = 'USDT' AND blockchain = 'Ethereum'"); + const usdtId = usdtResult.recordset[0]?.id; + + console.log(`Using: walletId=${walletId}, langId=${languageId}, chfId=${chfId}, eurId=${eurId}`); + console.log(`Assets: BTC=${btcId}, ETH=${ethId}, USDT=${usdtId}\n`); + + // ============================================================ + // Create UserData entries + // ============================================================ + 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' }, + ]; + + const userDataIds = []; + for (const ud of userDataConfigs) { + const existing = await pool.request() + .input('mail', mssql.NVarChar, ud.mail) + .query('SELECT id FROM user_data WHERE mail = @mail'); + + if (existing.recordset.length > 0) { + userDataIds.push(existing.recordset[0].id); + console.log(` UserData ${ud.mail} already exists (id=${existing.recordset[0].id})`); + continue; + } + + const kycHash = uuid(); + const result = await pool.request() + .input('mail', mssql.NVarChar, ud.mail) + .input('firstname', mssql.NVarChar, ud.firstname) + .input('surname', mssql.NVarChar, ud.surname) + .input('street', mssql.NVarChar, ud.street || null) + .input('houseNumber', mssql.NVarChar, ud.houseNumber || null) + .input('zip', mssql.NVarChar, ud.zip || null) + .input('location', mssql.NVarChar, ud.location || null) + .input('kycHash', mssql.NVarChar, kycHash) + .input('kycLevel', mssql.Int, ud.kycLevel) + .input('kycStatus', mssql.NVarChar, ud.kycStatus) + .input('kycType', mssql.NVarChar, 'DFX') + .input('status', mssql.NVarChar, ud.status) + .input('riskStatus', mssql.NVarChar, 'NA') + .input('countryId', mssql.Int, ud.countryId || null) + .input('nationalityId', mssql.Int, ud.countryId || null) + .input('languageId', mssql.Int, languageId) + .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(` + 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 + VALUES (@mail, @firstname, @surname, @street, @houseNumber, @zip, @location, @kycHash, @kycLevel, @kycStatus, @kycType, @status, @riskStatus, + @countryId, @nationalityId, @languageId, @currencyId, @walletId, @accountType, @birthday, GETUTCDATE(), GETUTCDATE()) + `); + + userDataIds.push(result.recordset[0].id); + console.log(` Created UserData: ${ud.mail} (id=${result.recordset[0].id}, kycLevel=${ud.kycLevel})`); + } + + // ============================================================ + // Create Users + // ============================================================ + console.log('\nCreating Users...'); + + const userConfigs = [ + { address: TEST_ADDRESSES.EVM[0], addressType: 'EVM', role: 'User', userDataIdx: 0 }, + { address: TEST_ADDRESSES.EVM[1], addressType: 'EVM', role: 'User', userDataIdx: 1 }, + { address: TEST_ADDRESSES.EVM[2], addressType: 'EVM', role: 'User', userDataIdx: 2 }, + { address: TEST_ADDRESSES.EVM[3], addressType: 'EVM', role: 'VIP', userDataIdx: 3 }, + { address: TEST_ADDRESSES.BITCOIN[0], addressType: 'Bitcoin', role: 'User', userDataIdx: 4 }, + ]; + + const userIds = []; + for (const u of userConfigs) { + const existing = await pool.request() + .input('address', mssql.NVarChar, u.address) + .query('SELECT id FROM [user] WHERE address = @address'); + + if (existing.recordset.length > 0) { + userIds.push(existing.recordset[0].id); + console.log(` User ${u.address.substring(0, 20)}... already exists (id=${existing.recordset[0].id})`); + continue; + } + + const result = await pool.request() + .input('address', mssql.NVarChar, u.address) + .input('addressType', mssql.NVarChar, u.addressType) + .input('role', mssql.NVarChar, u.role) + .input('status', mssql.NVarChar, 'Active') + .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(` + 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) + OUTPUT INSERTED.id + VALUES (@address, @addressType, @role, @status, @usedRef, @walletId, @userDataId, @refFeePercent, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, GETUTCDATE(), GETUTCDATE()) + `); + + userIds.push(result.recordset[0].id); + console.log(` Created User: ${u.address.substring(0, 20)}... (id=${result.recordset[0].id}, role=${u.role})`); + } + + // ============================================================ + // Create Routes (for Buy entities) + // ============================================================ + console.log('\nCreating Routes...'); + + const routeIds = []; + for (let i = 0; i < 4; i++) { + 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()) + `); + routeIds.push(result.recordset[0].id); + } + console.log(` Created ${routeIds.length} routes`); + + // ============================================================ + // Create Buy routes + // ============================================================ + console.log('\nCreating Buy routes...'); + + const buyConfigs = [ + { userId: userIds[0], assetId: btcId }, + { userId: userIds[0], assetId: ethId }, + { userId: userIds[1], assetId: btcId }, + { userId: userIds[2], assetId: usdtId }, + ]; + + for (let i = 0; i < buyConfigs.length; i++) { + const b = buyConfigs[i]; + if (!b.assetId) continue; + + const usage = bankUsage(); + 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'); + + if (existing.recordset.length > 0) { + console.log(` Buy route for user ${b.userId}, asset ${b.assetId} already exists`); + continue; + } + + 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(` + INSERT INTO buy (bankUsage, userId, assetId, routeId, active, volume, annualVolume, monthlyVolume, created, updated) + VALUES (@bankUsage, @userId, @assetId, @routeId, @active, 0, 0, 0, GETUTCDATE(), GETUTCDATE()) + `); + + console.log(` Created Buy route: user=${b.userId}, asset=${b.assetId}, usage=${usage}`); + } + + // ============================================================ + // Create BankData entries + // ============================================================ + console.log('\nCreating BankData entries...'); + + const bankDataConfigs = [ + { userDataId: userDataIds[2], iban: 'CH93 0076 2011 6238 5295 7', name: 'Anna Schmidt' }, + { userDataId: userDataIds[3], iban: 'DE89 3704 0044 0532 0130 00', name: 'Max Mueller' }, + { userDataId: userDataIds[4], iban: 'CH56 0483 5012 3456 7800 9', name: 'Lisa Weber' }, + ]; + + const bankDataIds = []; + for (const bd of bankDataConfigs) { + const cleanIban = bd.iban.replace(/\s/g, ''); + const existing = await pool.request() + .input('iban', mssql.NVarChar, cleanIban) + .query('SELECT id FROM bank_data WHERE iban = @iban'); + + if (existing.recordset.length > 0) { + bankDataIds.push(existing.recordset[0].id); + console.log(` BankData ${cleanIban} already exists`); + continue; + } + + 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(` + INSERT INTO bank_data (iban, name, userDataId, approved, created, updated) + OUTPUT INSERTED.id + VALUES (@iban, @name, @userDataId, @approved, GETUTCDATE(), GETUTCDATE()) + `); + + bankDataIds.push(result.recordset[0].id); + console.log(` Created BankData: ${cleanIban}`); + } + + // ============================================================ + // Create Deposits (crypto addresses) + // ============================================================ + console.log('\nCreating Deposit addresses...'); + + const depositConfigs = [ + { address: '0xDeposit000000000000000000000000000001', blockchains: 'Ethereum;Arbitrum;Optimism;Polygon;Base' }, + { address: '0xDeposit000000000000000000000000000002', blockchains: 'Ethereum;Arbitrum;Optimism;Polygon;Base' }, + { address: 'bc1qdeposit0000000000000000000001', blockchains: 'Bitcoin' }, + ]; + + for (const d of depositConfigs) { + const existing = await pool.request() + .input('address', mssql.NVarChar, d.address) + .query('SELECT id FROM deposit WHERE address = @address'); + + if (existing.recordset.length > 0) { + console.log(` Deposit ${d.address.substring(0, 25)}... already exists`); + continue; + } + + 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()) + `); + + console.log(` Created Deposit: ${d.address.substring(0, 25)}...`); + } + + // ============================================================ + // Create Transactions + // ============================================================ + console.log('\nCreating Transaction entries...'); + + const txConfigs = [ + { userId: userIds[0], userDataId: userDataIds[0], sourceType: 'BuyCrypto', amountInChf: 500, amlCheck: 'Pass' }, + { userId: userIds[0], userDataId: userDataIds[0], sourceType: 'BuyCrypto', amountInChf: 1200, amlCheck: 'Pass' }, + { userId: userIds[1], userDataId: userDataIds[1], sourceType: 'BuyCrypto', amountInChf: 2500, amlCheck: 'Pass' }, + { userId: userIds[2], userDataId: userDataIds[2], sourceType: 'BuyFiat', amountInChf: 800, amlCheck: 'Pass' }, + { userId: userIds[3], userDataId: userDataIds[3], sourceType: 'BuyCrypto', amountInChf: 5000, amlCheck: 'Pass' }, + { userId: userIds[3], userDataId: userDataIds[3], sourceType: 'BuyFiat', amountInChf: 3500, amlCheck: 'Pass' }, + ]; + + for (const tx of txConfigs) { + const uid = uuid(); + + 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(` + INSERT INTO [transaction] (uid, sourceType, userId, userDataId, amountInChf, amlCheck, eventDate, created, updated) + VALUES (@uid, @sourceType, @userId, @userDataId, @amountInChf, @amlCheck, @eventDate, GETUTCDATE(), GETUTCDATE()) + `); + + console.log(` Created Transaction: ${tx.sourceType}, CHF ${tx.amountInChf}, user=${tx.userId}`); + } + + // ============================================================ + // Create BankTx entries (incoming bank transfers) + // ============================================================ + console.log('\nCreating BankTx entries...'); + + const bankResult = await pool.request().query("SELECT TOP 1 id FROM bank WHERE receive = 1"); + const bankId = bankResult.recordset[0]?.id; + + if (bankId) { + const bankTxConfigs = [ + { accountIban: 'CH9300762011623852957', name: 'Anna Schmidt', amount: 500, currency: 'CHF', type: 'BuyCrypto' }, + { accountIban: 'DE89370400440532013000', name: 'Max Mueller', amount: 1000, currency: 'EUR', type: 'BuyCrypto' }, + { accountIban: 'CH5604835012345678009', name: 'Lisa Weber', amount: 2000, currency: 'CHF', type: 'BuyCrypto' }, + ]; + + for (const btx of bankTxConfigs) { + 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(` + INSERT INTO bank_tx (bankId, accountIban, name, amount, currency, type, creditDebitIndicator, created, updated) + VALUES (@bankId, @accountIban, @name, @amount, @currency, @type, @creditDebitIndicator, GETUTCDATE(), GETUTCDATE()) + `); + + console.log(` Created BankTx: ${btx.name}, ${btx.currency} ${btx.amount}`); + } + } + + // ============================================================ + // Summary + // ============================================================ + console.log('\n========================================'); + console.log('Test data creation complete!'); + console.log('========================================\n'); + + // Show counts + const tables = ['user_data', '[user]', 'buy', 'bank_data', 'deposit', '[transaction]', 'bank_tx']; + 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`); + } + + await pool.close(); +} + +main().catch(e => { + console.error('Error:', e.message); + process.exit(1); +}); diff --git a/src/integration/infrastructure/azure-service.ts b/src/integration/infrastructure/azure-service.ts deleted file mode 100644 index 03ee1e2d75..0000000000 --- a/src/integration/infrastructure/azure-service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Method } from 'axios'; -import { Config } from 'src/config/config'; -import { HttpService } from 'src/shared/services/http.service'; - -@Injectable() -export class AzureService { - private readonly baseUrl = `https://management.azure.com`; - private readonly apiVersion = '2022-03-01'; - - private accessToken = 'access-token-will-be-updated'; - - constructor(private readonly http: HttpService) {} - - public async restartWebApp(name: string, slot?: string) { - const appName = `app-dfx-${name}-${Config.environment}${slot ? `/slots/${slot}` : ''}`; - const resourceId = this.resourceId('Microsoft.Web/sites', appName); - return this.callApi(`${resourceId}/restart`, 'POST'); - } - - // --- HELPER METHODS --- // - private resourceId(provider: string, name: string): string { - return `subscriptions/${Config.azure.subscriptionId}/resourceGroups/rg-dfx-api-${Config.environment}/providers/${provider}/${name}`; - } - - private async callApi(url: string, method: Method = 'GET', data?: any): Promise { - return this.request(url, method, data); - } - - private async request(url: string, method: Method, data?: any, nthTry = 3, getNewAccessToken = false): Promise { - try { - if (getNewAccessToken) this.accessToken = await this.getAccessToken(); - - return await this.http.request({ - url: `${this.baseUrl}/${url}?api-version=${this.apiVersion}`, - method: method, - data: method !== 'GET' ? data : undefined, - params: method === 'GET' ? data : undefined, - headers: { - Authorization: `Bearer ${this.accessToken}`, - }, - }); - } catch (e) { - if (nthTry > 1 && e.response?.status == 401) { - return this.request(url, method, data, nthTry - 1, true); - } - throw e; - } - } - - private async getAccessToken(): Promise { - const { access_token } = await this.http.post<{ access_token: string }>( - `https://login.microsoftonline.com/${Config.azure.tenantId}/oauth2/token`, - new URLSearchParams({ - grant_type: 'client_credentials', - client_id: Config.azure.clientId, - client_secret: Config.azure.clientSecret, - resource: 'https://management.azure.com', - }), - ); - return access_token; - } -} diff --git a/src/integration/infrastructure/azure-storage.service.ts b/src/integration/infrastructure/azure-storage.service.ts index cf12862aaf..9d17cced65 100644 --- a/src/integration/infrastructure/azure-storage.service.ts +++ b/src/integration/infrastructure/azure-storage.service.ts @@ -1,4 +1,6 @@ import { BlobGetPropertiesResponse, BlobServiceClient, ContainerClient } from '@azure/storage-blob'; +import * as fs from 'fs'; +import * as path from 'path'; import { Config, Environment, GetConfig } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -21,6 +23,15 @@ export interface BlobContent extends BlobMetaData { // In-memory storage for local development const mockStorage = new Map }>(); +// Dummy files directory for local development +const DUMMY_FILES_DIR = path.join(process.cwd(), 'scripts', 'kyc', 'dummy-files'); + +// Load dummy file from disk +function loadDummyFile(filename: string): Buffer { + const filePath = path.join(DUMMY_FILES_DIR, filename); + return fs.readFileSync(filePath); +} + export class AzureStorageService { private readonly logger = new DfxLogger(AzureStorageService); private readonly client: ContainerClient; @@ -86,12 +97,56 @@ export class AzureStorageService { if (this.isMockMode) { const key = `${this.container}/${name}`; const stored = mockStorage.get(key); + + // Return stored data if available, otherwise return dummy test data based on file extension + if (stored) { + return { + data: stored.data, + contentType: stored.type, + created: new Date(), + updated: new Date(), + metadata: stored.metadata ?? {}, + }; + } + + // Provide dummy data for missing files in local dev mode + const ext = name.split('.').pop()?.toLowerCase(); + const filename = name.split('/').pop() ?? name; + + // Map common KYC file names to dummy files + const dummyFileMap: Record = { + 'id_front.png': { file: 'id_front.png', type: 'image/png' }, + 'id_back.png': { file: 'id_back.png', type: 'image/png' }, + 'selfie.jpg': { file: 'selfie.jpg', type: 'image/jpeg' }, + 'passport.png': { file: 'passport.png', type: 'image/png' }, + 'residence_permit.png': { file: 'residence_permit.png', type: 'image/png' }, + 'proof_of_address.pdf': { file: 'proof_of_address.pdf', type: 'application/pdf' }, + 'bank_statement.pdf': { file: 'bank_statement.pdf', type: 'application/pdf' }, + 'source_of_funds.pdf': { file: 'source_of_funds.pdf', type: 'application/pdf' }, + 'commercial_register.pdf': { file: 'commercial_register.pdf', type: 'application/pdf' }, + 'additional_document.pdf': { file: 'additional_document.pdf', type: 'application/pdf' }, + }; + + const mapping = dummyFileMap[filename]; + if (mapping) { + return { + data: loadDummyFile(mapping.file), + contentType: mapping.type, + created: new Date(), + updated: new Date(), + metadata: {}, + }; + } + + // Fallback based on extension + const isJpg = ext === 'jpg' || ext === 'jpeg'; + const isPdf = ext === 'pdf'; return { - data: stored?.data ?? Buffer.from(''), - contentType: stored?.type ?? 'application/octet-stream', + data: loadDummyFile(isPdf ? 'proof_of_address.pdf' : isJpg ? 'selfie.jpg' : 'id_front.png'), + contentType: isPdf ? 'application/pdf' : isJpg ? 'image/jpeg' : 'image/png', created: new Date(), updated: new Date(), - metadata: stored?.metadata ?? {}, + metadata: {}, }; } diff --git a/src/integration/integration.module.ts b/src/integration/integration.module.ts index 549bdb7957..551d89f7eb 100644 --- a/src/integration/integration.module.ts +++ b/src/integration/integration.module.ts @@ -6,7 +6,6 @@ import { CheckoutModule } from './checkout/checkout.module'; import { ExchangeModule } from './exchange/exchange.module'; import { IknaModule } from './ikna/ikna.module'; import { AppInsightsQueryService } from './infrastructure/app-insights-query.service'; -import { AzureService } from './infrastructure/azure-service'; import { LetterModule } from './letter/letter.module'; import { SiftModule } from './sift/sift.module'; @@ -22,7 +21,7 @@ import { SiftModule } from './sift/sift.module'; SiftModule, ], controllers: [], - providers: [AzureService, AppInsightsQueryService], + providers: [AppInsightsQueryService], exports: [ BankIntegrationModule, BlockchainModule, @@ -30,7 +29,6 @@ import { SiftModule } from './sift/sift.module'; LetterModule, IknaModule, CheckoutModule, - AzureService, AppInsightsQueryService, SiftModule, ], diff --git a/src/subdomains/core/sell-crypto/route/sell.service.ts b/src/subdomains/core/sell-crypto/route/sell.service.ts index b615cec990..4372443e26 100644 --- a/src/subdomains/core/sell-crypto/route/sell.service.ts +++ b/src/subdomains/core/sell-crypto/route/sell.service.ts @@ -132,6 +132,13 @@ export class SellService { return sells.filter((s) => s.deposit.blockchainList.some((b) => sellableBlockchains.includes(b))); } + async getSellsByUserDataId(userDataId: number): Promise { + return this.sellRepo.find({ + where: { user: { userData: { id: userDataId } } }, + relations: { fiat: true, user: true }, + }); + } + async getSellWithoutRoute(): Promise { return this.sellRepo.findBy({ route: { id: IsNull() } }); } diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 6895caab3e..e8cadda9be 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1228,6 +1228,13 @@ export class KycService { return this.kycStepRepo.findOne({ where: { id }, relations: { userData: true } }); } + async getStepsByUserData(userDataId: number): Promise { + return this.kycStepRepo.find({ + where: { userData: { id: userDataId } }, + order: { sequenceNumber: 'ASC' }, + }); + } + async saveKycStepUpdate(updateResult: UpdateResult): Promise { await this.kycStepRepo.update(...updateResult); } 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 f8c2e0417c..69c97f46e3 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -30,9 +30,65 @@ export class BankTxSupportInfo { iban?: string; } +export class UserSupportInfo { + id: number; + address: string; + role: string; + status: string; + created: Date; +} + +export class TransactionSupportInfo { + id: number; + uid: string; + type?: string; + sourceType: string; + amountInChf?: number; + amlCheck?: string; + created: Date; +} + +export class KycStepSupportInfo { + id: number; + name: string; + type?: string; + status: string; + sequenceNumber: number; + created: Date; +} + +export class BankDataSupportInfo { + id: number; + iban: string; + name: string; + approved: boolean; +} + +export class BuySupportInfo { + id: number; + bankUsage: string; + assetName: string; + blockchain: string; + volume: number; + active: boolean; +} + +export class SellSupportInfo { + id: number; + iban: string; + fiatName?: string; + volume: number; +} + export class UserDataSupportInfoDetails { userData: UserData; kycFiles: KycFile[]; + kycSteps: KycStepSupportInfo[]; + transactions: TransactionSupportInfo[]; + users: UserSupportInfo[]; + bankDatas: BankDataSupportInfo[]; + buyRoutes: BuySupportInfo[]; + sellRoutes: SellSupportInfo[]; } export class UserDataSupportQuery { diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index b167df9b09..87938cc2d2 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -4,11 +4,13 @@ import * as IbanTools from 'ibantools'; import { Config } from 'src/config/config'; 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'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; import { RefundDataDto } from 'src/subdomains/core/history/dto/refund-data.dto'; import { BankRefundDto } from 'src/subdomains/core/history/dto/transaction-refund.dto'; import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; import { BankTxReturnService } from 'src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service'; import { @@ -20,20 +22,31 @@ import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/service import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +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 { KycStep } from '../kyc/entities/kyc-step.entity'; import { KycFileService } from '../kyc/services/kyc-file.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'; import { UserData } from '../user/models/user-data/user-data.entity'; import { UserDataService } from '../user/models/user-data/user-data.service'; +import { User } from '../user/models/user/user.entity'; import { UserService } from '../user/models/user/user.service'; import { + BankDataSupportInfo, BankTxSupportInfo, + BuySupportInfo, ComplianceSearchType, + KycStepSupportInfo, + SellSupportInfo, + TransactionSupportInfo, UserDataSupportInfo, UserDataSupportInfoDetails, UserDataSupportInfoResult, UserDataSupportQuery, + UserSupportInfo, } from './dto/user-data-support.dto'; interface UserDataComplianceSearchTypePair { @@ -56,6 +69,7 @@ export class SupportService { private readonly bankTxService: BankTxService, private readonly payInService: PayInService, private readonly kycFileService: KycFileService, + private readonly kycService: KycService, private readonly bankDataService: BankDataService, private readonly bankTxReturnService: BankTxReturnService, private readonly transactionService: TransactionService, @@ -69,9 +83,91 @@ export class SupportService { const userData = await this.userDataService.getUserData(id, { wallet: true, bankDatas: true }); if (!userData) throw new NotFoundException(`User not found`); - const kycFiles = await this.kycFileService.getUserDataKycFiles(id); + // Load all related data in parallel + const [kycFiles, kycSteps, transactions, users, bankDatas, buyRoutes, sellRoutes] = await Promise.all([ + this.kycFileService.getUserDataKycFiles(id), + this.kycService.getStepsByUserData(id), + this.transactionService.getTransactionsByUserDataId(id), + this.userService.getAllUserDataUsers(id), + this.bankDataService.getBankDatasByUserData(id), + this.buyService.getUserDataBuys(id), + this.sellService.getSellsByUserDataId(id), + ]); - return { userData, kycFiles }; + return { + userData, + kycFiles, + kycSteps: kycSteps.map((s) => this.toKycStepSupportInfo(s)), + transactions: transactions.map((t) => this.toTransactionSupportInfo(t)), + users: users.map((u) => this.toUserSupportInfo(u)), + bankDatas: bankDatas.map((b) => this.toBankDataSupportInfo(b)), + buyRoutes: buyRoutes.map((b) => this.toBuySupportInfo(b)), + sellRoutes: sellRoutes.map((s) => this.toSellSupportInfo(s)), + }; + } + + // --- MAPPING METHODS --- // + + private toKycStepSupportInfo(step: KycStep): KycStepSupportInfo { + return { + id: step.id, + name: step.name, + type: step.type, + status: step.status, + sequenceNumber: step.sequenceNumber, + created: step.created, + }; + } + + private toTransactionSupportInfo(tx: Transaction): TransactionSupportInfo { + return { + id: tx.id, + uid: tx.uid, + type: tx.type, + sourceType: tx.sourceType, + amountInChf: tx.amountInChf, + amlCheck: tx.amlCheck, + created: tx.created, + }; + } + + private toUserSupportInfo(user: User): UserSupportInfo { + return { + id: user.id, + address: user.address, + role: user.role, + status: user.status, + created: user.created, + }; + } + + private toBankDataSupportInfo(bankData: BankData): BankDataSupportInfo { + return { + id: bankData.id, + iban: bankData.iban, + name: bankData.name, + approved: bankData.approved, + }; + } + + private toBuySupportInfo(buy: Buy): BuySupportInfo { + return { + id: buy.id, + bankUsage: buy.bankUsage, + assetName: buy.asset?.name, + blockchain: buy.asset?.blockchain, + volume: buy.volume, + active: buy.active, + }; + } + + private toSellSupportInfo(sell: Sell): SellSupportInfo { + return { + id: sell.id, + iban: sell.iban, + fiatName: sell.fiat?.name, + volume: sell.annualVolume, + }; } async searchUserDataByKey(query: UserDataSupportQuery): Promise { diff --git a/src/subdomains/generic/user/models/bank-data/bank-data.service.ts b/src/subdomains/generic/user/models/bank-data/bank-data.service.ts index 620a026f83..cc391c82e4 100644 --- a/src/subdomains/generic/user/models/bank-data/bank-data.service.ts +++ b/src/subdomains/generic/user/models/bank-data/bank-data.service.ts @@ -301,6 +301,12 @@ export class BankDataService { }); } + async getBankDatasByUserData(userDataId: number): Promise { + return this.bankDataRepo.find({ + where: { userData: { id: userDataId } }, + }); + } + async getVerifiedBankDataWithIban( iban: string, userDataId?: number, diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 8b7ce181be..f4ff249ffe 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -121,6 +121,14 @@ export class TransactionService { return this.repo.findBy({ uid: IsNull(), created: LessThanOrEqual(filterDate) }); } + async getTransactionsByUserDataId(userDataId: number): Promise { + return this.repo.find({ + where: { userData: { id: userDataId } }, + order: { created: 'DESC' }, + take: 100, + }); + } + async getTransactionsForAccount(userDataId: number, from = new Date(0), to = new Date()): Promise { return this.repo.find({ where: { userData: { id: userDataId }, type: Not(IsNull()), created: Between(from, to) },