diff --git a/.gitignore b/.gitignore index 1eb34065c5..0a7234f76b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ marketing_diagram_generation/outputs/* .specstory project/project coursier +metals.sbt \ No newline at end of file diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 4394e738a3..ef59394b1b 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. @@ -1075,6 +1075,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 9423d64c0d..6cba7993f8 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 @@ -5820,6 +5820,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/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 => 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 7d50f502f7..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,20 +1,22 @@ package code.api.v6_0_0 -import code.api.{APIFailureNewStyle, DirectLogin, ObpApiFailure} -import code.api.v6_0_0.JSONFactory600 +import code.accountattribute.AccountAttributeX +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.v5_0_0.{JSONFactory500, PostBankJson500} +import code.api.util.{APIUtil, ErrorMessages, NewStyle, RateLimitingUtil} +import code.api.v3_0_0.JSONFactory300 +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.entitlement.Entitlement +import code.model._ import code.ratelimiting.RateLimitingDI import code.util.Helper import code.util.Helper.SILENCE_IS_GOLDEN @@ -23,11 +25,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.{Empty, 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 @@ -49,6 +50,102 @@ 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) + } + + // --- GET Holding Account by Parent --- + staticResourceDocs += ResourceDoc( + getHoldingAccountByReleaser, + implementedInApiVersion, + nameOf(getHoldingAccountByReleaser), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-accounts", + "Get Holding Accounts By Releaser", + s""" + | + |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, + moderatedCoreAccountsJsonV300, + List( + $UserNotLoggedIn, + $BankNotFound, + $BankAccountNotFound, + $UserNoPermissionAccessView, + UnknownError + ), + List(apiTagAccount) + ) + + lazy val getHoldingAccountByReleaser: OBPEndpoint = { + 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 + // 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 <- { + 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 <- 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 accountsJson = JSONFactory300.createFirehoseCoreBankAccountJSON(List(moderatedAccount), Some(attributes)) + (accountsJson, HttpCode.`200`(callContext)) + } + } + 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 e13951a5bc..74585cd287 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 @@ -128,6 +128,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..25e48a15fd 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,31 @@ 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] + } + 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, + releaserAccount, + 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 +1590,83 @@ object LocalMappedConnectorInternal extends MdcLoggable { } } + /** + * Find or create a Holding Account for the given releaser account and link via account attributes. + * Rules: + * - Holding account uses the same currency as releaser. + * - Holding account type: "HOLDING". + * - Attributes on holding: ACCOUNT_ROLE=HOLDING, RELEASER_ACCOUNT_ID=releaserAccountId + * - Optional reverse link on releaser: HOLDING_ACCOUNT_ID=holdingAccountId + */ + private def getOrCreateHoldingAccount( + bankId: BankId, + releaserAccount: BankAccount, + callContext: Option[CallContext] + ): Future[(BankAccount, Option[CallContext])] = { + val params = Map("RELEASER_ACCOUNT_ID" -> List(releaserAccount.accountId.value)) + for { + // 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 + 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, cc) <- NewStyle.function.createBankAccount( + bankId = bankId, + accountId = newAccountId, + accountType = "HOLDING", + accountLabel = s"Holding account for ${releaserAccount.accountId.value}", + currency = releaserAccount.currency, + initialBalance = BigDecimal(0), + accountHolderName = Option(releaserAccount.accountHolder).getOrElse(""), + branchId = releaserAccount.branchId, + accountRoutings = Nil, + callContext = callContext + ) + _ <- 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 = "RELEASER_ACCOUNT_ID", + attributeType = AccountAttributeType.STRING, + value = releaserAccount.accountId.value, + productInstanceCode = None, + callContext = cc + ) + // create attribute on releaser account to link to holding account + _ <- NewStyle.function.createOrUpdateAccountAttribute( + bankId = bankId, + accountId = releaserAccount.accountId, + productCode = ProductCode(releaserAccount.accountType), + accountAttributeId = None, + name = "HOLDING_ACCOUNT_ID", + attributeType = AccountAttributeType.STRING, + value = holding.accountId.value, + productInstanceCode = None, + callContext = cc + ) + } yield (holding, cc) + } + } 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 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 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",