diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt new file mode 100644 index 000000000..f4dd5b78f --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AdminTransferReserveRequest.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.inout + +import java.math.BigDecimal + +data class AdminTransferReserveRequest( + val sourceSymbol: String, + val sourceAmount: BigDecimal, + val destSymbol: String, + val destAmount: BigDecimal? = null, + val rate: BigDecimal? = null, + val receiverUuid: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt index ab728f008..838b2ba40 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TransferCategory.kt @@ -10,6 +10,7 @@ enum class TransferCategory { WITHDRAW_REJECT, WITHDRAW_CANCEL, PURCHASE_FINALIZED, + IMPERSONATED_PURCHASE_FINALIZED, WITHDRAW_MANUALLY, ORDER_CREATE, ORDER_CANCEL, diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt index 690a0c411..1e12ff483 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/UserTransactionCategory.kt @@ -9,6 +9,7 @@ enum class UserTransactionCategory { WITHDRAW, FEE, SWAP, + IMPERSONATED_SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt index d2c8e8956..8b0bd2e7c 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/WalletProxy.kt @@ -259,4 +259,12 @@ interface WalletProxy { suspend fun deleteGateway(token: String, gatewayUuid: String, currencySymbol: String) suspend fun submitDepositWebhook(request: DepositWebhookRequest, signature: String): DepositWebhookResponse + + suspend fun reserveSwapByAdmin(token: String, request: AdminTransferReserveRequest): ReservedTransferResponse + suspend fun finalizeSwapByAdmin( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult } \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt new file mode 100644 index 000000000..5fefbdba2 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AdminSwapController.kt @@ -0,0 +1,111 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AdminTransferReserveRequest +import co.nilin.opex.api.core.inout.ReservedTransferResponse +import co.nilin.opex.api.core.inout.TransferResult +import co.nilin.opex.api.core.spi.WalletProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/opex/v1/admin/swap") +@Tag(name = "Admin Swap", description = "Admin impersonated swap reserve and finalize operations.") +class AdminSwapController( + val walletProxy: WalletProxy +) { + @PostMapping("/reserve") + @Operation( + summary = "Admin Reserve Swap", + description = """POST /opex/v1/admin/swap/reserve. +Security: Bearer admin-token required. Requires authenticated admin JWT. +Behavior: Admin can reserve swap on behalf of a user by specifying custom rate or amounts.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ReservedTransferResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. User does not have admin permissions.", + content = [Content()] + ) + ] + ) + suspend fun reserve( + @RequestBody request: AdminTransferReserveRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ReservedTransferResponse { + return walletProxy.reserveSwapByAdmin(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PostMapping("/finalize/{reserveUuid}") + @Operation( + summary = "Admin Finalize Transfer", + description = """POST /opex/v1/admin/swap/finalize/{reserveUuid}. +Security: Bearer admin-token required. Requires authenticated admin JWT. +Behavior: Finalizes a reserved swap on behalf of the user.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TransferResult::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. User does not have admin permissions.", + content = [Content()] + ) + ] + ) + suspend fun finalizeTransfer( + @Parameter( + name = "reserveUuid", + description = "Swap reserve UUID returned by reserve endpoint.", + required = true + ) + @PathVariable reserveUuid: String, + @Parameter(name = "description", description = "Optional transfer description.", required = false) + @RequestParam description: String?, + @Parameter(name = "transferRef", description = "Optional external transfer reference.", required = false) + @RequestParam transferRef: String?, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): TransferResult { + return walletProxy.finalizeSwapByAdmin( + securityContext.jwtAuthentication().tokenValue(), + reserveUuid, + description, + transferRef + ) + } +} \ No newline at end of file diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt index a5b9aae5e..7e87050fb 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/WalletProxyImpl.kt @@ -1089,6 +1089,7 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC } .awaitBodilessEntity() } + override suspend fun submitDepositWebhook( request: DepositWebhookRequest, signature: String @@ -1107,5 +1108,40 @@ class WalletProxyImpl(@Qualifier("generalWebClient") private val webClient: WebC .awaitSingle() } } + + override suspend fun reserveSwapByAdmin( + token: String, + request: AdminTransferReserveRequest + ): ReservedTransferResponse { + return webClient.post() + .uri("$baseUrl/admin/v1/transfer/reserve") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } + + override suspend fun finalizeSwapByAdmin( + token: String, + reserveUuid: String, + description: String?, + transferRef: String? + ): TransferResult { + return webClient.post() + .uri("$baseUrl/admin/v1/transfer/${reserveUuid}") { + it.queryParam("description", description) + it.queryParam("transferRef", transferRef) + it.build() + } + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception() } + } } diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt index 703419083..50376d4db 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/controller/AdvancedTransferAdminController.kt @@ -1,18 +1,18 @@ package co.nilin.opex.wallet.app.controller +import co.nilin.opex.wallet.app.dto.AdminTransferReserveRequest +import co.nilin.opex.wallet.app.dto.ReservedTransferResponse import co.nilin.opex.wallet.app.dto.UserSwapTransactionRequest -import co.nilin.opex.wallet.app.dto.UserTransactionRequest +import co.nilin.opex.wallet.app.service.TransferService import co.nilin.opex.wallet.core.inout.AdminSwapResponse +import co.nilin.opex.wallet.core.inout.TransferResult +import co.nilin.opex.wallet.core.model.WalletType import co.nilin.opex.wallet.core.spi.ReservedTransferManager import io.swagger.annotations.ApiResponse import io.swagger.annotations.Example import io.swagger.annotations.ExampleProperty import org.springframework.beans.factory.annotation.Autowired -import org.springframework.security.core.annotation.CurrentSecurityContext -import org.springframework.security.core.context.SecurityContext -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -23,6 +23,8 @@ class AdvancedTransferAdminController { @Autowired lateinit var reservedTransferManager: ReservedTransferManager + @Autowired + lateinit var transferService: TransferService @PostMapping("/admin/v1/swap/history") @ApiResponse( @@ -63,4 +65,32 @@ class AdvancedTransferAdminController { ) } } + + @PostMapping("/admin/v1/transfer/reserve") + suspend fun reserve(@RequestBody request: AdminTransferReserveRequest): ReservedTransferResponse { + return transferService.reserveTransferByAdmin( + request.sourceSymbol, + request.destSymbol, + request.receiverUuid, + WalletType.MAIN, + request.receiverUuid, + WalletType.MAIN, + request.sourceAmount, + request.destAmount, + request.rate + ) + } + + @PostMapping("/admin/v1/transfer/{reserveUuid}") + suspend fun finalizeTransfer( + @PathVariable reserveUuid: String, + @RequestParam description: String?, + @RequestParam transferRef: String?, + ): TransferResult { + return transferService.advanceTransferByAdmin( + reserveUuid, + description, + transferRef + ).transferResult + } } diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/AdminTransferReserveRequest.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/AdminTransferReserveRequest.kt new file mode 100644 index 000000000..84f623215 --- /dev/null +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/dto/AdminTransferReserveRequest.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.wallet.app.dto + +import java.math.BigDecimal + +data class AdminTransferReserveRequest( + val sourceSymbol: String, + val sourceAmount: BigDecimal, + val destSymbol: String, + val destAmount: BigDecimal? = null, + val rate: BigDecimal? = null, + val receiverUuid: String, +) \ No newline at end of file diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/TransferService.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/TransferService.kt index 3749bbb7b..49087cf13 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/TransferService.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/service/TransferService.kt @@ -135,49 +135,161 @@ class TransferService( } } + suspend fun reserveTransferByAdmin( + sourceSymbol: String, + destSymbol: String, + senderUuid: String, + senderWalletType: WalletType, + receiverUuid: String, + receiverWalletType: WalletType, + sourceAmount: BigDecimal, + destAmount: BigDecimal? = null, + rate: BigDecimal? = null, + ): ReservedTransferResponse { + + if ((rate != null && destAmount != null) || (rate == null && destAmount == null)) { + throw OpexError.BadRequest.exception("Either 'rate' or 'destAmount' must be provided, but not both") + } + + if (rate != null && rate <= BigDecimal.ZERO) { + throw OpexError.BadRequest.exception("rate must be greater than zero") + } + + if (destAmount != null && destAmount <= BigDecimal.ZERO) { + throw OpexError.BadRequest.exception("destAmount must be greater than zero") + } + + val (finalRate, rawDestAmount) = if (rate != null) { + val calculatedDest = calculateDestAmount(sourceAmount, Rate(sourceSymbol, destSymbol, rate)) + Pair(rate, calculatedDest) + } else { + val calculatedRate = destAmount!!.divide(sourceAmount, 28, RoundingMode.HALF_UP) + Pair(calculatedRate, destAmount) + } + + validateInitialAmountAndPrecision(sourceAmount, sourceSymbol) + val scaledDestAmount = precisionService.calculatePrecision(rawDestAmount, destSymbol) + + validateMinimumAmount(sourceSymbol, sourceAmount, scaledDestAmount, destSymbol, finalRate) + validateMaximumAmount(sourceSymbol, sourceAmount, destSymbol, scaledDestAmount) + precisionService.validatePrecision(scaledDestAmount, destSymbol) + checkIfSystemHasEnoughBalance(destSymbol, receiverWalletType, scaledDestAmount) + val reserveNumber = UUID.randomUUID().toString() + val resp = reservedTransferManager.reserve( + ReservedTransfer( + reserveNumber = reserveNumber, + destSymbol = destSymbol, + sourceSymbol = sourceSymbol, + sourceAmount = sourceAmount, + senderUuid = senderUuid, + receiverUuid = receiverUuid, + senderWalletType = senderWalletType, + receiverWalletType = receiverWalletType, + reservedDestAmount = scaledDestAmount, + rate = finalRate + ) + ) + + return with(resp) { + ReservedTransferResponse( + reserveNumber, + sourceSymbol, + destSymbol, + receiverUuid, + sourceAmount, + reservedDestAmount, + reserveDate, + expDate, + status + ) + } + } + @Transactional suspend fun advanceTransfer( reserveNumber: String, description: String?, transferRef: String?, issuer: String? = null, - //todo need to review transferCategory: TransferCategory = TransferCategory.PURCHASE_FINALIZED, ): TransferResultDetailed { + return executeAdvanceTransfer( + reserveNumber = reserveNumber, + description = description, + transferRef = transferRef, + transferCategory = transferCategory, + issuer = issuer, + validateIssuer = true + ) + } + + @Transactional + suspend fun advanceTransferByAdmin( + reserveNumber: String, + description: String?, + transferRef: String?, + transferCategory: TransferCategory = TransferCategory.IMPERSONATED_PURCHASE_FINALIZED, + ): TransferResultDetailed { + return executeAdvanceTransfer( + reserveNumber = reserveNumber, + description = description, + transferRef = transferRef, + transferCategory = transferCategory, + issuer = null, + validateIssuer = false + ) + } + + private suspend fun executeAdvanceTransfer( + reserveNumber: String, + description: String?, + transferRef: String?, + transferCategory: TransferCategory, + issuer: String?, + validateIssuer: Boolean + ): TransferResultDetailed { + val reservations = reservedTransferManager.fetchValidReserve(reserveNumber) ?: throw OpexError.InvalidReserveNumber.exception() - if (!(issuer == null || reservations.senderUuid == issuer)) + + if (validateIssuer && !(issuer == null || reservations.senderUuid == issuer)) { throw OpexError.Forbidden.exception() + } + + val refPrefix = transferRef?.let { "$it-" } ?: "" + val withdrawRef = "${refPrefix}${reserveNumber}-withdraw" + val depositRef = "${refPrefix}${reserveNumber}-deposit" val senderTransfer = _transfer( - reservations.sourceSymbol, - reservations.senderWalletType, - reservations.senderUuid, - WalletType.MAIN, - walletOwnerManager.systemUuid, - reservations.sourceAmount, - description, - "$transferRef-$reserveNumber-withdraw", - transferCategory, - reservations.sourceSymbol, - reservations.sourceAmount + symbol = reservations.sourceSymbol, + senderWalletType = reservations.senderWalletType, + senderUuid = reservations.senderUuid, + receiverWalletType = WalletType.MAIN, + receiverUuid = walletOwnerManager.systemUuid, + amount = reservations.sourceAmount, + description = description, + transferRef = withdrawRef, + transferCategory = transferCategory, + destSymbol = reservations.destSymbol, + destAmount = reservations.reservedDestAmount ).transferResult val receiverTransfer = _transfer( - reservations.destSymbol, - WalletType.MAIN, - walletOwnerManager.systemUuid, - reservations.receiverWalletType, - reservations.receiverUuid, - reservations.reservedDestAmount, - description, - "$transferRef-$reserveNumber-deposit", - transferCategory, - reservations.destSymbol, - reservations.reservedDestAmount + symbol = reservations.destSymbol, + senderWalletType = WalletType.MAIN, + senderUuid = walletOwnerManager.systemUuid, + receiverWalletType = reservations.receiverWalletType, + receiverUuid = reservations.receiverUuid, + amount = reservations.reservedDestAmount, + description = description, + transferRef = depositRef, + transferCategory = transferCategory, + destSymbol = reservations.destSymbol, + destAmount = reservations.reservedDestAmount ).transferResult reservedTransferManager.commitReserve(reserveNumber) + return TransferResultDetailed( transferResult = TransferResult( senderTransfer.date, @@ -316,7 +428,10 @@ class TransferService( val minDestAmount = minPrecisionAmount(destPrecision) val minimumSource = - maxOf(minSourceAmount, minDestAmount.divide(rate, 10, RoundingMode.DOWN)).setScale(sourcePrecision, RoundingMode.DOWN) + maxOf(minSourceAmount, minDestAmount.divide(rate, 10, RoundingMode.DOWN)).setScale( + sourcePrecision, + RoundingMode.DOWN + ) val minimumDest = maxOf(minDestAmount, minSourceAmount.multiply(rate)).setScale(destPrecision, RoundingMode.DOWN) @@ -332,7 +447,7 @@ class TransferService( destAmount: BigDecimal, ) { suspend fun getMaxOrder(symbol: String): BigDecimal { - return currencyManager.fetchCurrencyMaxOrder(symbol)?: BigDecimal.ZERO + return currencyManager.fetchCurrencyMaxOrder(symbol) ?: BigDecimal.ZERO } if (sourceAmount > getMaxOrder(sourceSymbol) || destAmount > getMaxOrder(destSymbol)) { diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/TransferCategory.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/TransferCategory.kt index 9cf82d1d6..c29c2fc16 100644 --- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/TransferCategory.kt +++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/TransferCategory.kt @@ -10,6 +10,7 @@ enum class TransferCategory { WITHDRAW_REJECT, WITHDRAW_CANCEL, PURCHASE_FINALIZED, + IMPERSONATED_PURCHASE_FINALIZED, WITHDRAW_MANUALLY, ORDER_CREATE, ORDER_CANCEL, diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/UserTransactionCategory.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/UserTransactionCategory.kt index 734c1b173..39b35b617 100644 --- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/UserTransactionCategory.kt +++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/UserTransactionCategory.kt @@ -9,6 +9,7 @@ enum class UserTransactionCategory { WITHDRAW, FEE, SWAP, + IMPERSONATED_SWAP, REFERRAL_COMMISSION, REFERRAL_KYC_REWARD, REFERENT_COMMISSION, diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt index 9a368972d..b03fb97ed 100644 --- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt +++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt @@ -240,6 +240,28 @@ class TransferManagerImpl( ) userTransactionManager.save(dstTx) } + TransferCategory.IMPERSONATED_PURCHASE_FINALIZED -> { + val srcTx = UserTransaction( + command.sourceWallet.owner.id!!, + txId, + currency, + command.sourceWallet.balance.amount - amount, + -amount, + UserTransactionCategory.IMPERSONATED_SWAP, + command.description + ) + userTransactionManager.save(srcTx) + + val dstTx = UserTransaction( + command.destWallet.owner.id!!, + txId, + currency, + command.destWallet.balance.amount + amount, + amount, + UserTransactionCategory.IMPERSONATED_SWAP + ) + userTransactionManager.save(dstTx) + } TransferCategory.KYC_ACCEPTED_REWARD -> { val loserOwner = command.sourceWallet.owner.id!!