diff --git a/dependency-check-suppressions.xml b/dependency-check-suppressions.xml new file mode 100644 index 0000000000..3dc5bae678 --- /dev/null +++ b/dependency-check-suppressions.xml @@ -0,0 +1,75 @@ + + + + + + ^pkg:maven/sh\.ory\.hydra/hydra-client@.*$ + CVE-2026-33504 + + + + + ^pkg:maven/org\.apache\.avro/.*@1\.8\.2$ + CVE-2023-37475 + + + + + ^pkg:maven/com\.sksamuel\.avro4s/.*@1\.8\.2$ + CVE-2023-37475 + + + + + ^pkg:maven/com\.microsoft\.azure/msal4j@.*$ + CVE-2024-35255 + + + + + .*avro-1\.8\.2\.jar.META-INF.maven.com\.google\.guava.guava.pom\.xml$ + CVE-2018-10237 + CVE-2020-8908 + CVE-2023-2976 + + + diff --git a/obp-api/pom.xml b/obp-api/pom.xml index d6f8c3c25d..b12349c9fc 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -99,11 +99,13 @@ runtime - + com.mysql mysql-connector-j - 8.1.0 + 9.7.0 @@ -126,12 +128,14 @@ commons-beanutils 1.10.1 - + com.microsoft.azure msal4j - 1.16.2 + 1.24.1 + - com.sksamuel.elastic4s + nl.gn0s1s elastic4s-client-esjava_${scala.version} - 8.5.2 + 8.19.1 - + org.elasticsearch.client elasticsearch-rest-client - 8.14.0 + 8.19.12 @@ -384,27 +391,27 @@ io.grpc grpc-netty-shaded - 1.48.1 + 1.68.3 io.grpc grpc-protobuf - 1.48.1 + 1.68.3 io.grpc grpc-stub - 1.48.1 + 1.68.3 io.grpc grpc-services - 1.48.1 + 1.68.3 org.asynchttpclient async-http-client - 2.10.4 + 2.15.0 javax.activation @@ -446,7 +453,7 @@ com.microsoft.sqlserver mssql-jdbc - 12.6.4.jre${java.version} + 13.4.0.jre${java.version} @@ -500,7 +507,7 @@ com.fasterxml.jackson.core jackson-databind - 2.12.7.1 + diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 0c5c811125..ff7d9a8f8b 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -215,6 +215,8 @@ jwt.use.ssl=false write_metrics=false ## Enable writing connector metrics (which methods are called)to RDBMS write_connector_metrics=false +## Enable writing connector traces (full outbound/inbound message payloads per call) to RDBMS table `connector_trace`. Verbose — keep off in prod unless debugging. +write_connector_trace=false ## ElasticSearch #allow_elasticsearch=true diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index f7fe5b168e..c580ae3c29 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -89,6 +89,7 @@ import code.group.Group import code.organisation.Organisation import code.routingscheme.{RoutingScheme, BankSupportedRoutingScheme} import code.payeelookup.PayeeLookup +import code.bulkpayment.{BulkPayment, BulkBatchReference} import code.kycchecks.MappedKycCheck import code.kycdocuments.MappedKycDocument import code.kycmedias.MappedKycMedia @@ -1221,6 +1222,8 @@ object ToSchemify { RoutingScheme, BankSupportedRoutingScheme, PayeeLookup, + BulkPayment, + BulkBatchReference, AccountAccessRequest, code.chat.ChatRoom, code.chat.Participant, 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 3153bc4289..9fc2d195f6 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -497,6 +497,17 @@ object ErrorMessages { 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." + // BULK transaction-request (OBP-30536 .. OBP-30544) + val BulkBatchReferenceAlreadyUsed = "OBP-30536: batch_reference has already been used for this source account. Use a unique batch_reference per submission." + val BulkPaymentsArrayEmpty = "OBP-30537: payments array must contain at least one item." + val BulkPaymentsArrayTooLarge = "OBP-30538: payments array exceeds the configured maximum. See `bulk_payments.max_items_per_batch`." + val BulkDuplicateEndToEndId = "OBP-30539: Duplicate end_to_end_id within the batch. Each item's end_to_end_id must be unique within a single batch submission." + val BulkPaymentCurrencyMismatch = "OBP-30540: One or more payments use a currency that does not match the source account's currency. Cross-currency bulk payments are not supported in v7.0.0." + val BulkPaymentRoutingSchemeNotRegistered = "OBP-30541: A payment references a routing_scheme that is not in the Routing-Scheme registry." + val BulkPaymentRoutingSchemeWrongCategory = "OBP-30542: A payment's routing_scheme is not an ACCOUNT-category scheme — only ACCOUNT schemes are valid for BULK destinations." + val BulkPaymentAddressMismatch = "OBP-30543: A payment's address does not match the address_pattern of its routing_scheme." + val BulkPaymentTransactionRequestError = "OBP-30544: Could not create BULK 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/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index 5acb196d15..b23065c2bc 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -11,6 +11,7 @@ import net.liftweb.json.JsonDSL._ import org.http4s._ import org.http4s.headers.`Content-Type` import org.typelevel.ci.CIString +import org.slf4j.LoggerFactory /** * Converts OBP errors to http4s Response[IO]. @@ -28,7 +29,8 @@ import org.typelevel.ci.CIString object ErrorResponseConverter { import net.liftweb.json.Formats import code.api.util.CustomJsonFormats - + + private val logger = LoggerFactory.getLogger(getClass) implicit val formats: Formats = CustomJsonFormats.formats private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) @@ -112,6 +114,7 @@ object ErrorResponseConverter { * Returns 500 Internal Server Error. */ def unknownErrorToResponse(e: Throwable, callContext: CallContext): IO[Response[IO]] = { + logger.error(s"unknownErrorToResponse says: 500 returned (correlationId=${callContext.correlationId})", e) val errorJson = OBPErrorResponse(500, s"$UnknownError: ${e.getMessage}") IO.pure( Response[IO](org.http4s.Status.InternalServerError) 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 eeec04df12..7acd976e56 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 @@ -23,9 +23,12 @@ import code.bankconnectors.storedprocedure.StoredProcedureUtils 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.organisation.Organisations +import code.routingscheme.{RoutingSchemes, RoutingSchemeValidation} +import code.payeelookup.PayeeLookups +import code.bulkpayment.{BulkPaymentHandler, BulkPayments} +import code.transactionrequests.MappedTransactionRequestProvider +import com.openbankproject.commons.model.TransactionRequestCharge import code.metadata.tags.Tags import code.views.Views import code.accountattribute.AccountAttributeX @@ -2346,7 +2349,7 @@ object Http4s700 { // CRUD for the Organisation resource. Migrated from v6.0.0 (Lift) to v7.0.0 // (http4s). Path uses ORGANISATION_ID; not resolved by middleware (only BANK_ID // / ACCOUNT_ID / VIEW_ID / COUNTERPARTY_ID are), so endpoints fetch directly - // via OrganisationX.organisation.vend. + // via Organisations.organisation.vend. private val ValidOrganisationStatuses = Set("active", "suspended", "archived") private val ValidOrganisationVisibilities = Set("public", "unlisted", "private") @@ -2367,10 +2370,10 @@ object Http4s700 { _ <- Helper.booleanToFuture(InvalidOrganisationVisibility, 400, Some(cc)) { ValidOrganisationVisibilities.contains(visibility) } - existing <- Future(OrganisationX.organisation.vend.getOrganisation(body.organisation_id)) + existing <- Future(Organisations.organisation.vend.getOrganisation(body.organisation_id)) _ <- Helper.booleanToFuture(OrganisationAlreadyExists, 409, Some(cc))(existing.isEmpty) created <- Future { - OrganisationX.organisation.vend.createOrganisation( + Organisations.organisation.vend.createOrganisation( body.organisation_id, body.name, body.website, body.logo_url, status, visibility, user.userId ) @@ -2428,7 +2431,7 @@ object Http4s700 { case req @ GET -> `prefixPath` / "organisations" => EndpointHelpers.withUser(req) { (user, cc) => for { - allOrgs <- OrganisationX.organisation.vend.getAllOrganisations() + allOrgs <- Organisations.organisation.vend.getAllOrganisations() .map(unboxFullOrFail(_, Some(cc), UnknownError, 500)) hasGetAny = APIUtil.hasEntitlement("", user.userId, canGetAnyOrganisation) visible = if (hasGetAny) allOrgs else allOrgs.filter(_.visibility == "public") @@ -2472,7 +2475,7 @@ object Http4s700 { case req @ GET -> `prefixPath` / "organisations" / organisationId => EndpointHelpers.withUser(req) { (user, cc) => for { - org <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + org <- Future(Organisations.organisation.vend.getOrganisation(organisationId)) .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) _ <- if (org.visibility == "private") NewStyle.function.hasEntitlement("", user.userId, canGetAnyOrganisation, Some(cc)) @@ -2515,7 +2518,7 @@ object Http4s700 { case req @ PUT -> `prefixPath` / "organisations" / organisationId => EndpointHelpers.withUserAndBody[JSONFactory700.PutOrganisationJsonV700, JSONFactory700.OrganisationJsonV700](req) { (_, body, cc) => for { - _ <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + _ <- Future(Organisations.organisation.vend.getOrganisation(organisationId)) .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) _ <- Helper.booleanToFuture(InvalidOrganisationStatus, 400, Some(cc)) { body.status.forall(ValidOrganisationStatuses.contains) @@ -2524,7 +2527,7 @@ object Http4s700 { body.visibility.forall(ValidOrganisationVisibilities.contains) } updated <- Future { - OrganisationX.organisation.vend.updateOrganisation( + Organisations.organisation.vend.updateOrganisation( organisationId, body.name, body.website, body.logo_url, body.status, body.visibility ) }.map(unboxFullOrFail(_, Some(cc), UpdateOrganisationError, 400)) @@ -2572,9 +2575,9 @@ object Http4s700 { case req @ DELETE -> `prefixPath` / "organisations" / organisationId => EndpointHelpers.withUserDelete(req) { (_, cc) => for { - _ <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + _ <- Future(Organisations.organisation.vend.getOrganisation(organisationId)) .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) - _ <- Future(OrganisationX.organisation.vend.deleteOrganisation(organisationId)) + _ <- Future(Organisations.organisation.vend.deleteOrganisation(organisationId)) .map(unboxFullOrFail(_, Some(cc), DeleteOrganisationError, 400)) } yield () } @@ -2634,10 +2637,10 @@ object Http4s700 { _ <- Helper.booleanToFuture(RoutingSchemeExampleAddressMismatch, 400, Some(cc)) { RoutingSchemeValidation.addressMatchesPattern(body.address_pattern, body.example_address) } - existing <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(body.scheme)) + existing <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(body.scheme)) _ <- Helper.booleanToFuture(RoutingSchemeAlreadyExists, 409, Some(cc))(existing.isEmpty) created <- Future { - RoutingSchemeX.routingScheme.vend.createRoutingScheme( + RoutingSchemes.routingScheme.vend.createRoutingScheme( scheme = body.scheme, country = body.country, category = body.category, @@ -2720,7 +2723,7 @@ object Http4s700 { 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) + page <- RoutingSchemes.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) @@ -2763,7 +2766,7 @@ object Http4s700 { case req @ GET -> `prefixPath` / "routing-schemes" / schemeName => EndpointHelpers.executeAndRespond(req) { cc => for { - row <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + row <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(schemeName)) .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) } yield JSONFactory700.createRoutingSchemeJsonV700(row) } @@ -2802,7 +2805,7 @@ object Http4s700 { case req @ PUT -> `prefixPath` / "routing-schemes" / schemeName => EndpointHelpers.withUserAndBody[JSONFactory700.PutRoutingSchemeJsonV700, JSONFactory700.RoutingSchemeJsonV700](req) { (_, body, cc) => for { - existing <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + existing <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(schemeName)) .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) _ <- Helper.booleanToFuture(InvalidRoutingSchemeStatus, 400, Some(cc)) { body.status.forall(RoutingSchemeValidation.ValidStatuses.contains) @@ -2818,7 +2821,7 @@ object Http4s700 { RoutingSchemeValidation.addressMatchesPattern(effectivePattern, effectiveExample) } updated <- Future { - RoutingSchemeX.routingScheme.vend.updateRoutingScheme( + RoutingSchemes.routingScheme.vend.updateRoutingScheme( scheme = schemeName, addressPattern = body.address_pattern, secondaryAddressPattern = body.secondary_address_pattern, @@ -2881,9 +2884,9 @@ object Http4s700 { case req @ DELETE -> `prefixPath` / "routing-schemes" / schemeName => EndpointHelpers.withUserDelete(req) { (_, cc) => for { - _ <- Future(RoutingSchemeX.routingScheme.vend.getRoutingScheme(schemeName)) + _ <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(schemeName)) .map(unboxFullOrFail(_, Some(cc), RoutingSchemeNotFound, 404)) - _ <- Future(RoutingSchemeX.routingScheme.vend.deleteRoutingScheme(schemeName)) + _ <- Future(RoutingSchemes.routingScheme.vend.deleteRoutingScheme(schemeName)) .map(unboxFullOrFail(_, Some(cc), DeleteRoutingSchemeError, 400)) } yield () } @@ -2912,7 +2915,7 @@ object Http4s700 { case req @ GET -> `prefixPath` / "banks" / _ / "supported-routing-schemes" => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => for { - rows <- RoutingSchemeX.routingScheme.vend.getBankSupportedRoutingSchemes(bank.bankId.value) + rows <- RoutingSchemes.routingScheme.vend.getBankSupportedRoutingSchemes(bank.bankId.value) .map(unboxFullOrFail(_, Some(cc), UnknownError, 500)) } yield JSONFactory700.createBankSupportedRoutingSchemesJsonV700(bank.bankId.value, rows) } @@ -2952,13 +2955,13 @@ object Http4s700 { 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)) + scheme <- Future(RoutingSchemes.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( + RoutingSchemes.routingScheme.vend.putBankSupportedRoutingScheme( bankId = bank.bankId.value, scheme = schemeName, enabled = body.enabled.getOrElse(true), @@ -3004,11 +3007,12 @@ object Http4s700 { // ── 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. + // an identifier { scheme, address } pair (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 @@ -3017,22 +3021,22 @@ object Http4s700 { 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)) + // 1. identifier.scheme must exist in the registry. + scheme <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(body.identifier.scheme)) .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. + // 3. identifier.value must match the scheme's address_pattern. _ <- Helper.booleanToFuture(PayeeLookupAddressMismatch, 400, Some(cc)) { - RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.identifier) + RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.identifier.value) } // 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)) + .getBankAccountByRouting(None, body.identifier.scheme, body.identifier.value, Some(cc)) .map(_._1) payeeAccount <- Future { unboxFullOrFail(payeeBox, Some(cc), PayeeNotFound, 404) @@ -3040,11 +3044,11 @@ object Http4s700 { // 5. Persist a lookup record with a 10-minute TTL. lookupId = APIUtil.generateUUID() stored <- Future { - PayeeLookupX.payeeLookup.vend.createPayeeLookup( + PayeeLookups.payeeLookup.vend.createPayeeLookup( lookupId = lookupId, - identifierType = body.identifier_type, - identifier = body.identifier, - fspId = body.fsp_id, + identifierType = body.identifier.scheme, + identifier = body.identifier.value, + fspId = body.identifier.fsp_id, networkProvider = None, fullName = payeeAccount.label, accountCategory = None, @@ -3060,9 +3064,11 @@ object Http4s700 { } yield JSONFactory700.PayeeLookupResponseJsonV700( lookup_id = stored.lookupId, expires_at = stored.expiresAt, - identifier_type = stored.identifierType, - identifier = stored.identifier, - fsp_id = stored.fspId, + identifier = JSONFactory700.QualifiedIdentifierJsonV700( + scheme = stored.identifierType, + value = stored.identifier, + fsp_id = stored.fspId + ), network_provider = stored.networkProvider, full_name = stored.fullName, account_category = stored.accountCategory, @@ -3081,28 +3087,30 @@ object Http4s700 { "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. + |The endpoint is **polymorphic on `identifier.scheme`**: pass any registered routing scheme as the `identifier.scheme` and the corresponding `identifier.value`. The scheme's `category` must be one of ACCOUNT, BILL, UTILITY for it to be valid here. + | + |The `identifier` is a `QualifiedIdentifier` — `scheme` and `value` travel as a pair because neither is meaningful on its own. Optionally include `fsp_id` (Financial Service Provider) for multi-FSP namespaces where the same value may live with different providers (e.g. TZ.MSISDN); for such namespaces `scheme + value` alone may not uniquely identify the wallet. | |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` + |- Mobile-money / TIPS payee: `identifier: { scheme: TZ.MSISDN, value: 255778300336, fsp_id: 503 }` + |- TIPS bank-account name verify: `identifier: { scheme: TZ.BANK_ACCOUNT, value: 24110000296 }` + |- GePG bill inquiry: `identifier: { scheme: TZ.GEPG_CONTROL_NUMBER, value: 991043383705 }` + |- Luku meter inquiry: `identifier: { scheme: TZ.LUKU_METER, value: 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") + identifier = JSONFactory700.QualifiedIdentifierJsonV700( + scheme = "TZ.MSISDN", value = "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"), + identifier = JSONFactory700.QualifiedIdentifierJsonV700( + scheme = "TZ.MSISDN", value = "255778300336", fsp_id = Some("503") + ), network_provider = Some("ZANTEL"), full_name = "ERASTO EMILE MALEMA", account_category = Some("PERSON"), @@ -3128,7 +3136,7 @@ object Http4s700 { 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) => + EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyMobileWalletJsonV700, JSONFactory700.TransactionRequestWithChargeMobileWalletJsonV700](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") @@ -3136,7 +3144,7 @@ object Http4s700 { 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)) + scheme <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(msisdnScheme)) .map(unboxFullOrFail(_, callCtx, PayeeLookupIdentifierTypeNotRegistered, 400)) _ <- Helper.booleanToFuture(MobileWalletInvalidMsisdn, 400, callCtx) { RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.to.msisdn) @@ -3147,7 +3155,7 @@ object Http4s700 { _ <- body.verified_payee_lookup_id match { case Some(lkpId) => for { - lkp <- Future(PayeeLookupX.payeeLookup.vend.getActivePayeeLookup(lkpId)) + lkp <- Future(PayeeLookups.payeeLookup.vend.getActivePayeeLookup(lkpId)) .map(unboxFullOrFail(_, callCtx, PayeeLookupExpiredOrNotFound, 400)) _ <- Helper.booleanToFuture(PayeeLookupMismatch, 400, callCtx) { lkp.identifier == body.to.msisdn && lkp.identifierType == msisdnScheme @@ -3184,10 +3192,29 @@ object Http4s700 { None, callCtx ) - } yield code.api.v4_0_0.JSONFactory400.createTransactionRequestWithChargeJSON(tr, Nil, Nil) + } yield JSONFactory700.createTransactionRequestWithChargeMobileWalletJsonV700(tr, body, Nil, Nil) } } + val mobileWalletBodyExample = 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") + ) + resourceDocs += ResourceDoc( null, implementedInApiVersion, @@ -3204,25 +3231,26 @@ object Http4s700 { |**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 + mobileWalletBodyExample, + JSONFactory700.TransactionRequestWithChargeMobileWalletJsonV700( + id = "4050046c-63b3-4868-8a22-14b4181d33a6", + `type` = "MOBILE_WALLET", + from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f1" ), - 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") + details = mobileWalletBodyExample, + transaction_ids = List("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1"), + status = "COMPLETED", + start_date = code.api.util.APIUtil.DateWithDayExampleObject, + end_date = code.api.util.APIUtil.DateWithDayExampleObject, + challenges = Nil, + charge = code.api.v2_0_0.TransactionRequestChargeJsonV200( + summary = "Total charges for completed transaction", + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "0.00") + ), + attributes = None ), - transactionRequestWithChargeJSON400, List($AuthenticatedUserIsRequired, InvalidJsonFormat, PayeeLookupIdentifierTypeNotRegistered, MobileWalletInvalidMsisdn, PayeeLookupExpiredOrNotFound, PayeeLookupMismatch, @@ -3234,6 +3262,180 @@ object Http4s700 { // ── End MOBILE_WALLET ───────────────────────────────────────────────────── + // ── BULK transaction request ────────────────────────────────────────────── + // One TransactionRequest with type=BULK serves as the envelope; N actual + // Transactions (one per payment) are linked back to it via transaction_ids. + // Per-payment outcomes live in BulkPayment so each result can be + // mapped back to its end_to_end_id. Validation failures (unknown scheme, + // bad address, missing destination) mark the individual payment FAILED but + // do not abort the whole batch — matches how real CBS bulk processing + // behaves. See BulkPaymentHandler for the orchestration. + + val createTransactionRequestBulk: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "BULK" / "transaction-requests" => + EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyBulkJsonV700, JSONFactory700.BulkTransactionRequestResponseJsonV700](req) { (user, fromAccount, view, body, cc) => + val callCtx = Some(cc) + val chargePolicy = body.charge_policy.getOrElse("SHARED") + for { + // 1. Envelope-level validation (idempotency, size, currency, totals). + _ <- BulkPaymentHandler.validateEnvelope(body, fromAccount, callCtx) + // 2. Standard view-based authorisation check. + _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest( + view.viewId, BankIdAccountId(fromAccount.bankId, fromAccount.accountId), user, callCtx + ) + // 3. Create the parent BULK TR row. toAccount = self (envelope only; + // the real destinations live in the per-payment side-table). + trId = APIUtil.generateUUID() + detailsPlain = prettyRender(Extraction.decompose(body)) + parentTrBox = MappedTransactionRequestProvider.createTransactionRequestImpl210( + com.openbankproject.commons.model.TransactionRequestId(trId), + TransactionRequestType("BULK"), + fromAccount, + fromAccount, + body, + detailsPlain, + "INITIATED", + TransactionRequestCharge( + "Bulk payment", + com.openbankproject.commons.model.AmountOfMoney(fromAccount.currency, "0") + ), + chargePolicy, + None, None, None, None, + callCtx + ) + _ <- Future { + unboxFullOrFail(parentTrBox, callCtx, BulkPaymentTransactionRequestError, 500) + } + // 4. Claim the batch_reference for idempotency. After this, a + // second submission with the same batch_reference fails fast. + _ <- Future { + BulkPayments.bulkPayment.vend.claimBatchReference( + fromAccount.bankId.value, fromAccount.accountId.value, body.batch_reference, trId + ) + } + // 5. Fan-out — sequential per-payment execution. Returns one row + // per input item (SUCCEEDED / FAILED + reason). + itemRows <- BulkPaymentHandler.executeAllItems(body, fromAccount, trId, chargePolicy, callCtx) + // 6. Roll up the parent status. + rollupStatus = BulkPaymentHandler.computeStatus(itemRows) + _ <- Future { + MappedTransactionRequestProvider.saveTransactionRequestStatusImpl( + com.openbankproject.commons.model.TransactionRequestId(trId), rollupStatus + ) + } + // 7. Read back the final TR with rolled-up status + transaction_ids. + finalTr <- Future { + unboxFullOrFail( + MappedTransactionRequestProvider.getTransactionRequest( + com.openbankproject.commons.model.TransactionRequestId(trId) + ), + callCtx, BulkPaymentTransactionRequestError, 500 + ) + } + } yield JSONFactory700.createBulkTransactionRequestResponseJsonV700( + finalTr, body.batch_reference, itemRows + ) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createTransactionRequestBulk), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/BULK/transaction-requests", + "Create Transaction Request (BULK)", + """Submit a batch of payments against a single source account. + | + |Each item in `payments` is a heterogeneous payment instruction: + |- `end_to_end_id` — caller-supplied unique reference (ISO 20022 convention). Must be unique within the batch. + |- `to_account_routing.scheme` — any registered routing scheme of category `ACCOUNT` (e.g. `TZ.BANK_ACCOUNT`, `TZ.MSISDN`). + |- `to_account_routing.address` — must match the scheme's `address_pattern`. + |- `value` + `description` — per-payment amount and label. Currency must match the source account's currency. + | + |The envelope `value` must equal the sum of item amounts (caller declares the total; the server validates it). + | + |**Idempotency**: `batch_reference` is unique per (source account, batch). Re-submitting the same batch_reference returns `OBP-30536`. + | + |**Atomicity**: validation failures (unknown scheme, address mismatch, missing destination) mark the individual payment as `FAILED` and do not abort the batch. The TR-level `status` rolls up to `COMPLETED`, `PARTIALLY_COMPLETED`, or `FAILED` accordingly. + | + |**Maximum size**: `bulk_payments.max_items_per_batch` (default 1000). + | + |Authentication is Required.""".stripMargin, + JSONFactory700.TransactionRequestBodyBulkJsonV700( + batch_reference = "BATCH-2026-05-13-001", + payments = List( + JSONFactory700.BulkPaymentItemJsonV700( + end_to_end_id = "E2E-0001", + to_account_routing = com.openbankproject.commons.model.AccountRoutingJsonV121( + scheme = "TZ.BANK_ACCOUNT", address = "24110000296" + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "50000.00"), + description = "Payroll April 2026 — beneficiary 1" + ), + JSONFactory700.BulkPaymentItemJsonV700( + end_to_end_id = "E2E-0002", + to_account_routing = com.openbankproject.commons.model.AccountRoutingJsonV121( + scheme = "TZ.MSISDN", address = "255778300336" + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "25000.00"), + description = "Payroll April 2026 — beneficiary 2" + ) + ), + requested_execution_date = None, + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "75000.00"), + description = "Payroll batch April 2026", + charge_policy = Some("SHARED") + ), + JSONFactory700.BulkTransactionRequestResponseJsonV700( + id = "d8839721-ad8f-45dd-9f78-2080414b93f9", + batch_reference = "BATCH-2026-05-13-001", + status = "COMPLETED", + from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140( + bank_id = "nmb.tz", account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0" + ), + total_value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "75000.00"), + total_payments = 2, + succeeded_count = 2, + failed_count = 0, + payments = List( + JSONFactory700.BulkPaymentItemResultJsonV700( + end_to_end_id = "E2E-0001", + to_account_routing = com.openbankproject.commons.model.AccountRoutingJsonV121( + scheme = "TZ.BANK_ACCOUNT", address = "24110000296" + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "50000.00"), + status = "SUCCEEDED", + transaction_id = Some("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1"), + failure_reason = None + ), + JSONFactory700.BulkPaymentItemResultJsonV700( + end_to_end_id = "E2E-0002", + to_account_routing = com.openbankproject.commons.model.AccountRoutingJsonV121( + scheme = "TZ.MSISDN", address = "255778300336" + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "25000.00"), + status = "SUCCEEDED", + transaction_id = Some("a3b40c2c-fff5-462b-924e-ab8eb4c89523"), + failure_reason = None + ) + ), + transaction_ids = List("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1", "a3b40c2c-fff5-462b-924e-ab8eb4c89523"), + start_date = new java.util.Date(), + end_date = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + BulkPaymentsArrayEmpty, BulkPaymentsArrayTooLarge, + BulkPaymentCurrencyMismatch, BulkDuplicateEndToEndId, + BulkBatchReferenceAlreadyUsed, BulkPaymentTransactionRequestError, + UnknownError), + apiTagTransactionRequest :: Nil, + None, + http4sPartialFunction = Some(createTransactionRequestBulk) + ) + + // ── End BULK ────────────────────────────────────────────────────────────── + // ── 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 1ef2f038ab..9dd0347b9f 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 @@ -651,22 +651,35 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) ) + // ── Qualified Identifier ──────────────────────────────────────────────────── + // A (scheme, value) triple where the scheme qualifies the value's namespace. + // Used wherever the API takes or returns an identifier that belongs to a + // registered routing-scheme: account routings, bill references, meter + // numbers, KYC documents, etc. + // + // `fsp_id` is optional and only meaningful for multi-FSP namespaces where + // the same value may live with different providers (e.g. mobile money: + // TZ.MSISDN portability). When present, it participates in identity: + // (scheme + value + fsp_id) uniquely picks one wallet; (scheme + value) + // alone may not. + case class QualifiedIdentifierJsonV700( + scheme: String, + value: String, + fsp_id: Option[String] = None + ) + // ── Payee Lookup JSON case classes ────────────────────────────────────────── case class PayeeIdentityJsonV700(`type`: String, value: String) case class PostPayeeLookupJsonV700( - identifier_type: String, - identifier: String, - fsp_id: Option[String] + identifier: QualifiedIdentifierJsonV700 ) case class PayeeLookupResponseJsonV700( lookup_id: String, expires_at: java.util.Date, - identifier_type: String, - identifier: String, - fsp_id: Option[String], + identifier: QualifiedIdentifierJsonV700, network_provider: Option[String], full_name: String, account_category: Option[String], @@ -704,4 +717,135 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { data_fields: Option[List[MobileWalletDataFieldJsonV700]], charge_policy: Option[String] ) extends com.openbankproject.commons.model.TransactionRequestCommonBodyJSON + + // v7 response shape for MOBILE_WALLET. Mirrors v4's wrapper but binds `details` + // to the type-specific request body so resource-doc examples and the live + // response no longer advertise the legacy `TransactionRequestBodyAllTypes` union. + case class TransactionRequestWithChargeMobileWalletJsonV700( + id: String, + `type`: String, + from: code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140, + details: TransactionRequestBodyMobileWalletJsonV700, + transaction_ids: List[String], + status: String, + start_date: java.util.Date, + end_date: java.util.Date, + challenges: List[code.api.v4_0_0.ChallengeJsonV400], + charge: code.api.v2_0_0.TransactionRequestChargeJsonV200, + attributes: Option[List[code.api.v4_0_0.BankAttributeBankResponseJsonV400]] + ) + + def createTransactionRequestWithChargeMobileWalletJsonV700( + tr: com.openbankproject.commons.model.TransactionRequest, + requestBody: TransactionRequestBodyMobileWalletJsonV700, + challenges: List[com.openbankproject.commons.model.ChallengeTrait], + transactionRequestAttribute: List[com.openbankproject.commons.model.TransactionRequestAttributeTrait] + ): TransactionRequestWithChargeMobileWalletJsonV700 = { + val v4 = code.api.v4_0_0.JSONFactory400.createTransactionRequestWithChargeJSON( + tr, challenges, transactionRequestAttribute + ) + TransactionRequestWithChargeMobileWalletJsonV700( + id = v4.id, + `type` = v4.`type`, + from = v4.from, + details = requestBody, + transaction_ids = v4.transaction_ids, + status = v4.status, + start_date = v4.start_date, + end_date = v4.end_date, + challenges = v4.challenges, + charge = v4.charge, + attributes = v4.attributes + ) + } + + // ── BULK transaction-request body ───────────────────────────────────────── + + case class BulkPaymentItemJsonV700( + end_to_end_id: String, + to_account_routing: com.openbankproject.commons.model.AccountRoutingJsonV121, + value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + description: String + ) + + /** + * Body for `POST .../transaction-request-types/BULK/transaction-requests`. + * + * `value` and `description` at this level are the **batch-level rollups** — + * `value` is the sum of all items' amounts (server-validated), and `description` + * is a free-text label for the batch. Required because we plug into the existing + * v400 transaction-request pipeline via `TransactionRequestCommonBodyJSON`. + */ + case class TransactionRequestBodyBulkJsonV700( + batch_reference: String, + payments: List[BulkPaymentItemJsonV700], + requested_execution_date: Option[java.util.Date], + value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + description: String, + charge_policy: Option[String] + ) extends com.openbankproject.commons.model.TransactionRequestCommonBodyJSON + + case class BulkPaymentItemResultJsonV700( + end_to_end_id: String, + to_account_routing: com.openbankproject.commons.model.AccountRoutingJsonV121, + value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + status: String, // SUCCEEDED | FAILED | PENDING + transaction_id: Option[String], + failure_reason: Option[String] + ) + + case class BulkTransactionRequestResponseJsonV700( + id: String, // OBP transaction_request_id + batch_reference: String, // caller-supplied + status: String, // batch-level rollup: COMPLETED | PARTIALLY_COMPLETED | FAILED | INITIATED + from: code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140, + total_value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + total_payments: Int, + succeeded_count: Int, + failed_count: Int, + payments: List[BulkPaymentItemResultJsonV700], + transaction_ids: List[String], + start_date: java.util.Date, + end_date: java.util.Date + ) + + def createBulkTransactionRequestResponseJsonV700( + tr: com.openbankproject.commons.model.TransactionRequest, + batchReference: String, + results: List[code.bulkpayment.BulkPaymentTrait] + ): BulkTransactionRequestResponseJsonV700 = { + val v4From = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140( + bank_id = tr.from.bank_id, account_id = tr.from.account_id + ) + val succeeded = results.count(_.status == "SUCCEEDED") + val failed = results.count(_.status == "FAILED") + val total = tr.body.value + BulkTransactionRequestResponseJsonV700( + id = tr.id.value, + batch_reference = batchReference, + status = tr.status, + from = v4From, + total_value = com.openbankproject.commons.model.AmountOfMoneyJsonV121( + currency = total.currency, amount = total.amount + ), + total_payments = results.size, + succeeded_count = succeeded, + failed_count = failed, + payments = results.map { p => + BulkPaymentItemResultJsonV700( + end_to_end_id = p.endToEndId, + to_account_routing = com.openbankproject.commons.model.AccountRoutingJsonV121( + scheme = p.routingScheme, address = p.address + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = p.currency, amount = p.amount), + status = p.status, + transaction_id = p.transactionId, + failure_reason = p.failureReason + ) + }, + transaction_ids = Option(tr.transaction_ids).getOrElse("").split(",").toList.map(_.trim).filter(_.nonEmpty), + start_date = tr.start_date, + end_date = tr.end_date + ) + } } diff --git a/obp-api/src/main/scala/code/bulkpayment/BulkPayment.scala b/obp-api/src/main/scala/code/bulkpayment/BulkPayment.scala new file mode 100644 index 0000000000..9495ae4dbc --- /dev/null +++ b/obp-api/src/main/scala/code/bulkpayment/BulkPayment.scala @@ -0,0 +1,115 @@ +package code.bulkpayment + +import net.liftweb.common.{Box, Full} +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedBulkPaymentProvider extends BulkPaymentProvider { + + override def createBulkPayment( + transactionRequestId: String, + itemIndex: Int, + endToEndId: String, + routingScheme: String, + address: String, + currency: String, + amount: String, + description: String, + status: String, + failureReason: Option[String], + transactionId: Option[String] + ): Box[BulkPaymentTrait] = tryo { + BulkPayment.create + .TransactionRequestId(transactionRequestId) + .ItemIndex(itemIndex) + .EndToEndId(endToEndId) + .RoutingScheme(routingScheme) + .Address(address) + .Currency(currency) + .Amount(amount) + .Description(description) + .Status(status) + .FailureReason(failureReason.orNull) + .TransactionId(transactionId.orNull) + .saveMe() + } + + override def getBulkPaymentsForTransactionRequest(transactionRequestId: String): List[BulkPaymentTrait] = + BulkPayment.findAll( + By(BulkPayment.TransactionRequestId, transactionRequestId), + OrderBy(BulkPayment.ItemIndex, Ascending) + ).asInstanceOf[List[BulkPaymentTrait]] + + override def isBatchReferenceUsed(fromBankId: String, fromAccountId: String, batchReference: String): Boolean = + BulkBatchReference.find( + By(BulkBatchReference.FromBankId, fromBankId), + By(BulkBatchReference.FromAccountId, fromAccountId), + By(BulkBatchReference.BatchReference, batchReference) + ).isDefined + + override def claimBatchReference(fromBankId: String, fromAccountId: String, batchReference: String, transactionRequestId: String): Box[Unit] = + tryo { + BulkBatchReference.create + .FromBankId(fromBankId) + .FromAccountId(fromAccountId) + .BatchReference(batchReference) + .TransactionRequestId(transactionRequestId) + .saveMe() + () + } +} + +class BulkPayment extends BulkPaymentTrait with LongKeyedMapper[BulkPayment] with IdPK { + def getSingleton = BulkPayment + + object TransactionRequestId extends MappedString(this, 64) + object ItemIndex extends MappedInt(this) + object EndToEndId extends MappedString(this, 64) + object RoutingScheme extends MappedString(this, 64) + object Address extends MappedString(this, 128) + object Currency extends MappedString(this, 8) + object Amount extends MappedString(this, 32) + object Description extends MappedString(this, 2000) + object Status extends MappedString(this, 16) + object FailureReason extends MappedString(this, 1000) { + override def dbNotNull_? = false + } + object TransactionId extends MappedString(this, 64) { + override def dbNotNull_? = false + } + + override def transactionRequestId: String = TransactionRequestId.get + override def itemIndex: Int = ItemIndex.get + override def endToEndId: String = EndToEndId.get + override def routingScheme: String = RoutingScheme.get + override def address: String = Address.get + override def currency: String = Currency.get + override def amount: String = Amount.get + override def description: String = Description.get + override def status: String = Status.get + override def failureReason: Option[String] = Option(FailureReason.get) + override def transactionId: Option[String] = Option(TransactionId.get) +} + +object BulkPayment extends BulkPayment with LongKeyedMetaMapper[BulkPayment] { + override def dbTableName = "BulkPayment" + override def dbIndexes = + Index(TransactionRequestId) :: UniqueIndex(TransactionRequestId, ItemIndex) :: super.dbIndexes +} + +/** One row per claimed batch_reference, scoped to a source account. + * Existence is checked at submission time for idempotency. */ +class BulkBatchReference extends LongKeyedMapper[BulkBatchReference] with IdPK { + def getSingleton = BulkBatchReference + + object FromBankId extends MappedString(this, 255) + object FromAccountId extends MappedString(this, 255) + object BatchReference extends MappedString(this, 64) + object TransactionRequestId extends MappedString(this, 64) +} + +object BulkBatchReference extends BulkBatchReference with LongKeyedMetaMapper[BulkBatchReference] { + override def dbTableName = "BulkBatchReference" + override def dbIndexes = + UniqueIndex(FromBankId, FromAccountId, BatchReference) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/bulkpayment/BulkPaymentHandler.scala b/obp-api/src/main/scala/code/bulkpayment/BulkPaymentHandler.scala new file mode 100644 index 0000000000..705ffaa86e --- /dev/null +++ b/obp-api/src/main/scala/code/bulkpayment/BulkPaymentHandler.scala @@ -0,0 +1,201 @@ +package code.bulkpayment + +import code.api.util.{APIUtil, CallContext, ErrorMessages, NewStyle} +import code.api.util.ErrorMessages._ +import code.api.v7_0_0.JSONFactory700 +import code.bankconnectors.{Connector => BankConnector} +import code.routingscheme.{RoutingSchemeValidation, RoutingSchemes} +import code.transactionrequests.MappedTransactionRequestProvider +import code.util.Helper +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.model.enums.TransactionRequestStatus +import net.liftweb.common.{Box, Full} +import net.liftweb.json.{Extraction, Formats} +import net.liftweb.json.JsonAST.prettyRender + +import scala.concurrent.Future + +/** + * Orchestrator for BULK transaction-requests in mapped-mode. + * + * Mapped mode = fan out each payment into a real Transaction (debit source / credit + * destination). All resulting Transactions share the parent BULK transaction-request_id. + * Per-payment outcomes (SUCCEEDED / FAILED + reason) are recorded in the + * BulkPayment side-table so callers can map each result back to its + * end_to_end_id. + * + * In adapter mode this orchestrator would be replaced by a single connector call + * that hands the whole batch to the south side; the side-table rows would be + * populated later via callbacks. + */ +object BulkPaymentHandler { + + /** Idempotency + envelope checks. Returns an error message or unit. */ + def validateEnvelope( + body: JSONFactory700.TransactionRequestBodyBulkJsonV700, + fromAccount: BankAccount, + callContext: Option[CallContext] + )(implicit formats: Formats): Future[Unit] = { + val maxItems = APIUtil.getPropsAsIntValue("bulk_payments.max_items_per_batch", 1000) + val sourceCurrency = fromAccount.currency + + for { + _ <- Helper.booleanToFuture(BulkPaymentsArrayEmpty, 400, callContext) { + body.payments.nonEmpty + } + _ <- Helper.booleanToFuture(BulkPaymentsArrayTooLarge, 400, callContext) { + body.payments.size <= maxItems + } + _ <- Helper.booleanToFuture(BulkPaymentCurrencyMismatch, 400, callContext) { + body.value.currency == sourceCurrency && + body.payments.forall(_.value.currency == sourceCurrency) + } + _ <- Helper.booleanToFuture(BulkDuplicateEndToEndId, 400, callContext) { + body.payments.map(_.end_to_end_id).distinct.size == body.payments.size + } + // Total = sum of items (server-validated against caller's declared total) + _ <- { + val declared = BigDecimal(body.value.amount) + val actual = body.payments.map(p => BigDecimal(p.value.amount)).sum + Helper.booleanToFuture( + s"$InvalidNumber Declared total $declared does not match sum of payments $actual", 400, callContext + ) { + declared == actual + } + } + _ <- Helper.booleanToFuture(BulkBatchReferenceAlreadyUsed, 409, callContext) { + !BulkPayments.bulkPayment.vend.isBatchReferenceUsed( + fromAccount.bankId.value, fromAccount.accountId.value, body.batch_reference + ) + } + } yield () + } + + /** + * Fan-out: for each item, validate routing + resolve destination + makePayment; + * record outcome in BulkPayment. Always returns one row per input item. + * Validation failures do NOT abort the batch — they mark the item FAILED and continue. + */ + def executeAllItems( + body: JSONFactory700.TransactionRequestBodyBulkJsonV700, + fromAccount: BankAccount, + transactionRequestId: String, + chargePolicy: String, + callContext: Option[CallContext] + )(implicit formats: Formats): Future[List[BulkPaymentTrait]] = { + // Run items sequentially — preserves debit ordering against the source + // account so per-leg balance checks are deterministic. + body.payments.zipWithIndex.foldLeft(Future.successful(List.empty[BulkPaymentTrait])) { + case (accFut, (item, idx)) => + accFut.flatMap { acc => + executeOneItem(item, idx, fromAccount, transactionRequestId, chargePolicy, callContext) + .map(row => acc :+ row) + } + } + } + + private def executeOneItem( + item: JSONFactory700.BulkPaymentItemJsonV700, + idx: Int, + fromAccount: BankAccount, + transactionRequestId: String, + chargePolicy: String, + callContext: Option[CallContext] + )(implicit formats: Formats): Future[BulkPaymentTrait] = { + // Closure that records a FAILED row and returns it. + def recordFailure(reason: String): BulkPaymentTrait = + BulkPayments.bulkPayment.vend.createBulkPayment( + transactionRequestId = transactionRequestId, + itemIndex = idx, + endToEndId = item.end_to_end_id, + routingScheme = item.to_account_routing.scheme, + address = item.to_account_routing.address, + currency = item.value.currency, + amount = item.value.amount, + description = item.description, + status = "FAILED", + failureReason = Some(reason), + transactionId = None + ).openOrThrowException("BulkPayment write failed") + + // 1. Scheme must be registered. + val schemeBox = RoutingSchemes.routingScheme.vend.getRoutingScheme(item.to_account_routing.scheme) + schemeBox match { + case Full(scheme) => + // 2. Scheme must be ACCOUNT category (BULK only routes between accounts). + if (scheme.category != "ACCOUNT") + Future.successful(recordFailure(BulkPaymentRoutingSchemeWrongCategory)) + // 3. Address must match the scheme's pattern. + else if (!RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, item.to_account_routing.address)) + Future.successful(recordFailure(BulkPaymentAddressMismatch)) + else { + // 4. Resolve destination account. + BankConnector.connector.vend.getBankAccountByRouting(None, item.to_account_routing.scheme, item.to_account_routing.address, callContext) + .flatMap { case (destBox, _) => + destBox match { + case Full(toAccount) => + // 5. Do the actual debit/credit. + // transaction_request_id is the BULK parent — all payments share it. + val bodyShim = SingletonBulkLegBody(item.value, item.description) + BankConnector.connector.vend.makePaymentv210( + fromAccount, + toAccount, + TransactionRequestId(transactionRequestId), + bodyShim, + BigDecimal(item.value.amount), + item.description, + TransactionRequestType("BULK"), + chargePolicy, + callContext + ).map { case (txIdBox, _) => + txIdBox match { + case Full(txId) => + // Link the new transaction to the parent TR. + MappedTransactionRequestProvider + .saveTransactionRequestTransactionImpl(TransactionRequestId(transactionRequestId), txId) + BulkPayments.bulkPayment.vend.createBulkPayment( + transactionRequestId = transactionRequestId, + itemIndex = idx, + endToEndId = item.end_to_end_id, + routingScheme = item.to_account_routing.scheme, + address = item.to_account_routing.address, + currency = item.value.currency, + amount = item.value.amount, + description = item.description, + status = "SUCCEEDED", + failureReason = None, + transactionId = Some(txId.value) + ).openOrThrowException("BulkPayment write failed") + case _ => + recordFailure(txIdBox.toString) + } + } + case _ => + Future.successful(recordFailure(s"$PayeeNotFound (scheme=${item.to_account_routing.scheme}, address=${item.to_account_routing.address})")) + } + } + } + case _ => + Future.successful(recordFailure(BulkPaymentRoutingSchemeNotRegistered)) + } + } + + def computeStatus(items: List[BulkPaymentTrait]): String = { + val succeeded = items.count(_.status == "SUCCEEDED") + val failed = items.count(_.status == "FAILED") + (succeeded, failed) match { + case (n, 0) if n > 0 => "COMPLETED" + case (n, _) if n > 0 => "PARTIALLY_COMPLETED" + case _ => "FAILED" + } + } + + /** Thin shim implementing TransactionRequestCommonBodyJSON for one leg — + * makePaymentv210 needs this even though for BULK the meaningful body is at + * the batch level. */ + private case class SingletonBulkLegBody( + value: AmountOfMoneyJsonV121, + description: String + ) extends TransactionRequestCommonBodyJSON +} diff --git a/obp-api/src/main/scala/code/bulkpayment/BulkPaymentTrait.scala b/obp-api/src/main/scala/code/bulkpayment/BulkPaymentTrait.scala new file mode 100644 index 0000000000..f9668c6dc9 --- /dev/null +++ b/obp-api/src/main/scala/code/bulkpayment/BulkPaymentTrait.scala @@ -0,0 +1,53 @@ +package code.bulkpayment + +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object BulkPayments extends SimpleInjector { + val bulkPayment = new Inject(buildOne _) {} + + def buildOne: BulkPaymentProvider = MappedBulkPaymentProvider +} + +trait BulkPaymentProvider { + /** Append one payment row to a bulk transaction-request. */ + def createBulkPayment( + transactionRequestId: String, + itemIndex: Int, + endToEndId: String, + routingScheme: String, + address: String, + currency: String, + amount: String, + description: String, + status: String, + failureReason: Option[String], + transactionId: Option[String] + ): Box[BulkPaymentTrait] + + /** All payment rows for a bulk TR, in item_index order. */ + def getBulkPaymentsForTransactionRequest(transactionRequestId: String): List[BulkPaymentTrait] + + /** True iff any row already exists with the given batch_reference (caller-supplied) + * for the same source account — used for idempotency check before insertion. */ + def isBatchReferenceUsed(fromBankId: String, fromAccountId: String, batchReference: String): Boolean + + /** Mark that a batch_reference has been claimed by a TR (separate row in + * the batch-references table so the check above is O(1)). */ + def claimBatchReference(fromBankId: String, fromAccountId: String, batchReference: String, transactionRequestId: String): Box[Unit] +} + +/** One row per payment inside a bulk TR. */ +trait BulkPaymentTrait { + def transactionRequestId: String + def itemIndex: Int + def endToEndId: String + def routingScheme: String + def address: String + def currency: String + def amount: String + def description: String + def status: String // PENDING | SUCCEEDED | FAILED + def failureReason: Option[String] + def transactionId: Option[String] +} diff --git a/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala b/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala index 847f0a1eaa..8982fe20b7 100644 --- a/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala +++ b/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala @@ -5,7 +5,7 @@ import net.liftweb.util.SimpleInjector import scala.concurrent.Future -object OrganisationX extends SimpleInjector { +object Organisations extends SimpleInjector { val organisation = new Inject(buildOne _) {} def buildOne: OrganisationProvider = MappedOrganisationProvider diff --git a/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala b/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala index c14db307cd..ae3e8df832 100644 --- a/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala +++ b/obp-api/src/main/scala/code/payeelookup/PayeeLookupTrait.scala @@ -3,7 +3,7 @@ package code.payeelookup import net.liftweb.common.Box import net.liftweb.util.SimpleInjector -object PayeeLookupX extends SimpleInjector { +object PayeeLookups extends SimpleInjector { val payeeLookup = new Inject(buildOne _) {} def buildOne: PayeeLookupProvider = MappedPayeeLookupProvider diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala index 48f417ccc9..e99dce41d2 100644 --- a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala +++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeTrait.scala @@ -5,7 +5,7 @@ import net.liftweb.util.SimpleInjector import scala.concurrent.Future -object RoutingSchemeX extends SimpleInjector { +object RoutingSchemes extends SimpleInjector { val routingScheme = new Inject(buildOne _) {} def buildOne: RoutingSchemeProvider = MappedRoutingSchemeProvider 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 597f6a9ed3..31c2432798 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 @@ -8,11 +8,11 @@ import code.api.ResponseHeader import code.api.util.APIUtil 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.routingscheme.RoutingSchemes import code.model.dataAccess.BankAccountRouting import code.customer.CustomerX import code.entitlement.Entitlement -import code.organisation.OrganisationX +import code.organisation.Organisations import code.metadata.counterparties.Counterparties import com.openbankproject.commons.model.{BankId => CommBankId, CreditLimit, CreditRating, CustomerFaceImage} import fs2.Stream @@ -1991,7 +1991,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { visibility: String = "public", status: String = "active" ): Unit = { - OrganisationX.organisation.vend.createOrganisation( + Organisations.organisation.vend.createOrganisation( orgId, s"Test $orgId", None, None, status, visibility, resourceUser1.userId ) } @@ -2305,7 +2305,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { /** 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( + RoutingSchemes.routingScheme.vend.createRoutingScheme( scheme = scheme, country = country, category = "ACCOUNT", @@ -2519,7 +2519,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { statusCode shouldBe 204 And("the row should still exist with status RETIRED") - val fetched = RoutingSchemeX.routingScheme.vend.getRoutingScheme(scheme) + val fetched = RoutingSchemes.routingScheme.vend.getRoutingScheme(scheme) fetched.map(_.status) shouldBe net.liftweb.common.Full("RETIRED") } } @@ -2610,7 +2610,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { */ private def seedPayeeForLookup(prefix: String, address: String, destBankId: String, destAccountId: String): String = { val scheme = freshSchemeName(prefix) - RoutingSchemeX.routingScheme.vend.createRoutingScheme( + RoutingSchemes.routingScheme.vend.createRoutingScheme( scheme = scheme, country = "TZ", category = "ACCOUNT", addressPattern = "^[0-9]+$", secondaryAddressPattern = None, exampleAddress = address, description = "Test", downstreamRails = Nil, @@ -2630,15 +2630,15 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { 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 body = """{"identifier":{"scheme":"TZ.MSISDN","value":"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) { + scenario("Return 400 when identifier.scheme is not registered", Http4s700RoutesTag) { val bankId = testBankId1.value val accountId = testAccountId0.value - val body = """{"identifier_type":"TZ.UNKNOWN_SCHEME","identifier":"123"}""" + val body = """{"identifier":{"scheme":"TZ.UNKNOWN_SCHEME","value":"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 @@ -2652,18 +2652,18 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - scenario("Return 400 when identifier does not match the scheme's address_pattern", Http4s700RoutesTag) { + scenario("Return 400 when identifier.value 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( + RoutingSchemes.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 body = s"""{"identifier":{"scheme":"$scheme","value":"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 @@ -2682,13 +2682,13 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val accountId = testAccountId0.value // Registered scheme, valid pattern match, but no account_routings row. val scheme = freshSchemeName("NMA") - RoutingSchemeX.routingScheme.vend.createRoutingScheme( + RoutingSchemes.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 body = s"""{"identifier":{"scheme":"$scheme","value":"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 @@ -2708,17 +2708,23 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { 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 body = s"""{"identifier":{"scheme":"$scheme","value":"$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")) + map.keys should contain allOf ("lookup_id", "expires_at", "identifier", "full_name") + map.keys should not contain "fsp_id" // fsp_id is nested inside identifier, not top-level + map.get("identifier") match { + case Some(JObject(idFields)) => + val idMap = toFieldMap(idFields) + idMap.get("scheme") shouldBe Some(JString(scheme)) + idMap.get("value") shouldBe Some(JString(address)) + idMap.get("fsp_id") shouldBe Some(JString("503")) + case other => fail(s"Expected identifier to be an object {scheme,value,fsp_id}, got: $other") + } case _ => fail("Expected JSON object") } } @@ -2760,10 +2766,10 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { // 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 { + RoutingSchemes.routingScheme.vend.getRoutingScheme(schemeName) match { case net.liftweb.common.Full(_) => // already registered from a previous run case _ => - RoutingSchemeX.routingScheme.vend.createRoutingScheme( + RoutingSchemes.routingScheme.vend.createRoutingScheme( scheme = schemeName, country = country, category = "ACCOUNT", addressPattern = "^999[0-9]{9}$", secondaryAddressPattern = None, exampleAddress = "999778300336", description = "Test only", @@ -2785,4 +2791,177 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── BULK transaction request ───────────────────────────────────────────── + + /** Fresh batch reference for each test scenario to avoid idempotency collisions. */ + private def freshBatchReference(): String = + s"BATCH-${APIUtil.generateUUID().take(12)}" + + feature("Http4s700 createTransactionRequestBulk endpoint") { + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = + s"""{ + | "batch_reference": "${freshBatchReference()}", + | "payments": [{"end_to_end_id":"e1","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"123"},"value":{"currency":"EUR","amount":"1.00"},"description":"x"}], + | "value": {"currency":"EUR","amount":"1.00"}, + | "description": "test" + |}""".stripMargin + val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/BULK/transaction-requests", body) + statusCode shouldBe 401 + } + + scenario("Return 400 when payments array is empty", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = + s"""{ + | "batch_reference": "${freshBatchReference()}", + | "payments": [], + | "value": {"currency":"EUR","amount":"0"}, + | "description": "empty" + |}""".stripMargin + 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/BULK/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include("OBP-30537") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when an item currency does not match the source account", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Pick a currency unlikely to match the test account's currency. + val body = + s"""{ + | "batch_reference": "${freshBatchReference()}", + | "payments": [{"end_to_end_id":"e1","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"123"},"value":{"currency":"XYZ","amount":"1.00"},"description":"x"}], + | "value": {"currency":"XYZ","amount":"1.00"}, + | "description": "wrong currency" + |}""".stripMargin + 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/BULK/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include("OBP-30540") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when end_to_end_id is duplicated in the batch", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Read account currency from the system to construct a matching body. + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val body = + s"""{ + | "batch_reference": "${freshBatchReference()}", + | "payments": [ + | {"end_to_end_id":"DUP","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"123"},"value":{"currency":"$acctCurrency","amount":"1.00"},"description":"x"}, + | {"end_to_end_id":"DUP","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"124"},"value":{"currency":"$acctCurrency","amount":"1.00"},"description":"y"} + | ], + | "value": {"currency":"$acctCurrency","amount":"2.00"}, + | "description": "dupes" + |}""".stripMargin + 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/BULK/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include("OBP-30539") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 409 when batch_reference is reused on the same source account", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val ref = freshBatchReference() + // First submission — accepted (note: every payment will be FAILED in mapped mode because + // we haven't seeded a matching account_routing, but the envelope is accepted). + val body = + s"""{ + | "batch_reference": "$ref", + | "payments": [{"end_to_end_id":"E1","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"77777777777"},"value":{"currency":"$acctCurrency","amount":"1.00"},"description":"x"}], + | "value": {"currency":"$acctCurrency","amount":"1.00"}, + | "description": "first submission" + |}""".stripMargin + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (firstStatus, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/BULK/transaction-requests", body, headers) + firstStatus shouldBe 201 + + // Second submission with same batch_reference — must be rejected. + val (secondStatus, secondJson, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/BULK/transaction-requests", body, headers) + secondStatus shouldBe 409 + secondJson match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include("OBP-30536") + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 201 with PARTIALLY_COMPLETED when one item destination resolves and another does not", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + + // Seed one resolvable destination — a fresh scheme + matching account_routing pointing + // back at the test account (we don't care that the destination is the same account; this + // exercises the SUCCESS branch). + val resolvableAddress = s"BULK-${APIUtil.generateUUID().take(8)}" + val resolvableScheme = seedPayeeForLookup("BLK", resolvableAddress, bankId, accountId) + + val body = + s"""{ + | "batch_reference": "${freshBatchReference()}", + | "payments": [ + | {"end_to_end_id":"OK","to_account_routing":{"scheme":"$resolvableScheme","address":"$resolvableAddress"},"value":{"currency":"$acctCurrency","amount":"1.00"},"description":"will-succeed"}, + | {"end_to_end_id":"NOPE","to_account_routing":{"scheme":"TZ.BANK_ACCOUNT","address":"00000000000"},"value":{"currency":"$acctCurrency","amount":"2.00"},"description":"will-fail"} + | ], + | "value": {"currency":"$acctCurrency","amount":"3.00"}, + | "description": "partial" + |}""".stripMargin + + 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/BULK/transaction-requests", body, headers) + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("id", "batch_reference", "status", "total_payments", "succeeded_count", "failed_count", "payments") + map.get("total_payments") shouldBe Some(net.liftweb.json.JsonAST.JInt(2)) + map.get("status") match { + case Some(JString(s)) => s should (be("PARTIALLY_COMPLETED") or be("FAILED") or be("COMPLETED")) + case _ => fail("status should be a string") + } + case _ => fail("Expected JSON object") + } + } + } + } diff --git a/obp-commons/pom.xml b/obp-commons/pom.xml index d69346a214..df3d111d2e 100644 --- a/obp-commons/pom.xml +++ b/obp-commons/pom.xml @@ -229,6 +229,10 @@ false false false + + + ${maven.multiModuleProjectDirectory}/dependency-check-suppressions.xml + 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 b6cc8a22c5..1c852c9090 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 @@ -125,6 +125,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object ETH_SEND_TRANSACTION extends Value object ETH_SEND_RAW_TRANSACTION extends Value object MOBILE_WALLET extends Value + object BULK extends Value } sealed trait StrongCustomerAuthentication extends EnumValue diff --git a/pom.xml b/pom.xml index 5ac5a44dad..6c3c6266e5 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,16 @@ + + + com.fasterxml.jackson + jackson-bom + 2.18.7 + pom + import + com.tesobe obp-commons