From c7b4790ea447f55672b91c2716c8e5999afd8f30 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:31:16 +0100 Subject: [PATCH 01/12] [NOTASK] amlCheck refactoring remove instant check (#3180) --- src/subdomains/core/aml/services/aml-helper.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 124b9e9a68..db4100b70c 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -270,7 +270,6 @@ export class AmlHelperService { errors.push(AmlError.ACCOUNT_IBAN_BLACKLISTED); const bank = banks.find((b) => b.iban === entity.bankTx.accountIban); - if (bank?.sctInst && !entity.userData.olkypayAllowed) errors.push(AmlError.INSTANT_NOT_ALLOWED); if (bank?.sctInst && !entity.outputAsset.instantBuyable) errors.push(AmlError.ASSET_NOT_INSTANT_BUYABLE); if (bank && !bank.amlEnabled) errors.push(AmlError.BANK_DEACTIVATED); } else if (entity.checkoutTx) { From 4019401c440ab6035e9f7a6f53c98d20cb806725 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:40:08 +0100 Subject: [PATCH 02/12] feat: migrate RealUnit price to Aktionariat API (#3188) * feat: migrate RealUnit price from Brokerbot contract to Aktionariat API Replace direct smart contract reads (getPrice, getBuyPrice, getShares, settings) with the Aktionariat REST API endpoint for price and share data. Add 30s AsyncCache with fallback for resilience. Expose availableShares in all broker DTOs and remove raw wei fields (pricePerShareRaw, totalPriceRaw). * feat: use EUR price directly from Aktionariat API The API returns priceInEUR directly, so use it for the /realunit/price endpoint instead of deriving EUR from CHF via exchange rate conversion. * feat: use Aktionariat EUR price consistently across pricing system Add getDirectEurPrice() to PricingRealUnitService that returns the EUR price from the Aktionariat API for REALU<->EUR pairs. PricingService checks for this direct price before falling back to the rule chain, ensuring buy/sell and display endpoints use the same EUR rate. * feat: call Aktionariat requestPaymentInstructions on REALU buy confirm When a REALU buy quote is confirmed, call the Aktionariat requestPaymentInstructions endpoint to register the order and store the response in a new aktionariatResponse column on transaction_request. * fix: use JSDoc typedef pattern in migration file * refactor: move REALU buy-confirm logic into RealUnitService - Add PaymentInstructionsResponse DTO to replace Promise - Move Aktionariat order submission from TransactionRequestService into RealUnitService.confirmBuy() - Add PUT /realunit/buy/:id/confirm endpoint - Remove RealUnitBlockchainService dependency from TransactionRequestService - Make confirmTransactionRequest accept optional aktionariatResponse param --- .../1770600000000-AddAktionariatResponse.js | 26 ++++ .../realunit/dto/realunit-broker.dto.ts | 20 +-- .../realunit/realunit-blockchain.service.ts | 114 ++++++++++-------- src/shared/services/http.service.ts | 1 + .../entities/transaction-request.entity.ts | 3 + .../services/transaction-request.service.ts | 7 +- .../integration/pricing-realunit.service.ts | 14 +++ .../pricing/services/pricing.service.ts | 4 + .../controllers/realunit.controller.ts | 9 ++ .../supporting/realunit/realunit.service.ts | 22 ++++ 10 files changed, 162 insertions(+), 58 deletions(-) create mode 100644 migration/1770600000000-AddAktionariatResponse.js diff --git a/migration/1770600000000-AddAktionariatResponse.js b/migration/1770600000000-AddAktionariatResponse.js new file mode 100644 index 0000000000..30fc400c33 --- /dev/null +++ b/migration/1770600000000-AddAktionariatResponse.js @@ -0,0 +1,26 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddAktionariatResponse1770600000000 { + name = 'AddAktionariatResponse1770600000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."transaction_request" ADD "aktionariatResponse" nvarchar(MAX)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."transaction_request" DROP COLUMN "aktionariatResponse"`); + } +}; diff --git a/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts b/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts index 33ed336354..7591b9bb37 100644 --- a/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts +++ b/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts @@ -1,25 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; export class BrokerbotPriceDto { - @ApiProperty({ description: 'Current price per share in CHF (18 decimals formatted)' }) + @ApiProperty({ description: 'Current price per share in CHF' }) pricePerShare: string; - @ApiProperty({ description: 'Raw price per share in wei' }) - pricePerShareRaw: string; + @ApiProperty({ description: 'Available shares for purchase' }) + availableShares: number; } export class BrokerbotBuyPriceDto { @ApiProperty({ description: 'Number of shares' }) shares: number; - @ApiProperty({ description: 'Total cost in CHF (18 decimals formatted)' }) + @ApiProperty({ description: 'Total cost in CHF' }) totalPrice: string; - @ApiProperty({ description: 'Raw total cost in wei' }) - totalPriceRaw: string; - @ApiProperty({ description: 'Price per share in CHF' }) pricePerShare: string; + + @ApiProperty({ description: 'Available shares for purchase' }) + availableShares: number; } export class BrokerbotSharesDto { @@ -31,6 +31,9 @@ export class BrokerbotSharesDto { @ApiProperty({ description: 'Price per share in CHF' }) pricePerShare: string; + + @ApiProperty({ description: 'Available shares for purchase' }) + availableShares: number; } export class BrokerbotInfoDto { @@ -51,4 +54,7 @@ export class BrokerbotInfoDto { @ApiProperty({ description: 'Whether selling is enabled' }) sellingEnabled: boolean; + + @ApiProperty({ description: 'Available shares for purchase' }) + availableShares: number; } diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index bcf7c55c2b..e6fc15d707 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -1,10 +1,7 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { Contract } from 'ethers'; -import { Blockchain } from '../shared/enums/blockchain.enum'; -import { EvmClient } from '../shared/evm/evm-client'; -import { EvmUtil } from '../shared/evm/evm.util'; -import { BlockchainRegistryService } from '../shared/services/blockchain-registry.service'; +import { Injectable } from '@nestjs/common'; +import { GetConfig } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; import { BrokerbotBuyPriceDto, BrokerbotInfoDto, @@ -17,86 +14,105 @@ const BROKERBOT_ADDRESS = '0xCFF32C60B87296B8c0c12980De685bEd6Cb9dD6d'; const REALU_TOKEN_ADDRESS = '0x553C7f9C780316FC1D34b8e14ac2465Ab22a090B'; const ZCHF_ADDRESS = '0xb58e61c3098d85632df34eecfb899a1ed80921cb'; -// Contract ABIs -const BROKERBOT_ABI = [ - 'function getPrice() public view returns (uint256)', - 'function getBuyPrice(uint256 shares) public view returns (uint256)', - 'function getShares(uint256 money) public view returns (uint256)', - 'function settings() public view returns (uint256)', -]; +interface AktionariatPriceResponse { + priceInCHF: number; + priceInEUR: number; + availableShares: number; +} -@Injectable() -export class RealUnitBlockchainService implements OnModuleInit { - private registryService: BlockchainRegistryService; +interface PaymentInstructionsRequest { + currency: string; + address: string; + shares: number; + price: number; +} - constructor(private readonly moduleRef: ModuleRef) {} +interface PaymentInstructionsResponse { + [key: string]: unknown; +} - private getEvmClient(): EvmClient { - return this.registryService.getClient(Blockchain.ETHEREUM) as EvmClient; +@Injectable() +export class RealUnitBlockchainService { + private readonly priceCache = new AsyncCache(CacheItemResetPeriod.EVERY_30_SECONDS); + + constructor(private readonly http: HttpService) {} + + private async fetchPrice(): Promise { + return this.priceCache.get( + 'price', + async () => { + const { url, key } = GetConfig().blockchain.realunit.api; + return this.http.post(`${url}/realunit/directinvestment/getPrice`, null, { + headers: { 'x-api-key': key }, + }); + }, + undefined, + true, + ); } - private getBrokerbotContract(): Contract { - return new Contract(BROKERBOT_ADDRESS, BROKERBOT_ABI, this.getEvmClient().wallet); + async getRealUnitPrice(): Promise { + const { priceInCHF } = await this.fetchPrice(); + return priceInCHF; } - onModuleInit() { - this.registryService = this.moduleRef.get(BlockchainRegistryService, { strict: false }); + async getRealUnitPriceEur(): Promise { + const { priceInEUR } = await this.fetchPrice(); + return priceInEUR; } - async getRealUnitPrice(): Promise { - const price = await this.getBrokerbotContract().getPrice(); - return EvmUtil.fromWeiAmount(price); + async requestPaymentInstructions(request: PaymentInstructionsRequest): Promise { + const { url, key } = GetConfig().blockchain.realunit.api; + return this.http.post(`${url}/realunit/directinvestment/requestPaymentInstructions`, request, { + headers: { 'x-api-key': key }, + }); } // --- Brokerbot Methods --- async getBrokerbotPrice(): Promise { - const priceRaw = await this.getBrokerbotContract().getPrice(); + const { priceInCHF, availableShares } = await this.fetchPrice(); return { - pricePerShare: EvmUtil.fromWeiAmount(priceRaw).toString(), - pricePerShareRaw: priceRaw.toString(), + pricePerShare: priceInCHF.toString(), + availableShares, }; } async getBrokerbotBuyPrice(shares: number): Promise { - const contract = this.getBrokerbotContract(); - const [totalPriceRaw, pricePerShareRaw] = await Promise.all([contract.getBuyPrice(shares), contract.getPrice()]); + const { priceInCHF, availableShares } = await this.fetchPrice(); + const totalPrice = priceInCHF * shares; return { shares, - totalPrice: EvmUtil.fromWeiAmount(totalPriceRaw).toString(), - totalPriceRaw: totalPriceRaw.toString(), - pricePerShare: EvmUtil.fromWeiAmount(pricePerShareRaw).toString(), + totalPrice: totalPrice.toString(), + pricePerShare: priceInCHF.toString(), + availableShares, }; } async getBrokerbotShares(amountChf: string): Promise { - const contract = this.getBrokerbotContract(); - const amountWei = EvmUtil.toWeiAmount(parseFloat(amountChf)); - const [shares, pricePerShareRaw] = await Promise.all([contract.getShares(amountWei), contract.getPrice()]); + const { priceInCHF, availableShares } = await this.fetchPrice(); + const shares = Math.floor(parseFloat(amountChf) / priceInCHF); return { amount: amountChf, - shares: shares.toNumber(), - pricePerShare: EvmUtil.fromWeiAmount(pricePerShareRaw).toString(), + shares, + pricePerShare: priceInCHF.toString(), + availableShares, }; } async getBrokerbotInfo(): Promise { - const contract = this.getBrokerbotContract(); - const [priceRaw, settings] = await Promise.all([contract.getPrice(), contract.settings()]); - - // Settings bitmask: bit 0 = buying enabled, bit 1 = selling enabled - const buyingEnabled = (settings.toNumber() & 1) === 1; - const sellingEnabled = (settings.toNumber() & 2) === 2; + const { priceInCHF, availableShares } = await this.fetchPrice(); return { brokerbotAddress: BROKERBOT_ADDRESS, tokenAddress: REALU_TOKEN_ADDRESS, baseCurrencyAddress: ZCHF_ADDRESS, - pricePerShare: EvmUtil.fromWeiAmount(priceRaw).toString(), - buyingEnabled, - sellingEnabled, + pricePerShare: priceInCHF.toString(), + buyingEnabled: availableShares > 0, + sellingEnabled: true, + availableShares, }; } } diff --git a/src/shared/services/http.service.ts b/src/shared/services/http.service.ts index 766a02b1a1..96293cb7e2 100644 --- a/src/shared/services/http.service.ts +++ b/src/shared/services/http.service.ts @@ -40,6 +40,7 @@ const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [ }, { pattern: /login\.microsoftonline\.com/, response: { access_token: 'mock-token', expires_in: 3600 } }, { pattern: /api\.applicationinsights\.io/, response: { tables: [{ name: 'PrimaryResult', columns: [], rows: [] }] } }, + { pattern: /aktionariat\.com/, response: { priceInCHF: 1.57, priceInEUR: 1.71, availableShares: 65488 } }, ]; @Injectable() diff --git a/src/subdomains/supporting/payment/entities/transaction-request.entity.ts b/src/subdomains/supporting/payment/entities/transaction-request.entity.ts index 96c76dc794..3406861e15 100644 --- a/src/subdomains/supporting/payment/entities/transaction-request.entity.ts +++ b/src/subdomains/supporting/payment/entities/transaction-request.entity.ts @@ -95,6 +95,9 @@ export class TransactionRequest extends IEntity { @Column({ length: 'MAX', nullable: true }) siftResponse?: string; + @Column({ length: 'MAX', nullable: true }) + aktionariatResponse?: string; + @OneToOne(() => Transaction, (transaction) => transaction.request, { nullable: true }) transaction?: Transaction; diff --git a/src/subdomains/supporting/payment/services/transaction-request.service.ts b/src/subdomains/supporting/payment/services/transaction-request.service.ts index 02219fe062..1936c5ea51 100644 --- a/src/subdomains/supporting/payment/services/transaction-request.service.ts +++ b/src/subdomains/supporting/payment/services/transaction-request.service.ts @@ -326,8 +326,11 @@ export class TransactionRequestService { } } - async confirmTransactionRequest(txRequest: TransactionRequest): Promise { - await this.transactionRequestRepo.update(txRequest.id, { status: TransactionRequestStatus.WAITING_FOR_PAYMENT }); + async confirmTransactionRequest(txRequest: TransactionRequest, aktionariatResponse?: string): Promise { + await this.transactionRequestRepo.update(txRequest.id, { + status: TransactionRequestStatus.WAITING_FOR_PAYMENT, + ...(aktionariatResponse && { aktionariatResponse }), + }); } async getActiveDepositAddresses(created: Date, blockchain: Blockchain): Promise { diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts index 53c6d9b1af..c705e04ccb 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts @@ -9,6 +9,7 @@ import { PricingProvider } from './pricing-provider'; export class PricingRealUnitService extends PricingProvider implements OnModuleInit { private static readonly REALU = 'REALU'; private static readonly ZCHF = 'ZCHF'; + private static readonly EUR = 'EUR'; private static readonly ALLOWED_ASSETS = [PricingRealUnitService.REALU, PricingRealUnitService.ZCHF]; @@ -32,4 +33,17 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI return Price.create(from, to, Util.round(assetPrice, 8)); } + + async getDirectEurPrice(from: string, to: string): Promise { + const isRealuToEur = from === PricingRealUnitService.REALU && to === PricingRealUnitService.EUR; + const isEurToRealu = from === PricingRealUnitService.EUR && to === PricingRealUnitService.REALU; + + if (!isRealuToEur && !isEurToRealu) return undefined; + + const eurPrice = await this.realunitService.getRealUnitPriceEur(); + if (eurPrice == null) return undefined; + + const price = isRealuToEur ? 1 / eurPrice : eurPrice; + return Price.create(from, to, Util.round(price, 8)); + } } diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index 23e69d98a3..7f5e8cbbda 100644 --- a/src/subdomains/supporting/pricing/services/pricing.service.ts +++ b/src/subdomains/supporting/pricing/services/pricing.service.ts @@ -187,6 +187,10 @@ export class PricingService implements OnModuleInit { try { if (activesEqual(from, to)) return Price.create(from.name, to.name, 1); + // Direct EUR price for REALU from Aktionariat API + const directPrice = await this.realunitService.getDirectEurPrice(from.name, to.name).catch(() => undefined); + if (directPrice) return directPrice; + const shouldUpdate = validity !== PriceValidity.ANY; const [fromRules, toRules] = await Promise.all([ diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 23327e13b6..a4365f39e5 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -34,6 +34,7 @@ import { } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { IpGuard } from 'src/shared/auth/ip.guard'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; @@ -284,6 +285,14 @@ export class RealUnitController { return this.realunitService.getPaymentInfo(user, dto); } + @Put('buy/:id/confirm') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), IpGuard) + @ApiOkResponse() + async confirmBuy(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + await this.realunitService.confirmBuy(jwt.user, +id); + } + // --- Sell Payment Info Endpoints --- @Put('sell') diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index babdcd6a5b..3ff810e169 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -42,6 +42,7 @@ import { UserDataService } from 'src/subdomains/generic/user/models/user-data/us import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { transliterate } from 'transliteration'; import { AssetPricesService } from '../pricing/services/asset-prices.service'; @@ -288,6 +289,27 @@ export class RealUnitService { return response; } + async confirmBuy(userId: number, requestId: number): Promise { + const request = await this.transactionRequestService.getOrThrow(requestId, userId); + if (!request.isValid) throw new BadRequestException('Transaction request is not valid'); + if ([TransactionRequestStatus.COMPLETED, TransactionRequestStatus.WAITING_FOR_PAYMENT].includes(request.status)) + throw new ConflictException('Transaction request is already confirmed'); + if (Util.daysDiff(request.created) >= Config.txRequestWaitingExpiryDays) + throw new BadRequestException('Transaction request is expired'); + + // Aktionariat API aufrufen + const fiat = await this.fiatService.getFiat(request.sourceId); + const aktionariatResponse = await this.blockchainService.requestPaymentInstructions({ + currency: fiat.name, + address: request.user.address, + shares: Math.floor(request.estimatedAmount), + price: Math.round(request.exchangeRate * 100), + }); + + // Status + Response speichern + await this.transactionRequestService.confirmTransactionRequest(request, JSON.stringify(aktionariatResponse)); + } + // --- Registration Methods --- // returns true if registration needs manual review, false if completed From 38a81e016592d66784e94fdd85f9f67e65d49758 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:58:50 +0100 Subject: [PATCH 03/12] feat: add admin quotes and transactions endpoints for RealUnit (#3190) * feat: add admin quotes and transactions endpoints for RealUnit Add GET /realunit/admin/quotes and GET /realunit/admin/transactions endpoints to display pending quotes and completed transactions on the RealUnit admin page. Includes getByAssetId query methods in TransactionRequestService and TransactionService. * doc: add swagger tags. --------- Co-authored-by: TuanLamNguyen --- .../services/transaction-request.service.ts | 13 ++++ .../payment/services/transaction.service.ts | 27 +++++++ .../controllers/realunit.controller.ts | 21 ++++++ .../realunit/dto/realunit-admin.dto.ts | 73 +++++++++++++++++++ .../supporting/realunit/realunit.service.ts | 37 ++++++++++ 5 files changed, 171 insertions(+) create mode 100644 src/subdomains/supporting/realunit/dto/realunit-admin.dto.ts diff --git a/src/subdomains/supporting/payment/services/transaction-request.service.ts b/src/subdomains/supporting/payment/services/transaction-request.service.ts index 1936c5ea51..00f42dc3de 100644 --- a/src/subdomains/supporting/payment/services/transaction-request.service.ts +++ b/src/subdomains/supporting/payment/services/transaction-request.service.ts @@ -352,6 +352,19 @@ export class TransactionRequestService { .then((transactionRequests) => transactionRequests.map((deposit) => deposit.address)); } + async getByAssetId(assetId: number, limit = 50, offset = 0): Promise { + return this.transactionRequestRepo.find({ + where: [ + { type: TransactionRequestType.BUY, targetId: assetId, isComplete: false }, + { type: TransactionRequestType.SELL, sourceId: assetId, isComplete: false }, + ], + order: { created: 'DESC' }, + take: limit, + skip: offset, + relations: { user: true }, + }); + } + // --- HELPER METHODS --- // private currentStatus(entity: TransactionRequest, expiryDate: Date): TransactionRequestStatus { diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 3d7df91a47..3e0f3cd3c7 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -256,6 +256,33 @@ export class TransactionService { .getRawMany(); } + async getByAssetId(assetId: number, limit = 50, offset = 0): Promise { + return this.repo + .createQueryBuilder('transaction') + .select('transaction') + .leftJoinAndSelect('transaction.request', 'request') + .leftJoinAndSelect('transaction.user', 'user') + .leftJoinAndSelect('transaction.userData', 'userData') + .where('transaction.type IS NOT NULL') + .andWhere( + new Brackets((qb) => + qb + .where('request.type = :buyType AND request.targetId = :assetId', { + buyType: 'Buy', + assetId, + }) + .orWhere('request.type = :sellType AND request.sourceId = :assetId', { + sellType: 'Sell', + assetId, + }), + ), + ) + .orderBy('transaction.created', 'DESC') + .take(limit) + .skip(offset) + .getMany(); + } + async getTransactionByKey(key: string, value: any): Promise { return this.repo .createQueryBuilder('transaction') diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index a4365f39e5..9b4f82e2ac 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -46,6 +46,7 @@ import { BalancePdfService } from '../../balance/services/balance-pdf.service'; import { TxStatementType } from '../../payment/dto/transaction-helper/tx-statement-details.dto'; import { SwissQRService } from '../../payment/services/swiss-qr.service'; import { TransactionHelper } from '../../payment/services/transaction-helper'; +import { RealUnitAdminQueryDto, RealUnitQuoteDto, RealUnitTransactionDto } from '../dto/realunit-admin.dto'; import { RealUnitBalancePdfDto, RealUnitMultiReceiptPdfDto, @@ -416,6 +417,26 @@ export class RealUnitController { // --- Admin Endpoints --- + @Get('admin/quotes') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Get RealUnit quotes' }) + @ApiOkResponse({ type: [RealUnitQuoteDto], description: 'List of open RealUnit requests (quotes)' }) + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getAdminQuotes(@Query() { limit, offset }: RealUnitAdminQueryDto): Promise { + return this.realunitService.getAdminQuotes(limit, offset); + } + + @Get('admin/transactions') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Get RealUnit transactions' }) + @ApiOkResponse({ type: [RealUnitTransactionDto], description: 'List of completed RealUnit transactions' }) + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async getAdminTransactions(@Query() { limit, offset }: RealUnitAdminQueryDto): Promise { + return this.realunitService.getAdminTransactions(limit, offset); + } + @Put('admin/registration/:kycStepId/forward') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/supporting/realunit/dto/realunit-admin.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-admin.dto.ts new file mode 100644 index 0000000000..b9bc9dd1d7 --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-admin.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, Min } from 'class-validator'; +import { TransactionRequestStatus, TransactionRequestType } from '../../payment/entities/transaction-request.entity'; +import { TransactionTypeInternal } from '../../payment/entities/transaction.entity'; + +export class RealUnitAdminQueryDto { + @ApiPropertyOptional({ description: 'Number of items to return' }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + limit?: number; + + @ApiPropertyOptional({ description: 'Number of items to skip' }) + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + offset?: number; +} + +export class RealUnitQuoteDto { + @ApiProperty({ description: 'Quote ID' }) + id: number; + + @ApiProperty({ description: 'Quote UID' }) + uid: string; + + @ApiProperty({ description: 'Quote type', enum: TransactionRequestType }) + type: TransactionRequestType; + + @ApiProperty({ description: 'Quote status', enum: TransactionRequestStatus }) + status: TransactionRequestStatus; + + @ApiProperty({ description: 'Quote amount' }) + amount: number; + + @ApiProperty({ description: 'Estimated amount' }) + estimatedAmount: number; + + @ApiProperty({ description: 'Creation date' }) + created: Date; + + @ApiPropertyOptional({ description: 'User address' }) + userAddress?: string; +} + +export class RealUnitTransactionDto { + @ApiProperty({ description: 'Transaction ID' }) + id: number; + + @ApiProperty({ description: 'Transaction UID' }) + uid: string; + + @ApiProperty({ description: 'Transaction type', enum: TransactionTypeInternal }) + type: TransactionTypeInternal; + + @ApiProperty({ description: 'Amount in CHF' }) + amountInChf: number; + + @ApiProperty({ description: 'Assets involved' }) + assets: string; + + @ApiProperty({ description: 'Creation date' }) + created: Date; + + @ApiPropertyOptional({ description: 'Output date' }) + outputDate?: Date; + + @ApiPropertyOptional({ description: 'User address' }) + userAddress?: string; +} diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 3ff810e169..2054f35c5c 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -44,6 +44,7 @@ import { UserService } from 'src/subdomains/generic/user/models/user/user.servic import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { transliterate } from 'transliteration'; import { AssetPricesService } from '../pricing/services/asset-prices.service'; import { PriceCurrency, PriceValidity, PricingService } from '../pricing/services/pricing.service'; @@ -53,6 +54,7 @@ import { HoldersClientResponse, TokenInfoClientResponse, } from './dto/client.dto'; +import { RealUnitQuoteDto, RealUnitTransactionDto } from './dto/realunit-admin.dto'; import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; import { AktionariatRegistrationDto, @@ -107,6 +109,7 @@ export class RealUnitService { private readonly sellService: SellService, private readonly eip7702DelegationService: Eip7702DelegationService, private readonly transactionRequestService: TransactionRequestService, + private readonly transactionService: TransactionService, private readonly accountMergeService: AccountMergeService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; @@ -791,6 +794,40 @@ export class RealUnitService { return response; } + // --- Admin Methods --- + + async getAdminQuotes(limit = 50, offset = 0): Promise { + const realuAsset = await this.getRealuAsset(); + const requests = await this.transactionRequestService.getByAssetId(realuAsset.id, limit, offset); + + return requests.map((r) => ({ + id: r.id, + uid: r.uid, + type: r.type, + status: r.status, + amount: r.amount, + estimatedAmount: r.estimatedAmount, + created: r.created, + userAddress: r.user?.address, + })); + } + + async getAdminTransactions(limit = 50, offset = 0): Promise { + const realuAsset = await this.getRealuAsset(); + const transactions = await this.transactionService.getByAssetId(realuAsset.id, limit, offset); + + return transactions.map((t) => ({ + id: t.id, + uid: t.uid, + type: t.type, + amountInChf: t.amountInChf, + assets: t.assets, + created: t.created, + outputDate: t.outputDate, + userAddress: t.user?.address, + })); + } + async confirmSell(userId: number, requestId: number, dto: RealUnitSellConfirmDto): Promise<{ txHash: string }> { // 1. Get and validate TransactionRequest (getOrThrow validates ownership and existence) const request = await this.transactionRequestService.getOrThrow(requestId, userId); From c98251c7f8ce0ba3f9fffc39b10bf934a89a0f52 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:00:25 +0100 Subject: [PATCH 04/12] [NOTASK] Refactoring (#3089) * [NOTASK] Refactoring * [NOTASK] Refactoring 2 * [NOTASK] Refactoring 3 * [NOTASK] add async declaration to method * [NOTASK] Refactoring 2 --- .../core/buy-crypto/routes/buy/buy.service.ts | 2 +- .../buy-crypto/routes/swap/swap.service.ts | 2 +- .../core/sell-crypto/route/sell.service.ts | 2 +- .../models/user-data/user-data.service.ts | 44 +++++++++---------- .../user/models/user/user.repository.ts | 2 +- .../generic/user/models/user/user.service.ts | 2 +- .../supporting/dex/services/dex.service.ts | 5 ++- .../register/impl/base/citrea.strategy.ts | 2 +- .../register/impl/cardano.strategy.ts | 2 +- .../register/impl/monero.strategy.ts | 2 +- .../strategies/register/impl/zano.strategy.ts | 2 +- 11 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index 480195cf29..9b840a1587 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -81,7 +81,7 @@ export class BuyService { const { user } = await this.buyRepo.findOne({ where: { id: buyId }, relations: { user: true }, - select: ['id', 'user'], + select: { id: true, user: true }, }); const userVolume = await this.getUserVolume(user.id); diff --git a/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts b/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts index bf66c89ea0..b8b32967b9 100644 --- a/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts +++ b/src/subdomains/core/buy-crypto/routes/swap/swap.service.ts @@ -106,7 +106,7 @@ export class SwapService { const { user } = await this.swapRepo.findOne({ where: { id: swapId }, relations: { user: true }, - select: ['id', 'user'], + select: { id: true, user: true }, }); const userVolume = await this.getUserVolume(user.id); diff --git a/src/subdomains/core/sell-crypto/route/sell.service.ts b/src/subdomains/core/sell-crypto/route/sell.service.ts index 4372443e26..082ae23695 100644 --- a/src/subdomains/core/sell-crypto/route/sell.service.ts +++ b/src/subdomains/core/sell-crypto/route/sell.service.ts @@ -240,7 +240,7 @@ export class SellService { const { user } = await this.sellRepo.findOne({ where: { id: sellId }, relations: { user: true }, - select: ['id', 'user'], + select: { id: true, user: true }, }); const userVolume = await this.getUserVolume(user.id); 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 41bf1cc383..7377f4e62f 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 @@ -222,28 +222,26 @@ export class UserDataService { } async getUserDatasWithKycFile(): Promise { - return this.userDataRepo - .createQueryBuilder('userData') - .leftJoinAndSelect('userData.country', 'country') - .select([ - 'userData.id', - 'userData.kycFileId', - 'userData.amlAccountType', - 'userData.verifiedName', - 'userData.allBeneficialOwnersDomicile', - 'userData.amlListAddedDate', - 'userData.amlListExpiredDate', - 'userData.amlListReactivatedDate', - 'userData.highRisk', - 'userData.pep', - 'userData.complexOrgStructure', - 'userData.totalVolumeChfAuditPeriod', - 'userData.totalCustodyBalanceChfAuditPeriod', - 'country.name', - ]) - .where('userData.kycFileId > 0') - .orderBy('userData.kycFileId', 'ASC') - .getMany(); + return this.userDataRepo.find({ + select: { + id: true, + kycFileId: true, + amlAccountType: true, + verifiedName: true, + allBeneficialOwnersDomicile: true, + amlListAddedDate: true, + amlListExpiredDate: true, + amlListReactivatedDate: true, + highRisk: true, + pep: true, + complexOrgStructure: true, + totalVolumeChfAuditPeriod: true, + totalCustodyBalanceChfAuditPeriod: true, + country: { name: true }, + }, + where: { kycFileId: MoreThan(0) }, + order: { kycFileId: 'ASC' }, + }); } async getUserDataByKey(key: string, value: any): Promise { @@ -1224,7 +1222,7 @@ export class UserDataService { private async updateBankTxTime(userDataId: number): Promise { const txList = await this.repos.bankTx.find({ - select: ['id'], + select: { id: true }, where: [ { buyCrypto: { buy: { user: { userData: { id: userDataId } } } } }, { buyFiats: { sell: { user: { userData: { id: userDataId } } } } }, diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index 732a75762b..f97896ec9e 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -27,7 +27,7 @@ export class UserRepository extends BaseRepository { private async getNextRef(): Promise { // get highest numerical ref const nextRef = await this.findOne({ - select: ['id', 'ref'], + select: { id: true, ref: true }, where: { ref: Like('%[0-9]-[0-9]%') }, order: { ref: 'DESC' }, }).then((u) => +u.ref.replace('-', '') + 1); diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index c848b77a2e..e92432e3ea 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -453,7 +453,7 @@ export class UserService { const { userData } = await this.userRepo.findOne({ where: { id: userId }, relations: { userData: true }, - select: ['id', 'userData'], + select: { id: true, userData: true }, }); await this.userDataService.updateVolumes(userData.id); } diff --git a/src/subdomains/supporting/dex/services/dex.service.ts b/src/subdomains/supporting/dex/services/dex.service.ts index 392780bbf8..f67870718e 100644 --- a/src/subdomains/supporting/dex/services/dex.service.ts +++ b/src/subdomains/supporting/dex/services/dex.service.ts @@ -222,7 +222,10 @@ export class DexService { } async getPendingOrders(context: LiquidityOrderContext): Promise { - const pending = await this.liquidityOrderRepo.find({ where: { context }, select: ['context', 'correlationId'] }); + const pending = await this.liquidityOrderRepo.find({ + where: { context }, + select: { context: true, correlationId: true }, + }); return pending.map((o) => o.correlationId); } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts index 83f0743c58..493a47348c 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts @@ -71,7 +71,7 @@ export abstract class CitreaBaseStrategy extends RegisterStrategy { private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { return this.payInRepository .findOne({ - select: ['id', 'blockHeight'], + select: { id: true, blockHeight: true }, where: { address: depositAddress }, order: { blockHeight: 'DESC' }, loadEagerRelations: false, diff --git a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts index 9b036e1415..369b2a24c7 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/cardano.strategy.ts @@ -70,7 +70,7 @@ export class CardanoStrategy extends RegisterStrategy { private async getLastCheckedBlockHeight(depositAddress: BlockchainAddress): Promise { return this.payInRepository .findOne({ - select: ['id', 'blockHeight'], + select: { id: true, blockHeight: true }, where: { address: depositAddress }, order: { blockHeight: 'DESC' }, loadEagerRelations: false, diff --git a/src/subdomains/supporting/payin/strategies/register/impl/monero.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/monero.strategy.ts index a2cd2ef83c..2d1f75e0bd 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/monero.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/monero.strategy.ts @@ -53,7 +53,7 @@ export class MoneroStrategy extends PollingStrategy { private async getLastCheckedBlockHeight(): Promise { return this.payInRepository .findOne({ - select: ['id', 'blockHeight'], + select: { id: true, blockHeight: true }, where: { address: { blockchain: this.blockchain } }, order: { blockHeight: 'DESC' }, loadEagerRelations: false, diff --git a/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts index 87d799ab4d..e04d26326a 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/zano.strategy.ts @@ -55,7 +55,7 @@ export class ZanoStrategy extends PollingStrategy { private async getLastCheckedBlockHeight(): Promise { return this.payInRepository .findOne({ - select: ['id', 'blockHeight'], + select: { id: true, blockHeight: true }, where: { address: { blockchain: this.blockchain } }, order: { blockHeight: 'DESC' }, loadEagerRelations: false, From 6eca08084f983fa480dadedda1f69195fabcf273 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:03:23 +0100 Subject: [PATCH 05/12] fix: realunit brokerbot path for price and payment instructions. (#3192) --- .../blockchain/realunit/realunit-blockchain.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index e6fc15d707..d09329bacf 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -42,7 +42,7 @@ export class RealUnitBlockchainService { 'price', async () => { const { url, key } = GetConfig().blockchain.realunit.api; - return this.http.post(`${url}/realunit/directinvestment/getPrice`, null, { + return this.http.post(`${url}/directinvestment/getPrice`, null, { headers: { 'x-api-key': key }, }); }, @@ -63,7 +63,7 @@ export class RealUnitBlockchainService { async requestPaymentInstructions(request: PaymentInstructionsRequest): Promise { const { url, key } = GetConfig().blockchain.realunit.api; - return this.http.post(`${url}/realunit/directinvestment/requestPaymentInstructions`, request, { + return this.http.post(`${url}/directinvestment/requestPaymentInstructions`, request, { headers: { 'x-api-key': key }, }); } From f964e49cfed4a814f9412f7d26e62b7d07168c49 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:09:06 +0100 Subject: [PATCH 06/12] feat: add confirm payment endpoint for RealUnit admin (#3193) * feat: add confirm payment endpoint for RealUnit admin Add admin endpoint to manually confirm payment receipt for RealUnit buy transactions. In DEV/LOC environments, simulates bank transaction; in PROD, calls Aktionariat payAndAllocate API. Also removes automatic cron-based payment simulation in favor of manual confirmation, and fixes IBAN to use RealUnit config bank instead of Yapeal/DFX. * fix: regenerate QR code with RealUnit IBAN and convert EUR to CHF - Regenerate paymentRequest (QR/GiroCode) using RealUnit bank config instead of passing through the Yapeal/DFX QR code from BuyService - Convert EUR amounts to CHF Rappen for Aktionariat payAndAllocate API since request.amount can be EUR when user pays in EUR * style: fix prettier formatting * test: update dev service tests after cron removal Remove simulateRealuPayments tests (cron was removed) and update remaining tests to call simulatePaymentForRequest directly. Remove unused AssetService mock and environment toggling. * chore: realunit url and swagger tags. --------- Co-authored-by: TuanLamNguyen --- .../realunit/realunit-blockchain.service.ts | 12 ++ .../__tests__/realunit-dev.service.spec.ts | 158 ++---------------- .../controllers/realunit.controller.ts | 11 ++ .../realunit/realunit-dev.service.ts | 67 +------- .../supporting/realunit/realunit.service.ts | 86 +++++++++- 5 files changed, 118 insertions(+), 216 deletions(-) diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index d09329bacf..5ced8c9c98 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -31,6 +31,11 @@ interface PaymentInstructionsResponse { [key: string]: unknown; } +interface PayAndAllocateRequest { + amount: number; + ref: string; +} + @Injectable() export class RealUnitBlockchainService { private readonly priceCache = new AsyncCache(CacheItemResetPeriod.EVERY_30_SECONDS); @@ -68,6 +73,13 @@ export class RealUnitBlockchainService { }); } + async payAndAllocate(request: PayAndAllocateRequest): Promise { + const { url, key } = GetConfig().blockchain.realunit.api; + await this.http.post(`${url}/directinvestment/payAndAllocate`, request, { + headers: { 'x-api-key': key }, + }); + } + // --- Brokerbot Methods --- async getBrokerbotPrice(): Promise { diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts index 78658278bb..ee8b8bfa2b 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts @@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; -import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; @@ -14,13 +13,9 @@ import { SpecialExternalAccountService } from '../../payment/services/special-ex import { TransactionService } from '../../payment/services/transaction.service'; import { RealUnitDevService } from '../realunit-dev.service'; -// Mock environment - must be declared before jest.mock since mocks are hoisted -// Use a global variable that can be mutated -(global as any).__mockEnvironment = 'loc'; - jest.mock('src/config/config', () => ({ get Config() { - return { environment: (global as any).__mockEnvironment }; + return { environment: 'loc' }; }, Environment: { LOC: 'loc', @@ -77,11 +72,6 @@ jest.mock('src/shared/services/dfx-logger', () => ({ })), })); -// Mock Lock decorator -jest.mock('src/shared/utils/lock', () => ({ - Lock: () => () => {}, -})); - // Mock Util jest.mock('src/shared/utils/util', () => ({ Util: { @@ -92,7 +82,6 @@ jest.mock('src/shared/utils/util', () => ({ describe('RealUnitDevService', () => { let service: RealUnitDevService; let transactionRequestRepo: jest.Mocked; - let assetService: jest.Mocked; let fiatService: jest.Mocked; let buyService: jest.Mocked; let bankTxService: jest.Mocked; @@ -137,16 +126,13 @@ describe('RealUnitDevService', () => { id: 7, amount: 100, sourceId: 1, - targetId: 408, // Sepolia REALU asset ID + targetId: 408, routeId: 1, status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, }; beforeEach(async () => { - // Reset environment to LOC before each test - (global as any).__mockEnvironment = 'loc'; - const module: TestingModule = await Test.createTestingModule({ providers: [ RealUnitDevService, @@ -157,12 +143,6 @@ describe('RealUnitDevService', () => { update: jest.fn(), }, }, - { - provide: AssetService, - useValue: { - getAssetByQuery: jest.fn(), - }, - }, { provide: FiatService, useValue: { @@ -212,7 +192,6 @@ describe('RealUnitDevService', () => { service = module.get(RealUnitDevService); transactionRequestRepo = module.get(TransactionRequestRepository); - assetService = module.get(AssetService); fiatService = module.get(FiatService); buyService = module.get(BuyService); bankTxService = module.get(BankTxService); @@ -226,141 +205,68 @@ describe('RealUnitDevService', () => { jest.clearAllMocks(); }); - describe('simulateRealuPayments', () => { - it('should skip execution on PRD environment', async () => { - (global as any).__mockEnvironment = 'prd'; - - await service.simulateRealuPayments(); - - expect(assetService.getAssetByQuery).not.toHaveBeenCalled(); - }); - - it('should execute on DEV environment', async () => { - (global as any).__mockEnvironment = 'dev'; - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - transactionRequestRepo.find.mockResolvedValue([]); - - await service.simulateRealuPayments(); - - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); - }); - - it('should execute on LOC environment', async () => { - (global as any).__mockEnvironment = 'loc'; - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - transactionRequestRepo.find.mockResolvedValue([]); - - await service.simulateRealuPayments(); - - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); - }); - - it('should skip if sepolia REALU asset not found', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(null); - - await service.simulateRealuPayments(); - - expect(transactionRequestRepo.find).not.toHaveBeenCalled(); - }); - - it('should skip if no waiting requests', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - transactionRequestRepo.find.mockResolvedValue([]); - - await service.simulateRealuPayments(); - - expect(buyService.getBuyByKey).not.toHaveBeenCalled(); - }); - - it('should query for WAITING_FOR_PAYMENT requests with sepolia REALU targetId', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - transactionRequestRepo.find.mockResolvedValue([]); - - await service.simulateRealuPayments(); - - expect(transactionRequestRepo.find).toHaveBeenCalledWith({ - where: { - status: TransactionRequestStatus.WAITING_FOR_PAYMENT, - type: TransactionRequestType.BUY, - targetId: 408, - }, - }); - }); - }); - describe('simulatePaymentForRequest', () => { - beforeEach(() => { - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - }); - it('should skip if buy route not found', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(null); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(bankTxService.getBankTxByKey).not.toHaveBeenCalled(); }); it('should skip if BankTx already exists (duplicate prevention)', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue({ id: 1 } as any); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(fiatService.getFiat).not.toHaveBeenCalled(); }); it('should skip if fiat not found', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue(null); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(bankService.getBankInternal).not.toHaveBeenCalled(); }); it('should skip if bank not found', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue(mockFiat as any); bankService.getBankInternal.mockResolvedValue(null); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(bankTxService.create).not.toHaveBeenCalled(); }); it('should use YAPEAL bank for CHF', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue({ id: 1, name: 'CHF' } as any); bankService.getBankInternal.mockResolvedValue(null); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(bankService.getBankInternal).toHaveBeenCalledWith('Yapeal', 'CHF'); }); it('should use OLKY bank for EUR', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue({ id: 2, name: 'EUR' } as any); bankService.getBankInternal.mockResolvedValue(null); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); expect(bankService.getBankInternal).toHaveBeenCalledWith('Olkypay', 'EUR'); }); it('should create BankTx, BuyCrypto, update Transaction, and complete TransactionRequest', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue(mockFiat as any); @@ -370,7 +276,7 @@ describe('RealUnitDevService', () => { buyCryptoRepo.create.mockReturnValue({ id: 1 } as any); buyCryptoRepo.save.mockResolvedValue({ id: 1 } as any); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); // 1. Should create BankTx expect(bankTxService.create).toHaveBeenCalledWith( @@ -409,51 +315,7 @@ describe('RealUnitDevService', () => { }); }); - it('should process multiple requests', async () => { - const request1 = { ...mockTransactionRequest, id: 1 }; - const request2 = { ...mockTransactionRequest, id: 2 }; - const request3 = { ...mockTransactionRequest, id: 3 }; - - transactionRequestRepo.find.mockResolvedValue([request1, request2, request3] as any); - buyService.getBuyByKey.mockResolvedValue(mockBuy as any); - bankTxService.getBankTxByKey.mockResolvedValue(null); - fiatService.getFiat.mockResolvedValue(mockFiat as any); - bankService.getBankInternal.mockResolvedValue(mockBank as any); - specialAccountService.getMultiAccounts.mockResolvedValue([]); - bankTxService.create.mockResolvedValue(mockBankTx as any); - buyCryptoRepo.create.mockReturnValue({ id: 1 } as any); - buyCryptoRepo.save.mockResolvedValue({ id: 1 } as any); - - await service.simulateRealuPayments(); - - expect(bankTxService.create).toHaveBeenCalledTimes(3); - expect(buyCryptoRepo.save).toHaveBeenCalledTimes(3); - expect(transactionRequestRepo.update).toHaveBeenCalledTimes(3); - }); - - it('should continue processing other requests if one fails', async () => { - const request1 = { ...mockTransactionRequest, id: 1 }; - const request2 = { ...mockTransactionRequest, id: 2 }; - - transactionRequestRepo.find.mockResolvedValue([request1, request2] as any); - buyService.getBuyByKey.mockRejectedValueOnce(new Error('Failed')).mockResolvedValueOnce(mockBuy as any); - bankTxService.getBankTxByKey.mockResolvedValue(null); - fiatService.getFiat.mockResolvedValue(mockFiat as any); - bankService.getBankInternal.mockResolvedValue(mockBank as any); - specialAccountService.getMultiAccounts.mockResolvedValue([]); - bankTxService.create.mockResolvedValue(mockBankTx as any); - buyCryptoRepo.create.mockReturnValue({ id: 1 } as any); - buyCryptoRepo.save.mockResolvedValue({ id: 1 } as any); - - await service.simulateRealuPayments(); - - // Second request should still be processed - expect(transactionRequestRepo.update).toHaveBeenCalledTimes(1); - expect(transactionRequestRepo.update).toHaveBeenCalledWith(2, expect.anything()); - }); - it('should use unique txInfo per TransactionRequest for duplicate detection', async () => { - transactionRequestRepo.find.mockResolvedValue([mockTransactionRequest as any]); buyService.getBuyByKey.mockResolvedValue(mockBuy as any); bankTxService.getBankTxByKey.mockResolvedValue(null); fiatService.getFiat.mockResolvedValue(mockFiat as any); @@ -463,7 +325,7 @@ describe('RealUnitDevService', () => { buyCryptoRepo.create.mockReturnValue({ id: 1 } as any); buyCryptoRepo.save.mockResolvedValue({ id: 1 } as any); - await service.simulateRealuPayments(); + await service.simulatePaymentForRequest(mockTransactionRequest as any, sepoliaRealuAsset); // Should check for existing BankTx using txInfo field with TransactionRequest ID expect(bankTxService.getBankTxByKey).toHaveBeenCalledWith('txInfo', 'DEV simulation for TransactionRequest 7'); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 9b4f82e2ac..9ad78fb99e 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -437,6 +437,17 @@ export class RealUnitController { return this.realunitService.getAdminTransactions(limit, offset); } + @Put('admin/quotes/:id/confirm-payment') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Confirm payment received for a open RealUnit request (quote)' }) + @ApiParam({ name: 'id', description: 'Transaction request ID' }) + @ApiOkResponse({ description: 'Payment confirmed and shares allocated' }) + @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) + async confirmPaymentReceived(@Param('id') id: string): Promise { + await this.realunitService.confirmPaymentReceived(+id); + } + @Put('admin/registration/:kycStepId/forward') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/supporting/realunit/realunit-dev.service.ts b/src/subdomains/supporting/realunit/realunit-dev.service.ts index 9025c16cca..1e788182b6 100644 --- a/src/subdomains/supporting/realunit/realunit-dev.service.ts +++ b/src/subdomains/supporting/realunit/realunit-dev.service.ts @@ -1,12 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { Config, Environment } from 'src/config/config'; -import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; -import { AssetService } from 'src/shared/models/asset/asset.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { Lock } from 'src/shared/utils/lock'; import { Util } from 'src/shared/utils/util'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; @@ -15,11 +10,7 @@ import { BankTxIndicator } from '../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from '../bank-tx/bank-tx/services/bank-tx.service'; import { BankService } from '../bank/bank/bank.service'; import { IbanBankName } from '../bank/bank/dto/bank.dto'; -import { - TransactionRequest, - TransactionRequestStatus, - TransactionRequestType, -} from '../payment/entities/transaction-request.entity'; +import { TransactionRequest, TransactionRequestStatus } from '../payment/entities/transaction-request.entity'; import { TransactionTypeInternal } from '../payment/entities/transaction.entity'; import { TransactionRequestRepository } from '../payment/repositories/transaction-request.repository'; import { SpecialExternalAccountService } from '../payment/services/special-external-account.service'; @@ -28,16 +19,9 @@ import { TransactionService } from '../payment/services/transaction.service'; @Injectable() export class RealUnitDevService { private readonly logger = new DfxLogger(RealUnitDevService); - private readonly tokenName = 'REALU'; - private readonly tokenBlockchain = Blockchain.SEPOLIA; - - private get isDevEnvironment(): boolean { - return [Environment.DEV, Environment.LOC].includes(Config.environment); - } constructor( private readonly transactionRequestRepo: TransactionRequestRepository, - private readonly assetService: AssetService, private readonly fiatService: FiatService, private readonly buyService: BuyService, private readonly bankTxService: BankTxService, @@ -47,52 +31,7 @@ export class RealUnitDevService { private readonly buyCryptoRepo: BuyCryptoRepository, ) {} - @Cron(CronExpression.EVERY_MINUTE) - @Lock(60) - async simulateRealuPayments(): Promise { - if (!this.isDevEnvironment) return; - - try { - await this.processWaitingRealuRequests(); - } catch (e) { - this.logger.error('Error in REALU payment simulation:', e); - } - } - - private async processWaitingRealuRequests(): Promise { - const realuAsset = await this.assetService.getAssetByQuery({ - name: this.tokenName, - blockchain: this.tokenBlockchain, - type: AssetType.TOKEN, - }); - - if (!realuAsset) { - this.logger.warn('REALU asset not found - skipping buy simulation'); - return; - } - - const waitingRequests = await this.transactionRequestRepo.find({ - where: { - status: TransactionRequestStatus.WAITING_FOR_PAYMENT, - type: TransactionRequestType.BUY, - targetId: realuAsset.id, - }, - }); - - if (waitingRequests.length === 0) return; - - this.logger.info(`Found ${waitingRequests.length} waiting REALU transaction requests to simulate`); - - for (const request of waitingRequests) { - try { - await this.simulatePaymentForRequest(request, realuAsset); - } catch (e) { - this.logger.error(`Failed to simulate payment for TransactionRequest ${request.id}:`, e); - } - } - } - - private async simulatePaymentForRequest(request: TransactionRequest, sepoliaRealuAsset: Asset): Promise { + async simulatePaymentForRequest(request: TransactionRequest, sepoliaRealuAsset: Asset): Promise { // Get Buy route with user relation const buy = await this.buyService.getBuyByKey('id', request.routeId); if (!buy) { diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 2054f35c5c..e2b8f5a551 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -27,6 +27,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 { 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'; import { SellService } from 'src/subdomains/core/sell-crypto/route/sell.service'; @@ -44,10 +45,12 @@ import { UserService } from 'src/subdomains/generic/user/models/user/user.servic import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; import { transliterate } from 'transliteration'; import { AssetPricesService } from '../pricing/services/asset-prices.service'; import { PriceCurrency, PriceValidity, PricingService } from '../pricing/services/pricing.service'; +import { RealUnitDevService } from './realunit-dev.service'; import { AccountHistoryClientResponse, AccountSummaryClientResponse, @@ -111,6 +114,8 @@ export class RealUnitService { private readonly transactionRequestService: TransactionRequestService, private readonly transactionService: TransactionService, private readonly accountMergeService: AccountMergeService, + private readonly devService: RealUnitDevService, + private readonly swissQrService: SwissQRService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -266,9 +271,9 @@ export class RealUnitService { zip: realunitAddress.zip, city: realunitAddress.city, country: realunitAddress.country, - // Bank info from BuyService - iban: buyPaymentInfo.iban, - bic: buyPaymentInfo.bic, + // Bank info from RealUnit config (not Yapeal/DFX) + iban: realunitBank.iban, + bic: realunitBank.bic, // Amount and currency amount: buyPaymentInfo.amount, currency: buyPaymentInfo.currency.name, @@ -284,7 +289,16 @@ export class RealUnitService { priceSteps: buyPaymentInfo.priceSteps, // RealUnit specific estimatedAmount: buyPaymentInfo.estimatedAmount, - paymentRequest: buyPaymentInfo.paymentRequest, + paymentRequest: buyPaymentInfo.isValid + ? this.generatePaymentRequest( + currencyName, + buyPaymentInfo.amount, + buyPaymentInfo.remittanceInfo, + realunitBank, + realunitAddress, + user.userData, + ) + : undefined, isValid: buyPaymentInfo.isValid, error: buyPaymentInfo.error, }; @@ -292,6 +306,39 @@ export class RealUnitService { return response; } + private generatePaymentRequest( + currency: string, + amount: number, + reference: string, + bank: { iban: string; bic: string; recipient: string; name: string }, + address: { street: string; number: string; zip: string; city: string; country: string }, + userData: UserData, + ): string { + const bankInfo = { + name: bank.recipient, + bank: bank.name, + street: address.street, + number: address.number, + zip: address.zip, + city: address.city, + country: address.country, + iban: bank.iban, + bic: bank.bic, + sepaInstant: false, + }; + + if (currency === 'CHF') { + return this.swissQrService.createQrCode(amount, 'CHF', reference, bankInfo, userData); + } + + return PdfUtil.generateGiroCode({ + ...bankInfo, + currency, + amount, + reference, + }); + } + async confirmBuy(userId: number, requestId: number): Promise { const request = await this.transactionRequestService.getOrThrow(requestId, userId); if (!request.isValid) throw new BadRequestException('Transaction request is not valid'); @@ -796,6 +843,37 @@ export class RealUnitService { // --- Admin Methods --- + async confirmPaymentReceived(requestId: number): Promise { + const request = await this.transactionRequestService.getTransactionRequest(requestId, { user: true }); + if (!request) throw new NotFoundException('Transaction request not found'); + if (request.status !== TransactionRequestStatus.WAITING_FOR_PAYMENT) { + throw new BadRequestException('Transaction request is not in WaitingForPayment status'); + } + + if ([Environment.DEV, Environment.LOC].includes(Config.environment)) { + const realuAsset = await this.getRealuAsset(); + await this.devService.simulatePaymentForRequest(request, realuAsset); + } else { + const aktionariatResponse = JSON.parse(request.aktionariatResponse); + const reference = aktionariatResponse.reference; + if (!reference) throw new BadRequestException('No reference found in aktionariat response'); + + // Convert amount to CHF Rappen for Aktionariat API + const fiat = await this.fiatService.getFiat(request.sourceId); + let amountChf = request.amount; + if (fiat.name !== 'CHF') { + const price = await this.pricingService.getPrice(fiat, PriceCurrency.CHF, PriceValidity.ANY); + amountChf = price.convert(request.amount); + } + + await this.blockchainService.payAndAllocate({ + amount: Math.round(amountChf * 100), + ref: reference, + }); + await this.transactionRequestService.complete(request.id); + } + } + async getAdminQuotes(limit = 50, offset = 0): Promise { const realuAsset = await this.getRealuAsset(); const requests = await this.transactionRequestService.getByAssetId(realuAsset.id, limit, offset); From b04ab40347f844cf74f4a67e4a0c0768acdc3207 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:09:41 +0100 Subject: [PATCH 07/12] fix: always require KYC Level 30 for RealUnit purchases (#3194) * fix: always require KYC Level 30 for RealUnit purchases Remove the amount-based KYC Level 50 requirement for REALU buys. All RealUnit purchases now consistently require only KYC Level 30, regardless of the purchase amount. * fix: update API docs to reflect KYC Level 30 for RealUnit buy --- .../controllers/realunit.controller.ts | 4 ++-- .../supporting/realunit/realunit.service.ts | 20 +++---------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 9ad78fb99e..5c4030bcf4 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -277,10 +277,10 @@ export class RealUnitController { @ApiOperation({ summary: 'Get payment info for RealUnit buy', description: - 'Returns personal IBAN and payment details for purchasing REALU tokens. Requires KYC Level 50 and RealUnit registration.', + 'Returns personal IBAN and payment details for purchasing REALU tokens. Requires KYC Level 30 and RealUnit registration.', }) @ApiOkResponse({ type: RealUnitPaymentInfoDto }) - @ApiBadRequestResponse({ description: 'KYC Level 50 required, registration missing, or address not on allowlist' }) + @ApiBadRequestResponse({ description: 'KYC Level 30 required, registration missing, or address not on allowlist' }) async getPaymentInfo(@GetJwt() jwt: JwtPayload, @Body() dto: RealUnitBuyDto): Promise { const user = await this.userService.getUser(jwt.user, { userData: { kycSteps: true, country: true } }); return this.realunitService.getPaymentInfo(user, dto); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index e2b8f5a551..d28975f12b 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -223,25 +223,11 @@ export class RealUnitService { throw new RegistrationRequiredException(); } - // 2. KYC Level check - Level 30 for amounts <= 1000 CHF, Level 50 for higher amounts + // 2. KYC Level check - Level 30 required for all RealUnit purchases const currency = await this.fiatService.getFiatByName(currencyName); - const amountChf = - currencyName === 'CHF' - ? dto.amount - : (await this.pricingService.getPrice(currency, PriceCurrency.CHF, PriceValidity.ANY)).convert(dto.amount); - const maxAmountForLevel30 = Config.tradingLimits.monthlyDefaultWoKyc; - const requiresLevel50 = amountChf > maxAmountForLevel30; - const requiredLevel = requiresLevel50 ? KycLevel.LEVEL_50 : KycLevel.LEVEL_30; - - if (userData.kycLevel < requiredLevel) { - throw new KycLevelRequiredException( - requiredLevel, - userData.kycLevel, - requiresLevel50 - ? `KYC Level 50 required for amounts above ${maxAmountForLevel30} CHF` - : 'KYC Level 30 required for RealUnit', - ); + if (userData.kycLevel < KycLevel.LEVEL_30) { + throw new KycLevelRequiredException(KycLevel.LEVEL_30, userData.kycLevel, 'KYC Level 30 required for RealUnit'); } // 3. Get or create Buy route for REALU From 7912f38ac159af948969c05a1d44ebd5f1659309 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:14:51 +0100 Subject: [PATCH 08/12] [NOTASK] AutoResponder Refactoring (#3189) * [NOTASK] AutoResponder Refactoring * [NOTASK] Refactoring * [NOTASK] Refactoring 2 --- .../services/support-issue-job.service.ts | 22 +++++++++++++------ .../services/support-issue.service.ts | 6 ++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts b/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts index 9f5ca4daa8..03cc1a42be 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue-job.service.ts @@ -76,7 +76,11 @@ export class SupportIssueJobService { async moneroComplete(): Promise { const issues = await this.getAutoResponseIssues({ type: SupportIssueType.TRANSACTION_ISSUE, - reason: In([SupportIssueReason.FUNDS_NOT_RECEIVED, SupportIssueReason.TRANSACTION_MISSING]), + reason: In([ + SupportIssueReason.FUNDS_NOT_RECEIVED, + SupportIssueReason.TRANSACTION_MISSING, + SupportIssueReason.OTHER, + ]), transaction: { buyCrypto: { id: Not(IsNull()), isComplete: true, amlCheck: CheckStatus.PASS, outputAsset: { name: 'XMR' } }, }, @@ -87,16 +91,20 @@ export class SupportIssueJobService { // --- HELPER METHODS --- // private async getAutoResponseIssues(where: FindOptionsWhere): Promise { + const request: FindOptionsWhere = { state: SupportIssueInternalState.CREATED, ...where }; return this.supportIssueRepo .find({ - where: { - state: SupportIssueInternalState.CREATED, - messages: { author: Not(AutoResponder) }, - ...where, - }, + where: [ + { ...request, clerk: IsNull() }, + { ...request, clerk: Not(AutoResponder) }, + ], relations: { messages: true }, }) - .then((issues) => issues.filter((i) => i.messages.at(-1).author === CustomerAuthor)); + .then((issues) => + issues.filter( + (i) => i.messages.at(-1).author === CustomerAuthor && i.messages.every((m) => m.author !== AutoResponder), + ), + ); } private async sendAutoResponse( diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 27c6620dbc..0f85f3b0e2 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -168,15 +168,15 @@ export class SupportIssueService { } async updateIssueInternal(entity: SupportIssue, dto: UpdateSupportIssueDto): Promise { - Object.assign(entity, dto); - await this.supportLogService.createSupportLog(entity.userData, { type: SupportLogType.SUPPORT, supportIssue: entity, ...dto, }); - return this.supportIssueRepo.save(entity); + await this.supportIssueRepo.update(entity.id, { state: dto.state, clerk: dto.clerk, department: dto.department }); + + return Object.assign(entity, dto); } async createMessage(id: string, dto: CreateSupportMessageDto, userDataId?: number): Promise { From 66e347497f8d16bca85c6c32b4d3c91d2310d7c9 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:40:41 +0100 Subject: [PATCH 09/12] [NOTASK] fix ident syncIdentFile bug (#3197) --- .../generic/kyc/services/integration/sum-sub.service.ts | 1 + src/subdomains/generic/kyc/services/kyc.service.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/subdomains/generic/kyc/services/integration/sum-sub.service.ts b/src/subdomains/generic/kyc/services/integration/sum-sub.service.ts index 5ddd5baeef..6795ddb289 100644 --- a/src/subdomains/generic/kyc/services/integration/sum-sub.service.ts +++ b/src/subdomains/generic/kyc/services/integration/sum-sub.service.ts @@ -230,6 +230,7 @@ export class SumsubService { 'X-App-Access-Sig': signature, 'X-App-Access-Ts': ts, }, + tryCount: 3, }); } diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 9147646e34..38817f8d69 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -269,15 +269,15 @@ export class KycService { entity.manualReview(comment); } - await this.createStepLog(entity.userData, entity); - await this.kycStepRepo.save(entity); - if ( !entity.userData.kycFiles.some((f) => f.subType === FileSubType.IDENT_REPORT) && (entity.isCompleted || entity.status === ReviewStatus.MANUAL_REVIEW) ) await this.syncIdentFilesInternal(entity); + await this.createStepLog(entity.userData, entity); + await this.kycStepRepo.save(entity); + if (entity.isCompleted) { await this.completeIdent(entity, nationality); await this.checkDfxApproval(entity); From 59623a5fa9da62a5fa0ba5cec9f3813195f48c86 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:54:42 +0100 Subject: [PATCH 10/12] fix: add Cardano to payment request broadcast hint (#3198) * fix: add Cardano to payment request broadcast hint * fix: format payment-request mapper --- .../core/payment-link/dto/payment-request.mapper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts index 7083666bfb..c4b79eeab0 100644 --- a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts +++ b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts @@ -45,7 +45,9 @@ export class PaymentRequestMapper { ): PaymentLinkEvmPaymentDto { const infoUrl = `${Config.url()}/lnurlp/tx/${paymentActivation.payment.uniqueId}`; - const hint = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.SOLANA, Blockchain.TRON].includes(method) + const hint = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO].includes( + method, + ) ? `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}` : `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`; From cec5c29ded33f4e518fa7aab6c7c84953187ffc8 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:06:40 +0100 Subject: [PATCH 11/12] [NOTASK] fix refundAmount bug (#3179) * [NOTASK] fix refundAmount bug * [NOTASK] Fix different currency refund bug * [NOTASK] Refactoring * [NOTASK] Fix refundAmount for different refundAsset * [NOTASK] Refactoring 2 * [NOTASK] Refactoring 3 * [NOTASK] fix format --- .../process/services/buy-crypto.service.ts | 1 + .../controllers/transaction.controller.ts | 2 ++ .../core/history/dto/refund-data.dto.ts | 3 +++ .../core/history/dto/refund-internal.dto.ts | 1 + .../core/transaction/transaction-util.service.ts | 11 +++++++++-- .../bank-tx-return/bank-tx-return.service.ts | 2 +- .../payment/services/transaction-helper.ts | 16 +++++++++------- 7 files changed, 26 insertions(+), 10 deletions(-) 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 006c9ea7f4..a9155f0c12 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 @@ -535,6 +535,7 @@ export class BuyCryptoService { TransactionUtilService.validateRefund(buyCrypto, { refundIban: chargebackIban, chargebackAmount, + chargebackAmountInInputAsset: dto.chargebackAmountInInputAsset, }); if ( diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index d8bcd0b5d1..35cba584ca 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -543,6 +543,7 @@ export class TransactionController { refundIban: dto.refundTarget ?? refundData.refundTarget, chargebackCurrency, creditorData: dto.creditorData, + chargebackAmountInInputAsset: refundData.refundPrice.invert().convert(refundData.refundAmount), ...refundDto, }); } @@ -571,6 +572,7 @@ export class TransactionController { refundIban: dto.refundTarget ?? refundData.refundTarget, chargebackCurrency, creditorData: dto.creditorData, + chargebackAmountInInputAsset: refundData.refundPrice.invert().convert(refundData.refundAmount), ...refundDto, }); } diff --git a/src/subdomains/core/history/dto/refund-data.dto.ts b/src/subdomains/core/history/dto/refund-data.dto.ts index 214cd750c6..93d2d91975 100644 --- a/src/subdomains/core/history/dto/refund-data.dto.ts +++ b/src/subdomains/core/history/dto/refund-data.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger import { ActiveDto } from 'src/shared/models/active'; import { AssetDto } from 'src/shared/models/asset/dto/asset.dto'; import { FiatDto } from 'src/shared/models/fiat/dto/fiat.dto'; +import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; export class RefundFeeDto { @ApiProperty({ description: 'Network fee in refundAsset' }) @@ -59,6 +60,8 @@ export class RefundDataDto { @ApiProperty({ oneOf: [{ $ref: getSchemaPath(AssetDto) }, { $ref: getSchemaPath(FiatDto) }] }) refundAsset: ActiveDto; + refundPrice: Price; + @ApiPropertyOptional({ description: 'IBAN for bank tx or blockchain address for crypto tx' }) refundTarget?: string; diff --git a/src/subdomains/core/history/dto/refund-internal.dto.ts b/src/subdomains/core/history/dto/refund-internal.dto.ts index 357bcc187e..de0b8ccae0 100644 --- a/src/subdomains/core/history/dto/refund-internal.dto.ts +++ b/src/subdomains/core/history/dto/refund-internal.dto.ts @@ -45,6 +45,7 @@ export class BankTxRefund extends BaseRefund { chargebackCurrency?: string; chargebackOutput?: FiatOutput; creditorData?: CreditorData; + chargebackAmountInInputAsset?: number; } export class CheckoutTxRefund extends BaseRefund { diff --git a/src/subdomains/core/transaction/transaction-util.service.ts b/src/subdomains/core/transaction/transaction-util.service.ts index 9fb2eed606..4d1f8f704b 100644 --- a/src/subdomains/core/transaction/transaction-util.service.ts +++ b/src/subdomains/core/transaction/transaction-util.service.ts @@ -32,6 +32,7 @@ export type RefundValidation = { refundIban?: string; refundUser?: User; chargebackAmount?: number; + chargebackAmountInInputAsset?: number; }; @Injectable() @@ -77,16 +78,22 @@ export class TransactionUtilService { throw new BadRequestException('Transaction is already returned'); if (entity instanceof BankTxReturn) { - if (dto.chargebackAmount && dto.chargebackAmount > entity.bankTx.amount) + if ( + dto.chargebackAmount && + ((dto.chargebackAmount > entity.bankTx.refundAmount && !dto.chargebackAmountInInputAsset) || + dto.chargebackAmountInInputAsset > entity.bankTx.refundAmount) + ) throw new BadRequestException('You can not refund more than the input amount'); return; } if (![CheckStatus.FAIL, CheckStatus.PENDING].includes(entity.amlCheck) || entity.outputAmount) throw new BadRequestException('Only failed or pending transactions are refundable'); + if ( dto.chargebackAmount && - dto.chargebackAmount > (entity instanceof BuyCrypto && entity.bankTx ? entity.bankTx.amount : entity.inputAmount) + ((dto.chargebackAmount > entity.refundAmount && !dto.chargebackAmountInInputAsset) || + dto.chargebackAmountInInputAsset > entity.refundAmount) ) throw new BadRequestException('You can not refund more than the input amount'); } diff --git a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts index 2992519413..50715d78df 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts @@ -170,7 +170,6 @@ export class BankTxReturnService { where: { id: buyCryptoId }, relations: { transaction: { userData: true }, bankTx: true, chargebackOutput: true }, }); - if (!bankTxReturn) throw new NotFoundException('BankTxReturn not found'); return this.refundBankTx(bankTxReturn, { @@ -190,6 +189,7 @@ export class BankTxReturnService { TransactionUtilService.validateRefund(bankTxReturn, { refundIban: chargebackIban, chargebackAmount, + chargebackAmountInInputAsset: dto.chargebackAmountInInputAsset, }); if ( diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index d6972a33c0..b673e656cf 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -408,7 +408,7 @@ export class TransactionHelper implements OnModuleInit { ? await this.fiatService.getFiatByName('EUR') : inputCurrency; - const price = + const chfPrice = refundEntity.manualChfPrice ?? (await this.pricingService.getPrice(PriceCurrency.CHF, inputCurrency, PriceValidity.PREFER_VALID)); @@ -419,7 +419,7 @@ export class TransactionHelper implements OnModuleInit { const chargebackFee = await this.feeService.getChargebackFee({ from: inputCurrency, - txVolume: price.invert().convert(inputAmount), + txVolume: chfPrice.invert().convert(inputAmount), paymentMethodIn: refundEntity.paymentMethodIn, bankIn, specialCodes: [], @@ -427,16 +427,16 @@ export class TransactionHelper implements OnModuleInit { userData, }); - const dfxFeeAmount = inputAmount * chargebackFee.rate + price.convert(chargebackFee.fixed); + const dfxFeeAmount = inputAmount * chargebackFee.rate + chfPrice.convert(chargebackFee.fixed); - let networkFeeAmount = price.convert(chargebackFee.network); + let networkFeeAmount = chfPrice.convert(chargebackFee.network); if (isAsset(inputCurrency) && inputCurrency.blockchain === Blockchain.SOLANA) networkFeeAmount += await this.getSolanaRentExemptionFee(inputCurrency); const bankFeeAmount = refundEntity.paymentMethodIn === FiatPaymentMethod.BANK - ? price.convert( + ? chfPrice.convert( chargebackFee.bankRate * inputAmount + chargebackFee.bankFixed + refundEntity.chargebackBankFee * 1.01, ) : 0; // Bank fee buffer 1% @@ -452,7 +452,7 @@ export class TransactionHelper implements OnModuleInit { // convert to refund currency const refundPrice = await this.pricingService.getPrice(inputCurrency, refundCurrency, PriceValidity.VALID_ONLY); - const refundAmount = Util.roundReadable(refundPrice.convert(inputAmount - totalFeeAmount), amountType); + const targetRefundAmount = Util.roundReadable(refundPrice.convert(inputAmount - totalFeeAmount), amountType); const feeDfx = Util.roundReadable(refundPrice.convert(dfxFeeAmount), feeAmountType); const feeNetwork = Util.roundReadable(refundPrice.convert(networkFeeAmount), feeAmountType); const feeBank = Util.roundReadable(refundPrice.convert(bankFeeAmount), feeAmountType); @@ -464,7 +464,9 @@ export class TransactionHelper implements OnModuleInit { expiryDate: Util.secondsAfter(Config.transactionRefundExpirySeconds), inputAmount: Util.roundReadable(inputAmount, amountType), inputAsset, - refundAmount, + refundAmount: + inputAsset.id !== refundAsset.id ? targetRefundAmount : Math.min(refundEntity.refundAmount, targetRefundAmount), + refundPrice, fee: { dfx: feeDfx, network: feeNetwork, From 1d1cb3d54a67dbe46f71387ed96a15532121e2c7 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:24:38 +0100 Subject: [PATCH 12/12] chore: refactoring (#3199) --- .../realunit/realunit-blockchain.service.ts | 8 ++--- .../payment/services/transaction.service.ts | 35 ++++++------------- .../integration/pricing-realunit.service.ts | 29 ++++++++------- .../pricing/services/pricing.service.ts | 14 ++++++-- 4 files changed, 39 insertions(+), 47 deletions(-) diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index 5ced8c9c98..cd92384ca5 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -56,14 +56,12 @@ export class RealUnitBlockchainService { ); } - async getRealUnitPrice(): Promise { - const { priceInCHF } = await this.fetchPrice(); - return priceInCHF; + async getRealUnitPriceChf(): Promise { + return this.fetchPrice().then((r) => r.priceInCHF); } async getRealUnitPriceEur(): Promise { - const { priceInEUR } = await this.fetchPrice(); - return priceInEUR; + return this.fetchPrice().then((r) => r.priceInEUR); } async requestPaymentInstructions(request: PaymentInstructionsRequest): Promise { diff --git a/src/subdomains/supporting/payment/services/transaction.service.ts b/src/subdomains/supporting/payment/services/transaction.service.ts index 3e0f3cd3c7..884a04c6d9 100644 --- a/src/subdomains/supporting/payment/services/transaction.service.ts +++ b/src/subdomains/supporting/payment/services/transaction.service.ts @@ -8,6 +8,7 @@ import { Between, Brackets, FindOptionsRelations, IsNull, LessThanOrEqual, Not } import { CreateTransactionDto } from '../dto/input/create-transaction.dto'; import { UpdateTransactionInternalDto } from '../dto/input/update-transaction-internal.dto'; import { UpdateTransactionDto } from '../dto/update-transaction.dto'; +import { TransactionRequestType } from '../entities/transaction-request.entity'; import { Transaction, TransactionSourceType } from '../entities/transaction.entity'; import { TransactionRepository } from '../repositories/transaction.repository'; import { SpecialExternalAccountService } from './special-external-account.service'; @@ -257,30 +258,16 @@ export class TransactionService { } async getByAssetId(assetId: number, limit = 50, offset = 0): Promise { - return this.repo - .createQueryBuilder('transaction') - .select('transaction') - .leftJoinAndSelect('transaction.request', 'request') - .leftJoinAndSelect('transaction.user', 'user') - .leftJoinAndSelect('transaction.userData', 'userData') - .where('transaction.type IS NOT NULL') - .andWhere( - new Brackets((qb) => - qb - .where('request.type = :buyType AND request.targetId = :assetId', { - buyType: 'Buy', - assetId, - }) - .orWhere('request.type = :sellType AND request.sourceId = :assetId', { - sellType: 'Sell', - assetId, - }), - ), - ) - .orderBy('transaction.created', 'DESC') - .take(limit) - .skip(offset) - .getMany(); + return this.repo.find({ + where: [ + { type: Not(IsNull()), request: { type: TransactionRequestType.BUY, targetId: assetId } }, + { type: Not(IsNull()), request: { type: TransactionRequestType.SELL, sourceId: assetId } }, + ], + order: { created: 'DESC' }, + take: limit, + skip: offset, + relations: { request: true, user: true, userData: true }, + }); } async getTransactionByKey(key: string, value: any): Promise { diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts index c705e04ccb..c62d673ffa 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts @@ -11,7 +11,11 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI private static readonly ZCHF = 'ZCHF'; private static readonly EUR = 'EUR'; - private static readonly ALLOWED_ASSETS = [PricingRealUnitService.REALU, PricingRealUnitService.ZCHF]; + private static readonly ALLOWED_ASSETS = [ + PricingRealUnitService.REALU, + PricingRealUnitService.ZCHF, + PricingRealUnitService.EUR, + ]; private realunitService: RealUnitBlockchainService; @@ -26,24 +30,19 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI async getPrice(from: string, to: string): Promise { if (!PricingRealUnitService.ALLOWED_ASSETS.includes(from)) throw new Error(`from asset ${from} is not allowed`); if (!PricingRealUnitService.ALLOWED_ASSETS.includes(to)) throw new Error(`to asset ${to} is not allowed`); + if (![from, to].includes(PricingRealUnitService.REALU)) + throw new Error(`from asset ${from} to asset ${to} is not allowed`); - const realunitPrice = await this.realunitService.getRealUnitPrice(); + const isEurPair = [from, to].includes(PricingRealUnitService.EUR); - const assetPrice = from === PricingRealUnitService.REALU ? 1 / realunitPrice : realunitPrice; - - return Price.create(from, to, Util.round(assetPrice, 8)); - } + const realunitPrice = isEurPair + ? await this.realunitService.getRealUnitPriceEur() + : await this.realunitService.getRealUnitPriceChf(); - async getDirectEurPrice(from: string, to: string): Promise { - const isRealuToEur = from === PricingRealUnitService.REALU && to === PricingRealUnitService.EUR; - const isEurToRealu = from === PricingRealUnitService.EUR && to === PricingRealUnitService.REALU; + if (realunitPrice == null) throw new Error(`No price available for ${from} -> ${to}`); - if (!isRealuToEur && !isEurToRealu) return undefined; - - const eurPrice = await this.realunitService.getRealUnitPriceEur(); - if (eurPrice == null) return undefined; + const assetPrice = from === PricingRealUnitService.REALU ? 1 / realunitPrice : realunitPrice; - const price = isRealuToEur ? 1 / eurPrice : eurPrice; - return Price.create(from, to, Util.round(price, 8)); + return Price.create(from, to, Util.round(assetPrice, 8)); } } diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index 7f5e8cbbda..b33bc7d863 100644 --- a/src/subdomains/supporting/pricing/services/pricing.service.ts +++ b/src/subdomains/supporting/pricing/services/pricing.service.ts @@ -27,8 +27,8 @@ import { PricingConstantService } from './integration/pricing-constant.service'; import { PricingDeuroService } from './integration/pricing-deuro.service'; import { PricingDexService } from './integration/pricing-dex.service'; import { PricingEbel2xService } from './integration/pricing-ebel2x.service'; -import { PricingJuiceService } from './integration/pricing-juice.service'; import { PricingFrankencoinService } from './integration/pricing-frankencoin.service'; +import { PricingJuiceService } from './integration/pricing-juice.service'; import { PricingRealUnitService } from './integration/pricing-realunit.service'; export enum PriceCurrency { @@ -187,8 +187,7 @@ export class PricingService implements OnModuleInit { try { if (activesEqual(from, to)) return Price.create(from.name, to.name, 1); - // Direct EUR price for REALU from Aktionariat API - const directPrice = await this.realunitService.getDirectEurPrice(from.name, to.name).catch(() => undefined); + const directPrice = await this.getDirectPrice(from, to); if (directPrice) return directPrice; const shouldUpdate = validity !== PriceValidity.ANY; @@ -318,6 +317,15 @@ export class PricingService implements OnModuleInit { return true; } + private getDirectPrice(from: Active, to: Active): Promise { + try { + if ([from.name, to.name].every((n) => ['EUR', 'REALU'].includes(n))) + return this.realunitService.getPrice(from.name, to.name); + } catch { + return undefined; + } + } + // --- HELPER METHODS --- // private itemString(item: Active): string { return `${isFiat(item) ? 'fiat' : 'asset'} ${item.id}`;