diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala index 2decaab967..faeba636a1 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/MessageDocsSwaggerDefinitions.scala @@ -193,7 +193,9 @@ object MessageDocsSwaggerDefinitions lastOkDate = DateWithDayExampleObject, title =titleExample.value, branchId = branchIdExample.value, - nameSuffix = nameSuffixExample.value + nameSuffix = nameSuffixExample.value, + customerType = "", + parentCustomerId = "" ) val customerAttribute = CustomerAttributeCommons( diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 5d56313bed..d710b9b472 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2561,9 +2561,48 @@ object SwaggerDefinitionsJSON { last_ok_date = Some(oneYearAgoDate), title = Some(ExampleValue.titleExample.value), branch_id = Some(ExampleValue.branchIdExample.value), + name_suffix = Some(ExampleValue.nameSuffixExample.value), + customer_type = Some(ExampleValue.customerTypeExample.value), + parent_customer_id = None + ) + + lazy val postRetailCustomerJsonV600 = + PostRetailCustomerJsonV600( + legal_name = ExampleValue.legalNameExample.value, + customer_number = Some(ExampleValue.customerNumberExample.value), + mobile_phone_number = ExampleValue.mobilePhoneNumberExample.value, + email = Some(ExampleValue.emailExample.value), + face_image = Some(customerFaceImageJson), + date_of_birth = Some("1990-05-15"), + relationship_status = Some(ExampleValue.relationshipStatusExample.value), + dependants = Some(ExampleValue.dependantsExample.value.toInt), + dob_of_dependants = Some(List("2015-03-20", "2018-07-10")), + credit_rating = Some(customerCreditRatingJSON), + credit_limit = Some(amountOfMoneyJsonV121), + highest_education_attained = Some(ExampleValue.highestEducationAttainedExample.value), + employment_status = Some(ExampleValue.employmentStatusExample.value), + kyc_status = Some(ExampleValue.kycStatusExample.value.toBoolean), + last_ok_date = Some(oneYearAgoDate), + title = Some(ExampleValue.titleExample.value), + branch_id = Some(ExampleValue.branchIdExample.value), name_suffix = Some(ExampleValue.nameSuffixExample.value) ) - + + lazy val postCorporateCustomerJsonV600 = + PostCorporateCustomerJsonV600( + legal_name = ExampleValue.legalNameExample.value, + customer_number = Some(ExampleValue.customerNumberExample.value), + mobile_phone_number = ExampleValue.mobilePhoneNumberExample.value, + email = Some(ExampleValue.emailExample.value), + credit_rating = Some(customerCreditRatingJSON), + credit_limit = Some(amountOfMoneyJsonV121), + kyc_status = Some(ExampleValue.kycStatusExample.value.toBoolean), + last_ok_date = Some(oneYearAgoDate), + branch_id = Some(ExampleValue.branchIdExample.value), + customer_type = Some("CORPORATE"), + parent_customer_id = None + ) + lazy val customerJsonV600 = CustomerJsonV600( bank_id = bankIdExample.value, customer_id = ExampleValue.customerIdExample.value, @@ -2584,7 +2623,9 @@ object SwaggerDefinitionsJSON { last_ok_date = oneYearAgoDate, title = ExampleValue.titleExample.value, branch_id = ExampleValue.branchIdExample.value, - name_suffix = ExampleValue.nameSuffixExample.value + name_suffix = ExampleValue.nameSuffixExample.value, + customer_type = ExampleValue.customerTypeExample.value, + parent_customer_id = ExampleValue.parentCustomerIdExample.value ) lazy val customerJSONsV600 = CustomerJSONsV600(List(customerJsonV600)) @@ -2626,6 +2667,8 @@ object SwaggerDefinitionsJSON { migration_script_logs = List(migrationScriptLogJsonV600) ) + lazy val customerChildrenJsonV600 = CustomerJSONsV600(List(customerJsonV600)) + lazy val customerWithAttributesJsonV600 = CustomerWithAttributesJsonV600( bank_id = bankIdExample.value, customer_id = ExampleValue.customerIdExample.value, @@ -2647,6 +2690,8 @@ object SwaggerDefinitionsJSON { title = ExampleValue.titleExample.value, branch_id = ExampleValue.branchIdExample.value, name_suffix = ExampleValue.nameSuffixExample.value, + customer_type = ExampleValue.customerTypeExample.value, + parent_customer_id = ExampleValue.parentCustomerIdExample.value, customer_attributes = List(customerAttributeResponseJson) ) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala index a119d07c41..4fc7d6e184 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/APIMethodsDynamicEntity.scala @@ -2,17 +2,11 @@ package code.api.dynamic.entity import code.DynamicData.{DynamicData, DynamicDataProvider} import code.api.Constant.PARAM_LOCALE -import code.api.dynamic.endpoint.helper.{DynamicEndpointHelper, MockResponseHolder} -import code.api.dynamic.endpoint.helper.DynamicEndpointHelper.DynamicReq -import code.api.dynamic.endpoint.helper.MockResponseHolder -import code.api.dynamic.entity.helper.{CommunityEntityName, DynamicEntityHelper, DynamicEntityInfo, EntityName, PublicEntityName} +import code.api.dynamic.entity.helper._ import code.api.util.APIUtil._ import code.api.util.ErrorMessages._ import code.api.util.NewStyle.HttpCode import code.api.util._ -import code.endpointMapping.EndpointMappingCommons -import com.openbankproject.commons.model.enums.TransactionRequestTypes._ -import com.openbankproject.commons.model.enums.PaymentServiceTypes._ import code.util.Helper import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model._ @@ -28,7 +22,6 @@ import net.liftweb.json._ import net.liftweb.util.StringHelpers import org.apache.commons.lang3.StringUtils -import scala.collection.immutable.List import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future @@ -212,7 +205,8 @@ trait APIMethodsDynamicEntity { jsonResponse.isEmpty } - (box, _) <- NewStyle.function.invokeDynamicConnector(operation, entityName, Some(json.asInstanceOf[JObject]), None, bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) + // Pass userId for all authenticated requests - personal records are filtered by userId + (box, _) <- NewStyle.function.invokeDynamicConnector(operation, entityName, Some(json.asInstanceOf[JObject]), None, bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName) } yield { val result: JObject = (singleName -> singleObject) 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 c257b22cd7..b3ee2ac81c 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -49,6 +49,8 @@ object ApiTag { val apiTagCounterparty = ResourceDocTag("Counterparty") val apiTagKyc = ResourceDocTag("KYC") val apiTagCustomer = ResourceDocTag("Customer") + val apiTagRetailCustomer = ResourceDocTag("Retail-Customer") + val apiTagCorporateCustomer = ResourceDocTag("Corporate-Customer") val apiTagCustomerAttribute = ResourceDocTag("Customer-Attribute") val apiTagOnboarding = ResourceDocTag("Onboarding") val apiTagUser = ResourceDocTag("User") // Use for User Management / Info APIs 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 1fdf757f13..b5c6b4c7b2 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -561,6 +561,10 @@ object ErrorMessages { val InvalidAccountNumber = "OBP-30270: Account not found. Please specify a valid value for ACCOUNT_NUMBER." val BankAccountNotFoundByRoutings = "OBP-30271: Bank Account not found. Please specify valid values for routing schemes and addresses." + val InvalidCustomerType = "OBP-30272: Invalid customer_type. Must be one of: INDIVIDUAL, CORPORATE, SUBSIDIARY." + val ParentCustomerNotFound = "OBP-30273: Parent customer not found. The parent_customer_id must reference an existing customer at the same bank." + val CustomerTypeMismatch = "OBP-30274: Customer type does not match the endpoint. Use the generic /customers endpoint or the correct type-specific endpoint." + val TaxResidenceNotFound = "OBP-30300: Tax Residence not found by TAX_RESIDENCE_ID. " val CustomerAddressNotFound = "OBP-30310: Customer's Address not found by CUSTOMER_ADDRESS_ID. " val AccountApplicationNotFound = "OBP-30311: AccountApplication not found by ACCOUNT_APPLICATION_ID. " @@ -753,7 +757,7 @@ object ErrorMessages { val UnspecifiedCbsError = "OBP-50013: The Core Banking System returned an unspecified error or response." val RefreshUserError = "OBP-50014: Can not refresh User." val InternalServerError = "OBP-50015: The server encountered an unexpected condition which prevented it from fulfilling the request." - val NotAllowedEndpoint = "OBP-50017: The endpoint is forbidden at this API instance." + val NotAllowedEndpoint = "OBP-50017: The endpoint is not enabled at this OBP API instance." val UnderConstructionError = "OBP-50018: Under Construction Error." val DatabaseConnectionClosedError = "OBP-50019: Cannot connect to the OBP database." diff --git a/obp-api/src/main/scala/code/api/util/ExampleValue.scala b/obp-api/src/main/scala/code/api/util/ExampleValue.scala index 626afd25dc..3460137886 100644 --- a/obp-api/src/main/scala/code/api/util/ExampleValue.scala +++ b/obp-api/src/main/scala/code/api/util/ExampleValue.scala @@ -99,6 +99,12 @@ object ExampleValue { lazy val nameSuffixExample = ConnectorField("Sr", s"suffix of the name") glossaryItems += makeGlossaryItem("Customer.nameSuffix", nameSuffixExample) + lazy val customerTypeExample = ConnectorField("INDIVIDUAL", "Type of customer: INDIVIDUAL, CORPORATE, or SUBSIDIARY") + glossaryItems += makeGlossaryItem("Customer.customerType", customerTypeExample) + + lazy val parentCustomerIdExample = ConnectorField("", "The customer_id of the parent customer in a corporate hierarchy, empty if none") + glossaryItems += makeGlossaryItem("Customer.parentCustomerId", parentCustomerIdExample) + lazy val titleExample = ConnectorField("Dr.", s"title of the name") glossaryItems += makeGlossaryItem("Customer.title", titleExample) 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 9cb662ef0c..f648a413b7 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5037,6 +5037,116 @@ object Glossary extends MdcLoggable { ) ) + glossaryItems += GlossaryItem( + title = "Password Reset for OBP Local Users", + description = + s""" + |### Overview + | + |The password reset flow allows a user who has forgotten their password to request a reset email and then set a new password. There are two steps: + | + |1. **Request a password reset email** (anonymous — no login required) + |2. **Set the new password** using the token from the email (anonymous — no login required) + | + |There is also an admin endpoint for requesting a reset on behalf of a user (requires authentication and the `CanCreateResetPasswordUrl` role). + | + |### Step 1: Request Password Reset Email + | + |**POST /obp/v6.0.0/users/password-reset-url** + | + |No authentication required. + | + |Request body: + | + | { + | "username": "user@example.com", + | "email": "user@example.com" + | } + | + |Response (201): + | + | { + | "message": "If the account exists, a password reset email has been sent." + | } + | + |Notes: + | + |- The response is always the same whether or not the user exists. This prevents user enumeration. + |- If the user exists, is validated, and the email matches, a reset email is sent containing a link with a reset token. + |- The App should present a form asking for username and email, call this endpoint, and then show a message saying "Check your email for a reset link." + | + |### Step 2: Complete Password Reset + | + |**POST /obp/v6.0.0/users/password** + | + |No authentication required. + | + |Request body: + | + | { + | "token": "a1b2c3d4e5f67890abcdef1234567890", + | "new_password": "NewStr0ng!Password" + | } + | + |Response (201): + | + | { + | "message": "Password has been reset successfully." + | } + | + |Error responses: + | + |- **400** — Invalid or expired token + |- **400** — Weak password + | + |Notes: + | + |- The token is a signed JWT with a configurable expiry (default: 120 minutes). The server-side expiry can be configured with the `password_reset_token_expiry_minutes` property. + |- The token comes from the reset email URL. The App should extract the token from the URL path (everything after `/user_mgt/reset_password/`) and URL-decode it before sending it to this endpoint. + |- The token is single-use. Once the password is reset, the token is invalidated. An expired token will also be rejected. + | + |### Admin Endpoint (Optional) + | + |**POST /obp/v6.0.0/management/user/reset-password-url** + | + |Authentication required. Requires the `CanCreateResetPasswordUrl` role. + | + |Request body: + | + | { + | "username": "user@example.com", + | "email": "user@example.com", + | "user_id": "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1" + | } + | + |Response (201): + | + | { + | "reset_password_url": "https://your-obp-instance.com/user_mgt/reset_password/TOKEN" + | } + | + |This endpoint returns the reset URL directly (for logging/admin purposes) and also sends the email. It requires all three fields: `username`, `email`, and `user_id`. + | + |### Typical App Flow + | + |1. User clicks "Forgot Password" + |2. App shows form with username and email fields + |3. App calls POST /obp/v6.0.0/users/password-reset-url + |4. App shows "Check your email for a reset link" + |5. User clicks link in email, App opens reset page and extracts token from URL + |6. App shows form with new password field + |7. App calls POST /obp/v6.0.0/users/password with token and new_password + |8. App shows "Password has been reset successfully. Please log in." + | + |### Password Requirements + | + |The new password must meet one of these criteria: + | + |- **10-16 characters:** Must contain at least one uppercase letter, one lowercase letter, one digit, and one special character + |- **17-512 characters:** No additional complexity requirements (length alone is sufficient) + | +""") + /////////////////////////////////////////////////////////////////// // NOTE! Some glossary items are generated in ExampleValue.scala ////////////////////////////////////////////////////////////////// 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 94972c7f60..d21ef5d8b8 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -2691,7 +2691,9 @@ object NewStyle extends MdcLoggable{ title: String, branchId: String, nameSuffix: String, - callContext: Option[CallContext]): OBPReturnType[Customer] = + customerType: String = "", + parentCustomerId: String = "", + callContext: Option[CallContext]): OBPReturnType[Customer] = Connector.connector.vend.createCustomerC2( bankId: BankId, legalName: String, @@ -2713,6 +2715,8 @@ object NewStyle extends MdcLoggable{ title: String, branchId: String, nameSuffix: String, + customerType: String, + parentCustomerId: String, callContext: Option[CallContext] ) map { i => (unboxFullOrFail(i._1, callContext, CreateCustomerError), i._2) @@ -2806,6 +2810,8 @@ object NewStyle extends MdcLoggable{ title: Option[String] = None, branchId: Option[String] = None, nameSuffix: Option[String] = None, + customerType: Option[String] = None, + parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Customer] = Connector.connector.vend.updateCustomerGeneralData( customerId, @@ -2819,10 +2825,22 @@ object NewStyle extends MdcLoggable{ title, branchId, nameSuffix, + customerType, + parentCustomerId, callContext) map { i => (unboxFullOrFail(i._1, callContext, UpdateCustomerError), i._2) } + def getCustomersByParentCustomerId(bankId: BankId, parentCustomerId: String, callContext: Option[CallContext]): OBPReturnType[List[Customer]] = + Connector.connector.vend.getCustomersByParentCustomerId(bankId, parentCustomerId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$CustomerNotFound Current BANK_ID(${bankId.value}) and PARENT_CUSTOMER_ID($parentCustomerId)"), i._2) + } + + def getCustomersByCustomerTypes(bankId: BankId, customerTypes: List[String], callContext: Option[CallContext], queryParams: List[OBPQueryParam]): OBPReturnType[List[Customer]] = + Connector.connector.vend.getCustomersByCustomerTypes(bankId, customerTypes, callContext, queryParams) map { + i => (connectorEmptyResponse(i._1, callContext), i._2) + } + def createPhysicalCard( bankCardNumber: String, nameOnCard: String, 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 29fba4cb91..b92165a67d 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 @@ -4657,6 +4657,8 @@ trait APIMethods310 { Some(putData.title), None, Some(putData.name_suffix), + None, + None, callContext ) } yield { @@ -5305,6 +5307,8 @@ trait APIMethods310 { None, Some(putData.branch_id), None, + None, + None, callContext ) } yield { @@ -5361,6 +5365,8 @@ trait APIMethods310 { None, None, None, + None, + None, callContext ) } yield { 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 58bb1d8904..66011f2313 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 @@ -1405,6 +1405,8 @@ trait APIMethods500 { postedData.title.getOrElse(""), postedData.branch_id.getOrElse(""), postedData.name_suffix.getOrElse(""), + "", + "", callContext, ) } yield { 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 2fd9e0fe79..608ad93a34 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 @@ -12,7 +12,7 @@ import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages.{$AuthenticatedUserIsRequired, InvalidDateFormat, InvalidJsonFormat, UnknownError, DynamicEntityOperationNotAllowed, _} import code.api.util.FutureUtil.EndpointContext -import code.api.util.Glossary +import code.api.util.{CertificateUtil, Glossary} import code.api.util.JsonSchemaGenerator import code.api.util.NewStyle.HttpCode import code.api.util.{APIUtil, ApiVersionUtils, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, OBPLimit, RateLimitingUtil} @@ -30,7 +30,7 @@ import code.api.v5_0_0.JSONFactory500 import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500} import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} -import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, CleanupOrphanedDynamicEntityResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, OrphanedDynamicEntityJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createActiveRateLimitsJsonV600FromCallLimit, createCallLimitJsonV600, createConsumerJsonV600, createRedisCallCountersJson, createFeaturedApiCollectionJsonV600, createFeaturedApiCollectionsJsonV600} +import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, CleanupOrphanedDynamicEntityResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, OrphanedDynamicEntityJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PostResetPasswordUrlAnonymousJsonV600, PostResetPasswordCompleteJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, ResetPasswordUrlAnonymousResponseJsonV600, ResetPasswordCompleteResponseJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createActiveRateLimitsJsonV600FromCallLimit, createCallLimitJsonV600, createConsumerJsonV600, createRedisCallCountersJson, createFeaturedApiCollectionJsonV600, createFeaturedApiCollectionsJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.{APIMetrics, ConnectorCountsRedis, ConnectorTraceProvider} @@ -2615,6 +2615,8 @@ trait APIMethods600 { |- title: Customer's title (e.g., Mr., Mrs., Dr.) |- branch_id: Associated branch identifier |- name_suffix: Customer's name suffix (e.g., Jr., Sr.) + |- customer_type: Type of customer - INDIVIDUAL (default), CORPORATE, or SUBSIDIARY + |- parent_customer_id: For SUBSIDIARY customers, the customer_id of the parent CORPORATE customer | |**Date Format:** |In v6.0.0, date_of_birth and dob_of_dependants must be provided in ISO 8601 date format: **YYYY-MM-DD** (e.g., "1990-05-15", "2010-03-20"). @@ -2640,6 +2642,8 @@ trait APIMethods600 { InvalidJsonFormat, InvalidJsonContent, InvalidDateFormat, + InvalidCustomerType, + ParentCustomerNotFound, CustomerNumberAlreadyExists, UserNotFoundById, CustomerAlreadyExistsForUser, @@ -2696,6 +2700,19 @@ trait APIMethods600 { !`checkIfContains::::` (customerNumber) } (_, callContext) <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, cc.callContext) + + customerType = postedData.customer_type.getOrElse("INDIVIDUAL") + _ <- Helper.booleanToFuture(failMsg = InvalidCustomerType, 400, callContext) { + List("INDIVIDUAL", "CORPORATE", "SUBSIDIARY").contains(customerType) + } + + parentCustomerIdValue = postedData.parent_customer_id.getOrElse("") + _ <- if (parentCustomerIdValue.nonEmpty) { + NewStyle.function.getCustomerByCustomerId(parentCustomerIdValue, callContext).map(_ => ()) + } else { + Future.successful(()) + } + (customer, callContext) <- NewStyle.function.createCustomerC2( bankId, postedData.legal_name, @@ -2719,6 +2736,8 @@ trait APIMethods600 { postedData.title.getOrElse(""), postedData.branch_id.getOrElse(""), postedData.name_suffix.getOrElse(""), + customerType, + parentCustomerIdValue, callContext, ) } yield { @@ -2727,6 +2746,44 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getCustomerChildren, + implementedInApiVersion, + nameOf(getCustomerChildren), + "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/children", + "Get Customer Children", + s"""Get the child (subsidiary) customers of a parent customer. + | + |Returns a list of customers whose parent_customer_id matches the specified CUSTOMER_ID. + |This is useful for corporate banking where a corporate customer may have subsidiary customers. + | + |Authentication is Required + |""", + EmptyBody, + customerJSONsV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerNotFoundByCustomerId, + UnknownError + ), + List(apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getCustomerChildren: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customers" :: customerId :: "children" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- NewStyle.function.getBank(bankId, cc.callContext) + (_, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + (children, callContext) <- NewStyle.function.getCustomersByParentCustomerId(bankId, customerId, callContext) + } yield { + (JSONFactory600.createCustomersJson(children), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( getCustomersAtAllBanks, implementedInApiVersion, @@ -2954,6 +3011,450 @@ trait APIMethods600 { } } + // Retail Customer Endpoints + + staticResourceDocs += ResourceDoc( + createRetailCustomer, + implementedInApiVersion, + nameOf(createRetailCustomer), + "POST", + "/banks/BANK_ID/retail-customers", + "Create Retail Customer", + s"""Create a retail (individual) customer. + | + |This endpoint is specifically for creating individual/retail customers. + |The customer_type will be automatically set to INDIVIDUAL. + | + |**Required Fields:** + |- legal_name: The customer's full legal name + |- mobile_phone_number: The customer's mobile phone number + | + |**Optional Fields:** + |- customer_number: If not provided, a random number will be generated + |- email, face_image, date_of_birth, relationship_status, dependants, dob_of_dependants + |- credit_rating, credit_limit, highest_education_attained, employment_status + |- kyc_status, last_ok_date, title, branch_id, name_suffix + | + |**Date Format:** + |date_of_birth and dob_of_dependants must be in ISO 8601 date format: **YYYY-MM-DD** + | + |**Validations:** + |- customer_number cannot contain `::::` characters + |- customer_number must be unique for the bank + |- The number of dependants must equal the length of the dob_of_dependants array + | + |Authentication is Required + |""", + postRetailCustomerJsonV600, + customerJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + InvalidJsonFormat, + InvalidJsonContent, + InvalidDateFormat, + CustomerNumberAlreadyExists, + UserNotFoundById, + CustomerAlreadyExistsForUser, + CreateConsumerError, + UnknownError + ), + List(apiTagRetailCustomer, apiTagCustomer), + Some(List(canCreateCustomer, canCreateCustomerAtAnyBank)) + ) + lazy val createRetailCustomer: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "retail-customers" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostRetailCustomerJsonV600 ", 400, cc.callContext) { + json.extract[PostRetailCustomerJsonV600] + } + _ <- Helper.booleanToFuture(failMsg = InvalidJsonContent + s" The field dependants(${postedData.dependants.getOrElse(0)}) not equal the length(${postedData.dob_of_dependants.getOrElse(Nil).length}) of dob_of_dependants array", 400, cc.callContext) { + postedData.dependants.getOrElse(0) == postedData.dob_of_dependants.getOrElse(Nil).length + } + dateOfBirth <- Future { + postedData.date_of_birth.map { dateStr => + try { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(dateStr) + } catch { + case _: Exception => + throw new Exception(s"$InvalidJsonFormat date_of_birth must be in YYYY-MM-DD format (e.g., 1990-05-15), got: $dateStr") + } + }.orNull + } + dobOfDependants <- Future { + postedData.dob_of_dependants.getOrElse(Nil).map { dateStr => + try { + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(dateStr) + } catch { + case _: Exception => + throw new Exception(s"$InvalidJsonFormat dob_of_dependants must contain dates in YYYY-MM-DD format (e.g., 2010-03-20), got: $dateStr") + } + } + } + customerNumber = postedData.customer_number.getOrElse(Random.nextInt(Integer.MAX_VALUE).toString) + _ <- Helper.booleanToFuture(failMsg = s"$InvalidJsonFormat customer_number can not contain `::::` characters", cc = cc.callContext) { + !`checkIfContains::::` (customerNumber) + } + (_, callContext) <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, cc.callContext) + (customer, callContext) <- NewStyle.function.createCustomerC2( + bankId, + postedData.legal_name, + customerNumber, + postedData.mobile_phone_number, + postedData.email.getOrElse(""), + CustomerFaceImage( + postedData.face_image.map(_.date).getOrElse(null), + postedData.face_image.map(_.url).getOrElse("") + ), + dateOfBirth, + postedData.relationship_status.getOrElse(""), + postedData.dependants.getOrElse(0), + dobOfDependants, + postedData.highest_education_attained.getOrElse(""), + postedData.employment_status.getOrElse(""), + postedData.kyc_status.getOrElse(false), + postedData.last_ok_date.getOrElse(null), + postedData.credit_rating.map(i => CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => CreditLimit(i.currency, i.amount)), + postedData.title.getOrElse(""), + postedData.branch_id.getOrElse(""), + postedData.name_suffix.getOrElse(""), + "INDIVIDUAL", + "", + callContext, + ) + } yield { + (JSONFactory600.createCustomerJson(customer), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getRetailCustomersAtOneBank, + implementedInApiVersion, + nameOf(getRetailCustomersAtOneBank), + "GET", + "/banks/BANK_ID/retail-customers", + "Get Retail Customers at Bank", + s"""Get Retail (Individual) Customers at Bank. + | + |Returns a list of customers with customer_type INDIVIDUAL at the specified bank. + | + |**Date Format:** + |date_of_birth and dob_of_dependants are returned in ISO 8601 date format: **YYYY-MM-DD** + | + |**Query Parameters:** + |- limit: Maximum number of customers to return (optional) + |- offset: Number of customers to skip for pagination (optional) + |- sort_direction: Sort direction - ASC or DESC (optional) + | + |Authentication is Required + |""", + EmptyBody, + customerJSONsV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagRetailCustomer, apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getRetailCustomersAtOneBank: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "retail-customers" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (requestParams, callContext) <- extractQueryParams(cc.url, List("limit", "offset", "sort_direction"), cc.callContext) + (customers, callContext) <- NewStyle.function.getCustomersByCustomerTypes(bankId, List("INDIVIDUAL"), callContext, requestParams) + } yield { + (JSONFactory600.createCustomersJson(customers), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getRetailCustomerByCustomerId, + implementedInApiVersion, + nameOf(getRetailCustomerByCustomerId), + "GET", + "/banks/BANK_ID/retail-customers/CUSTOMER_ID", + "Get Retail Customer by CUSTOMER_ID", + s"""Gets the Retail Customer specified by CUSTOMER_ID. + | + |Returns 404 if the customer exists but is not of type INDIVIDUAL. + |Use the generic /customers/CUSTOMER_ID endpoint for any customer type. + | + |**Date Format:** + |date_of_birth and dob_of_dependants are returned in ISO 8601 date format: **YYYY-MM-DD** + | + |Authentication is Required + |""", + EmptyBody, + customerWithAttributesJsonV600, + List( + $AuthenticatedUserIsRequired, + CustomerTypeMismatch, + UnknownError + ), + List(apiTagRetailCustomer, apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getRetailCustomerByCustomerId: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "retail-customers" :: customerId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- NewStyle.function.getBank(bankId, cc.callContext) + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + _ <- Helper.booleanToFuture(failMsg = CustomerTypeMismatch, 404, callContext) { + customer.customerType == "INDIVIDUAL" + } + (customerAttributes, callContext) <- NewStyle.function.getCustomerAttributes( + bankId, + CustomerId(customerId), + callContext: Option[CallContext]) + } yield { + (JSONFactory600.createCustomerWithAttributesJson(customer, customerAttributes), HttpCode.`200`(callContext)) + } + } + } + + // Corporate Customer Endpoints + + staticResourceDocs += ResourceDoc( + createCorporateCustomer, + implementedInApiVersion, + nameOf(createCorporateCustomer), + "POST", + "/banks/BANK_ID/corporate-customers", + "Create Corporate Customer", + s"""Create a corporate customer. + | + |This endpoint is specifically for creating corporate customers. + |Individual-oriented fields (relationship_status, dependants, highest_education_attained, employment_status, name_suffix, date_of_birth, face_image, title) are not available on this endpoint. + | + |**Required Fields:** + |- legal_name: The corporate entity's legal name + |- mobile_phone_number: The corporate entity's phone number + | + |**Optional Fields:** + |- customer_number: If not provided, a random number will be generated + |- email, credit_rating, credit_limit, kyc_status, last_ok_date, branch_id + |- customer_type: CORPORATE (default) or SUBSIDIARY + |- parent_customer_id: For SUBSIDIARY customers, the customer_id of the parent customer + | + |**Validations:** + |- customer_number cannot contain `::::` characters + |- customer_number must be unique for the bank + |- customer_type must be CORPORATE or SUBSIDIARY + |- parent_customer_id must reference an existing customer if provided + | + |Authentication is Required + |""", + postCorporateCustomerJsonV600, + customerJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + InvalidJsonFormat, + InvalidCustomerType, + ParentCustomerNotFound, + CustomerNumberAlreadyExists, + UserNotFoundById, + CustomerAlreadyExistsForUser, + CreateConsumerError, + UnknownError + ), + List(apiTagCorporateCustomer, apiTagCustomer), + Some(List(canCreateCustomer, canCreateCustomerAtAnyBank)) + ) + lazy val createCorporateCustomer: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "corporate-customers" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostCorporateCustomerJsonV600 ", 400, cc.callContext) { + json.extract[PostCorporateCustomerJsonV600] + } + customerNumber = postedData.customer_number.getOrElse(Random.nextInt(Integer.MAX_VALUE).toString) + _ <- Helper.booleanToFuture(failMsg = s"$InvalidJsonFormat customer_number can not contain `::::` characters", cc = cc.callContext) { + !`checkIfContains::::` (customerNumber) + } + (_, callContext) <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, cc.callContext) + customerType = postedData.customer_type.getOrElse("CORPORATE") + _ <- Helper.booleanToFuture(failMsg = InvalidCustomerType + " For corporate customers, must be CORPORATE or SUBSIDIARY.", 400, callContext) { + List("CORPORATE", "SUBSIDIARY").contains(customerType) + } + parentCustomerIdValue = postedData.parent_customer_id.getOrElse("") + _ <- if (parentCustomerIdValue.nonEmpty) { + NewStyle.function.getCustomerByCustomerId(parentCustomerIdValue, callContext).map(_ => ()) + } else { + Future.successful(()) + } + (customer, callContext) <- NewStyle.function.createCustomerC2( + bankId, + postedData.legal_name, + customerNumber, + postedData.mobile_phone_number, + postedData.email.getOrElse(""), + CustomerFaceImage(null, ""), // not applicable for corporate + null, // date_of_birth - not applicable for corporate + "", // relationship_status - not applicable for corporate + 0, // dependants - not applicable for corporate + Nil, // dob_of_dependants - not applicable for corporate + "", // highest_education_attained - not applicable for corporate + "", // employment_status - not applicable for corporate + postedData.kyc_status.getOrElse(false), + postedData.last_ok_date.getOrElse(null), + postedData.credit_rating.map(i => CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => CreditLimit(i.currency, i.amount)), + "", // title - not applicable for corporate + postedData.branch_id.getOrElse(""), + "", // name_suffix - not applicable for corporate + customerType, + parentCustomerIdValue, + callContext, + ) + } yield { + (JSONFactory600.createCustomerJson(customer), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCorporateCustomersAtOneBank, + implementedInApiVersion, + nameOf(getCorporateCustomersAtOneBank), + "GET", + "/banks/BANK_ID/corporate-customers", + "Get Corporate Customers at Bank", + s"""Get Corporate Customers at Bank. + | + |Returns a list of customers with customer_type CORPORATE or SUBSIDIARY at the specified bank. + | + |**Date Format:** + |date_of_birth and dob_of_dependants are returned in ISO 8601 date format: **YYYY-MM-DD** + | + |**Query Parameters:** + |- limit: Maximum number of customers to return (optional) + |- offset: Number of customers to skip for pagination (optional) + |- sort_direction: Sort direction - ASC or DESC (optional) + | + |Authentication is Required + |""", + EmptyBody, + customerJSONsV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagCorporateCustomer, apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getCorporateCustomersAtOneBank: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "corporate-customers" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (requestParams, callContext) <- extractQueryParams(cc.url, List("limit", "offset", "sort_direction"), cc.callContext) + (customers, callContext) <- NewStyle.function.getCustomersByCustomerTypes(bankId, List("CORPORATE", "SUBSIDIARY"), callContext, requestParams) + } yield { + (JSONFactory600.createCustomersJson(customers), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCorporateCustomerByCustomerId, + implementedInApiVersion, + nameOf(getCorporateCustomerByCustomerId), + "GET", + "/banks/BANK_ID/corporate-customers/CUSTOMER_ID", + "Get Corporate Customer by CUSTOMER_ID", + s"""Gets the Corporate Customer specified by CUSTOMER_ID. + | + |Returns 404 if the customer exists but is not of type CORPORATE or SUBSIDIARY. + |Use the generic /customers/CUSTOMER_ID endpoint for any customer type. + | + |**Date Format:** + |date_of_birth and dob_of_dependants are returned in ISO 8601 date format: **YYYY-MM-DD** + | + |Authentication is Required + |""", + EmptyBody, + customerWithAttributesJsonV600, + List( + $AuthenticatedUserIsRequired, + CustomerTypeMismatch, + UnknownError + ), + List(apiTagCorporateCustomer, apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getCorporateCustomerByCustomerId: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "corporate-customers" :: customerId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- NewStyle.function.getBank(bankId, cc.callContext) + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + _ <- Helper.booleanToFuture(failMsg = CustomerTypeMismatch, 404, callContext) { + List("CORPORATE", "SUBSIDIARY").contains(customer.customerType) + } + (customerAttributes, callContext) <- NewStyle.function.getCustomerAttributes( + bankId, + CustomerId(customerId), + callContext: Option[CallContext]) + } yield { + (JSONFactory600.createCustomerWithAttributesJson(customer, customerAttributes), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCorporateCustomerSubsidiaries, + implementedInApiVersion, + nameOf(getCorporateCustomerSubsidiaries), + "GET", + "/banks/BANK_ID/corporate-customers/CUSTOMER_ID/subsidiaries", + "Get Corporate Customer Subsidiaries", + s"""Get the subsidiary customers of a corporate customer. + | + |Returns a list of customers whose parent_customer_id matches the specified CUSTOMER_ID. + |The specified customer must be of type CORPORATE or SUBSIDIARY. + | + |Authentication is Required + |""", + EmptyBody, + customerJSONsV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerNotFoundByCustomerId, + CustomerTypeMismatch, + UnknownError + ), + List(apiTagCorporateCustomer, apiTagCustomer), + Some(List(canGetCustomersAtOneBank)) + ) + lazy val getCorporateCustomerSubsidiaries: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "corporate-customers" :: customerId :: "subsidiaries" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- NewStyle.function.getBank(bankId, cc.callContext) + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + _ <- Helper.booleanToFuture(failMsg = CustomerTypeMismatch, 404, callContext) { + List("CORPORATE", "SUBSIDIARY").contains(customer.customerType) + } + (children, callContext) <- NewStyle.function.getCustomersByParentCustomerId(bankId, customerId, callContext) + } yield { + (JSONFactory600.createCustomersJson(children), HttpCode.`200`(callContext)) + } + } + } + staticResourceDocs += ResourceDoc( getMetrics, implementedInApiVersion, @@ -4858,13 +5359,11 @@ trait APIMethods600 { implementedInApiVersion, nameOf(resetPasswordUrl), "POST", - "/users/password-reset", + "/management/user/reset-password-url", "Create Password Reset URL and Send Email", s"""Create a password reset URL for a user and automatically send it via email. | - |This endpoint generates a password reset URL and sends it to the user's email address. - | - |${userAuthenticationMessage(true)} + |Authentication is Required. | |Behavior: |- Generates a unique password reset token @@ -4901,16 +5400,10 @@ trait APIMethods600 { ) lazy val resetPasswordUrl: OBPEndpoint = { - case "users" :: "password-reset" :: Nil JsonPost json -> _ => { + case "management" :: "user" :: "reset-password-url" :: Nil JsonPost json -> _ => { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- Helper.booleanToFuture( - failMsg = ErrorMessages.NotAllowedEndpoint, - cc = callContext - ) { - APIUtil.getPropsAsBoolValue("ResetPasswordUrlEnabled", false) - } postedData <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[PostResetPasswordUrlJsonV600]}", 400, @@ -4946,15 +5439,22 @@ trait APIMethods600 { val user: code.model.dataAccess.AuthUser = authUser // Generate new reset token - // Reset the unique ID token by generating a new random value (32 chars, no hyphens) user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) user.save + // Create a JWT token with the uniqueId as subject and configurable expiry + val expiryMinutes = APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(user.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + // Construct reset URL using portal_hostname - // Get the unique ID value for the reset token URL val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + "/user_mgt/reset_password/" + - java.net.URLEncoder.encode(user.uniqueId.get, "UTF-8") + java.net.URLEncoder.encode(jwtToken, "UTF-8") // Send email using CommonsEmailWrapper (like createUser does) val textContent = Some(s"Please use the following link to reset your password: $resetPasswordLink") @@ -4980,6 +5480,233 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + resetPasswordUrlAnonymous, + implementedInApiVersion, + nameOf(resetPasswordUrlAnonymous), + "POST", + "/users/password-reset-url", + "Request Password Reset Email", + s"""Request a password reset email for a user. No authentication is required. + | + |Authentication is NOT Required. + | + |This endpoint is designed for users who have forgotten their password and cannot log in. + | + |Behavior: + |- Looks up the user by username and email + |- Generates a unique password reset token + |- Creates a reset URL using the portal_external_url property (falls back to API hostname) + |- Sends an email to the user with the reset link + | + |Required fields: + |- username: The user's username (typically email) + |- email: The user's email address (must match username) + | + |The user must exist and be validated before a reset email can be sent. + | + |Email configuration must be set up correctly for email delivery to work. + | + |Note: For security reasons, this endpoint returns a generic success message regardless of + |whether the user was found, to prevent user enumeration. + | + |""".stripMargin, + PostResetPasswordUrlAnonymousJsonV600( + "user@example.com", + "user@example.com" + ), + ResetPasswordUrlAnonymousResponseJsonV600( + "If the account exists, a password reset email has been sent." + ), + List( + InvalidJsonFormat, + UnknownError + ), + List(apiTagUser), + Some(List()) + ) + + lazy val resetPasswordUrlAnonymous: OBPEndpoint = { + case "users" :: "password-reset-url" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostResetPasswordUrlAnonymousJsonV600]}", + 400, + callContext + ) { + json.extract[PostResetPasswordUrlAnonymousJsonV600] + } + } yield { + // Look up the user - but always return the same response to prevent user enumeration + val authUserBox = code.model.dataAccess.AuthUser.find( + net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username) + ) + + authUserBox match { + case Full(user) if user.validated.get && user.email.get == postedData.email => + // Generate new reset token + user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) + user.save + + // Create a JWT token with the uniqueId as subject and configurable expiry + val expiryMinutes = APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(user.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + + // Construct reset URL + val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + + "/user_mgt/reset_password/" + + java.net.URLEncoder.encode(jwtToken, "UTF-8") + + // Send email + val textContent = Some(s"Please use the following link to reset your password: $resetPasswordLink") + val htmlContent = Some(s"
Please use the following link to reset your password:
") + val subjectContent = "Reset your password - " + user.username.get + + val emailContent = code.api.util.CommonsEmailWrapper.EmailContent( + from = code.model.dataAccess.AuthUser.emailFrom, + to = List(user.email.get), + bcc = code.model.dataAccess.AuthUser.bccEmail.toList, + subject = subjectContent, + textContent = textContent, + htmlContent = htmlContent + ) + + code.api.util.CommonsEmailWrapper.sendHtmlEmail(emailContent) + + case _ => + // Do nothing - return same response to prevent user enumeration + } + + ( + ResetPasswordUrlAnonymousResponseJsonV600("If the account exists, a password reset email has been sent."), + HttpCode.`201`(callContext) + ) + } + } + } + + staticResourceDocs += ResourceDoc( + resetPasswordComplete, + implementedInApiVersion, + nameOf(resetPasswordComplete), + "POST", + "/users/password", + "Complete Password Reset", + s"""Complete a password reset using the token received via email. + | + |Authentication is NOT Required. + | + |After requesting a password reset email (via POST /management/user/reset-password-url or + |POST /users/password-reset-url), the user receives an email with a reset link containing a JWT token. + | + |This endpoint accepts that token along with a new password and completes the password reset. + | + |The token is a signed JWT with a configurable expiry (default: 120 minutes). + |Configure the expiry with the property: password_reset_token_expiry_minutes + | + |Required fields: + |- token: The JWT reset token from the password reset email + |- new_password: The new password (must meet strong password requirements) + | + |The token is single-use. Once the password is reset, the token is invalidated. + | + |""".stripMargin, + PostResetPasswordCompleteJsonV600( + "a1b2c3d4e5f67890abcdef1234567890", + "NewStr0ng!Password" + ), + ResetPasswordCompleteResponseJsonV600( + "Password has been reset successfully." + ), + List( + InvalidJsonFormat, + InvalidStrongPasswordFormat, + UnknownError + ), + List(apiTagUser), + Some(List()) + ) + + lazy val resetPasswordComplete: OBPEndpoint = { + case "users" :: "password" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, callContext) <- anonymousAccess(cc) + _ <- Helper.booleanToFuture( + failMsg = ErrorMessages.NotAllowedEndpoint, + cc = callContext + ) { + APIUtil.getPropsAsBoolValue("ResetPasswordUrlEnabled", false) + } + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostResetPasswordCompleteJsonV600]}", + 400, + callContext + ) { + json.extract[PostResetPasswordCompleteJsonV600] + } + token = postedData.token.trim + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Token cannot be empty", cc = callContext) { + token.nonEmpty + } + // Validate password strength + _ <- Helper.booleanToFuture(ErrorMessages.InvalidStrongPasswordFormat, 400, callContext) { + fullPasswordValidation(postedData.new_password) + } + // Verify JWT signature + _ <- Helper.booleanToFuture(s"$UnknownError Invalid or expired reset token", 400, callContext) { + try { + CertificateUtil.verifywtWithHmacProtection(token) + } catch { + case _: Exception => false + } + } + // Check JWT expiration and extract subject (uniqueId) + uniqueId <- NewStyle.function.tryons( + s"$UnknownError Invalid or expired reset token", + 400, + callContext + ) { + val signedJWT = com.nimbusds.jwt.SignedJWT.parse(token) + val expiration = signedJWT.getJWTClaimsSet.getExpirationTime + if (expiration == null || expiration.before(new java.util.Date())) { + throw new Exception("Token has expired") + } + signedJWT.getJWTClaimsSet.getSubject + } + // Find user by uniqueId from JWT + authUserBox <- Future { + code.model.dataAccess.AuthUser.findUserByValidationToken(uniqueId) + } + user <- NewStyle.function.tryons( + s"$UnknownError Invalid or expired reset token", + 400, + callContext + ) { + authUserBox.openOrThrowException("User not found") + } + } yield { + // Set the new password + user.password.set(postedData.new_password) + // Reset the unique ID token to invalidate the reset link + user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) + user.save + + ( + ResetPasswordCompleteResponseJsonV600("Password has been reset successfully."), + HttpCode.`201`(callContext) + ) + } + } + } + staticResourceDocs += ResourceDoc( getWebUiProp, implementedInApiVersion, @@ -7836,11 +8563,11 @@ trait APIMethods600 { schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], _links = Some(DynamicEntityLinksJsonV600( related = List( - RelatedLinkJsonV600("list", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences", "GET"), - RelatedLinkJsonV600("create", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences", "POST"), - RelatedLinkJsonV600("read", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), - RelatedLinkJsonV600("update", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), - RelatedLinkJsonV600("delete", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") + RelatedLinkJsonV600("personal-list", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences", "GET"), + RelatedLinkJsonV600("personal-create", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences", "POST"), + RelatedLinkJsonV600("personal-read", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), + RelatedLinkJsonV600("personal-update", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), + RelatedLinkJsonV600("personal-delete", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") ) )) ) @@ -7900,11 +8627,11 @@ trait APIMethods600 { schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string"}, "language": {"type": "string"}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject], _links = Some(DynamicEntityLinksJsonV600( related = List( - RelatedLinkJsonV600("list", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences", "GET"), - RelatedLinkJsonV600("create", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences", "POST"), - RelatedLinkJsonV600("read", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), - RelatedLinkJsonV600("update", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), - RelatedLinkJsonV600("delete", s"/obp/${ApiVersion.v6_0_0}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") + RelatedLinkJsonV600("personal-list", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences", "GET"), + RelatedLinkJsonV600("personal-create", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences", "POST"), + RelatedLinkJsonV600("personal-read", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "GET"), + RelatedLinkJsonV600("personal-update", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "PUT"), + RelatedLinkJsonV600("personal-delete", s"/obp/${ApiVersion.`dynamic-entity`}/my/customer_preferences/CUSTOMER_PREFERENCES_ID", "DELETE") ) )) ) 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 9d13ed342c..c971e7cd4c 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 @@ -474,9 +474,46 @@ case class PostCustomerJsonV600( last_ok_date: Option[Date] = None, title: Option[String] = None, branch_id: Option[String] = None, + name_suffix: Option[String] = None, + customer_type: Option[String] = None, + parent_customer_id: Option[String] = None +) + +case class PostRetailCustomerJsonV600( + legal_name: String, + customer_number: Option[String] = None, + mobile_phone_number: String, + email: Option[String] = None, + face_image: Option[CustomerFaceImageJson] = None, + date_of_birth: Option[String] = None, + relationship_status: Option[String] = None, + dependants: Option[Int] = None, + dob_of_dependants: Option[List[String]] = None, + credit_rating: Option[CustomerCreditRatingJSON] = None, + credit_limit: Option[AmountOfMoneyJsonV121] = None, + highest_education_attained: Option[String] = None, + employment_status: Option[String] = None, + kyc_status: Option[Boolean] = None, + last_ok_date: Option[Date] = None, + title: Option[String] = None, + branch_id: Option[String] = None, name_suffix: Option[String] = None ) +case class PostCorporateCustomerJsonV600( + legal_name: String, + customer_number: Option[String] = None, + mobile_phone_number: String, + email: Option[String] = None, + credit_rating: Option[CustomerCreditRatingJSON] = None, + credit_limit: Option[AmountOfMoneyJsonV121] = None, + kyc_status: Option[Boolean] = None, + last_ok_date: Option[Date] = None, + branch_id: Option[String] = None, + customer_type: Option[String] = None, + parent_customer_id: Option[String] = None +) + case class CustomerJsonV600( bank_id: String, customer_id: String, @@ -497,7 +534,9 @@ case class CustomerJsonV600( last_ok_date: Date, title: String, branch_id: String, - name_suffix: String + name_suffix: String, + customer_type: String, + parent_customer_id: String ) case class CustomerJSONsV600(customers: List[CustomerJsonV600]) @@ -523,6 +562,8 @@ case class CustomerWithAttributesJsonV600( title: String, branch_id: String, name_suffix: String, + customer_type: String, + parent_customer_id: String, customer_attributes: List[CustomerAttributeResponseJsonV300] ) @@ -704,7 +745,8 @@ case class DynamicEntityDefinitionWithCountJsonV600( has_community_access: Boolean = false, personal_requires_role: Boolean = false, schema: net.liftweb.json.JsonAST.JObject, - record_count: Long + record_count: Long, + _links: Option[DynamicEntityLinksJsonV600] = None ) case class DynamicEntitiesWithCountJsonV600( @@ -1267,7 +1309,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { last_ok_date = cInfo.lastOkDate, title = cInfo.title, branch_id = cInfo.branchId, - name_suffix = cInfo.nameSuffix + name_suffix = cInfo.nameSuffix, + customer_type = cInfo.customerType, + parent_customer_id = cInfo.parentCustomerId ) } @@ -1318,6 +1362,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { title = cInfo.title, branch_id = cInfo.branchId, name_suffix = cInfo.nameSuffix, + customer_type = cInfo.customerType, + parent_customer_id = cInfo.parentCustomerId, customer_attributes = customerAttributes.map(customerAttribute => CustomerAttributeResponseJsonV300( customer_attribute_id = customerAttribute.customerAttributeId, @@ -1492,6 +1538,20 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { case class ResetPasswordUrlJsonV600(reset_password_url: String) + case class PostResetPasswordUrlAnonymousJsonV600( + username: String, + email: String + ) + + case class ResetPasswordUrlAnonymousResponseJsonV600(message: String) + + case class PostResetPasswordCompleteJsonV600( + token: String, + new_password: String + ) + + case class ResetPasswordCompleteResponseJsonV600(message: String) + case class ScannedApiVersionJsonV600( url_prefix: String, api_standard: String, @@ -1874,6 +1934,46 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { * ] * } */ + private def buildDynamicEntityLinks(entity: code.dynamicEntity.DynamicEntityCommons): DynamicEntityLinksJsonV600 = { + val entityName = entity.entityName + val idPlaceholder = net.liftweb.util.StringHelpers.snakify(entityName + "Id").toUpperCase() + val bankPrefix = entity.bankId match { + case Some(bankId) => s"/obp/${ApiVersion.`dynamic-entity`}/banks/$bankId" + case None => s"/obp/${ApiVersion.`dynamic-entity`}" + } + + val personalLinks = if (entity.hasPersonalEntity) { + val baseUrl = s"$bankPrefix/my/$entityName" + List( + RelatedLinkJsonV600("personal-list", baseUrl, "GET"), + RelatedLinkJsonV600("personal-create", baseUrl, "POST"), + RelatedLinkJsonV600("personal-read", s"$baseUrl/$idPlaceholder", "GET"), + RelatedLinkJsonV600("personal-update", s"$baseUrl/$idPlaceholder", "PUT"), + RelatedLinkJsonV600("personal-delete", s"$baseUrl/$idPlaceholder", "DELETE") + ) + } else Nil + + val publicLinks = if (entity.hasPublicAccess) { + val baseUrl = s"$bankPrefix/public/$entityName" + List( + RelatedLinkJsonV600("public-list", baseUrl, "GET"), + RelatedLinkJsonV600("public-read", s"$baseUrl/$idPlaceholder", "GET") + ) + } else Nil + + val communityLinks = if (entity.hasCommunityAccess) { + val baseUrl = s"$bankPrefix/community/$entityName" + List( + RelatedLinkJsonV600("community-list", baseUrl, "GET"), + RelatedLinkJsonV600("community-read", s"$baseUrl/$idPlaceholder", "GET") + ) + } else Nil + + DynamicEntityLinksJsonV600( + related = personalLinks ++ publicLinks ++ communityLinks + ) + } + def createMyDynamicEntitiesJson(dynamicEntities: List[code.dynamicEntity.DynamicEntityCommons]): MyDynamicEntitiesJsonV600 = { import net.liftweb.json.JsonAST._ import net.liftweb.json.parse @@ -1899,23 +1999,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") ) - // Build HATEOAS-style links for this dynamic entity - val entityName = entity.entityName - val idPlaceholder = StringHelpers.snakify(entityName + "Id").toUpperCase() - val baseUrl = entity.bankId match { - case Some(bankId) => s"/obp/${ApiVersion.v6_0_0}/banks/$bankId/my/$entityName" - case None => s"/obp/${ApiVersion.v6_0_0}/my/$entityName" - } - - val links = DynamicEntityLinksJsonV600( - related = List( - RelatedLinkJsonV600("list", baseUrl, "GET"), - RelatedLinkJsonV600("create", baseUrl, "POST"), - RelatedLinkJsonV600("read", s"$baseUrl/$idPlaceholder", "GET"), - RelatedLinkJsonV600("update", s"$baseUrl/$idPlaceholder", "PUT"), - RelatedLinkJsonV600("delete", s"$baseUrl/$idPlaceholder", "DELETE") - ) - ) + val links = buildDynamicEntityLinks(entity) DynamicEntityDefinitionJsonV600( dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), @@ -1962,6 +2046,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { throw new IllegalStateException(s"Could not extract schema for entity '${entity.entityName}' from metadataJson") ) + val links = buildDynamicEntityLinks(entity) + DynamicEntityDefinitionWithCountJsonV600( dynamic_entity_id = entity.dynamicEntityId.getOrElse(""), entity_name = entity.entityName, @@ -1972,7 +2058,8 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { has_community_access = entity.hasCommunityAccess, personal_requires_role = entity.personalRequiresRole, schema = schema, - record_count = recordCount + record_count = recordCount, + _links = Some(links) ) } ) diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 8ef5483e37..c43a9dfb94 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -1135,6 +1135,8 @@ trait Connector extends MdcLoggable { title: String, branchId: String, nameSuffix: String, + customerType: String = "", + parentCustomerId: String = "", callContext: Option[CallContext], ): OBPReturnType[Box[Customer]] = Future{(Failure(setUnimplementedError(nameOf(createCustomerC2 _))), callContext)} @@ -1198,11 +1200,23 @@ trait Connector extends MdcLoggable { title: Option[String], branchId: Option[String], nameSuffix: Option[String], + customerType: Option[String] = None, + parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = Future { (Failure(setUnimplementedError(nameOf(updateCustomerGeneralData _))), callContext) } + def getCustomersByParentCustomerId(bankId: BankId, parentCustomerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[Customer]]] = + Future { + (Failure(setUnimplementedError(nameOf(getCustomersByParentCustomerId _))), callContext) + } + + def getCustomersByCustomerTypes(bankId: BankId, customerTypes: List[String], callContext: Option[CallContext], queryParams: List[OBPQueryParam] = Nil): OBPReturnType[Box[List[Customer]]] = + Future { + (Failure(setUnimplementedError(nameOf(getCustomersByCustomerTypes _))), callContext) + } + def getCustomersByUserId(userId: String, callContext: Option[CallContext]): Future[Box[(List[Customer],Option[CallContext])]] = Future{Failure(setUnimplementedError(nameOf(getCustomersByUserId _)))} def getCustomerByCustomerIdLegacy(customerId: String, callContext: Option[CallContext]): Box[(Customer,Option[CallContext])]= Failure(setUnimplementedError(nameOf(getCustomerByCustomerIdLegacy _))) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index ef36b37249..c6899c616f 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -3389,6 +3389,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { title: String, branchId: String, nameSuffix: String, + customerType: String = "", + parentCustomerId: String = "", callContext: Option[CallContext] ): OBPReturnType[Box[Customer]] = Future { (CustomerX.customerProvider.vend.addCustomer( @@ -3410,7 +3412,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { creditLimit, title, branchId, - nameSuffix + nameSuffix, + customerType, + parentCustomerId ), callContext) } @@ -3453,6 +3457,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { title: Option[String], branchId: Option[String], nameSuffix: Option[String], + customerType: Option[String] = None, + parentCustomerId: Option[String] = None, callContext: Option[CallContext] ): OBPReturnType[Box[Customer]] = CustomerX.customerProvider.vend.updateCustomerGeneralData( @@ -3466,11 +3472,23 @@ object LocalMappedConnector extends Connector with MdcLoggable { employmentStatus, title, branchId, - nameSuffix + nameSuffix, + customerType, + parentCustomerId ) map { (_, callContext) } + override def getCustomersByParentCustomerId(bankId: BankId, parentCustomerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[Customer]]] = + CustomerX.customerProvider.vend.getCustomersByParentCustomerId(bankId, parentCustomerId) map { + (_, callContext) + } + + override def getCustomersByCustomerTypes(bankId: BankId, customerTypes: List[String], callContext: Option[CallContext], queryParams: List[OBPQueryParam]): OBPReturnType[Box[List[Customer]]] = + CustomerX.customerProvider.vend.getCustomersByCustomerTypes(bankId, customerTypes, queryParams) map { + (_, callContext) + } + def getCustomersByUserIdLegacy(userId: String, callContext: Option[CallContext]): Box[(List[Customer], Option[CallContext])] = { Full((CustomerX.customerProvider.vend.getCustomersByUserId(userId), callContext)) } diff --git a/obp-api/src/main/scala/code/bankconnectors/akka/AkkaConnector_vDec2018.scala b/obp-api/src/main/scala/code/bankconnectors/akka/AkkaConnector_vDec2018.scala index e9248d4c17..017ce18066 100644 --- a/obp-api/src/main/scala/code/bankconnectors/akka/AkkaConnector_vDec2018.scala +++ b/obp-api/src/main/scala/code/bankconnectors/akka/AkkaConnector_vDec2018.scala @@ -3836,7 +3836,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -3887,7 +3889,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -3939,7 +3943,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -3971,7 +3977,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { employmentStatus=Some(employmentStatusExample.value), title=Some(titleExample.value), branchId=Some(branchIdExample.value), - nameSuffix=Some(nameSuffixExample.value)) + nameSuffix=Some(nameSuffixExample.value), + customerType=Some(customerTypeExample.value), + parentCustomerId=Some(parentCustomerIdExample.value)) ), exampleInboundMessage = ( InBoundUpdateCustomerGeneralData(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, @@ -3998,15 +4006,17 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) - override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { + override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], customerType: Option[String] = None, parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { import com.openbankproject.commons.dto.{InBoundUpdateCustomerGeneralData => InBound, OutBoundUpdateCustomerGeneralData => OutBound} - val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix) - val response: Future[Box[InBound]] = (southSideActor ? req).mapTo[InBound].recoverWith(recoverFunction).map(Box !! _) + val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix, customerType, parentCustomerId) + val response: Future[Box[InBound]] = (southSideActor ? req).mapTo[InBound].recoverWith(recoverFunction).map(Box !! _) response.map(convertToTuple[CustomerCommons](callContext)) } @@ -4046,7 +4056,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4095,7 +4107,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4393,7 +4407,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4442,7 +4458,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5145,7 +5163,9 @@ object AkkaConnector_vDec2018 extends Connector with AkkaConnectorActorInit { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), exampleInboundMessage = ( InBoundGetCustomerAttributesForCustomers(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, diff --git a/obp-api/src/main/scala/code/bankconnectors/akka/actor/SouthSideActorOfAkkaConnector.scala b/obp-api/src/main/scala/code/bankconnectors/akka/actor/SouthSideActorOfAkkaConnector.scala index d06a3b3751..78f083579f 100644 --- a/obp-api/src/main/scala/code/bankconnectors/akka/actor/SouthSideActorOfAkkaConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/akka/actor/SouthSideActorOfAkkaConnector.scala @@ -128,7 +128,9 @@ object Transformer { lastOkDate = customer.lastOkDate, title = customer.title, branchId = customer.branchId, - nameSuffix = customer.nameSuffix + nameSuffix = customer.nameSuffix, + customerType = customer.customerType, + parentCustomerId = customer.parentCustomerId ) } diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/Adapter/MockedRabbitMqAdapter.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/Adapter/MockedRabbitMqAdapter.scala index 3d0b863d54..febb088fdc 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/Adapter/MockedRabbitMqAdapter.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/Adapter/MockedRabbitMqAdapter.scala @@ -1745,7 +1745,7 @@ class ServerCallback(val ch: Channel) extends DeliverCallback with MdcLoggable{ } } else if (obpMessageId.contains("update_customer_general_data")) { val outBound = json.parse(message).extract[OutBoundUpdateCustomerGeneralData] - val obpMappedResponse = code.bankconnectors.LocalMappedConnector.updateCustomerGeneralData(outBound.customerId,outBound.legalName,outBound.faceImage,outBound.dateOfBirth,outBound.relationshipStatus,outBound.dependents,outBound.highestEducationAttained,outBound.employmentStatus,outBound.title,outBound.branchId,outBound.nameSuffix,None).map(_._1.head) + val obpMappedResponse = code.bankconnectors.LocalMappedConnector.updateCustomerGeneralData(outBound.customerId,outBound.legalName,outBound.faceImage,outBound.dateOfBirth,outBound.relationshipStatus,outBound.dependents,outBound.highestEducationAttained,outBound.employmentStatus,outBound.title,outBound.branchId,outBound.nameSuffix,None,None,None).map(_._1.head) obpMappedResponse.map(response => InBoundUpdateCustomerGeneralData( diff --git a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala index 88b7fe1b28..3d16eeee4d 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rabbitmq/RabbitMQConnector_vOct2024.scala @@ -4533,7 +4533,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4584,7 +4586,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4636,7 +4640,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4668,7 +4674,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { employmentStatus=Some(employmentStatusExample.value), title=Some(titleExample.value), branchId=Some(branchIdExample.value), - nameSuffix=Some(nameSuffixExample.value)) + nameSuffix=Some(nameSuffixExample.value), + customerType=Some(customerTypeExample.value), + parentCustomerId=Some(parentCustomerIdExample.value)) ), exampleInboundMessage = ( InBoundUpdateCustomerGeneralData(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, @@ -4695,14 +4703,16 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) - override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { + override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], customerType: Option[String] = None, parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { import com.openbankproject.commons.dto.{InBoundUpdateCustomerGeneralData => InBound, OutBoundUpdateCustomerGeneralData => OutBound} - val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix) + val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix, customerType, parentCustomerId) val response: Future[Box[InBound]] = sendRequest[InBound]("obp_update_customer_general_data", req, callContext) response.map(convertToTuple[CustomerCommons](callContext)) } @@ -4743,7 +4753,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4791,7 +4803,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4840,7 +4854,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5138,7 +5154,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5187,7 +5205,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5921,7 +5941,9 @@ trait RabbitMQConnector_vOct2024 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), exampleInboundMessage = ( InBoundGetCustomerAttributesForCustomers(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, diff --git a/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala b/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala index 2cdf943901..3ecb945a52 100644 --- a/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala +++ b/obp-api/src/main/scala/code/bankconnectors/rest/RestConnector_vMar2019.scala @@ -4463,7 +4463,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4514,7 +4516,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4566,7 +4570,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4598,7 +4604,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { employmentStatus=Some(employmentStatusExample.value), title=Some(titleExample.value), branchId=Some(branchIdExample.value), - nameSuffix=Some(nameSuffixExample.value)) + nameSuffix=Some(nameSuffixExample.value), + customerType=Some(customerTypeExample.value), + parentCustomerId=Some(parentCustomerIdExample.value)) ), exampleInboundMessage = ( InBoundUpdateCustomerGeneralData(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, @@ -4625,14 +4633,16 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) - override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { + override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], customerType: Option[String] = None, parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { import com.openbankproject.commons.dto.{InBoundUpdateCustomerGeneralData => InBound, OutBoundUpdateCustomerGeneralData => OutBound} - val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix) + val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix, customerType, parentCustomerId) val response: Future[Box[InBound]] = sendRequest[InBound](getUrl(callContext, "updateCustomerGeneralData"), HttpMethods.POST, req, callContext) response.map(convertToTuple[CustomerCommons](callContext)) } @@ -4673,7 +4683,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4721,7 +4733,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4770,7 +4784,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5068,7 +5084,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5117,7 +5135,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5820,7 +5840,9 @@ trait RestConnector_vMar2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), exampleInboundMessage = ( InBoundGetCustomerAttributesForCustomers(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, diff --git a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala index fd5b7acd90..b3a5b54d36 100644 --- a/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala +++ b/obp-api/src/main/scala/code/bankconnectors/storedprocedure/StoredProcedureConnector_vDec2019.scala @@ -4668,7 +4668,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4719,7 +4721,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4771,7 +4775,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4803,7 +4809,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { employmentStatus=Some(employmentStatusExample.value), title=Some(titleExample.value), branchId=Some(branchIdExample.value), - nameSuffix=Some(nameSuffixExample.value)) + nameSuffix=Some(nameSuffixExample.value), + customerType=Some(customerTypeExample.value), + parentCustomerId=Some(parentCustomerIdExample.value)) ), exampleInboundMessage = ( InBoundUpdateCustomerGeneralData(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, @@ -4830,14 +4838,16 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) - override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { + override def updateCustomerGeneralData(customerId: String, legalName: Option[String], faceImage: Option[CustomerFaceImageTrait], dateOfBirth: Option[Date], relationshipStatus: Option[String], dependents: Option[Int], highestEducationAttained: Option[String], employmentStatus: Option[String], title: Option[String], branchId: Option[String], nameSuffix: Option[String], customerType: Option[String] = None, parentCustomerId: Option[String] = None, callContext: Option[CallContext]): OBPReturnType[Box[Customer]] = { import com.openbankproject.commons.dto.{InBoundUpdateCustomerGeneralData => InBound, OutBoundUpdateCustomerGeneralData => OutBound} - val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix) + val req = OutBound(callContext.map(_.toOutboundAdapterCallContext).orNull, customerId, legalName, faceImage, dateOfBirth, relationshipStatus, dependents, highestEducationAttained, employmentStatus, title, branchId, nameSuffix, customerType, parentCustomerId) val response: Future[Box[InBound]] = sendRequest[InBound]("obp_update_customer_general_data", req, callContext) response.map(convertToTuple[CustomerCommons](callContext)) } @@ -4878,7 +4888,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4926,7 +4938,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -4975,7 +4989,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value)) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId="")) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5273,7 +5289,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -5322,7 +5340,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), adapterImplementation = Some(AdapterImplementation("- Core", 1)) ) @@ -6056,7 +6076,9 @@ trait StoredProcedureConnector_vDec2019 extends Connector with MdcLoggable { lastOkDate=toDate(customerLastOkDateExample), title=customerTitleExample.value, branchId=branchIdExample.value, - nameSuffix=nameSuffixExample.value))) + nameSuffix=nameSuffixExample.value, + customerType="", + parentCustomerId=""))) ), exampleInboundMessage = ( InBoundGetCustomerAttributesForCustomers(inboundAdapterCallContext=MessageDocsSwaggerDefinitions.inboundAdapterCallContext, diff --git a/obp-api/src/main/scala/code/customer/CustomerProvider.scala b/obp-api/src/main/scala/code/customer/CustomerProvider.scala index 2f7952b1a9..3d4748b67f 100644 --- a/obp-api/src/main/scala/code/customer/CustomerProvider.scala +++ b/obp-api/src/main/scala/code/customer/CustomerProvider.scala @@ -66,9 +66,11 @@ trait CustomerProvider { lastOkDate: Date, creditRating: Option[CreditRatingTrait], creditLimit: Option[AmountOfMoneyTrait], - title: String, - branchId: String, - nameSuffix: String + title: String, + branchId: String, + nameSuffix: String, + customerType: String = "", + parentCustomerId: String = "" ): Box[Customer] def updateCustomerScaData(customerId: String, @@ -91,9 +93,15 @@ trait CustomerProvider { employmentStatus: Option[String], title: Option[String], branchId: Option[String], - nameSuffix: Option[String] + nameSuffix: Option[String], + customerType: Option[String] = None, + parentCustomerId: Option[String] = None ): Future[Box[Customer]] + def getCustomersByParentCustomerId(bankId: BankId, parentCustomerId: String): Future[Box[List[Customer]]] + + def getCustomersByCustomerTypes(bankId: BankId, customerTypes: List[String], queryParams: List[OBPQueryParam]): Future[Box[List[Customer]]] + def bulkDeleteCustomers(): Boolean def populateMissingUUIDs(): Boolean } \ No newline at end of file diff --git a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala index 9ef7a89949..de644968a2 100644 --- a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala +++ b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala @@ -156,9 +156,11 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { lastOkDate: Date, creditRating: Option[CreditRatingTrait], creditLimit: Option[AmountOfMoneyTrait], - title: String, - branchId: String, - nameSuffix: String + title: String, + branchId: String, + nameSuffix: String, + customerType: String = "", + parentCustomerId: String = "" ) : Box[Customer] = { val cr = creditRating match { @@ -196,6 +198,8 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { .mTitle(title) .mBranchId(branchId) .mNameSuffix(nameSuffix) + .mCustomerType(customerType) + .mParentCustomerId(parentCustomerId) .mIsPendingAgent(true) .mIsConfirmedAgent(false) .saveMe() @@ -264,6 +268,8 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { title: Option[String], branchId: Option[String], nameSuffix: Option[String], + customerType: Option[String] = None, + parentCustomerId: Option[String] = None, ): Future[Box[Customer]] = Future { MappedCustomer.find( By(MappedCustomer.mCustomerId, customerId) @@ -311,10 +317,30 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { case Some(nameSuffix) => c.mNameSuffix(nameSuffix) case _ => // There is no update } + customerType match { + case Some(customerType) => c.mCustomerType(customerType) + case _ => // There is no update + } + parentCustomerId match { + case Some(parentCustomerId) => c.mParentCustomerId(parentCustomerId) + case _ => // There is no update + } c.saveMe() } } + override def getCustomersByParentCustomerId(bankId: BankId, parentCustomerId: String): Future[Box[List[Customer]]] = Future { + Full(MappedCustomer.findAll( + By(MappedCustomer.mBank, bankId.value), + By(MappedCustomer.mParentCustomerId, parentCustomerId) + )) + } + + override def getCustomersByCustomerTypes(bankId: BankId, customerTypes: List[String], queryParams: List[OBPQueryParam]): Future[Box[List[Customer]]] = Future { + val mapperParams = Seq(By(MappedCustomer.mBank, bankId.value), ByList(MappedCustomer.mCustomerType, customerTypes)) ++ getOptionalParams(queryParams) + Full(MappedCustomer.findAll(mapperParams: _*)) + } + override def bulkDeleteCustomers(): Boolean = { MappedCustomer.bulkDelete_!!() } @@ -364,6 +390,12 @@ class MappedCustomer extends Customer with Agent with LongKeyedMapper[MappedCust object mTitle extends MappedString(this, 255) object mBranchId extends MappedString(this, 255) object mNameSuffix extends MappedString(this, 255) + object mCustomerType extends MappedString(this, 50) { + override def defaultValue = "INDIVIDUAL" + } + object mParentCustomerId extends MappedString(this, 255) { + override def defaultValue = "" + } object mIsPendingAgent extends MappedBoolean(this){ override def defaultValue = true } @@ -403,6 +435,8 @@ class MappedCustomer extends Customer with Agent with LongKeyedMapper[MappedCust override def title: String = mTitle.get override def branchId: String = mBranchId.get override def nameSuffix: String = mNameSuffix.get + override def customerType: String = mCustomerType.get + override def parentCustomerId: String = mParentCustomerId.get override def isConfirmedAgent: Boolean = mIsConfirmedAgent.get //This is for Agent diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index f696a23a30..d6615dbd65 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -49,7 +49,6 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm DynamicData.find( By(DynamicData.DynamicDataId, id), By(DynamicData.DynamicEntityName, entityName), - By(DynamicData.UserId, userId.getOrElse(null)), By(DynamicData.IsPersonalEntity, false), NullRef(DynamicData.BankId) ) match { 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 f4e402eca5..4a8a05cd59 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 @@ -548,4 +548,112 @@ class DynamicEntityTest extends V600ServerSetup { } } + + feature("v6.0.0 Dynamic Entity _links match resource doc URLs") { + + scenario("_links URLs for personal/public/community must match resource doc URLs", ApiEndpoint1, ApiEndpoint9, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateSystemLevelDynamicEntity.toString) + + // Create entity with all access flags enabled + val allFlagsEntity = parse( + """ + |{ + | "entity_name": "links_test", + | "has_personal_entity": true, + | "has_public_access": true, + | "has_community_access": true, + | "schema": { + | "description": "Entity to test _links correctness.", + | "required": ["name"], + | "properties": { + | "name": { + | "type": "string", + | "example": "Test" + | } + | } + | } + |} + """.stripMargin) + + val createRequest = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) + val createResponse = makePostRequest(createRequest, write(allFlagsEntity)) + createResponse.code should equal(201) + + val dynamicEntityId = (createResponse.body \ "dynamic_entity_id").extract[String] + + When("We GET available personal dynamic entities") + val getRequest = (v6_0_0_Request / "personal-dynamic-entities" / "available").GET <@(user1) + val getResponse = makeGetRequest(getRequest) + getResponse.code should equal(200) + + val entities = (getResponse.body \ "dynamic_entities").asInstanceOf[JArray].arr + val linksTestEntity = entities.find(e => (e \ "entity_name").extract[String] == "links_test") + linksTestEntity should not be empty + + val linksJson = linksTestEntity.get \ "_links" \ "related" + linksJson shouldBe a[JArray] + val links = linksJson.asInstanceOf[JArray].arr + + Then("_links should contain personal, public, and community links") + val linkMap = links.map { link => + val rel = (link \ "rel").extract[String] + val href = (link \ "href").extract[String] + val method = (link \ "method").extract[String] + (rel, href, method) + } + + And("_links URLs should use the dynamic-entity API version prefix") + val dynamicEntityPrefix = s"/obp/${ApiVersion.`dynamic-entity`}" + linkMap.foreach { case (_, href, _) => + href should startWith(dynamicEntityPrefix) + } + + And("_links should match the resource doc URLs for this entity") + import code.api.dynamic.entity.helper.DynamicEntityHelper + val resourceDocs = DynamicEntityHelper.operationToResourceDoc + + // Build expected URLs from resource docs + val entityName = "links_test" + import com.openbankproject.commons.model.enums.DynamicEntityOperation._ + + // Personal (My) resource doc URLs + val myGetAll = resourceDocs.get((GET_ALL, s"My$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val myCreate = resourceDocs.get((CREATE, s"My$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val myGetOne = resourceDocs.get((GET_ONE, s"My$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val myUpdate = resourceDocs.get((UPDATE, s"My$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val myDelete = resourceDocs.get((DELETE, s"My$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + + // Public resource doc URLs + val publicGetAll = resourceDocs.get((GET_ALL, s"Public$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val publicGetOne = resourceDocs.get((GET_ONE, s"Public$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + + // Community resource doc URLs + val communityGetAll = resourceDocs.get((GET_ALL, s"Community$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + val communityGetOne = resourceDocs.get((GET_ONE, s"Community$entityName")).map(rd => (s"$dynamicEntityPrefix${rd.requestUrl}", rd.requestVerb)) + + // Verify personal links match resource docs + myGetAll should not be empty + linkMap should contain(("personal-list", myGetAll.get._1, myGetAll.get._2)) + linkMap should contain(("personal-create", myCreate.get._1, myCreate.get._2)) + linkMap should contain(("personal-read", myGetOne.get._1, myGetOne.get._2)) + linkMap should contain(("personal-update", myUpdate.get._1, myUpdate.get._2)) + linkMap should contain(("personal-delete", myDelete.get._1, myDelete.get._2)) + + // Verify public links match resource docs + publicGetAll should not be empty + linkMap should contain(("public-list", publicGetAll.get._1, publicGetAll.get._2)) + linkMap should contain(("public-read", publicGetOne.get._1, publicGetOne.get._2)) + + // Verify community links match resource docs + communityGetAll should not be empty + linkMap should contain(("community-list", communityGetAll.get._1, communityGetAll.get._2)) + linkMap should contain(("community-read", communityGetOne.get._1, communityGetOne.get._2)) + + // Cleanup + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanDeleteSystemLevelDynamicEntity.toString) + val deleteRequest = (v4_0_0_Request / "management" / "system-dynamic-entities" / dynamicEntityId).DELETE <@(user1) + makeDeleteRequest(deleteRequest) + } + } + } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala index de3cb027d4..8bc09f4794 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala @@ -29,6 +29,7 @@ import java.util.UUID import com.openbankproject.commons.model.ErrorMessage import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ +import code.api.util.CertificateUtil import com.openbankproject.commons.util.ApiVersion import code.api.util.ErrorMessages._ import code.api.v6_0_0.APIMethods600 @@ -38,23 +39,24 @@ import code.model.dataAccess.{AuthUser, ResourceUser} import code.users.Users import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.User -import net.liftweb.common.Box +import net.liftweb.common.{Box, Full} import net.liftweb.json.Serialization.write import net.liftweb.mapper.By import org.scalatest.Tag /** - * Test suite for Password Reset URL endpoint (POST /obp/v6.0.0/management/user/password-reset) - * - * Tests cover: - * - Unauthorized access (no authentication) - * - Missing role (authenticated but no CanCreateResetPasswordUrl) - * - Successful password reset URL creation (with proper role) - * - User validation requirements - * - Email sending functionality + * Test suite for v6.0.0 Password Reset flow: + * - Authenticated: POST /obp/v6.0.0/management/user/reset-password-url + * - Anonymous request: POST /obp/v6.0.0/users/password-reset-url + * - Anonymous complete: POST /obp/v6.0.0/users/password */ class PasswordResetTest extends V600ServerSetup { + override def beforeAll(): Unit = { + super.beforeAll() + setPropsValues("ResetPasswordUrlEnabled" -> "true") + } + override def beforeEach() = { wipeTestData() super.beforeEach() @@ -62,22 +64,43 @@ class PasswordResetTest extends V600ServerSetup { ResourceUser.bulkDelete_!!(By(ResourceUser.providerId, postJson.username)) } - /** - * Test tags - * Example: To run tests with tag "getPermissions": - * mvn test -D tagsToInclude - * - * This is made possible by the scalatest maven plugin - */ object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) object ApiEndpoint1 extends Tag(nameOf(APIMethods600.Implementations6_0_0.resetPasswordUrl)) + object ApiEndpoint2 extends Tag(nameOf(APIMethods600.Implementations6_0_0.resetPasswordUrlAnonymous)) + object ApiEndpoint3 extends Tag(nameOf(APIMethods600.Implementations6_0_0.resetPasswordComplete)) lazy val postUserId = UUID.randomUUID.toString lazy val postJson = JSONFactory600.PostResetPasswordUrlJsonV600("marko", "marko@tesobe.com", postUserId) + val strongPassword = "StrongP@ssw0rd123!" + + /** Helper to create a JWT token for a given uniqueId with configurable expiry */ + def createJwtToken(uniqueId: String, expiryMinutes: Int = 120): String = { + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(uniqueId) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + CertificateUtil.jwtWithHmacProtection(claimsSet) + } + + /** Helper to create an expired JWT token */ + def createExpiredJwtToken(uniqueId: String): String = { + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(uniqueId) + .expirationTime(new java.util.Date(System.currentTimeMillis() - 1000L)) // 1 second in the past + .issueTime(new java.util.Date(System.currentTimeMillis() - 60000L)) + .build() + CertificateUtil.jwtWithHmacProtection(claimsSet) + } + + // ========================================== + // Authenticated endpoint: POST /management/user/reset-password-url + // ========================================== + feature("Reset password url v6.0.0 - Unauthorized access") { scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) { When("We make a request v6.0.0") - val request600 = (v6_0_0_Request / "users" / "password-reset").POST + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST val response600 = makePostRequest(request600, write(postJson)) Then("We should get a 401") response600.code should equal(401) @@ -89,7 +112,7 @@ class PasswordResetTest extends V600ServerSetup { feature("Reset password url v6.0.0 - Authorized access") { scenario("We will call the endpoint without the proper Role " + canCreateResetPasswordUrl, ApiEndpoint1, VersionOfApi) { When("We make a request v6.0.0 without a Role " + canCreateResetPasswordUrl) - val request600 = (v6_0_0_Request / "users" / "password-reset").POST <@(user1) + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val response600 = makePostRequest(request600, write(postJson)) Then("We should get a 403") response600.code should equal(403) @@ -102,7 +125,7 @@ class PasswordResetTest extends V600ServerSetup { val authUser: AuthUser = AuthUser.create.email(postJson.email).username(postJson.username).validated(true).saveMe() val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get) When("We make a request v6.0.0") - val request600 = (v6_0_0_Request / "users" / "password-reset").POST <@(user1) + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val response600 = makePostRequest(request600, write(postJson.copy(user_id = resourceUser.map(_.userId).getOrElse("")))) Then("We should get a 201") response600.code should equal(201) @@ -120,7 +143,7 @@ class PasswordResetTest extends V600ServerSetup { val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(false).saveMe() val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get) When("We make a request v6.0.0 with unvalidated user") - val request600 = (v6_0_0_Request / "users" / "password-reset").POST <@(user1) + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val testJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, testEmail, resourceUser.map(_.userId).getOrElse("")) val response600 = makePostRequest(request600, write(testJson)) Then("We should get a 400") @@ -139,7 +162,7 @@ class PasswordResetTest extends V600ServerSetup { val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(true).saveMe() val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get) When("We make a request v6.0.0 with mismatched email") - val request600 = (v6_0_0_Request / "users" / "password-reset").POST <@(user1) + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val testJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, wrongEmail, resourceUser.map(_.userId).getOrElse("")) val response600 = makePostRequest(request600, write(testJson)) Then("We should get a 400") @@ -153,7 +176,7 @@ class PasswordResetTest extends V600ServerSetup { scenario("We will call the endpoint with non-existent user", ApiEndpoint1, VersionOfApi) { Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString) When("We make a request v6.0.0 with non-existent user") - val request600 = (v6_0_0_Request / "users" / "password-reset").POST <@(user1) + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val nonExistentJson = JSONFactory600.PostResetPasswordUrlJsonV600("nonexistent@tesobe.com", "nonexistent@tesobe.com", UUID.randomUUID.toString) val response600 = makePostRequest(request600, write(nonExistentJson)) Then("We should get a 400") @@ -162,4 +185,230 @@ class PasswordResetTest extends V600ServerSetup { response600.body.extract[ErrorMessage].message should include("User not found") } } -} \ No newline at end of file + + // ========================================== + // Anonymous request endpoint: POST /users/password-reset-url + // ========================================== + + feature("Anonymous password reset url request v6.0.0") { + scenario("We will request a password reset for a valid user without authentication", ApiEndpoint2, VersionOfApi) { + val testUsername = "anonreset@tesobe.com" + val testEmail = "anonreset@tesobe.com" + val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(true).saveMe() + When("We make an anonymous request to reset password") + val request600 = (v6_0_0_Request / "users" / "password-reset-url").POST + val anonJson = JSONFactory600.PostResetPasswordUrlAnonymousJsonV600(testUsername, testEmail) + val response600 = makePostRequest(request600, write(anonJson)) + Then("We should get a 201") + response600.code should equal(201) + And("The response should contain a generic message") + val message = (response600.body \ "message").extract[String] + message should include("If the account exists") + // Clean up + authUser.delete_! + } + + scenario("We will request a password reset for a non-existent user - should still return 201", ApiEndpoint2, VersionOfApi) { + When("We make an anonymous request for non-existent user") + val request600 = (v6_0_0_Request / "users" / "password-reset-url").POST + val anonJson = JSONFactory600.PostResetPasswordUrlAnonymousJsonV600("nonexistent@tesobe.com", "nonexistent@tesobe.com") + val response600 = makePostRequest(request600, write(anonJson)) + Then("We should get a 201 to prevent user enumeration") + response600.code should equal(201) + And("The response should contain the same generic message") + val message = (response600.body \ "message").extract[String] + message should include("If the account exists") + } + + scenario("We will request a password reset with mismatched email - should still return 201", ApiEndpoint2, VersionOfApi) { + val testUsername = "anonmismatch@tesobe.com" + val testEmail = "anonmismatch@tesobe.com" + val authUser: AuthUser = AuthUser.create.email(testEmail).username(testUsername).validated(true).saveMe() + When("We make an anonymous request with wrong email") + val request600 = (v6_0_0_Request / "users" / "password-reset-url").POST + val anonJson = JSONFactory600.PostResetPasswordUrlAnonymousJsonV600(testUsername, "wrong@tesobe.com") + val response600 = makePostRequest(request600, write(anonJson)) + Then("We should get a 201 to prevent user enumeration") + response600.code should equal(201) + val message = (response600.body \ "message").extract[String] + message should include("If the account exists") + // Clean up + authUser.delete_! + } + + scenario("We will request a password reset with invalid JSON", ApiEndpoint2, VersionOfApi) { + When("We make an anonymous request with invalid JSON") + val request600 = (v6_0_0_Request / "users" / "password-reset-url").POST + val response600 = makePostRequest(request600, "{ invalid json }") + Then("We should get a 400") + response600.code should equal(400) + } + } + + // ========================================== + // Complete password reset: POST /users/password + // ========================================== + + feature("Complete password reset v6.0.0") { + scenario("Successfully reset password with valid JWT token and strong password", ApiEndpoint3, VersionOfApi) { + val testUsername = "complete@tesobe.com" + val testEmail = "complete@tesobe.com" + val authUser: AuthUser = AuthUser.create + .email(testEmail) + .username(testUsername) + .password(strongPassword) + .validated(true) + .saveMe() + // Set a known uniqueId and create a JWT containing it + val resetUniqueId = UUID.randomUUID().toString.replace("-", "") + authUser.uniqueId.set(resetUniqueId) + authUser.save + val jwtToken = createJwtToken(resetUniqueId) + + When("We complete the password reset with the JWT token") + val request600 = (v6_0_0_Request / "users" / "password").POST + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600(jwtToken, "NewStr0ng!Pass123") + val response600 = makePostRequest(request600, write(completeJson)) + Then("We should get a 201") + response600.code should equal(201) + And("The response should confirm the reset") + val message = (response600.body \ "message").extract[String] + message should include("Password has been reset successfully") + + And("The token should be invalidated (using the same token again should fail)") + val response600Again = makePostRequest(request600, write(completeJson)) + response600Again.code should equal(400) + + // Clean up + AuthUser.find(By(AuthUser.username, testUsername)).map(_.delete_!) + } + + scenario("Fail to reset password with expired JWT token", ApiEndpoint3, VersionOfApi) { + val testUsername = "expired@tesobe.com" + val testEmail = "expired@tesobe.com" + val authUser: AuthUser = AuthUser.create + .email(testEmail) + .username(testUsername) + .password(strongPassword) + .validated(true) + .saveMe() + val resetUniqueId = UUID.randomUUID().toString.replace("-", "") + authUser.uniqueId.set(resetUniqueId) + authUser.save + val expiredToken = createExpiredJwtToken(resetUniqueId) + + When("We try to complete a password reset with an expired JWT token") + val request600 = (v6_0_0_Request / "users" / "password").POST + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600(expiredToken, strongPassword) + val response600 = makePostRequest(request600, write(completeJson)) + Then("We should get a 400") + response600.code should equal(400) + + // Clean up + AuthUser.find(By(AuthUser.username, testUsername)).map(_.delete_!) + } + + scenario("Fail to reset password with invalid token", ApiEndpoint3, VersionOfApi) { + When("We try to complete a password reset with a bogus token") + val request600 = (v6_0_0_Request / "users" / "password").POST + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600("bogus_token_12345", strongPassword) + val response600 = makePostRequest(request600, write(completeJson)) + Then("We should get a 400") + response600.code should equal(400) + } + + scenario("Fail to reset password with empty token", ApiEndpoint3, VersionOfApi) { + When("We try to complete a password reset with an empty token") + val request600 = (v6_0_0_Request / "users" / "password").POST + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600("", strongPassword) + val response600 = makePostRequest(request600, write(completeJson)) + Then("We should get a 400") + response600.code should equal(400) + } + + scenario("Fail to reset password with weak password", ApiEndpoint3, VersionOfApi) { + val testUsername = "weakpw@tesobe.com" + val testEmail = "weakpw@tesobe.com" + val authUser: AuthUser = AuthUser.create + .email(testEmail) + .username(testUsername) + .password(strongPassword) + .validated(true) + .saveMe() + val resetUniqueId = UUID.randomUUID().toString.replace("-", "") + authUser.uniqueId.set(resetUniqueId) + authUser.save + val jwtToken = createJwtToken(resetUniqueId) + + When("We try to complete a password reset with a weak password") + val request600 = (v6_0_0_Request / "users" / "password").POST + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600(jwtToken, "weak") + val response600 = makePostRequest(request600, write(completeJson)) + Then("We should get a 400") + response600.code should equal(400) + And("The error should indicate invalid password format") + response600.body.extract[ErrorMessage].message should include(InvalidStrongPasswordFormat) + + // Clean up + AuthUser.find(By(AuthUser.username, testUsername)).map(_.delete_!) + } + + scenario("Fail to reset password with invalid JSON", ApiEndpoint3, VersionOfApi) { + When("We send invalid JSON") + val request600 = (v6_0_0_Request / "users" / "password").POST + val response600 = makePostRequest(request600, "{ invalid json }") + Then("We should get a 400") + response600.code should equal(400) + } + } + + // ========================================== + // Full flow: request reset URL then complete reset + // ========================================== + + feature("Full password reset flow v6.0.0") { + scenario("Request reset URL (authenticated) then complete password reset", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateResetPasswordUrl.toString) + val testUsername = "fullflow@tesobe.com" + val testEmail = "fullflow@tesobe.com" + val authUser: AuthUser = AuthUser.create + .email(testEmail) + .username(testUsername) + .password(strongPassword) + .validated(true) + .saveMe() + val resourceUser: Box[User] = Users.users.vend.getUserByResourceUserId(authUser.user.get) + + When("We request a password reset URL via the authenticated endpoint") + val resetUrlRequest = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) + val resetUrlJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, testEmail, resourceUser.map(_.userId).getOrElse("")) + val resetUrlResponse = makePostRequest(resetUrlRequest, write(resetUrlJson)) + Then("We should get a 201 with a reset URL") + resetUrlResponse.code should equal(201) + val resetUrl = (resetUrlResponse.body \ "reset_password_url").extract[String] + resetUrl should include("/user_mgt/reset_password/") + + And("We extract the JWT token from the URL (URL-decoded)") + val encodedToken = resetUrl.split("/user_mgt/reset_password/").last + val token = java.net.URLDecoder.decode(encodedToken, "UTF-8") + token.length should be > 0 + + When("We complete the password reset with the JWT token") + val completeRequest = (v6_0_0_Request / "users" / "password").POST + val newPassword = "BrandNew!Pass999" + val completeJson = JSONFactory600.PostResetPasswordCompleteJsonV600(token, newPassword) + val completeResponse = makePostRequest(completeRequest, write(completeJson)) + Then("We should get a 201") + completeResponse.code should equal(201) + val message = (completeResponse.body \ "message").extract[String] + message should include("Password has been reset successfully") + + And("Using the same token again should fail") + val completeResponseAgain = makePostRequest(completeRequest, write(completeJson)) + completeResponseAgain.code should equal(400) + + // Clean up + AuthUser.find(By(AuthUser.username, testUsername)).map(_.delete_!) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/RetailAndCorporateCustomerTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/RetailAndCorporateCustomerTest.scala new file mode 100644 index 0000000000..b0b0a89199 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/RetailAndCorporateCustomerTest.scala @@ -0,0 +1,481 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2025, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see