diff --git a/.postman.json b/.postman.json deleted file mode 100644 index 7595ec5220..0000000000 --- a/.postman.json +++ /dev/null @@ -1,446 +0,0 @@ -{ - "info": { - "name": "OBP-API DirectLogin Tests", - "description": "Tests for OBP-API DirectLogin authentication including new consumer/user retrieval methods", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_postman_id": "obp-api-directlogin-tests", - "version": "1.0.0" - }, - "variable": [ - { - "key": "baseUrl", - "value": "http://localhost:8086", - "type": "string" - }, - { - "key": "apiVersion", - "value": "v5.1.0", - "type": "string" - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "// Set default values if not already set", - "if (!pm.environment.get('consumer_key')) {", - " pm.environment.set('consumer_key', 'test-consumer-key');", - "}", - "if (!pm.environment.get('username')) {", - " pm.environment.set('username', 'hongwei');", - "}", - "if (!pm.environment.get('password')) {", - " pm.environment.set('password', 'hongwei@tesobe.comhongwei@tesobe.com');", - "}" - ] - } - } - ], - "item": [ - { - "name": "Health & Discovery", - "item": [ - { - "name": "API Health Check", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('API is reachable', function () {", - " pm.expect([200, 404]).to.include(pm.response.code);", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Get API Info", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/root", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "root"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Root endpoint responds', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('Response has API info', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('version');", - "});" - ], - "type": "text/javascript" - } - } - ] - } - ] - }, - { - "name": "DirectLogin Authentication", - "item": [ - { - "name": "DirectLogin - Get Token", - "request": { - "method": "POST", - "header": [ - { - "key": "DirectLogin", - "value": "username={{username}},password={{password}},consumer_key={{consumer_key}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/my/logins/direct", - "host": ["{{baseUrl}}"], - "path": ["my", "logins", "direct"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('DirectLogin successful', function () {", - " pm.response.to.have.status(201);", - "});", - "pm.test('Token received', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('token');", - " pm.environment.set('directlogin_token', json.token);", - "});", - "pm.test('Consumer ID present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('consumer_id');", - " pm.environment.set('consumer_id', json.consumer_id);", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Get Current User (with DirectLogin token)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/users/current", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "users", "current"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('User info retrieved', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('User has required fields', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('user_id');", - " pm.expect(json).to.have.property('username');", - " pm.expect(json).to.have.property('email');", - " pm.environment.set('user_id', json.user_id);", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Test Consumer Retrieval (Internal)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/users/current", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "users", "current"] - }, - "description": "This tests that the new getConsumerFromDirectLoginToken method works correctly by verifying the token is valid" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Token validation successful (consumer retrieved)', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('Consumer context available', function () {", - " // If we get a 200, it means the consumer was successfully retrieved from token", - " pm.expect(pm.response.code).to.equal(200);", - "});" - ], - "type": "text/javascript" - } - } - ] - } - ] - }, - { - "name": "API Operations with DirectLogin", - "item": [ - { - "name": "Get Banks", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/banks", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "banks"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Banks retrieved', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('Banks array present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('banks');", - " pm.expect(json.banks).to.be.an('array');", - " if (json.banks.length > 0) {", - " pm.environment.set('bank_id', json.banks[0].id);", - " }", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Get My Accounts", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/my/accounts", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "my", "accounts"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Accounts retrieved', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('Accounts array present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('accounts');", - " pm.expect(json.accounts).to.be.an('array');", - "});" - ], - "type": "text/javascript" - } - } - ] - } - ] - }, - { - "name": "Token Validation Tests", - "item": [ - { - "name": "Invalid Token Test", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token=invalid-token-12345", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/users/current", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "users", "current"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Invalid token rejected', function () {", - " pm.expect([401, 403]).to.include(pm.response.code);", - "});", - "pm.test('Error message present', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('message');", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Missing Token Test", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/users/current", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "users", "current"] - } - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Missing token rejected', function () {", - " pm.expect([401, 403]).to.include(pm.response.code);", - "});" - ], - "type": "text/javascript" - } - } - ] - } - ] - }, - { - "name": "New Methods Validation", - "item": [ - { - "name": "Verify Consumer Context (Multiple Requests)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/banks", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "banks"] - }, - "description": "Tests that getConsumerFromDirectLoginToken works consistently across multiple requests" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Consumer context maintained', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('Response time acceptable', function () {", - " pm.expect(pm.response.responseTime).to.be.below(2000);", - "});" - ], - "type": "text/javascript" - } - } - ] - }, - { - "name": "Verify User Context (Multiple Requests)", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{directlogin_token}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/obp/{{apiVersion}}/users/current", - "host": ["{{baseUrl}}"], - "path": ["obp", "{{apiVersion}}", "users", "current"] - }, - "description": "Tests that getUserFromDirectLoginToken works consistently across multiple requests" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('User context maintained', function () {", - " pm.response.to.have.status(200);", - "});", - "pm.test('User ID consistent', function () {", - " var json = pm.response.json();", - " var savedUserId = pm.environment.get('user_id');", - " if (savedUserId) {", - " pm.expect(json.user_id).to.equal(savedUserId);", - " }", - "});" - ], - "type": "text/javascript" - } - } - ] - } - ] - } - ] -} diff --git a/.postman_environment.json b/.postman_environment.json deleted file mode 100644 index efbaa386ec..0000000000 --- a/.postman_environment.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "OBP-API Local", - "values": [ - { - "key": "baseUrl", - "value": "http://localhost:8086", - "enabled": true - }, - { - "key": "apiVersion", - "value": "v5.1.0", - "enabled": true - }, - { - "key": "username", - "value": "susan.uk.29@example.com", - "enabled": true - }, - { - "key": "password", - "value": "2b78e81", - "enabled": true - }, - { - "key": "consumer_key", - "value": "res2r5eiexq2znnu54gy1bj0d0yz0noqegiugvtr", - "enabled": true - } - ] -} diff --git a/.postman_simple.json b/.postman_simple.json deleted file mode 100644 index f6fb55e44f..0000000000 --- a/.postman_simple.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "info": { - "name": "OBP-API DirectLogin Tests - Simple", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Health Check", - "request": { - "method": "GET", - "header": [], - "url": "http://localhost:8086/obp/v5.1.0/root" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('API responds', function () {", - " pm.response.to.have.status(200);", - "});" - ] - } - } - ] - }, - { - "name": "DirectLogin - Get Token", - "request": { - "method": "POST", - "header": [ - { - "key": "DirectLogin", - "value": "username=hongwei,password=hongwei@tesobe.comhongwei@tesobe.com,consumer_key=ldok3nlci2voe0cnudk3onk2emkdy3myfcocgoy3" - } - ], - "url": "http://localhost:8086/my/logins/direct" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('DirectLogin successful', function () {", - " pm.response.to.have.status(201);", - "});", - "pm.test('Token received', function () {", - " var json = pm.response.json();", - " pm.expect(json).to.have.property('token');", - " pm.environment.set('token', json.token);", - "});" - ] - } - } - ] - }, - { - "name": "Get Current User", - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "DirectLogin token={{token}}" - } - ], - "url": "http://localhost:8086/obp/v5.1.0/users/current" - }, - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('User retrieved', function () {", - " pm.response.to.have.status(200);", - "});" - ] - } - } - ] - } - ] -} diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index fb92047c66..6ef32178ff 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -103,6 +103,8 @@ object ApiTag { val apiTagSystem = ResourceDocTag("System") val apiTagCache = ResourceDocTag("Cache") val apiTagLogCache = ResourceDocTag("Log-Cache") + val apiTagTrading = ResourceDocTag("Trading") + val apiTagMarket = ResourceDocTag("Market") val apiTagApiCollection = ResourceDocTag("Api-Collection") diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 7842c09b44..70ace758d4 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -850,6 +850,27 @@ object ErrorMessages { val MethodRoutingNotFoundByMethodRoutingId = "OBP-70002: MethodRouting not found. Please specify a valid value for method_routing_id." val MethodRoutingAlreadyExistsError = "OBP-70003: Method Routing is already exists." + // Trading Exceptions (OBP-71XXX) + val OfferNotFound = "OBP-71001: Trading offer not found." + val InvalidOfferType = "OBP-71002: Invalid offer type. Must be 'BUY' or 'SELL'." + val InvalidTradingAmount = "OBP-71003: Invalid amount. Must be a positive number." + val CreateTradingOfferError = "OBP-71005: Could not create trading offer." + + // Market Trading Exceptions (OBP-72XXX) + val OrderNotFound = "OBP-72001: Market order not found." + val InvalidOrderSide = "OBP-72002: Invalid order side. Must be 'BUY' or 'SELL'." + val TradeNotFound = "OBP-72003: Market trade not found." + val InvalidMatchParameters = "OBP-72004: Invalid match parameters." + val SettlementFailed = "OBP-72005: Settlement request failed." + val WithdrawalFailed = "OBP-72006: Withdrawal request failed." + + // TCC Payment Authorization Exceptions (OBP-73XXX) + val PaymentAuthNotFound = "OBP-73001: Payment authorization not found." + val InvalidPaymentAuthState = "OBP-73002: Invalid payment authorization state transition." + val PaymentAuthAlreadyCaptured = "OBP-73003: Payment authorization has already been captured." + val PaymentAuthAlreadyReleased = "OBP-73004: Payment authorization has already been released." + val CreatePaymentAuthError = "OBP-73005: Could not create payment authorization." + // Cascade Deletion Exceptions (OBP-8XXXX) val CouldNotDeleteCascade = "OBP-80001: Could not delete cascade." val CannotDeleteCascadePersonalEntity = "OBP-80002: Cannot delete cascade for personal entities (hasPersonalEntity=true). Please delete the records and definition separately." diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index d9e2337b43..c5fe37fc9d 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4563,6 +4563,237 @@ object NewStyle extends MdcLoggable{ ) map { i => (unboxFullOrFail(i._1, callContext, s"$DeleteCounterpartyLimitError"), i._2) } + + // Trading Methods + def createTradingOffer( + bankId: BankId, + accountId: AccountId, + offerType: String, + assetCode: String, + assetAmount: BigDecimal, + priceCurrency: String, + priceAmount: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.TradingOffer] = { + Connector.connector.vend.createTradingOffer( + bankId, accountId, offerType, assetCode, assetAmount, + priceCurrency, priceAmount, settlementAccountId, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$OfferNotFound"), i._2) + } + } + + def getTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.TradingOffer] = { + Connector.connector.vend.getTradingOffer(offerId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OfferNotFound"), i._2) + } + } + + def cancelTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.TradingOffer] = { + Connector.connector.vend.cancelTradingOffer(offerId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OfferNotFound"), i._2) + } + } + + def updateTradingOffer( + offerId: String, + priceAmount: Option[BigDecimal], + expiryDatetime: Option[Date], + minimumFill: Option[BigDecimal], + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.TradingOffer] = { + Connector.connector.vend.updateTradingOffer(offerId, priceAmount, expiryDatetime, minimumFill, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OfferNotFound"), i._2) + } + } + + def getTradingOffers( + bankId: BankId, + accountId: AccountId, + status: Option[String], + offerType: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[List[com.openbankproject.commons.model.TradingOffer]] = { + Connector.connector.vend.getTradingOffers(bankId, accountId, status, offerType, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$BankAccountNotFound"), i._2) + } + } + + // Market Methods + def createMarketOrder( + bankId: BankId, + accountId: AccountId, + side: String, + price: BigDecimal, + quantity: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.createMarketOrder( + bankId, accountId, side, price, quantity, settlementAccountId, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def getMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.getMarketOrder(bankId, accountId, orderId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def cancelMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.cancelMarketOrder(bankId, accountId, orderId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def createMarketMatch( + bankId: BankId, + accountId: AccountId, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketMatch] = { + Connector.connector.vend.createMarketMatch( + bankId, accountId, orderId, counterOrderId, amount, price, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidMatchParameters"), i._2) + } + } + + def getMarketTrade( + bankId: BankId, + accountId: AccountId, + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketTrade] = { + Connector.connector.vend.getMarketTrade(bankId, accountId, tradeId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound"), i._2) + } + } + + def requestSettlement( + bankId: BankId, + accountId: AccountId, + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Settlement] = { + Connector.connector.vend.requestSettlement(bankId, accountId, tradeId, step, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$SettlementFailed"), i._2) + } + } + + def notifyDeposit( + bankId: BankId, + accountId: AccountId, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + requiredConfirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Deposit] = { + Connector.connector.vend.notifyDeposit( + bankId, accountId, txHash, from, to, amount, confirmations, requiredConfirmations, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidTradingAmount"), i._2) + } + } + + def requestWithdrawal( + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, + amount: BigDecimal, + address: String, + requiredConfirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Withdrawal] = { + Connector.connector.vend.requestWithdrawal( + bankId, accountId, settlementAccountId, amount, address, requiredConfirmations, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$WithdrawalFailed"), i._2) + } + } + + // TCC Payment Authorization NewStyle Wrappers + def createPaymentAuth( + bankId: BankId, + accountId: AccountId, + tradeId: String, + buyerAccountId: String, + sellerAccountId: String, + amountFiat: BigDecimal, + currency: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.PaymentAuth] = { + Connector.connector.vend.createPaymentAuth( + bankId, accountId, tradeId, buyerAccountId, sellerAccountId, amountFiat, currency, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$CreatePaymentAuthError"), i._2) + } + } + + def capturePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.PaymentAuth] = { + Connector.connector.vend.capturePaymentAuth( + bankId, accountId, authId, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidPaymentAuthState"), i._2) + } + } + + def releasePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.PaymentAuth] = { + Connector.connector.vend.releasePaymentAuth( + bankId, accountId, authId, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidPaymentAuthState"), i._2) + } + } + + def getPaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.PaymentAuth] = { + Connector.connector.vend.getPaymentAuth( + bankId, accountId, authId, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$PaymentAuthNotFound"), i._2) + } + } } } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 9ee114cbaf..8d448ef6f4 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -860,8 +860,1021 @@ object Http4s700 { http4sPartialFunction = Some(getAccountsAtBank) ) + // ── Trading Endpoints ────────────────────────────────────────────────── + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers + val createTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreateOfferRequestJson, JSONFactory700.TradingOfferJson](req) { (user, createOfferJson, cc) => + for { + // Validate offer_type + _ <- Helper.booleanToFuture( + failMsg = InvalidOfferType, + failCode = 400, + cc = Some(cc) + )(createOfferJson.offer_type == "BUY" || createOfferJson.offer_type == "SELL") + + // Validate asset_amount + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(createOfferJson.asset_amount > 0) + + // Validate price_amount + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(createOfferJson.price_amount > 0) + + // Invoke connector + (offer, callContext) <- NewStyle.function.createTradingOffer( + BankId(bankId), + AccountId(accountId), + createOfferJson.offer_type, + createOfferJson.asset_code, + createOfferJson.asset_amount, + createOfferJson.price_currency, + createOfferJson.price_amount, + createOfferJson.settlement_account_id, + Some(cc) + ) + } yield JSONFactory700.createTradingOfferJson(offer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createTradingOffer), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers", + "Create Trading Offer", + """**WORK IN PROGRESS** + | + |Create a new trading offer to buy or sell digital assets. + | + |The offer will be matched against existing offers in the order book. + |The offer_id is automatically generated as a UUID. + | + |Authentication is required.""", + JSONFactory700.CreateOfferRequestJson( + offer_type = "BUY", + asset_code = "OGCR", + asset_amount = BigDecimal("100.00"), + price_currency = "EUR", + price_amount = BigDecimal("1.50"), + settlement_account_id = "settlement-account-123" + ), + JSONFactory700.TradingOfferJson( + offer_id = "550e8400-e29b-41d4-a716-446655440000", + status = "active", + offer_details = JSONFactory700.OfferDetailsJson( + offer_type = "BUY", + asset_code = "OGCR", + asset_amount = BigDecimal("100.00"), + price_currency = "EUR", + price_amount = BigDecimal("1.50"), + settlement_account_id = "settlement-account-123", + expiry_datetime = None, + minimum_fill = None + ), + account_info = JSONFactory700.AccountInfoJson( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", + view_id = "owner" + ), + executions = List.empty, + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-15T10:30:00Z", + updated_at = "2026-04-15T10:30:00Z" + ), + List(InvalidJsonFormat, InvalidOfferType, InvalidTradingAmount, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagTrading :: Nil, + http4sPartialFunction = Some(createTradingOffer) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID + val getTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + // Invoke connector + (offer, callContext) <- NewStyle.function.getTradingOffer(offerId, Some(cc)) + } yield JSONFactory700.createTradingOfferJson(offer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTradingOffer), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", + "Get Trading Offer", + """**WORK IN PROGRESS** + | + |Get details of a specific trading offer including execution history. + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.TradingOfferJson( + offer_id = "550e8400-e29b-41d4-a716-446655440000", + status = "active", + offer_details = JSONFactory700.OfferDetailsJson( + offer_type = "BUY", + asset_code = "OGCR", + asset_amount = BigDecimal("100.00"), + price_currency = "EUR", + price_amount = BigDecimal("1.50"), + settlement_account_id = "settlement-account-123", + expiry_datetime = None, + minimum_fill = None + ), + account_info = JSONFactory700.AccountInfoJson( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", + view_id = "owner" + ), + executions = List.empty, + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-15T10:30:00Z", + updated_at = "2026-04-15T10:30:00Z" + ), + List(OfferNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagTrading :: Nil, + http4sPartialFunction = Some(getTradingOffer) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers + val getTradingOffers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => + EndpointHelpers.withUser(req) { (user, cc) => + // Extract query parameters + val status = req.uri.query.params.get("status") + val offerType = req.uri.query.params.get("offer_type") + + for { + // Invoke connector + (offers, callContext) <- NewStyle.function.getTradingOffers( + BankId(bankId), + AccountId(accountId), + status, + offerType, + Some(cc) + ) + } yield { + // Convert to JSON + val offersJson = offers.map(JSONFactory700.createTradingOfferJson) + JSONFactory700.TradingOffersJson(offersJson) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getTradingOffers), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers", + "Get Trading Offers", + """**WORK IN PROGRESS** + | + |Get a list of trading offers for a specific account. + | + |Optional query parameters: + |- status: Filter by offer status (e.g., "active", "cancelled", "filled", "expired") + |- offer_type: Filter by offer type ("BUY" or "SELL") + | + |Results are sorted by creation date (most recent first). + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.TradingOffersJson( + offers = List( + JSONFactory700.TradingOfferJson( + offer_id = "550e8400-e29b-41d4-a716-446655440000", + status = "active", + offer_details = JSONFactory700.OfferDetailsJson( + offer_type = "BUY", + asset_code = "OGCR", + asset_amount = BigDecimal("100.00"), + price_currency = "EUR", + price_amount = BigDecimal("1.50"), + settlement_account_id = "settlement-account-123", + expiry_datetime = None, + minimum_fill = None + ), + account_info = JSONFactory700.AccountInfoJson( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", + view_id = "owner" + ), + executions = List.empty, + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-15T10:30:00Z", + updated_at = "2026-04-15T10:30:00Z" + ) + ) + ), + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagTrading :: Nil, + http4sPartialFunction = Some(getTradingOffers) + ) + + // Route: DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID + val cancelTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + // Invoke connector + (offer, callContext) <- NewStyle.function.cancelTradingOffer(offerId, Some(cc)) + } yield JSONFactory700.createCancelOfferResponseJson(offer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(cancelTradingOffer), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", + "Cancel Trading Offer", + """**WORK IN PROGRESS** + | + |Cancel an active trading offer. + | + |This operation is idempotent - canceling an already-cancelled offer returns success. + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.CancelOfferResponseJson( + offer_id = "550e8400-e29b-41d4-a716-446655440000", + status = "cancelled" + ), + List(OfferNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagTrading :: Nil, + http4sPartialFunction = Some(cancelTradingOffer) + ) + + // ── End Phase 1 batch 2 ────────────────────────────────────────────────── + // ── Market Endpoints (Phase 2) ───────────────────────────────────────── + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders + val createMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "orders" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreateMarketOrderRequestJson, JSONFactory700.MarketOrderJson](req) { (user, createOrderJson, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Validate side + _ <- Helper.booleanToFuture( + failMsg = InvalidOrderSide, + failCode = 400, + cc = callContext + )(createOrderJson.side == "BUY" || createOrderJson.side == "SELL") + + // Validate price + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = callContext + )(createOrderJson.price > 0) + + // Validate quantity + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = callContext + )(createOrderJson.quantity > 0) + + // Invoke connector + (order, callContext2) <- NewStyle.function.createMarketOrder( + BankId(bankId), + AccountId(accountId), + createOrderJson.side, + createOrderJson.price, + createOrderJson.quantity, + createOrderJson.settlement_account_id, + callContext + ) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createMarketOrder), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders", + "Create Market Order", + """**WORK IN PROGRESS** + | + |Create a new market order to buy or sell assets. + | + |The order will be matched against existing orders in the order book. + |The order_id is automatically generated as a UUID. + |Each request creates a new order with a unique order_id. + | + |Authentication is required.""", + JSONFactory700.CreateMarketOrderRequestJson( + side = "BUY", + price = BigDecimal("25.0"), + quantity = BigDecimal("10.0"), + settlement_account_id = "buyer-fiat-account" + ), + JSONFactory700.MarketOrderJson( + order_id = "550e8400-e29b-41d4-a716-446655440000", + side = "BUY", + price = BigDecimal("25.0"), + quantity = BigDecimal("10.0"), + account_id = "buyer-fiat-account", + status = "active", + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:30:00Z" + ), + List(InvalidJsonFormat, InvalidOrderSide, InvalidTradingAmount, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(createMarketOrder) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID + val getMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "orders" / orderId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Get order + (order, callContext2) <- NewStyle.function.getMarketOrder( + BankId(bankId), + AccountId(accountId), + orderId, + callContext + ) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMarketOrder), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", + "Get Market Order", + """**WORK IN PROGRESS** + | + |Get details of a specific market order. + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.MarketOrderJson( + order_id = "550e8400-e29b-41d4-a716-446655440000", + side = "BUY", + price = BigDecimal("25.0"), + quantity = BigDecimal("10.0"), + account_id = "buyer-fiat-account", + status = "active", + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:30:00Z" + ), + List(OrderNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(getMarketOrder) + ) + + // Route: DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID + val cancelMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "orders" / orderId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Cancel order + (order, callContext2) <- NewStyle.function.cancelMarketOrder( + BankId(bankId), + AccountId(accountId), + orderId, + callContext + ) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(cancelMarketOrder), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", + "Cancel Market Order", + """**WORK IN PROGRESS** + | + |Cancel an active market order. + | + |This operation is idempotent - canceling an already-cancelled order returns success. + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.MarketOrderJson( + order_id = "550e8400-e29b-41d4-a716-446655440000", + side = "BUY", + price = BigDecimal("25.0"), + quantity = BigDecimal("10.0"), + account_id = "buyer-fiat-account", + status = "cancelled", + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:35:00Z" + ), + List(OrderNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(cancelMarketOrder) + ) + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/matches + val createMarketMatch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "matches" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreateMarketMatchRequestJson, JSONFactory700.MarketMatchJson](req) { (user, createMatchJson, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Validate amount + _ <- Helper.booleanToFuture( + failMsg = InvalidMatchParameters, + failCode = 400, + cc = callContext + )(createMatchJson.amount > 0) + + // Validate price + _ <- Helper.booleanToFuture( + failMsg = InvalidMatchParameters, + failCode = 400, + cc = callContext + )(createMatchJson.price > 0) + + // Invoke connector + (matchResult, callContext2) <- NewStyle.function.createMarketMatch( + BankId(bankId), + AccountId(accountId), + createMatchJson.order_id, + createMatchJson.counter_order_id, + createMatchJson.amount, + createMatchJson.price, + callContext + ) + } yield JSONFactory700.createMarketMatchJson(matchResult) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createMarketMatch), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/matches", + "Create Market Match", + """**WORK IN PROGRESS** + | + |Create a match between two market orders. + | + |This creates a MarketMatch and automatically generates a corresponding MarketTrade. + | + |Authentication is required.""", + JSONFactory700.CreateMarketMatchRequestJson( + order_id = "order-123", + counter_order_id = "order-456", + amount = BigDecimal("5.0"), + price = BigDecimal("25.0") + ), + JSONFactory700.MarketMatchJson( + match_id = "match-789", + order_id = "order-123", + counter_order_id = "order-456", + amount = BigDecimal("5.0"), + price = BigDecimal("25.0"), + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:40:00Z" + ), + List(InvalidJsonFormat, InvalidMatchParameters, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(createMarketMatch) + ) + + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/trades/TRADE_ID + val getMarketTrade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "trades" / tradeId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Get trade + (trade, callContext2) <- NewStyle.function.getMarketTrade( + BankId(bankId), + AccountId(accountId), + tradeId, + callContext + ) + } yield JSONFactory700.createMarketTradeJson(trade) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMarketTrade), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/trades/TRADE_ID", + "Get Market Trade", + """**WORK IN PROGRESS** + | + |Get details of a specific market trade. + | + |Authentication is required.""", + EmptyBody, + JSONFactory700.MarketTradeJson( + trade_id = "trade-789", + buy_order_id = "order-123", + sell_order_id = "order-456", + amount = BigDecimal("5.0"), + price = BigDecimal("25.0"), + status = "pending", + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:40:00Z" + ), + List(TradeNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(getMarketTrade) + ) + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/settlements + val requestSettlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "settlements" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.RequestSettlementJson, JSONFactory700.SettlementJson](req) { (user, requestJson, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Invoke connector + (settlement, callContext2) <- NewStyle.function.requestSettlement( + BankId(bankId), + AccountId(accountId), + requestJson.trade_id, + requestJson.step, + callContext + ) + } yield JSONFactory700.createSettlementJson(settlement) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(requestSettlement), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/settlements", + "Request Settlement", + """**WORK IN PROGRESS** + | + |Request settlement for a completed trade. + | + |Authentication is required.""", + JSONFactory700.RequestSettlementJson( + trade_id = "trade-789", + step = Some("step1") + ), + JSONFactory700.SettlementJson( + settlement_id = "settlement-101", + trade_id = "trade-789", + step = Some("step1"), + status = "pending", + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:45:00Z", + completed_at = None + ), + List(InvalidJsonFormat, SettlementFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(requestSettlement) + ) + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/deposits +// val notifyDeposit: HttpRoutes[IO] = HttpRoutes.of[IO] { +// case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "deposits" => +// EndpointHelpers.withUserAndBodyCreated[JSONFactory700.NotifyDepositJson, JSONFactory700.DepositJson](req) { (user, depositJson, cc) => +// for { +// // Validate bank and account +// (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) +// +// // Validate amount +// _ <- Helper.booleanToFuture( +// failMsg = InvalidTradingAmount, +// failCode = 400, +// cc = callContext +// )(depositJson.amount > 0) +// +// // Validate confirmations +// _ <- Helper.booleanToFuture( +// failMsg = InvalidMatchParameters, +// failCode = 400, +// cc = callContext +// )(depositJson.confirmations >= 0) +// +// // Invoke connector +// (deposit, callContext2) <- NewStyle.function.notifyDeposit( +// BankId(bankId), +// AccountId(accountId), +// depositJson.tx_hash, +// depositJson.from, +// depositJson.to, +// depositJson.amount, +// depositJson.confirmations, +// 12, // Ethereum mainnet standard: 12 confirmations required +// callContext +// ) +// } yield JSONFactory700.createDepositJson(deposit) +// } +// } +// +// resourceDocs += ResourceDoc( +// null, +// implementedInApiVersion, +// nameOf(notifyDeposit), +// "POST", +// "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/deposits", +// "Notify Deposit", +// """**WORK IN PROGRESS** +// | +// |Record a blockchain deposit notification. +// | +// |Authentication is required.""", +// JSONFactory700.NotifyDepositJson( +// tx_hash = "0x123abc", +// from = "0xsender", +// to = "0xreceiver", +// amount = BigDecimal("100.0"), +// confirmations = 6 +// ), +// JSONFactory700.DepositJson( +// deposit_id = "deposit-202", +// tx_hash = "0x123abc", +// from = "0xsender", +// to = "0xreceiver", +// amount = BigDecimal("100.0"), +// confirmations = 6, +// required_confirmations = 12, +// status = "pending", +// nonce = Some(123456L), +// gas_used = Some(21000L), +// error_message = None, +// user_id = "user-abc-123", +// consent_id = None, +// created_at = "2026-04-16T00:50:00Z" +// ), +// List(InvalidJsonFormat, InvalidTradingAmount, InvalidMatchParameters, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), +// apiTagMarket :: Nil, +// http4sPartialFunction = Some(notifyDeposit) +// ) + + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/withdrawals + val requestWithdrawal: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "withdrawals" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.RequestWithdrawalJson, JSONFactory700.WithdrawalJson](req) { (user, withdrawalJson, cc) => + for { + // Validate bank and account + (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) + + // Validate amount + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = callContext + )(withdrawalJson.amount > 0) + + // Invoke connector + (withdrawal, callContext2) <- NewStyle.function.requestWithdrawal( + BankId(bankId), + AccountId(accountId), + withdrawalJson.settlement_account_id, + withdrawalJson.amount, + withdrawalJson.address, + 12, // Ethereum mainnet standard: 12 confirmations required + callContext + ) + } yield JSONFactory700.createWithdrawalJson(withdrawal) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(requestWithdrawal), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/withdrawals", + "Request Withdrawal", + """**WORK IN PROGRESS** + | + |Request a withdrawal to a blockchain address. + | + |The withdrawal_id is automatically generated as a UUID. + |Each request creates a new withdrawal with a unique withdrawal_id. + | + |Authentication is required.""", + JSONFactory700.RequestWithdrawalJson( + settlement_account_id = "account-123", + amount = BigDecimal("50.0"), + address = "0xdestination" + ), + JSONFactory700.WithdrawalJson( + withdrawal_id = "withdrawal-303", + account_id = "account-123", + amount = BigDecimal("50.0"), + address = "0xdestination", + status = "pending", + tx_hash = None, + confirmations = None, + required_confirmations = 12, + nonce = None, + gas_used = None, + error_message = None, + user_id = "user-abc-123", + consent_id = None, + created_at = "2026-04-16T00:55:00Z" + ), + List(InvalidJsonFormat, InvalidTradingAmount, WithdrawalFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(requestWithdrawal) + ) + +// // ── TCC Payment Authorization Endpoints (Phase 3 - P3) ───────────────── +// +// // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths +// val createPaymentAuth: HttpRoutes[IO] = HttpRoutes.of[IO] { +// case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "payment-auths" => +// EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreatePaymentAuthRequestJson, JSONFactory700.PaymentAuthJson](req) { (user, createAuthJson, cc) => +// for { +// // Validate bank and account +// (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) +// +// // Validate amount +// _ <- Helper.booleanToFuture( +// failMsg = InvalidTradingAmount, +// failCode = 400, +// cc = callContext +// )(createAuthJson.amount_fiat > 0) +// +// // Invoke connector to create payment authorization (PREAUTH state) +// (auth, callContext2) <- NewStyle.function.createPaymentAuth( +// BankId(bankId), +// AccountId(accountId), +// createAuthJson.trade_id, +// createAuthJson.buyer_account_id, +// createAuthJson.seller_account_id, +// createAuthJson.amount_fiat, +// createAuthJson.currency, +// callContext +// ) +// } yield JSONFactory700.createPaymentAuthJson(auth) +// } +// } +// +// resourceDocs += ResourceDoc( +// null, +// implementedInApiVersion, +// nameOf(createPaymentAuth), +// "POST", +// "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths", +// "Create Payment Authorization (TCC Preauth)", +// """**WORK IN PROGRESS** +// | +// |Create a payment authorization for a trade settlement using the Try-Confirm-Cancel (TCC) pattern. +// | +// |This creates a PREAUTH state authorization that freezes funds for the trade. +// |The auth_id is automatically generated as a UUID. +// | +// |TCC Flow: +// |- PREAUTH: Funds are frozen (this endpoint) +// |- CAPTURED: Funds are actually deducted (capture endpoint) +// |- RELEASED: Funds are unfrozen/refunded (release endpoint) +// | +// |Authentication is required.""", +// JSONFactory700.CreatePaymentAuthRequestJson( +// trade_id = "trade-789", +// buyer_account_id = "buyer-account-456", +// seller_account_id = "seller-account-789", +// amount_fiat = BigDecimal("1000.0"), +// currency = "EUR" +// ), +// JSONFactory700.PaymentAuthJson( +// auth_id = "auth-101", +// trade_id = "trade-789", +// buyer_account_id = "buyer-account-456", +// seller_account_id = "seller-account-789", +// amount_fiat = BigDecimal("1000.0"), +// currency = "EUR", +// state = "PREAUTH", +// hold_id = None, +// error_message = None, +// user_id = "user-abc-123", +// consent_id = None, +// created_at = "2026-04-17T10:00:00Z", +// updated_at = "2026-04-17T10:00:00Z" +// ), +// List(InvalidJsonFormat, InvalidTradingAmount, CreatePaymentAuthError, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), +// apiTagMarket :: Nil, +// http4sPartialFunction = Some(createPaymentAuth) +// ) +// +// // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/capture +// val capturePaymentAuth: HttpRoutes[IO] = HttpRoutes.of[IO] { +// case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "payment-auths" / authId / "capture" => +// EndpointHelpers.withUser(req) { (user, cc) => +// for { +// // Validate bank and account +// (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) +// +// // Invoke connector to capture payment (PREAUTH → CAPTURED) +// (auth, callContext2) <- NewStyle.function.capturePaymentAuth( +// BankId(bankId), +// AccountId(accountId), +// authId, +// callContext +// ) +// } yield JSONFactory700.createPaymentAuthJson(auth) +// } +// } +// +// resourceDocs += ResourceDoc( +// null, +// implementedInApiVersion, +// nameOf(capturePaymentAuth), +// "POST", +// "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/capture", +// "Capture Payment Authorization (TCC Confirm)", +// """**WORK IN PROGRESS** +// | +// |Capture a payment authorization to complete the trade settlement. +// | +// |This transitions the authorization from PREAUTH to CAPTURED state. +// |Funds are actually deducted from the buyer's account. +// | +// |Only PREAUTH state authorizations can be captured. +// | +// |Authentication is required.""", +// EmptyBody, +// JSONFactory700.PaymentAuthJson( +// auth_id = "auth-101", +// trade_id = "trade-789", +// buyer_account_id = "buyer-account-456", +// seller_account_id = "seller-account-789", +// amount_fiat = BigDecimal("1000.0"), +// currency = "EUR", +// state = "CAPTURED", +// hold_id = None, +// error_message = None, +// user_id = "user-abc-123", +// consent_id = None, +// created_at = "2026-04-17T10:00:00Z", +// updated_at = "2026-04-17T10:05:00Z" +// ), +// List(PaymentAuthNotFound, InvalidPaymentAuthState, PaymentAuthAlreadyCaptured, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), +// apiTagMarket :: Nil, +// http4sPartialFunction = Some(capturePaymentAuth) +// ) +// +// // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/release +// val releasePaymentAuth: HttpRoutes[IO] = HttpRoutes.of[IO] { +// case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "payment-auths" / authId / "release" => +// EndpointHelpers.withUser(req) { (user, cc) => +// for { +// // Validate bank and account +// (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) +// +// // Invoke connector to release payment (PREAUTH/CAPTURED → RELEASED) +// (auth, callContext2) <- NewStyle.function.releasePaymentAuth( +// BankId(bankId), +// AccountId(accountId), +// authId, +// callContext +// ) +// } yield JSONFactory700.createPaymentAuthJson(auth) +// } +// } +// +// resourceDocs += ResourceDoc( +// null, +// implementedInApiVersion, +// nameOf(releasePaymentAuth), +// "POST", +// "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/release", +// "Release Payment Authorization (TCC Cancel)", +// """**WORK IN PROGRESS** +// | +// |Release a payment authorization to cancel the trade settlement. +// | +// |This transitions the authorization to RELEASED state. +// |Frozen funds are unfrozen (if PREAUTH) or refunded (if CAPTURED). +// | +// |Both PREAUTH and CAPTURED state authorizations can be released. +// | +// |Authentication is required.""", +// EmptyBody, +// JSONFactory700.PaymentAuthJson( +// auth_id = "auth-101", +// trade_id = "trade-789", +// buyer_account_id = "buyer-account-456", +// seller_account_id = "seller-account-789", +// amount_fiat = BigDecimal("1000.0"), +// currency = "EUR", +// state = "RELEASED", +// hold_id = None, +// error_message = None, +// user_id = "user-abc-123", +// consent_id = None, +// created_at = "2026-04-17T10:00:00Z", +// updated_at = "2026-04-17T10:10:00Z" +// ), +// List(PaymentAuthNotFound, InvalidPaymentAuthState, PaymentAuthAlreadyReleased, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), +// apiTagMarket :: Nil, +// http4sPartialFunction = Some(releasePaymentAuth) +// ) +// +// // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID +// val getPaymentAuth: HttpRoutes[IO] = HttpRoutes.of[IO] { +// case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "payment-auths" / authId => +// EndpointHelpers.withUser(req) { (user, cc) => +// for { +// // Validate bank and account +// (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) +// +// // Invoke connector to get payment authorization +// (auth, callContext2) <- NewStyle.function.getPaymentAuth( +// BankId(bankId), +// AccountId(accountId), +// authId, +// callContext +// ) +// } yield JSONFactory700.createPaymentAuthJson(auth) +// } +// } +// +// resourceDocs += ResourceDoc( +// null, +// implementedInApiVersion, +// nameOf(getPaymentAuth), +// "GET", +// "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID", +// "Get Payment Authorization", +// """**WORK IN PROGRESS** +// | +// |Get details of a payment authorization. +// | +// |Returns the current state and details of the authorization. +// | +// |Authentication is required.""", +// EmptyBody, +// JSONFactory700.PaymentAuthJson( +// auth_id = "auth-101", +// trade_id = "trade-789", +// buyer_account_id = "buyer-account-456", +// seller_account_id = "seller-account-789", +// amount_fiat = BigDecimal("1000.0"), +// currency = "EUR", +// state = "PREAUTH", +// hold_id = None, +// error_message = None, +// user_id = "user-abc-123", +// consent_id = None, +// created_at = "2026-04-17T10:00:00Z", +// updated_at = "2026-04-17T10:00:00Z" +// ), +// List(PaymentAuthNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), +// apiTagMarket :: Nil, +// http4sPartialFunction = Some(getPaymentAuth) +// ) + + // ── End Market Endpoints (Phase 2) ───────────────────────────────────── + // ── Phase 1 batch 3 — system endpoints ────────────────────────────────── // Route: GET /obp/v7.0.0/system/cache/config diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 8bb51db931..852e1f7b5f 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -7,7 +7,7 @@ import code.api.v4_0_0.{EnergySource400, HostedAt400, HostedBy400} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.ApiVersion -object JSONFactory700 extends MdcLoggable { +object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { case class APIInfoJsonV700( version: String, @@ -55,5 +55,367 @@ object JSONFactory700 extends MdcLoggable { resource_docs_requires_role = resourceDocsRequiresRole ) } -} + // Trading JSON Models + + // Request Models + case class CreateOfferRequestJson( + offer_type: String, // "BUY" | "SELL" + asset_code: String, // e.g., "OGCR" + asset_amount: BigDecimal, // e.g., 100.00 + price_currency: String, // e.g., "EUR" + price_amount: BigDecimal, // e.g., 1.50 + settlement_account_id: String, + expiry_datetime: Option[String] = None, // ISO 8601 + minimum_fill: Option[BigDecimal] = None + ) + + case class UpdateOfferRequestJson( + price_amount: Option[BigDecimal], + expiry_datetime: Option[String], // ISO 8601 + minimum_fill: Option[BigDecimal] + ) + + // Response Models + case class TradingOfferJson( + offer_id: String, + status: String, + offer_details: OfferDetailsJson, + account_info: AccountInfoJson, + executions: List[OfferExecutionJson], + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String, // ISO 8601 + updated_at: String // ISO 8601 + ) + + case class OfferDetailsJson( + offer_type: String, + asset_code: String, + asset_amount: BigDecimal, + price_currency: String, + price_amount: BigDecimal, + settlement_account_id: String, + expiry_datetime: Option[String], + minimum_fill: Option[BigDecimal] + ) + + case class AccountInfoJson( + bank_id: String, + account_id: String, + view_id: String + ) + + case class OfferExecutionJson( + execution_id: String, + executed_amount: BigDecimal, + executed_price: BigDecimal, + executed_at: String, // ISO 8601 + counterpart_offer_id: String + ) + + case class CancelOfferResponseJson( + offer_id: String, + status: String + ) + + case class TradingOffersJson( + offers: List[TradingOfferJson] + ) + + // Conversion Functions + def createTradingOfferJson(offer: com.openbankproject.commons.model.TradingOffer): TradingOfferJson = { + TradingOfferJson( + offer_id = offer.offerId, + status = offer.status, + offer_details = OfferDetailsJson( + offer_type = offer.offerType, + asset_code = offer.offerDetails.assetCode, + asset_amount = offer.offerDetails.assetAmount, + price_currency = offer.offerDetails.priceCurrency, + price_amount = offer.offerDetails.priceAmount, + settlement_account_id = offer.offerDetails.settlementAccountId, + expiry_datetime = offer.offerDetails.expiryDatetime.map(_.toInstant.toString), + minimum_fill = offer.offerDetails.minimumFill + ), + account_info = AccountInfoJson( + bank_id = offer.accountInfo.bankId, + account_id = offer.accountInfo.accountId, + view_id = offer.accountInfo.viewId + ), + executions = offer.executions.map(e => OfferExecutionJson( + execution_id = e.executionId, + executed_amount = e.executedAmount, + executed_price = e.executedPrice, + executed_at = e.executedAt.toInstant.toString, + counterpart_offer_id = e.counterpartOfferId + )), + user_id = offer.userId, + consent_id = offer.consentId, + created_at = offer.createdAt.toInstant.toString, + updated_at = offer.updatedAt.toInstant.toString + ) + } + + def createCancelOfferResponseJson(offer: com.openbankproject.commons.model.TradingOffer): CancelOfferResponseJson = { + CancelOfferResponseJson( + offer_id = offer.offerId, + status = offer.status + ) + } + + // Market Trading JSON Models + + // Market Request Models + case class CreateMarketOrderRequestJson( + side: String, // "BUY" | "SELL" + price: BigDecimal, + quantity: BigDecimal, + settlement_account_id: String + ) + + case class CreateMarketMatchRequestJson( + order_id: String, + counter_order_id: String, + amount: BigDecimal, + price: BigDecimal + ) + + case class RequestSettlementJson( + trade_id: String, + step: Option[String] + ) + + case class NotifyDepositJson( + tx_hash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int + ) + + case class RequestWithdrawalJson( + settlement_account_id: String, + amount: BigDecimal, + address: String + ) + + // Market Response Models + case class MarketOrderJson( + order_id: String, + side: String, + price: BigDecimal, + quantity: BigDecimal, + account_id: String, + status: String, + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String, // ISO 8601 + updated_at: String // ISO 8601 + ) + + case class MarketMatchJson( + match_id: String, + order_id: String, + counter_order_id: String, + amount: BigDecimal, + price: BigDecimal, + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String // ISO 8601 + ) + + case class MarketTradeJson( + trade_id: String, + buy_order_id: String, + sell_order_id: String, + amount: BigDecimal, + price: BigDecimal, + status: String, + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String // ISO 8601 + ) + + case class SettlementJson( + settlement_id: String, + trade_id: String, + step: Option[String], + status: String, + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String, // ISO 8601 + completed_at: Option[String] // ISO 8601 + ) + + case class DepositJson( + deposit_id: String, + tx_hash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + required_confirmations: Int, // Number of confirmations required + status: String, + nonce: Option[Long], // Transaction nonce + gas_used: Option[Long], // Gas consumed + error_message: Option[String], // Error details if failed + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String // ISO 8601 + ) + + case class WithdrawalJson( + withdrawal_id: String, + account_id: String, + amount: BigDecimal, + address: String, + status: String, + tx_hash: Option[String], + confirmations: Option[Int], // Current confirmations + required_confirmations: Int, // Required confirmations + nonce: Option[Long], // Transaction nonce + gas_used: Option[Long], // Gas consumed + error_message: Option[String], // Error details if failed + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String // ISO 8601 + ) + + // TCC Payment Authorization Request/Response JSON + case class CreatePaymentAuthRequestJson( + trade_id: String, + buyer_account_id: String, + seller_account_id: String, + amount_fiat: BigDecimal, + currency: String + ) + + case class PaymentAuthJson( + auth_id: String, + trade_id: String, + buyer_account_id: String, + seller_account_id: String, + amount_fiat: BigDecimal, + currency: String, + state: String, // PREAUTH | CAPTURED | RELEASED | FAILED + hold_id: Option[String], // Link to OBP Account Hold + error_message: Option[String], // Error details if failed + user_id: String, // Audit field + consent_id: Option[String], // Audit field + created_at: String, // ISO 8601 + updated_at: String // ISO 8601 + ) + + // Market Conversion Functions + def createMarketOrderJson(order: com.openbankproject.commons.model.MarketOrder): MarketOrderJson = { + MarketOrderJson( + order_id = order.orderId, + side = order.side, + price = order.price, + quantity = order.quantity, + account_id = order.accountId, + status = order.status, + user_id = order.userId, + consent_id = order.consentId, + created_at = order.createdAt.toInstant.toString, + updated_at = order.updatedAt.toInstant.toString + ) + } + + def createMarketMatchJson(marketMatch: com.openbankproject.commons.model.MarketMatch): MarketMatchJson = { + MarketMatchJson( + match_id = marketMatch.matchId, + order_id = marketMatch.orderId, + counter_order_id = marketMatch.counterOrderId, + amount = marketMatch.amount, + price = marketMatch.price, + user_id = marketMatch.userId, + consent_id = marketMatch.consentId, + created_at = marketMatch.createdAt.toInstant.toString + ) + } + + def createMarketTradeJson(trade: com.openbankproject.commons.model.MarketTrade): MarketTradeJson = { + MarketTradeJson( + trade_id = trade.tradeId, + buy_order_id = trade.buyOrderId, + sell_order_id = trade.sellOrderId, + amount = trade.amount, + price = trade.price, + status = trade.status, + user_id = trade.userId, + consent_id = trade.consentId, + created_at = trade.createdAt.toInstant.toString + ) + } + + def createSettlementJson(settlement: com.openbankproject.commons.model.Settlement): SettlementJson = { + SettlementJson( + settlement_id = settlement.settlementId, + trade_id = settlement.tradeId, + step = settlement.step, + status = settlement.status, + user_id = settlement.userId, + consent_id = settlement.consentId, + created_at = settlement.createdAt.toInstant.toString, + completed_at = settlement.completedAt.map(_.toInstant.toString) + ) + } + + def createDepositJson(deposit: com.openbankproject.commons.model.Deposit): DepositJson = { + DepositJson( + deposit_id = deposit.depositId, + tx_hash = deposit.txHash, + from = deposit.from, + to = deposit.to, + amount = deposit.amount, + confirmations = deposit.confirmations, + required_confirmations = deposit.requiredConfirmations, + status = deposit.status, + nonce = deposit.nonce, + gas_used = deposit.gasUsed, + error_message = deposit.errorMessage, + user_id = deposit.userId, + consent_id = deposit.consentId, + created_at = deposit.createdAt.toInstant.toString + ) + } + + def createWithdrawalJson(withdrawal: com.openbankproject.commons.model.Withdrawal): WithdrawalJson = { + WithdrawalJson( + withdrawal_id = withdrawal.withdrawalId, + account_id = withdrawal.accountId, + amount = withdrawal.amount, + address = withdrawal.address, + status = withdrawal.status, + tx_hash = withdrawal.txHash, + confirmations = withdrawal.confirmations, + required_confirmations = withdrawal.requiredConfirmations, + nonce = withdrawal.nonce, + gas_used = withdrawal.gasUsed, + error_message = withdrawal.errorMessage, + user_id = withdrawal.userId, + consent_id = withdrawal.consentId, + created_at = withdrawal.createdAt.toInstant.toString + ) + } + + def createPaymentAuthJson(auth: com.openbankproject.commons.model.PaymentAuth): PaymentAuthJson = { + PaymentAuthJson( + auth_id = auth.authId, + trade_id = auth.tradeId, + buyer_account_id = auth.buyerAccountId, + seller_account_id = auth.sellerAccountId, + amount_fiat = auth.amountFiat, + currency = auth.currency, + state = auth.state, + hold_id = auth.holdId, + error_message = auth.errorMessage, + user_id = auth.userId, + consent_id = auth.consentId, + created_at = auth.createdAt.toInstant.toString, + updated_at = auth.updatedAt.toInstant.toString + ) + } +} diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index d32b943ab1..f97b871f80 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2092,4 +2092,182 @@ trait Connector extends MdcLoggable { panelId: String, callContext: Option[CallContext] ): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteSignatoryPanel _))), callContext)} + + // Trading Methods + def createTradingOffer( + bankId: BankId, + accountId: AccountId, + offerType: String, + assetCode: String, + assetAmount: BigDecimal, + priceCurrency: String, + priceAmount: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + (Failure(setUnimplementedError(nameOf(createTradingOffer _))), callContext) + } + + def getTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + (Empty, callContext) + } + + def cancelTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + (Failure(setUnimplementedError(nameOf(cancelTradingOffer _))), callContext) + } + + def updateTradingOffer( + offerId: String, + priceAmount: Option[BigDecimal], + expiryDatetime: Option[Date], + minimumFill: Option[BigDecimal], + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + (Failure(setUnimplementedError(nameOf(updateTradingOffer _))), callContext) + } + + def getTradingOffers( + bankId: BankId, + accountId: AccountId, + status: Option[String], + offerType: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[List[TradingOffer]]] = Future { + (Full(List.empty), callContext) + } + + // Market Trading Methods + def createMarketOrder( + bankId: BankId, + accountId: AccountId, + side: String, + price: BigDecimal, + quantity: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Failure(setUnimplementedError(nameOf(createMarketOrder _))), callContext) + } + + def getMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Empty, callContext) + } + + def cancelMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Failure(setUnimplementedError(nameOf(cancelMarketOrder _))), callContext) + } + + def createMarketMatch( + bankId: BankId, + accountId: AccountId, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketMatch]] = Future { + (Failure(setUnimplementedError(nameOf(createMarketMatch _))), callContext) + } + + def getMarketTrade( + bankId: BankId, + accountId: AccountId, + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketTrade]] = Future { + (Empty, callContext) + } + + def requestSettlement( + bankId: BankId, + accountId: AccountId, + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[Settlement]] = Future { + (Failure(setUnimplementedError(nameOf(requestSettlement _))), callContext) + } + + def notifyDeposit( + bankId: BankId, + accountId: AccountId, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + requiredConfirmations: Int, // Number of confirmations required + callContext: Option[CallContext] + ): OBPReturnType[Box[Deposit]] = Future { + (Failure(setUnimplementedError(nameOf(notifyDeposit _))), callContext) + } + + def requestWithdrawal( + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, + amount: BigDecimal, + address: String, + requiredConfirmations: Int, // Number of confirmations required + callContext: Option[CallContext] + ): OBPReturnType[Box[Withdrawal]] = Future { + (Failure(setUnimplementedError(nameOf(requestWithdrawal _))), callContext) + } + + // TCC Payment Authorization Methods + def createPaymentAuth( + bankId: BankId, + accountId: AccountId, + tradeId: String, + buyerAccountId: String, + sellerAccountId: String, + amountFiat: BigDecimal, + currency: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + (Failure(setUnimplementedError(nameOf(createPaymentAuth _))), callContext) + } + + def capturePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + (Failure(setUnimplementedError(nameOf(capturePaymentAuth _))), callContext) + } + + def releasePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + (Failure(setUnimplementedError(nameOf(releasePaymentAuth _))), callContext) + } + + def getPaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + (Failure(setUnimplementedError(nameOf(getPaymentAuth _))), callContext) + } } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index cab71474e1..e0b5b216e2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -88,6 +88,7 @@ import scalikejdbc.{ConnectionPool, ConnectionPoolSettings, MultipleConnectionPo import java.util.Date import java.util.UUID.randomUUID import scala.collection.immutable.{List, Nil} +import scala.collection.JavaConverters._ import scala.concurrent._ import scala.concurrent.duration._ import scala.language.postfixOps @@ -99,6 +100,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { val getTransactionsTTL = APIUtil.getPropsValue("connector.cache.ttl.seconds.getTransactions", "0").toInt * 1000 // Miliseconds + // Trading offer storage + private val tradingOffers = new java.util.concurrent.ConcurrentHashMap[String, TradingOffer]() + + // Market trading storage + private val marketOrders = new java.util.concurrent.ConcurrentHashMap[String, MarketOrder]() + private val marketMatches = new java.util.concurrent.ConcurrentHashMap[String, MarketMatch]() + private val marketTrades = new java.util.concurrent.ConcurrentHashMap[String, MarketTrade]() + private val settlements = new java.util.concurrent.ConcurrentHashMap[String, Settlement]() + private val deposits = new java.util.concurrent.ConcurrentHashMap[String, Deposit]() + private val withdrawals = new java.util.concurrent.ConcurrentHashMap[String, Withdrawal]() + private val paymentAuths = new java.util.concurrent.ConcurrentHashMap[String, PaymentAuth]() + //This is the implicit parameter for saveConnectorMetric function. //eg: override def getBank(bankId: BankId, callContext: Option[CallContext]) = saveConnectorMetric implicit override val nameOfConnector = LocalMappedConnector.getClass.getSimpleName @@ -5878,4 +5891,501 @@ object LocalMappedConnector extends Connector with MdcLoggable { (MappedMandateProvider.deleteSignatoryPanel(panelId), callContext) } + // Trading Methods Implementation + override def createTradingOffer( + bankId: BankId, + accountId: AccountId, + offerType: String, + assetCode: String, + assetAmount: BigDecimal, + priceCurrency: String, + priceAmount: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate offer ID (auto-generated UUID following OBP design pattern) + val offerId = randomUUID().toString + + // Create offer + val offer = TradingOffer( + offerId = offerId, + offerType = offerType, + status = "active", + offerDetails = TradingOfferDetails( + assetCode = assetCode, + assetAmount = assetAmount, + priceCurrency = priceCurrency, + priceAmount = priceAmount, + settlementAccountId = settlementAccountId, + expiryDatetime = None, + minimumFill = None + ), + accountInfo = TradingAccountInfo( + bankId = bankId.value, + accountId = accountId.value, + viewId = "owner" // Default view + ), + executions = List.empty, + userId = userId, + consentId = consentId, + createdAt = new Date(), + updatedAt = new Date() + ) + + // Store offer + tradingOffers.put(offerId, offer) + + (Full(offer), callContext) + } + + override def getTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + val offer = Option(tradingOffers.get(offerId)) + (Box(offer), callContext) + } + + override def cancelTradingOffer( + offerId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + val offer = Option(tradingOffers.get(offerId)) + + offer match { + case Some(o) => + val cancelledOffer = o.copy( + status = "cancelled", + updatedAt = new Date() + ) + tradingOffers.put(offerId, cancelledOffer) + (Full(cancelledOffer), callContext) + case None => + (Empty, callContext) + } + } + + override def updateTradingOffer( + offerId: String, + priceAmount: Option[BigDecimal], + expiryDatetime: Option[Date], + minimumFill: Option[BigDecimal], + callContext: Option[CallContext] + ): OBPReturnType[Box[TradingOffer]] = Future { + val offer = Option(tradingOffers.get(offerId)) + + offer match { + case Some(o) => + // Update only the fields that are provided + val updatedDetails = o.offerDetails.copy( + priceAmount = priceAmount.getOrElse(o.offerDetails.priceAmount), + expiryDatetime = expiryDatetime.orElse(o.offerDetails.expiryDatetime), + minimumFill = minimumFill.orElse(o.offerDetails.minimumFill) + ) + val updatedOffer = o.copy( + offerDetails = updatedDetails, + updatedAt = new Date() + ) + tradingOffers.put(offerId, updatedOffer) + (Full(updatedOffer), callContext) + case None => + (Empty, callContext) + } + } + + override def getTradingOffers( + bankId: BankId, + accountId: AccountId, + status: Option[String], + offerType: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[List[TradingOffer]]] = Future { + // Get all offers and filter by bankId and accountId + val allOffers = tradingOffers.values().asScala.toList + + val filteredOffers = allOffers + .filter(o => o.accountInfo.bankId == bankId.value && o.accountInfo.accountId == accountId.value) + .filter(o => status.forall(_ == o.status)) + .filter(o => offerType.forall(_ == o.offerType)) + .sortBy(_.createdAt.getTime)(Ordering[Long].reverse) // Most recent first + + (Full(filteredOffers), callContext) + } + + // Market Trading Methods Implementation + override def createMarketOrder( + bankId: BankId, + accountId: AccountId, + side: String, + price: BigDecimal, + quantity: BigDecimal, + settlementAccountId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate order ID (auto-generated UUID following OBP design pattern) + val orderId = randomUUID().toString + + // Create order + val order = MarketOrder( + orderId = orderId, + side = side, + price = price, + quantity = quantity, + accountId = settlementAccountId, + status = "active", + userId = userId, + consentId = consentId, + createdAt = new Date(), + updatedAt = new Date() + ) + + // Store order + marketOrders.put(orderId, order) + + (Full(order), callContext) + } + + override def getMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + val order = Option(marketOrders.get(orderId)) + (Box(order), callContext) + } + + override def cancelMarketOrder( + bankId: BankId, + accountId: AccountId, + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + val order = Option(marketOrders.get(orderId)) + + order match { + case Some(o) => + val cancelledOrder = o.copy( + status = "cancelled", + updatedAt = new Date() + ) + marketOrders.put(orderId, cancelledOrder) + (Full(cancelledOrder), callContext) + case None => + (Empty, callContext) + } + } + + override def createMarketMatch( + bankId: BankId, + accountId: AccountId, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketMatch]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate match ID + val matchId = randomUUID().toString + + // Create match + val marketMatch = MarketMatch( + matchId = matchId, + orderId = orderId, + counterOrderId = counterOrderId, + amount = amount, + price = price, + userId = userId, + consentId = consentId, + createdAt = new Date() + ) + + // Store match + marketMatches.put(matchId, marketMatch) + + // Create corresponding trade + val tradeId = randomUUID().toString + val trade = MarketTrade( + tradeId = tradeId, + buyOrderId = orderId, + sellOrderId = counterOrderId, + amount = amount, + price = price, + status = "pending", + userId = userId, + consentId = consentId, + createdAt = new Date() + ) + marketTrades.put(tradeId, trade) + + (Full(marketMatch), callContext) + } + + override def getMarketTrade( + bankId: BankId, + accountId: AccountId, + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketTrade]] = Future { + val trade = Option(marketTrades.get(tradeId)) + (Box(trade), callContext) + } + + override def requestSettlement( + bankId: BankId, + accountId: AccountId, + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[Settlement]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate settlement ID + val settlementId = randomUUID().toString + + // Create settlement + val settlement = Settlement( + settlementId = settlementId, + tradeId = tradeId, + step = step, + status = "pending", + userId = userId, + consentId = consentId, + createdAt = new Date(), + completedAt = None + ) + + // Store settlement + settlements.put(settlementId, settlement) + + (Full(settlement), callContext) + } + + override def notifyDeposit( + bankId: BankId, + accountId: AccountId, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + requiredConfirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[Deposit]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate deposit ID + val depositId = randomUUID().toString + + // Determine status based on confirmations + val status = if (confirmations >= requiredConfirmations) "confirmed" else "pending" + + // Create deposit + val deposit = Deposit( + depositId = depositId, + txHash = txHash, + from = from, + to = to, + amount = amount, + confirmations = confirmations, + requiredConfirmations = requiredConfirmations, + status = status, + nonce = None, // TODO: Extract from blockchain transaction + gasUsed = None, // TODO: Extract from blockchain transaction receipt + errorMessage = None, + userId = userId, + consentId = consentId, + createdAt = new Date() + ) + + // Store deposit + deposits.put(depositId, deposit) + + (Full(deposit), callContext) + } + + override def requestWithdrawal( + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, + amount: BigDecimal, + address: String, + requiredConfirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[Withdrawal]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate withdrawal ID (auto-generated UUID following OBP design pattern) + val withdrawalId = randomUUID().toString + + // Create withdrawal + val withdrawal = Withdrawal( + withdrawalId = withdrawalId, + accountId = settlementAccountId, + amount = amount, + address = address, + status = "pending", + txHash = None, // Will be set when transaction is submitted to blockchain + confirmations = None, // Will be updated as blockchain confirms + requiredConfirmations = requiredConfirmations, + nonce = None, // TODO: Will be set when transaction is submitted + gasUsed = None, // TODO: Will be set after transaction is mined + errorMessage = None, + userId = userId, + consentId = consentId, + createdAt = new Date() + ) + + // Store withdrawal + withdrawals.put(withdrawalId, withdrawal) + + (Full(withdrawal), callContext) + } + + // TCC Payment Authorization Implementation + override def createPaymentAuth( + bankId: BankId, + accountId: AccountId, + tradeId: String, + buyerAccountId: String, + sellerAccountId: String, + amountFiat: BigDecimal, + currency: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId: Option[String] = None // TODO: Extract from consent when available + + // Generate auth ID (auto-generated UUID following OBP design pattern) + val authId = randomUUID().toString + val now = new Date() + + // Create payment authorization in PREAUTH state + val auth = PaymentAuth( + authId = authId, + tradeId = tradeId, + buyerAccountId = buyerAccountId, + sellerAccountId = sellerAccountId, + amountFiat = amountFiat, + currency = currency, + state = "PREAUTH", // Initial state: funds are frozen + holdId = None, // TODO: P5 integration - create account hold + errorMessage = None, + userId = userId, + consentId = consentId, + createdAt = now, + updatedAt = now + ) + + // Store payment authorization + paymentAuths.put(authId, auth) + + (Full(auth), callContext) + } + + override def capturePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + // Retrieve existing authorization + Option(paymentAuths.get(authId)) match { + case Some(auth) => + // Validate state transition: only PREAUTH can be captured + auth.state match { + case "PREAUTH" => + // Update to CAPTURED state (funds are actually deducted) + val updatedAuth = auth.copy( + state = "CAPTURED", + updatedAt = new Date() + ) + paymentAuths.put(authId, updatedAuth) + (Full(updatedAuth), callContext) + + case "CAPTURED" => + (Failure(ErrorMessages.PaymentAuthAlreadyCaptured), callContext) + + case "RELEASED" => + (Failure(ErrorMessages.InvalidPaymentAuthState + " Cannot capture a released authorization."), callContext) + + case "FAILED" => + (Failure(ErrorMessages.InvalidPaymentAuthState + " Cannot capture a failed authorization."), callContext) + + case _ => + (Failure(ErrorMessages.InvalidPaymentAuthState), callContext) + } + + case None => + (Failure(ErrorMessages.PaymentAuthNotFound), callContext) + } + } + + override def releasePaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + // Retrieve existing authorization + Option(paymentAuths.get(authId)) match { + case Some(auth) => + // Validate state transition: PREAUTH or CAPTURED can be released + auth.state match { + case "PREAUTH" | "CAPTURED" => + // Update to RELEASED state (funds are unfrozen/refunded) + val updatedAuth = auth.copy( + state = "RELEASED", + updatedAt = new Date() + ) + paymentAuths.put(authId, updatedAuth) + (Full(updatedAuth), callContext) + + case "RELEASED" => + (Failure(ErrorMessages.PaymentAuthAlreadyReleased), callContext) + + case "FAILED" => + (Failure(ErrorMessages.InvalidPaymentAuthState + " Cannot release a failed authorization."), callContext) + + case _ => + (Failure(ErrorMessages.InvalidPaymentAuthState), callContext) + } + + case None => + (Failure(ErrorMessages.PaymentAuthNotFound), callContext) + } + } + + override def getPaymentAuth( + bankId: BankId, + accountId: AccountId, + authId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[PaymentAuth]] = Future { + // Retrieve payment authorization + Option(paymentAuths.get(authId)) match { + case Some(auth) => (Full(auth), callContext) + case None => (Failure(ErrorMessages.PaymentAuthNotFound), callContext) + } + } + } diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala index f24613c278..41fc854394 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModel.scala @@ -1374,4 +1374,141 @@ case class ListResult[+T <: List[_] : TypeTag](name: String, results: T) { def itemType: Type = implicitly[TypeTag[T]].tpe -} \ No newline at end of file +} + +// Trading Offer Models +case class TradingOffer( + offerId: String, + offerType: String, // "BUY" | "SELL" + status: String, // "active" | "cancelled" | "filled" | "expired" + offerDetails: TradingOfferDetails, + accountInfo: TradingAccountInfo, + executions: List[OfferExecution], + userId: String, // Audit: User who created the offer + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date, + updatedAt: Date +) + +case class TradingOfferDetails( + assetCode: String, + assetAmount: BigDecimal, + priceCurrency: String, + priceAmount: BigDecimal, + settlementAccountId: String, + expiryDatetime: Option[Date], + minimumFill: Option[BigDecimal] +) + +case class TradingAccountInfo( + bankId: String, + accountId: String, + viewId: String +) + +case class OfferExecution( + executionId: String, + executedAmount: BigDecimal, + executedPrice: BigDecimal, + executedAt: Date, + counterpartOfferId: String +) + +// Market Trading Models +case class MarketOrder( + orderId: String, + side: String, // "BUY" | "SELL" + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + status: String, // "active" | "cancelled" | "filled" + userId: String, // Audit: User who created the order + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date, + updatedAt: Date +) + +case class MarketMatch( + matchId: String, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + userId: String, // Audit: User who created the match + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date +) + +case class MarketTrade( + tradeId: String, + buyOrderId: String, + sellOrderId: String, + amount: BigDecimal, + price: BigDecimal, + status: String, // "pending" | "settled" + userId: String, // Audit: User who initiated the trade + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date +) + +case class Settlement( + settlementId: String, + tradeId: String, + step: Option[String], + status: String, // "pending" | "completed" | "failed" + userId: String, // Audit: User who requested settlement + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date, + completedAt: Option[Date] +) + +case class Deposit( + depositId: String, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + requiredConfirmations: Int, // Number of confirmations required (e.g., 12 for Ethereum mainnet) + status: String, // "confirmed" | "pending" + nonce: Option[Long], // Transaction nonce from blockchain + gasUsed: Option[Long], // Gas consumed by the transaction + errorMessage: Option[String], // Error details if transaction failed + userId: String, // Audit: User who notified the deposit + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date +) + +// TCC (Try-Confirm-Cancel) Payment Authorization for atomic settlement +case class PaymentAuth( + authId: String, + tradeId: String, // Link to the trade being settled + buyerAccountId: String, // Buyer's fiat account (EUR) + sellerAccountId: String, // Seller's fiat account (EUR) + amountFiat: BigDecimal, // Amount to authorize in fiat currency + currency: String, // Currency code (e.g., "EUR") + state: String, // "PREAUTH" | "CAPTURED" | "RELEASED" | "FAILED" + holdId: Option[String], // Link to OBP Account Hold (P5 integration) + errorMessage: Option[String], // Error details if state is FAILED + userId: String, // Audit: User who created the authorization + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date, + updatedAt: Date +) + +case class Withdrawal( + withdrawalId: String, + accountId: String, + amount: BigDecimal, + address: String, + status: String, // "pending" | "completed" | "failed" + txHash: Option[String], + confirmations: Option[Int], // Current number of confirmations (if tx submitted) + requiredConfirmations: Int, // Number of confirmations required + nonce: Option[Long], // Transaction nonce from blockchain + gasUsed: Option[Long], // Gas consumed by the transaction + errorMessage: Option[String], // Error details if transaction failed + userId: String, // Audit: User who requested withdrawal + consentId: Option[String], // Audit: Consent ID if applicable + createdAt: Date +)