From 2091dc846bcab0d96362d60aee7c7f22c3095cbd Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 13 May 2026 00:42:47 +0200 Subject: [PATCH] Payee Lookup, MOBILE_WALLET transaction request and Routing Scheme --- .../main/scala/bootstrap/liftweb/Boot.scala | 9 + .../main/scala/code/api/util/ApiRole.scala | 15 +- .../src/main/scala/code/api/util/ApiTag.scala | 2 + .../scala/code/api/util/ErrorMessages.scala | 28 + .../code/api/util/http4s/Http4sSupport.scala | 24 + .../scala/code/api/v7_0_0/Http4s700.scala | 640 +++++++++++++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 173 +++++ .../scala/code/payeelookup/PayeeLookup.scala | 96 +++ .../code/payeelookup/PayeeLookupTrait.scala | 51 ++ .../code/routingscheme/RoutingScheme.scala | 240 +++++++ .../routingscheme/RoutingSchemeSeed.scala | 121 ++++ .../routingscheme/RoutingSchemeTrait.scala | 83 +++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 490 +++++++++++++- .../commons/model/enums/Enumerations.scala | 1 + 14 files changed, 1967 insertions(+), 6 deletions(-) create mode 100644 obp-api/src/main/scala/code/payeelookup/PayeeLookup.scala create mode 100644 obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala create mode 100644 obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala create mode 100644 obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala create mode 100644 obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index db274dc038..f7fe5b168e 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -87,6 +87,8 @@ import code.featuredapicollection.FeaturedApiCollection import code.fx.{MappedCurrency, MappedFXRate} import code.group.Group import code.organisation.Organisation +import code.routingscheme.{RoutingScheme, BankSupportedRoutingScheme} +import code.payeelookup.PayeeLookup import code.kycchecks.MappedKycCheck import code.kycdocuments.MappedKycDocument import code.kycmedias.MappedKycMedia @@ -282,6 +284,10 @@ class Boot extends MdcLoggable { // Please note that migration scripts are executed after Lift Mapper Schemifier Migration.database.executeScripts(startedBeforeSchemifier = false) + // Idempotent seed of country-qualified routing schemes (TZ.MSISDN, GePG, Luku, etc.). + // Toggle off via routing_schemes.seed_defaults_at_boot=false in environments that don't want defaults. + code.routingscheme.RoutingSchemeSeed.runIfEnabled() + if (APIUtil.getPropsAsBoolValue("create_system_views_at_boot", true)) { // Create system views val owner = Views.views.vend.getOrCreateSystemView(SYSTEM_OWNER_VIEW_ID).isDefined @@ -1212,6 +1218,9 @@ object ToSchemify { BankAccountBalance, Group, Organisation, + RoutingScheme, + BankSupportedRoutingScheme, + PayeeLookup, AccountAccessRequest, code.chat.ChatRoom, code.chat.Participant, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index d6ea415f09..7d57ea37fe 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1354,6 +1354,17 @@ object ApiRole extends MdcLoggable{ case class CanDeleteOrganisation(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteOrganisation = CanDeleteOrganisation() + // Routing Scheme registry roles (system-scoped: schemes are global infrastructure) + case class CanCreateRoutingScheme(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateRoutingScheme = CanCreateRoutingScheme() + case class CanUpdateRoutingScheme(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateRoutingScheme = CanUpdateRoutingScheme() + case class CanDeleteRoutingScheme(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteRoutingScheme = CanDeleteRoutingScheme() + // Per-bank opt-in / opt-out for routing schemes the bank's adapter supports + case class CanUpdateBankSupportedRoutingScheme(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateBankSupportedRoutingScheme = CanUpdateBankSupportedRoutingScheme() + // Group membership management roles case class CanAddUserToGroupAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canAddUserToGroupAtAllBanks = CanAddUserToGroupAtAllBanks() @@ -1459,7 +1470,6 @@ object Util { "CanGet", "CanUpdate", "CanDelete", - "CanMaintain", "CanSearch", "CanEnable", "CanDisable" @@ -1478,7 +1488,8 @@ object Util { "CanRefreshUser", "CanReadFx", "CanSetCallLimits", - "CanDeleteRateLimits" + "CanDeleteRateLimits", + "CanMaintainProductCollection" ) val allowed = allowedPrefixes ::: allowedExistingNames 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 e22f49bfaf..56d35db260 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -92,6 +92,8 @@ object ApiTag { val apiTagChat = ResourceDocTag("Chat") val apiTagGroup = ResourceDocTag("Group") val apiTagOrganisation = ResourceDocTag("Organisation") + val apiTagRoutingScheme = ResourceDocTag("Routing-Scheme") + val apiTagPayee = ResourceDocTag("Payee") val apiTagWebhook = ResourceDocTag("Webhook") val apiTagMockedData = ResourceDocTag("Mocked-Data") val apiTagConsent = ResourceDocTag("Consent") 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 e319a3b20f..3153bc4289 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -469,6 +469,34 @@ object ErrorMessages { val UpdateOrganisationError = "OBP-30512: Could not update Organisation." val DeleteOrganisationError = "OBP-30513: Could not delete Organisation." + // Routing Scheme registry (OBP-30514 .. OBP-30525) + val RoutingSchemeNotFound = "OBP-30514: Routing Scheme not found. Please specify a valid value for SCHEME." + val RoutingSchemeAlreadyExists = "OBP-30515: Routing Scheme already exists. Please specify a different value for scheme." + val InvalidRoutingSchemeName = "OBP-30516: Invalid Routing Scheme name. Must match ^(?:IBAN|BIC|OBP|[A-Z]{2}(?:\\.[A-Z][A-Z0-9_]*)+)$ — either an allow-listed global scheme (IBAN, BIC, OBP) or a country-qualified name like TZ.MSISDN." + val RoutingSchemeCountryMismatch = "OBP-30517: Routing Scheme country prefix does not match the country field." + val InvalidRoutingSchemeCategory = "OBP-30518: Invalid Routing Scheme category. Allowed values are: ACCOUNT, BANK, BRANCH, IDENTITY, BILL, UTILITY." + val InvalidRoutingSchemeStatus = "OBP-30519: Invalid Routing Scheme status. Allowed values are: ACTIVE, RESERVED, DEPRECATED, RETIRED." + val InvalidRoutingSchemeAddressPattern = "OBP-30520: Invalid Routing Scheme address_pattern. The value must be a valid regular expression." + val RoutingSchemeExampleAddressMismatch = "OBP-30521: Routing Scheme example_address does not match address_pattern." + val CreateRoutingSchemeError = "OBP-30522: Could not create Routing Scheme." + val UpdateRoutingSchemeError = "OBP-30523: Could not update Routing Scheme." + val DeleteRoutingSchemeError = "OBP-30524: Could not delete Routing Scheme." + val RoutingSchemeNotSupportedByBank = "OBP-30525: This bank does not support the requested Routing Scheme." + + // Payee Lookup (OBP-30526 .. OBP-30530) + val PayeeLookupIdentifierTypeNotRegistered = "OBP-30526: identifier_type is not a registered Routing Scheme. Register it via POST /routing-schemes first." + val PayeeLookupIdentifierTypeWrongCategory = "OBP-30527: identifier_type category is not valid for payee lookup. Allowed categories are: ACCOUNT, BILL, UTILITY." + val PayeeLookupAddressMismatch = "OBP-30528: identifier does not match the address_pattern of the identifier_type." + val PayeeNotFound = "OBP-30529: No payee was found for the given identifier." + val PayeeLookupCreateError = "OBP-30530: Could not create payee lookup." + + // Mobile-Wallet transaction-request (OBP-30531 .. OBP-30535) + val PayeeLookupExpiredOrNotFound = "OBP-30531: verified_payee_lookup_id is unknown or has expired. Lookups are valid for 10 minutes." + val PayeeLookupMismatch = "OBP-30532: verified_payee_lookup_id does not match the supplied identifier." + val MobileWalletDestinationNotFound = "OBP-30533: No mobile-wallet account is registered for the supplied msisdn. In mapped mode the destination must have an account routing for the country-qualified MSISDN scheme (e.g. TZ.MSISDN)." + val MobileWalletInvalidMsisdn = "OBP-30534: Invalid msisdn — does not match the address_pattern of the country-qualified MSISDN routing scheme." + val MobileWalletPaymentError = "OBP-30535: Could not create MOBILE_WALLET transaction request." + val FeaturedApiCollectionNotFound = "OBP-30400: FeaturedApiCollection not found. Please specify a valid value for API_COLLECTION_ID." val CreateFeaturedApiCollectionError = "OBP-30401: Could not create FeaturedApiCollection." val UpdateFeaturedApiCollectionError = "OBP-30402: Could not update FeaturedApiCollection." diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 3ce9d2e745..d63f3f665a 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -344,6 +344,30 @@ object Http4sRequestAttributes { } } + /** + * Execute POST business logic with JSON body parsing, requiring validated User, BankAccount, and View. + * Returns 201 Created on success, 400 on body parse failure, converts errors via ErrorResponseConverter. + */ + def withViewAndBodyCreated[B, A](req: Request[IO])(f: (User, BankAccount, View, B, CallContext) => Future[A])(implicit formats: Formats, mf: Manifest[B]): IO[Response[IO]] = { + implicit val cc: CallContext = req.callContext + parseBody[B](cc) match { + case Left(msg) => ErrorResponseConverter.createErrorResponse(400, msg, cc).flatTap(recordMetric(msg, _)) + case Right(body) => + val io = for { + user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) + bankAccount <- IO.fromOption(cc.bankAccount)(new RuntimeException("BankAccount not found in CallContext")) + view <- IO.fromOption(cc.view)(new RuntimeException("View not found in CallContext")) + result <- RequestScopeConnection.fromFuture(f(user, bankAccount, view, body, cc)) + } yield result + io.attempt.flatMap { + case Right(result) => + val jsonString = prettyRender(Extraction.decompose(result)) + Created(jsonString).flatTap(recordMetric(result, _)) + case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) + } + } + } + /** * Execute business logic requiring validated User, BankAccount, and View (URL must contain VIEW_ID). * Returns 200 OK on success, converts errors via ErrorResponseConverter. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index add1f90131..eeec04df12 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -8,7 +8,7 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateOrganisation} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, IdempotencyMiddleware, RequestScopeConnection, ResourceDocMiddleware} @@ -24,11 +24,14 @@ import code.migration.MigrationScriptLogProvider import code.bankconnectors.{Connector => BankConnector} import code.entitlement.Entitlement import code.organisation.OrganisationX +import code.routingscheme.{RoutingSchemeX, RoutingSchemeValidation} +import code.payeelookup.PayeeLookupX import code.metadata.tags.Tags import code.views.Views import code.accountattribute.AccountAttributeX import code.users.{Users => UserVend} -import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, CounterpartyId, CustomerId, ListResult, ViewId} +import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, CounterpartyId, CustomerId, ListResult, TransactionRequestType, ViewId} +import com.openbankproject.commons.model.enums.ChallengeType import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} @@ -2598,6 +2601,639 @@ object Http4s700 { // ── End Organisations ───────────────────────────────────────────────────── + // ── Routing Schemes ─────────────────────────────────────────────────────── + // A registry of country-qualified routing scheme names (e.g. TZ.MSISDN, + // TZ.GEPG_CONTROL_NUMBER) so that downstream adapters and clients agree on + // identifier scheme semantics. Two tiers: + // • /routing-schemes — system catalogue (5 endpoints) + // • /banks/BANK_ID/supported-routing-schemes — per-bank subset (2 endpoints) + // Scheme is the resource key. SCHEME segments may contain '.' — http4s + // matches path segments by '/', not by '.', so "TZ.MSISDN" is a single + // segment. + + val createRoutingScheme: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "routing-schemes" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.PostRoutingSchemeJsonV700, JSONFactory700.RoutingSchemeJsonV700](req) { (user, body, cc) => + for { + _ <- Helper.booleanToFuture(InvalidRoutingSchemeName, 400, Some(cc)) { + RoutingSchemeValidation.isValidSchemeName(body.scheme) + } + _ <- Helper.booleanToFuture(RoutingSchemeCountryMismatch, 400, Some(cc)) { + RoutingSchemeValidation.countryMatchesPrefix(body.scheme, body.country) + } + _ <- Helper.booleanToFuture(InvalidRoutingSchemeCategory, 400, Some(cc)) { + RoutingSchemeValidation.ValidCategories.contains(body.category) + } + status = body.status.getOrElse("ACTIVE") + _ <- Helper.booleanToFuture(InvalidRoutingSchemeStatus, 400, Some(cc)) { + RoutingSchemeValidation.ValidStatuses.contains(status) + } + _ <- Helper.booleanToFuture(InvalidRoutingSchemeAddressPattern, 400, Some(cc)) { + RoutingSchemeValidation.isValidRegex(body.address_pattern) + } + _ <- Helper.booleanToFuture(RoutingSchemeExampleAddressMismatch, 400, Some(cc)) { + RoutingSchemeValidation.addressMatchesPattern(body.address_pattern, body.example_address) + } + existing <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(body.scheme)) + _ <- Helper.booleanToFuture(RoutingSchemeAlreadyExists, 409, Some(cc))(existing.isEmpty) + created <- Future { + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = body.scheme, + country = body.country, + category = body.category, + addressPattern = body.address_pattern, + secondaryAddressPattern = body.secondary_address_pattern, + exampleAddress = body.example_address, + description = body.description, + downstreamRails = body.downstream_rails.getOrElse(Nil), + status = status, + createdByUserId = user.userId + ) + }.map(unboxFullOrFail(_, Some(cc), CreateRoutingSchemeError, 400)) + } yield JSONFactory700.createRoutingSchemeJsonV700(created) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createRoutingScheme), + "POST", + "/routing-schemes", + "Create Routing Scheme", + """Register a new routing scheme. + | + |Scheme names follow the convention `.` — uppercase ISO 3166-1 alpha-2 country code, a dot, then an uppercase local scheme name (e.g. `TZ.MSISDN`, `TZ.GEPG_CONTROL_NUMBER`). + | + |Globally-unique schemes `IBAN`, `BIC`, `OBP` are accepted unprefixed; their `country` MUST be the literal `INT`. + | + |Categories: ACCOUNT, BANK, BRANCH, IDENTITY, BILL, UTILITY. The category constrains which OBP fields may carry a routing of this scheme. + | + |`address_pattern` is a regex used to validate addresses presented in this scheme. `example_address` MUST match the pattern. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PostRoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + country = "TZ", + category = "ACCOUNT", + address_pattern = "^255[0-9]{9}$", + secondary_address_pattern = None, + example_address = "255778300336", + description = "Tanzanian mobile number, E.164 without leading +.", + downstream_rails = Some(List("TIPS", "MNO_DIRECT")), + status = Some("ACTIVE") + ), + JSONFactory700.RoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + country = "TZ", + category = "ACCOUNT", + address_pattern = "^255[0-9]{9}$", + secondary_address_pattern = None, + example_address = "255778300336", + description = "Tanzanian mobile number, E.164 without leading +.", + downstream_rails = List("TIPS", "MNO_DIRECT"), + status = "ACTIVE", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + InvalidRoutingSchemeName, RoutingSchemeCountryMismatch, + InvalidRoutingSchemeCategory, InvalidRoutingSchemeStatus, + InvalidRoutingSchemeAddressPattern, RoutingSchemeExampleAddressMismatch, + RoutingSchemeAlreadyExists, CreateRoutingSchemeError, UnknownError), + apiTagRoutingScheme :: Nil, + Some(List(canCreateRoutingScheme)), + http4sPartialFunction = Some(createRoutingScheme) + ) + + val getRoutingSchemes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "routing-schemes" => + EndpointHelpers.executeAndRespond(req) { cc => + val q = req.uri.query.params + val country = q.get("country").filter(_.nonEmpty) + val category = q.get("category").filter(_.nonEmpty) + val rail = q.get("rail").filter(_.nonEmpty) + // Default to ACTIVE only; pass status=ALL to include retired/deprecated. + val rawStatus = q.get("status").filter(_.nonEmpty).getOrElse("ACTIVE") + val statusFilter = if (rawStatus.equalsIgnoreCase("ALL")) None else Some(rawStatus.toUpperCase) + val limit = q.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(100).max(1).min(500) + val offset = q.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0).max(0) + for { + page <- RoutingSchemeX.routingScheme.vend.getRoutingSchemes(country, category, statusFilter, rail, limit, offset) + .map(unboxFullOrFail(_, Some(cc), UnknownError, 500)) + (rows, total) = page + } yield JSONFactory700.createRoutingSchemesJsonV700(rows, total, limit, offset) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getRoutingSchemes), + "GET", + "/routing-schemes", + "Get Routing Schemes", + """Lists registered routing schemes. + | + |Query parameters (all optional): + |- `country` — ISO 3166-1 alpha-2, e.g. `TZ` + |- `category` — ACCOUNT, BANK, BRANCH, IDENTITY, BILL, UTILITY + |- `status` — defaults to `ACTIVE`. Pass `ALL` to include DEPRECATED and RETIRED. + |- `rail` — match against the `downstream_rails` list (e.g. `TIPS`, `GEPG`) + |- `limit` (default 100, max 500), `offset` (default 0)""".stripMargin, + EmptyBody, + JSONFactory700.RoutingSchemesJsonV700( + routing_schemes = List( + JSONFactory700.RoutingSchemeSummaryJsonV700( + scheme = "TZ.MSISDN", country = "TZ", category = "ACCOUNT", + status = "ACTIVE", address_pattern = "^255[0-9]{9}$", + example_address = "255778300336" + ) + ), + pagination = JSONFactory700.RoutingSchemePaginationJsonV700(total = 1, limit = 100, offset = 0) + ), + List(UnknownError), + apiTagRoutingScheme :: Nil, + None, + http4sPartialFunction = Some(getRoutingSchemes) + ) + + val getRoutingScheme: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "routing-schemes" / schemeName => + EndpointHelpers.executeAndRespond(req) { cc => + for { + row <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) + } yield JSONFactory700.createRoutingSchemeJsonV700(row) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getRoutingScheme), + "GET", + "/routing-schemes/SCHEME", + "Get Routing Scheme", + """Returns the routing scheme identified by `SCHEME` (e.g. `TZ.MSISDN`).""", + EmptyBody, + JSONFactory700.RoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + country = "TZ", + category = "ACCOUNT", + address_pattern = "^255[0-9]{9}$", + secondary_address_pattern = None, + example_address = "255778300336", + description = "Tanzanian mobile number, E.164 without leading +.", + downstream_rails = List("TIPS", "MNO_DIRECT"), + status = "ACTIVE", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List(RoutingSchemeNotFound, UnknownError), + apiTagRoutingScheme :: Nil, + None, + http4sPartialFunction = Some(getRoutingScheme) + ) + + val updateRoutingScheme: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "routing-schemes" / schemeName => + EndpointHelpers.withUserAndBody[JSONFactory700.PutRoutingSchemeJsonV700, JSONFactory700.RoutingSchemeJsonV700](req) { (_, body, cc) => + for { + existing <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) + _ <- Helper.booleanToFuture(InvalidRoutingSchemeStatus, 400, Some(cc)) { + body.status.forall(RoutingSchemeValidation.ValidStatuses.contains) + } + _ <- Helper.booleanToFuture(InvalidRoutingSchemeAddressPattern, 400, Some(cc)) { + body.address_pattern.forall(RoutingSchemeValidation.isValidRegex) + } + // If either pattern or example is being updated, the post-update + // pair must be consistent. + effectivePattern = body.address_pattern.getOrElse(existing.addressPattern) + effectiveExample = body.example_address.getOrElse(existing.exampleAddress) + _ <- Helper.booleanToFuture(RoutingSchemeExampleAddressMismatch, 400, Some(cc)) { + RoutingSchemeValidation.addressMatchesPattern(effectivePattern, effectiveExample) + } + updated <- Future { + RoutingSchemeX.routingScheme.vend.updateRoutingScheme( + scheme = schemeName, + addressPattern = body.address_pattern, + secondaryAddressPattern = body.secondary_address_pattern, + exampleAddress = body.example_address, + description = body.description, + downstreamRails = body.downstream_rails, + status = body.status + ) + }.map(unboxFullOrFail(_, Some(cc), UpdateRoutingSchemeError, 400)) + } yield JSONFactory700.createRoutingSchemeJsonV700(updated) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateRoutingScheme), + "PUT", + "/routing-schemes/SCHEME", + "Update Routing Scheme", + """Updates a routing scheme. All body fields are optional. + | + |Immutable fields (cannot be changed via this endpoint): `scheme`, `country`, `category`. + | + |If you tighten `address_pattern`, existing addresses already on the books are not retroactively rejected — the change applies only to new validations. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PutRoutingSchemeJsonV700( + address_pattern = Some("^255[0-9]{9}$"), + secondary_address_pattern = None, + example_address = Some("255778300336"), + description = Some("Tanzanian mobile number, E.164 without leading +."), + downstream_rails = Some(List("TIPS", "MNO_DIRECT")), + status = Some("ACTIVE") + ), + JSONFactory700.RoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + country = "TZ", + category = "ACCOUNT", + address_pattern = "^255[0-9]{9}$", + secondary_address_pattern = None, + example_address = "255778300336", + description = "Tanzanian mobile number, E.164 without leading +.", + downstream_rails = List("TIPS", "MNO_DIRECT"), + status = "ACTIVE", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + RoutingSchemeNotFound, InvalidRoutingSchemeStatus, + InvalidRoutingSchemeAddressPattern, RoutingSchemeExampleAddressMismatch, + UpdateRoutingSchemeError, UnknownError), + apiTagRoutingScheme :: Nil, + Some(List(canUpdateRoutingScheme)), + http4sPartialFunction = Some(updateRoutingScheme) + ) + + val deleteRoutingScheme: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "routing-schemes" / schemeName => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + _ <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) + _ <- Future(RoutingSchemeX.routingScheme.vend.deleteRoutingScheme(schemeName)) + .map(unboxFullOrFail(_, Some(cc), DeleteRoutingSchemeError, 400)) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteRoutingScheme), + "DELETE", + "/routing-schemes/SCHEME", + "Delete Routing Scheme", + """Soft-deletes the routing scheme — sets its status to `RETIRED`. The row is kept for audit and resolution of historical records that reference it; subsequent attempts to use the scheme in a routing or payment fail with `OBP-30525`. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, RoutingSchemeNotFound, + DeleteRoutingSchemeError, UnknownError), + apiTagRoutingScheme :: Nil, + Some(List(canDeleteRoutingScheme)), + http4sPartialFunction = Some(deleteRoutingScheme) + ) + + val getBankSupportedRoutingSchemes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "supported-routing-schemes" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + rows <- RoutingSchemeX.routingScheme.vend.getBankSupportedRoutingSchemes(bank.bankId.value) + .map(unboxFullOrFail(_, Some(cc), UnknownError, 500)) + } yield JSONFactory700.createBankSupportedRoutingSchemesJsonV700(bank.bankId.value, rows) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBankSupportedRoutingSchemes), + "GET", + "/banks/BANK_ID/supported-routing-schemes", + "Get Bank Supported Routing Schemes", + """Returns the subset of routing schemes the bank's adapter routes for, with optional per-bank notes (e.g. cutoff times, downstream rail caveats). + | + |Use this to gate UI options: a transaction-request creation form should list payee-type choices based on what this bank supports, not the global registry. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.BankSupportedRoutingSchemesJsonV700( + bank_id = "nmb.tz", + supported_routing_schemes = List( + JSONFactory700.BankSupportedRoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + bank_notes = Some("Routed via Gateway X to TIPS.") + ) + ) + ), + List($AuthenticatedUserIsRequired, BankNotFound, UnknownError), + apiTagRoutingScheme :: apiTagBank :: Nil, + None, + http4sPartialFunction = Some(getBankSupportedRoutingSchemes) + ) + + val putBankSupportedRoutingScheme: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "supported-routing-schemes" / schemeName => + EndpointHelpers.withUserAndBankAndBody[JSONFactory700.PutBankSupportedRoutingSchemeJsonV700, JSONFactory700.BankSupportedRoutingSchemeJsonV700](req) { (_, bank, body, cc) => + for { + // Scheme must exist in the global registry (and not be retired) + // before a bank can opt in / out of it. + scheme <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) + _ <- Helper.booleanToFuture(RoutingSchemeNotSupportedByBank, 400, Some(cc)) { + scheme.status != "RETIRED" + } + row <- Future { + RoutingSchemeX.routingScheme.vend.putBankSupportedRoutingScheme( + bankId = bank.bankId.value, + scheme = schemeName, + enabled = body.enabled.getOrElse(true), + bankNotes = body.bank_notes + ) + }.map(unboxFullOrFail(_, Some(cc), UpdateRoutingSchemeError, 400)) + } yield JSONFactory700.BankSupportedRoutingSchemeJsonV700( + scheme = row.scheme, + bank_notes = row.bankNotes + ) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(putBankSupportedRoutingScheme), + "PUT", + "/banks/BANK_ID/supported-routing-schemes/SCHEME", + "Set Bank Supported Routing Scheme", + """Opt this bank in to (or out of) a registered routing scheme. Set `enabled: false` to opt out without losing the per-bank notes. + | + |The scheme must exist in the global registry (`GET /routing-schemes/SCHEME`) and not be RETIRED. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PutBankSupportedRoutingSchemeJsonV700( + bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT."), + enabled = Some(true) + ), + JSONFactory700.BankSupportedRoutingSchemeJsonV700( + scheme = "TZ.MSISDN", + bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT.") + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + BankNotFound, RoutingSchemeNotFound, RoutingSchemeNotSupportedByBank, + UpdateRoutingSchemeError, UnknownError), + apiTagRoutingScheme :: apiTagBank :: Nil, + Some(List(canUpdateBankSupportedRoutingScheme)), + http4sPartialFunction = Some(putBankSupportedRoutingScheme) + ) + + // ── End Routing Schemes ─────────────────────────────────────────────────── + + // ── Payee Lookup ────────────────────────────────────────────────────────── + // Generic "confirmation-of-payee" / pre-payment lookup. Caller supplies + // identifier_type + identifier (e.g. TZ.MSISDN + 255778300336); endpoint + // resolves to a payee name and returns a short-lived lookup_id that can be + // quoted in a subsequent transaction-request as evidence the payer saw the + // resolved name. Auth perimeter is the source account's view: the same + // view that lets you pay from this account lets you lookup a payee. + + private val PayeeLookupValidCategories: Set[String] = Set("ACCOUNT", "BILL", "UTILITY") + private val PayeeLookupTtlSeconds: Long = 600 // 10 minutes + + val createPayeeLookup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "payees" / "lookup" => + EndpointHelpers.withViewAndBodyCreated[JSONFactory700.PostPayeeLookupJsonV700, JSONFactory700.PayeeLookupResponseJsonV700](req) { (user, bankAccount, _, body, cc) => + for { + // 1. identifier_type must exist in the registry. + scheme <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(body.identifier_type)) + .map(unboxFullOrFail(_, Some(cc), PayeeLookupIdentifierTypeNotRegistered, 400)) + // 2. Scheme must be in a payee-lookup-valid category. + _ <- Helper.booleanToFuture(PayeeLookupIdentifierTypeWrongCategory, 400, Some(cc)) { + PayeeLookupValidCategories.contains(scheme.category) + } + // 3. identifier must match the scheme's address_pattern. + _ <- Helper.booleanToFuture(PayeeLookupAddressMismatch, 400, Some(cc)) { + RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.identifier) + } + // 4. Resolve payee. In mapped mode the destination account is + // located by its account_routing (scheme,address). In adapter + // mode the south-side connector handles this. + payeeBox <- BankConnector.connector.vend + .getBankAccountByRouting(None, body.identifier_type, body.identifier, Some(cc)) + .map(_._1) + payeeAccount <- Future { + unboxFullOrFail(payeeBox, Some(cc), PayeeNotFound, 404) + } + // 5. Persist a lookup record with a 10-minute TTL. + lookupId = APIUtil.generateUUID() + stored <- Future { + PayeeLookupX.payeeLookup.vend.createPayeeLookup( + lookupId = lookupId, + identifierType = body.identifier_type, + identifier = body.identifier, + fspId = body.fsp_id, + networkProvider = None, + fullName = payeeAccount.label, + accountCategory = None, + accountType = Some(payeeAccount.accountType), + identityType = None, + identityValue = None, + fromBankId = bankAccount.bankId.value, + fromAccountId = bankAccount.accountId.value, + createdByUserId = user.userId, + ttlSeconds = PayeeLookupTtlSeconds + ) + }.map(unboxFullOrFail(_, Some(cc), PayeeLookupCreateError, 500)) + } yield JSONFactory700.PayeeLookupResponseJsonV700( + lookup_id = stored.lookupId, + expires_at = stored.expiresAt, + identifier_type = stored.identifierType, + identifier = stored.identifier, + fsp_id = stored.fspId, + network_provider = stored.networkProvider, + full_name = stored.fullName, + account_category = stored.accountCategory, + account_type = stored.accountType, + identity = None + ) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createPayeeLookup), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/payees/lookup", + "Create Payee Lookup", + """Look up a payee (Confirmation-of-Payee) before initiating a payment. + | + |The endpoint is **polymorphic on `identifier_type`**: pass any registered routing scheme as the `identifier_type` and the corresponding `identifier`. The scheme's `category` must be one of ACCOUNT, BILL, UTILITY for it to be valid here. + | + |Examples: + |- Mobile-money / TIPS payee: `identifier_type: TZ.MSISDN`, `identifier: 255778300336`, `fsp_id: 503` + |- TIPS bank-account name verify: `identifier_type: TZ.BANK_ACCOUNT`, `identifier: 24110000296` + |- GePG bill inquiry: `identifier_type: TZ.GEPG_CONTROL_NUMBER`, `identifier: 991043383705` + |- Luku meter inquiry: `identifier_type: TZ.LUKU_METER`, `identifier: 24730238417` + | + |The response includes a `lookup_id` valid for 10 minutes. A subsequent transaction-request can quote it via `verified_payee_lookup_id` to prove the payer saw the resolved name (Confirmation-of-Payee handshake). + | + |Authentication is Required. The caller must have a view on the source account (`/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID`) — the same authorization perimeter as paying from it.""".stripMargin, + JSONFactory700.PostPayeeLookupJsonV700( + identifier_type = "TZ.MSISDN", + identifier = "255778300336", + fsp_id = Some("503") + ), + JSONFactory700.PayeeLookupResponseJsonV700( + lookup_id = "lkp_01HXY7Z8AB9C0D1E2F3G4H5J6K", + expires_at = new java.util.Date(System.currentTimeMillis() + 10L * 60 * 1000), + identifier_type = "TZ.MSISDN", + identifier = "255778300336", + fsp_id = Some("503"), + network_provider = Some("ZANTEL"), + full_name = "ERASTO EMILE MALEMA", + account_category = Some("PERSON"), + account_type = Some("WALLET"), + identity = None + ), + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + PayeeLookupIdentifierTypeNotRegistered, PayeeLookupIdentifierTypeWrongCategory, + PayeeLookupAddressMismatch, PayeeNotFound, PayeeLookupCreateError, UnknownError), + apiTagPayee :: apiTagAccount :: Nil, + None, + http4sPartialFunction = Some(createPayeeLookup) + ) + + // ── End Payee Lookup ────────────────────────────────────────────────────── + + // ── MOBILE_WALLET transaction request ───────────────────────────────────── + // POST to a mobile-money wallet identified by an MSISDN. In mapped mode the + // destination resolves via the country-qualified MSISDN routing scheme + // (defaults to TZ.MSISDN; override via `country_code`). The endpoint plugs + // into the existing v400 payment pipeline so the standard transaction-request + // response shape is preserved. + + val createTransactionRequestMobileWallet: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "MOBILE_WALLET" / "transaction-requests" => + EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyMobileWalletJsonV700, code.api.v4_0_0.TransactionRequestWithChargeJSON400](req) { (user, fromAccount, view, body, cc) => + val countryCode = body.country_code.getOrElse("TZ") + val msisdnScheme = s"${countryCode}.MSISDN" + val chargePolicy = body.charge_policy.getOrElse("SHARED") + val callCtx = Some(cc) + for { + // 1. The MSISDN routing scheme must exist in the registry and + // msisdn must match its address_pattern. + scheme <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(msisdnScheme)) + .map(unboxFullOrFail(_, callCtx, PayeeLookupIdentifierTypeNotRegistered, 400)) + _ <- Helper.booleanToFuture(MobileWalletInvalidMsisdn, 400, callCtx) { + RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.to.msisdn) + } + // 2. If the caller provided a verified_payee_lookup_id, validate it + // is unexpired AND matches the supplied msisdn. This is the + // Confirmation-of-Payee handshake. + _ <- body.verified_payee_lookup_id match { + case Some(lkpId) => + for { + lkp <- Future(PayeeLookupX.payeeLookup.vend.getActivePayeeLookup(lkpId)) + .map(unboxFullOrFail(_, callCtx, PayeeLookupExpiredOrNotFound, 400)) + _ <- Helper.booleanToFuture(PayeeLookupMismatch, 400, callCtx) { + lkp.identifier == body.to.msisdn && lkp.identifierType == msisdnScheme + } + } yield () + case None => Future.successful(()) + } + // 3. Resolve destination account via routing (mapped-mode path). + destinationBox <- BankConnector.connector.vend + .getBankAccountByRouting(None, msisdnScheme, body.to.msisdn, callCtx) + .map(_._1) + toAccount <- Future { + unboxFullOrFail(destinationBox, callCtx, MobileWalletDestinationNotFound, 404) + } + // 4. Standard view authorisation check (same as v4 COUNTERPARTY). + _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest( + view.viewId, BankIdAccountId(fromAccount.bankId, fromAccount.accountId), user, callCtx + ) + // 5. Serialise the body to JSON for the connector's audit blob. + detailsPlain = prettyRender(Extraction.decompose(body)) + // 6. Create the transaction request via the standard pipeline. + txnReqType = TransactionRequestType("MOBILE_WALLET") + (tr, _) <- NewStyle.function.createTransactionRequestv400( + user, + view.viewId, + fromAccount, + toAccount, + txnReqType, + body, + detailsPlain, + chargePolicy, + Some(ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE), + None, + None, + callCtx + ) + } yield code.api.v4_0_0.JSONFactory400.createTransactionRequestWithChargeJSON(tr, Nil, Nil) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createTransactionRequestMobileWallet), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/MOBILE_WALLET/transaction-requests", + "Create Transaction Request (MOBILE_WALLET)", + """Initiate a payment to a mobile-money wallet identified by an MSISDN (phone number). + | + |The destination wallet is resolved via the country-qualified MSISDN routing scheme — by default `TZ.MSISDN`; override via the `country_code` field. The scheme must be registered in the routing-scheme catalogue (`GET /obp/v7.0.0/routing-schemes/TZ.MSISDN`) and the wallet account must have a matching `account_routings` entry. + | + |**Confirmation-of-Payee handshake** (optional): call `POST /banks/.../accounts/.../payees/lookup` first, then pass the returned `lookup_id` here as `verified_payee_lookup_id`. The endpoint will reject the request if the lookup has expired or does not match the supplied `msisdn`. + | + |**Provider passthrough**: `data_fields` carries arbitrary name/value pairs that adapters can forward to the downstream MNO / TIPS rail without OBP interpretation. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.TransactionRequestBodyMobileWalletJsonV700( + to = JSONFactory700.MobileWalletToJsonV700( + msisdn = "255778300336", + fsp_id = Some("503"), + network_provider = Some("AIRTEL"), + full_name = Some("Chinua Achebe"), + account_category = Some("PERSON"), + account_type = Some("WALLET"), + identity = None + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "1000"), + description = "buy airtime", + client_reference = Some("MK45078200"), + verified_payee_lookup_id = None, + country_code = Some("TZ"), + data_fields = Some(List(JSONFactory700.MobileWalletDataFieldJsonV700("fieldName1", "fieldValue1"))), + charge_policy = Some("SHARED") + ), + transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + PayeeLookupIdentifierTypeNotRegistered, MobileWalletInvalidMsisdn, + PayeeLookupExpiredOrNotFound, PayeeLookupMismatch, + MobileWalletDestinationNotFound, MobileWalletPaymentError, UnknownError), + apiTagTransactionRequest :: apiTagPayee :: Nil, + None, + http4sPartialFunction = Some(createTransactionRequestMobileWallet) + ) + + // ── End MOBILE_WALLET ───────────────────────────────────────────────────── + // ── Test-only rollback endpoint ─────────────────────────────────────────── // Enabled only in Lift test mode (Props.testMode == true, i.e. -Drun.mode=test). // Props.testMode is set from the JVM system property before any props file loads, diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 3329231fe1..1ef2f038ab 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -531,4 +531,177 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { def createOrganisationsJsonV700(orgs: List[code.organisation.OrganisationTrait]): OrganisationsJsonV700 = { OrganisationsJsonV700(orgs.map(createOrganisationJsonV700)) } + + // ── Routing Scheme JSON case classes ───────────────────────────────────────── + + case class PostRoutingSchemeJsonV700( + scheme: String, + country: String, + category: String, + address_pattern: String, + secondary_address_pattern: Option[String], + example_address: String, + description: String, + downstream_rails: Option[List[String]], + status: Option[String] + ) + + case class PutRoutingSchemeJsonV700( + address_pattern: Option[String], + secondary_address_pattern: Option[String], + example_address: Option[String], + description: Option[String], + downstream_rails: Option[List[String]], + status: Option[String] + ) + + // Full record returned on POST/GET-single/PUT. + case class RoutingSchemeJsonV700( + scheme: String, + country: String, + category: String, + address_pattern: String, + secondary_address_pattern: Option[String], + example_address: String, + description: String, + downstream_rails: List[String], + status: String, + created_by_user_id: String, + created_at: java.util.Date, + updated_at: java.util.Date + ) + + // Trimmed record returned in list responses. + case class RoutingSchemeSummaryJsonV700( + scheme: String, + country: String, + category: String, + status: String, + address_pattern: String, + example_address: String + ) + + case class RoutingSchemePaginationJsonV700(total: Int, limit: Int, offset: Int) + + case class RoutingSchemesJsonV700( + routing_schemes: List[RoutingSchemeSummaryJsonV700], + pagination: RoutingSchemePaginationJsonV700 + ) + + case class BankSupportedRoutingSchemeJsonV700( + scheme: String, + bank_notes: Option[String] + ) + + case class BankSupportedRoutingSchemesJsonV700( + bank_id: String, + supported_routing_schemes: List[BankSupportedRoutingSchemeJsonV700] + ) + + case class PutBankSupportedRoutingSchemeJsonV700( + bank_notes: Option[String], + enabled: Option[Boolean] + ) + + def createRoutingSchemeJsonV700(r: code.routingscheme.RoutingSchemeTrait): RoutingSchemeJsonV700 = + RoutingSchemeJsonV700( + scheme = r.scheme, + country = r.country, + category = r.category, + address_pattern = r.addressPattern, + secondary_address_pattern = r.secondaryAddressPattern, + example_address = r.exampleAddress, + description = r.description, + downstream_rails = r.downstreamRails, + status = r.status, + created_by_user_id = r.createdByUserId, + created_at = r.createdAt, + updated_at = r.updatedAt + ) + + def createRoutingSchemeSummaryJsonV700(r: code.routingscheme.RoutingSchemeTrait): RoutingSchemeSummaryJsonV700 = + RoutingSchemeSummaryJsonV700( + scheme = r.scheme, + country = r.country, + category = r.category, + status = r.status, + address_pattern = r.addressPattern, + example_address = r.exampleAddress + ) + + def createRoutingSchemesJsonV700( + rows: List[code.routingscheme.RoutingSchemeTrait], + total: Int, + limit: Int, + offset: Int + ): RoutingSchemesJsonV700 = + RoutingSchemesJsonV700( + routing_schemes = rows.map(createRoutingSchemeSummaryJsonV700), + pagination = RoutingSchemePaginationJsonV700(total = total, limit = limit, offset = offset) + ) + + def createBankSupportedRoutingSchemesJsonV700( + bankId: String, + rows: List[code.routingscheme.BankSupportedRoutingSchemeTrait] + ): BankSupportedRoutingSchemesJsonV700 = + BankSupportedRoutingSchemesJsonV700( + bank_id = bankId, + supported_routing_schemes = rows.filter(_.enabled).map(r => + BankSupportedRoutingSchemeJsonV700(scheme = r.scheme, bank_notes = r.bankNotes) + ) + ) + + // ── Payee Lookup JSON case classes ────────────────────────────────────────── + + case class PayeeIdentityJsonV700(`type`: String, value: String) + + case class PostPayeeLookupJsonV700( + identifier_type: String, + identifier: String, + fsp_id: Option[String] + ) + + case class PayeeLookupResponseJsonV700( + lookup_id: String, + expires_at: java.util.Date, + identifier_type: String, + identifier: String, + fsp_id: Option[String], + network_provider: Option[String], + full_name: String, + account_category: Option[String], + account_type: Option[String], + identity: Option[PayeeIdentityJsonV700] + ) + + // ── MOBILE_WALLET transaction-request body ───────────────────────────────── + + case class MobileWalletToJsonV700( + msisdn: String, + fsp_id: Option[String], + network_provider: Option[String], + full_name: Option[String], + account_category: Option[String], + account_type: Option[String], + identity: Option[PayeeIdentityJsonV700] + ) + + case class MobileWalletDataFieldJsonV700(name: String, value: String) + + /** + * Body for `POST .../transaction-request-types/MOBILE_WALLET/transaction-requests`. + * + * Implements `TransactionRequestCommonBodyJSON` so it plugs into the existing + * v400 transaction-request pipeline (which requires `value` + `description`). + */ + case class TransactionRequestBodyMobileWalletJsonV700( + to: MobileWalletToJsonV700, + value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + description: String, + client_reference: Option[String], + verified_payee_lookup_id: Option[String], + country_code: Option[String], + data_fields: Option[List[MobileWalletDataFieldJsonV700]], + charge_policy: Option[String] + ) extends com.openbankproject.commons.model.TransactionRequestCommonBodyJSON } diff --git a/obp-api/src/main/scala/code/payeelookup/PayeeLookup.scala b/obp-api/src/main/scala/code/payeelookup/PayeeLookup.scala new file mode 100644 index 0000000000..60f77792f5 --- /dev/null +++ b/obp-api/src/main/scala/code/payeelookup/PayeeLookup.scala @@ -0,0 +1,96 @@ +package code.payeelookup + +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedPayeeLookupProvider extends PayeeLookupProvider { + + override def createPayeeLookup( + lookupId: String, + identifierType: String, + identifier: String, + fspId: Option[String], + networkProvider: Option[String], + fullName: String, + accountCategory: Option[String], + accountType: Option[String], + identityType: Option[String], + identityValue: Option[String], + fromBankId: String, + fromAccountId: String, + createdByUserId: String, + ttlSeconds: Long + ): Box[PayeeLookupTrait] = { + val now = System.currentTimeMillis() + tryo { + PayeeLookup.create + .LookupId(lookupId) + .IdentifierType(identifierType) + .Identifier(identifier) + .FspId(fspId.getOrElse("")) + .NetworkProvider(networkProvider.getOrElse("")) + .FullName(fullName) + .AccountCategory(accountCategory.getOrElse("")) + .AccountType(accountType.getOrElse("")) + .IdentityType(identityType.getOrElse("")) + .IdentityValue(identityValue.getOrElse("")) + .FromBankId(fromBankId) + .FromAccountId(fromAccountId) + .CreatedByUserId(createdByUserId) + .CreationDate(new java.util.Date(now)) + .ExpiresAt(new java.util.Date(now + ttlSeconds * 1000)) + .saveMe() + } + } + + override def getActivePayeeLookup(lookupId: String): Box[PayeeLookupTrait] = { + PayeeLookup.find(By(PayeeLookup.LookupId, lookupId)).filter(!_.isExpired) + } +} + +class PayeeLookup extends PayeeLookupTrait with LongKeyedMapper[PayeeLookup] with IdPK { + def getSingleton = PayeeLookup + + object LookupId extends MappedString(this, 64) + object IdentifierType extends MappedString(this, 64) + object Identifier extends MappedString(this, 255) + object FspId extends MappedString(this, 32) + object NetworkProvider extends MappedString(this, 64) + object FullName extends MappedString(this, 255) + object AccountCategory extends MappedString(this, 32) + object AccountType extends MappedString(this, 32) + object IdentityType extends MappedString(this, 32) + object IdentityValue extends MappedString(this, 64) + object FromBankId extends MappedString(this, 255) + object FromAccountId extends MappedString(this, 255) + object CreatedByUserId extends MappedString(this, 255) + object CreationDate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + object ExpiresAt extends MappedDateTime(this) + + private def opt(s: String): Option[String] = + if (s == null || s.isEmpty) None else Some(s) + + override def lookupId: String = LookupId.get + override def identifierType: String = IdentifierType.get + override def identifier: String = Identifier.get + override def fspId: Option[String] = opt(FspId.get) + override def networkProvider: Option[String] = opt(NetworkProvider.get) + override def fullName: String = FullName.get + override def accountCategory: Option[String] = opt(AccountCategory.get) + override def accountType: Option[String] = opt(AccountType.get) + override def identityType: Option[String] = opt(IdentityType.get) + override def identityValue: Option[String] = opt(IdentityValue.get) + override def fromBankId: String = FromBankId.get + override def fromAccountId: String = FromAccountId.get + override def createdByUserId: String = CreatedByUserId.get + override def createdAt: java.util.Date = CreationDate.get + override def expiresAt: java.util.Date = ExpiresAt.get +} + +object PayeeLookup extends PayeeLookup with LongKeyedMetaMapper[PayeeLookup] { + override def dbTableName = "PayeeLookup" + override def dbIndexes = UniqueIndex(LookupId) :: Index(ExpiresAt) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala b/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala new file mode 100644 index 0000000000..c14db307cd --- /dev/null +++ b/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala @@ -0,0 +1,51 @@ +package code.payeelookup + +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object PayeeLookupX extends SimpleInjector { + val payeeLookup = new Inject(buildOne _) {} + + def buildOne: PayeeLookupProvider = MappedPayeeLookupProvider +} + +trait PayeeLookupProvider { + def createPayeeLookup( + lookupId: String, + identifierType: String, + identifier: String, + fspId: Option[String], + networkProvider: Option[String], + fullName: String, + accountCategory: Option[String], + accountType: Option[String], + identityType: Option[String], + identityValue: Option[String], + fromBankId: String, + fromAccountId: String, + createdByUserId: String, + ttlSeconds: Long + ): Box[PayeeLookupTrait] + + /** Returns the lookup if found and not expired. Expired rows are NOT auto-deleted. */ + def getActivePayeeLookup(lookupId: String): Box[PayeeLookupTrait] +} + +trait PayeeLookupTrait { + def lookupId: String + def identifierType: String + def identifier: String + def fspId: Option[String] + def networkProvider: Option[String] + def fullName: String + def accountCategory: Option[String] + def accountType: Option[String] + def identityType: Option[String] + def identityValue: Option[String] + def fromBankId: String + def fromAccountId: String + def createdByUserId: String + def createdAt: java.util.Date + def expiresAt: java.util.Date + def isExpired: Boolean = expiresAt.getTime <= System.currentTimeMillis() +} diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala new file mode 100644 index 0000000000..56030d47cb --- /dev/null +++ b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala @@ -0,0 +1,240 @@ +package code.routingscheme + +import net.liftweb.common.{Box, Full} +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo +import com.openbankproject.commons.ExecutionContext.Implicits.global + +import scala.concurrent.Future +import scala.util.{Try, Success, Failure} + +object MappedRoutingSchemeProvider extends RoutingSchemeProvider { + + // ── Routing scheme CRUD ──────────────────────────────────────────────────── + + override def createRoutingScheme( + scheme: String, + country: String, + category: String, + addressPattern: String, + secondaryAddressPattern: Option[String], + exampleAddress: String, + description: String, + downstreamRails: List[String], + status: String, + createdByUserId: String + ): Box[RoutingSchemeTrait] = { + tryo { + RoutingScheme.create + .Scheme(scheme) + .Country(country) + .Category(category) + .AddressPattern(addressPattern) + .SecondaryAddressPattern(secondaryAddressPattern.getOrElse("")) + .ExampleAddress(exampleAddress) + .Description(description) + .DownstreamRails(downstreamRails.mkString(",")) + .Status(status) + .CreatedByUserId(createdByUserId) + .saveMe() + } + } + + override def getRoutingScheme(scheme: String): Box[RoutingSchemeTrait] = + RoutingScheme.find(By(RoutingScheme.Scheme, scheme)) + + override def getRoutingSchemes( + country: Option[String], + category: Option[String], + status: Option[String], + rail: Option[String], + limit: Int, + offset: Int + ): Future[Box[(List[RoutingSchemeTrait], Int)]] = Future { + tryo { + val baseQuery: List[QueryParam[RoutingScheme]] = + country.map(c => By(RoutingScheme.Country, c)).toList ::: + category.map(c => By(RoutingScheme.Category, c)).toList ::: + status.map(s => By(RoutingScheme.Status, s)).toList + // Count BEFORE applying limit/offset for total + val total: Int = RoutingScheme.count(baseQuery: _*).toInt + val rows: List[RoutingScheme] = + RoutingScheme.findAll((baseQuery :+ OrderBy(RoutingScheme.Scheme, Ascending) :+ StartAt[RoutingScheme](offset) :+ MaxRows[RoutingScheme](limit)): _*) + // Rail is a free-text tag list (CSV); filter in-memory after the SQL pass. + val filtered = rail match { + case Some(r) => rows.filter(_.downstreamRails.contains(r)) + case None => rows + } + (filtered.asInstanceOf[List[RoutingSchemeTrait]], total) + } + } + + override def updateRoutingScheme( + scheme: String, + addressPattern: Option[String], + secondaryAddressPattern: Option[String], + exampleAddress: Option[String], + description: Option[String], + downstreamRails: Option[List[String]], + status: Option[String] + ): Box[RoutingSchemeTrait] = { + RoutingScheme.find(By(RoutingScheme.Scheme, scheme)).flatMap { row => + tryo { + addressPattern.foreach(v => row.AddressPattern(v)) + secondaryAddressPattern.foreach(v => row.SecondaryAddressPattern(v)) + exampleAddress.foreach(v => row.ExampleAddress(v)) + description.foreach(v => row.Description(v)) + downstreamRails.foreach(v => row.DownstreamRails(v.mkString(","))) + status.foreach(v => row.Status(v)) + row.LastUpdate(new java.util.Date()) + row.saveMe() + } + } + } + + override def deleteRoutingScheme(scheme: String): Box[Boolean] = { + // Soft delete — set status to RETIRED, keep the row for historical resolution. + RoutingScheme.find(By(RoutingScheme.Scheme, scheme)).flatMap { row => + tryo { + row.Status("RETIRED").LastUpdate(new java.util.Date()).saveMe() + true + } + } + } + + // ── Bank-supported routing schemes ───────────────────────────────────────── + + override def getBankSupportedRoutingSchemes(bankId: String): Future[Box[List[BankSupportedRoutingSchemeTrait]]] = Future { + tryo { + BankSupportedRoutingScheme.findAll(By(BankSupportedRoutingScheme.BankId, bankId)) + .asInstanceOf[List[BankSupportedRoutingSchemeTrait]] + } + } + + override def putBankSupportedRoutingScheme( + bankId: String, + scheme: String, + enabled: Boolean, + bankNotes: Option[String] + ): Box[BankSupportedRoutingSchemeTrait] = { + val existing = BankSupportedRoutingScheme.find( + By(BankSupportedRoutingScheme.BankId, bankId), + By(BankSupportedRoutingScheme.Scheme, scheme) + ) + tryo { + existing match { + case Full(row) => + row.Enabled(enabled) + .BankNotes(bankNotes.getOrElse("")) + .saveMe() + case _ => + BankSupportedRoutingScheme.create + .BankId(bankId) + .Scheme(scheme) + .Enabled(enabled) + .BankNotes(bankNotes.getOrElse("")) + .saveMe() + } + } + } +} + +class RoutingScheme extends RoutingSchemeTrait with LongKeyedMapper[RoutingScheme] with IdPK { + def getSingleton = RoutingScheme + + object Scheme extends MappedString(this, 64) + object Country extends MappedString(this, 8) // alpha-2 or "INT" for global allow-list + object Category extends MappedString(this, 16) // ACCOUNT | BANK | BRANCH | IDENTITY | BILL | UTILITY + object AddressPattern extends MappedString(this, 1024) + object SecondaryAddressPattern extends MappedString(this, 1024) + object ExampleAddress extends MappedString(this, 255) + object Description extends MappedText(this) + object DownstreamRails extends MappedString(this, 512) // CSV: "TIPS,MNO_DIRECT" + object Status extends MappedString(this, 16) // ACTIVE | RESERVED | DEPRECATED | RETIRED + object CreatedByUserId extends MappedString(this, 255) + object CreationDate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + object LastUpdate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + + override def scheme: String = Scheme.get + override def country: String = Country.get + override def category: String = Category.get + override def addressPattern: String = AddressPattern.get + override def secondaryAddressPattern: Option[String] = { + val v = SecondaryAddressPattern.get + if (v == null || v.isEmpty) None else Some(v) + } + override def exampleAddress: String = ExampleAddress.get + override def description: String = Description.get + override def downstreamRails: List[String] = { + val v = DownstreamRails.get + if (v == null || v.isEmpty) Nil else v.split(",").toList.map(_.trim).filter(_.nonEmpty) + } + override def status: String = Status.get + override def createdByUserId: String = CreatedByUserId.get + override def createdAt: java.util.Date = CreationDate.get + override def updatedAt: java.util.Date = LastUpdate.get +} + +object RoutingScheme extends RoutingScheme with LongKeyedMetaMapper[RoutingScheme] { + override def dbTableName = "RoutingScheme" + override def dbIndexes = UniqueIndex(Scheme) :: super.dbIndexes +} + +class BankSupportedRoutingScheme extends BankSupportedRoutingSchemeTrait with LongKeyedMapper[BankSupportedRoutingScheme] with IdPK { + def getSingleton = BankSupportedRoutingScheme + + object BankId extends MappedString(this, 255) + object Scheme extends MappedString(this, 64) + object Enabled extends MappedBoolean(this) { + override def defaultValue = true + } + object BankNotes extends MappedString(this, 1024) + + override def bankId: String = BankId.get + override def scheme: String = Scheme.get + override def enabled: Boolean = Enabled.get + override def bankNotes: Option[String] = { + val v = BankNotes.get + if (v == null || v.isEmpty) None else Some(v) + } +} + +object BankSupportedRoutingScheme + extends BankSupportedRoutingScheme + with LongKeyedMetaMapper[BankSupportedRoutingScheme] { + override def dbTableName = "BankSupportedRoutingScheme" + override def dbIndexes = + UniqueIndex(BankId, Scheme) :: Index(BankId) :: super.dbIndexes +} + +object RoutingSchemeValidation { + // Server-side guards. Mirrored in glossary + JSON-schema for clients. + private val NameRegex = "^(?:IBAN|BIC|OBP|[A-Z]{2}(?:\\.[A-Z][A-Z0-9_]*)+)$".r + private val GlobalAllowList = Set("IBAN", "BIC", "OBP") + val ValidCategories: Set[String] = Set("ACCOUNT", "BANK", "BRANCH", "IDENTITY", "BILL", "UTILITY") + val ValidStatuses: Set[String] = Set("ACTIVE", "RESERVED", "DEPRECATED", "RETIRED") + + def isValidSchemeName(s: String): Boolean = NameRegex.findFirstIn(s).isDefined + + /** The country prefix in the scheme name must match the `country` field. + * Globally-unique allow-listed schemes (IBAN/BIC/OBP) must use country "INT". + */ + def countryMatchesPrefix(scheme: String, country: String): Boolean = { + if (GlobalAllowList.contains(scheme)) country == "INT" + else scheme.split("\\.", 2).headOption.contains(country) + } + + def isValidRegex(pattern: String): Boolean = + Try(java.util.regex.Pattern.compile(pattern)) match { + case Success(_) => true + case Failure(_) => false + } + + /** Returns true if `address` matches `pattern`. False if pattern itself is invalid. */ + def addressMatchesPattern(pattern: String, address: String): Boolean = + Try(java.util.regex.Pattern.compile(pattern).matcher(address).matches()).getOrElse(false) +} diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala new file mode 100644 index 0000000000..216c240278 --- /dev/null +++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala @@ -0,0 +1,121 @@ +package code.routingscheme + +import net.liftweb.common.{Empty, Failure, Full} +import net.liftweb.util.SimpleInjector +import code.api.util.APIUtil +import net.liftweb.util.Helpers.tryo +import org.slf4j.LoggerFactory + +/** + * Idempotent seed of country-qualified routing schemes that ship with OBP. + * + * Called from Boot.scala once per process start. Each scheme is inserted only + * if not already present, so re-running boot (or running multiple instances + * concurrently) is safe. + * + * Toggle off in environments that don't want the defaults by setting the prop + * `routing_schemes.seed_defaults_at_boot = false`. + */ +object RoutingSchemeSeed { + + private val logger = LoggerFactory.getLogger(getClass) + + // Pseudo-user for the createdByUserId column on seeded rows. + private val SeedActor = "system:routing-scheme-seed" + + case class Entry( + scheme: String, + country: String, + category: String, + addressPattern: String, + exampleAddress: String, + description: String, + downstreamRails: List[String] + ) + + val tzSeeds: List[Entry] = List( + Entry("TZ.MSISDN", "TZ", "ACCOUNT", + "^255[0-9]{9}$", "255778300336", + "Tanzanian mobile number, E.164 without leading +. Used to identify a mobile-money wallet.", + List("TIPS", "MNO_DIRECT")), + Entry("TZ.FSP_ID", "TZ", "BANK", + "^[0-9]{3}$", "503", + "TIPS Financial Service Provider code (3 digits).", + List("TIPS")), + Entry("TZ.NETWORK_PROVIDER", "TZ", "BANK", + "^[A-Z]+$", "AIRTEL", + "Mobile network operator name (AIRTEL, MPESA, VODACOM, HALOTEL, TTCL, MIX, ZANTEL).", + List("MNO_DIRECT")), + Entry("TZ.BANK_ACCOUNT", "TZ", "ACCOUNT", + "^[0-9]{8,16}$", "24110000296", + "Tanzanian domestic bank account number.", + List("TIPS", "RTGS")), + Entry("TZ.BANK_CODE", "TZ", "BANK", + "^[0-9]{3}$", "003", + "Tanzanian domestic bank code (e.g. NMB = 003).", + List("TIPS", "RTGS")), + Entry("TZ.BRANCH_CODE", "TZ", "BRANCH", + "^[0-9]{3}$", "208", + "Tanzanian branch routing code.", + List("RTGS")), + Entry("TZ.GEPG_CONTROL_NUMBER", "TZ", "BILL", + "^[0-9]{12}$", "991043383705", + "GePG (Government e-Payment Gateway) bill control number.", + List("GEPG")), + Entry("TZ.GEPG_SP_CODE", "TZ", "BILL", + "^SP[0-9]{5}$", "SP99103", + "GePG service-provider code.", + List("GEPG")), + Entry("TZ.LUKU_METER", "TZ", "UTILITY", + "^[0-9]{8,14}$", "24730238417", + "TANESCO LUKU prepaid electricity meter number.", + List("LUKU")), + Entry("TZ.NIN", "TZ", "IDENTITY", + "^[0-9]{20}$", "19331007175010005135", + "Tanzania National Identification Number (NIDA).", + Nil), + Entry("TZ.TIN", "TZ", "IDENTITY", + "^[0-9]{9}$", "123456789", + "Tanzania Taxpayer Identification Number.", + Nil), + Entry("TZ.PASSPORT", "TZ", "IDENTITY", + "^[A-Z]{2}[0-9]{6,7}$", "AB068589", + "Tanzanian passport number.", + Nil) + ) + + def runIfEnabled(): Unit = { + if (!APIUtil.getPropsAsBoolValue("routing_schemes.seed_defaults_at_boot", defaultValue = true)) { + logger.info("[RoutingSchemeSeed] disabled via routing_schemes.seed_defaults_at_boot=false") + return + } + val provider = MappedRoutingSchemeProvider + val (inserted, skipped, failed) = tzSeeds.foldLeft((0, 0, 0)) { + case ((ins, skp, fld), entry) => + provider.getRoutingScheme(entry.scheme) match { + case Full(_) => + (ins, skp + 1, fld) // already exists — skip + case Empty | Failure(_, _, _) => + val r = tryo { + provider.createRoutingScheme( + scheme = entry.scheme, + country = entry.country, + category = entry.category, + addressPattern = entry.addressPattern, + secondaryAddressPattern = None, + exampleAddress = entry.exampleAddress, + description = entry.description, + downstreamRails = entry.downstreamRails, + status = "ACTIVE", + createdByUserId = SeedActor + ) + } + r.flatten match { + case Full(_) => (ins + 1, skp, fld) + case _ => (ins, skp, fld + 1) + } + } + } + logger.info(s"[RoutingSchemeSeed] inserted=$inserted skipped=$skipped failed=$failed (of ${tzSeeds.size} total seeds)") + } +} diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala new file mode 100644 index 0000000000..48f417ccc9 --- /dev/null +++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala @@ -0,0 +1,83 @@ +package code.routingscheme + +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +import scala.concurrent.Future + +object RoutingSchemeX extends SimpleInjector { + val routingScheme = new Inject(buildOne _) {} + + def buildOne: RoutingSchemeProvider = MappedRoutingSchemeProvider +} + +trait RoutingSchemeProvider { + def createRoutingScheme( + scheme: String, + country: String, + category: String, + addressPattern: String, + secondaryAddressPattern: Option[String], + exampleAddress: String, + description: String, + downstreamRails: List[String], + status: String, + createdByUserId: String + ): Box[RoutingSchemeTrait] + + def getRoutingScheme(scheme: String): Box[RoutingSchemeTrait] + + def getRoutingSchemes( + country: Option[String], + category: Option[String], + status: Option[String], + rail: Option[String], + limit: Int, + offset: Int + ): Future[Box[(List[RoutingSchemeTrait], Int)]] + + def updateRoutingScheme( + scheme: String, + addressPattern: Option[String], + secondaryAddressPattern: Option[String], + exampleAddress: Option[String], + description: Option[String], + downstreamRails: Option[List[String]], + status: Option[String] + ): Box[RoutingSchemeTrait] + + def deleteRoutingScheme(scheme: String): Box[Boolean] + + // ── Per-bank support ─────────────────────────────────────────────────────── + + def getBankSupportedRoutingSchemes(bankId: String): Future[Box[List[BankSupportedRoutingSchemeTrait]]] + + def putBankSupportedRoutingScheme( + bankId: String, + scheme: String, + enabled: Boolean, + bankNotes: Option[String] + ): Box[BankSupportedRoutingSchemeTrait] +} + +trait RoutingSchemeTrait { + def scheme: String + def country: String + def category: String + def addressPattern: String + def secondaryAddressPattern: Option[String] + def exampleAddress: String + def description: String + def downstreamRails: List[String] + def status: String + def createdByUserId: String + def createdAt: java.util.Date + def updatedAt: java.util.Date +} + +trait BankSupportedRoutingSchemeTrait { + def bankId: String + def scheme: String + def enabled: Boolean + def bankNotes: Option[String] +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 284029e3ae..597f6a9ed3 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -6,8 +6,10 @@ import code.api.util.http4s.Http4sLiftWebBridge import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateOrganisation} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, OrganisationAlreadyExists, OrganisationNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.routingscheme.RoutingSchemeX +import code.model.dataAccess.BankAccountRouting import code.customer.CustomerX import code.entitlement.Entitlement import code.organisation.OrganisationX @@ -2299,4 +2301,488 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── Routing Schemes ────────────────────────────────────────────────────── + + /** Create a routing scheme directly via the model layer for test setup. */ + private def createTestRoutingScheme(scheme: String, country: String = "TZ"): Unit = { + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = scheme, + country = country, + category = "ACCOUNT", + addressPattern = "^[0-9]{3,20}$", + secondaryAddressPattern = None, + exampleAddress = "12345678", + description = s"Test scheme $scheme", + downstreamRails = List("TEST"), + status = "ACTIVE", + createdByUserId = resourceUser1.userId + ) + } + + /** Returns a fresh, unique scheme name in the TZ namespace. */ + private def freshSchemeName(prefix: String = "TST"): String = + s"TZ.${prefix}_${APIUtil.generateUUID().take(6).toUpperCase}" + + feature("Http4s700 createRoutingScheme endpoint") { + + scenario("Reject unauthenticated POST to /routing-schemes", Http4s700RoutesTag) { + val body = """{"scheme":"TZ.X1","country":"TZ","category":"ACCOUNT","address_pattern":"^[0-9]+$","example_address":"123","description":"x"}""" + val (statusCode, _, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body) + statusCode shouldBe 401 + } + + scenario("Return 403 when authenticated but missing canCreateRoutingScheme role", Http4s700RoutesTag) { + val body = """{"scheme":"TZ.X2","country":"TZ","category":"ACCOUNT","address_pattern":"^[0-9]+$","example_address":"123","description":"x"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body, headers) + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserHasMissingRoles) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 201 with full routing scheme JSON on happy path", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canCreateRoutingScheme.toString) + val scheme = freshSchemeName("OK") + val body = s"""{"scheme":"$scheme","country":"TZ","category":"ACCOUNT","address_pattern":"^255[0-9]{9}$$","example_address":"255778300336","description":"Test MSISDN","downstream_rails":["TIPS"]}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body, headers) + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("scheme", "country", "category", "address_pattern", "example_address", "status", "created_by_user_id") + map.get("scheme") shouldBe Some(JString(scheme)) + map.get("country") shouldBe Some(JString("TZ")) + map.get("category") shouldBe Some(JString("ACCOUNT")) + map.get("status") shouldBe Some(JString("ACTIVE")) + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when scheme name does not match country-qualified convention", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canCreateRoutingScheme.toString) + val body = """{"scheme":"msisdn_tz","country":"TZ","category":"ACCOUNT","address_pattern":"^[0-9]+$","example_address":"123","description":"x"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(InvalidRoutingSchemeName) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when example_address does not match address_pattern", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canCreateRoutingScheme.toString) + val scheme = freshSchemeName("MIS") + // Pattern requires exactly 9 digits; example is letters. + val body = s"""{"scheme":"$scheme","country":"TZ","category":"ACCOUNT","address_pattern":"^[0-9]{9}$$","example_address":"not-numeric","description":"x"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(RoutingSchemeExampleAddressMismatch) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 409 when scheme already exists", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canCreateRoutingScheme.toString) + val scheme = freshSchemeName("DUP") + createTestRoutingScheme(scheme) + + val body = s"""{"scheme":"$scheme","country":"TZ","category":"ACCOUNT","address_pattern":"^[0-9]+$$","example_address":"123","description":"x"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/routing-schemes", body, headers) + statusCode shouldBe 409 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(RoutingSchemeAlreadyExists) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 getRoutingSchemes endpoint") { + + scenario("Public — returns 200 without authentication", Http4s700RoutesTag) { + val scheme = freshSchemeName("LST") + createTestRoutingScheme(scheme) + + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/routing-schemes") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("routing_schemes", "pagination") + map.get("routing_schemes") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected routing_schemes array") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 getRoutingScheme endpoint") { + + scenario("Return 200 for an existing scheme (no auth required)", Http4s700RoutesTag) { + val scheme = freshSchemeName("GET") + createTestRoutingScheme(scheme) + + val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/routing-schemes/$scheme") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).get("scheme") shouldBe Some(JString(scheme)) + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when scheme does not exist", Http4s700RoutesTag) { + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/routing-schemes/TZ.DOES_NOT_EXIST") + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(RoutingSchemeNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 updateRoutingScheme endpoint") { + + scenario("Reject unauthenticated PUT", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/routing-schemes/TZ.ANY", """{"status":"DEPRECATED"}""") + statusCode shouldBe 401 + } + + scenario("Return 403 when missing canUpdateRoutingScheme", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, _, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/routing-schemes/TZ.ANY", """{"status":"DEPRECATED"}""", headers) + statusCode shouldBe 403 + } + + scenario("Return 200 and persist new status when authenticated with role", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canUpdateRoutingScheme.toString) + val scheme = freshSchemeName("UPD") + createTestRoutingScheme(scheme) + + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = """{"status":"DEPRECATED","description":"updated"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/routing-schemes/$scheme", body, headers) + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("status") shouldBe Some(JString("DEPRECATED")) + map.get("description") shouldBe Some(JString("updated")) + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 deleteRoutingScheme endpoint") { + + scenario("Reject unauthenticated DELETE", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithMethod("DELETE", "/obp/v7.0.0/routing-schemes/TZ.ANY") + statusCode shouldBe 401 + } + + scenario("Return 204 and soft-delete (status flips to RETIRED) when role granted", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canDeleteRoutingScheme.toString) + val scheme = freshSchemeName("DEL") + createTestRoutingScheme(scheme) + + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, _, _) = makeHttpRequestWithMethod("DELETE", s"/obp/v7.0.0/routing-schemes/$scheme", headers) + statusCode shouldBe 204 + + And("the row should still exist with status RETIRED") + val fetched = RoutingSchemeX.routingScheme.vend.getRoutingScheme(scheme) + fetched.map(_.status) shouldBe net.liftweb.common.Full("RETIRED") + } + } + + feature("Http4s700 getBankSupportedRoutingSchemes endpoint") { + + scenario("Reject unauthenticated GET", Http4s700RoutesTag) { + val bankId = testBankId1.value + val (statusCode, _, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes") + statusCode shouldBe 401 + } + + scenario("Return 200 with empty/populated list for authenticated user", Http4s700RoutesTag) { + val bankId = testBankId1.value + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes", headers) + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("bank_id") shouldBe Some(JString(bankId)) + map.get("supported_routing_schemes") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected supported_routing_schemes array") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 putBankSupportedRoutingScheme endpoint") { + + scenario("Reject unauthenticated PUT", Http4s700RoutesTag) { + val bankId = testBankId1.value + val (statusCode, _, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/TZ.ANY", """{"enabled":true}""") + statusCode shouldBe 401 + } + + scenario("Return 403 when missing canUpdateBankSupportedRoutingScheme role", Http4s700RoutesTag) { + val bankId = testBankId1.value + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, _, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/TZ.ANY", """{"enabled":true}""", headers) + statusCode shouldBe 403 + } + + scenario("Return 404 when scheme does not exist in the registry", Http4s700RoutesTag) { + val bankId = testBankId1.value + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, canUpdateBankSupportedRoutingScheme.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/TZ.NOT_REGISTERED", """{"enabled":true}""", headers) + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(RoutingSchemeNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 when scheme exists and bank role granted; enabled=true persists notes", Http4s700RoutesTag) { + val bankId = testBankId1.value + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, canUpdateBankSupportedRoutingScheme.toString) + val scheme = freshSchemeName("BNK") + createTestRoutingScheme(scheme) + + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = """{"enabled":true,"bank_notes":"Routed via Gateway X. Cutoff 22:00."}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/$scheme", body, headers) + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("scheme") shouldBe Some(JString(scheme)) + map.get("bank_notes") shouldBe Some(JString("Routed via Gateway X. Cutoff 22:00.")) + case _ => fail("Expected JSON object") + } + } + } + + // ─── Payee Lookup ───────────────────────────────────────────────────────── + + /** + * Register a fresh routing scheme AND attach a matching account_routings entry + * to the named account, so getBankAccountByRouting(scheme, address) resolves. + * Returns the scheme name. + */ + private def seedPayeeForLookup(prefix: String, address: String, destBankId: String, destAccountId: String): String = { + val scheme = freshSchemeName(prefix) + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = "ACCOUNT", + addressPattern = "^[0-9]+$", secondaryAddressPattern = None, + exampleAddress = address, description = "Test", downstreamRails = Nil, + status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + BankAccountRouting.create + .BankId(destBankId) + .AccountId(destAccountId) + .AccountRoutingScheme(scheme) + .AccountRoutingAddress(address) + .saveMe() + scheme + } + + feature("Http4s700 createPayeeLookup endpoint") { + + scenario("Reject unauthenticated POST to /payees/lookup", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = """{"identifier_type":"TZ.MSISDN","identifier":"255778300336"}""" + val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/payees/lookup", body) + statusCode shouldBe 401 + } + + scenario("Return 400 when identifier_type is not registered", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = """{"identifier_type":"TZ.UNKNOWN_SCHEME","identifier":"123"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/payees/lookup", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(PayeeLookupIdentifierTypeNotRegistered) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when identifier does not match the scheme's address_pattern", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Create a strict scheme then send an address that doesn't match. + val scheme = freshSchemeName("STR") + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = "ACCOUNT", + addressPattern = "^255[0-9]{9}$", secondaryAddressPattern = None, + exampleAddress = "255778300336", description = "Strict TZ MSISDN", + downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + val body = s"""{"identifier_type":"$scheme","identifier":"not-a-phone"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/payees/lookup", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(PayeeLookupAddressMismatch) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when no account has the requested routing", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Registered scheme, valid pattern match, but no account_routings row. + val scheme = freshSchemeName("NMA") + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = "ACCOUNT", + addressPattern = "^[0-9]+$", secondaryAddressPattern = None, + exampleAddress = "12345", description = "No-match", downstreamRails = Nil, + status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + val body = s"""{"identifier_type":"$scheme","identifier":"99999999999"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/payees/lookup", body, headers) + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(PayeeNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 201 with lookup_id and payee details when account_routing resolves", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val address = s"2557${(System.currentTimeMillis() % 100000000L).toString.reverse.padTo(8, '0').reverse}" + val scheme = seedPayeeForLookup("HAP", address, bankId, accountId) + + val body = s"""{"identifier_type":"$scheme","identifier":"$address","fsp_id":"503"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/payees/lookup", body, headers) + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("lookup_id", "expires_at", "identifier_type", "identifier", "full_name") + map.get("identifier_type") shouldBe Some(JString(scheme)) + map.get("identifier") shouldBe Some(JString(address)) + map.get("fsp_id") shouldBe Some(JString("503")) + case _ => fail("Expected JSON object") + } + } + } + + // ─── MOBILE_WALLET transaction request ──────────────────────────────────── + + feature("Http4s700 createTransactionRequestMobileWallet endpoint") { + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = """{"to":{"msisdn":"255778300336"},"value":{"currency":"TZS","amount":"1000"},"description":"x"}""" + val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/MOBILE_WALLET/transaction-requests", body) + statusCode shouldBe 401 + } + + scenario("Return 400 when country-qualified MSISDN scheme is not in the registry", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // country_code=ZZ ⇒ scheme=ZZ.MSISDN which we never register. + val body = """{"to":{"msisdn":"255778300336"},"value":{"currency":"TZS","amount":"1000"},"description":"x","country_code":"ZZ"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/MOBILE_WALLET/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(PayeeLookupIdentifierTypeNotRegistered) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when msisdn does not match the scheme's address_pattern", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Use country_code=XW so the scheme is XW.MSISDN — register it with a strict pattern. + val country = "XW" + val schemeName = s"$country.MSISDN" + RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName) match { + case net.liftweb.common.Full(_) => // already registered from a previous run + case _ => + RoutingSchemeX.routingScheme.vend.createRoutingScheme( + scheme = schemeName, country = country, category = "ACCOUNT", + addressPattern = "^999[0-9]{9}$", secondaryAddressPattern = None, + exampleAddress = "999778300336", description = "Test only", + downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + } + val body = s"""{"to":{"msisdn":"not-a-phone"},"value":{"currency":"TZS","amount":"1000"},"description":"x","country_code":"$country"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/MOBILE_WALLET/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(MobileWalletInvalidMsisdn) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + } diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index ee935e0908..b6cc8a22c5 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -124,6 +124,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object CARDANO extends Value object ETH_SEND_TRANSACTION extends Value object ETH_SEND_RAW_TRANSACTION extends Value + object MOBILE_WALLET extends Value } sealed trait StrongCustomerAuthentication extends EnumValue