From 8039973613eaaaf44bcc599c93789204ee9c386e Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 14 Apr 2026 12:04:54 +0200 Subject: [PATCH 01/17] docfix/code clean --- .postman.json | 446 -------------------------------------- .postman_environment.json | 30 --- .postman_simple.json | 83 ------- 3 files changed, 559 deletions(-) delete mode 100644 .postman.json delete mode 100644 .postman_environment.json delete mode 100644 .postman_simple.json 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);", - "});" - ] - } - } - ] - } - ] -} From 603b2f9feb63ef6917045acfed8b9aa1510e42e0 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 15 Apr 2026 15:40:02 +0200 Subject: [PATCH 02/17] refactor/remove idempotency_key from trading endpoints - use auto-generated UUID for offer_id --- .../main/scala/code/api/util/NewStyle.scala | 38 ++++ .../scala/code/api/v7_0_0/Http4s700.scala | 175 ++++++++++++++++++ .../scala/code/bankconnectors/Connector.scala | 29 +++ .../bankconnectors/LocalMappedConnector.scala | 75 ++++++++ 4 files changed, 317 insertions(+) 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..3b2c46b094 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,44 @@ 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) + } + } } } 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 f3ca840ff2..3f07123be1 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 @@ -862,6 +862,181 @@ 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", + """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, + 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", + """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, + 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: 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", + """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 ────────────────────────────────────────────────── // All routes combined (without middleware - for direct use). diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index d32b943ab1..43a31e5bdf 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2092,4 +2092,33 @@ 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) + } } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index cab71474e1..a907db2fa8 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -99,6 +99,9 @@ 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]() + //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 +5881,76 @@ 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 { + // 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, + 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) + } + } + } From 2e84cf95af1bd30f0e7c1cce9884a340ac1b21e8 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 15 Apr 2026 22:12:51 +0200 Subject: [PATCH 03/17] feature/add trading domain models, JSON models, error messages and API tags for v7.0.0 --- .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 7 ++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 97 ++++++++++++++++++- .../commons/model/CommonModel.scala | 38 +++++++- 4 files changed, 140 insertions(+), 3 deletions(-) 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..e7593af8d2 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,7 @@ object ApiTag { val apiTagSystem = ResourceDocTag("System") val apiTagCache = ResourceDocTag("Cache") val apiTagLogCache = ResourceDocTag("Log-Cache") + val apiTagTrading = ResourceDocTag("Trading") 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 63d4033ebc..281307e58c 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -846,6 +846,13 @@ 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 DuplicateIdempotencyKey = "OBP-71004: Duplicate idempotency key." + val CreateTradingOfferError = "OBP-71005: Could not create trading offer." + // 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/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 8bb51db931..94d7b64b72 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,98 @@ 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 + ) + + // Response Models + case class TradingOfferJson( + offer_id: String, + status: String, + offer_details: OfferDetailsJson, + account_info: AccountInfoJson, + executions: List[OfferExecutionJson], + 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 + ) + + // 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 + )), + 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 + ) + } +} 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..041de4c911 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,40 @@ 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], + 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 +) From 21a548c4351fd7403f9c1329db85b24e077a3247 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 15 Apr 2026 22:29:47 +0200 Subject: [PATCH 04/17] feature/add GET trading offers list endpoint with filtering support --- .../main/scala/code/api/util/NewStyle.scala | 12 +++ .../scala/code/api/v7_0_0/Http4s700.scala | 73 +++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 4 + .../scala/code/bankconnectors/Connector.scala | 10 +++ .../bankconnectors/LocalMappedConnector.scala | 20 +++++ 5 files changed, 119 insertions(+) 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 3b2c46b094..c9a913bec6 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4601,6 +4601,18 @@ object NewStyle extends MdcLoggable{ 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) + } + } } } 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 3f07123be1..e8121671f8 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 @@ -1003,6 +1003,79 @@ object Http4s700 { 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", + """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, + 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 => 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 94d7b64b72..32a60af5e1 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 @@ -111,6 +111,10 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { status: String ) + case class TradingOffersJson( + offers: List[TradingOfferJson] + ) + // Conversion Functions def createTradingOfferJson(offer: com.openbankproject.commons.model.TradingOffer): TradingOfferJson = { TradingOfferJson( diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 43a31e5bdf..d19d479ce1 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2121,4 +2121,14 @@ trait Connector extends MdcLoggable { ): OBPReturnType[Box[TradingOffer]] = Future { (Failure(setUnimplementedError(nameOf(cancelTradingOffer _))), 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) + } } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index a907db2fa8..08500a4d63 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 @@ -5953,4 +5954,23 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } + 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) + } + } From 3432b6a11afa09c3d0cc9a946e349bffb9637362 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 00:01:54 +0200 Subject: [PATCH 05/17] feature/add market domain models and connector methods for Phase 2 --- .../scala/code/bankconnectors/Connector.scala | 72 ++++++ .../bankconnectors/LocalMappedConnector.scala | 214 ++++++++++++++++++ .../commons/model/CommonModel.scala | 61 +++++ 3 files changed, 347 insertions(+) diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index d19d479ce1..d8e4ebf590 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2131,4 +2131,76 @@ trait Connector extends MdcLoggable { ): OBPReturnType[Box[List[TradingOffer]]] = Future { (Full(List.empty), callContext) } + + // Market Trading Methods + def createMarketOrder( + side: String, + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Failure(setUnimplementedError(nameOf(createMarketOrder _))), callContext) + } + + def getMarketOrder( + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Empty, callContext) + } + + def cancelMarketOrder( + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + (Failure(setUnimplementedError(nameOf(cancelMarketOrder _))), callContext) + } + + def createMarketMatch( + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketMatch]] = Future { + (Failure(setUnimplementedError(nameOf(createMarketMatch _))), callContext) + } + + def getMarketTrade( + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketTrade]] = Future { + (Empty, callContext) + } + + def requestSettlement( + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[Settlement]] = Future { + (Failure(setUnimplementedError(nameOf(requestSettlement _))), callContext) + } + + def notifyDeposit( + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[Deposit]] = Future { + (Failure(setUnimplementedError(nameOf(notifyDeposit _))), callContext) + } + + def requestWithdrawal( + accountId: String, + amount: BigDecimal, + address: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Withdrawal]] = Future { + (Failure(setUnimplementedError(nameOf(requestWithdrawal _))), callContext) + } } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 08500a4d63..8c2c8fca5c 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -103,6 +103,16 @@ object LocalMappedConnector extends Connector with MdcLoggable { // 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 orderIdempotencyKeys = new java.util.concurrent.ConcurrentHashMap[String, String]() + private val withdrawalIdempotencyKeys = new java.util.concurrent.ConcurrentHashMap[String, String]() + //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 @@ -5973,4 +5983,208 @@ object LocalMappedConnector extends Connector with MdcLoggable { (Full(filteredOffers), callContext) } + // Market Trading Methods Implementation + override def createMarketOrder( + side: String, + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + // Check idempotency + val existingOrderId = Option(orderIdempotencyKeys.get(idempotencyKey)) + existingOrderId match { + case Some(orderId) => + // Return existing order + val existingOrder = Option(marketOrders.get(orderId)) + (Box(existingOrder), callContext) + case None => + // Generate order ID + val orderId = randomUUID().toString + + // Create order + val order = MarketOrder( + orderId = orderId, + side = side, + price = price, + quantity = quantity, + accountId = accountId, + status = "active", + createdAt = new Date(), + updatedAt = new Date() + ) + + // Store order and idempotency key + marketOrders.put(orderId, order) + orderIdempotencyKeys.put(idempotencyKey, orderId) + + (Full(order), callContext) + } + } + + override def getMarketOrder( + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketOrder]] = Future { + val order = Option(marketOrders.get(orderId)) + (Box(order), callContext) + } + + override def cancelMarketOrder( + 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( + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketMatch]] = Future { + // Generate match ID + val matchId = randomUUID().toString + + // Create match + val marketMatch = MarketMatch( + matchId = matchId, + orderId = orderId, + counterOrderId = counterOrderId, + amount = amount, + price = price, + 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", + createdAt = new Date() + ) + marketTrades.put(tradeId, trade) + + (Full(marketMatch), callContext) + } + + override def getMarketTrade( + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MarketTrade]] = Future { + val trade = Option(marketTrades.get(tradeId)) + (Box(trade), callContext) + } + + override def requestSettlement( + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[Box[Settlement]] = Future { + // Generate settlement ID + val settlementId = randomUUID().toString + + // Create settlement + val settlement = Settlement( + settlementId = settlementId, + tradeId = tradeId, + step = step, + status = "pending", + createdAt = new Date(), + completedAt = None + ) + + // Store settlement + settlements.put(settlementId, settlement) + + (Full(settlement), callContext) + } + + override def notifyDeposit( + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[Deposit]] = Future { + // Generate deposit ID + val depositId = randomUUID().toString + + // Create deposit + val deposit = Deposit( + depositId = depositId, + txHash = txHash, + from = from, + to = to, + amount = amount, + confirmations = confirmations, + status = "confirmed", + createdAt = new Date() + ) + + // Store deposit + deposits.put(depositId, deposit) + + (Full(deposit), callContext) + } + + override def requestWithdrawal( + accountId: String, + amount: BigDecimal, + address: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Withdrawal]] = Future { + // Check idempotency + val existingWithdrawalId = Option(withdrawalIdempotencyKeys.get(idempotencyKey)) + existingWithdrawalId match { + case Some(withdrawalId) => + // Return existing withdrawal + val existingWithdrawal = Option(withdrawals.get(withdrawalId)) + (Box(existingWithdrawal), callContext) + case None => + // Generate withdrawal ID + val withdrawalId = randomUUID().toString + + // Create withdrawal + val withdrawal = Withdrawal( + withdrawalId = withdrawalId, + accountId = accountId, + amount = amount, + address = address, + status = "pending", + txHash = None, + createdAt = new Date() + ) + + // Store withdrawal and idempotency key + withdrawals.put(withdrawalId, withdrawal) + withdrawalIdempotencyKeys.put(idempotencyKey, withdrawalId) + + (Full(withdrawal), 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 041de4c911..478a946d98 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 @@ -1411,3 +1411,64 @@ case class OfferExecution( 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" + createdAt: Date, + updatedAt: Date +) + +case class MarketMatch( + matchId: String, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + createdAt: Date +) + +case class MarketTrade( + tradeId: String, + buyOrderId: String, + sellOrderId: String, + amount: BigDecimal, + price: BigDecimal, + status: String, // "pending" | "settled" + createdAt: Date +) + +case class Settlement( + settlementId: String, + tradeId: String, + step: Option[String], + status: String, // "pending" | "completed" | "failed" + createdAt: Date, + completedAt: Option[Date] +) + +case class Deposit( + depositId: String, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + status: String, // "confirmed" | "pending" + createdAt: Date +) + +case class Withdrawal( + withdrawalId: String, + accountId: String, + amount: BigDecimal, + address: String, + status: String, // "pending" | "completed" | "failed" + txHash: Option[String], + createdAt: Date +) From 0a75e5d79e24a31a74ce98f574cac9f9f02d5220 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 00:06:57 +0200 Subject: [PATCH 06/17] feature/add market JSON models error messages and API tag for Phase 2 --- .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 8 + .../scala/code/api/v7_0_0/Http4s700.scala | 2 +- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 172 ++++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) 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 e7593af8d2..6ef32178ff 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -104,6 +104,7 @@ object ApiTag { 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 281307e58c..f145f0f203 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -853,6 +853,14 @@ object ErrorMessages { val DuplicateIdempotencyKey = "OBP-71004: Duplicate idempotency key." 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." + // 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/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index e8121671f8..f09188c6e4 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 @@ -1067,7 +1067,7 @@ object Http4s700 { ), executions = List.empty, created_at = "2026-04-15T10:30:00Z", - updated_at = º"2026-04-15T10:30:00Z" + updated_at = "2026-04-15T10:30:00Z" ) ) ), 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 32a60af5e1..ccc9732e5c 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 @@ -153,4 +153,176 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { status = offer.status ) } + + // Market Trading JSON Models + + // Market Request Models + case class CreateMarketOrderRequestJson( + side: String, // "BUY" | "SELL" + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + idempotencyKey: String + ) + + case class CreateMarketMatchRequestJson( + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal + ) + + case class RequestSettlementJson( + tradeId: String, + step: Option[String] + ) + + case class NotifyDepositJson( + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int + ) + + case class RequestWithdrawalJson( + accountId: String, + amount: BigDecimal, + address: String, + idempotencyKey: String + ) + + // Market Response Models + case class MarketOrderJson( + orderId: String, + side: String, + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + status: String, + createdAt: String, // ISO 8601 + updatedAt: String // ISO 8601 + ) + + case class MarketMatchJson( + matchId: String, + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + createdAt: String // ISO 8601 + ) + + case class MarketTradeJson( + tradeId: String, + buyOrderId: String, + sellOrderId: String, + amount: BigDecimal, + price: BigDecimal, + status: String, + createdAt: String // ISO 8601 + ) + + case class SettlementJson( + settlementId: String, + tradeId: String, + step: Option[String], + status: String, + createdAt: String, // ISO 8601 + completedAt: Option[String] // ISO 8601 + ) + + case class DepositJson( + depositId: String, + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + status: String, + createdAt: String // ISO 8601 + ) + + case class WithdrawalJson( + withdrawalId: String, + accountId: String, + amount: BigDecimal, + address: String, + status: String, + txHash: Option[String], + createdAt: String // ISO 8601 + ) + + // Market Conversion Functions + def createMarketOrderJson(order: com.openbankproject.commons.model.MarketOrder): MarketOrderJson = { + MarketOrderJson( + orderId = order.orderId, + side = order.side, + price = order.price, + quantity = order.quantity, + accountId = order.accountId, + status = order.status, + createdAt = order.createdAt.toInstant.toString, + updatedAt = order.updatedAt.toInstant.toString + ) + } + + def createMarketMatchJson(marketMatch: com.openbankproject.commons.model.MarketMatch): MarketMatchJson = { + MarketMatchJson( + matchId = marketMatch.matchId, + orderId = marketMatch.orderId, + counterOrderId = marketMatch.counterOrderId, + amount = marketMatch.amount, + price = marketMatch.price, + createdAt = marketMatch.createdAt.toInstant.toString + ) + } + + def createMarketTradeJson(trade: com.openbankproject.commons.model.MarketTrade): MarketTradeJson = { + MarketTradeJson( + tradeId = trade.tradeId, + buyOrderId = trade.buyOrderId, + sellOrderId = trade.sellOrderId, + amount = trade.amount, + price = trade.price, + status = trade.status, + createdAt = trade.createdAt.toInstant.toString + ) + } + + def createSettlementJson(settlement: com.openbankproject.commons.model.Settlement): SettlementJson = { + SettlementJson( + settlementId = settlement.settlementId, + tradeId = settlement.tradeId, + step = settlement.step, + status = settlement.status, + createdAt = settlement.createdAt.toInstant.toString, + completedAt = settlement.completedAt.map(_.toInstant.toString) + ) + } + + def createDepositJson(deposit: com.openbankproject.commons.model.Deposit): DepositJson = { + DepositJson( + depositId = deposit.depositId, + txHash = deposit.txHash, + from = deposit.from, + to = deposit.to, + amount = deposit.amount, + confirmations = deposit.confirmations, + status = deposit.status, + createdAt = deposit.createdAt.toInstant.toString + ) + } + + def createWithdrawalJson(withdrawal: com.openbankproject.commons.model.Withdrawal): WithdrawalJson = { + WithdrawalJson( + withdrawalId = withdrawal.withdrawalId, + accountId = withdrawal.accountId, + amount = withdrawal.amount, + address = withdrawal.address, + status = withdrawal.status, + txHash = withdrawal.txHash, + createdAt = withdrawal.createdAt.toInstant.toString + ) + } } From 1a772300cfb4e7491ed4b81837fe2fd7624cd5df Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 00:28:07 +0200 Subject: [PATCH 07/17] feature/implement 8 market endpoints for OBP Trading v7.0.0 - Added 8 HTTP endpoints in Http4s700.scala (POST/GET/DELETE for orders, matches, trades, settlements, deposits, withdrawals) - Added 8 NewStyle.function methods for market operations - Fixed snake_case field names in all market JSON models (JSONFactory7.0.0.scala) - All endpoints use EndpointHelpers.withUser for authentication - Idempotency enforced for create order and request withdrawal - Complete ResourceDocs with examples for all endpoints - Compilation successful (BUILD SUCCESS, 15.5s) - Tasks 21-28 completed (Phase 2 endpoints) --- .../main/scala/code/api/util/NewStyle.scala | 96 ++++ .../scala/code/api/v7_0_0/Http4s700.scala | 411 ++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 108 ++--- 3 files changed, 561 insertions(+), 54 deletions(-) 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 c9a913bec6..a71c36f810 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4613,6 +4613,102 @@ object NewStyle extends MdcLoggable{ i => (unboxFullOrFail(i._1, callContext, s"$BankAccountNotFound"), i._2) } } + + // Market Methods + def createMarketOrder( + side: String, + price: BigDecimal, + quantity: BigDecimal, + accountId: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.createMarketOrder( + side, price, quantity, accountId, idempotencyKey, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def getMarketOrder( + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.getMarketOrder(orderId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def cancelMarketOrder( + orderId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { + Connector.connector.vend.cancelMarketOrder(orderId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) + } + } + + def createMarketMatch( + orderId: String, + counterOrderId: String, + amount: BigDecimal, + price: BigDecimal, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketMatch] = { + Connector.connector.vend.createMarketMatch( + orderId, counterOrderId, amount, price, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidMatchParameters"), i._2) + } + } + + def getMarketTrade( + tradeId: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.MarketTrade] = { + Connector.connector.vend.getMarketTrade(tradeId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound"), i._2) + } + } + + def requestSettlement( + tradeId: String, + step: Option[String], + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Settlement] = { + Connector.connector.vend.requestSettlement(tradeId, step, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$SettlementFailed"), i._2) + } + } + + def notifyDeposit( + txHash: String, + from: String, + to: String, + amount: BigDecimal, + confirmations: Int, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Deposit] = { + Connector.connector.vend.notifyDeposit( + txHash, from, to, amount, confirmations, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$InvalidTradingAmount"), i._2) + } + } + + def requestWithdrawal( + accountId: String, + amount: BigDecimal, + address: String, + idempotencyKey: String, + callContext: Option[CallContext] + ): OBPReturnType[com.openbankproject.commons.model.Withdrawal] = { + Connector.connector.vend.requestWithdrawal( + accountId, amount, address, idempotencyKey, callContext + ) map { + i => (unboxFullOrFail(i._1, callContext, s"$WithdrawalFailed"), 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 f09188c6e4..c127e43054 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 @@ -1112,6 +1112,417 @@ object Http4s700 { // ── End Phase 1 batch 2 ────────────────────────────────────────────────── + // ── Market Endpoints (Phase 2) ───────────────────────────────────────── + + // Route: POST /obp/v7.0.0/market/orders + val createMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "market" / "orders" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreateMarketOrderRequestJson, JSONFactory700.MarketOrderJson](req) { (user, createOrderJson, cc) => + for { + // Validate side + _ <- Helper.booleanToFuture( + failMsg = InvalidOrderSide, + failCode = 400, + cc = Some(cc) + )(createOrderJson.side == "BUY" || createOrderJson.side == "SELL") + + // Validate price + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(createOrderJson.price > 0) + + // Validate quantity + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(createOrderJson.quantity > 0) + + // Invoke connector + (order, callContext) <- NewStyle.function.createMarketOrder( + createOrderJson.side, + createOrderJson.price, + createOrderJson.quantity, + createOrderJson.account_id, + createOrderJson.idempotency_key, + Some(cc) + ) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createMarketOrder), + "POST", + "/market/orders", + "Create Market Order", + """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. + | + |Authentication is required.""", + JSONFactory700.CreateMarketOrderRequestJson( + side = "BUY", + price = BigDecimal("25.0"), + quantity = BigDecimal("10.0"), + account_id = "buyer-fiat-account", + idempotency_key = "order-12345" + ), + 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", + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:30:00Z" + ), + List(InvalidJsonFormat, InvalidOrderSide, InvalidTradingAmount, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(createMarketOrder) + ) + + // Route: GET /obp/v7.0.0/market/orders/ORDER_ID + val getMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "market" / "orders" / orderId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (order, callContext) <- NewStyle.function.getMarketOrder(orderId, Some(cc)) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMarketOrder), + "GET", + "/market/orders/ORDER_ID", + "Get Market Order", + """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", + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:30:00Z" + ), + List(OrderNotFound, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(getMarketOrder) + ) + + // Route: DELETE /obp/v7.0.0/market/orders/ORDER_ID + val cancelMarketOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "market" / "orders" / orderId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (order, callContext) <- NewStyle.function.cancelMarketOrder(orderId, Some(cc)) + } yield JSONFactory700.createMarketOrderJson(order) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(cancelMarketOrder), + "DELETE", + "/market/orders/ORDER_ID", + "Cancel Market Order", + """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", + created_at = "2026-04-16T00:30:00Z", + updated_at = "2026-04-16T00:35:00Z" + ), + List(OrderNotFound, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(cancelMarketOrder) + ) + + // Route: POST /obp/v7.0.0/market/matches + val createMarketMatch: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "market" / "matches" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.CreateMarketMatchRequestJson, JSONFactory700.MarketMatchJson](req) { (user, createMatchJson, cc) => + for { + // Validate amount + _ <- Helper.booleanToFuture( + failMsg = InvalidMatchParameters, + failCode = 400, + cc = Some(cc) + )(createMatchJson.amount > 0) + + // Validate price + _ <- Helper.booleanToFuture( + failMsg = InvalidMatchParameters, + failCode = 400, + cc = Some(cc) + )(createMatchJson.price > 0) + + // Invoke connector + (matchResult, callContext) <- NewStyle.function.createMarketMatch( + createMatchJson.order_id, + createMatchJson.counter_order_id, + createMatchJson.amount, + createMatchJson.price, + Some(cc) + ) + } yield JSONFactory700.createMarketMatchJson(matchResult) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createMarketMatch), + "POST", + "/market/matches", + "Create Market Match", + """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"), + created_at = "2026-04-16T00:40:00Z" + ), + List(InvalidJsonFormat, InvalidMatchParameters, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(createMarketMatch) + ) + + // Route: GET /obp/v7.0.0/market/trades/TRADE_ID + val getMarketTrade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "market" / "trades" / tradeId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (trade, callContext) <- NewStyle.function.getMarketTrade(tradeId, Some(cc)) + } yield JSONFactory700.createMarketTradeJson(trade) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMarketTrade), + "GET", + "/market/trades/TRADE_ID", + "Get Market Trade", + """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", + created_at = "2026-04-16T00:40:00Z" + ), + List(TradeNotFound, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(getMarketTrade) + ) + + // Route: POST /obp/v7.0.0/market/settlements + val requestSettlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "market" / "settlements" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.RequestSettlementJson, JSONFactory700.SettlementJson](req) { (user, requestJson, cc) => + for { + // Invoke connector + (settlement, callContext) <- NewStyle.function.requestSettlement( + requestJson.trade_id, + requestJson.step, + Some(cc) + ) + } yield JSONFactory700.createSettlementJson(settlement) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(requestSettlement), + "POST", + "/market/settlements", + "Request Settlement", + """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", + created_at = "2026-04-16T00:45:00Z", + completed_at = None + ), + List(InvalidJsonFormat, SettlementFailed, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(requestSettlement) + ) + + // Route: POST /obp/v7.0.0/market/deposits + val notifyDeposit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "market" / "deposits" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.NotifyDepositJson, JSONFactory700.DepositJson](req) { (user, depositJson, cc) => + for { + // Validate amount + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(depositJson.amount > 0) + + // Validate confirmations + _ <- Helper.booleanToFuture( + failMsg = InvalidMatchParameters, + failCode = 400, + cc = Some(cc) + )(depositJson.confirmations >= 0) + + // Invoke connector + (deposit, callContext) <- NewStyle.function.notifyDeposit( + depositJson.tx_hash, + depositJson.from, + depositJson.to, + depositJson.amount, + depositJson.confirmations, + Some(cc) + ) + } yield JSONFactory700.createDepositJson(deposit) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(notifyDeposit), + "POST", + "/market/deposits", + "Notify Deposit", + """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, + status = "confirmed", + created_at = "2026-04-16T00:50:00Z" + ), + List(InvalidJsonFormat, InvalidTradingAmount, InvalidMatchParameters, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(notifyDeposit) + ) + + // Route: POST /obp/v7.0.0/market/withdrawals + val requestWithdrawal: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "market" / "withdrawals" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.RequestWithdrawalJson, JSONFactory700.WithdrawalJson](req) { (user, withdrawalJson, cc) => + for { + // Validate amount + _ <- Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(withdrawalJson.amount > 0) + + // Invoke connector + (withdrawal, callContext) <- NewStyle.function.requestWithdrawal( + withdrawalJson.account_id, + withdrawalJson.amount, + withdrawalJson.address, + withdrawalJson.idempotency_key, + Some(cc) + ) + } yield JSONFactory700.createWithdrawalJson(withdrawal) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(requestWithdrawal), + "POST", + "/market/withdrawals", + "Request Withdrawal", + """Request a withdrawal to a blockchain address. + | + |This operation is idempotent via the idempotency_key. + | + |Authentication is required.""", + JSONFactory700.RequestWithdrawalJson( + account_id = "account-123", + amount = BigDecimal("50.0"), + address = "0xdestination", + idempotency_key = "withdrawal-456" + ), + JSONFactory700.WithdrawalJson( + withdrawal_id = "withdrawal-303", + account_id = "account-123", + amount = BigDecimal("50.0"), + address = "0xdestination", + status = "pending", + tx_hash = None, + created_at = "2026-04-16T00:55:00Z" + ), + List(InvalidJsonFormat, InvalidTradingAmount, WithdrawalFailed, $AuthenticatedUserIsRequired, UnknownError), + apiTagMarket :: Nil, + http4sPartialFunction = Some(requestWithdrawal) + ) + + // ── End Market Endpoints (Phase 2) ───────────────────────────────────── + // All routes combined (without middleware - for direct use). // // Routes are sorted automatically by URL template specificity (segment count, 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 ccc9732e5c..afc00ddfda 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 @@ -161,24 +161,24 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { side: String, // "BUY" | "SELL" price: BigDecimal, quantity: BigDecimal, - accountId: String, - idempotencyKey: String + account_id: String, + idempotency_key: String ) case class CreateMarketMatchRequestJson( - orderId: String, - counterOrderId: String, + order_id: String, + counter_order_id: String, amount: BigDecimal, price: BigDecimal ) case class RequestSettlementJson( - tradeId: String, + trade_id: String, step: Option[String] ) case class NotifyDepositJson( - txHash: String, + tx_hash: String, from: String, to: String, amount: BigDecimal, @@ -186,143 +186,143 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) case class RequestWithdrawalJson( - accountId: String, + account_id: String, amount: BigDecimal, address: String, - idempotencyKey: String + idempotency_key: String ) // Market Response Models case class MarketOrderJson( - orderId: String, + order_id: String, side: String, price: BigDecimal, quantity: BigDecimal, - accountId: String, + account_id: String, status: String, - createdAt: String, // ISO 8601 - updatedAt: String // ISO 8601 + created_at: String, // ISO 8601 + updated_at: String // ISO 8601 ) case class MarketMatchJson( - matchId: String, - orderId: String, - counterOrderId: String, + match_id: String, + order_id: String, + counter_order_id: String, amount: BigDecimal, price: BigDecimal, - createdAt: String // ISO 8601 + created_at: String // ISO 8601 ) case class MarketTradeJson( - tradeId: String, - buyOrderId: String, - sellOrderId: String, + trade_id: String, + buy_order_id: String, + sell_order_id: String, amount: BigDecimal, price: BigDecimal, status: String, - createdAt: String // ISO 8601 + created_at: String // ISO 8601 ) case class SettlementJson( - settlementId: String, - tradeId: String, + settlement_id: String, + trade_id: String, step: Option[String], status: String, - createdAt: String, // ISO 8601 - completedAt: Option[String] // ISO 8601 + created_at: String, // ISO 8601 + completed_at: Option[String] // ISO 8601 ) case class DepositJson( - depositId: String, - txHash: String, + deposit_id: String, + tx_hash: String, from: String, to: String, amount: BigDecimal, confirmations: Int, status: String, - createdAt: String // ISO 8601 + created_at: String // ISO 8601 ) case class WithdrawalJson( - withdrawalId: String, - accountId: String, + withdrawal_id: String, + account_id: String, amount: BigDecimal, address: String, status: String, - txHash: Option[String], - createdAt: String // ISO 8601 + tx_hash: Option[String], + created_at: String // ISO 8601 ) // Market Conversion Functions def createMarketOrderJson(order: com.openbankproject.commons.model.MarketOrder): MarketOrderJson = { MarketOrderJson( - orderId = order.orderId, + order_id = order.orderId, side = order.side, price = order.price, quantity = order.quantity, - accountId = order.accountId, + account_id = order.accountId, status = order.status, - createdAt = order.createdAt.toInstant.toString, - updatedAt = order.updatedAt.toInstant.toString + created_at = order.createdAt.toInstant.toString, + updated_at = order.updatedAt.toInstant.toString ) } def createMarketMatchJson(marketMatch: com.openbankproject.commons.model.MarketMatch): MarketMatchJson = { MarketMatchJson( - matchId = marketMatch.matchId, - orderId = marketMatch.orderId, - counterOrderId = marketMatch.counterOrderId, + match_id = marketMatch.matchId, + order_id = marketMatch.orderId, + counter_order_id = marketMatch.counterOrderId, amount = marketMatch.amount, price = marketMatch.price, - createdAt = marketMatch.createdAt.toInstant.toString + created_at = marketMatch.createdAt.toInstant.toString ) } def createMarketTradeJson(trade: com.openbankproject.commons.model.MarketTrade): MarketTradeJson = { MarketTradeJson( - tradeId = trade.tradeId, - buyOrderId = trade.buyOrderId, - sellOrderId = trade.sellOrderId, + trade_id = trade.tradeId, + buy_order_id = trade.buyOrderId, + sell_order_id = trade.sellOrderId, amount = trade.amount, price = trade.price, status = trade.status, - createdAt = trade.createdAt.toInstant.toString + created_at = trade.createdAt.toInstant.toString ) } def createSettlementJson(settlement: com.openbankproject.commons.model.Settlement): SettlementJson = { SettlementJson( - settlementId = settlement.settlementId, - tradeId = settlement.tradeId, + settlement_id = settlement.settlementId, + trade_id = settlement.tradeId, step = settlement.step, status = settlement.status, - createdAt = settlement.createdAt.toInstant.toString, - completedAt = settlement.completedAt.map(_.toInstant.toString) + created_at = settlement.createdAt.toInstant.toString, + completed_at = settlement.completedAt.map(_.toInstant.toString) ) } def createDepositJson(deposit: com.openbankproject.commons.model.Deposit): DepositJson = { DepositJson( - depositId = deposit.depositId, - txHash = deposit.txHash, + deposit_id = deposit.depositId, + tx_hash = deposit.txHash, from = deposit.from, to = deposit.to, amount = deposit.amount, confirmations = deposit.confirmations, status = deposit.status, - createdAt = deposit.createdAt.toInstant.toString + created_at = deposit.createdAt.toInstant.toString ) } def createWithdrawalJson(withdrawal: com.openbankproject.commons.model.Withdrawal): WithdrawalJson = { WithdrawalJson( - withdrawalId = withdrawal.withdrawalId, - accountId = withdrawal.accountId, + withdrawal_id = withdrawal.withdrawalId, + account_id = withdrawal.accountId, amount = withdrawal.amount, address = withdrawal.address, status = withdrawal.status, - txHash = withdrawal.txHash, - createdAt = withdrawal.createdAt.toInstant.toString + tx_hash = withdrawal.txHash, + created_at = withdrawal.createdAt.toInstant.toString ) } } From 6271cc4da873a86a10d39945769b873710f17d04 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 13:38:10 +0200 Subject: [PATCH 08/17] refactor/remove idempotency_key field and use order_id UUID pattern for market endpoints --- .../scala/code/api/util/ErrorMessages.scala | 1 - .../main/scala/code/api/util/NewStyle.scala | 6 +- .../scala/code/api/v7_0_0/Http4s700.scala | 12 ++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 6 +- .../scala/code/bankconnectors/Connector.scala | 2 - .../bankconnectors/LocalMappedConnector.scala | 88 +++++++------------ 6 files changed, 41 insertions(+), 74 deletions(-) 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 f145f0f203..e4ec05e2ae 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -850,7 +850,6 @@ object ErrorMessages { 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 DuplicateIdempotencyKey = "OBP-71004: Duplicate idempotency key." val CreateTradingOfferError = "OBP-71005: Could not create trading offer." // Market Trading Exceptions (OBP-72XXX) 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 a71c36f810..41722bd44f 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4620,11 +4620,10 @@ object NewStyle extends MdcLoggable{ price: BigDecimal, quantity: BigDecimal, accountId: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { Connector.connector.vend.createMarketOrder( - side, price, quantity, accountId, idempotencyKey, callContext + side, price, quantity, accountId, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2) } @@ -4700,11 +4699,10 @@ object NewStyle extends MdcLoggable{ accountId: String, amount: BigDecimal, address: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.Withdrawal] = { Connector.connector.vend.requestWithdrawal( - accountId, amount, address, idempotencyKey, callContext + accountId, amount, address, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$WithdrawalFailed"), 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 c127e43054..b1b4b0e419 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 @@ -1146,7 +1146,6 @@ object Http4s700 { createOrderJson.price, createOrderJson.quantity, createOrderJson.account_id, - createOrderJson.idempotency_key, Some(cc) ) } yield JSONFactory700.createMarketOrderJson(order) @@ -1164,14 +1163,14 @@ object Http4s700 { | |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"), - account_id = "buyer-fiat-account", - idempotency_key = "order-12345" + account_id = "buyer-fiat-account" ), JSONFactory700.MarketOrderJson( order_id = "550e8400-e29b-41d4-a716-446655440000", @@ -1482,7 +1481,6 @@ object Http4s700 { withdrawalJson.account_id, withdrawalJson.amount, withdrawalJson.address, - withdrawalJson.idempotency_key, Some(cc) ) } yield JSONFactory700.createWithdrawalJson(withdrawal) @@ -1498,14 +1496,14 @@ object Http4s700 { "Request Withdrawal", """Request a withdrawal to a blockchain address. | - |This operation is idempotent via the idempotency_key. + |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( account_id = "account-123", amount = BigDecimal("50.0"), - address = "0xdestination", - idempotency_key = "withdrawal-456" + address = "0xdestination" ), JSONFactory700.WithdrawalJson( withdrawal_id = "withdrawal-303", 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 afc00ddfda..c5ac25b70c 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 @@ -161,8 +161,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { side: String, // "BUY" | "SELL" price: BigDecimal, quantity: BigDecimal, - account_id: String, - idempotency_key: String + account_id: String ) case class CreateMarketMatchRequestJson( @@ -188,8 +187,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { case class RequestWithdrawalJson( account_id: String, amount: BigDecimal, - address: String, - idempotency_key: String + address: String ) // Market Response Models diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index d8e4ebf590..a3eded29f6 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2138,7 +2138,6 @@ trait Connector extends MdcLoggable { price: BigDecimal, quantity: BigDecimal, accountId: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { (Failure(setUnimplementedError(nameOf(createMarketOrder _))), callContext) @@ -2198,7 +2197,6 @@ trait Connector extends MdcLoggable { accountId: String, amount: BigDecimal, address: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[Box[Withdrawal]] = Future { (Failure(setUnimplementedError(nameOf(requestWithdrawal _))), callContext) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 8c2c8fca5c..cf9c1a4669 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -110,8 +110,6 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 orderIdempotencyKeys = new java.util.concurrent.ConcurrentHashMap[String, String]() - private val withdrawalIdempotencyKeys = new java.util.concurrent.ConcurrentHashMap[String, String]() //This is the implicit parameter for saveConnectorMetric function. //eg: override def getBank(bankId: BankId, callContext: Option[CallContext]) = saveConnectorMetric @@ -5989,38 +5987,27 @@ object LocalMappedConnector extends Connector with MdcLoggable { price: BigDecimal, quantity: BigDecimal, accountId: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { - // Check idempotency - val existingOrderId = Option(orderIdempotencyKeys.get(idempotencyKey)) - existingOrderId match { - case Some(orderId) => - // Return existing order - val existingOrder = Option(marketOrders.get(orderId)) - (Box(existingOrder), callContext) - case None => - // Generate order ID - val orderId = randomUUID().toString - - // Create order - val order = MarketOrder( - orderId = orderId, - side = side, - price = price, - quantity = quantity, - accountId = accountId, - status = "active", - createdAt = new Date(), - updatedAt = new Date() - ) + // 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 = accountId, + status = "active", + createdAt = new Date(), + updatedAt = new Date() + ) - // Store order and idempotency key - marketOrders.put(orderId, order) - orderIdempotencyKeys.put(idempotencyKey, orderId) + // Store order + marketOrders.put(orderId, order) - (Full(order), callContext) - } + (Full(order), callContext) } override def getMarketOrder( @@ -6154,37 +6141,26 @@ object LocalMappedConnector extends Connector with MdcLoggable { accountId: String, amount: BigDecimal, address: String, - idempotencyKey: String, callContext: Option[CallContext] ): OBPReturnType[Box[Withdrawal]] = Future { - // Check idempotency - val existingWithdrawalId = Option(withdrawalIdempotencyKeys.get(idempotencyKey)) - existingWithdrawalId match { - case Some(withdrawalId) => - // Return existing withdrawal - val existingWithdrawal = Option(withdrawals.get(withdrawalId)) - (Box(existingWithdrawal), callContext) - case None => - // Generate withdrawal ID - val withdrawalId = randomUUID().toString + // Generate withdrawal ID (auto-generated UUID following OBP design pattern) + val withdrawalId = randomUUID().toString - // Create withdrawal - val withdrawal = Withdrawal( - withdrawalId = withdrawalId, - accountId = accountId, - amount = amount, - address = address, - status = "pending", - txHash = None, - createdAt = new Date() - ) + // Create withdrawal + val withdrawal = Withdrawal( + withdrawalId = withdrawalId, + accountId = accountId, + amount = amount, + address = address, + status = "pending", + txHash = None, + createdAt = new Date() + ) - // Store withdrawal and idempotency key - withdrawals.put(withdrawalId, withdrawal) - withdrawalIdempotencyKeys.put(idempotencyKey, withdrawalId) + // Store withdrawal + withdrawals.put(withdrawalId, withdrawal) - (Full(withdrawal), callContext) - } + (Full(withdrawal), callContext) } } From 7322b9862fa3d91804c810bd874b2c8ce6de72c0 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 22:26:13 +0200 Subject: [PATCH 09/17] feature/implement update trading offer endpoint to complete P1 --- .../main/scala/code/api/util/NewStyle.scala | 12 +++ .../scala/code/api/v7_0_0/Http4s700.scala | 79 +++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 6 ++ .../scala/code/bankconnectors/Connector.scala | 10 +++ .../bankconnectors/LocalMappedConnector.scala | 28 +++++++ 5 files changed, 135 insertions(+) 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 41722bd44f..437b0f66b0 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4602,6 +4602,18 @@ object NewStyle extends MdcLoggable{ } } + 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, 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 b1b4b0e419..96ee7918b2 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 @@ -1076,6 +1076,85 @@ object Http4s700 { http4sPartialFunction = Some(getTradingOffers) ) + // Route: PUT /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID + val updateTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => + EndpointHelpers.withUserAndBody[JSONFactory700.UpdateOfferRequestJson, JSONFactory700.TradingOfferJson](req) { (user, updateJson, cc) => + for { + // Validate price_amount if provided + _ <- updateJson.price_amount match { + case Some(price) => Helper.booleanToFuture( + failMsg = InvalidTradingAmount, + failCode = 400, + cc = Some(cc) + )(price > 0) + case None => Future.successful(()) + } + + // Parse expiry_datetime if provided (simple approach - parse outside for-comprehension if needed) + expiryDateOpt = updateJson.expiry_datetime.map(dateStr => APIUtil.parseDate(dateStr).getOrElse(new java.util.Date())) + + // Invoke connector + (offer, callContext) <- NewStyle.function.updateTradingOffer( + offerId, + updateJson.price_amount, + expiryDateOpt, + updateJson.minimum_fill, + Some(cc) + ) + } yield JSONFactory700.createTradingOfferJson(offer) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateTradingOffer), + "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", + "Update Trading Offer", + """Update an existing trading offer. + | + |Only certain fields can be updated: + |- price_amount: New price per unit + |- expiry_datetime: New expiration date (ISO 8601 format) + |- minimum_fill: New minimum fill amount + | + |All fields are optional - only provided fields will be updated. + | + |Authentication is required.""", + JSONFactory700.UpdateOfferRequestJson( + price_amount = Some(BigDecimal("1.60")), + expiry_datetime = Some("2026-12-31T23:59:59Z"), + minimum_fill = Some(BigDecimal("10.00")) + ), + 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.60"), + settlement_account_id = "settlement-account-123", + expiry_datetime = Some("2026-12-31T23:59:59Z"), + minimum_fill = Some(BigDecimal("10.00")) + ), + account_info = JSONFactory700.AccountInfoJson( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", + view_id = "owner" + ), + executions = List.empty, + created_at = "2026-04-15T10:30:00Z", + updated_at = "2026-04-15T10:35:00Z" + ), + List(InvalidJsonFormat, InvalidTradingAmount, InvalidDateFormat, OfferNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagTrading :: Nil, + http4sPartialFunction = Some(updateTradingOffer) + ) + // 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 => 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 c5ac25b70c..0ebc6d8734 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 @@ -70,6 +70,12 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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, diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index a3eded29f6..e9bfbd967f 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2122,6 +2122,16 @@ trait Connector extends MdcLoggable { (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, diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index cf9c1a4669..e2b08f8173 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5962,6 +5962,34 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } + 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, From ab6a2531b8c61f8d4d0a6145f12408fc29194abf Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 22:53:07 +0200 Subject: [PATCH 10/17] refactor/align Market Order endpoints with OBP convention using full bank account view paths --- .../main/scala/code/api/util/NewStyle.scala | 36 ++-- .../scala/code/api/v7_0_0/Http4s700.scala | 166 ++++++++++++------ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 4 +- .../scala/code/bankconnectors/Connector.scala | 20 ++- .../bankconnectors/LocalMappedConnector.scala | 24 ++- 5 files changed, 175 insertions(+), 75 deletions(-) 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 437b0f66b0..46224c394f 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4628,38 +4628,46 @@ object NewStyle extends MdcLoggable{ // Market Methods def createMarketOrder( + bankId: BankId, + accountId: AccountId, side: String, price: BigDecimal, quantity: BigDecimal, - accountId: String, + settlementAccountId: String, callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = { Connector.connector.vend.createMarketOrder( - side, price, quantity, accountId, callContext + 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(orderId, callContext) map { + 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(orderId, callContext) map { + 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, @@ -4667,32 +4675,38 @@ object NewStyle extends MdcLoggable{ callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.MarketMatch] = { Connector.connector.vend.createMarketMatch( - orderId, counterOrderId, amount, price, callContext + 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(tradeId, callContext) map { + 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(tradeId, step, callContext) map { + 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, @@ -4701,20 +4715,22 @@ object NewStyle extends MdcLoggable{ callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.Deposit] = { Connector.connector.vend.notifyDeposit( - txHash, from, to, amount, confirmations, callContext + bankId, accountId, txHash, from, to, amount, confirmations, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$InvalidTradingAmount"), i._2) } } def requestWithdrawal( - accountId: String, + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, amount: BigDecimal, address: String, callContext: Option[CallContext] ): OBPReturnType[com.openbankproject.commons.model.Withdrawal] = { Connector.connector.vend.requestWithdrawal( - accountId, amount, address, callContext + bankId, accountId, settlementAccountId, amount, address, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$WithdrawalFailed"), 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 96ee7918b2..a81e1635bf 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 @@ -1193,39 +1193,44 @@ object Http4s700 { // ── Market Endpoints (Phase 2) ───────────────────────────────────────── - // Route: POST /obp/v7.0.0/market/orders + // 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` / "market" / "orders" => + 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 = Some(cc) + cc = callContext )(createOrderJson.side == "BUY" || createOrderJson.side == "SELL") // Validate price _ <- Helper.booleanToFuture( failMsg = InvalidTradingAmount, failCode = 400, - cc = Some(cc) + cc = callContext )(createOrderJson.price > 0) // Validate quantity _ <- Helper.booleanToFuture( failMsg = InvalidTradingAmount, failCode = 400, - cc = Some(cc) + cc = callContext )(createOrderJson.quantity > 0) // Invoke connector - (order, callContext) <- NewStyle.function.createMarketOrder( + (order, callContext2) <- NewStyle.function.createMarketOrder( + BankId(bankId), + AccountId(accountId), createOrderJson.side, createOrderJson.price, createOrderJson.quantity, - createOrderJson.account_id, - Some(cc) + createOrderJson.settlement_account_id, + callContext ) } yield JSONFactory700.createMarketOrderJson(order) } @@ -1236,7 +1241,7 @@ object Http4s700 { implementedInApiVersion, nameOf(createMarketOrder), "POST", - "/market/orders", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders", "Create Market Order", """Create a new market order to buy or sell assets. | @@ -1249,7 +1254,7 @@ object Http4s700 { side = "BUY", price = BigDecimal("25.0"), quantity = BigDecimal("10.0"), - account_id = "buyer-fiat-account" + settlement_account_id = "buyer-fiat-account" ), JSONFactory700.MarketOrderJson( order_id = "550e8400-e29b-41d4-a716-446655440000", @@ -1261,17 +1266,26 @@ object Http4s700 { created_at = "2026-04-16T00:30:00Z", updated_at = "2026-04-16T00:30:00Z" ), - List(InvalidJsonFormat, InvalidOrderSide, InvalidTradingAmount, $AuthenticatedUserIsRequired, UnknownError), + List(InvalidJsonFormat, InvalidOrderSide, InvalidTradingAmount, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(createMarketOrder) ) - // Route: GET /obp/v7.0.0/market/orders/ORDER_ID + // 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` / "market" / "orders" / orderId => + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "orders" / orderId => EndpointHelpers.withUser(req) { (user, cc) => for { - (order, callContext) <- NewStyle.function.getMarketOrder(orderId, Some(cc)) + // 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) } } @@ -1281,7 +1295,7 @@ object Http4s700 { implementedInApiVersion, nameOf(getMarketOrder), "GET", - "/market/orders/ORDER_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", "Get Market Order", """Get details of a specific market order. | @@ -1297,17 +1311,26 @@ object Http4s700 { created_at = "2026-04-16T00:30:00Z", updated_at = "2026-04-16T00:30:00Z" ), - List(OrderNotFound, $AuthenticatedUserIsRequired, UnknownError), + List(OrderNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(getMarketOrder) ) - // Route: DELETE /obp/v7.0.0/market/orders/ORDER_ID + // 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` / "market" / "orders" / orderId => + case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "orders" / orderId => EndpointHelpers.withUser(req) { (user, cc) => for { - (order, callContext) <- NewStyle.function.cancelMarketOrder(orderId, Some(cc)) + // 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) } } @@ -1317,7 +1340,7 @@ object Http4s700 { implementedInApiVersion, nameOf(cancelMarketOrder), "DELETE", - "/market/orders/ORDER_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", "Cancel Market Order", """Cancel an active market order. | @@ -1335,37 +1358,42 @@ object Http4s700 { created_at = "2026-04-16T00:30:00Z", updated_at = "2026-04-16T00:35:00Z" ), - List(OrderNotFound, $AuthenticatedUserIsRequired, UnknownError), + List(OrderNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(cancelMarketOrder) ) - // Route: POST /obp/v7.0.0/market/matches + // 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` / "market" / "matches" => + 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 = Some(cc) + cc = callContext )(createMatchJson.amount > 0) // Validate price _ <- Helper.booleanToFuture( failMsg = InvalidMatchParameters, failCode = 400, - cc = Some(cc) + cc = callContext )(createMatchJson.price > 0) // Invoke connector - (matchResult, callContext) <- NewStyle.function.createMarketMatch( + (matchResult, callContext2) <- NewStyle.function.createMarketMatch( + BankId(bankId), + AccountId(accountId), createMatchJson.order_id, createMatchJson.counter_order_id, createMatchJson.amount, createMatchJson.price, - Some(cc) + callContext ) } yield JSONFactory700.createMarketMatchJson(matchResult) } @@ -1376,7 +1404,7 @@ object Http4s700 { implementedInApiVersion, nameOf(createMarketMatch), "POST", - "/market/matches", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/matches", "Create Market Match", """Create a match between two market orders. | @@ -1397,17 +1425,26 @@ object Http4s700 { price = BigDecimal("25.0"), created_at = "2026-04-16T00:40:00Z" ), - List(InvalidJsonFormat, InvalidMatchParameters, $AuthenticatedUserIsRequired, UnknownError), + List(InvalidJsonFormat, InvalidMatchParameters, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(createMarketMatch) ) - // Route: GET /obp/v7.0.0/market/trades/TRADE_ID + // 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` / "market" / "trades" / tradeId => + case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "market" / "trades" / tradeId => EndpointHelpers.withUser(req) { (user, cc) => for { - (trade, callContext) <- NewStyle.function.getMarketTrade(tradeId, Some(cc)) + // 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) } } @@ -1417,7 +1454,7 @@ object Http4s700 { implementedInApiVersion, nameOf(getMarketTrade), "GET", - "/market/trades/TRADE_ID", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/trades/TRADE_ID", "Get Market Trade", """Get details of a specific market trade. | @@ -1432,21 +1469,26 @@ object Http4s700 { status = "pending", created_at = "2026-04-16T00:40:00Z" ), - List(TradeNotFound, $AuthenticatedUserIsRequired, UnknownError), + List(TradeNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(getMarketTrade) ) - // Route: POST /obp/v7.0.0/market/settlements + // 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` / "market" / "settlements" => + 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, callContext) <- NewStyle.function.requestSettlement( + (settlement, callContext2) <- NewStyle.function.requestSettlement( + BankId(bankId), + AccountId(accountId), requestJson.trade_id, requestJson.step, - Some(cc) + callContext ) } yield JSONFactory700.createSettlementJson(settlement) } @@ -1457,7 +1499,7 @@ object Http4s700 { implementedInApiVersion, nameOf(requestSettlement), "POST", - "/market/settlements", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/settlements", "Request Settlement", """Request settlement for a completed trade. | @@ -1474,38 +1516,43 @@ object Http4s700 { created_at = "2026-04-16T00:45:00Z", completed_at = None ), - List(InvalidJsonFormat, SettlementFailed, $AuthenticatedUserIsRequired, UnknownError), + List(InvalidJsonFormat, SettlementFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(requestSettlement) ) - // Route: POST /obp/v7.0.0/market/deposits + // 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` / "market" / "deposits" => + 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 = Some(cc) + cc = callContext )(depositJson.amount > 0) // Validate confirmations _ <- Helper.booleanToFuture( failMsg = InvalidMatchParameters, failCode = 400, - cc = Some(cc) + cc = callContext )(depositJson.confirmations >= 0) // Invoke connector - (deposit, callContext) <- NewStyle.function.notifyDeposit( + (deposit, callContext2) <- NewStyle.function.notifyDeposit( + BankId(bankId), + AccountId(accountId), depositJson.tx_hash, depositJson.from, depositJson.to, depositJson.amount, depositJson.confirmations, - Some(cc) + callContext ) } yield JSONFactory700.createDepositJson(deposit) } @@ -1516,7 +1563,7 @@ object Http4s700 { implementedInApiVersion, nameOf(notifyDeposit), "POST", - "/market/deposits", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/deposits", "Notify Deposit", """Record a blockchain deposit notification. | @@ -1538,29 +1585,34 @@ object Http4s700 { status = "confirmed", created_at = "2026-04-16T00:50:00Z" ), - List(InvalidJsonFormat, InvalidTradingAmount, InvalidMatchParameters, $AuthenticatedUserIsRequired, UnknownError), + List(InvalidJsonFormat, InvalidTradingAmount, InvalidMatchParameters, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(notifyDeposit) ) - // Route: POST /obp/v7.0.0/market/withdrawals + // 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` / "market" / "withdrawals" => + 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 = Some(cc) + cc = callContext )(withdrawalJson.amount > 0) // Invoke connector - (withdrawal, callContext) <- NewStyle.function.requestWithdrawal( - withdrawalJson.account_id, + (withdrawal, callContext2) <- NewStyle.function.requestWithdrawal( + BankId(bankId), + AccountId(accountId), + withdrawalJson.settlement_account_id, withdrawalJson.amount, withdrawalJson.address, - Some(cc) + callContext ) } yield JSONFactory700.createWithdrawalJson(withdrawal) } @@ -1571,7 +1623,7 @@ object Http4s700 { implementedInApiVersion, nameOf(requestWithdrawal), "POST", - "/market/withdrawals", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/withdrawals", "Request Withdrawal", """Request a withdrawal to a blockchain address. | @@ -1580,7 +1632,7 @@ object Http4s700 { | |Authentication is required.""", JSONFactory700.RequestWithdrawalJson( - account_id = "account-123", + settlement_account_id = "account-123", amount = BigDecimal("50.0"), address = "0xdestination" ), @@ -1593,7 +1645,7 @@ object Http4s700 { tx_hash = None, created_at = "2026-04-16T00:55:00Z" ), - List(InvalidJsonFormat, InvalidTradingAmount, WithdrawalFailed, $AuthenticatedUserIsRequired, UnknownError), + List(InvalidJsonFormat, InvalidTradingAmount, WithdrawalFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), apiTagMarket :: Nil, http4sPartialFunction = Some(requestWithdrawal) ) 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 0ebc6d8734..f54e5b50e1 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 @@ -167,7 +167,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { side: String, // "BUY" | "SELL" price: BigDecimal, quantity: BigDecimal, - account_id: String + settlement_account_id: String ) case class CreateMarketMatchRequestJson( @@ -191,7 +191,7 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) case class RequestWithdrawalJson( - account_id: String, + settlement_account_id: String, amount: BigDecimal, address: String ) diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index e9bfbd967f..e5163084c8 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2144,16 +2144,20 @@ trait Connector extends MdcLoggable { // Market Trading Methods def createMarketOrder( + bankId: BankId, + accountId: AccountId, side: String, price: BigDecimal, quantity: BigDecimal, - accountId: String, + 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 { @@ -2161,6 +2165,8 @@ trait Connector extends MdcLoggable { } def cancelMarketOrder( + bankId: BankId, + accountId: AccountId, orderId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { @@ -2168,6 +2174,8 @@ trait Connector extends MdcLoggable { } def createMarketMatch( + bankId: BankId, + accountId: AccountId, orderId: String, counterOrderId: String, amount: BigDecimal, @@ -2178,6 +2186,8 @@ trait Connector extends MdcLoggable { } def getMarketTrade( + bankId: BankId, + accountId: AccountId, tradeId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketTrade]] = Future { @@ -2185,6 +2195,8 @@ trait Connector extends MdcLoggable { } def requestSettlement( + bankId: BankId, + accountId: AccountId, tradeId: String, step: Option[String], callContext: Option[CallContext] @@ -2193,6 +2205,8 @@ trait Connector extends MdcLoggable { } def notifyDeposit( + bankId: BankId, + accountId: AccountId, txHash: String, from: String, to: String, @@ -2204,7 +2218,9 @@ trait Connector extends MdcLoggable { } def requestWithdrawal( - accountId: String, + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, amount: BigDecimal, address: String, callContext: Option[CallContext] diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index e2b08f8173..049dc6d9b2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -6011,10 +6011,12 @@ object LocalMappedConnector extends Connector with MdcLoggable { // Market Trading Methods Implementation override def createMarketOrder( + bankId: BankId, + accountId: AccountId, side: String, price: BigDecimal, quantity: BigDecimal, - accountId: String, + settlementAccountId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { // Generate order ID (auto-generated UUID following OBP design pattern) @@ -6026,7 +6028,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { side = side, price = price, quantity = quantity, - accountId = accountId, + accountId = settlementAccountId, status = "active", createdAt = new Date(), updatedAt = new Date() @@ -6039,6 +6041,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def getMarketOrder( + bankId: BankId, + accountId: AccountId, orderId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { @@ -6047,6 +6051,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def cancelMarketOrder( + bankId: BankId, + accountId: AccountId, orderId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketOrder]] = Future { @@ -6066,6 +6072,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def createMarketMatch( + bankId: BankId, + accountId: AccountId, orderId: String, counterOrderId: String, amount: BigDecimal, @@ -6105,6 +6113,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def getMarketTrade( + bankId: BankId, + accountId: AccountId, tradeId: String, callContext: Option[CallContext] ): OBPReturnType[Box[MarketTrade]] = Future { @@ -6113,6 +6123,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def requestSettlement( + bankId: BankId, + accountId: AccountId, tradeId: String, step: Option[String], callContext: Option[CallContext] @@ -6137,6 +6149,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def notifyDeposit( + bankId: BankId, + accountId: AccountId, txHash: String, from: String, to: String, @@ -6166,7 +6180,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { } override def requestWithdrawal( - accountId: String, + bankId: BankId, + accountId: AccountId, + settlementAccountId: String, amount: BigDecimal, address: String, callContext: Option[CallContext] @@ -6177,7 +6193,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { // Create withdrawal val withdrawal = Withdrawal( withdrawalId = withdrawalId, - accountId = accountId, + accountId = settlementAccountId, amount = amount, address = address, status = "pending", From a3fa43b8d6eceb65d4ba55f40f118e3b2e31c9d0 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 23:43:08 +0200 Subject: [PATCH 11/17] feature/add userId and consentId audit fields to all trading and market domain models for compliance tracking --- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 28 ++++++++++++++ .../bankconnectors/LocalMappedConnector.scala | 38 +++++++++++++++++++ .../commons/model/CommonModel.scala | 14 +++++++ 3 files changed, 80 insertions(+) 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 f54e5b50e1..c7ddf29e42 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 @@ -83,6 +83,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) @@ -148,6 +150,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) @@ -204,6 +208,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) @@ -214,6 +220,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { counter_order_id: String, amount: BigDecimal, price: BigDecimal, + user_id: String, // Audit field + consent_id: Option[String], // Audit field created_at: String // ISO 8601 ) @@ -224,6 +232,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { amount: BigDecimal, price: BigDecimal, status: String, + user_id: String, // Audit field + consent_id: Option[String], // Audit field created_at: String // ISO 8601 ) @@ -232,6 +242,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) @@ -244,6 +256,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { amount: BigDecimal, confirmations: Int, status: String, + user_id: String, // Audit field + consent_id: Option[String], // Audit field created_at: String // ISO 8601 ) @@ -254,6 +268,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { address: String, status: String, tx_hash: Option[String], + user_id: String, // Audit field + consent_id: Option[String], // Audit field created_at: String // ISO 8601 ) @@ -266,6 +282,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) @@ -278,6 +296,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 ) } @@ -290,6 +310,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { amount = trade.amount, price = trade.price, status = trade.status, + user_id = trade.userId, + consent_id = trade.consentId, created_at = trade.createdAt.toInstant.toString ) } @@ -300,6 +322,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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) ) @@ -314,6 +338,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { amount = deposit.amount, confirmations = deposit.confirmations, status = deposit.status, + user_id = deposit.userId, + consent_id = deposit.consentId, created_at = deposit.createdAt.toInstant.toString ) } @@ -326,6 +352,8 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { address = withdrawal.address, status = withdrawal.status, tx_hash = withdrawal.txHash, + user_id = withdrawal.userId, + consent_id = withdrawal.consentId, created_at = withdrawal.createdAt.toInstant.toString ) } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 049dc6d9b2..660c33ae81 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5902,6 +5902,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate offer ID (auto-generated UUID following OBP design pattern) val offerId = randomUUID().toString @@ -5925,6 +5929,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { viewId = "owner" // Default view ), executions = List.empty, + userId = userId, + consentId = consentId, createdAt = new Date(), updatedAt = new Date() ) @@ -6019,6 +6025,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate order ID (auto-generated UUID following OBP design pattern) val orderId = randomUUID().toString @@ -6030,6 +6040,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { quantity = quantity, accountId = settlementAccountId, status = "active", + userId = userId, + consentId = consentId, createdAt = new Date(), updatedAt = new Date() ) @@ -6080,6 +6092,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate match ID val matchId = randomUUID().toString @@ -6090,6 +6106,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { counterOrderId = counterOrderId, amount = amount, price = price, + userId = userId, + consentId = consentId, createdAt = new Date() ) @@ -6105,6 +6123,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { amount = amount, price = price, status = "pending", + userId = userId, + consentId = consentId, createdAt = new Date() ) marketTrades.put(tradeId, trade) @@ -6129,6 +6149,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate settlement ID val settlementId = randomUUID().toString @@ -6138,6 +6162,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { tradeId = tradeId, step = step, status = "pending", + userId = userId, + consentId = consentId, createdAt = new Date(), completedAt = None ) @@ -6158,6 +6184,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { confirmations: Int, callContext: Option[CallContext] ): OBPReturnType[Box[Deposit]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate deposit ID val depositId = randomUUID().toString @@ -6170,6 +6200,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { amount = amount, confirmations = confirmations, status = "confirmed", + userId = userId, + consentId = consentId, createdAt = new Date() ) @@ -6187,6 +6219,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { address: String, callContext: Option[CallContext] ): OBPReturnType[Box[Withdrawal]] = Future { + // Extract audit fields from CallContext + val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") + val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + // Generate withdrawal ID (auto-generated UUID following OBP design pattern) val withdrawalId = randomUUID().toString @@ -6198,6 +6234,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { address = address, status = "pending", txHash = None, + userId = userId, + consentId = consentId, createdAt = new Date() ) 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 478a946d98..3d95bda919 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 @@ -1384,6 +1384,8 @@ case class TradingOffer( 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 ) @@ -1420,6 +1422,8 @@ case class MarketOrder( 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 ) @@ -1430,6 +1434,8 @@ case class MarketMatch( counterOrderId: String, amount: BigDecimal, price: BigDecimal, + userId: String, // Audit: User who created the match + consentId: Option[String], // Audit: Consent ID if applicable createdAt: Date ) @@ -1440,6 +1446,8 @@ case class MarketTrade( 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 ) @@ -1448,6 +1456,8 @@ case class Settlement( 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] ) @@ -1460,6 +1470,8 @@ case class Deposit( amount: BigDecimal, confirmations: Int, status: String, // "confirmed" | "pending" + userId: String, // Audit: User who notified the deposit + consentId: Option[String], // Audit: Consent ID if applicable createdAt: Date ) @@ -1470,5 +1482,7 @@ case class Withdrawal( address: String, status: String, // "pending" | "completed" | "failed" txHash: Option[String], + userId: String, // Audit: User who requested withdrawal + consentId: Option[String], // Audit: Consent ID if applicable createdAt: Date ) From f1f3383f569aca45a11eb72d0015d54823f34cba Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 16 Apr 2026 23:55:57 +0200 Subject: [PATCH 12/17] fix/add audit fields to ResourceDoc examples in all 13 trading endpoints --- .../scala/code/api/v7_0_0/Http4s700.scala | 24 +++++++++++++++++++ .../bankconnectors/LocalMappedConnector.scala | 12 +++++----- 2 files changed, 30 insertions(+), 6 deletions(-) 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 a81e1635bf..01418fd08f 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 @@ -946,6 +946,8 @@ object Http4s700 { 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" ), @@ -995,6 +997,8 @@ object Http4s700 { 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" ), @@ -1066,6 +1070,8 @@ object Http4s700 { 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" ) @@ -1147,6 +1153,8 @@ object Http4s700 { 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:35:00Z" ), @@ -1263,6 +1271,8 @@ object Http4s700 { 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" ), @@ -1308,6 +1318,8 @@ object Http4s700 { 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" ), @@ -1355,6 +1367,8 @@ object Http4s700 { 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" ), @@ -1423,6 +1437,8 @@ object Http4s700 { 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), @@ -1467,6 +1483,8 @@ object Http4s700 { 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), @@ -1513,6 +1531,8 @@ object Http4s700 { 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 ), @@ -1583,6 +1603,8 @@ object Http4s700 { amount = BigDecimal("100.0"), confirmations = 6, status = "confirmed", + user_id = "user-abc-123", + consent_id = None, created_at = "2026-04-16T00:50:00Z" ), List(InvalidJsonFormat, InvalidTradingAmount, InvalidMatchParameters, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), @@ -1643,6 +1665,8 @@ object Http4s700 { address = "0xdestination", status = "pending", tx_hash = None, + user_id = "user-abc-123", + consent_id = None, created_at = "2026-04-16T00:55:00Z" ), List(InvalidJsonFormat, InvalidTradingAmount, WithdrawalFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 660c33ae81..2b3d661c07 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5904,7 +5904,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[TradingOffer]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + 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 @@ -6027,7 +6027,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[MarketOrder]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + 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 @@ -6094,7 +6094,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[MarketMatch]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + val consentId: Option[String] = None // TODO: Extract from consent when available // Generate match ID val matchId = randomUUID().toString @@ -6151,7 +6151,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[Settlement]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + val consentId: Option[String] = None // TODO: Extract from consent when available // Generate settlement ID val settlementId = randomUUID().toString @@ -6186,7 +6186,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[Deposit]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + val consentId: Option[String] = None // TODO: Extract from consent when available // Generate deposit ID val depositId = randomUUID().toString @@ -6221,7 +6221,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { ): OBPReturnType[Box[Withdrawal]] = Future { // Extract audit fields from CallContext val userId = callContext.flatMap(_.user.map(_.userId)).getOrElse("SYSTEM") - val consentId = callContext.flatMap(_.implementedByPartialFunction.flatMap(_.consent.map(_.consentId))) + 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 From dda8e2c3b52c22337674936e6cd2d66b5f392846 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 17 Apr 2026 00:09:07 +0200 Subject: [PATCH 13/17] feature/add blockchain tracking fields to Deposit and Withdrawal models for P4 --- .../main/scala/code/api/util/NewStyle.scala | 6 ++-- .../scala/code/api/v7_0_0/Http4s700.scala | 13 +++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 30 +++++++++++++++---- .../scala/code/bankconnectors/Connector.scala | 2 ++ .../bankconnectors/LocalMappedConnector.scala | 18 +++++++++-- .../commons/model/CommonModel.scala | 21 +++++++++---- 6 files changed, 73 insertions(+), 17 deletions(-) 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 46224c394f..ab2c128064 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4712,10 +4712,11 @@ object NewStyle extends MdcLoggable{ 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, callContext + bankId, accountId, txHash, from, to, amount, confirmations, requiredConfirmations, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$InvalidTradingAmount"), i._2) } @@ -4727,10 +4728,11 @@ object NewStyle extends MdcLoggable{ 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, callContext + bankId, accountId, settlementAccountId, amount, address, requiredConfirmations, callContext ) map { i => (unboxFullOrFail(i._1, callContext, s"$WithdrawalFailed"), 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 01418fd08f..554cf284db 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 @@ -1572,6 +1572,7 @@ object Http4s700 { depositJson.to, depositJson.amount, depositJson.confirmations, + 12, // Ethereum mainnet standard: 12 confirmations required callContext ) } yield JSONFactory700.createDepositJson(deposit) @@ -1602,7 +1603,11 @@ object Http4s700 { to = "0xreceiver", amount = BigDecimal("100.0"), confirmations = 6, - status = "confirmed", + 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" @@ -1634,6 +1639,7 @@ object Http4s700 { withdrawalJson.settlement_account_id, withdrawalJson.amount, withdrawalJson.address, + 12, // Ethereum mainnet standard: 12 confirmations required callContext ) } yield JSONFactory700.createWithdrawalJson(withdrawal) @@ -1665,6 +1671,11 @@ object Http4s700 { 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" 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 c7ddf29e42..6668b04c81 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 @@ -255,10 +255,14 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { to: String, amount: BigDecimal, confirmations: Int, + required_confirmations: Int, // Number of confirmations required status: String, - user_id: String, // Audit field - consent_id: Option[String], // Audit field - created_at: String // ISO 8601 + 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( @@ -268,9 +272,14 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { address: String, status: String, tx_hash: Option[String], - user_id: String, // Audit field - consent_id: Option[String], // Audit field - created_at: String // ISO 8601 + 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 ) // Market Conversion Functions @@ -337,7 +346,11 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 @@ -352,6 +365,11 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index e5163084c8..b3c499a098 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2212,6 +2212,7 @@ trait Connector extends MdcLoggable { to: String, amount: BigDecimal, confirmations: Int, + requiredConfirmations: Int, // Number of confirmations required callContext: Option[CallContext] ): OBPReturnType[Box[Deposit]] = Future { (Failure(setUnimplementedError(nameOf(notifyDeposit _))), callContext) @@ -2223,6 +2224,7 @@ trait Connector extends MdcLoggable { settlementAccountId: String, amount: BigDecimal, address: String, + requiredConfirmations: Int, // Number of confirmations required callContext: Option[CallContext] ): OBPReturnType[Box[Withdrawal]] = Future { (Failure(setUnimplementedError(nameOf(requestWithdrawal _))), callContext) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 2b3d661c07..4f16ed006c 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -6182,6 +6182,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { to: String, amount: BigDecimal, confirmations: Int, + requiredConfirmations: Int, callContext: Option[CallContext] ): OBPReturnType[Box[Deposit]] = Future { // Extract audit fields from CallContext @@ -6191,6 +6192,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { // 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, @@ -6199,7 +6203,11 @@ object LocalMappedConnector extends Connector with MdcLoggable { to = to, amount = amount, confirmations = confirmations, - status = "confirmed", + 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() @@ -6217,6 +6225,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { settlementAccountId: String, amount: BigDecimal, address: String, + requiredConfirmations: Int, callContext: Option[CallContext] ): OBPReturnType[Box[Withdrawal]] = Future { // Extract audit fields from CallContext @@ -6233,7 +6242,12 @@ object LocalMappedConnector extends Connector with MdcLoggable { amount = amount, address = address, status = "pending", - txHash = None, + 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() 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 3d95bda919..67c7949b3d 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 @@ -1469,9 +1469,13 @@ case class Deposit( to: String, amount: BigDecimal, confirmations: Int, - status: String, // "confirmed" | "pending" - userId: String, // Audit: User who notified the deposit - consentId: Option[String], // Audit: Consent ID if applicable + 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 ) @@ -1480,9 +1484,14 @@ case class Withdrawal( accountId: String, amount: BigDecimal, address: String, - status: String, // "pending" | "completed" | "failed" + status: String, // "pending" | "completed" | "failed" txHash: Option[String], - userId: String, // Audit: User who requested withdrawal - consentId: Option[String], // Audit: Consent ID if applicable + 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 ) From 947f630f28aabaf9cbeca0de844044f47f1e11a3 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 17 Apr 2026 00:22:10 +0200 Subject: [PATCH 14/17] feature/implement TCC payment authorization with PaymentAuth model and 4 endpoints for P3 --- .../scala/code/api/util/ErrorMessages.scala | 7 + .../main/scala/code/api/util/NewStyle.scala | 57 +++++ .../scala/code/api/v7_0_0/Http4s700.scala | 239 ++++++++++++++++++ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 43 ++++ .../scala/code/bankconnectors/Connector.scala | 41 +++ .../bankconnectors/LocalMappedConnector.scala | 129 ++++++++++ .../commons/model/CommonModel.scala | 17 ++ 7 files changed, 533 insertions(+) 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 e4ec05e2ae..d1017f9fc7 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -860,6 +860,13 @@ object ErrorMessages { 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 ab2c128064..c5fe37fc9d 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4737,6 +4737,63 @@ object NewStyle extends MdcLoggable{ 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 554cf284db..9e63083d98 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 @@ -1685,6 +1685,245 @@ object Http4s700 { 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)", + """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)", + """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)", + """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", + """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) ───────────────────────────────────── // All routes combined (without middleware - for direct use). 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 6668b04c81..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 @@ -282,6 +282,31 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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( @@ -375,4 +400,22 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { 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 b3c499a098..f97b871f80 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -2229,4 +2229,45 @@ trait Connector extends MdcLoggable { ): 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 4f16ed006c..e0b5b216e2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -110,6 +110,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 @@ -6259,4 +6260,132 @@ object LocalMappedConnector extends Connector with MdcLoggable { (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 67c7949b3d..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 @@ -1479,6 +1479,23 @@ case class Deposit( 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, From ff92d4b25b55e95a57731f77156a11f1209bc861 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 24 Apr 2026 12:03:48 +0200 Subject: [PATCH 15/17] docfix/add work in progress warning to trading endpoints --- .../scala/code/api/v7_0_0/Http4s700.scala | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) 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 a422aad676..54e89750f2 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 @@ -911,7 +911,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers", "Create Trading Offer", - """Create a new trading offer to buy or sell digital assets. + """**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. @@ -972,7 +974,9 @@ object Http4s700 { "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", "Get Trading Offer", - """Get details of a specific trading offer including execution history. + """**WORK IN PROGRESS** + | + |Get details of a specific trading offer including execution history. | |Authentication is required.""", EmptyBody, @@ -1037,7 +1041,9 @@ object Http4s700 { "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers", "Get Trading Offers", - """Get a list of trading offers for a specific account. + """**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") @@ -1117,7 +1123,9 @@ object Http4s700 { "PUT", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", "Update Trading Offer", - """Update an existing trading offer. + """**WORK IN PROGRESS** + | + |Update an existing trading offer. | |Only certain fields can be updated: |- price_amount: New price per unit @@ -1179,7 +1187,9 @@ object Http4s700 { "DELETE", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", "Cancel Trading Offer", - """Cancel an active trading offer. + """**WORK IN PROGRESS** + | + |Cancel an active trading offer. | |This operation is idempotent - canceling an already-cancelled offer returns success. | @@ -1249,7 +1259,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders", "Create Market Order", - """Create a new market order to buy or sell assets. + """**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. @@ -1305,7 +1317,9 @@ object Http4s700 { "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", "Get Market Order", - """Get details of a specific market order. + """**WORK IN PROGRESS** + | + |Get details of a specific market order. | |Authentication is required.""", EmptyBody, @@ -1352,7 +1366,9 @@ object Http4s700 { "DELETE", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/orders/ORDER_ID", "Cancel Market Order", - """Cancel an active market order. + """**WORK IN PROGRESS** + | + |Cancel an active market order. | |This operation is idempotent - canceling an already-cancelled order returns success. | @@ -1418,7 +1434,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/matches", "Create Market Match", - """Create a match between two market orders. + """**WORK IN PROGRESS** + | + |Create a match between two market orders. | |This creates a MarketMatch and automatically generates a corresponding MarketTrade. | @@ -1470,7 +1488,9 @@ object Http4s700 { "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/trades/TRADE_ID", "Get Market Trade", - """Get details of a specific market trade. + """**WORK IN PROGRESS** + | + |Get details of a specific market trade. | |Authentication is required.""", EmptyBody, @@ -1517,7 +1537,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/settlements", "Request Settlement", - """Request settlement for a completed trade. + """**WORK IN PROGRESS** + | + |Request settlement for a completed trade. | |Authentication is required.""", JSONFactory700.RequestSettlementJson( @@ -1584,7 +1606,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/deposits", "Notify Deposit", - """Record a blockchain deposit notification. + """**WORK IN PROGRESS** + | + |Record a blockchain deposit notification. | |Authentication is required.""", JSONFactory700.NotifyDepositJson( @@ -1651,7 +1675,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/withdrawals", "Request Withdrawal", - """Request a withdrawal to a blockchain address. + """**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. @@ -1722,7 +1748,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths", "Create Payment Authorization (TCC Preauth)", - """Create a payment authorization for a trade settlement using the Try-Confirm-Cancel (TCC) pattern. + """**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. @@ -1786,7 +1814,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/capture", "Capture Payment Authorization (TCC Confirm)", - """Capture a payment authorization to complete the trade settlement. + """**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. @@ -1841,7 +1871,9 @@ object Http4s700 { "POST", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID/release", "Release Payment Authorization (TCC Cancel)", - """Release a payment authorization to cancel the trade settlement. + """**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). @@ -1896,7 +1928,9 @@ object Http4s700 { "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/market/payment-auths/AUTH_ID", "Get Payment Authorization", - """Get details of a payment authorization. + """**WORK IN PROGRESS** + | + |Get details of a payment authorization. | |Returns the current state and details of the authorization. | From 6fe146f075878911f014a4ec18104d27c4efcedc Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 24 Apr 2026 12:34:53 +0200 Subject: [PATCH 16/17] refactor/commented the TCC endpoints --- .../scala/code/api/v7_0_0/Http4s700.scala | 577 ++++++++---------- 1 file changed, 247 insertions(+), 330 deletions(-) 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 54e89750f2..74a011a408 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 @@ -1085,90 +1085,7 @@ object Http4s700 { apiTagTrading :: Nil, http4sPartialFunction = Some(getTradingOffers) ) - - // Route: PUT /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID - val updateTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ PUT -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => - EndpointHelpers.withUserAndBody[JSONFactory700.UpdateOfferRequestJson, JSONFactory700.TradingOfferJson](req) { (user, updateJson, cc) => - for { - // Validate price_amount if provided - _ <- updateJson.price_amount match { - case Some(price) => Helper.booleanToFuture( - failMsg = InvalidTradingAmount, - failCode = 400, - cc = Some(cc) - )(price > 0) - case None => Future.successful(()) - } - - // Parse expiry_datetime if provided (simple approach - parse outside for-comprehension if needed) - expiryDateOpt = updateJson.expiry_datetime.map(dateStr => APIUtil.parseDate(dateStr).getOrElse(new java.util.Date())) - - // Invoke connector - (offer, callContext) <- NewStyle.function.updateTradingOffer( - offerId, - updateJson.price_amount, - expiryDateOpt, - updateJson.minimum_fill, - Some(cc) - ) - } yield JSONFactory700.createTradingOfferJson(offer) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(updateTradingOffer), - "PUT", - "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID", - "Update Trading Offer", - """**WORK IN PROGRESS** - | - |Update an existing trading offer. - | - |Only certain fields can be updated: - |- price_amount: New price per unit - |- expiry_datetime: New expiration date (ISO 8601 format) - |- minimum_fill: New minimum fill amount - | - |All fields are optional - only provided fields will be updated. - | - |Authentication is required.""", - JSONFactory700.UpdateOfferRequestJson( - price_amount = Some(BigDecimal("1.60")), - expiry_datetime = Some("2026-12-31T23:59:59Z"), - minimum_fill = Some(BigDecimal("10.00")) - ), - 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.60"), - settlement_account_id = "settlement-account-123", - expiry_datetime = Some("2026-12-31T23:59:59Z"), - minimum_fill = Some(BigDecimal("10.00")) - ), - 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:35:00Z" - ), - List(InvalidJsonFormat, InvalidTradingAmount, InvalidDateFormat, OfferNotFound, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError), - apiTagTrading :: Nil, - http4sPartialFunction = Some(updateTradingOffer) - ) - + // 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 => @@ -1709,252 +1626,252 @@ object Http4s700 { 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) - ) +// // ── 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) ───────────────────────────────────── From fc2a566edeba0143e4cb76a9a2474a3414f09bec Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 24 Apr 2026 12:55:38 +0200 Subject: [PATCH 17/17] refactor/commented the notifyDeposit endpoint --- .../scala/code/api/v7_0_0/Http4s700.scala | 152 +++++++++--------- 1 file changed, 76 insertions(+), 76 deletions(-) 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 74a011a408..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 @@ -1479,82 +1479,82 @@ object Http4s700 { ) // 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) - ) +// 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] {