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..cd92384ca5 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,115 @@ 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; +} + +interface PayAndAllocateRequest { + amount: number; + ref: string; +} - 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}/directinvestment/getPrice`, null, { + headers: { 'x-api-key': key }, + }); + }, + undefined, + true, + ); + } + + async getRealUnitPriceChf(): Promise { + return this.fetchPrice().then((r) => r.priceInCHF); } - private getBrokerbotContract(): Contract { - return new Contract(BROKERBOT_ADDRESS, BROKERBOT_ABI, this.getEvmClient().wallet); + async getRealUnitPriceEur(): Promise { + return this.fetchPrice().then((r) => r.priceInEUR); } - onModuleInit() { - this.registryService = this.moduleRef.get(BlockchainRegistryService, { strict: false }); + async requestPaymentInstructions(request: PaymentInstructionsRequest): Promise { + const { url, key } = GetConfig().blockchain.realunit.api; + return this.http.post(`${url}/directinvestment/requestPaymentInstructions`, request, { + headers: { 'x-api-key': key }, + }); } - async getRealUnitPrice(): Promise { - const price = await this.getBrokerbotContract().getPrice(); - return EvmUtil.fromWeiAmount(price); + 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 { - 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/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) { 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/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/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/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.`; 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/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/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); 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/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/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, 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-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, diff --git a/src/subdomains/supporting/payment/services/transaction-request.service.ts b/src/subdomains/supporting/payment/services/transaction-request.service.ts index 02219fe062..00f42dc3de 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 { @@ -349,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..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'; @@ -256,6 +257,19 @@ export class TransactionService { .getRawMany(); } + async getByAssetId(assetId: number, limit = 50, offset = 0): Promise { + 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 { return this.repo .createQueryBuilder('transaction') 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..c62d673ffa 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts @@ -9,8 +9,13 @@ 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]; + private static readonly ALLOWED_ASSETS = [ + PricingRealUnitService.REALU, + PricingRealUnitService.ZCHF, + PricingRealUnitService.EUR, + ]; private realunitService: RealUnitBlockchainService; @@ -25,8 +30,16 @@ 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 realunitPrice = isEurPair + ? await this.realunitService.getRealUnitPriceEur() + : await this.realunitService.getRealUnitPriceChf(); + + if (realunitPrice == null) throw new Error(`No price available for ${from} -> ${to}`); const assetPrice = from === PricingRealUnitService.REALU ? 1 / realunitPrice : realunitPrice; diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index 23e69d98a3..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,6 +187,9 @@ export class PricingService implements OnModuleInit { try { if (activesEqual(from, to)) return Price.create(from.name, to.name, 1); + const directPrice = await this.getDirectPrice(from, to); + if (directPrice) return directPrice; + const shouldUpdate = validity !== PriceValidity.ANY; const [fromRules, toRules] = await Promise.all([ @@ -314,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}`; 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 23327e13b6..5c4030bcf4 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'; @@ -45,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, @@ -275,15 +277,23 @@ 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); } + @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') @@ -407,6 +417,37 @@ 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/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/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-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 babdcd6a5b..d28975f12b 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'; @@ -42,16 +43,21 @@ 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 { 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, HoldersClientResponse, TokenInfoClientResponse, } from './dto/client.dto'; +import { RealUnitQuoteDto, RealUnitTransactionDto } from './dto/realunit-admin.dto'; import { RealUnitDtoMapper } from './dto/realunit-dto.mapper'; import { AktionariatRegistrationDto, @@ -106,7 +112,10 @@ export class RealUnitService { private readonly sellService: SellService, private readonly eip7702DelegationService: Eip7702DelegationService, 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; } @@ -214,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 @@ -262,9 +257,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, @@ -280,7 +275,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, }; @@ -288,6 +292,60 @@ 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'); + 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 @@ -769,6 +827,71 @@ export class RealUnitService { return response; } + // --- 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); + + 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); 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 {