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