From 121ab9edae82478727c9b983689d8a4698d87672 Mon Sep 17 00:00:00 2001 From: hongwei Date: Sun, 5 Oct 2025 22:18:59 +0200 Subject: [PATCH 01/12] feature/add HOLD transaction request type and related models for v6.0.0 --- .../resources/props/sample.props.template | 3 +- .../SwaggerDefinitionsJSON.scala | 6 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 47 ++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 7 +- .../LocalMappedConnectorInternal.scala | 98 ++++++++++++++++++- .../commons/model/enums/Enumerations.scala | 1 + 6 files changed, 155 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index d63b0172dc..6cde75351b 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -362,7 +362,7 @@ transactionRequests_enabled=false transactionRequests_connector=mapped ## Transaction Request Types that are supported on this server. Possible values might include SANDBOX_TAN, COUNTERPARTY, SEPA, FREE_FORM -transactionRequests_supported_types=SANDBOX_TAN,COUNTERPARTY,SEPA,ACCOUNT_OTP,ACCOUNT,SIMPLE +transactionRequests_supported_types=SANDBOX_TAN,COUNTERPARTY,SEPA,ACCOUNT_OTP,ACCOUNT,SIMPLE,HOLD ## Transaction request challenge threshold. Level at which challenge is created and needs to be answered. ## The Currency is EUR unless set with transactionRequests_challenge_currency. @@ -1071,6 +1071,7 @@ database_messages_scheduler_interval=3600 # -- SCA (Strong Customer Authentication) method for OTP challenge------- # ACCOUNT_OTP_INSTRUCTION_TRANSPORT=DUMMY # SIMPLE_OTP_INSTRUCTION_TRANSPORT=DUMMY +# HOLD_OTP_INSTRUCTION_TRANSPORT=DUMMY # SEPA_OTP_INSTRUCTION_TRANSPORT=DUMMY # FREE_FORM_OTP_INSTRUCTION_TRANSPORT=DUMMY # COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=DUMMY diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index df9f3614fb..0e55a99a10 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5811,6 +5811,12 @@ object SwaggerDefinitionsJSON { description = descriptionExample.value ) + // HOLD sample (V600) + lazy val transactionRequestBodyHoldJsonV600 = TransactionRequestBodyHoldJsonV600( + value = amountOfMoneyJsonV121, + description = descriptionExample.value + ) + //The common error or success format. //Just some helper format to use in Json case class NotSupportedYet() diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 4bb912519d..f1b27003c9 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1,14 +1,14 @@ package code.api.v6_0_0 -import code.api.{APIFailureNewStyle, ObpApiFailure} +import code.api.ObpApiFailure import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil._ import code.api.util.ApiRole.{canDeleteRateLimiting, canReadCallLimits, canSetCallLimits} import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, _} import code.api.util.FutureUtil.EndpointContext -import code.api.util.{NewStyle, RateLimitingUtil} import code.api.util.NewStyle.HttpCode +import code.api.util.{NewStyle, RateLimitingUtil} import code.api.v6_0_0.JSONFactory600.{createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ @@ -19,11 +19,10 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.common.Full import net.liftweb.http.rest.RestHelper import java.text.SimpleDateFormat -import java.util.Date import scala.collection.immutable.{List, Nil} import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future @@ -45,6 +44,46 @@ trait APIMethods600 { val codeContext = CodeContext(staticResourceDocs, apiRelations) + staticResourceDocs += ResourceDoc( + createTransactionRequestHold, + implementedInApiVersion, + nameOf(createTransactionRequestHold), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/HOLD/transaction-requests", + "Create Transaction Request (HOLD)", + s""" + | + |Create a transaction request to move funds from the account to its Holding Account. + |If the Holding Account does not exist, it will be created automatically. + | + |${transactionRequestGeneralText} + | + """.stripMargin, + transactionRequestBodyHoldJsonV600, + transactionRequestWithChargeJSON400, + List( + $UserNotLoggedIn, + $BankNotFound, + $BankAccountNotFound, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidTransactionRequestType, + InvalidJsonFormat, + NotPositiveAmount, + InvalidTransactionRequestCurrency, + TransactionDisabled, + UnknownError + ), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2) + ) + + lazy val createTransactionRequestHold: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transaction-request-types" :: + "HOLD" :: "transaction-requests" :: Nil JsonPost json -> _ => + cc => implicit val ec = EndpointContext(Some(cc)) + val transactionRequestType = TransactionRequestType("HOLD") + LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json) + } + staticResourceDocs += ResourceDoc( getCurrentCallsLimit, implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 3ca749b81c..581435e1a2 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -35,7 +35,6 @@ import code.api.v3_1_0.{RateLimit, RedisCallLimitJson} import code.entitlement.Entitlement import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ -import java.util.Date case class CardanoPaymentJsonV600( address: String, @@ -122,6 +121,12 @@ case class TransactionRequestBodyEthSendRawTransactionJsonV600( description: String ) +// ---------------- HOLD models (V600) ---------------- +case class TransactionRequestBodyHoldJsonV600( + value: AmountOfMoneyJsonV121, + description: String +) extends TransactionRequestCommonBodyJSON + case class UserJsonV600( user_id: String, email : String, diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index a2b74af546..c706b9a9c8 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1,5 +1,6 @@ package code.bankconnectors +import code.accountattribute.AccountAttributeX import code.api.ChargePolicy import code.api.Constant._ import code.api.berlin.group.ConstantsBG @@ -13,7 +14,7 @@ import code.api.util.newstyle.ViewNewStyle import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140 import code.api.v2_1_0._ import code.api.v4_0_0._ -import code.api.v6_0_0.{TransactionRequestBodyCardanoJsonV600, TransactionRequestBodyEthSendRawTransactionJsonV600, TransactionRequestBodyEthereumJsonV600} +import code.api.v6_0_0.{TransactionRequestBodyCardanoJsonV600, TransactionRequestBodyEthSendRawTransactionJsonV600, TransactionRequestBodyEthereumJsonV600, TransactionRequestBodyHoldJsonV600} import code.bankconnectors.ethereum.DecodeRawTx import code.branches.MappedBranch import code.fx.fx @@ -801,6 +802,10 @@ object LocalMappedConnectorInternal extends MdcLoggable { description = transactionRequestBodyEthSendRawTransactionJsonV600.description ) } yield (transactionRequestBodyEthereum) + case HOLD => + NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $TransactionRequestBodyHoldJsonV600 ", 400, callContext) { + json.extract[TransactionRequestBodyHoldJsonV600] + } case _ => NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $TransactionRequestBodyCommonJSON ", 400, callContext) { json.extract[TransactionRequestBodyCommonJSON] @@ -823,6 +828,30 @@ object LocalMappedConnectorInternal extends MdcLoggable { }) (createdTransactionRequest, callContext) <- transactionRequestTypeValue match { + case HOLD => { + for { + holdBody <- NewStyle.function.tryons(s"$InvalidJsonFormat It should be $TransactionRequestBodyHoldJsonV600 json format", 400, callContext) { + json.extract[TransactionRequestBodyHoldJsonV600] + } + (holdingAccount, callContext) <- getOrCreateHoldingAccount(bankId, fromAccount, callContext) + transDetailsSerialized <- NewStyle.function.tryons(UnknownError, 400, callContext) { + write(holdBody)(Serialization.formats(NoTypeHints)) + } + (createdTransactionRequest, callContext) <- NewStyle.function.createTransactionRequestv400( + u, + viewId, + fromAccount, + holdingAccount, + transactionRequestType, + holdBody, + transDetailsSerialized, + sharedChargePolicy.toString, + Some(OBP_TRANSACTION_REQUEST_CHALLENGE), + getScaMethodAtInstance(transactionRequestType.value).toOption, + None, + callContext) + } yield (createdTransactionRequest, callContext) + } case REFUND => { for { transactionRequestBodyRefundJson <- NewStyle.function.tryons(s"${InvalidJsonFormat}, it should be $ACCOUNT json format", 400, callContext) { @@ -1560,5 +1589,72 @@ object LocalMappedConnectorInternal extends MdcLoggable { } } + /** + * Find or create a Holding Account for the given parent account and link via account attributes. + * Rules: + * - Holding account uses the same currency as parent. + * - Holding account type: "HOLDING". + * - Attributes on holding: ACCOUNT_ROLE=HOLDING, PARENT_ACCOUNT_ID=parentAccountId + * - Optional reverse link on parent: HOLDING_ACCOUNT_ID=holdingAccountId + */ + private def getOrCreateHoldingAccount( + bankId: BankId, + parentAccount: BankAccount, + callContext: Option[CallContext] + ): Future[(BankAccount, Option[CallContext])] = { + val params = Map("PARENT_ACCOUNT_ID" -> List(parentAccount.accountId.value)) + for { + // Query by attribute to find accounts that link to the parent + accountIdsBox <- AccountAttributeX.accountAttributeProvider.vend.getAccountIdsByParams(bankId, params) + accountIds = accountIdsBox.getOrElse(Nil).map(id => AccountId(id)) + // Try to find an existing holding account among them + existingOpt <- { + def firstHolding(ids: List[AccountId]): Future[Option[(BankAccount, Option[CallContext])]] = ids match { + case Nil => Future.successful(None) + case id :: tail => + NewStyle.function.getBankAccount(bankId, id, callContext).flatMap { case (acc, cc) => + if (acc.accountType == "HOLDING") Future.successful(Some((acc, cc))) + else firstHolding(tail) + } + } + firstHolding(accountIds) + } + result <- existingOpt match { + case Some((acc, cc)) => Future.successful((acc, cc)) + case None => + val newAccountId = AccountId(APIUtil.generateUUID()) + for { + // Create holding account with same currency and zero balance + (holding, cc1) <- NewStyle.function.createBankAccount( + bankId = bankId, + accountId = newAccountId, + accountType = "HOLDING", + accountLabel = s"Holding account for ${parentAccount.accountId.value}", + currency = parentAccount.currency, + initialBalance = BigDecimal(0), + accountHolderName = Option(parentAccount.accountHolder).getOrElse(""), + branchId = parentAccount.branchId, + accountRoutings= Nil, + callContext = callContext + ) + // Link attributes on holding account + _ <- NewStyle.function.createOrUpdateAccountAttribute( + bankId, holding.accountId, ProductCode("HOLDING"), + None, "ACCOUNT_ROLE", AccountAttributeType.STRING, "HOLDING", None, cc1 + ) + _ <- NewStyle.function.createOrUpdateAccountAttribute( + bankId, holding.accountId, ProductCode("HOLDING"), + None, "PARENT_ACCOUNT_ID", AccountAttributeType.STRING, parentAccount.accountId.value, None, cc1 + ) + // Optional reverse link on parent account + _ <- NewStyle.function.createOrUpdateAccountAttribute( + bankId, parentAccount.accountId, ProductCode(parentAccount.accountType), + None, "HOLDING_ACCOUNT_ID", AccountAttributeType.STRING, holding.accountId.value, None, cc1 + ) + } yield (holding, cc1) + } + } yield result + } + } diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index 322d70f644..2061382927 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -102,6 +102,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object SEPA extends Value object FREE_FORM extends Value object SIMPLE extends Value + object HOLD extends Value object CARD extends Value object TRANSFER_TO_PHONE extends Value object TRANSFER_TO_ATM extends Value From 7c72e651e48845b2664c9368bf8e2e0a60157329 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 6 Oct 2025 00:11:48 +0200 Subject: [PATCH 02/12] feature/ add endpoint to get holding account by parent account using account attribute PARENT_ACCOUNT_ID --- .../scala/code/api/v6_0_0/APIMethods600.scala | 54 +++++++++++++++++++ .../LocalMappedConnectorInternal.scala | 1 + .../src/main/scala/code/users/LiftUsers.scala | 5 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index f1b27003c9..6f18b84dca 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1,5 +1,6 @@ package code.api.v6_0_0 +import code.accountattribute.AccountAttributeX import code.api.ObpApiFailure import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil._ @@ -9,6 +10,7 @@ import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, Invalid import code.api.util.FutureUtil.EndpointContext import code.api.util.NewStyle.HttpCode import code.api.util.{NewStyle, RateLimitingUtil} +import code.api.v3_0_0.JSONFactory300 import code.api.v6_0_0.JSONFactory600.{createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ @@ -83,6 +85,58 @@ trait APIMethods600 { val transactionRequestType = TransactionRequestType("HOLD") LocalMappedConnectorInternal.createTransactionRequest(bankId, accountId, viewId , transactionRequestType, json) } + + // --- GET Holding Account by Parent --- + staticResourceDocs += ResourceDoc( + getHoldingAccountByParent, + implementedInApiVersion, + nameOf(getHoldingAccountByParent), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-account", + "Get Holding Account By Parent", + s""" + |Return the Holding Account linked to the given parent account via account attribute `PARENT_ACCOUNT_ID`. + |If multiple holding accounts exist, the first one will be returned. + | + """.stripMargin, + EmptyBody, + moderatedCoreAccountJsonV300, + List( + $UserNotLoggedIn, + $BankNotFound, + $BankAccountNotFound, + $UserNoPermissionAccessView, + UnknownError + ), + List(apiTagAccount) + ) + + lazy val getHoldingAccountByParent: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "holding-account" :: Nil JsonGet _ => + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (user @Full(u), _, _, view, callContext) <- SS.userBankAccountView + // Find accounts by attribute PARENT_ACCOUNT_ID + (accountIdsBox, callContext) <- AccountAttributeX.accountAttributeProvider.vend.getAccountIdsByParams(bankId, Map("PARENT_ACCOUNT_ID" -> List(accountId.value))) map { ids => (ids, callContext) } + accountIds = accountIdsBox.getOrElse(Nil) + // load the first holding account + holdingOpt <- { + def firstHolding(ids: List[String]): Future[Option[BankAccount]] = ids match { + case Nil => Future.successful(None) + case id :: tail => + NewStyle.function.getBankAccount(bankId, AccountId(id), callContext).flatMap { case (acc, cc) => + if (acc.accountType == "HOLDING") Future.successful(Some(acc)) else firstHolding(tail) + } + } + firstHolding(accountIds) + } + holding <- NewStyle.function.tryons($BankAccountNotFound, 404, callContext) { holdingOpt.get } + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(holding, view, user, callContext) + } yield { + val core = JSONFactory300.createCoreBankAccountJSON(moderatedAccount) + (core, HttpCode.`200`(callContext)) + } + } staticResourceDocs += ResourceDoc( getCurrentCallsLimit, diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index c706b9a9c8..87d3ae300f 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1637,6 +1637,7 @@ object LocalMappedConnectorInternal extends MdcLoggable { accountRoutings= Nil, callContext = callContext ) + _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, cc1.get.user.head, callContext) // Link attributes on holding account _ <- NewStyle.function.createOrUpdateAccountAttribute( bankId, holding.accountId, ProductCode("HOLDING"), diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index 39ae2f690a..b47b71ccfb 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -1,8 +1,5 @@ package code.users -import code.api.util.Consent.logger - -import java.util.Date import code.api.util._ import code.entitlement.Entitlement import code.loginattempts.LoginAttempt.maxBadLoginAttempts @@ -15,8 +12,8 @@ import net.liftweb.common.{Box, Empty, Full} import net.liftweb.mapper._ import net.liftweb.util.Helpers +import java.util.Date import scala.collection.immutable -import scala.collection.immutable.List import scala.concurrent.Future object LiftUsers extends Users with MdcLoggable{ From 3509314927842f522d790bf3e3d07c4f8671db21 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 17 Oct 2025 09:42:45 +0200 Subject: [PATCH 03/12] docfix/ delete auto-generated sbt-bloop configuration file --- project/metals.sbt | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 project/metals.sbt diff --git a/project/metals.sbt b/project/metals.sbt deleted file mode 100644 index 928b1abdab..0000000000 --- a/project/metals.sbt +++ /dev/null @@ -1,8 +0,0 @@ -// format: off -// DO NOT EDIT! This file is auto-generated. - -// This file enables sbt-bloop to create bloop config files. - -addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "2.0.12") - -// format: on From 9160c2b2e24732ee7d8434d9eeb51b73cfad334f Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 17 Oct 2025 09:45:02 +0200 Subject: [PATCH 04/12] docfix/ update .gitignore to include metals.sbt --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 561108a778..f5915320c3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ marketing_diagram_generation/outputs/* .bsp .specstory project/project -coursier \ No newline at end of file +coursier +metals.sbt \ No newline at end of file From ecb8a6cac67c57a6f8afdf31ed38ae801f80211d Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 17 Oct 2025 11:40:16 +0200 Subject: [PATCH 05/12] refactor/streamline account creation and attribute linking in LocalMappedConnectorInternal --- .../LocalMappedConnectorInternal.scala | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 87d3ae300f..bb5da05c99 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1626,31 +1626,52 @@ object LocalMappedConnectorInternal extends MdcLoggable { for { // Create holding account with same currency and zero balance (holding, cc1) <- NewStyle.function.createBankAccount( - bankId = bankId, - accountId = newAccountId, - accountType = "HOLDING", - accountLabel = s"Holding account for ${parentAccount.accountId.value}", - currency = parentAccount.currency, + bankId = bankId, + accountId = newAccountId, + accountType = "HOLDING", + accountLabel = s"Holding account for ${parentAccount.accountId.value}", + currency = parentAccount.currency, initialBalance = BigDecimal(0), accountHolderName = Option(parentAccount.accountHolder).getOrElse(""), - branchId = parentAccount.branchId, - accountRoutings= Nil, - callContext = callContext + branchId = parentAccount.branchId, + accountRoutings = Nil, + callContext = callContext ) _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, cc1.get.user.head, callContext) // Link attributes on holding account _ <- NewStyle.function.createOrUpdateAccountAttribute( - bankId, holding.accountId, ProductCode("HOLDING"), - None, "ACCOUNT_ROLE", AccountAttributeType.STRING, "HOLDING", None, cc1 + bankId = bankId, + accountId = holding.accountId, + productCode = ProductCode("HOLDING"), + accountAttributeId = None, + name = "ACCOUNT_ROLE", + attributeType = AccountAttributeType.STRING, + value = "HOLDING", + productInstanceCode = None, + callContext = cc1 ) _ <- NewStyle.function.createOrUpdateAccountAttribute( - bankId, holding.accountId, ProductCode("HOLDING"), - None, "PARENT_ACCOUNT_ID", AccountAttributeType.STRING, parentAccount.accountId.value, None, cc1 + bankId = bankId, + accountId = holding.accountId, + productCode = ProductCode("HOLDING"), + accountAttributeId = None, + name = "PARENT_ACCOUNT_ID", + attributeType = AccountAttributeType.STRING, + value = parentAccount.accountId.value, + productInstanceCode = None, + callContext = cc1 ) // Optional reverse link on parent account _ <- NewStyle.function.createOrUpdateAccountAttribute( - bankId, parentAccount.accountId, ProductCode(parentAccount.accountType), - None, "HOLDING_ACCOUNT_ID", AccountAttributeType.STRING, holding.accountId.value, None, cc1 + bankId = bankId, + accountId = parentAccount.accountId, + productCode = ProductCode(parentAccount.accountType), + accountAttributeId = None, + name = "HOLDING_ACCOUNT_ID", + attributeType = AccountAttributeType.STRING, + value = holding.accountId.value, + productInstanceCode = None, + callContext = cc1 ) } yield (holding, cc1) } From ec7bbd930a39200cd3576ff308c3bd59337b9ae7 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 20 Oct 2025 08:56:04 +0200 Subject: [PATCH 06/12] feature/rename and update endpoint to get holding account by releaser account using account attribute RELEASER_ACCOUNT_ID --- .../scala/code/api/v6_0_0/APIMethods600.scala | 14 ++--- .../LocalMappedConnectorInternal.scala | 55 ++++++++----------- 2 files changed, 29 insertions(+), 40 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 6f18b84dca..f2b45fff8d 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -88,14 +88,14 @@ trait APIMethods600 { // --- GET Holding Account by Parent --- staticResourceDocs += ResourceDoc( - getHoldingAccountByParent, + getHoldingAccountByReleaser, implementedInApiVersion, - nameOf(getHoldingAccountByParent), + nameOf(getHoldingAccountByReleaser), "GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-account", - "Get Holding Account By Parent", + "Get Holding Account By Releaser", s""" - |Return the Holding Account linked to the given parent account via account attribute `PARENT_ACCOUNT_ID`. + |Return the Holding Account linked to the given releaser account via account attribute `RELEASER_ACCOUNT_ID`. |If multiple holding accounts exist, the first one will be returned. | """.stripMargin, @@ -111,13 +111,13 @@ trait APIMethods600 { List(apiTagAccount) ) - lazy val getHoldingAccountByParent: OBPEndpoint = { + lazy val getHoldingAccountByReleaser: OBPEndpoint = { case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "holding-account" :: Nil JsonGet _ => cc => implicit val ec = EndpointContext(Some(cc)) for { (user @Full(u), _, _, view, callContext) <- SS.userBankAccountView - // Find accounts by attribute PARENT_ACCOUNT_ID - (accountIdsBox, callContext) <- AccountAttributeX.accountAttributeProvider.vend.getAccountIdsByParams(bankId, Map("PARENT_ACCOUNT_ID" -> List(accountId.value))) map { ids => (ids, callContext) } + // Find accounts by attribute RELEASER_ACCOUNT_ID + (accountIdsBox, callContext) <- AccountAttributeX.accountAttributeProvider.vend.getAccountIdsByParams(bankId, Map("RELEASER_ACCOUNT_ID" -> List(accountId.value))) map { ids => (ids, callContext) } accountIds = accountIdsBox.getOrElse(Nil) // load the first holding account holdingOpt <- { diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index bb5da05c99..8a7bf41314 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -1590,21 +1590,21 @@ object LocalMappedConnectorInternal extends MdcLoggable { } /** - * Find or create a Holding Account for the given parent account and link via account attributes. + * Find or create a Holding Account for the given releaser account and link via account attributes. * Rules: - * - Holding account uses the same currency as parent. + * - Holding account uses the same currency as releaser. * - Holding account type: "HOLDING". - * - Attributes on holding: ACCOUNT_ROLE=HOLDING, PARENT_ACCOUNT_ID=parentAccountId - * - Optional reverse link on parent: HOLDING_ACCOUNT_ID=holdingAccountId + * - Attributes on holding: ACCOUNT_ROLE=HOLDING, RELEASER_ACCOUNT_ID=releaserAccountId + * - Optional reverse link on releaser: HOLDING_ACCOUNT_ID=holdingAccountId */ private def getOrCreateHoldingAccount( bankId: BankId, - parentAccount: BankAccount, + releaserAccount: BankAccount, callContext: Option[CallContext] ): Future[(BankAccount, Option[CallContext])] = { - val params = Map("PARENT_ACCOUNT_ID" -> List(parentAccount.accountId.value)) + val params = Map("RELEASER_ACCOUNT_ID" -> List(releaserAccount.accountId.value)) for { - // Query by attribute to find accounts that link to the parent + // Query by attribute to find accounts that link to the releaser accountIdsBox <- AccountAttributeX.accountAttributeProvider.vend.getAccountIdsByParams(bankId, params) accountIds = accountIdsBox.getOrElse(Nil).map(id => AccountId(id)) // Try to find an existing holding account among them @@ -1625,55 +1625,44 @@ object LocalMappedConnectorInternal extends MdcLoggable { val newAccountId = AccountId(APIUtil.generateUUID()) for { // Create holding account with same currency and zero balance - (holding, cc1) <- NewStyle.function.createBankAccount( + (holding, cc) <- NewStyle.function.createBankAccount( bankId = bankId, accountId = newAccountId, accountType = "HOLDING", - accountLabel = s"Holding account for ${parentAccount.accountId.value}", - currency = parentAccount.currency, + accountLabel = s"Holding account for ${releaserAccount.accountId.value}", + currency = releaserAccount.currency, initialBalance = BigDecimal(0), - accountHolderName = Option(parentAccount.accountHolder).getOrElse(""), - branchId = parentAccount.branchId, + accountHolderName = Option(releaserAccount.accountHolder).getOrElse(""), + branchId = releaserAccount.branchId, accountRoutings = Nil, callContext = callContext ) - _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, cc1.get.user.head, callContext) - // Link attributes on holding account + _ <- code.model.dataAccess.BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, newAccountId, cc.get.user.head, callContext) + // create attribute on holding account to link to releaser account _ <- NewStyle.function.createOrUpdateAccountAttribute( bankId = bankId, accountId = holding.accountId, productCode = ProductCode("HOLDING"), accountAttributeId = None, - name = "ACCOUNT_ROLE", + name = "RELEASER_ACCOUNT_ID", attributeType = AccountAttributeType.STRING, - value = "HOLDING", + value = releaserAccount.accountId.value, productInstanceCode = None, - callContext = cc1 + callContext = cc ) + // create attribute on releaser account to link to holding account _ <- NewStyle.function.createOrUpdateAccountAttribute( bankId = bankId, - accountId = holding.accountId, - productCode = ProductCode("HOLDING"), - accountAttributeId = None, - name = "PARENT_ACCOUNT_ID", - attributeType = AccountAttributeType.STRING, - value = parentAccount.accountId.value, - productInstanceCode = None, - callContext = cc1 - ) - // Optional reverse link on parent account - _ <- NewStyle.function.createOrUpdateAccountAttribute( - bankId = bankId, - accountId = parentAccount.accountId, - productCode = ProductCode(parentAccount.accountType), + accountId = releaserAccount.accountId, + productCode = ProductCode(releaserAccount.accountType), accountAttributeId = None, name = "HOLDING_ACCOUNT_ID", attributeType = AccountAttributeType.STRING, value = holding.accountId.value, productInstanceCode = None, - callContext = cc1 + callContext = cc ) - } yield (holding, cc1) + } yield (holding, cc) } } yield result } From ee05fb7e509863739dbbca36a81fcb73434bfe02 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 20 Oct 2025 10:32:38 +0200 Subject: [PATCH 07/12] feature/update endpoint to retrieve multiple holding accounts by releaser account, including account attributes --- .../scala/code/api/v6_0_0/APIMethods600.scala | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index f2b45fff8d..880f2e5c7d 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -14,6 +14,7 @@ import code.api.v3_0_0.JSONFactory300 import code.api.v6_0_0.JSONFactory600.{createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ +import code.model._ import code.entitlement.Entitlement import code.ratelimiting.RateLimitingDI import code.views.Views @@ -92,15 +93,16 @@ trait APIMethods600 { implementedInApiVersion, nameOf(getHoldingAccountByReleaser), "GET", - "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-account", - "Get Holding Account By Releaser", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-accounts", + "Get Holding Accounts By Releaser", s""" - |Return the Holding Account linked to the given releaser account via account attribute `RELEASER_ACCOUNT_ID`. - |If multiple holding accounts exist, the first one will be returned. + | + |Return the first Holding Account linked to the given releaser account via account attribute `RELEASER_ACCOUNT_ID`. + |Response is wrapped in a list and includes account attributes. | """.stripMargin, EmptyBody, - moderatedCoreAccountJsonV300, + moderatedCoreAccountsJsonV300, List( $UserNotLoggedIn, $BankNotFound, @@ -112,7 +114,7 @@ trait APIMethods600 { ) lazy val getHoldingAccountByReleaser: OBPEndpoint = { - case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "holding-account" :: Nil JsonGet _ => + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "holding-accounts" :: Nil JsonGet _ => cc => implicit val ec = EndpointContext(Some(cc)) for { (user @Full(u), _, _, view, callContext) <- SS.userBankAccountView @@ -131,10 +133,13 @@ trait APIMethods600 { firstHolding(accountIds) } holding <- NewStyle.function.tryons($BankAccountNotFound, 404, callContext) { holdingOpt.get } - moderatedAccount <- NewStyle.function.moderatedBankAccountCore(holding, view, user, callContext) + moderatedAccount <- Future { holding.moderatedBankAccount(view, BankIdAccountId(holding.bankId, holding.accountId), user, callContext) } map { + x => unboxFullOrFail(x, callContext, UnknownError) + } + (attributes, callContext) <- NewStyle.function.getAccountAttributesByAccount(bankId, holding.accountId, callContext) } yield { - val core = JSONFactory300.createCoreBankAccountJSON(moderatedAccount) - (core, HttpCode.`200`(callContext)) + val accountsJson = JSONFactory300.createFirehoseCoreBankAccountJSON(List(moderatedAccount), Some(attributes)) + (accountsJson, HttpCode.`200`(callContext)) } } From fb9e9a114aa1517863e5ee25bee74ac96db85393 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 20 Oct 2025 12:29:08 +0200 Subject: [PATCH 08/12] refactor/update holding account retrieval to use releaser account instead of fromAccount in transaction request processing --- .../code/bankconnectors/LocalMappedConnectorInternal.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala index 8a7bf41314..25e48a15fd 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala @@ -833,14 +833,15 @@ object LocalMappedConnectorInternal extends MdcLoggable { holdBody <- NewStyle.function.tryons(s"$InvalidJsonFormat It should be $TransactionRequestBodyHoldJsonV600 json format", 400, callContext) { json.extract[TransactionRequestBodyHoldJsonV600] } - (holdingAccount, callContext) <- getOrCreateHoldingAccount(bankId, fromAccount, callContext) + releaserAccount = fromAccount + (holdingAccount, callContext) <- getOrCreateHoldingAccount(bankId, releaserAccount, callContext) transDetailsSerialized <- NewStyle.function.tryons(UnknownError, 400, callContext) { write(holdBody)(Serialization.formats(NoTypeHints)) } (createdTransactionRequest, callContext) <- NewStyle.function.createTransactionRequestv400( u, viewId, - fromAccount, + releaserAccount, holdingAccount, transactionRequestType, holdBody, From acf4c2cfadc648f902d165ccbb904fbd9e859449 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 20 Oct 2025 12:35:42 +0200 Subject: [PATCH 09/12] refactor/add logger import and clean up unused imports in LiftUsers.scala --- obp-api/src/main/scala/code/users/LiftUsers.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index b47b71ccfb..39ae2f690a 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -1,5 +1,8 @@ package code.users +import code.api.util.Consent.logger + +import java.util.Date import code.api.util._ import code.entitlement.Entitlement import code.loginattempts.LoginAttempt.maxBadLoginAttempts @@ -12,8 +15,8 @@ import net.liftweb.common.{Box, Empty, Full} import net.liftweb.mapper._ import net.liftweb.util.Helpers -import java.util.Date import scala.collection.immutable +import scala.collection.immutable.List import scala.concurrent.Future object LiftUsers extends Users with MdcLoggable{ From ee1919dbcee1a131c188ef12245ac497d9ace2a8 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 20 Oct 2025 12:57:18 +0200 Subject: [PATCH 10/12] refactor/add Box and Empty imports to APIMethods600.scala for improved type handling --- .../scala/code/api/v6_0_0/APIMethods600.scala | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index fd2f507129..4eb9a5bb3e 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1,25 +1,22 @@ package code.api.v6_0_0 import code.accountattribute.AccountAttributeX -import code.api.ObpApiFailure -import code.api.{APIFailureNewStyle, DirectLogin, ObpApiFailure} -import code.api.v6_0_0.JSONFactory600 +import code.api.{DirectLogin, ObpApiFailure} import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil._ -import code.api.util.ApiRole.{CanCreateEntitlementAtOneBank, CanReadDynamicResourceDocsAtOneBank, canCreateBank, canDeleteRateLimiting, canReadCallLimits, canSetCallLimits} +import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$UserNotLoggedIn, InvalidDateFormat, InvalidJsonFormat, UnknownError, _} import code.api.util.FutureUtil.EndpointContext -import code.api.util.{APIUtil, ErrorMessages, NewStyle, RateLimitingUtil} import code.api.util.NewStyle.HttpCode -import code.api.util.{NewStyle, RateLimitingUtil} +import code.api.util.{APIUtil, ErrorMessages, NewStyle, RateLimitingUtil} import code.api.v3_0_0.JSONFactory300 -import code.api.v5_0_0.{JSONFactory500, PostBankJson500} +import code.api.v5_0_0.JSONFactory500 import code.api.v6_0_0.JSONFactory600.{createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ -import code.model._ import code.entitlement.Entitlement +import code.model._ import code.ratelimiting.RateLimitingDI import code.util.Helper import code.util.Helper.SILENCE_IS_GOLDEN @@ -28,7 +25,7 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.Full +import net.liftweb.common.{Empty, Full} import net.liftweb.http.rest.RestHelper import java.text.SimpleDateFormat From ef29df74f87310d8f03507e8fd1265f5e14e6409 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 30 Oct 2025 14:08:14 +0100 Subject: [PATCH 11/12] feature/add populateMissingProviderAtAuthUser migration to handle missing provider values in AuthUser --- .../code/api/util/migration/Migration.scala | 12 ++++++ .../util/migration/MigrationOfAuthUser.scala | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 166a543423..7488a9e8d5 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -81,6 +81,7 @@ object Migration extends MdcLoggable { populateTheFieldDeletedAtResourceUser(startedBeforeSchemifier) populateTheFieldIsActiveAtProductAttribute(startedBeforeSchemifier) alterColumnUsernameProviderFirstnameAndLastnameAtAuthUser(startedBeforeSchemifier) + populateMissingProviderAtAuthUser(startedBeforeSchemifier) alterColumnEmailAtResourceUser(startedBeforeSchemifier) alterColumnNameAtProductFee(startedBeforeSchemifier) addFastFirehoseAccountsView(startedBeforeSchemifier) @@ -347,6 +348,17 @@ object Migration extends MdcLoggable { } } } + private def populateMissingProviderAtAuthUser(startedBeforeSchemifier: Boolean): Boolean = { + if(startedBeforeSchemifier == true) { + logger.warn(s"Migration.database.populateMissingProviderAtAuthUser(true) cannot be run before Schemifier.") + true + } else { + val name = nameOf(populateMissingProviderAtAuthUser(startedBeforeSchemifier)) + runOnce(name) { + MigrationOfAuthUser.populateMissingProviderWithLocalIdentity(name) + } + } + } private def alterColumnEmailAtResourceUser(startedBeforeSchemifier: Boolean): Boolean = { if(startedBeforeSchemifier == true) { logger.warn(s"Migration.database.alterColumnEmailAtResourceUser(true) cannot be run before Schemifier.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfAuthUser.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfAuthUser.scala index 037b3ed9bc..9885b0846a 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfAuthUser.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfAuthUser.scala @@ -3,6 +3,7 @@ package code.api.util.migration import java.time.format.DateTimeFormatter import java.time.{ZoneId, ZonedDateTime} +import code.api.Constant import code.api.util.APIUtil import code.api.util.migration.Migration.{DbFunction, saveLog} import code.util.Helper @@ -74,6 +75,45 @@ object MigrationOfAuthUser { } } + def populateMissingProviderWithLocalIdentity(name: String): Boolean = { + DbFunction.tableExists(AuthUser) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + // Make back up + DbFunction.makeBackUpOfTable(AuthUser) + + val updatedRows = + for { + user <- AuthUser.findAll() + providerValue = Option(user.provider.get).map(_.trim).getOrElse("") if providerValue.isEmpty + } yield { + user.provider(Constant.localIdentityProvider).saveMe() + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Updated number of rows: + |${updatedRows.size} + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${AuthUser._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } + def dropIndexAtColumnUsername(name: String): Boolean = { DbFunction.tableExists(AuthUser) match { case true => From db862316ad9d35945ea4e94979c528f485a6dcca Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 31 Oct 2025 10:54:43 +0100 Subject: [PATCH 12/12] refactor/added comma --- zed/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zed/tasks.json b/zed/tasks.json index a369daf572..236c2f7975 100644 --- a/zed/tasks.json +++ b/zed/tasks.json @@ -5,7 +5,7 @@ "args": ["jetty:run", "-pl", "obp-api"], "env": { "MAVEN_OPTS": "-Xss128m --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/java.util.stream=ALL-UNNAMED --add-opens=java.base/java.util.regex=ALL-UNNAMED" - } + }, "use_new_terminal": true, "allow_concurrent_runs": false, "reveal": "always",