From 7e53dc31e1890e2498a7f6d03ed2c9f38bebd0c5 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 13 May 2026 11:27:55 +0200 Subject: [PATCH 01/13] fix: added merge check to critical endpoints (#3701) --- .../core/buy-crypto/routes/buy/buy.controller.ts | 2 +- .../core/custody/services/custody-account.service.ts | 3 +-- src/subdomains/core/custody/services/custody.service.ts | 3 +-- .../core/payment-link/services/payment-link.service.ts | 2 +- src/subdomains/generic/user/models/auth/auth.service.ts | 6 ++++++ src/subdomains/generic/user/models/kyc/kyc.service.ts | 4 +--- .../user/models/recommendation/recommendation.service.ts | 3 +-- .../generic/user/models/user-data/user-data.service.ts | 7 +++++++ src/subdomains/supporting/realunit/realunit.service.ts | 3 +-- 9 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts index e5b415cdb4..9224b7ce36 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts @@ -235,7 +235,7 @@ export class BuyController { ) @ApiOkResponse({ type: VirtualIbanDto }) async createPersonalIban(@GetJwt() jwt: JwtPayload, @Body() dto: CreateVirtualIbanDto): Promise { - const userData = await this.userDataService.getUserData(jwt.account); + const userData = await this.userDataService.getActiveUserData(jwt.account); if (userData.kycLevel < KycLevel.LEVEL_50) throw new BadRequestException('KYC level 50 or higher required for personal IBAN'); diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index 2c984329af..2185838be4 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -103,8 +103,7 @@ export class CustodyAccountService { // --- CREATE --- // async createCustodyAccount(accountId: number, title: string, description?: string): Promise { - const owner = await this.userDataService.getUserData(accountId); - if (!owner) throw new NotFoundException('User not found'); + const owner = await this.userDataService.getActiveUserData(accountId); const custodyAccount = this.custodyAccountRepo.create({ title, diff --git a/src/subdomains/core/custody/services/custody.service.ts b/src/subdomains/core/custody/services/custody.service.ts index 6beb0fa4db..10f8cd0bff 100644 --- a/src/subdomains/core/custody/services/custody.service.ts +++ b/src/subdomains/core/custody/services/custody.service.ts @@ -54,8 +54,7 @@ export class CustodyService { const custodyWallet = EvmUtil.createWallet(Config.blockchain.evm.custodyAccount(addressIndex)); const signature = await custodyWallet.signMessage(Config.auth.signMessageGeneral + custodyWallet.address); - const account = await this.userDataService.getUserData(accountId, { users: true }); - if (!account) throw new NotFoundException('User not found'); + const account = await this.userDataService.getActiveUserData(accountId, { users: true }); const custodyUser = await this.userService.createUser( { diff --git a/src/subdomains/core/payment-link/services/payment-link.service.ts b/src/subdomains/core/payment-link/services/payment-link.service.ts index cd639153ca..ccea952ca9 100644 --- a/src/subdomains/core/payment-link/services/payment-link.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link.service.ts @@ -426,7 +426,7 @@ export class PaymentLinkService { } async updateUserPaymentLinksConfig(userDataId: number, dto: UpdatePaymentLinkConfigDto): Promise { - const userData = await this.userDataService.getUserData(userDataId, { users: { wallet: true } }); + const userData = await this.userDataService.getActiveUserData(userDataId, { users: { wallet: true } }); if (!userData.paymentLinksAllowed) throw new ForbiddenException('permission denied'); await this.userDataService.updatePaymentLinksConfig(userData, dto); diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 67b55b73ee..dbf1f0734e 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -145,6 +145,8 @@ export class AuthService { userId?: number, ): Promise { const userData = userDataId && (await this.userDataService.getUserData(userDataId, { users: true, wallet: true })); + if (userData?.status === UserDataStatus.MERGED) throw new UnauthorizedException('User data is merged'); + const primaryUser = userId && (await this.userService.getUser(userId)); const custodyProvider = await this.custodyProviderService.getWithMasterKey(dto.signature).catch(() => undefined); @@ -208,6 +210,8 @@ export class AuthService { }); if (!user) throw new NotFoundException('User not found'); + if (user.userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User data is merged'); + if (user.userData.isDeactivated) user.userData = await this.userDataService.updateUserDataInternal( user.userData, @@ -326,6 +330,7 @@ export class AuthService { if (!this.isMailKeyValid(entry)) throw new Error('Login link expired'); const account = await this.userDataService.getUserData(entry.userDataId, { users: true, wallet: true }); + if (account.status === UserDataStatus.MERGED) throw new UnauthorizedException('User data is merged'); const ipLog = await this.ipLogService.create(ip, entry.loginUrl, entry.mail, undefined, account); if (!ipLog.result) throw new Error('The country of IP address is not allowed'); @@ -379,6 +384,7 @@ export class AuthService { if (!user) throw new NotFoundException('User not found'); if (user.isBlockedOrDeleted || user.userData.isBlockedOrDeactivated) throw new BadRequestException('User is deactivated or blocked'); + if (user.userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User data is merged'); if (!user.userData.tradeApprovalDate) await this.checkPendingRecommendation(user.userData, user.wallet); diff --git a/src/subdomains/generic/user/models/kyc/kyc.service.ts b/src/subdomains/generic/user/models/kyc/kyc.service.ts index 65add007dd..b58256b938 100644 --- a/src/subdomains/generic/user/models/kyc/kyc.service.ts +++ b/src/subdomains/generic/user/models/kyc/kyc.service.ts @@ -120,9 +120,7 @@ export class KycService { } private async getUserById(id: number): Promise { - const userData = await this.userDataService.getUserData(id, { users: true }); - if (!userData) throw new NotFoundException('User not found'); - return userData; + return this.userDataService.getActiveUserData(id, { users: true }); } private async getUserByKycCode(code: string): Promise { diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts index 0a7cc531a6..88926c2087 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts @@ -34,8 +34,7 @@ export class RecommendationService { ) {} async createRecommendationByRecommender(userDataId: number, dto: CreateRecommendationDto): Promise { - const userData = await this.userDataService.getUserData(userDataId, { users: true }); - if (!userData) throw new NotFoundException('Account not found'); + const userData = await this.userDataService.getActiveUserData(userDataId, { users: true }); if (userData.kycLevel < KycLevel.LEVEL_50) throw new BadRequestException('Missing KYC'); if (!userData.tradeApprovalDate) throw new BadRequestException('Trade approval date missing'); if (!userData.hasTradeHistory) throw new BadRequestException('Trade history required'); diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 573daf44ca..f235f41942 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -139,6 +139,13 @@ export class UserDataService { : this.userDataRepo.findOne(request); } + async getActiveUserData(userDataId: number, relations?: FindOptionsRelations): Promise { + const userData = await this.getUserData(userDataId, relations); + if (!userData) throw new NotFoundException('UserData not found'); + if (userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User data is merged'); + return userData; + } + async getUserDataByIds(ids: number[]): Promise { if (!ids.length) return []; return this.userDataRepo.find({ where: { id: In(ids) } }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 6f884787e1..de40b99a18 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -549,8 +549,7 @@ export class RealUnitService { } async registerEmail(userDataId: number, dto: RealUnitEmailRegistrationDto): Promise { - const userData = await this.userDataService.getUserData(userDataId, { users: true }); - if (!userData) throw new NotFoundException('User not found'); + const userData = await this.userDataService.getActiveUserData(userDataId, { users: true }); if (!userData.mail) { try { From 8488857a028cdb158555ce448b39704522696287 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Wed, 13 May 2026 17:03:09 +0200 Subject: [PATCH 02/13] [NOTASK] hide depositAddress for invalid requests (#3707) --- src/subdomains/core/sell-crypto/route/sell.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subdomains/core/sell-crypto/route/sell.service.ts b/src/subdomains/core/sell-crypto/route/sell.service.ts index 6cc40e9278..4d069f71e0 100644 --- a/src/subdomains/core/sell-crypto/route/sell.service.ts +++ b/src/subdomains/core/sell-crypto/route/sell.service.ts @@ -414,7 +414,7 @@ export class SellService { timestamp, routeId: sell.id, fee: Util.round(feeSource.rate * 100, Config.defaultPercentageDecimal), - depositAddress: sell.active ? sell.deposit.address : undefined, + depositAddress: sell.active && isValid ? sell.deposit.address : undefined, blockchain: dto.asset.blockchain, minDeposit: { amount: minVolume, asset: dto.asset.dexName }, minVolume, From c7e1c918b2062cb065760b30913ff8ad08b317b7 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Wed, 13 May 2026 18:14:52 +0200 Subject: [PATCH 03/13] [NOTASK] allow support tx pdf (#3708) --- src/subdomains/generic/support/support.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index 75f536c240..136aa29a89 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -19,9 +19,9 @@ import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { RefundDataDto } from 'src/subdomains/core/history/dto/refund-data.dto'; import { ChargebackRefundDto } from 'src/subdomains/core/history/dto/transaction-refund.dto'; +import { ReviewStatus } from '../kyc/enums/review-status.enum'; import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; import { TransactionListQuery } from './dto/transaction-list-query.dto'; -import { ReviewStatus } from '../kyc/enums/review-status.enum'; import { CallQueue, CallQueueItem, @@ -29,10 +29,10 @@ import { KycFileListEntry, KycFileYearlyStats, PendingOnboardingInfo, - PendingTransactionInfo, PendingReviewItem, PendingReviewSummaryEntry, PendingReviewType, + PendingTransactionInfo, RecommendationGraph, TransactionListEntry, UserDataSupportInfoDetails, @@ -157,7 +157,7 @@ export class SupportController { @Get(':id/transaction-pdf') @ApiBearerAuth() @ApiExcludeEndpoint() - @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) async getTransactionPdf(@Param('id') id: string): Promise<{ pdfData: string }> { const pdfData = await this.supportService.generateTransactionPdf(+id); return { pdfData }; From 18e93025897afd27d9be5e826712d929004814c7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 14:38:51 +0200 Subject: [PATCH 04/13] chore(deps): bump sanitize-html to 2.17.4 to clear critical advisory (#3711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-rpr9-rxv7-x643 — default XSS via xmp raw-text passthrough, fixed in 2.17.4. Patch bump only; package range stays ^2.17.x. Unblocks the CI 'Security audit' step (npm audit --audit-level=critical), which started failing this morning after the advisory dropped and now blocks every PR build (#3707, #3708, #3709). --- package-lock.json | 51 ++++++++++++++++++++++++++++++++++++++++++----- package.json | 2 +- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 581fb42fd0..233b36f79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,7 @@ "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", "rxjs": "^7.8.2", - "sanitize-html": "^2.17.0", + "sanitize-html": "^2.17.4", "secp256k1": "^5.0.1", "swagger-ui-express": "^4.6.3", "swissqrbill": "^4.2.0", @@ -20773,6 +20773,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -25963,19 +25972,51 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", - "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", + "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", - "htmlparser2": "^8.0.0", + "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", + "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, + "node_modules/sanitize-html/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", diff --git a/package.json b/package.json index 5c8552b383..e7706a1d6e 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", "rxjs": "^7.8.2", - "sanitize-html": "^2.17.0", + "sanitize-html": "^2.17.4", "secp256k1": "^5.0.1", "swagger-ui-express": "^4.6.3", "swissqrbill": "^4.2.0", From 19981019776f0892f9e283698ec4384f31d7fbbb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 20:03:43 +0200 Subject: [PATCH 05/13] fix(realunit): accept BitBox-safe ASCII transliterations in registration (#3709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realunit): accept BitBox-safe ASCII transliterations in registration realunit-app v0.0.3+ transliterates EIP-712 string fields to ASCII-with-German-digraphs (Krüger → Krueger) so the BitBox firmware accepts them, while keeping the kycData copy in UTF-8 for ID verification. Without this change the backend rejects every BitBox registration that touches a non-ASCII character: - validateRegistrationDto compares dto.kycData.firstName/lastName (UTF-8) against dto.name (ASCII) and throws "firstName + lastName does not match signed name". Same for street/zip/city/organizationName. - verifyRealUnitRegistrationSignature recomputes the EIP-712 hash from the stored accountData; for users who registered before this fix the stored fields are UTF-8 and a fresh ASCII signature no longer recovers the wallet address. Adds a `toBitboxAscii` helper that mirrors the Dart implementation (`ä` → `ae`, `ß` → `ss`, …) — kept separate from the existing `transliteration` npm package, which uses single-char substitution and would not match the client's hash. validateRegistrationDto now accepts either the literal or the ASCII form via `matchesSignedField`. verifyRealUnitRegistrationSignature retries with ASCII-transliterated message values when the primary recovery fails, so re-login (registerWallet) keeps working for pre-fix registrations. * style: compact bitbox-ascii maps with // prettier-ignore Maps now group by base letter (one row per letter, multiple diacritic variants per row) for readability — 212 → 89 lines. Behavior unchanged, all 6 spec tests still pass. * refactor: switch toBitboxAscii to chained-replace idiom Match the existing house style for character normalization (Util.removeSpecialChars, util.ts). 89 → 79 lines, removes the need for // prettier-ignore (which was the only place in the repo using that pragma). Parity verified: all 168 entries in the Dart source map produce identical output in the chained-replace version. --- .../utils/__tests__/bitbox-ascii.spec.ts | 39 +++++++++ src/shared/utils/bitbox-ascii.util.ts | 79 +++++++++++++++++++ .../supporting/realunit/realunit.service.ts | 43 ++++++++-- 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/shared/utils/__tests__/bitbox-ascii.spec.ts create mode 100644 src/shared/utils/bitbox-ascii.util.ts diff --git a/src/shared/utils/__tests__/bitbox-ascii.spec.ts b/src/shared/utils/__tests__/bitbox-ascii.spec.ts new file mode 100644 index 0000000000..a9ea71a6b4 --- /dev/null +++ b/src/shared/utils/__tests__/bitbox-ascii.spec.ts @@ -0,0 +1,39 @@ +import { toBitboxAscii } from '../bitbox-ascii.util'; + +describe('toBitboxAscii', () => { + it('returns ASCII input unchanged', () => { + expect(toBitboxAscii('Joshua Krueger')).toBe('Joshua Krueger'); + expect(toBitboxAscii('')).toBe(''); + expect(toBitboxAscii('123 Main St')).toBe('123 Main St'); + }); + + it('expands German umlauts with digraph romanization', () => { + expect(toBitboxAscii('Krüger')).toBe('Krueger'); + expect(toBitboxAscii('Müller')).toBe('Mueller'); + expect(toBitboxAscii('Über')).toBe('Ueber'); + expect(toBitboxAscii('Größe')).toBe('Groesse'); + expect(toBitboxAscii('STRAẞE')).toBe('STRASSE'); + }); + + it('expands Nordic æ/ø to digraphs', () => { + expect(toBitboxAscii('Æsir')).toBe('Aesir'); + expect(toBitboxAscii('Mørk')).toBe('Moerk'); + expect(toBitboxAscii('Þór')).toBe('Thor'); + }); + + it('strips accents from Latin-script letters', () => { + expect(toBitboxAscii('Garção')).toBe('Garcao'); + expect(toBitboxAscii('Łódź')).toBe('Lodz'); + expect(toBitboxAscii('Naïve')).toBe('Naive'); + expect(toBitboxAscii('Ångström')).toBe('Angstroem'); + }); + + it('replaces unmapped non-ASCII runes with ?', () => { + expect(toBitboxAscii('hello 你好')).toBe('hello ??'); + expect(toBitboxAscii('emoji 🙂')).toMatch(/^emoji \?+$/); + }); + + it('treats name from production incident as expected', () => { + expect(toBitboxAscii('Joshua Krüger')).toBe('Joshua Krueger'); + }); +}); diff --git a/src/shared/utils/bitbox-ascii.util.ts b/src/shared/utils/bitbox-ascii.util.ts new file mode 100644 index 0000000000..5e574bddac --- /dev/null +++ b/src/shared/utils/bitbox-ascii.util.ts @@ -0,0 +1,79 @@ +// Transliteration that mirrors realunit-app's `toBitboxSafeAscii` (Dart). +// Used to reconcile EIP-712 string fields that the BitBox firmware accepts +// (printable ASCII only) with the UTF-8 originals stored on the user_data +// row. Distinct from the npm `transliteration` package — that one maps +// `ü` to `u` (single char) while the BitBox-safe convention follows the +// German romanization (`ü` → `ue`, `ß` → `ss`). +// +// Same chained-replace idiom as `Util.removeSpecialChars`. Digraph rules +// must run before the single-char fallback, and the final catch-all +// replaces any leftover non-ASCII with `?` so the BitBox firmware never +// sees a non-printable byte. + +export function toBitboxAscii(value: string): string { + if (/^[\x20-\x7E]*$/.test(value)) return value; + + return ( + value + // Digraph romanizations — must come before single-char fallbacks + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .replace(/Ä/g, 'Ae') + .replace(/Ö/g, 'Oe') + .replace(/Ü/g, 'Ue') + .replace(/ẞ/g, 'SS') + .replace(/æ/g, 'ae') + .replace(/Æ/g, 'Ae') + .replace(/œ/g, 'oe') + .replace(/Œ/g, 'Oe') + .replace(/ø/g, 'oe') + .replace(/Ø/g, 'Oe') + .replace(/þ/g, 'th') + .replace(/Þ/g, 'Th') + .replace(/ð/g, 'd') + .replace(/Ð/g, 'D') + // Single-char base-letter equivalents + .replace(/[àáâãåāăą]/g, 'a') + .replace(/[ÀÁÂÃÅĀĂĄ]/g, 'A') + .replace(/[çćčĉċ]/g, 'c') + .replace(/[ÇĆČĈĊ]/g, 'C') + .replace(/[ďđ]/g, 'd') + .replace(/[ĎĐ]/g, 'D') + .replace(/[èéêëēėęě]/g, 'e') + .replace(/[ÈÉÊËĒĖĘĚ]/g, 'E') + .replace(/[ğġ]/g, 'g') + .replace(/[ĞĠ]/g, 'G') + .replace(/ħ/g, 'h') + .replace(/Ħ/g, 'H') + .replace(/[ìíîïīįı]/g, 'i') + .replace(/[ÌÍÎÏĪĮİ]/g, 'I') + .replace(/ĵ/g, 'j') + .replace(/Ĵ/g, 'J') + .replace(/ķ/g, 'k') + .replace(/Ķ/g, 'K') + .replace(/[łľĺļ]/g, 'l') + .replace(/[ŁĽĹĻ]/g, 'L') + .replace(/[ñńňņ]/g, 'n') + .replace(/[ÑŃŇŅ]/g, 'N') + .replace(/[òóôõōő]/g, 'o') + .replace(/[ÒÓÔÕŌŐ]/g, 'O') + .replace(/[ŕřŗ]/g, 'r') + .replace(/[ŔŘŖ]/g, 'R') + .replace(/[śšşŝș]/g, 's') + .replace(/[ŚŠŞŜȘ]/g, 'S') + .replace(/[ťţțŧ]/g, 't') + .replace(/[ŤŢȚŦ]/g, 'T') + .replace(/[ùúûūůűų]/g, 'u') + .replace(/[ÙÚÛŪŮŰŲ]/g, 'U') + .replace(/ŵ/g, 'w') + .replace(/Ŵ/g, 'W') + .replace(/[ýÿŷ]/g, 'y') + .replace(/[ÝŸŶ]/g, 'Y') + .replace(/[źżž]/g, 'z') + .replace(/[ŹŻŽ]/g, 'Z') + // Any remaining non-ASCII or non-printable rune + .replace(/[^\x20-\x7E]/g, '?') + ); +} diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index de40b99a18..dfe10f6a1b 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -35,6 +35,7 @@ import { LanguageService } from 'src/shared/models/language/language.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; +import { toBitboxAscii } from 'src/shared/utils/bitbox-ascii.util'; import { PdfUtil } from 'src/shared/utils/pdf.util'; import { Util } from 'src/shared/utils/util'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; @@ -103,6 +104,16 @@ import { RealUnitDevService } from './realunit-dev.service'; import { getAccountHistoryQuery, getAccountSummaryQuery, getHoldersQuery, getTokenInfoQuery } from './utils/queries'; import { TimeseriesUtils } from './utils/timeseries-utils'; +// realunit-app v0.0.3+ transliterates EIP-712 string fields to BitBox-safe +// ASCII (Krüger → Krueger) but keeps the kycData copy in UTF-8 so ID +// verification still sees the legal name with diacritics. Accept either +// representation so registrations from both old and new app versions pass. +function matchesSignedField(kycValue: string | undefined, signedValue: string | undefined): boolean { + if (kycValue === signedValue) return true; + if (kycValue == null || signedValue == null) return false; + return toBitboxAscii(kycValue) === signedValue; +} + @Injectable() export class RealUnitService { private readonly logger = new DfxLogger(RealUnitService); @@ -721,7 +732,7 @@ export class RealUnitService { } // organization name - if (dto.kycData.organizationName !== dto.name) { + if (!matchesSignedField(dto.kycData.organizationName, dto.name)) { throw new BadRequestException('organizationName must match signed name'); } @@ -729,15 +740,15 @@ export class RealUnitService { const combinedOrgAddress = dto.kycData.organizationAddress.houseNumber ? `${dto.kycData.organizationAddress.street} ${dto.kycData.organizationAddress.houseNumber}` : dto.kycData.organizationAddress.street; - if (combinedOrgAddress !== dto.addressStreet) { + if (!matchesSignedField(combinedOrgAddress, dto.addressStreet)) { throw new BadRequestException('organizationAddress street + houseNumber must match signed addressStreet'); } - if (dto.kycData.organizationAddress.zip !== dto.addressPostalCode) { + if (!matchesSignedField(dto.kycData.organizationAddress.zip, dto.addressPostalCode)) { throw new BadRequestException('organizationAddress zip must match signed addressPostalCode'); } - if (dto.kycData.organizationAddress.city !== dto.addressCity) { + if (!matchesSignedField(dto.kycData.organizationAddress.city, dto.addressCity)) { throw new BadRequestException('organizationAddress city must match signed addressCity'); } @@ -752,7 +763,7 @@ export class RealUnitService { // personal name const combinedName = `${dto.kycData.firstName} ${dto.kycData.lastName}`; - if (combinedName !== dto.name) { + if (!matchesSignedField(combinedName, dto.name)) { throw new BadRequestException('firstName + lastName does not match signed name'); } @@ -760,7 +771,7 @@ export class RealUnitService { const combinedAddress = dto.kycData.address.houseNumber ? `${dto.kycData.address.street} ${dto.kycData.address.houseNumber}` : dto.kycData.address.street; - if (combinedAddress !== dto.addressStreet) { + if (!matchesSignedField(combinedAddress, dto.addressStreet)) { throw new BadRequestException('street + houseNumber does not match signed addressStreet'); } } @@ -808,8 +819,24 @@ export class RealUnitService { const signatureToUse = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`; const recoveredAddress = verifyTypedData(domain, types, message, signatureToUse); - - return Util.equalsIgnoreCase(recoveredAddress, data.walletAddress); + if (Util.equalsIgnoreCase(recoveredAddress, data.walletAddress)) return true; + + // Backwards-compat: app v0.0.3+ signs BitBox-safe ASCII. If the stored + // accountData still holds UTF-8 from a pre-transliteration registration, + // retry verify with the same fields transliterated so re-login (add new + // wallet) keeps working for those users. + const asciiMessage = { + ...message, + email: toBitboxAscii(message.email), + name: toBitboxAscii(message.name), + phoneNumber: toBitboxAscii(message.phoneNumber), + birthday: toBitboxAscii(message.birthday), + addressStreet: toBitboxAscii(message.addressStreet), + addressPostalCode: toBitboxAscii(message.addressPostalCode), + addressCity: toBitboxAscii(message.addressCity), + }; + const asciiRecovered = verifyTypedData(domain, types, asciiMessage, signatureToUse); + return Util.equalsIgnoreCase(asciiRecovered, data.walletAddress); } async forwardRegistrationToAktionariat(kycStepId: number): Promise { From b222d3631a6632899c608a3ef27ab953f5afca80 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 16 May 2026 21:05:45 +0200 Subject: [PATCH 06/13] refactor(evm): drop silent fallbacks in token gas estimation (#3698) Remove two implicit defaults in getTokenGasLimitForContact: - the amount parameter is now required; the previous `amount ?? 1` hid the intent at the call site - the try/catch returning a hardcoded 100k gas limit on estimateGas failure is removed; estimation errors now propagate to the caller (existing outer error handlers log and let the affected flow retry on the next cron pass) getTokenGasLimitForAsset passes an explicit 1-wei sample amount, documented as fee-estimation only. --- .../blockchain/shared/evm/evm-client.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index c2a3caa668..5530a984d6 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -184,25 +184,13 @@ export abstract class EvmClient extends BlockchainClient { protected async getTokenGasLimitForAsset(token: Asset): Promise { const contract = this.getERC20ContractForDex(token.chainId); - return this.getTokenGasLimitForContact(contract, this.randomReceiverAddress); - } - - async getTokenGasLimitForContact(contract: Contract, to: string, amount?: EthersNumber): Promise { - // Use actual amount if provided, otherwise use 1 for gas estimation - // Some tokens may have minimum transfer amounts or balance checks that fail with 1 Wei - const estimateAmount = amount ?? 1; - - try { - const gasEstimate = await contract.estimateGas.transfer(to, estimateAmount); - return gasEstimate.mul(12).div(10); - } catch (error) { - // If gas estimation fails (e.g., from EIP-7702 delegated address), use a safe default - // Standard ERC20 transfer is ~65k gas, using 100k as safe upper bound with buffer - this.logger.verbose( - `Gas estimation failed for token transfer to ${to}: ${error.message}. Using default gas limit of 100000`, - ); - return ethers.BigNumber.from(100000); - } + // Sample amount of 1 wei for fee estimation only (no concrete TX context here) + return this.getTokenGasLimitForContact(contract, this.randomReceiverAddress, ethers.BigNumber.from(1)); + } + + async getTokenGasLimitForContact(contract: Contract, to: string, amount: EthersNumber): Promise { + const gasEstimate = await contract.estimateGas.transfer(to, amount); + return gasEstimate.mul(12).div(10); } async prepareTransaction( From 533da4ac6153dbf4b5e2bbe315b78989accf8395 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 18 May 2026 11:06:53 +0200 Subject: [PATCH 07/13] feat(compliance): add manual AML pass endpoints for BuyCrypto and BuyFiat (#3705) * feat(compliance): add manual AML pass endpoints for BuyCrypto and BuyFiat Adds PUT :id/amlCheck/pass endpoints (COMPLIANCE role) that allow a compliance clerk to manually pass a pending AML check when all errors on the transaction comment are on the whitelist (phone-/referral-/ country-related). The ManualPassWhitelistErrors list and canManualPass helper are kept in sync with packages/core/src/definitions/compliance.ts. * refactor(compliance): apply review feedback on manual AML check endpoint - merge endpoint into PUT :id/amlCheck (drop /pass suffix) - add amlCheck field to DTO and gate canManualPass on PASS only - centralize DTO under aml/dto and rename to ManualAmlCheckDto * refactor(compliance): tighten manual AML check preconditions - reject when entity is complete or chargeback initiated by user - forbid only finalized amlCheck (PASS/FAIL) instead of allowing only PENDING * refactor(compliance): set amlReason and priceDefinitionAllowedDate on manual PASS - add optional amlReason field to ManualAmlCheckDto - on PASS: force amlReason to NA and stamp priceDefinitionAllowedDate - on non-PASS: forward optional amlReason from DTO --- .../core/aml/dto/manual-aml-check.dto.ts | 17 +++++++++++++++ .../core/aml/enums/aml-error.enum.ts | 21 +++++++++++++++++++ .../process/buy-crypto.controller.ts | 9 ++++++++ .../process/services/buy-crypto.service.ts | 20 ++++++++++++++++++ .../process/buy-fiat.controller.ts | 9 ++++++++ .../process/services/buy-fiat.service.ts | 20 ++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 src/subdomains/core/aml/dto/manual-aml-check.dto.ts diff --git a/src/subdomains/core/aml/dto/manual-aml-check.dto.ts b/src/subdomains/core/aml/dto/manual-aml-check.dto.ts new file mode 100644 index 0000000000..9829bcc211 --- /dev/null +++ b/src/subdomains/core/aml/dto/manual-aml-check.dto.ts @@ -0,0 +1,17 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { AmlReason } from '../enums/aml-reason.enum'; +import { CheckStatus } from '../enums/check-status.enum'; + +export class ManualAmlCheckDto { + @IsNotEmpty() + @IsEnum(CheckStatus) + amlCheck: CheckStatus; + + @IsOptional() + @IsEnum(AmlReason) + amlReason?: AmlReason; + + @IsNotEmpty() + @IsString() + responsible: string; +} diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 30c74e084c..43ecce0e8d 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -83,6 +83,27 @@ export const DelayResultError = [ AmlError.BANK_RELEASE_DATE_MISSING, ]; +// Keep in sync with packages/core/src/definitions/compliance.ts (ManualPassWhitelistErrors / canManualPass) +export const ManualPassWhitelistErrors: AmlError[] = [ + AmlError.PHONE_VERIFICATION_NEEDED, + AmlError.IP_PHONE_VERIFICATION_NEEDED, + AmlError.BIC_PHONE_VERIFICATION_NEEDED, + AmlError.IBAN_PHONE_VERIFICATION_NEEDED, + AmlError.IP_COUNTRY_MISMATCH, + AmlError.TRADE_APPROVAL_DATE_MISSING, + AmlError.USER_DATA_FAILED_CALL, + AmlError.USER_DATA_REJECTED_CALL, + AmlError.REFERRAL_NO_TRADE_HISTORY, +]; + +export function canManualPass(comment: string | null | undefined): boolean { + const errors = (comment ?? '') + .split(';') + .map((e) => e.trim()) + .filter(Boolean); + return errors.length > 0 && errors.every((e) => ManualPassWhitelistErrors.includes(e as AmlError)); +} + export enum AmlErrorType { SINGLE = 'Single', // Only one error may occur MULTI = 'Multi', // All errors must have the same amlCheck diff --git a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts index 6e36bef904..282687d3d3 100644 --- a/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts +++ b/src/subdomains/core/buy-crypto/process/buy-crypto.controller.ts @@ -5,6 +5,7 @@ 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 { RefundInternalDto } from '../../history/dto/refund-internal.dto'; +import { ManualAmlCheckDto } from '../../aml/dto/manual-aml-check.dto'; import { UpdateBuyCryptoDto } from './dto/update-buy-crypto.dto'; import { BuyCrypto } from './entities/buy-crypto.entity'; import { BuyCryptoWebhookService } from './services/buy-crypto-webhook.service'; @@ -65,4 +66,12 @@ export class BuyCryptoController { async resetAmlCheck(@Param('id') id: string): Promise { return this.buyCryptoService.resetAmlCheck(+id); } + + @Put(':id/amlCheck') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async manualPassAmlCheck(@Param('id') id: string, @Body() dto: ManualAmlCheckDto): Promise { + return this.buyCryptoService.manualPassAmlCheck(+id, dto); + } } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index bd974e7fdb..50664c7b23 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -53,12 +53,14 @@ import { TransactionRequestService } from 'src/subdomains/supporting/payment/ser import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { Between, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { canManualPass } from '../../../aml/enums/aml-error.enum'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { Buy } from '../../routes/buy/buy.entity'; import { BuyRepository } from '../../routes/buy/buy.repository'; import { BuyService } from '../../routes/buy/buy.service'; import { BuyHistoryDto } from '../../routes/buy/dto/buy-history.dto'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyCryptoDto } from '../dto/update-buy-crypto.dto'; import { BuyCrypto, BuyCryptoEditableAmlCheck, BuyCryptoStatus } from '../entities/buy-crypto.entity'; import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; @@ -682,6 +684,24 @@ export class BuyCryptoService { if (fiatOutputId) await this.fiatOutputService.delete(fiatOutputId); } + async manualPassAmlCheck(id: number, dto: ManualAmlCheckDto): Promise { + const entity = await this.buyCryptoRepo.findOneBy({ id }); + if (!entity) throw new NotFoundException('BuyCrypto not found'); + if (entity.isComplete || entity.chargebackAllowedDateUser) + throw new BadRequestException('BuyCrypto is already complete or chargeback initiated'); + if ([CheckStatus.PASS, CheckStatus.FAIL].includes(entity.amlCheck)) + throw new BadRequestException('BuyCrypto amlCheck is already finalized'); + if (dto.amlCheck === CheckStatus.PASS && !canManualPass(entity.comment)) + throw new BadRequestException('Manual pass only allowed when all errors are phone-related'); + + return this.update(id, { + amlCheck: dto.amlCheck, + amlResponsible: dto.responsible, + amlReason: dto.amlCheck === CheckStatus.PASS ? AmlReason.NA : dto.amlReason, + priceDefinitionAllowedDate: dto.amlCheck === CheckStatus.PASS ? new Date() : undefined, + } as UpdateBuyCryptoDto); + } + async getUserVolume( userIds: number[], dateFrom: Date = new Date(0), diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts index 3d2d7a0859..c3ba8195cb 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.controller.ts @@ -6,6 +6,7 @@ import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { RefundInternalDto } from '../../history/dto/refund-internal.dto'; import { BuyFiat } from './buy-fiat.entity'; +import { ManualAmlCheckDto } from '../../aml/dto/manual-aml-check.dto'; import { UpdateBuyFiatDto } from './dto/update-buy-fiat.dto'; import { BuyFiatService } from './services/buy-fiat.service'; @@ -61,4 +62,12 @@ export class BuyFiatController { async resetAmlCheck(@Param('id') id: string): Promise { return this.buyFiatService.resetAmlCheck(+id); } + + @Put(':id/amlCheck') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + async manualPassAmlCheck(@Param('id') id: string, @Body() dto: ManualAmlCheckDto): Promise { + return this.buyFiatService.manualPassAmlCheck(+id, dto); + } } diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 925582e953..a8cdcb6434 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -27,6 +27,7 @@ import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/su import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; import { Between, FindOptionsRelations, In, MoreThan } from 'typeorm'; import { FiatOutputService } from '../../../../supporting/fiat-output/fiat-output.service'; +import { canManualPass } from '../../../aml/enums/aml-error.enum'; import { AmlReason } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { BuyCryptoService } from '../../../buy-crypto/process/services/buy-crypto.service'; @@ -39,6 +40,7 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; @@ -415,6 +417,24 @@ export class BuyFiatService { } } + async manualPassAmlCheck(id: number, dto: ManualAmlCheckDto): Promise { + const entity = await this.buyFiatRepo.findOneBy({ id }); + if (!entity) throw new NotFoundException('BuyFiat not found'); + if (entity.isComplete || entity.chargebackAllowedDateUser) + throw new BadRequestException('BuyFiat is already complete or chargeback initiated'); + if ([CheckStatus.PASS, CheckStatus.FAIL].includes(entity.amlCheck)) + throw new BadRequestException('BuyFiat amlCheck is already finalized'); + if (dto.amlCheck === CheckStatus.PASS && !canManualPass(entity.comment)) + throw new BadRequestException('Manual pass only allowed when all errors are phone-related'); + + return this.update(id, { + amlCheck: dto.amlCheck, + amlResponsible: dto.responsible, + amlReason: dto.amlCheck === CheckStatus.PASS ? AmlReason.NA : dto.amlReason, + priceDefinitionAllowedDate: dto.amlCheck === CheckStatus.PASS ? new Date() : undefined, + } as UpdateBuyFiatDto); + } + async updateVolumes(start = 1, end = 100000): Promise { const sellIds = await this.buyFiatRepo .find({ From e3f2129c5b677e1990ee0b1e6dc5cb1e31706fcd Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 18 May 2026 14:10:19 +0200 Subject: [PATCH 08/13] feat(support): expose sell deposit address and blockchains (DEV-4570) (#3720) * feat(support): expose sell deposit address and blockchains Adds depositAddress, depositBlockchains and depositAddressExplorerUrl to SellSupportInfo so customer search shows the user's deposit data. * fix(support): only set deposit explorer URL when chain is unique For EVM multi-chain deposits the first blockchain was misleadingly returning an Ethereum URL while the address also applies to Arbitrum, Optimism, Polygon, etc. The URL is now only emitted when the deposit maps to exactly one blockchain. --- .../core/sell-crypto/route/sell.service.ts | 2 +- .../generic/support/dto/user-data-support.dto.ts | 3 +++ src/subdomains/generic/support/support.service.ts | 13 ++++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/subdomains/core/sell-crypto/route/sell.service.ts b/src/subdomains/core/sell-crypto/route/sell.service.ts index 4d069f71e0..2e2bf2fc7f 100644 --- a/src/subdomains/core/sell-crypto/route/sell.service.ts +++ b/src/subdomains/core/sell-crypto/route/sell.service.ts @@ -135,7 +135,7 @@ export class SellService { async getSellsByUserDataId(userDataId: number): Promise { return this.sellRepo.find({ where: { user: { userData: { id: userDataId } } }, - relations: { fiat: true, user: true }, + relations: { fiat: true, user: true, deposit: true }, }); } 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 8eab2de677..5d8552cbff 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -248,6 +248,9 @@ export class SellSupportInfo { id: number; iban: string; fiatName?: string; + depositAddress?: string; + depositBlockchains?: string[]; + depositAddressExplorerUrl?: string; volume: number; active: boolean; created: Date; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index b026fdc426..019a628908 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -584,10 +584,17 @@ export class SupportService { } private toSellSupportInfo(sell: Sell): SellSupportInfo { + const depositBlockchains = sell.deposit?.blockchainList; return { id: sell.id, iban: sell.iban, fiatName: sell.fiat?.name, + depositAddress: sell.deposit?.address, + depositBlockchains, + depositAddressExplorerUrl: + depositBlockchains?.length === 1 && sell.deposit?.address + ? addressExplorerUrl(depositBlockchains[0], sell.deposit.address) + : undefined, volume: sell.annualVolume, active: sell.active, created: sell.created, @@ -595,15 +602,15 @@ export class SupportService { } private toSwapSupportInfo(swap: Swap): SwapSupportInfo { - const depositBlockchain = swap.deposit?.blockchainList?.[0]; + const depositBlockchains = swap.deposit?.blockchainList; return { id: swap.id, assetName: swap.asset?.name, blockchain: swap.asset?.blockchain, depositAddress: swap.deposit?.address, depositAddressExplorerUrl: - depositBlockchain && swap.deposit?.address - ? addressExplorerUrl(depositBlockchain, swap.deposit.address) + depositBlockchains?.length === 1 && swap.deposit?.address + ? addressExplorerUrl(depositBlockchains[0], swap.deposit.address) : undefined, volume: swap.volume, annualVolume: swap.annualVolume, From 1379568818326dc193dd9922ab4a48be305a1d0b Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 18 May 2026 16:27:13 +0200 Subject: [PATCH 09/13] [NOTASK] dfxApproval expiredStep bug (#3721) --- src/subdomains/generic/kyc/services/kyc.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 17583caad7..0458f5a471 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -384,6 +384,9 @@ export class KycService { } async checkDfxApproval(userData: UserData, kycStep?: KycStep): Promise { + const missingCompletedSteps = requiredKycSteps(userData).filter((rs) => !userData.hasCompletedStep(rs)); + if (!missingCompletedSteps.includes(KycStepName.DFX_APPROVAL)) return; + const expiredSteps = [ ...userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_AUTO), ...userData.getStepsWith(KycStepName.IDENT, KycStepType.AUTO), @@ -408,8 +411,6 @@ export class KycService { return this.kycNotificationService.kycStepReminder(userData); } - const missingCompletedSteps = requiredKycSteps(userData).filter((rs) => !userData.hasCompletedStep(rs)); - if ( (missingCompletedSteps.length === 2 && missingCompletedSteps.every((s) => s === kycStep?.name || s === KycStepName.DFX_APPROVAL)) || From cd29b60bcbbac6871952676562e937f05841749e Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 18 May 2026 16:40:30 +0200 Subject: [PATCH 10/13] [NOTASK] phoneCall auto aml reset (#3656) * [NOTASK] phoneCall auto aml reset * [NOTASK] Refactoring * [NOTASK] Refactoring 2 * [NOTASK] fix unit test * [NOTASK] Refactoring 3 * [NOTASK] Refactoring 4 --- .../core/aml/enums/aml-reason.enum.ts | 8 +++++ .../__tests__/buy-crypto.service.spec.ts | 4 +++ .../process/services/buy-crypto.service.ts | 33 +++++++++++++++-- .../process/services/buy-fiat.service.ts | 36 ++++++++++++++++--- .../user/models/user-data/user-data.entity.ts | 1 + .../models/user-data/user-data.service.ts | 18 +++++++++- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index 21d376269b..a38d10c1dc 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -57,6 +57,14 @@ export const KycAmlReasons = [ AmlReason.KYC_DATA_NEEDED, ]; +export const PhoneAmlReasons = [ + AmlReason.MANUAL_CHECK_PHONE, + AmlReason.MANUAL_CHECK_PHONE_FAILED, + AmlReason.MANUAL_CHECK_IP_PHONE, + AmlReason.MANUAL_CHECK_IP_COUNTRY_PHONE, + AmlReason.MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE, +]; + export const RecheckAmlReasons = [ AmlReason.MANUAL_CHECK_PHONE, AmlReason.MANUAL_CHECK_IP_PHONE, diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index fd14a996f5..37107e5730 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -13,6 +13,7 @@ import { createCustomHistory } from 'src/subdomains/core/history/dto/__mocks__/h import { BuyFiatService } from 'src/subdomains/core/sell-crypto/process/services/buy-fiat.service'; import { TransactionUtilService } from 'src/subdomains/core/transaction/transaction-util.service'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; @@ -69,6 +70,7 @@ describe('BuyCryptoService', () => { let amlService: AmlService; let transactionHelper: TransactionHelper; let custodyOrderService: CustodyOrderService; + let userDataService: UserDataService; beforeEach(async () => { buyCryptoRepo = createMock(); @@ -95,6 +97,7 @@ describe('BuyCryptoService', () => { amlService = createMock(); transactionHelper = createMock(); custodyOrderService = createMock(); + userDataService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -124,6 +127,7 @@ describe('BuyCryptoService', () => { { provide: AmlService, useValue: amlService }, { provide: TransactionHelper, useValue: transactionHelper }, { provide: CustodyOrderService, useValue: custodyOrderService }, + { provide: UserDataService, useValue: userDataService }, ], }).compile(); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 50664c7b23..9581409693 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -5,6 +5,7 @@ import { Inject, Injectable, NotFoundException, + OnModuleInit, } from '@nestjs/common'; import { Config } from 'src/config/config'; import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; @@ -35,6 +36,8 @@ import { TransactionUtilService } from 'src/subdomains/core/transaction/transact import { BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { CreateBankDataDto } from 'src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; @@ -53,14 +56,14 @@ import { TransactionRequestService } from 'src/subdomains/supporting/payment/ser import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { Between, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { canManualPass } from '../../../aml/enums/aml-error.enum'; -import { AmlReason } from '../../../aml/enums/aml-reason.enum'; +import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { Buy } from '../../routes/buy/buy.entity'; import { BuyRepository } from '../../routes/buy/buy.repository'; import { BuyService } from '../../routes/buy/buy.service'; import { BuyHistoryDto } from '../../routes/buy/dto/buy-history.dto'; -import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyCryptoDto } from '../dto/update-buy-crypto.dto'; import { BuyCrypto, BuyCryptoEditableAmlCheck, BuyCryptoStatus } from '../entities/buy-crypto.entity'; import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; @@ -68,7 +71,7 @@ import { BuyCryptoNotificationService } from './buy-crypto-notification.service' import { BuyCryptoWebhookService } from './buy-crypto-webhook.service'; @Injectable() -export class BuyCryptoService { +export class BuyCryptoService implements OnModuleInit { constructor( private readonly buyCryptoRepo: BuyCryptoRepository, private readonly buyRepo: BuyRepository, @@ -103,8 +106,27 @@ export class BuyCryptoService { @Inject(forwardRef(() => TransactionHelper)) private readonly transactionHelper: TransactionHelper, private readonly custodyOrderService: CustodyOrderService, + private readonly userDataService: UserDataService, ) {} + onModuleInit() { + this.userDataService.phoneCallCompletedObservable.subscribe((userData) => this.checkAmlResetTx(userData)); + } + + async checkAmlResetTx(userData: UserData): Promise { + const entities = await this.buyCryptoRepo.findBy({ + transaction: { userData: { id: userData.id } }, + amlCheck: CheckStatus.FAIL, + amlReason: In(PhoneAmlReasons), + isComplete: false, + chargebackAllowedDate: IsNull(), + }); + + for (const entity of entities) { + await this.resetAmlCheckInternal(entity); + } + } + async createFromBankTx(bankTx: BankTx, buyId: number): Promise { let entity = await this.buyCryptoRepo.findOneBy({ bankTx: { id: bankTx.id } }); if (entity) throw new ConflictException('There is already a buy-crypto for the specified bank TX'); @@ -672,6 +694,11 @@ export class BuyCryptoService { async resetAmlCheck(id: number): Promise { const entity = await this.buyCryptoRepo.findOne({ where: { id }, relations: { chargebackOutput: true } }); if (!entity) throw new NotFoundException('BuyCrypto not found'); + + await this.resetAmlCheckInternal(entity); + } + + async resetAmlCheckInternal(entity: BuyCrypto): Promise { if (entity.isComplete || entity.batch || entity.chargebackOutput?.isComplete || entity.chargebackAllowedDate) throw new BadRequestException('BuyCrypto is already complete or payout initiated'); if (!entity.amlCheck) throw new BadRequestException('BuyCrypto AML check is not set'); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index a8cdcb6434..e1b575a208 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; @@ -11,6 +11,8 @@ import { TransactionUtilService } from 'src/subdomains/core/transaction/transact import { BankDataType } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; import { BankDataService } from 'src/subdomains/generic/user/models/bank-data/bank-data.service'; import { CreateBankDataDto } from 'src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; @@ -25,10 +27,11 @@ import { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/pa import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; -import { Between, FindOptionsRelations, In, MoreThan } from 'typeorm'; +import { Between, FindOptionsRelations, In, IsNull, MoreThan } from 'typeorm'; import { FiatOutputService } from '../../../../supporting/fiat-output/fiat-output.service'; +import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { canManualPass } from '../../../aml/enums/aml-error.enum'; -import { AmlReason } from '../../../aml/enums/aml-reason.enum'; +import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { BuyCryptoService } from '../../../buy-crypto/process/services/buy-crypto.service'; import { PaymentStatus } from '../../../history/dto/history.dto'; @@ -40,12 +43,11 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; -import { ManualAmlCheckDto } from '../../../aml/dto/manual-aml-check.dto'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; @Injectable() -export class BuyFiatService { +export class BuyFiatService implements OnModuleInit { constructor( private readonly buyFiatRepo: BuyFiatRepository, @Inject(forwardRef(() => BuyCryptoService)) @@ -74,8 +76,27 @@ export class BuyFiatService { private readonly custodyOrderService: CustodyOrderService, private readonly supportLogService: SupportLogService, private readonly payoutService: PayoutService, + private readonly userDataService: UserDataService, ) {} + onModuleInit() { + this.userDataService.phoneCallCompletedObservable.subscribe((userData) => this.checkAmlResetTx(userData)); + } + + async checkAmlResetTx(userData: UserData): Promise { + const entities = await this.buyFiatRepo.findBy({ + transaction: { userData: { id: userData.id } }, + amlCheck: CheckStatus.FAIL, + amlReason: In(PhoneAmlReasons), + isComplete: false, + chargebackAllowedDate: IsNull(), + }); + + for (const entity of entities) { + await this.resetAmlCheckInternal(entity); + } + } + async createFromCryptoInput(cryptoInput: CryptoInput, sell: Sell, request?: TransactionRequest): Promise { let entity = this.buyFiatRepo.create({ cryptoInput, @@ -388,6 +409,11 @@ export class BuyFiatService { relations: { fiatOutput: true, transaction: { userData: true }, outputAsset: true }, }); if (!entity) throw new NotFoundException('BuyFiat not found'); + + await this.resetAmlCheckInternal(entity); + } + + async resetAmlCheckInternal(entity: BuyFiat): Promise { if (entity.isComplete || entity.fiatOutput?.isComplete || entity.chargebackAllowedDate) throw new BadRequestException('BuyFiat is already complete'); if (!entity.amlCheck) throw new BadRequestException('BuyFiat amlcheck is not set'); diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index c2ea546350..1c420ce0e1 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -841,6 +841,7 @@ export const UserDataComplianceUpdateCols = [ 'phoneCallIpCheckDate', 'phoneCallIpCountryCheckDate', 'phoneCallExternalAccountCheckDate', + 'phoneCallExternalAccountCheckValue', ]; export function KycCompleted(kycStatus?: KycStatus): boolean { diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index f235f41942..760eac6113 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -11,6 +11,7 @@ import { import { CronExpression } from '@nestjs/schedule'; import { randomUUID } from 'crypto'; import JSZip from 'jszip'; +import { Observable, Subject } from 'rxjs'; import { Config } from 'src/config/config'; import { CreateAccount } from 'src/integration/sift/dto/sift.dto'; import { SiftService } from 'src/integration/sift/services/sift.service'; @@ -82,6 +83,7 @@ export class UserDataService { private readonly logger = new DfxLogger(UserDataService); private readonly secretCache: Map = new Map(); + private readonly phoneCallCompletedSubject: Subject = new Subject(); constructor( private readonly repos: RepositoryFactory, @@ -116,6 +118,10 @@ export class UserDataService { ) {} // --- GETTERS --- // + get phoneCallCompletedObservable(): Observable { + return this.phoneCallCompletedSubject.asObservable(); + } + async getUserDataByUser(userId: number): Promise { return this.userDataRepo .createQueryBuilder('userData') @@ -305,7 +311,11 @@ export class UserDataService { async updateUserData(userDataId: number, dto: UpdateUserDataDto): Promise { const userData = await this.userDataRepo.findOne({ where: { id: userDataId }, - relations: { users: { wallet: true }, kycSteps: true, wallet: true }, + relations: { + users: { wallet: true }, + kycSteps: true, + wallet: true, + }, }); if (!userData) throw new NotFoundException('User data not found'); @@ -314,6 +324,12 @@ export class UserDataService { if (dto.phoneCallExternalAccountCheckValue) userData.addPhoneCallExternalAccountCheckValue(dto.phoneCallExternalAccountCheckValue); + if ( + dto.phoneCallStatus === PhoneCallStatus.COMPLETED && + [PhoneCallStatus.FAILED, PhoneCallStatus.USER_REJECTED].includes(userData.phoneCallStatus) + ) + this.phoneCallCompletedSubject.next(userData); + if (dto.bankTransactionVerification === CheckStatus.PASS) { // cancel a pending video ident, if ident is completed const identCompleted = userData.hasCompletedStep(KycStepName.IDENT); From f0e827102be9f121a89314c8087bbeebf60a0cee Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 19 May 2026 08:33:26 +0200 Subject: [PATCH 11/13] feat(support): add support notes for user data (#3719) * feat(support): add support notes for user data * refactor(support): address review feedback on notes - Drop redundant userDataId column on SupportNote; rely on relation FK - Move @Index() onto userData property - Switch SupportNoteScope to PascalCase enum values - Consolidate getNotes/listNotes into a single GET /support/note endpoint - Replace QueryBuilder with repo.find in search/update paths * chore(support): regenerate support note migration via typeorm --- migration/1779096542464-AddSupportNode.js | 34 ++++ .../generic/support/dto/support-note.dto.ts | 77 +++++++ .../support/dto/user-data-support.dto.ts | 2 + .../support/entities/support-note.entity.ts | 26 +++ .../repositories/support-note.repository.ts | 11 + .../support/services/support-note.service.ts | 192 ++++++++++++++++++ .../generic/support/support.controller.ts | 63 +++++- .../generic/support/support.module.ts | 7 +- .../generic/support/support.service.ts | 7 +- .../support-issue/enums/department.enum.ts | 1 + 10 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 migration/1779096542464-AddSupportNode.js create mode 100644 src/subdomains/generic/support/dto/support-note.dto.ts create mode 100644 src/subdomains/generic/support/entities/support-note.entity.ts create mode 100644 src/subdomains/generic/support/repositories/support-note.repository.ts create mode 100644 src/subdomains/generic/support/services/support-note.service.ts diff --git a/migration/1779096542464-AddSupportNode.js b/migration/1779096542464-AddSupportNode.js new file mode 100644 index 0000000000..d5d9000600 --- /dev/null +++ b/migration/1779096542464-AddSupportNode.js @@ -0,0 +1,34 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddSupportNode1779096542464 { + name = 'AddSupportNode1779096542464'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query( + `CREATE TABLE "support_note" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_0808df6f9b73cd053eabaa41038" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_44fc0bddcb58532cdb9218636fd" DEFAULT getdate(), "userDataId" int, "department" nvarchar(256) NOT NULL, "authorId" int NOT NULL, "authorMail" nvarchar(256) NOT NULL, "subject" nvarchar(256), "content" nvarchar(MAX) NOT NULL, CONSTRAINT "PK_50d71a126818b986dfd11f3cc4c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`CREATE INDEX "IDX_1824182fbb53ca85959eedda54" ON "support_note" ("userDataId") `); + await queryRunner.query( + `ALTER TABLE "support_note" ADD CONSTRAINT "FK_1824182fbb53ca85959eedda54d" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "support_note" DROP CONSTRAINT "FK_1824182fbb53ca85959eedda54d"`); + await queryRunner.query(`DROP INDEX "IDX_1824182fbb53ca85959eedda54" ON "support_note"`); + await queryRunner.query(`DROP TABLE "support_note"`); + } +}; diff --git a/src/subdomains/generic/support/dto/support-note.dto.ts b/src/subdomains/generic/support/dto/support-note.dto.ts new file mode 100644 index 0000000000..cb17f65c51 --- /dev/null +++ b/src/subdomains/generic/support/dto/support-note.dto.ts @@ -0,0 +1,77 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, MaxLength } from 'class-validator'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; + +export class SupportNoteDto { + id: number; + department: Department; + authorMail: string; + subject?: string; + content: string; + userDataId?: number; + userName?: string; + isOwn: boolean; + isAdmin: boolean; + created: Date; + updated: Date; +} + +export class CreateSupportNoteDto { + @IsOptional() + @IsInt() + userDataId?: number; + + @IsOptional() + @IsString() + @MaxLength(256) + subject?: string; + + @IsString() + @MaxLength(8000) + content: string; + + // Only honored when the caller is ADMIN. For other roles the department is + // derived from the role via RoleDepartmentMap. + @IsOptional() + @IsEnum(Department) + department?: Department; +} + +export class UpdateSupportNoteDto { + @IsOptional() + @IsString() + @MaxLength(256) + subject?: string; + + @IsString() + @MaxLength(8000) + content: string; +} + +export enum SupportNoteScope { + ALL = 'All', + FREE = 'Free', + BOUND = 'Bound', +} + +export class SupportNoteListQuery { + @IsOptional() + @IsString() + @MaxLength(256) + search?: string; + + @IsOptional() + @IsEnum(SupportNoteScope) + scope?: SupportNoteScope; + + @IsOptional() + @Type(() => Number) + @IsInt() + userDataId?: number; +} + +export class SupportNoteUserDto { + userDataId: number; + name: string; + count: number; +} 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 5d8552cbff..7d634ceaa2 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -5,6 +5,7 @@ import { BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/b import { RecallReason } from 'src/subdomains/supporting/recall/recall-reason.enum'; import { KycFile } from '../../kyc/entities/kyc-file.entity'; import { AccountType } from '../../user/models/user-data/account-type.enum'; +import { SupportNoteDto } from './support-note.dto'; import { KycIdentificationType } from '../../user/models/user-data/kyc-identification-type.enum'; import { KycLevel, @@ -576,6 +577,7 @@ export class UserDataSupportInfoDetails { virtualIbans: VirtualIbanSupportInfo[]; refRewards: RefRewardSupportInfo[]; notifications: NotificationSupportInfo[]; + notes: SupportNoteDto[]; permissions: SupportPermissions; } diff --git a/src/subdomains/generic/support/entities/support-note.entity.ts b/src/subdomains/generic/support/entities/support-note.entity.ts new file mode 100644 index 0000000000..8d41ebe255 --- /dev/null +++ b/src/subdomains/generic/support/entities/support-note.entity.ts @@ -0,0 +1,26 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { UserData } from '../../user/models/user-data/user-data.entity'; + +@Entity() +export class SupportNote extends IEntity { + @Index() + @ManyToOne(() => UserData, { nullable: true }) + userData?: UserData; + + @Column({ length: 256 }) + department: Department; + + @Column({ type: 'int' }) + authorId: number; + + @Column({ length: 256 }) + authorMail: string; + + @Column({ length: 256, nullable: true }) + subject?: string; + + @Column({ length: 'MAX' }) + content: string; +} diff --git a/src/subdomains/generic/support/repositories/support-note.repository.ts b/src/subdomains/generic/support/repositories/support-note.repository.ts new file mode 100644 index 0000000000..2e4eb74beb --- /dev/null +++ b/src/subdomains/generic/support/repositories/support-note.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { SupportNote } from '../entities/support-note.entity'; + +@Injectable() +export class SupportNoteRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(SupportNote, manager); + } +} diff --git a/src/subdomains/generic/support/services/support-note.service.ts b/src/subdomains/generic/support/services/support-note.service.ts new file mode 100644 index 0000000000..17eac7fbee --- /dev/null +++ b/src/subdomains/generic/support/services/support-note.service.ts @@ -0,0 +1,192 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { Department, RoleDepartmentMap } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { FindOptionsWhere, In, IsNull, Like, Not } from 'typeorm'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { UserDataService } from '../../user/models/user-data/user-data.service'; +import { + CreateSupportNoteDto, + SupportNoteDto, + SupportNoteListQuery, + SupportNoteScope, + SupportNoteUserDto, + UpdateSupportNoteDto, +} from '../dto/support-note.dto'; +import { SupportNote } from '../entities/support-note.entity'; +import { SupportNoteRepository } from '../repositories/support-note.repository'; + +const ADMIN_DEPARTMENTS: Department[] = [Department.SUPPORT, Department.COMPLIANCE, Department.MARKETING]; +const SEARCH_LIMIT = 200; + +export function visibleDepartments(role: UserRole): Department[] { + if (role === UserRole.ADMIN) return ADMIN_DEPARTMENTS; + const dept = RoleDepartmentMap[role]; + return dept ? [dept] : []; +} + +@Injectable() +export class SupportNoteService { + constructor( + private readonly noteRepo: SupportNoteRepository, + private readonly userDataService: UserDataService, + ) {} + + async search(role: UserRole, query: SupportNoteListQuery): Promise { + const departments = visibleDepartments(role); + if (departments.length === 0) return []; + + const scope = query.scope ?? SupportNoteScope.ALL; + const baseFilter: FindOptionsWhere = { department: In(departments) }; + + if (scope === SupportNoteScope.FREE) { + baseFilter.userData = IsNull(); + } else if (query.userDataId != null) { + baseFilter.userData = { id: query.userDataId }; + } else if (scope === SupportNoteScope.BOUND) { + baseFilter.userData = { id: Not(IsNull()) }; + } + + const search = query.search?.trim(); + const findOptions = { + relations: { userData: true }, + order: { created: 'DESC' as const }, + take: SEARCH_LIMIT, + }; + + if (!search) return this.noteRepo.find({ where: baseFilter, ...findOptions }); + + const pattern = `%${search}%`; + const userDataWhere = (extra: FindOptionsWhere): FindOptionsWhere => ({ + ...((baseFilter.userData ?? {}) as FindOptionsWhere), + ...extra, + }); + const where: FindOptionsWhere[] = [ + { ...baseFilter, subject: Like(pattern) }, + { ...baseFilter, content: Like(pattern) }, + ...(scope === SupportNoteScope.FREE + ? [] + : [ + { ...baseFilter, userData: userDataWhere({ firstname: Like(pattern) }) }, + { ...baseFilter, userData: userDataWhere({ surname: Like(pattern) }) }, + { ...baseFilter, userData: userDataWhere({ organizationName: Like(pattern) }) }, + ]), + ]; + + return this.noteRepo.find({ where, ...findOptions }); + } + + async listUsers(role: UserRole): Promise { + const departments = visibleDepartments(role); + if (departments.length === 0) return []; + + const rows: Array<{ + userDataId: number; + firstname?: string; + surname?: string; + organizationName?: string; + count: string; + }> = await this.noteRepo + .createQueryBuilder('note') + .innerJoin('note.userData', 'userData') + .select('note.userDataId', 'userDataId') + .addSelect('userData.firstname', 'firstname') + .addSelect('userData.surname', 'surname') + .addSelect('userData.organizationName', 'organizationName') + .addSelect('COUNT(note.id)', 'count') + .where('note.department IN (:...departments)', { departments }) + .andWhere('note.userDataId IS NOT NULL') + .groupBy('note.userDataId') + .addGroupBy('userData.firstname') + .addGroupBy('userData.surname') + .addGroupBy('userData.organizationName') + .getRawMany(); + + return rows + .map((r) => ({ + userDataId: r.userDataId, + name: r.organizationName ?? [r.firstname, r.surname].filter(Boolean).join(' ') ?? '', + count: Number(r.count), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async create(role: UserRole, jwtAccount: number, dto: CreateSupportNoteDto): Promise { + const department = this.resolveDepartmentForCreate(role, dto.department); + + const userData = dto.userDataId ? await this.userDataService.getUserData(dto.userDataId) : undefined; + if (dto.userDataId && !userData) throw new NotFoundException('User not found'); + + const author = await this.userDataService.getUserData(jwtAccount); + if (!author) throw new ForbiddenException('Author user data not found'); + + return this.noteRepo.save( + this.noteRepo.create({ + userData, + department, + authorId: jwtAccount, + authorMail: author.mail ?? `userData#${jwtAccount}`, + subject: dto.subject, + content: dto.content, + }), + ); + } + + async update(id: number, role: UserRole, jwtAccount: number, dto: UpdateSupportNoteDto): Promise { + const note = await this.noteRepo.findOne({ where: { id }, relations: { userData: true } }); + if (!note) throw new NotFoundException('Note not found'); + + if (!this.canModify(note, role, jwtAccount)) { + throw new ForbiddenException('Only the author or an admin can edit this note'); + } + + note.content = dto.content; + note.subject = dto.subject; + return this.noteRepo.save(note); + } + + async delete(id: number, role: UserRole, jwtAccount: number): Promise { + const note = await this.noteRepo.findOneBy({ id }); + if (!note) throw new NotFoundException('Note not found'); + + if (!this.canModify(note, role, jwtAccount)) { + throw new ForbiddenException('Only the author or an admin can delete this note'); + } + + await this.noteRepo.remove(note); + } + + toDto(note: SupportNote, role: UserRole, jwtAccount: number): SupportNoteDto { + return { + id: note.id, + department: note.department, + authorMail: note.authorMail, + subject: note.subject, + content: note.content, + userDataId: note.userData?.id, + userName: note.userData?.completeName, + isOwn: note.authorId === jwtAccount, + isAdmin: role === UserRole.ADMIN, + created: note.created, + updated: note.updated, + }; + } + + private resolveDepartmentForCreate(role: UserRole, requested: Department | undefined): Department { + if (role === UserRole.ADMIN) { + if (!requested) throw new ForbiddenException('Department is required when creating notes as admin'); + if (!ADMIN_DEPARTMENTS.includes(requested)) { + throw new ForbiddenException(`Department ${requested} cannot be assigned to a note`); + } + return requested; + } + + const dept = RoleDepartmentMap[role]; + if (!dept) throw new ForbiddenException('Role is not allowed to create notes'); + return dept; + } + + private canModify(note: SupportNote, role: UserRole, jwtAccount: number): boolean { + if (role === UserRole.ADMIN) return true; + return note.authorId === jwtAccount; + } +} diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index 136aa29a89..4a084f48de 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, NotFoundException, Param, @@ -21,6 +22,13 @@ import { RefundDataDto } from 'src/subdomains/core/history/dto/refund-data.dto'; import { ChargebackRefundDto } from 'src/subdomains/core/history/dto/transaction-refund.dto'; import { ReviewStatus } from '../kyc/enums/review-status.enum'; import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; +import { + CreateSupportNoteDto, + SupportNoteDto, + SupportNoteListQuery, + SupportNoteUserDto, + UpdateSupportNoteDto, +} from './dto/support-note.dto'; import { TransactionListQuery } from './dto/transaction-list-query.dto'; import { CallQueue, @@ -39,11 +47,15 @@ import { UserDataSupportInfoResult, UserDataSupportQuery, } from './dto/user-data-support.dto'; +import { SupportNoteService } from './services/support-note.service'; import { SupportService } from './support.service'; @Controller('support') export class SupportController { - constructor(private readonly supportService: SupportService) {} + constructor( + private readonly supportService: SupportService, + private readonly supportNoteService: SupportNoteService, + ) {} @Get() @ApiBearerAuth() @@ -174,12 +186,59 @@ export class SupportController { return this.supportService.generateAndSaveOnboardingPdf(+id, dto); } + @Get('note') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async getNotes(@Query() query: SupportNoteListQuery, @GetJwt() jwt: JwtPayload): Promise { + const notes = await this.supportNoteService.search(jwt.role, query); + return notes.map((n) => this.supportNoteService.toDto(n, jwt.role, jwt.account)); + } + + @Get('note/users') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async listNoteUsers(@GetJwt() jwt: JwtPayload): Promise { + return this.supportNoteService.listUsers(jwt.role); + } + + @Post('note') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async createNote(@Body() dto: CreateSupportNoteDto, @GetJwt() jwt: JwtPayload): Promise { + const note = await this.supportNoteService.create(jwt.role, jwt.account, dto); + return this.supportNoteService.toDto(note, jwt.role, jwt.account); + } + + @Put('note/:id') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async updateNote( + @Param('id') id: string, + @Body() dto: UpdateSupportNoteDto, + @GetJwt() jwt: JwtPayload, + ): Promise { + const note = await this.supportNoteService.update(+id, jwt.role, jwt.account, dto); + return this.supportNoteService.toDto(note, jwt.role, jwt.account); + } + + @Delete('note/:id') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async deleteNote(@Param('id') id: string, @GetJwt() jwt: JwtPayload): Promise { + await this.supportNoteService.delete(+id, jwt.role, jwt.account); + } + @Get(':id') @ApiBearerAuth() @ApiExcludeEndpoint() @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) async getUserData(@Param('id') id: string, @GetJwt() jwt: JwtPayload): Promise { - return this.supportService.getUserDataDetails(+id, jwt.role); + return this.supportService.getUserDataDetails(+id, jwt.role, jwt.account); } @Get('transaction/:id/refund') diff --git a/src/subdomains/generic/support/support.module.ts b/src/subdomains/generic/support/support.module.ts index 4bd9782968..76e5dadc1d 100644 --- a/src/subdomains/generic/support/support.module.ts +++ b/src/subdomains/generic/support/support.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; import { ReferralModule } from 'src/subdomains/core/referral/referral.module'; @@ -13,12 +14,16 @@ import { RecallModule } from 'src/subdomains/supporting/recall/recall.module'; import { SupportIssueModule } from 'src/subdomains/supporting/support-issue/support-issue.module'; import { KycModule } from '../kyc/kyc.module'; import { UserModule } from '../user/user.module'; +import { SupportNote } from './entities/support-note.entity'; +import { SupportNoteRepository } from './repositories/support-note.repository'; +import { SupportNoteService } from './services/support-note.service'; import { SupportController } from './support.controller'; import { SupportPdfService } from './support-pdf.service'; import { SupportService } from './support.service'; @Module({ imports: [ + TypeOrmModule.forFeature([SupportNote]), SharedModule, UserModule, BuyCryptoModule, @@ -35,7 +40,7 @@ import { SupportService } from './support.service'; forwardRef(() => PaymentModule), ], controllers: [SupportController], - providers: [SupportService, SupportPdfService], + providers: [SupportService, SupportPdfService, SupportNoteService, SupportNoteRepository], exports: [], }) export class SupportModule {} diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 019a628908..2b677e1002 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -112,6 +112,7 @@ import { UserSupportInfo, VirtualIbanSupportInfo, } from './dto/user-data-support.dto'; +import { SupportNoteService } from './services/support-note.service'; import { SupportPdfService } from './support-pdf.service'; import { toUserDataDetailDto } from './user-data-detail.mapper'; @@ -168,6 +169,7 @@ export class SupportService { private readonly kycDocumentService: KycDocumentService, private readonly supportPdfService: SupportPdfService, private readonly recallService: RecallService, + private readonly supportNoteService: SupportNoteService, ) {} async generateIpLogPdf(userDataId: number): Promise { @@ -221,7 +223,7 @@ export class SupportService { return { pdfData, fileName }; } - async getUserDataDetails(id: number, role: UserRole): Promise { + async getUserDataDetails(id: number, role: UserRole, jwtAccount: number): Promise { const userData = await this.userDataService.getUserData(id, { wallet: true, bankDatas: true }); if (!userData) throw new NotFoundException(`User not found`); @@ -252,6 +254,7 @@ export class SupportService { notifications, ipLogs, supportIssues, + notes, ] = await Promise.all([ permissions.viewKycFiles ? this.kycFileService.getUserDataKycFiles(id) @@ -273,6 +276,7 @@ export class SupportService { permissions.viewSupportIssues ? this.supportIssueService.getIssueEntities(id) : Promise.resolve(undefined), + this.supportNoteService.search(role, { userDataId: id }), ]); // Load bank transactions for the loaded transactions (incoming + outgoing) @@ -336,6 +340,7 @@ export class SupportService { virtualIbans: virtualIbans.map((v) => this.toVirtualIbanSupportInfo(v)), refRewards: refRewards.map((r) => this.toRefRewardSupportInfo(r)), notifications: notifications.map((n) => this.toNotificationSupportInfo(n)), + notes: notes.map((n) => this.supportNoteService.toDto(n, role, jwtAccount)), permissions, }; } diff --git a/src/subdomains/supporting/support-issue/enums/department.enum.ts b/src/subdomains/supporting/support-issue/enums/department.enum.ts index 0d8e9ed97c..92e2ee84aa 100644 --- a/src/subdomains/supporting/support-issue/enums/department.enum.ts +++ b/src/subdomains/supporting/support-issue/enums/department.enum.ts @@ -10,4 +10,5 @@ export enum Department { export const RoleDepartmentMap: Partial> = { [UserRole.SUPPORT]: Department.SUPPORT, [UserRole.COMPLIANCE]: Department.COMPLIANCE, + [UserRole.MARKETING]: Department.MARKETING, }; From cea705ed944323e217bc2c85d1beacb25034a3d6 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 19 May 2026 08:53:47 +0200 Subject: [PATCH 12/13] feat(support): expose bankDataId on transactions (DEV-4573) (#3722) --- src/subdomains/generic/support/dto/user-data-support.dto.ts | 1 + src/subdomains/generic/support/support.service.ts | 1 + .../supporting/payment/services/transaction.service.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) 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 7d634ceaa2..7dc91d2d91 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -156,6 +156,7 @@ export class TransactionSupportInfo { uid: string; buyCryptoId?: number; buyFiatId?: number; + bankDataId?: number; type?: string; sourceType: string; inputAmount?: number; diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 2b677e1002..6ccaced460 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -487,6 +487,7 @@ export class SupportService { uid: tx.uid, buyCryptoId: tx.buyCrypto?.id, buyFiatId: tx.buyFiat?.id, + bankDataId: tx.buyCrypto?.bankData?.id ?? tx.buyFiat?.bankData?.id, type: tx.type, sourceType: tx.sourceType, inputAmount: tx.buyCrypto?.inputAmount ?? tx.buyFiat?.inputAmount, diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 22ee973f49..490cbe3840 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -147,8 +147,8 @@ export class TransactionService { return this.repo.find({ where: { userData: { id: userDataId } }, relations: { - buyCrypto: { cryptoInput: true, outputAsset: true }, - buyFiat: { cryptoInput: true, outputAsset: true }, + buyCrypto: { cryptoInput: true, outputAsset: true, bankData: true }, + buyFiat: { cryptoInput: true, outputAsset: true, bankData: true }, bankTxReturn: true, bankTxRepeat: true, }, From 2880499f3972a5dcb67e3d6a6e32963e3681c717 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 19 May 2026 15:32:08 +0200 Subject: [PATCH 13/13] feat(support): remove pending-onboardings endpoint (DEV-4574) (#3723) --- .../generic/kyc/services/kyc.service.ts | 42 ------------------- .../support/dto/user-data-support.dto.ts | 7 ---- .../generic/support/support.controller.ts | 9 ---- .../generic/support/support.service.ts | 20 --------- 4 files changed, 78 deletions(-) diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 0458f5a471..495eea9ee0 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1842,48 +1842,6 @@ export class KycService { } } - // --- Company Onboarding Queries --- - - async getPendingCompanyOnboardings(): Promise<{ userDataId: number; date: Date }[]> { - const companyStepNames = [ - KycStepName.LEGAL_ENTITY, - KycStepName.AUTHORITY, - KycStepName.OWNER_DIRECTORY, - KycStepName.SIGNATORY_POWER, - KycStepName.BENEFICIAL_OWNER, - KycStepName.OPERATIONAL_ACTIVITY, - KycStepName.DFX_APPROVAL, - ]; - - const results = await this.kycStepRepo - .createQueryBuilder('step') - .select('step.userDataId', 'userDataId') - .addSelect('MIN(step.updated)', 'date') - .innerJoin('step.userData', 'userData') - .where('step.name IN (:...names)', { names: companyStepNames }) - .andWhere('step.status = :status', { status: ReviewStatus.MANUAL_REVIEW }) - .andWhere('userData.accountType IN (:...accountTypes)', { - accountTypes: [AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP], - }) - .andWhere('userData.kycLevel >= :minLevel', { minLevel: KycLevel.LEVEL_30 }) - .andWhere('userData.status != :mergedStatus', { mergedStatus: UserDataStatus.MERGED }) - .andWhere( - `step.userDataId NOT IN ( - SELECT s2.userDataId FROM kyc_step s2 - WHERE s2.name = :approvalName AND s2.status IN (:...doneStatuses) - )`, - { - approvalName: KycStepName.DFX_APPROVAL, - doneStatuses: [ReviewStatus.COMPLETED, ReviewStatus.FAILED], - }, - ) - .groupBy('step.userDataId') - .orderBy('date', 'ASC') - .getRawMany<{ userDataId: number; date: Date }>(); - - return results; - } - async getDfxApprovalSteps(userDataIds: number[]): Promise { if (userDataIds.length === 0) return []; 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 7dc91d2d91..72496c6c3b 100644 --- a/src/subdomains/generic/support/dto/user-data-support.dto.ts +++ b/src/subdomains/generic/support/dto/user-data-support.dto.ts @@ -41,13 +41,6 @@ export class UserDataSupportInfo { onboardingStatus?: OnboardingStatus; } -export class PendingOnboardingInfo { - id: number; - name?: string; - accountType?: string; - date: Date; -} - export class PendingTransactionInfo { txId: number; uid: string; diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index 4a084f48de..f3b8231bbf 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -36,7 +36,6 @@ import { CallQueueSummaryEntry, KycFileListEntry, KycFileYearlyStats, - PendingOnboardingInfo, PendingReviewItem, PendingReviewSummaryEntry, PendingReviewType, @@ -97,14 +96,6 @@ export class SupportController { return this.supportService.getRecommendationGraph(+id); } - @Get('pending-onboardings') - @ApiBearerAuth() - @ApiExcludeEndpoint() - @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) - async getPendingOnboardings(): Promise { - return this.supportService.getPendingOnboardings(); - } - @Get('pending-transactions') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 6ccaced460..63c1d2c125 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -87,7 +87,6 @@ import { KycStepSupportInfo, NotificationSupportInfo, OnboardingStatus, - PendingOnboardingInfo, PendingTransactionInfo, PendingReviewItem, PendingReviewSummaryEntry, @@ -807,25 +806,6 @@ export class SupportService { }; } - async getPendingOnboardings(): Promise { - const pendingEntries = await this.kycService.getPendingCompanyOnboardings(); - if (pendingEntries.length === 0) return []; - - const userDataIds = pendingEntries.map((e) => e.userDataId); - const userDatas = await Promise.all(userDataIds.map((id) => this.userDataService.getUserData(id))); - - const dateMap = new Map(pendingEntries.map((e) => [e.userDataId, e.date])); - - return userDatas - .filter((ud): ud is UserData => !!ud) - .map((ud) => ({ - id: ud.id, - name: this.formatUserName(ud), - accountType: ud.accountType, - date: dateMap.get(ud.id) ?? ud.created, - })); - } - async getPendingReviewsSummary(): Promise { const [kycRows, bankRows] = await Promise.all([ this.kycService.getPendingReviewSummary(),