From 05163fd503a0fd0836ec7a51263030061da5a6e0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 22:31:23 +0100 Subject: [PATCH 01/23] index page HTML --- obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index e0049c7723..0f9371d549 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -21,7 +21,7 @@ object StatusPage { private def prefersJson(req: Request[IO]): Boolean = req.headers.get[Accept].exists { accept => accept.values.toList.exists { mediaRange => - mediaRange.mediaRange.satisfiedBy(MediaType.application.json) + mediaRange.mediaRange == MediaType.application.json } } From dffa9a18751347ea35ab6e07434ec2da3b19d8dc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 6 Mar 2026 09:10:21 +0100 Subject: [PATCH 02/23] Add pagination to my consents --- .../main/scala/code/api/util/Glossary.scala | 7 ++- .../scala/code/api/v5_1_0/APIMethods510.scala | 28 +++++++++--- .../scala/code/api/v6_0_0/APIMethods600.scala | 1 + .../code/scheduler/ConsentScheduler.scala | 43 ++++++++++--------- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 96952353bf..c6a06d2623 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -2956,7 +2956,7 @@ object Glossary extends MdcLoggable { |Berlin Group consents with status "received" that remain unfinished (e.g. the PSU never completed the SCA flow) beyond a configured time threshold are automatically rejected. | |* `berlin_group_outdated_consents_time_in_seconds` - Time in seconds after which an unfinished consent is considered outdated. Default: **300** (5 minutes). -|* `berlin_group_outdated_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled). +|* `berlin_group_outdated_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated consents. Default: **599**. Set to 0 to disable. | |Example: | @@ -2967,13 +2967,16 @@ object Glossary extends MdcLoggable { |## Expired Consents | |Berlin Group consents with status "valid" whose `validUntil` date has passed are automatically transitioned to "expired" status. +|OBP consents with status "ACCEPTED" whose `validUntil` date has passed are automatically transitioned to "EXPIRED" status. | -|* `berlin_group_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled). +|* `berlin_group_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired Berlin Group consents. Default: **597**. Set to 0 to disable. +|* `obp_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired OBP consents. Default: **595**. Set to 0 to disable. | |Example: | | # Check for expired consents every 120 seconds | berlin_group_expired_consents_interval_in_seconds = 120 +| obp_expired_consents_interval_in_seconds = 120 | """) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index e6728b32f5..9bd570c06a 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -1842,16 +1842,25 @@ trait APIMethods510 { "Get My Consents", s""" | - |This endpoint gets the Consents created by a current User. + |This endpoint gets the Consents created by the current User. | |${userAuthenticationMessage(true)} | + |1 limit (for pagination: defaults to 50) eg:limit=200 + | + |2 offset (for pagination: zero index, defaults to 0) eg: offset=10 + | + |3 status (ignore if omitted) + | + |4 sort_by (defaults to created_date:desc) eg: sort_by=created_date:desc + | + |eg: /my/consents?limit=10&offset=0&sort_by=created_date:desc + | """.stripMargin, EmptyBody, consentsInfoJsonV510, List( $AuthenticatedUserIsRequired, - $BankNotFound, UnknownError ), List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2)) @@ -1861,12 +1870,19 @@ trait APIMethods510 { cc => implicit val ec = EndpointContext(Some(cc)) for { - consents <- Future { - Consents.consentProvider.vend.getConsentsByUser(cc.userId) - .sortBy(i => (i.creationDateTime, i.apiStandard)).reverse + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) + // Add user_id filter for current user and default sort if not specified + userIdParam = OBPUserId(cc.userId) + sortByParam = obpQueryParams.collectFirst { case OBPSortBy(_) => true } + queryParamsWithDefaults = userIdParam :: obpQueryParams ++ ( + if (sortByParam.isEmpty) List(OBPSortBy("created_date:desc")) else Nil + ) + (consents, _) <- Future { + Consents.consentProvider.vend.getConsents(queryParamsWithDefaults) } } yield { - (createConsentsInfoJsonV510(consents), HttpCode.`200`(cc)) + (createConsentsInfoJsonV510(consents), HttpCode.`200`(callContext)) } } } 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 24e75d26ec..f31cc63eb3 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 @@ -11290,6 +11290,7 @@ trait APIMethods600 { } } + } } diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index 36792bf435..f52cfe5c4e 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -18,34 +18,37 @@ object ConsentScheduler extends MdcLoggable { val dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) def currentDate = dateFormat.format(new Date()) - // Starts multiple scheduled tasks with different intervals + // Starts multiple scheduled tasks with different intervals. + // All tasks are enabled by default. Set the interval prop to 0 to disable a task. + // Default intervals use prime-ish offsets (599, 597, 595) to avoid tasks firing at the same time + // and spreading load more evenly. def startAll(): Unit = { var initialDelay = 0 // Berlin Group - APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval_in_seconds") match { - case Full(interval) if interval > 0 => - val time = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_time_in_seconds", 300) - SchedulerUtil.startTask(interval = interval, () => unfinishedBerlinGroupConsents(time)) // Runs periodically - initialDelay = initialDelay + 10 - case _ => - logger.warn("|---> Skipping unfinishedBerlinGroupConsents task: berlin_group_outdated_consents_interval_in_seconds not set or invalid") + val bgOutdatedInterval = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval_in_seconds", 599) + if (bgOutdatedInterval > 0) { + val time = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_time_in_seconds", 300) + SchedulerUtil.startTask(interval = bgOutdatedInterval, () => unfinishedBerlinGroupConsents(time)) + initialDelay = initialDelay + 10 + } else { + logger.warn("|---> Skipping unfinishedBerlinGroupConsents task: berlin_group_outdated_consents_interval_in_seconds set to 0") } - APIUtil.getPropsAsIntValue("berlin_group_expired_consents_interval_in_seconds") match { - case Full(interval) if interval > 0 => - SchedulerUtil.startTask(interval = interval, () => expiredBerlinGroupConsents(), initialDelay) // Delay for 10 seconds - initialDelay = initialDelay + 10 - case _ => - logger.warn("|---> Skipping expiredBerlinGroupConsents task: berlin_group_expired_consents_interval_in_seconds not set or invalid") + val bgExpiredInterval = APIUtil.getPropsAsIntValue("berlin_group_expired_consents_interval_in_seconds", 597) + if (bgExpiredInterval > 0) { + SchedulerUtil.startTask(interval = bgExpiredInterval, () => expiredBerlinGroupConsents(), initialDelay) + initialDelay = initialDelay + 10 + } else { + logger.warn("|---> Skipping expiredBerlinGroupConsents task: berlin_group_expired_consents_interval_in_seconds set to 0") } // Open Bank Project - APIUtil.getPropsAsIntValue("obp_expired_consents_interval_in_seconds") match { - case Full(interval) if interval > 0 => - SchedulerUtil.startTask(interval = interval, () => expiredObpConsents(), initialDelay) // Delay for 10 seconds - initialDelay = initialDelay + 10 - case _ => - logger.warn("|---> Skipping expiredObpConsents task: obp_expired_consents_interval_in_seconds not set or invalid") + val obpExpiredInterval = APIUtil.getPropsAsIntValue("obp_expired_consents_interval_in_seconds", 595) + if (obpExpiredInterval > 0) { + SchedulerUtil.startTask(interval = obpExpiredInterval, () => expiredObpConsents(), initialDelay) + initialDelay = initialDelay + 10 + } else { + logger.warn("|---> Skipping expiredObpConsents task: obp_expired_consents_interval_in_seconds set to 0") } } From c0583620bfbcf57996d1a797417acdb65c5104d9 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 6 Mar 2026 12:48:30 +0100 Subject: [PATCH 03/23] Allow Application Access and Scope --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f31cc63eb3..15d41dd039 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 @@ -6401,7 +6401,8 @@ trait APIMethods600 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canCreateSystemLevelDynamicEntity)) + Some(List(canCreateSystemLevelDynamicEntity)), + authMode = UserOrApplication ) // v6.0.0 entity names must be lowercase with underscores (snake_case) From d27ec882b1be5f7868bf486e4d1cc60494af2c53 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 15:21:37 +0100 Subject: [PATCH 04/23] Adding isNaturalPerson (defaults true) and principalUserId (defaults None) to more formalise the Human Agent relationship / chain. --- .../code/model/dataAccess/ResourceUser.scala | 8 ++++++ .../src/main/scala/code/users/LiftUsers.scala | 26 +++++++++++++------ obp-api/src/main/scala/code/users/Users.scala | 18 +++++++------ .../commons/model/UserModel.scala | 2 ++ 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala index 1b88ccf93b..d96099fd26 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/ResourceUser.scala @@ -93,6 +93,12 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa object LastUsedLocale extends MappedString(this, 10) { override def defaultValue = null } + object IsNaturalPerson extends MappedBoolean(this) { + override def defaultValue = true + } + object PrincipalUserId extends MappedString(this, 100) { + override def defaultValue = null + } def emailAddress = { val e = email.get @@ -123,6 +129,8 @@ class ResourceUser extends LongKeyedMapper[ResourceUser] with User with ManyToMa override def isDeleted: Option[Boolean] = if(IsDeleted.jdbcFriendly(IsDeleted.calcFieldName) == null) None else Some(IsDeleted.get) // null --> None override def lastMarketingAgreementSignedDate: Option[Date] = if(IsDeleted.jdbcFriendly(LastMarketingAgreementSignedDate.calcFieldName) == null) None else Some(LastMarketingAgreementSignedDate.get) // null --> None override def lastUsedLocale: Option[String] = if(LastUsedLocale.get == null) None else Some(LastUsedLocale.get) // null --> None + override def isNaturalPerson: Boolean = IsNaturalPerson.get + override def principalUserIdOption: Option[String] = if(PrincipalUserId.get == null) None else if (PrincipalUserId.get.isEmpty) None else Some(PrincipalUserId.get) } object ResourceUser extends ResourceUser with LongKeyedMetaMapper[ResourceUser]{ diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index 3012940271..f7ebd0b64c 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -223,15 +223,17 @@ object LiftUsers extends Users with MdcLoggable{ } - override def createResourceUser(provider: String, - providerId: Option[String], - createdByConsentId: Option[String], - name: Option[String], - email: Option[String], - userId: Option[String], - createdByUserInvitationId: Option[String], + override def createResourceUser(provider: String, + providerId: Option[String], + createdByConsentId: Option[String], + name: Option[String], + email: Option[String], + userId: Option[String], + createdByUserInvitationId: Option[String], company: Option[String], - lastMarketingAgreementSignedDate: Option[Date]): Box[ResourceUser] = { + lastMarketingAgreementSignedDate: Option[Date], + isNaturalPerson: Option[Boolean] = Some(true), + principalUserId: Option[String] = None): Box[ResourceUser] = { val ru = ResourceUser.create ru.provider_(provider) providerId match { @@ -266,6 +268,14 @@ object LiftUsers extends Users with MdcLoggable{ case Some(v) => ru.LastMarketingAgreementSignedDate(v) case None => } + isNaturalPerson match { + case Some(v) => ru.IsNaturalPerson(v) + case None => + } + principalUserId match { + case Some(v) => ru.PrincipalUserId(v) + case None => + } Full(ru.saveMe()) } diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index bbab121229..f9b75138d1 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -51,15 +51,17 @@ trait Users { def getUsers(queryParams: List[OBPQueryParam]): Future[List[(ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]])]] - def createResourceUser(provider: String, - providerId: Option[String], - createdByConsentId: Option[String], - name: Option[String], - email: Option[String], - userId: Option[String], - createdByUserInvitationId: Option[String], + def createResourceUser(provider: String, + providerId: Option[String], + createdByConsentId: Option[String], + name: Option[String], + email: Option[String], + userId: Option[String], + createdByUserInvitationId: Option[String], company: Option[String], - lastMarketingAgreementSignedDate: Option[Date]) : Box[ResourceUser] + lastMarketingAgreementSignedDate: Option[Date], + isNaturalPerson: Option[Boolean] = Some(true), + principalUserId: Option[String] = None) : Box[ResourceUser] def createUnsavedResourceUser(provider: String, providerId: Option[String], name: Option[String], email: Option[String], userId: Option[String]) : Box[ResourceUser] diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala index 5c16626798..1bc7702515 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/UserModel.scala @@ -71,6 +71,8 @@ trait User { def isDeleted: Option[Boolean] def lastMarketingAgreementSignedDate: Option[Date] def lastUsedLocale: Option[String] = None + def isNaturalPerson: Boolean = true + def principalUserIdOption: Option[String] = None } case class UserPrimaryKey(val value : Long) { From 79e253f1aa54438f016bec2b5b524b3c6beb41c0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 20:56:26 +0100 Subject: [PATCH 05/23] Adding user_id and on_behalf_of_user_id to Transaction Request --- .../MappedTransactionRequestProvider.scala | 9 ++++++++- .../com/openbankproject/commons/model/CommonModel.scala | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index 73e7bf6b25..aaea5417c4 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -179,6 +179,8 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with .mConsentReferenceId(consentReferenceIdOption.getOrElse(null)) .mApiVersion(apiVersion.getOrElse(null)) .mApiStandard(apiStandard.getOrElse(null)) + .mUserId(callContext.flatMap(_.user.map(_.userId)).getOrElse(null)) + .mOnBehalfOfUserId(callContext.flatMap(cc => cc.onBehalfOfUser.or(cc.consenter).map(_.userId)).getOrElse(null)) .saveMe Full(mappedTransactionRequest).flatMap(_.toTransactionRequest) @@ -288,6 +290,9 @@ class MappedTransactionRequest extends LongKeyedMapper[MappedTransactionRequest] object mApiStandard extends MappedString(this, 50) object mApiVersion extends MappedString(this, 50) + object mUserId extends MappedString(this, 100) + object mOnBehalfOfUserId extends MappedString(this, 100) + def updateStatus(newStatus: String) = { mStatus.set(newStatus) } @@ -463,7 +468,9 @@ class MappedTransactionRequest extends LongKeyedMapper[MappedTransactionRequest] other_account_routing_address = mOtherAccountRoutingAddress.get, other_bank_routing_scheme = mOtherBankRoutingScheme.get, other_bank_routing_address = mOtherBankRoutingAddress.get, - is_beneficiary = mIsBeneficiary.get + is_beneficiary = mIsBeneficiary.get, + user_id = Option(mUserId.get).filter(_.nonEmpty), + on_behalf_of_user_id = Option(mOnBehalfOfUserId.get).filter(_.nonEmpty) ) ) } 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 b79636ad90..f24613c278 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 @@ -1107,6 +1107,10 @@ case class TransactionRequest ( payment_frequency :Option[String] = None, @optional payment_day_of_execution :Option[String] = None, + @optional + user_id :Option[String] = None, + @optional + on_behalf_of_user_id :Option[String] = None, ) case class TransactionRequestBGV1( From e914da8156ab226b9cea0643586311d1f01407e4 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 21:11:06 +0100 Subject: [PATCH 06/23] Cleanup: Removed unused createTransactionRequestImpl --- .../MappedTransactionRequestProvider.scala | 23 ------------------- .../TransactionRequests.scala | 8 ------- 2 files changed, 31 deletions(-) diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index aaea5417c4..a9ae81e672 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -55,29 +55,6 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with MappedTransactionRequest.bulkDelete_!!() } - override def createTransactionRequestImpl(transactionRequestId: TransactionRequestId, - transactionRequestType: TransactionRequestType, - account : BankAccount, - counterparty : BankAccount, - body: TransactionRequestBody, - status: String, - charge: TransactionRequestCharge) : Box[TransactionRequest] = { - val mappedTransactionRequest = MappedTransactionRequest.create - .mTransactionRequestId(transactionRequestId.value) - .mType(transactionRequestType.value) - .mFrom_BankId(account.bankId.value) - .mFrom_AccountId(account.accountId.value) - .mTo_BankId(counterparty.bankId.value) - .mTo_AccountId(counterparty.accountId.value) - .mBody_Value_Currency(body.value.currency) - .mBody_Value_Amount(body.value.amount) - .mBody_Description(body.description) - .mStatus(status) - .mStartDate(now) - .mEndDate(now).saveMe - Full(mappedTransactionRequest).flatMap(_.toTransactionRequest) - } - override def createTransactionRequestImpl210(transactionRequestId: TransactionRequestId, transactionRequestType: TransactionRequestType, fromAccount: BankAccount, diff --git a/obp-api/src/main/scala/code/transactionrequests/TransactionRequests.scala b/obp-api/src/main/scala/code/transactionrequests/TransactionRequests.scala index ff6c351188..e65ca437b4 100644 --- a/obp-api/src/main/scala/code/transactionrequests/TransactionRequests.scala +++ b/obp-api/src/main/scala/code/transactionrequests/TransactionRequests.scala @@ -44,14 +44,6 @@ trait TransactionRequestProvider extends MdcLoggable { def getTransactionRequestsFromProvider(bankId: BankId, accountId: AccountId): Box[List[TransactionRequest]] def getTransactionRequestFromProvider(transactionRequestId : TransactionRequestId) : Box[TransactionRequest] def updateAllPendingTransactionRequests: Box[Option[Unit]] - def createTransactionRequestImpl(transactionRequestId: TransactionRequestId, - transactionRequestType: TransactionRequestType, - account : BankAccount, - counterparty : BankAccount, - body: TransactionRequestBody, - status: String, - charge: TransactionRequestCharge) : Box[TransactionRequest] - /** * * @param transactionRequestId From e1e18481bd935148817306e9d50e6806ff667253 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 21:48:25 +0100 Subject: [PATCH 07/23] Adding ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER to possible view permission --- obp-api/src/main/scala/code/api/constant/constant.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index ac6ffc87d1..fc7ee55681 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -379,6 +379,7 @@ object Constant extends MdcLoggable { final val CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY = "can_add_transaction_request_to_beneficiary" final val CAN_GRANT_ACCESS_TO_VIEWS = "can_grant_access_to_views" final val CAN_REVOKE_ACCESS_TO_VIEWS = "can_revoke_access_to_views" + final val ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER = "enforce_transaction_request_maker_checker" final val SYSTEM_OWNER_VIEW_PERMISSION_ADMIN = List( CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT, @@ -660,6 +661,7 @@ object Constant extends MdcLoggable { CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GRANT_ACCESS_TO_VIEWS, CAN_REVOKE_ACCESS_TO_VIEWS, + ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER, ) From 68b0d6e8bc3c827f567977195c6f1100de74cffb Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 22:21:56 +0100 Subject: [PATCH 08/23] Adding checkMakerCheckerForTransactionRequest --- .../scala/code/api/constant/constant.scala | 16 +++--- .../scala/code/api/util/ErrorMessages.scala | 1 + .../main/scala/code/api/util/NewStyle.scala | 53 +++++++++++++++++++ .../scala/code/api/v4_0_0/APIMethods400.scala | 10 ++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index fc7ee55681..0366773445 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -379,7 +379,7 @@ object Constant extends MdcLoggable { final val CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY = "can_add_transaction_request_to_beneficiary" final val CAN_GRANT_ACCESS_TO_VIEWS = "can_grant_access_to_views" final val CAN_REVOKE_ACCESS_TO_VIEWS = "can_revoke_access_to_views" - final val ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER = "enforce_transaction_request_maker_checker" + final val CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST = "can_have_same_maker_checker_for_transaction_request" final val SYSTEM_OWNER_VIEW_PERMISSION_ADMIN = List( CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT, @@ -392,7 +392,8 @@ object Constant extends MdcLoggable { CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT, CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GRANT_ACCESS_TO_VIEWS, - CAN_REVOKE_ACCESS_TO_VIEWS + CAN_REVOKE_ACCESS_TO_VIEWS, + CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST ) final val SYSTEM_MANAGER_VIEW_PERMISSION = List( @@ -401,12 +402,14 @@ object Constant extends MdcLoggable { CAN_CREATE_CUSTOM_VIEW, CAN_DELETE_CUSTOM_VIEW, CAN_UPDATE_CUSTOM_VIEW, - CAN_GET_CUSTOM_VIEW + CAN_GET_CUSTOM_VIEW, + CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST ) final val SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_PERMISSION = List( CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT, - CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY + CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, + CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST ) final val SYSTEM_PUBLIC_VIEW_PERMISSION = List( @@ -564,7 +567,8 @@ object Constant extends MdcLoggable { CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME, CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS, CAN_SEE_TRANSACTION_STATUS, - CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT + CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT, + CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST ) final val ALL_VIEW_PERMISSION_NAMES = List( @@ -661,7 +665,7 @@ object Constant extends MdcLoggable { CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GRANT_ACCESS_TO_VIEWS, CAN_REVOKE_ACCESS_TO_VIEWS, - ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER, + CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST, ) 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 1298d8bc65..61bf2c577c 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -572,6 +572,7 @@ object ErrorMessages { val AccountAccessRequestCannotBeCreated = "OBP-30277: Account Access Request could not be created." val AccountAccessRequestStatusNotInitiated = "OBP-30278: Account Access Request status is not INITIATED. Only INITIATED requests can be approved or rejected." val MakerCheckerSameUser = "OBP-30279: The checker (approver/rejecter) cannot be the same user as the maker (requestor). Maker/Checker separation is required." + val MakerCheckerUnknownMaker = "OBP-30283: Maker/Checker separation is required for this view but the Transaction Request has no user_id (maker) recorded. Cannot verify separation." val BusinessJustificationRequired = "OBP-30280: Business justification is required." val CheckerCommentRequiredForRejection = "OBP-30281: A comment is required when rejecting an Account Access Request." val AccountAccessRequestCannotBeUpdated = "OBP-30282: Account Access Request could not be updated." 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 eff56b245f..2f3795ed90 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -41,7 +41,9 @@ import code.users._ import code.util.Helper import code.util.Helper.MdcLoggable import code.validation.{JsonSchemaValidationProvider, JsonValidation} +import code.transactionrequests.TransactionRequests import code.views.Views +import code.views.system.ViewPermission import code.webhook.AccountWebhook import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.dto.{CustomerAndAttribute, GetProductsParam, ProductCollectionItemsTree} @@ -546,6 +548,57 @@ object NewStyle extends MdcLoggable{ } } + def checkMakerCheckerForTransactionRequest( + bankId: BankId, + accountId: AccountId, + viewId: ViewId, + transactionRequestId: TransactionRequestId, + checkerUserId: String, + callContext: Option[CallContext] + ): Future[Boolean] = { + Future { + // Check if the view has the can_have_same_maker_checker_for_transaction_request permission + val permissionName = Constant.CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + val hasPermission = Views.views.vend.customView(viewId, BankIdAccountId(bankId, accountId)) match { + case Full(view) => ViewPermission.findViewPermission(view, permissionName).isDefined + case _ => Views.views.vend.systemView(viewId) match { + case Full(view) => ViewPermission.findViewPermission(view, permissionName).isDefined + case _ => false + } + } + + // If the view has can_have_same_maker_checker_for_transaction_request, no check needed + if (hasPermission) { + Full(true) + } else { + // Maker-checker is required — get the TR + val transactionRequest = TransactionRequests.transactionRequestProvider.vend + .getTransactionRequest(transactionRequestId) + + transactionRequest match { + case Full(tr) => + tr.user_id match { + case None | Some("") => + Failure(MakerCheckerUnknownMaker) + case Some(makerUserId) if makerUserId == checkerUserId => + Failure(MakerCheckerSameUser) + case _ => + tr.on_behalf_of_user_id match { + case Some(onBehalfOfUserId) if onBehalfOfUserId.nonEmpty && onBehalfOfUserId == checkerUserId => + Failure(MakerCheckerSameUser) + case _ => + Full(true) + } + } + case _ => + Failure(s"$InvalidTransactionRequestId Current TransactionRequestId($transactionRequestId)") + } + } + } map { + unboxFullOrFail(_, callContext, "Maker/Checker check failed") + } + } + def getConsumerByConsumerId(consumerId: String, callContext: Option[CallContext]): Future[Consumer] = { Consumers.consumers.vend.getConsumerByConsumerIdFuture(consumerId) map { unboxFullOrFail(_, callContext, s"$ConsumerNotFoundByConsumerId Current ConsumerId is $consumerId", 404) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index f885bd6caf..7c06183aa0 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -1457,6 +1457,16 @@ trait APIMethods400 extends MdcLoggable { ) } + // Check Maker/Checker separation if required by the view + _ <- NewStyle.function.checkMakerCheckerForTransactionRequest( + bankId, + accountId, + viewId, + transReqId, + u.userId, + callContext + ) + // Check the input transactionRequestType is the same as when the user created the TransactionRequest existingTransactionRequestType = existingTransactionRequest.`type` _ <- Helper.booleanToFuture( From 9587acd3b5df5a5fc12d2e0de5190c22f1972a9d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 22:37:16 +0100 Subject: [PATCH 09/23] Cleaning up: Moving some Agent generated MD files --- CHANGES_SUMMARY.md => temp_docs/CHANGES_SUMMARY.md | 0 FINAL_SUMMARY.md => temp_docs/FINAL_SUMMARY.md | 0 IMPLEMENTATION_SUMMARY.md => temp_docs/IMPLEMENTATION_SUMMARY.md | 0 .../OBP_OIDC_Configuration_Guide.md | 0 RATE_LIMITING_BUG_FIX.md => temp_docs/RATE_LIMITING_BUG_FIX.md | 0 .../REDIS_RATE_LIMITING_DOCUMENTATION.md | 0 .../REDIS_READ_ACCESS_FUNCTIONS.md | 0 _NEXT_STEPS.md => temp_docs/_NEXT_STEPS.md | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename CHANGES_SUMMARY.md => temp_docs/CHANGES_SUMMARY.md (100%) rename FINAL_SUMMARY.md => temp_docs/FINAL_SUMMARY.md (100%) rename IMPLEMENTATION_SUMMARY.md => temp_docs/IMPLEMENTATION_SUMMARY.md (100%) rename OBP_OIDC_Configuration_Guide.md => temp_docs/OBP_OIDC_Configuration_Guide.md (100%) rename RATE_LIMITING_BUG_FIX.md => temp_docs/RATE_LIMITING_BUG_FIX.md (100%) rename REDIS_RATE_LIMITING_DOCUMENTATION.md => temp_docs/REDIS_RATE_LIMITING_DOCUMENTATION.md (100%) rename REDIS_READ_ACCESS_FUNCTIONS.md => temp_docs/REDIS_READ_ACCESS_FUNCTIONS.md (100%) rename _NEXT_STEPS.md => temp_docs/_NEXT_STEPS.md (100%) diff --git a/CHANGES_SUMMARY.md b/temp_docs/CHANGES_SUMMARY.md similarity index 100% rename from CHANGES_SUMMARY.md rename to temp_docs/CHANGES_SUMMARY.md diff --git a/FINAL_SUMMARY.md b/temp_docs/FINAL_SUMMARY.md similarity index 100% rename from FINAL_SUMMARY.md rename to temp_docs/FINAL_SUMMARY.md diff --git a/IMPLEMENTATION_SUMMARY.md b/temp_docs/IMPLEMENTATION_SUMMARY.md similarity index 100% rename from IMPLEMENTATION_SUMMARY.md rename to temp_docs/IMPLEMENTATION_SUMMARY.md diff --git a/OBP_OIDC_Configuration_Guide.md b/temp_docs/OBP_OIDC_Configuration_Guide.md similarity index 100% rename from OBP_OIDC_Configuration_Guide.md rename to temp_docs/OBP_OIDC_Configuration_Guide.md diff --git a/RATE_LIMITING_BUG_FIX.md b/temp_docs/RATE_LIMITING_BUG_FIX.md similarity index 100% rename from RATE_LIMITING_BUG_FIX.md rename to temp_docs/RATE_LIMITING_BUG_FIX.md diff --git a/REDIS_RATE_LIMITING_DOCUMENTATION.md b/temp_docs/REDIS_RATE_LIMITING_DOCUMENTATION.md similarity index 100% rename from REDIS_RATE_LIMITING_DOCUMENTATION.md rename to temp_docs/REDIS_RATE_LIMITING_DOCUMENTATION.md diff --git a/REDIS_READ_ACCESS_FUNCTIONS.md b/temp_docs/REDIS_READ_ACCESS_FUNCTIONS.md similarity index 100% rename from REDIS_READ_ACCESS_FUNCTIONS.md rename to temp_docs/REDIS_READ_ACCESS_FUNCTIONS.md diff --git a/_NEXT_STEPS.md b/temp_docs/_NEXT_STEPS.md similarity index 100% rename from _NEXT_STEPS.md rename to temp_docs/_NEXT_STEPS.md From 5e8656f3baf8bdf5a6f1de05dcc96683cc0b02b0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 22:39:40 +0100 Subject: [PATCH 10/23] Adding canGetAnyBankLevelDynamicEntities --- obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 7c06183aa0..417bf1cfc9 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -2169,7 +2169,7 @@ trait APIMethods400 extends MdcLoggable { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canGetBankLevelDynamicEntities)) + Some(List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities)) ) lazy val getBankLevelDynamicEntities: OBPEndpoint = { @@ -2642,7 +2642,7 @@ trait APIMethods400 extends MdcLoggable { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canCreateBankLevelDynamicEntity)) + Some(List(canCreateBankLevelDynamicEntity, canCreateAnyBankLevelDynamicEntity)) ) lazy val createBankLevelDynamicEntity: OBPEndpoint = { case "management" :: "banks" :: BankId( From c74980e1268c8ea6e66e11a4046ece083fdc2f5e Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 22:40:41 +0100 Subject: [PATCH 11/23] canGetAnyBankLevelDynamicEntities so we don't need to know bank_id in advance --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 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 15d41dd039..18f221a272 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 @@ -6241,7 +6241,7 @@ trait APIMethods600 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canGetBankLevelDynamicEntities)) + Some(List(canGetBankLevelDynamicEntities, canGetAnyBankLevelDynamicEntities)) ) lazy val getBankLevelDynamicEntities: OBPEndpoint = { @@ -6497,7 +6497,7 @@ trait APIMethods600 { UnknownError ), List(apiTagManageDynamicEntity, apiTagApi), - Some(List(canCreateBankLevelDynamicEntity)) + Some(List(canCreateBankLevelDynamicEntity, canCreateAnyBankLevelDynamicEntity)) ) lazy val createBankLevelDynamicEntity: OBPEndpoint = { From 7b18ac60fb5d91882465e904b04e1283e5aff0dc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 7 Mar 2026 22:41:20 +0100 Subject: [PATCH 12/23] CanCreateAnyBankLevelDynamicEntity + Maker Checking test --- .../main/scala/code/api/util/ApiRole.scala | 6 + .../MakerCheckerTransactionRequestTest.scala | 274 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index d4a3332b04..20d78fe410 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -783,6 +783,9 @@ object ApiRole extends MdcLoggable{ case class CanCreateBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateBankLevelDynamicEntity = CanCreateBankLevelDynamicEntity() + case class CanCreateAnyBankLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateAnyBankLevelDynamicEntity = CanCreateAnyBankLevelDynamicEntity() + case class CanUpdateSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole lazy val canUpdateSystemDynamicEntity = CanUpdateSystemLevelDynamicEntity() @@ -807,6 +810,9 @@ object ApiRole extends MdcLoggable{ case class CanGetBankLevelDynamicEntities(requiresBankId: Boolean = true) extends ApiRole lazy val canGetBankLevelDynamicEntities = CanGetBankLevelDynamicEntities() + case class CanGetAnyBankLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetAnyBankLevelDynamicEntities = CanGetAnyBankLevelDynamicEntities() + case class CanGetDynamicEntityDiagnostics(requiresBankId: Boolean = false) extends ApiRole lazy val canGetDynamicEntityDiagnostics = CanGetDynamicEntityDiagnostics() diff --git a/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala new file mode 100644 index 0000000000..baf168f318 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala @@ -0,0 +1,274 @@ +package code.api.v4_0_0 + +import code.api.Constant._ +import code.api.util.APIUtil +import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.extractErrorMessageCode +import code.api.util.ErrorMessages._ +import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140 +import code.api.v2_0_0.TransactionRequestBodyJsonV200 +import code.api.v2_1_0._ +import code.api.v4_0_0.APIMethods400.Implementations4_0_0 +import code.model.BankAccountX +import code.setup.DefaultUsers +import code.views.system.ViewPermission +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.TransactionRequestStatus +import com.openbankproject.commons.model.enums.TransactionRequestTypes._ +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +class MakerCheckerTransactionRequestTest extends V400ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.answerTransactionRequestChallenge)) + + /** + * Helper to remove the can_have_same_maker_checker_for_transaction_request permission + * from the owner system view, forcing maker != checker. + */ + def removeMakerCheckerPermissionFromOwnerView(): Unit = { + val viewId = ViewId(SYSTEM_OWNER_VIEW_ID) + ViewPermission.findSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST) + .foreach(_.delete_!) + } + + /** + * Helper to restore the permission after a test. + */ + def addMakerCheckerPermissionToOwnerView(): Unit = { + val viewId = ViewId(SYSTEM_OWNER_VIEW_ID) + // Only add if not already present + if (ViewPermission.findSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST).isEmpty) { + ViewPermission.createSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST, None) + } + } + + /** + * Create a transaction request with a high amount to trigger a challenge, + * then return (transRequestId, challengeId, helper). + */ + def createTransactionRequestWithChallenge(consumerAndToken: Option[(Consumer, Token)] = user1) = { + val transactionRequestType = ACCOUNT.toString + val testBank = createBank("__mc-test-bank") + val bankId = testBank.bankId + val accountId1 = AccountId("__mc_acc1__") + val accountId2 = AccountId("__mc_acc2__") + val fromCurrency = "AED" + val toCurrency = "AED" + + createAccountRelevantResource(Some(resourceUser1), bankId, accountId1, fromCurrency) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId2, toCurrency) + + val fromAccount = BankAccountX(bankId, accountId1).getOrElse(fail("couldn't get from account")) + val toAccount = BankAccountX(bankId, accountId2).getOrElse(fail("couldn't get to account")) + + val amt = "30000.00" + val toAccountJson = TransactionRequestAccountJsonV140(toAccount.bankId.value, toAccount.accountId.value) + val bodyValue = AmountOfMoneyJsonV121(fromCurrency, amt) + val transactionRequestBody = TransactionRequestBodyJsonV200(toAccountJson, bodyValue, "Maker-Checker test") + + val createTransReqRequest = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests").POST <@(consumerAndToken) + + val createResponse = makePostRequest(createTransReqRequest, write(transactionRequestBody)) + createResponse.code should equal(201) + + val transRequestId = (createResponse.body \ "id").values.toString + transRequestId should not equal ("") + + (createResponse.body \ "status").values.toString should equal(TransactionRequestStatus.INITIATED.toString) + + val challengeId = (createResponse.body \ "challenges" \ "id").values.toString + challengeId should not equal ("") + + (bankId, fromAccount, transactionRequestType, transRequestId, challengeId) + } + + feature("Maker-Checker enforcement on answerTransactionRequestChallenge") { + + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("Same maker and checker WITH can_have_same_maker_checker permission should SUCCEED", ApiEndpoint1) {} + } else { + scenario("Same maker and checker WITH can_have_same_maker_checker permission should SUCCEED", ApiEndpoint1) { + // Default: owner view has the permission, so same user can make and check + addMakerCheckerPermissionToOwnerView() + + val (bankId, fromAccount, transactionRequestType, transRequestId, challengeId) = + createTransactionRequestWithChallenge(user1) + + // Same user (user1) answers the challenge + val answerJson = ChallengeAnswerJson400(id = challengeId, answer = "123") + val answerRequest = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests" / transRequestId / "challenge").POST <@(user1) + + val answerResponse = makePostRequest(answerRequest, write(answerJson)) + + Then("we should get a 202 code - same maker/checker allowed") + answerResponse.code should equal(202) + (answerResponse.body \ "status").values.toString should equal(TransactionRequestStatus.COMPLETED.toString) + } + } + + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("Same maker and checker WITHOUT can_have_same_maker_checker permission should FAIL", ApiEndpoint1) {} + } else { + scenario("Same maker and checker WITHOUT can_have_same_maker_checker permission should FAIL", ApiEndpoint1) { + val (bankId, fromAccount, transactionRequestType, transRequestId, challengeId) = + createTransactionRequestWithChallenge(user1) + + // Remove the permission to enforce maker != checker + removeMakerCheckerPermissionFromOwnerView() + + try { + // Same user (user1) tries to answer the challenge + val answerJson = ChallengeAnswerJson400(id = challengeId, answer = "123") + val answerRequest = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests" / transRequestId / "challenge").POST <@(user1) + + val answerResponse = makePostRequest(answerRequest, write(answerJson)) + + Then("we should get a 400 code - same maker/checker NOT allowed") + answerResponse.code should equal(400) + + And("the error message should indicate maker/checker separation") + val errorMessage = (answerResponse.body \ "message").values.toString + errorMessage should include("OBP-30279") + } finally { + // Restore the permission for other tests + addMakerCheckerPermissionToOwnerView() + } + } + } + + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("Different maker and checker WITHOUT can_have_same_maker_checker permission should SUCCEED", ApiEndpoint1) {} + } else { + scenario("Different maker and checker WITHOUT can_have_same_maker_checker permission should SUCCEED", ApiEndpoint1) { + val (bankId, fromAccount, transactionRequestType, transRequestId, challengeId) = + createTransactionRequestWithChallenge(user1) + + // Remove the permission to enforce maker != checker + removeMakerCheckerPermissionFromOwnerView() + + try { + // Grant user2 access to the owner view on this account + grantUserAccessToViewViaEndpoint( + bankId.value, + fromAccount.accountId.value, + resourceUser2.userId, + user1, + PostViewJsonV400(view_id = SYSTEM_OWNER_VIEW_ID, is_system = true) + ) + + // Different user (user2) answers the challenge + val answerJson = ChallengeAnswerJson400(id = challengeId, answer = "123") + val answerRequest = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests" / transRequestId / "challenge").POST <@(user2) + + val answerResponse = makePostRequest(answerRequest, write(answerJson)) + + Then("we should get a 202 code - different maker and checker is allowed") + answerResponse.code should equal(202) + (answerResponse.body \ "status").values.toString should equal(TransactionRequestStatus.COMPLETED.toString) + } finally { + // Restore the permission for other tests + addMakerCheckerPermissionToOwnerView() + } + } + } + + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("Multiple challenges with maker-checker: different users answer their own challenges", ApiEndpoint1) {} + } else { + scenario("Multiple challenges with maker-checker: different users answer their own challenges", ApiEndpoint1) { + val transactionRequestType = COUNTERPARTY.toString + val testBank = createBank("__mc-test-bank-multi") + val bankId = testBank.bankId + val accountId1 = AccountId("__mc_multi_acc1__") + val accountId2 = AccountId("__mc_multi_acc2__") + val fromCurrency = "AED" + val toCurrency = "INR" + + createAccountRelevantResource(Some(resourceUser1), bankId, accountId1, fromCurrency) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId2, toCurrency) + updateAccountCurrency(bankId, accountId2, toCurrency) + + val fromAccount = BankAccountX(bankId, accountId1).getOrElse(fail("couldn't get from account")) + val toAccount = BankAccountX(bankId, accountId2).getOrElse(fail("couldn't get to account")) + + val counterparty = createCounterparty(bankId.value, accountId1.value, accountId2.value, true, java.util.UUID.randomUUID.toString) + + // Set REQUIRED_CHALLENGE_ANSWERS to 2 + createAccountAttributeViaEndpoint( + bankId.value, + accountId1.value, + "REQUIRED_CHALLENGE_ANSWERS", + "2", + "INTEGER", + Some("LKJL98769G") + ) + + // Grant user2 access to the owner view + grantUserAccessToViewViaEndpoint( + bankId.value, + accountId1.value, + resourceUser2.userId, + user1, + PostViewJsonV400(view_id = SYSTEM_OWNER_VIEW_ID, is_system = true) + ) + + // Remove the permission to enforce maker != checker + removeMakerCheckerPermissionFromOwnerView() + + try { + val amt = "30000.00" + val bodyValue = AmountOfMoneyJsonV121(fromCurrency, amt) + val transactionRequestBodyCounterparty = TransactionRequestBodyCounterpartyJSON( + CounterpartyIdJson(counterparty.counterpartyId), bodyValue, "Multi-challenge MC test", "SHARED" + ) + + val createTransReqRequest = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests").POST <@(user1) + + val createResponse = makePostRequest(createTransReqRequest, write(transactionRequestBodyCounterparty)) + createResponse.code should equal(201) + + val createResponseJson = createResponse.body.extract[TransactionRequestWithChargeJSON400] + createResponseJson.status should equal(TransactionRequestStatus.INITIATED.toString) + + val transRequestId = createResponseJson.id + val challengeOfUser1: Option[ChallengeJsonV400] = createResponseJson.challenges.find(_.user_id == resourceUser1.userId) + val challengeOfUser2: Option[ChallengeJsonV400] = createResponseJson.challenges.find(_.user_id == resourceUser2.userId) + + challengeOfUser1 should not be (None) + challengeOfUser2 should not be (None) + + Then("User1 answers their own challenge (user1 is maker, but this is user1's own challenge)") + val answerJson1 = ChallengeAnswerJson400(id = challengeOfUser1.map(_.id).getOrElse(""), answer = "123") + val answerRequest1 = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests" / transRequestId / "challenge").POST <@(user1) + val ansReqResponseUser1 = makePostRequest(answerRequest1, write(answerJson1)) + + And("User1's answer should indicate next challenge is pending (maker-checker check passes because user1 is maker answering their own challenge)") + ansReqResponseUser1.body.extract[ErrorMessage].message contains extractErrorMessageCode(NextChallengePending) should be(true) + + Then("User2 answers their own challenge (user2 is different from maker user1)") + val answerJson2 = ChallengeAnswerJson400(id = challengeOfUser2.map(_.id).getOrElse(""), answer = "123") + val answerRequest2 = (v4_0_0_Request / "banks" / bankId.value / "accounts" / fromAccount.accountId.value / + SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / transactionRequestType / "transaction-requests" / transRequestId / "challenge").POST <@(user2) + val ansReqResponseUser2 = makePostRequest(answerRequest2, write(answerJson2)) + + And("The transaction request should be completed") + ansReqResponseUser2.body.extract[TransactionRequestWithChargeJSON400].status should equal(TransactionRequestStatus.COMPLETED.toString) + } finally { + addMakerCheckerPermissionToOwnerView() + } + } + } + + } + +} From 1b280d25594021769d28b3bbcf3a550ad27fc796 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 00:33:49 +0100 Subject: [PATCH 13/23] return consumer_id on too many requests --- .../scala/code/api/util/RateLimitingUtil.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala index 4217387202..7eb3cf75ce 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -345,7 +345,7 @@ object RateLimitingUtil extends MdcLoggable { def underCallLimits(userAndCallContext: (Box[User], Option[CallContext])): (Box[User], Option[CallContext]) = { // Configuration and helper functions def perHourLimitAnonymous = APIUtil.getPropsAsIntValue("user_consumer_limit_anonymous_access", 1000) - def composeMsgAuthorizedAccess(period: LimitCallPeriod, limit: Long): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for this Consumer." + def composeMsgAuthorizedAccess(period: LimitCallPeriod, limit: Long, consumerId: String): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for this Consumer (consumer_id: $consumerId)." def composeMsgAnonymousAccess(period: LimitCallPeriod, limit: Long): String = TooManyRequests + s" We only allow $limit requests ${RateLimitingPeriod.humanReadable(period)} for anonymous access." // Helper function to set rate limit headers in successful responses @@ -422,17 +422,17 @@ object RateLimitingUtil extends MdcLoggable { // Return 429 error for first exceeded limit (shorter periods take precedence) checkLimits match { case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x1 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_SECOND, rl.per_second), 429, exceededRateLimit(rl, PER_SECOND))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_SECOND, rl.per_second, rl.consumer_id), 429, exceededRateLimit(rl, PER_SECOND))), userAndCallContext._2) case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x2 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_MINUTE, rl.per_minute), 429, exceededRateLimit(rl, PER_MINUTE))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_MINUTE, rl.per_minute, rl.consumer_id), 429, exceededRateLimit(rl, PER_MINUTE))), userAndCallContext._2) case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x3 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_HOUR, rl.per_hour), 429, exceededRateLimit(rl, PER_HOUR))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_HOUR, rl.per_hour, rl.consumer_id), 429, exceededRateLimit(rl, PER_HOUR))), userAndCallContext._2) case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x4 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_DAY, rl.per_day), 429, exceededRateLimit(rl, PER_DAY))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_DAY, rl.per_day, rl.consumer_id), 429, exceededRateLimit(rl, PER_DAY))), userAndCallContext._2) case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x5 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_WEEK, rl.per_week), 429, exceededRateLimit(rl, PER_WEEK))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_WEEK, rl.per_week, rl.consumer_id), 429, exceededRateLimit(rl, PER_WEEK))), userAndCallContext._2) case x1 :: x2 :: x3 :: x4 :: x5 :: x6 :: Nil if x6 == false => - (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_MONTH, rl.per_month), 429, exceededRateLimit(rl, PER_MONTH))), userAndCallContext._2) + (fullBoxOrException(Empty ~> APIFailureNewStyle(composeMsgAuthorizedAccess(PER_MONTH, rl.per_month, rl.consumer_id), 429, exceededRateLimit(rl, PER_MONTH))), userAndCallContext._2) case _ => // All limits passed - increment counters and set rate limit headers val incrementCounters = List ( From 7f0add6450788989a3572362b4877078eea55f24 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 08:50:48 +0100 Subject: [PATCH 14/23] Fixtests in v6.0.0 System Level Dynamic Entity endpoints with snake_case JSON --- .../code/api/v6_0_0/DynamicEntityTest.scala | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala index 4a8a05cd59..59f401769f 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityTest.scala @@ -26,10 +26,12 @@ TESOBE (http://www.tesobe.com/) package code.api.v6_0_0 import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole import code.api.util.ApiRole._ import code.api.util.ErrorMessages._ import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0 import code.entitlement.Entitlement +import code.scope.Scope import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.util.ApiVersion @@ -169,14 +171,14 @@ class DynamicEntityTest extends V600ServerSetup { feature("v6.0.0 System Level Dynamic Entity endpoints with snake_case JSON") { - scenario("Create System Dynamic Entity - without user credentials", ApiEndpoint1, VersionOfApi) { - When(s"We make a POST request without user credentials") + scenario("Create System Dynamic Entity - without any credentials", ApiEndpoint1, VersionOfApi) { + When(s"We make a POST request without any credentials") val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST val response = makePostRequest(request, write(rightEntityV600)) Then("We should get a 401") response.code should equal(401) - And("error should be " + AuthenticatedUserIsRequired) - response.body.extract[ErrorMessage].message should equal(AuthenticatedUserIsRequired) + And("error should be " + ApplicationNotIdentified) + response.body.extract[ErrorMessage].message should equal(ApplicationNotIdentified) } scenario("Create System Dynamic Entity - without proper role", ApiEndpoint1, VersionOfApi) { @@ -189,6 +191,32 @@ class DynamicEntityTest extends V600ServerSetup { response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) } + scenario("Create System Dynamic Entity with consumer scope (no user entitlement)", ApiEndpoint1, VersionOfApi) { + // Add scope to consumer instead of entitlement to user — UserOrApplication should accept this + val addedScope = Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanCreateSystemLevelDynamicEntity.toString) + + When("We create a dynamic entity using consumer with scope") + val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val response = try { + makePostRequest(request, write(rightEntityV600)) + } finally { + Scope.scope.vend.deleteScope(addedScope) + } + + Then("We should get a 201") + response.code should equal(201) + + And("Response should have snake_case field: entity_name") + (response.body \ "entity_name").extract[String] should equal("foo_bar") + + val dynamicEntityId = (response.body \ "dynamic_entity_id").extract[String] + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + scenario("Create and verify v6.0.0 snake_case response format", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) From 72f9e4b072cbb7cd75f98d12524803691a17dceb Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 09:13:36 +0100 Subject: [PATCH 15/23] Update run_specific_tests.sh --- run_specific_tests.sh | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/run_specific_tests.sh b/run_specific_tests.sh index ca7e7e5c41..ce05948534 100755 --- a/run_specific_tests.sh +++ b/run_specific_tests.sh @@ -37,7 +37,7 @@ # The -Dtest parameter is for surefire plugin and doesn't work with ScalaTest ################################################################################ -set -e +set -eo pipefail ################################################################################ # CONFIGURATION @@ -142,8 +142,11 @@ if [ "$TOTAL_TESTS" -gt 0 ]; then TOTAL_FAILED=$(grep "Tests: succeeded" "${DETAIL_LOG}" | sed -E 's/.*failed ([0-9]+).*/\1/' | awk '{s+=$1} END {print s}') fi -# Determine overall result -if [ ${#FAILED_TEST_NAMES[@]} -gt 0 ]; then +# Determine overall result based on parsed test output +if [ -n "$TOTAL_FAILED" ] && [ "$TOTAL_FAILED" -gt 0 ] 2>/dev/null; then + TEST_RESULT="FAILURE" +elif [ ${#FAILED_TEST_NAMES[@]} -gt 0 ]; then + # Maven failed but no test failures parsed — likely compilation error TEST_RESULT="FAILURE" else TEST_RESULT="SUCCESS" @@ -192,6 +195,28 @@ DURATION_SEC=$((DURATION % 60)) echo " ${SUMMARY_LOG}" } | tee "${SUMMARY_LOG}" +# Update failed_tests.txt with only the tests that still fail +# so the next run doesn't re-run tests that have been fixed +if [ -f "${FAILED_TESTS_FILE}" ]; then + if [ ${#FAILED_TEST_NAMES[@]} -gt 0 ]; then + { + echo "# Failed test classes from last specific run" + echo "# Updated: $(date)" + for failed_test in "${FAILED_TEST_NAMES[@]}"; do + echo "$failed_test" + done + } > "${FAILED_TESTS_FILE}" + echo "Updated ${FAILED_TESTS_FILE} with ${#FAILED_TEST_NAMES[@]} still-failing test(s)" + else + { + echo "# All tests passed on last specific run" + echo "# Updated: $(date)" + echo "# Add test classes here or run ./run_all_tests.sh to repopulate" + } > "${FAILED_TESTS_FILE}" + echo "Cleared ${FAILED_TESTS_FILE} — all tests passed" + fi +fi + echo "" echo "==========================================" echo "Done!" From 8b3685fec52c6a1c659c92abaaa31727bdb6d7a5 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 11:35:01 +0100 Subject: [PATCH 16/23] Using can_bypass_maker_checker_separation and adding can_answer_transaction_request_challenge --- .../scala/code/api/constant/constant.scala | 18 ++++--- .../main/scala/code/api/util/NewStyle.scala | 51 +++++++++++++++++-- .../scala/code/api/v4_0_0/APIMethods400.scala | 20 +++++++- .../bankconnectors/LocalMappedConnector.scala | 2 +- .../MakerCheckerTransactionRequestTest.scala | 8 +-- 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 0366773445..b906ce4bc1 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -379,7 +379,8 @@ object Constant extends MdcLoggable { final val CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY = "can_add_transaction_request_to_beneficiary" final val CAN_GRANT_ACCESS_TO_VIEWS = "can_grant_access_to_views" final val CAN_REVOKE_ACCESS_TO_VIEWS = "can_revoke_access_to_views" - final val CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST = "can_have_same_maker_checker_for_transaction_request" + final val CAN_BYPASS_MAKER_CHECKER_SEPARATION = "can_bypass_maker_checker_separation" + final val CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE = "can_answer_transaction_request_challenge" final val SYSTEM_OWNER_VIEW_PERMISSION_ADMIN = List( CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT, @@ -393,7 +394,8 @@ object Constant extends MdcLoggable { CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GRANT_ACCESS_TO_VIEWS, CAN_REVOKE_ACCESS_TO_VIEWS, - CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + CAN_BYPASS_MAKER_CHECKER_SEPARATION, + CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) final val SYSTEM_MANAGER_VIEW_PERMISSION = List( @@ -403,13 +405,15 @@ object Constant extends MdcLoggable { CAN_DELETE_CUSTOM_VIEW, CAN_UPDATE_CUSTOM_VIEW, CAN_GET_CUSTOM_VIEW, - CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + CAN_BYPASS_MAKER_CHECKER_SEPARATION, + CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) final val SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_PERMISSION = List( CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT, CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, - CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + CAN_BYPASS_MAKER_CHECKER_SEPARATION, + CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) final val SYSTEM_PUBLIC_VIEW_PERMISSION = List( @@ -568,7 +572,8 @@ object Constant extends MdcLoggable { CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS, CAN_SEE_TRANSACTION_STATUS, CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT, - CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + CAN_BYPASS_MAKER_CHECKER_SEPARATION, + CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) final val ALL_VIEW_PERMISSION_NAMES = List( @@ -665,7 +670,8 @@ object Constant extends MdcLoggable { CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GRANT_ACCESS_TO_VIEWS, CAN_REVOKE_ACCESS_TO_VIEWS, - CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST, + CAN_BYPASS_MAKER_CHECKER_SEPARATION, + CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE, ) 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 2f3795ed90..05dbfc2d64 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -41,6 +41,7 @@ import code.users._ import code.util.Helper import code.util.Helper.MdcLoggable import code.validation.{JsonSchemaValidationProvider, JsonValidation} +import code.transactionChallenge.Challenges import code.transactionrequests.TransactionRequests import code.views.Views import code.views.system.ViewPermission @@ -553,12 +554,13 @@ object NewStyle extends MdcLoggable{ accountId: AccountId, viewId: ViewId, transactionRequestId: TransactionRequestId, + challengeId: String, checkerUserId: String, callContext: Option[CallContext] ): Future[Boolean] = { Future { - // Check if the view has the can_have_same_maker_checker_for_transaction_request permission - val permissionName = Constant.CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST + // Check if the view has the can_bypass_maker_checker_separation permission + val permissionName = Constant.CAN_BYPASS_MAKER_CHECKER_SEPARATION val hasPermission = Views.views.vend.customView(viewId, BankIdAccountId(bankId, accountId)) match { case Full(view) => ViewPermission.findViewPermission(view, permissionName).isDefined case _ => Views.views.vend.systemView(viewId) match { @@ -567,7 +569,7 @@ object NewStyle extends MdcLoggable{ } } - // If the view has can_have_same_maker_checker_for_transaction_request, no check needed + // If the view has can_bypass_maker_checker_separation, no check needed if (hasPermission) { Full(true) } else { @@ -581,7 +583,22 @@ object NewStyle extends MdcLoggable{ case None | Some("") => Failure(MakerCheckerUnknownMaker) case Some(makerUserId) if makerUserId == checkerUserId => - Failure(MakerCheckerSameUser) + // Same user as maker — check if this is a multi-challenge scenario + // where the user is answering their own assigned SCA challenge + val challenges = Challenges.ChallengeProvider.vend + .getChallengesByTransactionRequestId(transactionRequestId.value) + challenges match { + case Full(challengeList) if challengeList.size > 1 => + // Multiple challenges: allow if this specific challenge is assigned to the checker + val isOwnChallenge = challengeList.exists(c => + c.challengeId == challengeId && c.expectedUserId == checkerUserId + ) + if (isOwnChallenge) Full(true) + else Failure(MakerCheckerSameUser) + case _ => + // Single challenge or no challenges found: block same user + Failure(MakerCheckerSameUser) + } case _ => tr.on_behalf_of_user_id match { case Some(onBehalfOfUserId) if onBehalfOfUserId.nonEmpty && onBehalfOfUserId == checkerUserId => @@ -1322,7 +1339,7 @@ object NewStyle extends MdcLoggable{ } } - def validateChallengeAnswer(challengeId: String, suppliedAnswer: String, suppliedAnswerType:SuppliedAnswerType.Value, callContext: Option[CallContext]): OBPReturnType[Boolean] = + def validateChallengeAnswer(challengeId: String, suppliedAnswer: String, suppliedAnswerType:SuppliedAnswerType.Value, callContext: Option[CallContext]): OBPReturnType[Boolean] = Connector.connector.vend.validateChallengeAnswerV2(challengeId, suppliedAnswer, suppliedAnswerType, callContext) map { i => (unboxFullOrFail(i._1, callContext, s"${ InvalidChallengeAnswer @@ -1331,6 +1348,30 @@ object NewStyle extends MdcLoggable{ }"), i._2) } + /** + * Validate a challenge answer without checking the userId. + * Used when a checker (different user) answers a challenge that was assigned to the maker, + * in the single-challenge maker-checker scenario. The maker-checker check has already + * verified this user is allowed to answer. + */ + def validateChallengeAnswerWithoutUserIdCheck( + challengeId: String, + suppliedAnswer: String, + suppliedAnswerType: SuppliedAnswerType.Value, + callContext: Option[CallContext] + ): OBPReturnType[Boolean] = { + Future { + val result = Challenges.ChallengeProvider.vend.validateChallenge(challengeId, suppliedAnswer, None) + (Full(result.isDefined), callContext) + } map { i => + (unboxFullOrFail(i._1, callContext, s"${ + InvalidChallengeAnswer + .replace("answer may be expired.", s"answer may be expired (${transactionRequestChallengeTtl} seconds).") + .replace("up your allowed attempts.", s"up your allowed attempts (${allowedAnswerTransactionRequestChallengeAttempts} times).") + }"), i._2) + } + } + def allChallengesSuccessfullyAnswered( bankId: BankId, accountId: AccountId, diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 417bf1cfc9..fff39560bf 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -1463,6 +1463,7 @@ trait APIMethods400 extends MdcLoggable { accountId, viewId, transReqId, + challengeAnswerJson.id, u.userId, callContext ) @@ -1594,15 +1595,30 @@ trait APIMethods400 extends MdcLoggable { ) } yield (transactionRequest, callContext) case _ => + // Determine if the current user is answering their own assigned challenge + val challengeToAnswer = challenges.find(_.challengeId == challengeAnswerJson.id) + val isAnsweringOwnChallenge = challengeToAnswer.exists(_.expectedUserId == u.userId) + for { - (challengeAnswerIsValidated, callContext) <- NewStyle.function - .validateChallengeAnswer( + (challengeAnswerIsValidated, callContext) <- if (isAnsweringOwnChallenge) { + // User is answering their own challenge — validate with userId check + NewStyle.function.validateChallengeAnswer( + challengeAnswerJson.id, + challengeAnswerJson.answer, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + callContext + ) + } else { + // User is answering someone else's challenge (checker answering maker's challenge) + // Safe because maker-checker check already approved this user + NewStyle.function.validateChallengeAnswerWithoutUserIdCheck( challengeAnswerJson.id, challengeAnswerJson.answer, SuppliedAnswerType.PLAIN_TEXT_VALUE, callContext ) + } _ <- Helper.booleanToFuture( s"${InvalidChallengeAnswer diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 90efbea8c9..14405fddeb 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -4926,7 +4926,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { for ( permission <- Views.views.vend.permissions(BankIdAccountId(bankId, accountId)) ) yield { - permission.views.exists(view =>view.view.allowed_actions.exists( _ == CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT)) + permission.views.exists(view =>view.view.allowed_actions.exists( _ == CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE)) match { case true => Some(permission.user) case _ => None diff --git a/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala index baf168f318..3eba9bf842 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/MakerCheckerTransactionRequestTest.scala @@ -26,12 +26,12 @@ class MakerCheckerTransactionRequestTest extends V400ServerSetup with DefaultUse object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.answerTransactionRequestChallenge)) /** - * Helper to remove the can_have_same_maker_checker_for_transaction_request permission + * Helper to remove the can_bypass_maker_checker_separation permission * from the owner system view, forcing maker != checker. */ def removeMakerCheckerPermissionFromOwnerView(): Unit = { val viewId = ViewId(SYSTEM_OWNER_VIEW_ID) - ViewPermission.findSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST) + ViewPermission.findSystemViewPermission(viewId, CAN_BYPASS_MAKER_CHECKER_SEPARATION) .foreach(_.delete_!) } @@ -41,8 +41,8 @@ class MakerCheckerTransactionRequestTest extends V400ServerSetup with DefaultUse def addMakerCheckerPermissionToOwnerView(): Unit = { val viewId = ViewId(SYSTEM_OWNER_VIEW_ID) // Only add if not already present - if (ViewPermission.findSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST).isEmpty) { - ViewPermission.createSystemViewPermission(viewId, CAN_HAVE_SAME_MAKER_CHECKER_FOR_TRANSACTION_REQUEST, None) + if (ViewPermission.findSystemViewPermission(viewId, CAN_BYPASS_MAKER_CHECKER_SEPARATION).isEmpty) { + ViewPermission.createSystemViewPermission(viewId, CAN_BYPASS_MAKER_CHECKER_SEPARATION, None) } } From 4baeeb05ee9af3a66501a951bb6e50cc697918ac Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 13:21:32 +0100 Subject: [PATCH 17/23] Create CLAUDE.md --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..3edee67170 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# Project Instructions + +## Working Style +- Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. From 2c1486bf8319e263a4427caa4a2fd349b735506b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 13:38:04 +0100 Subject: [PATCH 18/23] Update RestConnector_vMar2019_frozen_meta_data --- .../RestConnector_vMar2019_frozen_meta_data | Bin 124207 -> 124216 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data index 8a1d0bb4d61e66f7e8f3c46c674a86ea6bac3add..8d546f7e32408ed146d82fc64d631c64ae718bf9 100644 GIT binary patch delta 256 zcmZ2~ihai^_6;wpCr=9&o_z9#`)2!^9lDH)lM`!XCofxMzS(EhJ#I$D>9wMaiJN=2 z?GXmD@1-zGPH$NbROE0{eDeETp6TKbfx-=bj6Iu!Pp9#MjM{wiMx+?y{LRYmFS0St zpM3CV)#m(ffy_XWgFkmmGb(Ne8hF4UMmZM7`P+f^ z=mMPrv2Qm&qwM5@(;}1q3UF+Xo5XlrmTP_q16y8cZc=K|WXEYD(<5dAUG!i!&-Q{1 qjJx=NmKIL7pL`&ZeY$`YBim$c3Bm0h`x)PeKs_q3eaaQa*#ZDiM`+Ig delta 280 zcmdmSihcbl_6;wpSqmBJn2k4osOHjRRG4mfl~H!`%0=dz{a4-NW>na$w@p?AD4dwU zC^@}rIiup_1$x|*{Z8^sexJ*;dF3f#K8W_oAF6|gr00^7C37|pyG6}AIi)x-ic6znfl6ZnsNiysQIt{BrF{jB Date: Sun, 8 Mar 2026 20:22:03 +0100 Subject: [PATCH 19/23] docfix: Info about the metadata view. + getCustomViews v6.0.0 uses v6.0.0 JSON --- .../src/main/scala/code/api/v3_0_0/APIMethods300.scala | 2 ++ .../src/main/scala/code/api/v3_1_0/APIMethods310.scala | 2 ++ .../src/main/scala/code/api/v5_0_0/APIMethods500.scala | 2 ++ .../src/main/scala/code/api/v5_1_0/APIMethods510.scala | 2 ++ .../src/main/scala/code/api/v6_0_0/APIMethods600.scala | 8 ++++++-- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala index ff8ab29da2..e6f54fa3b1 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala @@ -182,6 +182,8 @@ trait APIMethods300 { | | The 'allowed_actions' field is a list containing the name of the actions allowed on this view, all the actions contained will be set to `true` on the view creation, the rest will be set to `false`. | + | The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | | You MUST use a leading _ (underscore) in the view name because other view names are reserved for OBP [system views](/index#group-View-System). | """, SwaggerDefinitionsJSON.createViewJsonV300, diff --git a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala index b92165a67d..ec49c0c5ed 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/APIMethods310.scala @@ -3974,6 +3974,8 @@ trait APIMethods310 { | | The 'allowed_actions' field is a list containing the name of the actions allowed on this view, all the actions contained will be set to `true` on the view creation, the rest will be set to `false`. | + | The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | | Please note that system views cannot be public. In case you try to set it you will get the error $SystemViewCannotBePublicError | """, SwaggerDefinitionsJSON.createSystemViewJsonV300, diff --git a/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala b/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala index 66011f2313..95d9293b26 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala @@ -2121,6 +2121,8 @@ trait APIMethods500 { | | The 'hide_metadata_if_alias_used' field in the JSON can take boolean values. If it is set to `true` and there is an alias on the other account then the other accounts' metadata (like more_info, url, image_url, open_corporates_url, etc.) will be hidden. Otherwise the metadata will be shown. | + | The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | | System views cannot be public. In case you try to set it you will get the error $SystemViewCannotBePublicError | """, createSystemViewJsonV500, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala index 9bd570c06a..52a3977642 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/APIMethods510.scala @@ -5033,6 +5033,8 @@ trait APIMethods510 { | | The 'allowed_actions' field is a list containing the name of the actions allowed on this view, all the actions contained will be set to `true` on the view creation, the rest will be set to `false`. | + | The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | | You MUST use a leading _ (underscore) in the view name because other view names are reserved for OBP [system views](/index#group-View-System). | """, createCustomViewJson, 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 18f221a272..36804d9a22 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 @@ -5170,6 +5170,8 @@ trait APIMethods600 { |The JSON sent is the same as during view creation, with one difference: the 'name' field |of a view is not editable (it is only set when a view is created). | + |The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | |The response contains the updated view with an `allowed_actions` array. | |""".stripMargin, @@ -5343,6 +5345,8 @@ trait APIMethods600 { | | The 'allowed_actions' field is a list containing the name of the actions allowed on this view, all the actions contained will be set to `true` on the view creation, the rest will be set to `false`. | + | The 'metadata_view' field determines where metadata (comments, tags, images, where tags) for transactions are stored and retrieved. If set to another view's ID (e.g. 'owner'), metadata added through this view will be shared with all other views that also use the same metadata_view value. If left empty, metadata is stored under this view's own ID and is not shared with other views. + | | You MUST use a leading _ (underscore) in the view name because other view names are reserved for OBP [system views](/index#group-View-System). | |""".stripMargin, @@ -5398,7 +5402,7 @@ trait APIMethods600 { | |""".stripMargin, EmptyBody, - ViewsJsonV500(List()), + ViewsJsonV600(List()), List( AuthenticatedUserIsRequired, UserHasMissingRoles, @@ -5415,7 +5419,7 @@ trait APIMethods600 { (Full(u), callContext) <- authenticatedAccess(cc) customViews <- Future { ViewDefinition.getCustomViews() } } yield { - (JSONFactory500.createViewsJsonV500(customViews), HttpCode.`200`(callContext)) + (JSONFactory600.createViewsJsonV600(customViews), HttpCode.`200`(callContext)) } } } From 154c87f9516bc248b27f1f901630425e1c9e95a8 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 8 Mar 2026 23:24:34 +0100 Subject: [PATCH 20/23] ViewJsonV600 add bank_id, account_id --- .../scala/code/api/v6_0_0/APIMethods600.scala | 72 +++++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 4 ++ 2 files changed, 76 insertions(+) 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 36804d9a22..1a9fa63e01 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 @@ -4997,6 +4997,8 @@ trait APIMethods600 { EmptyBody, ViewsJsonV600(List( ViewJsonV600( + bank_id = "", + account_id = "", view_id = "owner", short_name = "Owner", description = "The owner of the account", @@ -5054,6 +5056,8 @@ trait APIMethods600 { |""".stripMargin, EmptyBody, ViewJsonV600( + bank_id = "", + account_id = "", view_id = "owner", short_name = "Owner", description = "The owner of the account. Has full privileges.", @@ -5191,6 +5195,8 @@ trait APIMethods600 { can_revoke_access_to_views = Some(List("owner", "accountant")) ), ViewJsonV600( + bank_id = "", + account_id = "", view_id = "owner", short_name = "Owner", description = "This is the owner view", @@ -5424,6 +5430,70 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getCustomViewById, + implementedInApiVersion, + nameOf(getCustomViewById), + "GET", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID", + "Get Custom View", + s"""Get a single custom view by bank, account, and view ID. + | + |Custom views are user-created views with names starting with underscore (_), such as: + |- _work + |- _personal + |- _audit + | + |Custom views are unique per bank_id, account_id, and view_id combination. + | + |The view is returned with an `allowed_actions` array containing all permissions for that view. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + ViewJsonV600( + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = "_work", + short_name = "Work", + description = "A custom view for work-related transactions.", + metadata_view = "_work", + is_public = false, + is_system = false, + is_firehose = Some(false), + alias = "private", + hide_metadata_if_alias_used = false, + can_grant_access_to_views = List("_work"), + can_revoke_access_to_views = List("_work"), + allowed_actions = List( + "can_see_transaction_amount", + "can_see_bank_account_balance", + "can_add_comment" + ) + ), + List( + AuthenticatedUserIsRequired, + UserHasMissingRoles, + ViewNotFound, + UnknownError + ), + List(apiTagView, apiTagSystemView), + Some(List(canGetCustomViews)) + ) + + lazy val getCustomViewById: OBPEndpoint = { + case "management" :: "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "views" :: viewId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + view <- ViewNewStyle.customView(ViewId(viewId), BankIdAccountId(bankId, accountId), callContext) + } yield { + (JSONFactory600.createViewJsonV600(view), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( resetPasswordUrl, implementedInApiVersion, @@ -11225,6 +11295,8 @@ trait APIMethods600 { product_code = ExampleValue.productCodeExample.value, balance = amountOfMoneyJsonV121, views_available = List(ViewJsonV600( + bank_id = "", + account_id = "", view_id = "owner", short_name = "Owner", description = "The owner of the account", 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 2eab8090be..91ceb90ab0 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 @@ -1646,6 +1646,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) case class ViewJsonV600( + bank_id: String, + account_id: String, view_id: String, short_name: String, description: String, @@ -1698,6 +1700,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { "" ViewJsonV600( + bank_id = view.bankId.value, + account_id = view.accountId.value, view_id = view.viewId.value, short_name = view.name, description = view.description, From a33d35eef3b22834388256f38245863c30d89574 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 9 Mar 2026 00:01:49 +0100 Subject: [PATCH 21/23] Mandates --- .../main/scala/bootstrap/liftweb/Boot.scala | 3 + .../main/scala/code/api/util/ApiRole.scala | 30 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../main/scala/code/api/util/Glossary.scala | 66 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 900 ++++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 198 ++++ .../scala/code/bankconnectors/Connector.scala | 132 +++ .../bankconnectors/LocalMappedConnector.scala | 180 +++- .../scala/code/mandate/MandateTrait.scala | 493 ++++++++++ 9 files changed, 2002 insertions(+), 1 deletion(-) create mode 100644 obp-api/src/main/scala/code/mandate/MandateTrait.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index bac7995200..f871b46299 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -1014,6 +1014,9 @@ object ToSchemify { MappedRegulatedEntity, AtmAttribute, AbacRule, + code.mandate.Mandate, + code.mandate.MandateProvision, + code.mandate.SignatoryPanel, MappedBank, MappedBankAccount, BankAccountRouting, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 20d78fe410..753f744d9b 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -774,6 +774,36 @@ object ApiRole extends MdcLoggable{ case class CanExecuteAbacRule(requiresBankId: Boolean = false) extends ApiRole lazy val canExecuteAbacRule = CanExecuteAbacRule() + // Mandate roles (bank-level) + case class CanCreateMandate(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateMandate = CanCreateMandate() + case class CanGetMandate(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetMandate = CanGetMandate() + case class CanUpdateMandate(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateMandate = CanUpdateMandate() + case class CanDeleteMandate(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteMandate = CanDeleteMandate() + + // Mandate Provision roles (bank-level) + case class CanCreateMandateProvision(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateMandateProvision = CanCreateMandateProvision() + case class CanGetMandateProvision(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetMandateProvision = CanGetMandateProvision() + case class CanUpdateMandateProvision(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateMandateProvision = CanUpdateMandateProvision() + case class CanDeleteMandateProvision(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteMandateProvision = CanDeleteMandateProvision() + + // Signatory Panel roles (bank-level) + case class CanCreateSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateSignatoryPanel = CanCreateSignatoryPanel() + case class CanGetSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetSignatoryPanel = CanGetSignatoryPanel() + case class CanUpdateSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateSignatoryPanel = CanUpdateSignatoryPanel() + case class CanDeleteSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteSignatoryPanel = CanDeleteSignatoryPanel() + case class CanGetSystemLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole lazy val canGetSystemLevelDynamicEntities = CanGetSystemLevelDynamicEntities() 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 2bcd2b91b3..160cbeaf30 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -45,6 +45,7 @@ object ApiTag { val apiTagEntitlement = ResourceDocTag("Entitlement") val apiTagRole = ResourceDocTag("Role") val apiTagABAC = ResourceDocTag("ABAC") + val apiTagMandate = ResourceDocTag("Mandate") val apiTagScope = ResourceDocTag("Scope") val apiTagOwnerRequired = ResourceDocTag("OwnerViewRequired") val apiTagCounterparty = ResourceDocTag("Counterparty") diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index c6a06d2623..1df5f7ff08 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5254,6 +5254,72 @@ object Glossary extends MdcLoggable { | """) + glossaryItems += GlossaryItem( + title = "Mandates", + description = + s""" + |# Mandates + | + |## Overview + | + |A Mandate is a formal agreement between a corporate customer and a bank that defines who can operate an account, what they can do, and under what conditions. + | + |In OBP, a Mandate is an entity that ties together existing authorisation constructs (Views, ABAC Rules, Challenges) into a single, auditable policy document. + | + |## Structure + | + |A Mandate has three parts: + | + |### 1. Mandate + | + |The top-level container. It is linked to a bank account and a corporate customer, and holds the legal text, status (ACTIVE, SUSPENDED, EXPIRED, DRAFT), and validity period. + | + |### 2. Mandate Provisions + | + |Each provision maps a clause of the mandate to an OBP enforcement mechanism. Provision types: + | + |- **SIGNATORY_RULE** — defines who can sign and in what combination (e.g., "2 from Panel A" or "1 from Panel A and 1 from Panel B") + |- **VIEW_ASSIGNMENT** — links a Signatory Panel to a View, controlling what members of that panel can see and do + |- **ABAC_CONDITION** — links to an ABAC rule for attribute-based conditions (e.g., department matching, amount limits) + |- **RESTRICTION** — a negative rule that blocks certain operations (e.g., no international payments) + |- **NOTIFICATION** — triggers a notification rather than blocking (e.g., alert CFO for payments over a threshold) + | + |Provisions can specify conditions (e.g., amount thresholds, currency), link to a View, an ABAC Rule, and/or a Challenge type. + | + |### 3. Signatory Panels + | + |A Signatory Panel is a named set of users who are authorised to act under the mandate. For example: + | + |- Panel A: Directors (user-1, user-2, user-3) + |- Panel B: Finance team (user-4, user-5) + | + |Provisions reference panels by ID and specify how many signatories are required from each panel. + | + |## How it connects to existing OBP features + | + |- **Views** control what each panel member can see and do on the account (e.g., canSeeTransactionAmount, canAddTransactionRequestToBeneficiary) + |- **ABAC Rules** provide attribute-based conditions evaluated at runtime (e.g., user department must match account business unit) + |- **Challenges / Maker-Checker** enforce signatory requirements. A provision can require multiple challenges answered by different users from specified panels + |- **Corporate Customers** (CORPORATE / SUBSIDIARY types with parent-child hierarchy) represent the legal entities that mandates apply to + | + |## Example + | + |ACME Corp has a mandate on their operating account: + | + |1. Panel A (Directors): user-1, user-2, user-3 + |2. Panel B (Finance): user-4, user-5 + |3. Provision: payments < 5,000 EUR require 1 signature from Panel A + |4. Provision: payments 5,000-50,000 EUR require 2 signatures from Panel A + |5. Provision: payments > 50,000 EUR require 1 from Panel A and 1 from Panel B + | + |## API Endpoints + | + |Mandates, Provisions, and Signatory Panels each have CRUD endpoints under the Mandate tag. + | + |All endpoints require bank-level roles (e.g., CanCreateMandate, CanGetMandateProvision, CanUpdateSignatoryPanel). + | +""") + /////////////////////////////////////////////////////////////////// // NOTE! Some glossary items are generated in ExampleValue.scala ////////////////////////////////////////////////////////////////// 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 1a9fa63e01..024c611a95 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 @@ -34,6 +34,8 @@ import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, CleanupOr import code.metadata.tags.Tags import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} +import code.mandate.{MappedMandateProvider} +import code.api.v6_0_0.JSONFactory600.{createMandateJsonV600, createMandatesJsonV600, createMandateProvisionJsonV600, createMandateProvisionsJsonV600, createSignatoryPanelJsonV600, createSignatoryPanelsJsonV600} import code.metrics.{APIMetrics, ConnectorCountsRedis, ConnectorTraceProvider} import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.bankconnectors.storedprocedure.StoredProcedureUtils @@ -11368,6 +11370,904 @@ trait APIMethods600 { } + // ========== Mandate Endpoints ========== + + staticResourceDocs += ResourceDoc( + createMandate, + implementedInApiVersion, + nameOf(createMandate), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates", + "Create Mandate", + s"""Create a new mandate for a bank account. + | + |A mandate is a legal document that defines who can operate an account, what they can do, + |and under what conditions (e.g., signatory requirements, amount thresholds). + | + |Mandates tie together OBP constructs such as Views, ABAC Rules, Signatory Panels, + |and Challenges into a coherent authorization policy. + | + |**Status values:** ACTIVE, SUSPENDED, EXPIRED, DRAFT + | + |**Date format:** yyyy-MM-dd'T'HH:mm:ss'Z' (UTC) + | + |Authentication is Required + |""", + CreateMandateJsonV600( + customer_id = "customer-id-123", + mandate_name = "ACME Corp Operating Account Authority", + mandate_reference = "MND-2026-00042", + legal_text = "The following persons are authorised to operate this account...", + description = "Payment and account access authority for ACME Corp", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z" + ), + MandateJsonV600( + mandate_id = "mandate-id-123", + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02", + customer_id = "customer-id-123", + mandate_name = "ACME Corp Operating Account Authority", + mandate_reference = "MND-2026-00042", + legal_text = "The following persons are authorised to operate this account...", + description = "Payment and account access authority for ACME Corp", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z", + created_by_user_id = "user-id-123", + updated_by_user_id = "user-id-123" + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canCreateMandate)) + ) + + lazy val createMandate: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "mandates" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateMandateJsonV600] + } + validFrom <- NewStyle.function.tryons(s"$InvalidDateFormat valid_from must be in yyyy-MM-dd'T'HH:mm:ss'Z' format", 400, cc.callContext) { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(createJson.valid_from) + } + validTo <- NewStyle.function.tryons(s"$InvalidDateFormat valid_to must be in yyyy-MM-dd'T'HH:mm:ss'Z' format", 400, cc.callContext) { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(createJson.valid_to) + } + (mandate, callContext) <- Connector.connector.vend.createMandate( + bankId, + accountId, + createJson.customer_id, + createJson.mandate_name, + createJson.mandate_reference, + createJson.legal_text, + createJson.description, + createJson.status, + validFrom, + validTo, + cc.userId, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not create mandate"), i._2) + } + } yield { + (createMandateJsonV600(mandate), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getMandates, + implementedInApiVersion, + nameOf(getMandates), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates", + "Get Mandates for Account", + s"""Get all mandates for a bank account. + | + |Authentication is Required + |""", + EmptyBody, + MandatesJsonV600(List(MandateJsonV600( + mandate_id = "mandate-id-123", + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02", + customer_id = "customer-id-123", + mandate_name = "ACME Corp Operating Account Authority", + mandate_reference = "MND-2026-00042", + legal_text = "The following persons are authorised...", + description = "Payment authority for ACME Corp", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z", + created_by_user_id = "user-id-123", + updated_by_user_id = "user-id-123" + ))), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetMandate)) + ) + + lazy val getMandates: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "mandates" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (mandates, callContext) <- Connector.connector.vend.getMandatesByBankAndAccount( + bankId, accountId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not get mandates"), i._2) + } + } yield { + (createMandatesJsonV600(mandates), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getMandate, + implementedInApiVersion, + nameOf(getMandate), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", + "Get Mandate", + s"""Get a mandate by its ID. + | + |Authentication is Required + |""", + EmptyBody, + MandateJsonV600( + mandate_id = "mandate-id-123", + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02", + customer_id = "customer-id-123", + mandate_name = "ACME Corp Operating Account Authority", + mandate_reference = "MND-2026-00042", + legal_text = "The following persons are authorised...", + description = "Payment authority for ACME Corp", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z", + created_by_user_id = "user-id-123", + updated_by_user_id = "user-id-123" + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetMandate)) + ) + + lazy val getMandate: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "mandates" :: mandateId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (mandate, callContext) <- Connector.connector.vend.getMandateById( + mandateId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Mandate not found. Mandate ID: $mandateId", 404), i._2) + } + } yield { + (createMandateJsonV600(mandate), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateMandate, + implementedInApiVersion, + nameOf(updateMandate), + "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", + "Update Mandate", + s"""Update a mandate. + | + |Authentication is Required + |""", + UpdateMandateJsonV600( + mandate_name = "Updated Mandate Name", + mandate_reference = "MND-2026-00042", + legal_text = "Updated legal text...", + description = "Updated description", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z" + ), + MandateJsonV600( + mandate_id = "mandate-id-123", + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02", + customer_id = "customer-id-123", + mandate_name = "Updated Mandate Name", + mandate_reference = "MND-2026-00042", + legal_text = "Updated legal text...", + description = "Updated description", + status = "ACTIVE", + valid_from = "2026-01-01T00:00:00Z", + valid_to = "2027-01-01T00:00:00Z", + created_by_user_id = "user-id-123", + updated_by_user_id = "user-id-456" + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canUpdateMandate)) + ) + + lazy val updateMandate: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "mandates" :: mandateId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateMandateJsonV600] + } + validFrom <- NewStyle.function.tryons(s"$InvalidDateFormat valid_from must be in yyyy-MM-dd'T'HH:mm:ss'Z' format", 400, cc.callContext) { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(updateJson.valid_from) + } + validTo <- NewStyle.function.tryons(s"$InvalidDateFormat valid_to must be in yyyy-MM-dd'T'HH:mm:ss'Z' format", 400, cc.callContext) { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(updateJson.valid_to) + } + (mandate, callContext) <- Connector.connector.vend.updateMandate( + mandateId, + updateJson.mandate_name, + updateJson.mandate_reference, + updateJson.legal_text, + updateJson.description, + updateJson.status, + validFrom, + validTo, + cc.userId, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not update mandate. Mandate ID: $mandateId"), i._2) + } + } yield { + (createMandateJsonV600(mandate), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteMandate, + implementedInApiVersion, + nameOf(deleteMandate), + "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", + "Delete Mandate", + s"""Delete a mandate and all its provisions and signatory panels. + | + |Authentication is Required + |""", + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canDeleteMandate)) + ) + + lazy val deleteMandate: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "mandates" :: mandateId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (deleted, callContext) <- Connector.connector.vend.deleteMandate( + mandateId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not delete mandate. Mandate ID: $mandateId"), i._2) + } + } yield { + (deleted, HttpCode.`204`(callContext)) + } + } + } + + // ========== Mandate Provision Endpoints ========== + + staticResourceDocs += ResourceDoc( + createMandateProvision, + implementedInApiVersion, + nameOf(createMandateProvision), + "POST", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions", + "Create Mandate Provision", + s"""Create a new provision for a mandate. + | + |A provision links the mandate's legal clauses to OBP enforcement mechanisms + |(Views, ABAC Rules, Challenges). + | + |**Provision types:** + |- SIGNATORY_RULE — Who can sign and in what combination + |- VIEW_ASSIGNMENT — Which view a signatory panel gets on the account + |- ABAC_CONDITION — Links to an ABAC rule for attribute-based conditions + |- RESTRICTION — Negative rule (e.g., no international payments) + |- NOTIFICATION — Triggers notification rather than blocking + | + |Authentication is Required + |""", + CreateMandateProvisionJsonV600( + provision_name = "Payments under 5000", + provision_description = "Any single Director may authorise payments below EUR 5,000", + legal_reference = "Clause 3.1(a)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 5000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 1)), + linked_view_id = Some("PaymentInitiator"), + linked_abac_rule_id = None, + linked_challenge_type = Some("OBP_TRANSACTION_REQUEST_CHALLENGE"), + is_active = true, + sort_order = 1 + ), + MandateProvisionJsonV600( + provision_id = "provision-id-123", + mandate_id = "mandate-id-123", + provision_name = "Payments under 5000", + provision_description = "Any single Director may authorise payments below EUR 5,000", + legal_reference = "Clause 3.1(a)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 5000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 1)), + linked_view_id = "PaymentInitiator", + linked_abac_rule_id = "", + linked_challenge_type = "OBP_TRANSACTION_REQUEST_CHALLENGE", + is_active = true, + sort_order = 1 + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canCreateMandateProvision)) + ) + + lazy val createMandateProvision: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "provisions" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateMandateProvisionJsonV600] + } + sigReqJson <- Future { + import net.liftweb.json._ + implicit val formats: Formats = DefaultFormats + net.liftweb.json.Serialization.write(createJson.signatory_requirements) + } + (provision, callContext) <- Connector.connector.vend.createMandateProvision( + mandateId, + createJson.provision_name, + createJson.provision_description, + createJson.legal_reference, + createJson.provision_type, + createJson.conditions, + sigReqJson, + createJson.linked_view_id.getOrElse(""), + createJson.linked_abac_rule_id.getOrElse(""), + createJson.linked_challenge_type.getOrElse(""), + createJson.is_active, + createJson.sort_order, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not create mandate provision"), i._2) + } + } yield { + (createMandateProvisionJsonV600(provision), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getMandateProvisions, + implementedInApiVersion, + nameOf(getMandateProvisions), + "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions", + "Get Mandate Provisions", + s"""Get all provisions for a mandate. + | + |Authentication is Required + |""", + EmptyBody, + MandateProvisionsJsonV600(List(MandateProvisionJsonV600( + provision_id = "provision-id-123", + mandate_id = "mandate-id-123", + provision_name = "Payments under 5000", + provision_description = "Any single Director may authorise payments below EUR 5,000", + legal_reference = "Clause 3.1(a)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 5000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 1)), + linked_view_id = "PaymentInitiator", + linked_abac_rule_id = "", + linked_challenge_type = "OBP_TRANSACTION_REQUEST_CHALLENGE", + is_active = true, + sort_order = 1 + ))), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetMandateProvision)) + ) + + lazy val getMandateProvisions: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "provisions" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (provisions, callContext) <- Connector.connector.vend.getMandateProvisionsByMandateId( + mandateId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not get provisions for mandate: $mandateId"), i._2) + } + } yield { + (createMandateProvisionsJsonV600(provisions), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getMandateProvision, + implementedInApiVersion, + nameOf(getMandateProvision), + "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", + "Get Mandate Provision", + s"""Get a specific provision by its ID. + | + |Authentication is Required + |""", + EmptyBody, + MandateProvisionJsonV600( + provision_id = "provision-id-123", + mandate_id = "mandate-id-123", + provision_name = "Payments under 5000", + provision_description = "Any single Director may authorise payments below EUR 5,000", + legal_reference = "Clause 3.1(a)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 5000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 1)), + linked_view_id = "PaymentInitiator", + linked_abac_rule_id = "", + linked_challenge_type = "OBP_TRANSACTION_REQUEST_CHALLENGE", + is_active = true, + sort_order = 1 + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetMandateProvision)) + ) + + lazy val getMandateProvision: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "provisions" :: provisionId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (provision, callContext) <- Connector.connector.vend.getMandateProvisionById( + provisionId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Mandate provision not found. Provision ID: $provisionId", 404), i._2) + } + } yield { + (createMandateProvisionJsonV600(provision), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateMandateProvision, + implementedInApiVersion, + nameOf(updateMandateProvision), + "PUT", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", + "Update Mandate Provision", + s"""Update a mandate provision. + | + |Authentication is Required + |""", + UpdateMandateProvisionJsonV600( + provision_name = "Updated provision", + provision_description = "Updated description", + legal_reference = "Clause 3.1(b)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 50000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 2)), + linked_view_id = Some("PaymentInitiator"), + linked_abac_rule_id = None, + linked_challenge_type = Some("OBP_TRANSACTION_REQUEST_CHALLENGE"), + is_active = true, + sort_order = 2 + ), + MandateProvisionJsonV600( + provision_id = "provision-id-123", + mandate_id = "mandate-id-123", + provision_name = "Updated provision", + provision_description = "Updated description", + legal_reference = "Clause 3.1(b)", + provision_type = "SIGNATORY_RULE", + conditions = """{"currency": "EUR", "amount_below": 50000.00}""", + signatory_requirements = List(SignatoryRequirementJsonV600(panel_id = "panel-id-001", required_count = 2)), + linked_view_id = "PaymentInitiator", + linked_abac_rule_id = "", + linked_challenge_type = "OBP_TRANSACTION_REQUEST_CHALLENGE", + is_active = true, + sort_order = 2 + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canUpdateMandateProvision)) + ) + + lazy val updateMandateProvision: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "provisions" :: provisionId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateMandateProvisionJsonV600] + } + sigReqJson <- Future { + import net.liftweb.json._ + implicit val formats: Formats = DefaultFormats + net.liftweb.json.Serialization.write(updateJson.signatory_requirements) + } + (provision, callContext) <- Connector.connector.vend.updateMandateProvision( + provisionId, + updateJson.provision_name, + updateJson.provision_description, + updateJson.legal_reference, + updateJson.provision_type, + updateJson.conditions, + sigReqJson, + updateJson.linked_view_id.getOrElse(""), + updateJson.linked_abac_rule_id.getOrElse(""), + updateJson.linked_challenge_type.getOrElse(""), + updateJson.is_active, + updateJson.sort_order, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not update provision. Provision ID: $provisionId"), i._2) + } + } yield { + (createMandateProvisionJsonV600(provision), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteMandateProvision, + implementedInApiVersion, + nameOf(deleteMandateProvision), + "DELETE", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", + "Delete Mandate Provision", + s"""Delete a mandate provision. + | + |Authentication is Required + |""", + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canDeleteMandateProvision)) + ) + + lazy val deleteMandateProvision: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "provisions" :: provisionId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (deleted, callContext) <- Connector.connector.vend.deleteMandateProvision( + provisionId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not delete provision. Provision ID: $provisionId"), i._2) + } + } yield { + (deleted, HttpCode.`204`(callContext)) + } + } + } + + // ========== Signatory Panel Endpoints ========== + + staticResourceDocs += ResourceDoc( + createSignatoryPanel, + implementedInApiVersion, + nameOf(createSignatoryPanel), + "POST", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels", + "Create Signatory Panel", + s"""Create a new signatory panel for a mandate. + | + |A signatory panel is a named set of authorised signatories (users) that can be + |referenced by mandate provisions. For example, "Panel A - Directors" and "Panel B - Finance". + | + |Provision rules then reference panels, e.g., "1 from Panel A and 1 from Panel B". + | + |Authentication is Required + |""", + CreateSignatoryPanelJsonV600( + panel_name = "Panel A - Directors", + description = "Board directors authorised to sign", + user_ids = List("user-id-1", "user-id-2", "user-id-3") + ), + SignatoryPanelJsonV600( + panel_id = "panel-id-001", + mandate_id = "mandate-id-123", + panel_name = "Panel A - Directors", + description = "Board directors authorised to sign", + user_ids = List("user-id-1", "user-id-2", "user-id-3") + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canCreateSignatoryPanel)) + ) + + lazy val createSignatoryPanel: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "signatory-panels" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[CreateSignatoryPanelJsonV600] + } + userIdsStr = createJson.user_ids.mkString(",") + (panel, callContext) <- Connector.connector.vend.createSignatoryPanel( + mandateId, + createJson.panel_name, + createJson.description, + userIdsStr, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not create signatory panel"), i._2) + } + } yield { + (createSignatoryPanelJsonV600(panel), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getSignatoryPanels, + implementedInApiVersion, + nameOf(getSignatoryPanels), + "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels", + "Get Signatory Panels", + s"""Get all signatory panels for a mandate. + | + |Authentication is Required + |""", + EmptyBody, + SignatoryPanelsJsonV600(List(SignatoryPanelJsonV600( + panel_id = "panel-id-001", + mandate_id = "mandate-id-123", + panel_name = "Panel A - Directors", + description = "Board directors authorised to sign", + user_ids = List("user-id-1", "user-id-2", "user-id-3") + ))), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetSignatoryPanel)) + ) + + lazy val getSignatoryPanels: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "signatory-panels" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (panels, callContext) <- Connector.connector.vend.getSignatoryPanelsByMandateId( + mandateId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not get signatory panels for mandate: $mandateId"), i._2) + } + } yield { + (createSignatoryPanelsJsonV600(panels), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getSignatoryPanel, + implementedInApiVersion, + nameOf(getSignatoryPanel), + "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", + "Get Signatory Panel", + s"""Get a specific signatory panel by its ID. + | + |Authentication is Required + |""", + EmptyBody, + SignatoryPanelJsonV600( + panel_id = "panel-id-001", + mandate_id = "mandate-id-123", + panel_name = "Panel A - Directors", + description = "Board directors authorised to sign", + user_ids = List("user-id-1", "user-id-2", "user-id-3") + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canGetSignatoryPanel)) + ) + + lazy val getSignatoryPanel: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "signatory-panels" :: panelId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (panel, callContext) <- Connector.connector.vend.getSignatoryPanelById( + panelId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Signatory panel not found. Panel ID: $panelId", 404), i._2) + } + } yield { + (createSignatoryPanelJsonV600(panel), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateSignatoryPanel, + implementedInApiVersion, + nameOf(updateSignatoryPanel), + "PUT", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", + "Update Signatory Panel", + s"""Update a signatory panel. + | + |Authentication is Required + |""", + UpdateSignatoryPanelJsonV600( + panel_name = "Panel A - Updated Directors", + description = "Updated board directors", + user_ids = List("user-id-1", "user-id-2", "user-id-4") + ), + SignatoryPanelJsonV600( + panel_id = "panel-id-001", + mandate_id = "mandate-id-123", + panel_name = "Panel A - Updated Directors", + description = "Updated board directors", + user_ids = List("user-id-1", "user-id-2", "user-id-4") + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + InvalidJsonFormat, + UnknownError + ), + List(apiTagMandate), + Some(List(canUpdateSignatoryPanel)) + ) + + lazy val updateSignatoryPanel: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "signatory-panels" :: panelId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, cc.callContext) { + json.extract[UpdateSignatoryPanelJsonV600] + } + userIdsStr = updateJson.user_ids.mkString(",") + (panel, callContext) <- Connector.connector.vend.updateSignatoryPanel( + panelId, + updateJson.panel_name, + updateJson.description, + userIdsStr, + cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not update signatory panel. Panel ID: $panelId"), i._2) + } + } yield { + (createSignatoryPanelJsonV600(panel), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteSignatoryPanel, + implementedInApiVersion, + nameOf(deleteSignatoryPanel), + "DELETE", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", + "Delete Signatory Panel", + s"""Delete a signatory panel. + | + |Authentication is Required + |""", + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + UnknownError + ), + List(apiTagMandate), + Some(List(canDeleteSignatoryPanel)) + ) + + lazy val deleteSignatoryPanel: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "mandates" :: mandateId :: "signatory-panels" :: panelId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (deleted, callContext) <- Connector.connector.vend.deleteSignatoryPanel( + panelId, cc.callContext + ) map { + i => (unboxFullOrFail(i._1, cc.callContext, s"Could not delete signatory panel. Panel ID: $panelId"), i._2) + } + } yield { + (deleted, HttpCode.`204`(callContext)) + } + } + } + } } 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 91ceb90ab0..5db9e86ab2 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 @@ -613,6 +613,124 @@ case class AbacRuleJsonV600( case class AbacRulesJsonV600(abac_rules: List[AbacRuleJsonV600]) +// Mandate JSON case classes + +case class CreateMandateJsonV600( + customer_id: String, + mandate_name: String, + mandate_reference: String, + legal_text: String, + description: String, + status: String, + valid_from: String, + valid_to: String +) + +case class UpdateMandateJsonV600( + mandate_name: String, + mandate_reference: String, + legal_text: String, + description: String, + status: String, + valid_from: String, + valid_to: String +) + +case class MandateJsonV600( + mandate_id: String, + bank_id: String, + account_id: String, + customer_id: String, + mandate_name: String, + mandate_reference: String, + legal_text: String, + description: String, + status: String, + valid_from: String, + valid_to: String, + created_by_user_id: String, + updated_by_user_id: String +) + +case class MandatesJsonV600(mandates: List[MandateJsonV600]) + +// Mandate Provision JSON case classes + +case class SignatoryRequirementJsonV600( + panel_id: String, + required_count: Int +) + +case class CreateMandateProvisionJsonV600( + provision_name: String, + provision_description: String, + legal_reference: String, + provision_type: String, + conditions: String, + signatory_requirements: List[SignatoryRequirementJsonV600], + linked_view_id: Option[String], + linked_abac_rule_id: Option[String], + linked_challenge_type: Option[String], + is_active: Boolean, + sort_order: Int +) + +case class UpdateMandateProvisionJsonV600( + provision_name: String, + provision_description: String, + legal_reference: String, + provision_type: String, + conditions: String, + signatory_requirements: List[SignatoryRequirementJsonV600], + linked_view_id: Option[String], + linked_abac_rule_id: Option[String], + linked_challenge_type: Option[String], + is_active: Boolean, + sort_order: Int +) + +case class MandateProvisionJsonV600( + provision_id: String, + mandate_id: String, + provision_name: String, + provision_description: String, + legal_reference: String, + provision_type: String, + conditions: String, + signatory_requirements: List[SignatoryRequirementJsonV600], + linked_view_id: String, + linked_abac_rule_id: String, + linked_challenge_type: String, + is_active: Boolean, + sort_order: Int +) + +case class MandateProvisionsJsonV600(provisions: List[MandateProvisionJsonV600]) + +// Signatory Panel JSON case classes + +case class CreateSignatoryPanelJsonV600( + panel_name: String, + description: String, + user_ids: List[String] +) + +case class UpdateSignatoryPanelJsonV600( + panel_name: String, + description: String, + user_ids: List[String] +) + +case class SignatoryPanelJsonV600( + panel_id: String, + mandate_id: String, + panel_name: String, + description: String, + user_ids: List[String] +) + +case class SignatoryPanelsJsonV600(signatory_panels: List[SignatoryPanelJsonV600]) + case class ExecuteAbacRuleJsonV600( authenticated_user_id: Option[String], on_behalf_of_user_id: Option[String], @@ -1742,6 +1860,86 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { AbacRulesJsonV600(rules.map(createAbacRuleJsonV600)) } + // Mandate conversion functions + private val dateFormatter = { + val df = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + df.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + df + } + + def createMandateJsonV600(mandate: code.mandate.MandateTrait): MandateJsonV600 = { + MandateJsonV600( + mandate_id = mandate.mandateId, + bank_id = mandate.bankId, + account_id = mandate.accountId, + customer_id = mandate.customerId, + mandate_name = mandate.mandateName, + mandate_reference = mandate.mandateReference, + legal_text = mandate.legalText, + description = mandate.description, + status = mandate.status, + valid_from = if (mandate.validFrom != null) dateFormatter.format(mandate.validFrom) else "", + valid_to = if (mandate.validTo != null) dateFormatter.format(mandate.validTo) else "", + created_by_user_id = mandate.createdByUserId, + updated_by_user_id = mandate.updatedByUserId + ) + } + + def createMandatesJsonV600(mandates: List[code.mandate.MandateTrait]): MandatesJsonV600 = { + MandatesJsonV600(mandates.map(createMandateJsonV600)) + } + + private def parseSignatoryRequirements(json: String): List[SignatoryRequirementJsonV600] = { + if (json == null || json.isEmpty) Nil + else { + try { + import net.liftweb.json._ + implicit val formats: Formats = DefaultFormats + net.liftweb.json.parse(json).extract[List[SignatoryRequirementJsonV600]] + } catch { + case _: Exception => Nil + } + } + } + + def createMandateProvisionJsonV600(provision: code.mandate.MandateProvisionTrait): MandateProvisionJsonV600 = { + MandateProvisionJsonV600( + provision_id = provision.provisionId, + mandate_id = provision.mandateId, + provision_name = provision.provisionName, + provision_description = provision.provisionDescription, + legal_reference = provision.legalReference, + provision_type = provision.provisionType, + conditions = provision.conditions, + signatory_requirements = parseSignatoryRequirements(provision.signatoryRequirements), + linked_view_id = provision.linkedViewId, + linked_abac_rule_id = provision.linkedAbacRuleId, + linked_challenge_type = provision.linkedChallengeType, + is_active = provision.isActive, + sort_order = provision.sortOrder + ) + } + + def createMandateProvisionsJsonV600(provisions: List[code.mandate.MandateProvisionTrait]): MandateProvisionsJsonV600 = { + MandateProvisionsJsonV600(provisions.map(createMandateProvisionJsonV600)) + } + + def createSignatoryPanelJsonV600(panel: code.mandate.SignatoryPanelTrait): SignatoryPanelJsonV600 = { + val userIdList = if (panel.userIds == null || panel.userIds.isEmpty) Nil + else panel.userIds.split(",").map(_.trim).filter(_.nonEmpty).toList + SignatoryPanelJsonV600( + panel_id = panel.panelId, + mandate_id = panel.mandateId, + panel_name = panel.panelName, + description = panel.description, + user_ids = userIdList + ) + } + + def createSignatoryPanelsJsonV600(panels: List[code.mandate.SignatoryPanelTrait]): SignatoryPanelsJsonV600 = { + SignatoryPanelsJsonV600(panels.map(createSignatoryPanelJsonV600)) + } + def createFeaturedApiCollectionJsonV600( featuredApiCollection: FeaturedApiCollectionTrait ): FeaturedApiCollectionJsonV600 = { diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 18dcc9fef8..86e14edf90 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -9,6 +9,7 @@ import code.api.util._ import code.api.{APIFailure, APIFailureNewStyle} import code.atmattribute.AtmAttribute import code.bankattribute.BankAttribute +import code.mandate.{MandateTrait, MandateProvisionTrait, SignatoryPanelTrait} import code.bankconnectors.akka.AkkaConnector_vDec2018 import code.bankconnectors.cardano.CardanoConnector_vJun2025 import code.bankconnectors.ethereum.EthereumConnector_vSept2025 @@ -1948,4 +1949,135 @@ trait Connector extends MdcLoggable { balanceId: BalanceId, callContext: Option[CallContext] ): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteBankAccountBalance _))), callContext)} + + // Mandate methods + def getMandateById( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future{(Failure(setUnimplementedError(nameOf(getMandateById _))), callContext)} + + def getMandatesByBankAndAccount( + bankId: BankId, + accountId: AccountId, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getMandatesByBankAndAccount _))), callContext)} + + def getActiveMandatesByBankAndAccount( + bankId: BankId, + accountId: AccountId, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getActiveMandatesByBankAndAccount _))), callContext)} + + def createMandate( + bankId: BankId, + accountId: AccountId, + customerId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + createdByUserId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future{(Failure(setUnimplementedError(nameOf(createMandate _))), callContext)} + + def updateMandate( + mandateId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + updatedByUserId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateMandate _))), callContext)} + + def deleteMandate( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteMandate _))), callContext)} + + // Mandate Provision methods + def getMandateProvisionById( + provisionId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future{(Failure(setUnimplementedError(nameOf(getMandateProvisionById _))), callContext)} + + def getMandateProvisionsByMandateId( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateProvisionTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getMandateProvisionsByMandateId _))), callContext)} + + def createMandateProvision( + mandateId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future{(Failure(setUnimplementedError(nameOf(createMandateProvision _))), callContext)} + + def updateMandateProvision( + provisionId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateMandateProvision _))), callContext)} + + def deleteMandateProvision( + provisionId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteMandateProvision _))), callContext)} + + // Signatory Panel methods + def getSignatoryPanelById( + panelId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future{(Failure(setUnimplementedError(nameOf(getSignatoryPanelById _))), callContext)} + + def getSignatoryPanelsByMandateId( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[SignatoryPanelTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getSignatoryPanelsByMandateId _))), callContext)} + + def createSignatoryPanel( + mandateId: String, + panelName: String, + description: String, + userIds: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future{(Failure(setUnimplementedError(nameOf(createSignatoryPanel _))), callContext)} + + def updateSignatoryPanel( + panelId: String, + panelName: String, + description: String, + userIds: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateSignatoryPanel _))), callContext)} + + def deleteSignatoryPanel( + panelId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteSignatoryPanel _))), callContext)} } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 14405fddeb..b9ee184e26 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -26,6 +26,7 @@ import code.cards.MappedPhysicalCard import code.context.{UserAuthContextProvider, UserAuthContextUpdateProvider} import code.counterpartylimit.CounterpartyLimitProvider import code.customer._ +import code.mandate.{MandateTrait, MandateProvisionTrait, SignatoryPanelTrait, MappedMandateProvider} import code.customer.agent.AgentX import code.customeraccountlinks.CustomerAccountLinkX import code.customeraddress.CustomerAddressX @@ -5659,5 +5660,182 @@ object LocalMappedConnector extends Connector with MdcLoggable { (_, callContext) } } - + + // Mandate methods + override def getMandateById( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future { + (MappedMandateProvider.getMandateById(mandateId), callContext) + } + + override def getMandatesByBankAndAccount( + bankId: BankId, + accountId: AccountId, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateTrait]]] = Future { + (MappedMandateProvider.getMandatesByBankAndAccount(bankId.value, accountId.value), callContext) + } + + override def getActiveMandatesByBankAndAccount( + bankId: BankId, + accountId: AccountId, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateTrait]]] = Future { + (MappedMandateProvider.getActiveMandatesByBankAndAccount(bankId.value, accountId.value), callContext) + } + + override def createMandate( + bankId: BankId, + accountId: AccountId, + customerId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + createdByUserId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future { + (MappedMandateProvider.createMandate( + bankId.value, accountId.value, customerId, mandateName, mandateReference, + legalText, description, status, validFrom, validTo, createdByUserId + ), callContext) + } + + override def updateMandate( + mandateId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + updatedByUserId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateTrait]] = Future { + (MappedMandateProvider.updateMandate( + mandateId, mandateName, mandateReference, legalText, description, + status, validFrom, validTo, updatedByUserId + ), callContext) + } + + override def deleteMandate( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future { + (MappedMandateProvider.deleteMandate(mandateId), callContext) + } + + // Mandate Provision methods + override def getMandateProvisionById( + provisionId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future { + (MappedMandateProvider.getMandateProvisionById(provisionId), callContext) + } + + override def getMandateProvisionsByMandateId( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[MandateProvisionTrait]]] = Future { + (MappedMandateProvider.getMandateProvisionsByMandateId(mandateId), callContext) + } + + override def createMandateProvision( + mandateId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future { + (MappedMandateProvider.createMandateProvision( + mandateId, provisionName, provisionDescription, legalReference, provisionType, + conditions, signatoryRequirements, linkedViewId, linkedAbacRuleId, + linkedChallengeType, isActive, sortOrder + ), callContext) + } + + override def updateMandateProvision( + provisionId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int, + callContext: Option[CallContext] + ): OBPReturnType[Box[MandateProvisionTrait]] = Future { + (MappedMandateProvider.updateMandateProvision( + provisionId, provisionName, provisionDescription, legalReference, provisionType, + conditions, signatoryRequirements, linkedViewId, linkedAbacRuleId, + linkedChallengeType, isActive, sortOrder + ), callContext) + } + + override def deleteMandateProvision( + provisionId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future { + (MappedMandateProvider.deleteMandateProvision(provisionId), callContext) + } + + // Signatory Panel methods + override def getSignatoryPanelById( + panelId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future { + (MappedMandateProvider.getSignatoryPanelById(panelId), callContext) + } + + override def getSignatoryPanelsByMandateId( + mandateId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[List[SignatoryPanelTrait]]] = Future { + (MappedMandateProvider.getSignatoryPanelsByMandateId(mandateId), callContext) + } + + override def createSignatoryPanel( + mandateId: String, + panelName: String, + description: String, + userIds: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future { + (MappedMandateProvider.createSignatoryPanel(mandateId, panelName, description, userIds), callContext) + } + + override def updateSignatoryPanel( + panelId: String, + panelName: String, + description: String, + userIds: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[SignatoryPanelTrait]] = Future { + (MappedMandateProvider.updateSignatoryPanel(panelId, panelName, description, userIds), callContext) + } + + override def deleteSignatoryPanel( + panelId: String, + callContext: Option[CallContext] + ): OBPReturnType[Box[Boolean]] = Future { + (MappedMandateProvider.deleteSignatoryPanel(panelId), callContext) + } + } diff --git a/obp-api/src/main/scala/code/mandate/MandateTrait.scala b/obp-api/src/main/scala/code/mandate/MandateTrait.scala new file mode 100644 index 0000000000..896be04b84 --- /dev/null +++ b/obp-api/src/main/scala/code/mandate/MandateTrait.scala @@ -0,0 +1,493 @@ +package code.mandate + +import code.api.util.APIUtil +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +import java.util.Date + +// ==================== Traits ==================== + +trait MandateTrait { + def mandateId: String + def bankId: String + def accountId: String + def customerId: String + def mandateName: String + def mandateReference: String + def legalText: String + def description: String + def status: String + def validFrom: Date + def validTo: Date + def createdByUserId: String + def updatedByUserId: String +} + +trait MandateProvisionTrait { + def provisionId: String + def mandateId: String + def provisionName: String + def provisionDescription: String + def legalReference: String + def provisionType: String + def conditions: String + def signatoryRequirements: String + def linkedViewId: String + def linkedAbacRuleId: String + def linkedChallengeType: String + def isActive: Boolean + def sortOrder: Int +} + +trait SignatoryPanelTrait { + def panelId: String + def mandateId: String + def panelName: String + def description: String + def userIds: String +} + +// ==================== Mapped Models ==================== + +class Mandate extends MandateTrait with LongKeyedMapper[Mandate] with IdPK with CreatedUpdated { + def getSingleton = Mandate + + object MandateId extends MappedString(this, 255) { + override def defaultValue = APIUtil.generateUUID() + } + object BankId extends MappedString(this, 255) + object AccountId extends MappedString(this, 255) + object CustomerId extends MappedString(this, 255) + object MandateName extends MappedString(this, 255) + object MandateReference extends MappedString(this, 255) + object LegalText extends MappedText(this) + object Description extends MappedText(this) + object Status extends MappedString(this, 50) { + override def defaultValue = "ACTIVE" + } + object ValidFrom extends MappedDateTime(this) + object ValidTo extends MappedDateTime(this) + object CreatedByUserId extends MappedString(this, 255) + object UpdatedByUserId extends MappedString(this, 255) + + override def mandateId: String = MandateId.get + override def bankId: String = BankId.get + override def accountId: String = AccountId.get + override def customerId: String = CustomerId.get + override def mandateName: String = MandateName.get + override def mandateReference: String = MandateReference.get + override def legalText: String = LegalText.get + override def description: String = Description.get + override def status: String = Status.get + override def validFrom: Date = ValidFrom.get + override def validTo: Date = ValidTo.get + override def createdByUserId: String = CreatedByUserId.get + override def updatedByUserId: String = UpdatedByUserId.get +} + +object Mandate extends Mandate with LongKeyedMetaMapper[Mandate] { + override def dbIndexes: List[BaseIndex[Mandate]] = + UniqueIndex(MandateId) :: + Index(BankId, AccountId) :: + Index(CustomerId) :: + Index(MandateReference) :: + super.dbIndexes +} + +class MandateProvision extends MandateProvisionTrait with LongKeyedMapper[MandateProvision] with IdPK with CreatedUpdated { + def getSingleton = MandateProvision + + object ProvisionId extends MappedString(this, 255) { + override def defaultValue = APIUtil.generateUUID() + } + object MandateId extends MappedString(this, 255) + object ProvisionName extends MappedString(this, 255) + object ProvisionDescription extends MappedText(this) + object LegalReference extends MappedString(this, 255) + object ProvisionType extends MappedString(this, 50) + object Conditions extends MappedText(this) + object SignatoryRequirements extends MappedText(this) + object LinkedViewId extends MappedString(this, 255) + object LinkedAbacRuleId extends MappedString(this, 255) + object LinkedChallengeType extends MappedString(this, 255) + object IsActive extends MappedBoolean(this) { + override def defaultValue = true + } + object SortOrder extends MappedInt(this) { + override def defaultValue = 0 + } + + override def provisionId: String = ProvisionId.get + override def mandateId: String = MandateId.get + override def provisionName: String = ProvisionName.get + override def provisionDescription: String = ProvisionDescription.get + override def legalReference: String = LegalReference.get + override def provisionType: String = ProvisionType.get + override def conditions: String = Conditions.get + override def signatoryRequirements: String = SignatoryRequirements.get + override def linkedViewId: String = LinkedViewId.get + override def linkedAbacRuleId: String = LinkedAbacRuleId.get + override def linkedChallengeType: String = LinkedChallengeType.get + override def isActive: Boolean = IsActive.get + override def sortOrder: Int = SortOrder.get +} + +object MandateProvision extends MandateProvision with LongKeyedMetaMapper[MandateProvision] { + override def dbIndexes: List[BaseIndex[MandateProvision]] = + UniqueIndex(ProvisionId) :: + Index(MandateId) :: + super.dbIndexes +} + +class SignatoryPanel extends SignatoryPanelTrait with LongKeyedMapper[SignatoryPanel] with IdPK with CreatedUpdated { + def getSingleton = SignatoryPanel + + object PanelId extends MappedString(this, 255) { + override def defaultValue = APIUtil.generateUUID() + } + object MandateId extends MappedString(this, 255) + object PanelName extends MappedString(this, 255) + object Description extends MappedText(this) + object UserIds extends MappedText(this) + + override def panelId: String = PanelId.get + override def mandateId: String = MandateId.get + override def panelName: String = PanelName.get + override def description: String = Description.get + override def userIds: String = UserIds.get +} + +object SignatoryPanel extends SignatoryPanel with LongKeyedMetaMapper[SignatoryPanel] { + override def dbIndexes: List[BaseIndex[SignatoryPanel]] = + UniqueIndex(PanelId) :: + Index(MandateId) :: + super.dbIndexes +} + +// ==================== Provider ==================== + +trait MandateProvider { + // Mandate CRUD + def getMandateById(mandateId: String): Box[MandateTrait] + def getMandatesByBankAndAccount(bankId: String, accountId: String): Box[List[MandateTrait]] + def getActiveMandatesByBankAndAccount(bankId: String, accountId: String): Box[List[MandateTrait]] + def createMandate( + bankId: String, + accountId: String, + customerId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + createdByUserId: String + ): Box[MandateTrait] + def updateMandate( + mandateId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + updatedByUserId: String + ): Box[MandateTrait] + def deleteMandate(mandateId: String): Box[Boolean] + + // Mandate Provision CRUD + def getMandateProvisionById(provisionId: String): Box[MandateProvisionTrait] + def getMandateProvisionsByMandateId(mandateId: String): Box[List[MandateProvisionTrait]] + def createMandateProvision( + mandateId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int + ): Box[MandateProvisionTrait] + def updateMandateProvision( + provisionId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int + ): Box[MandateProvisionTrait] + def deleteMandateProvision(provisionId: String): Box[Boolean] + + // Signatory Panel CRUD + def getSignatoryPanelById(panelId: String): Box[SignatoryPanelTrait] + def getSignatoryPanelsByMandateId(mandateId: String): Box[List[SignatoryPanelTrait]] + def createSignatoryPanel( + mandateId: String, + panelName: String, + description: String, + userIds: String + ): Box[SignatoryPanelTrait] + def updateSignatoryPanel( + panelId: String, + panelName: String, + description: String, + userIds: String + ): Box[SignatoryPanelTrait] + def deleteSignatoryPanel(panelId: String): Box[Boolean] +} + +// ==================== Mapped Provider ==================== + +object MappedMandateProvider extends MandateProvider { + + // ---- Mandate ---- + + override def getMandateById(mandateId: String): Box[MandateTrait] = { + Mandate.find(By(Mandate.MandateId, mandateId)) + } + + override def getMandatesByBankAndAccount(bankId: String, accountId: String): Box[List[MandateTrait]] = { + tryo { + Mandate.findAll( + By(Mandate.BankId, bankId), + By(Mandate.AccountId, accountId), + OrderBy(Mandate.updatedAt, Descending) + ) + } + } + + override def getActiveMandatesByBankAndAccount(bankId: String, accountId: String): Box[List[MandateTrait]] = { + tryo { + Mandate.findAll( + By(Mandate.BankId, bankId), + By(Mandate.AccountId, accountId), + By(Mandate.Status, "ACTIVE"), + OrderBy(Mandate.updatedAt, Descending) + ) + } + } + + override def createMandate( + bankId: String, + accountId: String, + customerId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + createdByUserId: String + ): Box[MandateTrait] = { + tryo { + Mandate.create + .BankId(bankId) + .AccountId(accountId) + .CustomerId(customerId) + .MandateName(mandateName) + .MandateReference(mandateReference) + .LegalText(legalText) + .Description(description) + .Status(status) + .ValidFrom(validFrom) + .ValidTo(validTo) + .CreatedByUserId(createdByUserId) + .UpdatedByUserId(createdByUserId) + .saveMe() + } + } + + override def updateMandate( + mandateId: String, + mandateName: String, + mandateReference: String, + legalText: String, + description: String, + status: String, + validFrom: Date, + validTo: Date, + updatedByUserId: String + ): Box[MandateTrait] = { + for { + mandate <- Mandate.find(By(Mandate.MandateId, mandateId)) + updated <- tryo { + mandate + .MandateName(mandateName) + .MandateReference(mandateReference) + .LegalText(legalText) + .Description(description) + .Status(status) + .ValidFrom(validFrom) + .ValidTo(validTo) + .UpdatedByUserId(updatedByUserId) + .saveMe() + } + } yield updated + } + + override def deleteMandate(mandateId: String): Box[Boolean] = { + for { + mandate <- Mandate.find(By(Mandate.MandateId, mandateId)) + deleted <- tryo(mandate.delete_!) + } yield deleted + } + + // ---- Mandate Provision ---- + + override def getMandateProvisionById(provisionId: String): Box[MandateProvisionTrait] = { + MandateProvision.find(By(MandateProvision.ProvisionId, provisionId)) + } + + override def getMandateProvisionsByMandateId(mandateId: String): Box[List[MandateProvisionTrait]] = { + tryo { + MandateProvision.findAll( + By(MandateProvision.MandateId, mandateId), + OrderBy(MandateProvision.SortOrder, Ascending) + ) + } + } + + override def createMandateProvision( + mandateId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int + ): Box[MandateProvisionTrait] = { + tryo { + MandateProvision.create + .MandateId(mandateId) + .ProvisionName(provisionName) + .ProvisionDescription(provisionDescription) + .LegalReference(legalReference) + .ProvisionType(provisionType) + .Conditions(conditions) + .SignatoryRequirements(signatoryRequirements) + .LinkedViewId(linkedViewId) + .LinkedAbacRuleId(linkedAbacRuleId) + .LinkedChallengeType(linkedChallengeType) + .IsActive(isActive) + .SortOrder(sortOrder) + .saveMe() + } + } + + override def updateMandateProvision( + provisionId: String, + provisionName: String, + provisionDescription: String, + legalReference: String, + provisionType: String, + conditions: String, + signatoryRequirements: String, + linkedViewId: String, + linkedAbacRuleId: String, + linkedChallengeType: String, + isActive: Boolean, + sortOrder: Int + ): Box[MandateProvisionTrait] = { + for { + provision <- MandateProvision.find(By(MandateProvision.ProvisionId, provisionId)) + updated <- tryo { + provision + .ProvisionName(provisionName) + .ProvisionDescription(provisionDescription) + .LegalReference(legalReference) + .ProvisionType(provisionType) + .Conditions(conditions) + .SignatoryRequirements(signatoryRequirements) + .LinkedViewId(linkedViewId) + .LinkedAbacRuleId(linkedAbacRuleId) + .LinkedChallengeType(linkedChallengeType) + .IsActive(isActive) + .SortOrder(sortOrder) + .saveMe() + } + } yield updated + } + + override def deleteMandateProvision(provisionId: String): Box[Boolean] = { + for { + provision <- MandateProvision.find(By(MandateProvision.ProvisionId, provisionId)) + deleted <- tryo(provision.delete_!) + } yield deleted + } + + // ---- Signatory Panel ---- + + override def getSignatoryPanelById(panelId: String): Box[SignatoryPanelTrait] = { + SignatoryPanel.find(By(SignatoryPanel.PanelId, panelId)) + } + + override def getSignatoryPanelsByMandateId(mandateId: String): Box[List[SignatoryPanelTrait]] = { + tryo { + SignatoryPanel.findAll( + By(SignatoryPanel.MandateId, mandateId), + OrderBy(SignatoryPanel.PanelName, Ascending) + ) + } + } + + override def createSignatoryPanel( + mandateId: String, + panelName: String, + description: String, + userIds: String + ): Box[SignatoryPanelTrait] = { + tryo { + SignatoryPanel.create + .MandateId(mandateId) + .PanelName(panelName) + .Description(description) + .UserIds(userIds) + .saveMe() + } + } + + override def updateSignatoryPanel( + panelId: String, + panelName: String, + description: String, + userIds: String + ): Box[SignatoryPanelTrait] = { + for { + panel <- SignatoryPanel.find(By(SignatoryPanel.PanelId, panelId)) + updated <- tryo { + panel + .PanelName(panelName) + .Description(description) + .UserIds(userIds) + .saveMe() + } + } yield updated + } + + override def deleteSignatoryPanel(panelId: String): Box[Boolean] = { + for { + panel <- SignatoryPanel.find(By(SignatoryPanel.PanelId, panelId)) + deleted <- tryo(panel.delete_!) + } yield deleted + } +} From 5391ed53ecef5e26c76ae7774fbe1f949802c4d5 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 9 Mar 2026 00:09:10 +0100 Subject: [PATCH 22/23] test/fixed failed tests --- .../src/main/scala/code/api/v6_0_0/APIMethods600.scala | 2 +- .../src/test/scala/code/api/v6_0_0/CustomViewsTest.scala | 8 ++++---- 2 files changed, 5 insertions(+), 5 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 36804d9a22..b6dd94ce79 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 @@ -5379,7 +5379,7 @@ trait APIMethods600 { (account, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) (view, callContext) <- ViewNewStyle.createCustomView(BankIdAccountId(bankId, accountId), createViewJson, callContext) } yield { - (JSONFactory300.createViewJSON(view), HttpCode.`201`(callContext)) + (JSONFactory600.createViewJsonV600(view), HttpCode.`201`(callContext)) } } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CustomViewsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CustomViewsTest.scala index d3fd431422..30991895d2 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/CustomViewsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/CustomViewsTest.scala @@ -74,7 +74,7 @@ class CustomViewsTest extends V600ServerSetup with DefaultUsers { And("All returned views should be custom views (names starting with underscore)") if (viewsArray.nonEmpty) { - val viewIds = viewsArray.map(view => (view \ "id").values.toString) + val viewIds = viewsArray.map(view => (view \ "view_id").values.toString) viewIds.foreach { viewId => viewId should startWith("_") } @@ -103,7 +103,7 @@ class CustomViewsTest extends V600ServerSetup with DefaultUsers { And("Response should not contain system views like owner, accountant, auditor") val json = response.body val viewsArray = (json \ "views").children - val viewIds = viewsArray.map(view => (view \ "id").values.toString) + val viewIds = viewsArray.map(view => (view \ "view_id").values.toString) viewIds should not contain "owner" viewIds should not contain "accountant" @@ -159,7 +159,7 @@ class CustomViewsTest extends V600ServerSetup with DefaultUsers { if (viewsArray.nonEmpty) { info(s"Found ${viewsArray.size} custom view(s)") viewsArray.foreach { view => - val viewId = (view \ "id").values.toString + val viewId = (view \ "view_id").values.toString info(s" - Custom view: $viewId") viewId should startWith regex "^_.*" } @@ -236,7 +236,7 @@ class CustomViewsTest extends V600ServerSetup with DefaultUsers { And("Response should contain the created view") val json = response.body - val viewId = (json \ "id").values.toString + val viewId = (json \ "view_id").values.toString viewId should equal("_my_custom_view") And("View should be marked as custom view (is_system = false)") From 0f5d49123a6f269cf1470616d7f2fd723056bc17 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 9 Mar 2026 00:21:05 +0100 Subject: [PATCH 23/23] test/fixed failed tests RestConnector_vMar2019_FrozenTest --- .../RestConnector_vMar2019_frozen_meta_data | Bin 124216 -> 124274 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data b/obp-api/src/test/scala/code/connector/RestConnector_vMar2019_frozen_meta_data index 8d546f7e32408ed146d82fc64d631c64ae718bf9..9de525b2b0957976229c1923a8e4e8712ae01822 100644 GIT binary patch delta 329 zcmdmSiv80m_6;wpSqmBJn2k4osOHjRRG4mfl~H!`%0=dz{a4-NW>na$w@p?AD4dwU zC^@}rIiup_1$x|*{Z8^sexJ*;dF3f#K8W_oAF6|1HIA20yGrt9RW>7&dJfqvXd3!`KC9hGxBb? uOJ%&Q1NH54?MaMsjPoWRoFuZ{VFTj_KE`?5fsXSOg?m_f``jyxvjqUe#CxXz delta 246 zcmex#ihai^_6;wpCr=9&o_z9#`)2!^9lDH)lM`!XCofxMzS(EhJ#I$D=?6p^6F2v4 z+anBQE2c6^PCvdJsL0`@_~iGwJkz5d0y#A&do~B3PU8icwE5(XY9SWs5(e4Hg|Edn z$Gz@j+^qlBjEQmn=Ct=0*%;?fzbMWqJK44;cKU+zjLMT2Oyb`B;F~ftlEmh^Uyjm@ ziraxU-}Qppx_x^BV