diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ce8eac7691..de82a1bc51 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -135,7 +135,8 @@ import code.transactionRequestAttribute.TransactionRequestAttribute import code.transactionStatusScheduler.TransactionRequestStatusScheduler import code.transaction_types.MappedTransactionType import code.transactionattribute.MappedTransactionAttribute -import code.bankconnectors.opencorridor.{OpenCorridorBankBroker, OpenCorridorOutbox, OpenCorridorOutboxRelay} +import code.amqpbroker.AmqpBankBroker +import code.messageoutbox.{MessageOutbox, MessageOutboxRelay} import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons} import code.usercustomerlinks.MappedUserCustomerLink import code.customerlinks.CustomerLink @@ -564,7 +565,7 @@ class Boot extends MdcLoggable { // Open Corridor: the transactional-outbox relay publishing Interface C messages // (credit notifications + settlement instructions) to the banks' own vhosts. if (APIUtil.getPropsAsBoolValue("open_corridor_enabled", false)) { - OpenCorridorOutboxRelay.start(APIUtil.getPropsAsLongValue("open_corridor.outbox_relay_interval", 10L)) + MessageOutboxRelay.start(APIUtil.getPropsAsLongValue("open_corridor.outbox_relay_interval", 10L)) } APIUtil.getPropsAsLongValue("database_messages_scheduler_interval") match { case Full(i) => DatabaseDriverScheduler.start(i) @@ -994,8 +995,8 @@ object ToSchemify extends MdcLoggable { MappedCounterpartyWhereTag, MappedTransactionRequest, TransactionRequestAttribute, - OpenCorridorBankBroker, - OpenCorridorOutbox, + AmqpBankBroker, + MessageOutbox, MappedMetric, MetricArchive, MetricsArchiveRun, diff --git a/obp-api/src/main/scala/code/amqpbroker/AmqpBankBroker.scala b/obp-api/src/main/scala/code/amqpbroker/AmqpBankBroker.scala new file mode 100644 index 0000000000..84d57b3465 --- /dev/null +++ b/obp-api/src/main/scala/code/amqpbroker/AmqpBankBroker.scala @@ -0,0 +1,103 @@ +package code.amqpbroker + +import net.liftweb.common.Box +import net.liftweb.mapper._ + +/** + * Per-bank AMQP broker coordinates — where OBP-API publishes messages destined + * for a bank's own infrastructure. + * + * Named by transport, not by consumer: the fields (host/port/vhost/credentials) + * are AMQP 0-9-1 concepts, and any feature that needs to push AMQP messages to + * a specific bank resolves its coordinates here. The first consumer is Open + * Corridor Interface C: each onboarded bank's Bank Node consumes on its OWN + * vhost (e.g. `/bank.ke.01.kcs`) with its own credentials — permission + * isolation is enforced at the broker level, and publishing is keyed by + * bank_id through this registry (populated at onboarding). + * + * Transport coordinates only: the bank's on-chain settlement address is NOT + * stored here — it is the CARDANO account routing on the bank's + * OBP-INCOMING-SETTLEMENT-ACCOUNT. + */ +class AmqpBankBroker extends LongKeyedMapper[AmqpBankBroker] with IdPK { + def getSingleton = AmqpBankBroker + + object BankId extends MappedString(this, 255) { + override def dbColumnName = "bank_id" + } + object Host extends MappedString(this, 255) { + override def dbColumnName = "host" + } + object Port extends MappedInt(this) { + override def dbColumnName = "port" + override def defaultValue = 5672 + } + object VirtualHost extends MappedString(this, 255) { + override def dbColumnName = "virtual_host" + } + object Username extends MappedString(this, 255) { + override def dbColumnName = "username" + } + /** Write-only: accepted on registration, never echoed by any endpoint. */ + object Password extends MappedString(this, 255) { + override def dbColumnName = "password" + } + object UseSsl extends MappedBoolean(this) { + override def dbColumnName = "use_ssl" + override def defaultValue = false + } + object CreatedAt extends MappedDateTime(this) { + override def dbColumnName = "created_at" + override def defaultValue = new java.util.Date() + } + object UpdatedAt extends MappedDateTime(this) { + override def dbColumnName = "updated_at" + override def defaultValue = new java.util.Date() + } + + def bankId: String = BankId.get + def host: String = Host.get + def port: Int = Port.get + def virtualHost: String = VirtualHost.get + def username: String = Username.get + def password: String = Password.get + def useSsl: Boolean = UseSsl.get + + override def save: Boolean = { + UpdatedAt(new java.util.Date()) + super.save + } +} + +object AmqpBankBroker extends AmqpBankBroker with LongKeyedMetaMapper[AmqpBankBroker] { + override def dbTableName = "amqp_bank_broker" + + override def dbIndexes: List[BaseIndex[AmqpBankBroker]] = UniqueIndex(BankId) :: super.dbIndexes + + def findByBankId(bankId: String): Box[AmqpBankBroker] = + AmqpBankBroker.find(By(AmqpBankBroker.BankId, bankId)) + + /** Upsert the broker coordinates for a bank (one row per bank, enforced by the unique index). */ + def upsert( + bankId: String, + host: String, + port: Int, + virtualHost: String, + username: String, + password: String, + useSsl: Boolean + ): AmqpBankBroker = { + val row = findByBankId(bankId).getOrElse(AmqpBankBroker.create.BankId(bankId)) + row + .Host(host) + .Port(port) + .VirtualHost(virtualHost) + .Username(username) + .Password(password) + .UseSsl(useSsl) + .saveMe() + } + + def deleteByBankId(bankId: String): Boolean = + AmqpBankBroker.bulkDelete_!!(By(AmqpBankBroker.BankId, bankId)) +} diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 272cba141d..67ee49f854 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1723,6 +1723,15 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ _autoValidateRoles = false this } + + /** + * Whether the declared roles are enforced automatically (Lift wrappedWithAuthCheck / + * http4s ResourceDocMiddleware). False when the endpoint declared + * disableAutoValidateRoles() because enforcement is conditional and done in the + * handler — the roles stay in the doc for the catalog / API Explorer / entitlement + * requests. + */ + def isAutoValidateRoles: Boolean = _autoValidateRoles private var _autoValidateAuthenticate = true def disableAutoValidateAuthenticate(): ResourceDoc = { _autoValidateAuthenticate = false @@ -2292,7 +2301,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ bankId, userId, role.toString, - "create_just_in_time_entitlements" + "create_just_in_time_entitlements", + grantedByUserId = Some(userId) ) logger.info(s"Just in Time Entitlements: $addedEntitlement") addedEntitlement.isDefined @@ -2347,7 +2357,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && roles.forall { role => val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( - bankId, userId, role.toString, "create_just_in_time_entitlements" + bankId, userId, role.toString, "create_just_in_time_entitlements", + grantedByUserId = Some(userId) ) logger.info(s"Just in Time Entitlements: $addedEntitlement") addedEntitlement.isDefined diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index e1de008909..5dbfbcb40f 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -212,16 +212,27 @@ object ApiRole extends MdcLoggable{ case class CanAttachOpenCorridorPromise(requiresBankId: Boolean = true) extends ApiRole lazy val canAttachOpenCorridorPromise = CanAttachOpenCorridorPromise() - // Open Corridor: operator role for registering each onboarded bank's RabbitMQ broker - // coordinates (host/port/vhost/credentials) in the per-bank publish registry. - case class CanConfigureOpenCorridorBroker(requiresBankId: Boolean = false) extends ApiRole - lazy val canConfigureOpenCorridorBroker = CanConfigureOpenCorridorBroker() + // Operator role for registering each onboarded bank's AMQP broker coordinates + // (host/port/vhost/credentials) in the per-bank publish registry. Transport + // registry, not corridor-specific; Open Corridor Interface C is the first consumer. + case class CanConfigureAmqpBankBroker(requiresBankId: Boolean = false) extends ApiRole + lazy val canConfigureAmqpBankBroker = CanConfigureAmqpBankBroker() // Open Corridor: operator role for the settle-pair trigger — nets a bank pair's // PENDING promises, posts the net Transaction and enqueues the Interface C messages. - case class CanSettleOpenCorridor(requiresBankId: Boolean = false) extends ApiRole + case class CanSettleOpenCorridor(requiresBankId: Boolean = true) extends ApiRole lazy val canSettleOpenCorridor = CanSettleOpenCorridor() + // Operator role for reading the generic message outbox (delivery states, + // sticky failures) across all outbox types. + case class CanGetMessageOutbox(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetMessageOutbox = CanGetMessageOutbox() + + // Operator role for re-queuing a STICKY message-outbox row after + // reconciliation — flips it back to PENDING for the relay to redeliver. + case class CanRetryMessageOutbox(requiresBankId: Boolean = false) extends ApiRole + lazy val canRetryMessageOutbox = CanRetryMessageOutbox() + case class CanAddSocialMediaHandle(requiresBankId: Boolean = true) extends ApiRole lazy val canAddSocialMediaHandle = CanAddSocialMediaHandle() 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 a34e402eca..6dccd28a5b 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -889,10 +889,14 @@ object ErrorMessages { val OpenCorridorPromiseTypeMismatch = "OBP-40051: The Transaction Request is not of type OPEN_CORRIDOR_PROMISE." val OpenCorridorPromiseNotPending = "OBP-40052: The Open Corridor promise Transaction Request is not in PENDING status." val OpenCorridorPromiseEvidenceConflict = "OBP-40053: Open Corridor promise evidence is already attached to this Transaction Request with different values. Evidence cannot be overwritten." - val OpenCorridorBankBrokerNotConfigured = "OBP-40054: No Open Corridor broker is configured for this bank. Register the bank's RabbitMQ coordinates first." + val AmqpBankBrokerNotConfigured = "OBP-40054: No AMQP broker is configured for this bank. Register the bank's AMQP coordinates first." val OpenCorridorPublishFailed = "OBP-40055: Could not publish the Open Corridor message to the bank's broker or no reply arrived in time." val OpenCorridorSettlementAddressMissing = "OBP-40056: The creditor bank has no settlement address registered in its Open Corridor broker registration, so the settlement instruction cannot be addressed." val OpenCorridorDisabled = "OBP-40057: Open Corridor is not enabled on this API instance. Set open_corridor_enabled=true in the props." + val OpenCorridorSettlementNotFound = "OBP-40058: No Open Corridor settlement with this SETTLEMENT_ID exists for this bank." + val OpenCorridorSameBankNotAllowed = "OBP-40061: OPEN_CORRIDOR is inter-bank: the beneficiary bank must differ from the sending bank. Use an ordinary payment for intra-bank transfers." + val MessageOutboxRowNotFound = "OBP-40059: No message outbox row with this OUTBOX_ID exists." + val MessageOutboxRowNotSticky = "OBP-40060: The message outbox row is not STICKY. Only STICKY rows can be re-queued; PENDING rows retry automatically." // Exceptions (OBP-50XXX) val UnknownError = "OBP-50000: Unknown Error." val FutureTimeoutException = "OBP-50001: Future Timeout Exception." diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 145275cc99..404af3bd61 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -331,8 +331,11 @@ object ResourceDocMiddleware extends MdcLoggable { private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { import DSL._ + // Docs may declare roles purely for the catalog and enforce them conditionally in + // the handler (disableAutoValidateRoles, e.g. create-account's "role or self") — + // mirror Lift's isNeedCheckRoles = _autoValidateRoles && rolesForCheck.nonEmpty. resourceDoc.roles match { - case Some(roles) if roles.nonEmpty => + case Some(roles) if roles.nonEmpty && resourceDoc.isAutoValidateRoles => ctx.user match { case Full(user) => val bankId = pathParams.getOrElse("BANK_ID", "") diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index ffdf40165a..ed8fefb1fa 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -136,6 +136,7 @@ object Migration extends MdcLoggable { alterMetricColumnUrlLength() alterMetricArchiveColumnCorrelationidLength() alterCounterpartyLimitFieldType() + alterTransactionRequestAttributeValueType() changeTypeOfAudFieldAtConsumerTable() renameCustomerRoleNames() addUniqueIndexOnResourceUserUserId() @@ -579,6 +580,13 @@ object Migration extends MdcLoggable { } } + private def alterTransactionRequestAttributeValueType(): Boolean = { + val name = nameOf(alterTransactionRequestAttributeValueType) + runOnce(name) { + MigrationOfTransactionRequestAttributeValueType.alterColumnValueType(name) + } + } + private def alterMappedCounterpartyDescriptionLength(): Boolean = { val name = nameOf(alterMappedCounterpartyDescriptionLength) runOnce(name) { diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfTransactionRequestAttributeValueType.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfTransactionRequestAttributeValueType.scala new file mode 100644 index 0000000000..0dd83c2ef7 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfTransactionRequestAttributeValueType.scala @@ -0,0 +1,56 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.transactionRequestAttribute.TransactionRequestAttribute +import net.liftweb.common.Full +import net.liftweb.mapper.Schemifier + +object MigrationOfTransactionRequestAttributeValueType { + + def alterColumnValueType(name: String): Boolean = { + DbFunction.tableExists(TransactionRequestAttribute) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ + |-- Open Corridor promise evidence (preimage JSON) exceeds varchar(255) + |ALTER TABLE transactionrequestattribute ALTER COLUMN value VARCHAR(MAX); + |""".stripMargin + case _ => + () => + """ + |-- Open Corridor promise evidence (preimage JSON) exceeds varchar(255) + |ALTER TABLE transactionrequestattribute ALTER COLUMN value TYPE text; + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${TransactionRequestAttribute._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala index 197d6bbec8..38b4915de1 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala @@ -1213,7 +1213,10 @@ object Http4s200 { !hasEntitlement(body.bank_id, userId, role) } addedEntitlement <- Future { - unboxFull(Entitlement.entitlement.vend.addEntitlement(body.bank_id, userId, body.role_name)) + // Audit only (no wire change): record who granted. + unboxFull(Entitlement.entitlement.vend.addEntitlement( + body.bank_id, userId, body.role_name, + grantedByUserId = Some(user.userId))) } } yield JSONFactory200.createEntitlementJSON(addedEntitlement) } diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index d8aee0340b..6851508e34 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -10569,7 +10569,7 @@ object Http4s400 { List(apiTagAccount), Some(List(canCreateAccount)), http4sPartialFunction = Some(addAccount) - ) + ).disableAutoValidateRoles() // Lift parity: "role or self-create" is enforced in the handler staticResourceDocs += ResourceDoc( implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 40fcd87a1e..b467a9fa21 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -468,12 +468,14 @@ object Http4s500 { _ <- entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString()) match { case true => Future.successful(()) case false => Future(Entitlement.entitlement.vend.addEntitlement( - postJson.id.getOrElse(""), cc.userId, CanCreateEntitlementAtOneBank.toString())) + postJson.id.getOrElse(""), cc.userId, CanCreateEntitlementAtOneBank.toString(), + grantedByUserId = Some(cc.userId))) } _ <- entitlementsByBank.exists(_.roleName == CanReadDynamicResourceDocsAtOneBank.toString()) match { case true => Future.successful(()) case false => Future(Entitlement.entitlement.vend.addEntitlement( - postJson.id.getOrElse(""), cc.userId, CanReadDynamicResourceDocsAtOneBank.toString())) + postJson.id.getOrElse(""), cc.userId, CanReadDynamicResourceDocsAtOneBank.toString(), + grantedByUserId = Some(cc.userId))) } } yield JSONFactory500.createBankJSON500(success) } @@ -655,7 +657,8 @@ object Http4s500 { List(apiTagAccount, apiTagOnboarding), Some(List(canCreateAccount)), http4sPartialFunction = Some(createAccount) - ) + ) // Lift parity: unlike v4 addAccount, the v5 Lift doc did NOT disableAutoValidateRoles — + // canCreateAccount is always enforced; the inline "role or self-create" check is a safety net. // ─── createUserAuthContext (POST /users/USER_ID/auth-context → 201) ───── diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 77a7e7d315..1425b76582 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -200,6 +200,7 @@ object Http4s600 { def entitlementRequestId: Option[String] = None def groupId: Option[String] = None def process: Option[String] = None + def grantedByUserId: Option[String] = None } } val currentUser = UserV600(user, entitlements ::: virtualEntitlements, permissions) @@ -491,7 +492,8 @@ object Http4s600 { ) } yield { crudRoles.foreach(role => - Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString())) + Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString(), + grantedByUserId = Some(cc.userId))) JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head } @@ -883,7 +885,8 @@ object Http4s600 { entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) entitlementsByBank = entitlements.filter(_.bankId == postJson.bank_id) _ = if (!entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString)) - Entitlement.entitlement.vend.addEntitlement(postJson.bank_id, cc.userId, CanCreateEntitlementAtOneBank.toString) + Entitlement.entitlement.vend.addEntitlement(postJson.bank_id, cc.userId, CanCreateEntitlementAtOneBank.toString, + grantedByUserId = Some(cc.userId)) } yield JSONFactory600.createBankJSON600(success) } } @@ -1941,7 +1944,7 @@ object Http4s600 { if (!alreadyHas) { Entitlement.entitlement.vend.addEntitlement( group.bankId.getOrElse(""), userIdStr, roleName, "manual", - None, Some(postJson.group_id), Some("GROUP_MEMBERSHIP")) + Some(user.userId), Some(postJson.group_id), Some("GROUP_MEMBERSHIP")) (roleName, true) } else (roleName, false) } @@ -4986,7 +4989,8 @@ object Http4s600 { _ <- Future(backupDynamicEntityIo(entity, backupName, resultList)) backupCanGetRole = code.api.dynamic.entity.helper.DynamicEntityInfo.canGetRole(backupName, entity.bankId) _ <- Future(code.entitlement.Entitlement.entitlement.vend.addEntitlement( - entity.bankId.getOrElse(""), cc.userId, backupCanGetRole.toString())) + entity.bankId.getOrElse(""), cc.userId, backupCanGetRole.toString(), + grantedByUserId = Some(cc.userId))) backupEntity <- Future { code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend .getByEntityName(entity.bankId, backupName) diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 279291d089..c8b2037cda 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -276,13 +276,32 @@ case class TransactionRequestBodyHoldJsonV600( description: String ) extends TransactionRequestCommonBodyJSON +// v6 entitlement JSON carries created_by_process ("manual", +// "create_just_in_time_entitlements", or "super_admin_user_ids" / +// "oidc_operator_user_ids" for the virtual entitlements merged into +// GET /users/current) — older versions' EntitlementJSON omits it. +case class EntitlementJsonV600( + entitlement_id: String, + role_name: String, + bank_id: String, + created_by_process: String, + // Set when the entitlement was granted off an entitlement request — + // links the grant back to who asked for it. + entitlement_request_id: Option[String], + // user_id of the granter when a person made the grant (directly or as a + // self-grant); absent for system-process grants, virtual entitlements, + // and rows created before the field existed (2026-08-09). + granted_by_user_id: Option[String] +) +case class EntitlementsJsonV600(list: List[EntitlementJsonV600]) + case class UserJsonV600( user_id: String, email: String, provider_id: String, provider: String, username: String, - entitlements: EntitlementJSONs, + entitlements: EntitlementsJsonV600, views: Option[ViewsJSON300], on_behalf_of: Option[UserJsonV300] ) @@ -1416,8 +1435,18 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { username = stringOrNull(current_user.user.name), provider_id = current_user.user.idGivenByProvider, provider = stringOrNull(current_user.user.provider), - entitlements = - JSONFactory200.createEntitlementJSONs(current_user.entitlements), + entitlements = EntitlementsJsonV600( + current_user.entitlements.map(e => + EntitlementJsonV600( + e.entitlementId, + e.roleName, + e.bankId, + e.createdByProcess, + e.entitlementRequestId, + e.grantedByUserId + ) + ) + ), views = current_user.views.map(y => ViewsJSON300( y.views.map( 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 698bb65460..f228ea7aa7 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -8,9 +8,9 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canSettleOpenCorridor, canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.CommonsEmailWrapper -import code.model.dataAccess.{AuthUser, MappedBank, ResourceUser} +import code.model.dataAccess.{AuthUser, BankAccountCreation, MappedBank, ResourceUser} import code.consent.Consents import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -39,7 +39,7 @@ import code.metadata.tags.Tags import code.views.Views import code.accountattribute.AccountAttributeX import code.users.{Users => UserVend} -import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, CoreAccount, CounterpartyId, CustomerId, ListResult, TransactionRequestType, ViewId} +import com.openbankproject.commons.model.{AccountId, AccountRouting, AccountRoutingJsonV121, AmountOfMoneyJsonV121, Bank, BankId, BankIdAccountId, CoreAccount, CounterpartyId, CustomerId, ListResult, ProductCode, TransactionRequestType, User, ViewId} import com.openbankproject.commons.model.enums.ChallengeType import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -498,7 +498,9 @@ object Http4s700 { canCreateEntitlementAtOneBank :: canCreateEntitlementAtAnyBank :: Nil, Some(cc)).map(_ => ()) _ <- Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, failCode = 409, cc = Some(cc))( !hasEntitlement(body.bank_id, userId, role)) - entitlement <- Future(Entitlement.entitlement.vend.addEntitlement(body.bank_id, userId, body.role_name)) + entitlement <- Future(Entitlement.entitlement.vend.addEntitlement( + body.bank_id, userId, body.role_name, + grantedByUserId = Some(user.userId))) .map(e => unboxFull(e)) } yield JSONFactory200.createEntitlementJSON(entitlement) } @@ -3485,7 +3487,7 @@ object Http4s700 { "Attach Open Corridor Promise Evidence", """Attach on-chain promise evidence to a PENDING OPEN_CORRIDOR_PROMISE Transaction Request. | - |Called by the bank's own Bank Node (machine-to-machine) after it has written the Promise commitment to the blockchain. The body carries the transaction hash of the on-chain write plus the commit–reveal evidence: the `commitment` (the hash written on-chain), the `salt`, and the `preimage`. OBP-API stores these as Transaction Request attributes and later relays them to the beneficiary bank inside the `obp_credit_notification` message, enabling the beneficiary to verify `SHA-256(salt ‖ preimage)` against the on-chain commitment without the originating bank's cooperation. + |Called by the bank's own Bank Node (machine-to-machine) after it has written the Promise commitment to the blockchain. The body carries the transaction hash of the on-chain write plus the commit–reveal evidence: the `commitment` (the hash written on-chain), the `salt`, and the `preimage`. OBP-API stores these as Transaction Request attributes and immediately relays them to the beneficiary bank inside the `obp_credit_notification` message (enqueued to the transactional outbox on the first successful attach), enabling the beneficiary to verify `SHA-256(salt ‖ preimage)` against the on-chain commitment without the originating bank's cooperation — and to credit its customer ahead of settlement on the strength of that verified promise. | |The evidence fields are opaque strings to OBP-API — they are stored and relayed verbatim, never parsed. | @@ -3513,122 +3515,378 @@ object Http4s700 { http4sPartialFunction = Some(attachOpenCorridorPromise) ) + // ── Message outbox (operator) ───────────────────────────────────────────── + // Read/repair access to the generic transactional outbox. The relay retries + // transient failures itself; STICKY rows wait here for a human. + + val getMessageOutbox: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "message-outbox" => + EndpointHelpers.withUser(req) { (_, cc) => + scala.concurrent.Future { + import code.messageoutbox.MessageOutbox + val params = req.uri.query.params + val limit = params.get("limit").flatMap(l => scala.util.Try(l.toInt).toOption) + .filter(l => l > 0 && l <= 500).getOrElse(100) + val filters: List[net.liftweb.mapper.QueryParam[MessageOutbox]] = List( + params.get("status").map(_.trim.toUpperCase).filter(_.nonEmpty).map(s => By(MessageOutbox.Status, s)), + params.get("outbox_type").map(_.trim.toUpperCase).filter(_.nonEmpty).map(t => By(MessageOutbox.OutboxType, t)) + ).flatten + val rows = MessageOutbox.findAll( + (filters ::: List(OrderBy(MessageOutbox.id, Descending), MaxRows[MessageOutbox](limit))): _*) + JSONFactory700.MessageOutboxJsonV700(rows.map(JSONFactory700.createMessageOutboxRowJson)) + } + } + } + + val retryMessageOutboxRow: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "message-outbox" / outboxIdStr / "retry" => + EndpointHelpers.withUser(req) { (_, cc) => + import code.messageoutbox.MessageOutbox + val rowOpt: Option[MessageOutbox] = scala.util.Try(outboxIdStr.toLong).toOption + .flatMap(id => MessageOutbox.find(By(MessageOutbox.id, id)).toOption) + for { + _ <- Helper.booleanToFuture(s"$MessageOutboxRowNotFound OUTBOX_ID: $outboxIdStr", failCode = 404, cc = Some(cc)) { + rowOpt.isDefined + } + row = rowOpt.get + _ <- Helper.booleanToFuture(s"$MessageOutboxRowNotSticky Current status: ${row.status}.", cc = Some(cc)) { + row.status == MessageOutbox.STATUS_STICKY + } + updated <- scala.concurrent.Future { + row.Status(MessageOutbox.STATUS_PENDING).Attempts(0).LastError("").saveMe() + } + } yield JSONFactory700.createMessageOutboxRowJson(updated) + } + } + + val messageOutboxRowExampleV700 = JSONFactory700.MessageOutboxRowJsonV700( + outbox_id = 42L, + outbox_type = "OPEN_CORRIDOR", + subject_id = "4050046c-63b3-4868-8a22-14b4181d33a6", + subject_id_type = "transaction_request_id", + operation_name = "obp_credit_notification", + target_id = "gh.29.uk", + status = "STICKY", + attempts = 3, + last_error = "OBP-BANK-NODE-COMMITMENT-MISMATCH", + created_at = "2026-08-09T15:17:02.000Z", + updated_at = "2026-08-09T15:26:27.000Z" + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMessageOutbox), + "GET", + "/management/message-outbox", + "Get Message Outbox", + """List rows of the generic transactional message outbox — the messages OBP-API must deliver asynchronously, written in the same DB transaction as the business event that caused them and published by the relay with at-least-once redelivery. + | + |Filter with `outbox_type` (e.g. `OPEN_CORRIDOR`), `status` (`PENDING` / `DELIVERED` / `STICKY`) and `limit` (default 100, max 500). `subject_id` + `subject_id_type` name the business object each message is about (a settlement, a transaction request, ...) — not to be confused with the per-request Correlation-Id. + | + |STICKY rows are failures redelivery cannot fix; after reconciliation, re-queue one with the retry endpoint. The wire payload is not exposed: it can carry commit-reveal evidence and originator PII. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.MessageOutboxJsonV700(List(messageOutboxRowExampleV700)), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagApi :: Nil, + Some(List(canGetMessageOutbox)), + http4sPartialFunction = Some(getMessageOutbox) + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(retryMessageOutboxRow), + "POST", + "/management/message-outbox/OUTBOX_ID/retry", + "Retry Message Outbox Row", + """Re-queue one STICKY message-outbox row after operator reconciliation: the row flips back to PENDING with its attempts reset, and the relay redelivers it on the next pass. + | + |Only STICKY rows can be re-queued — PENDING rows retry automatically, and DELIVERED rows are done. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + messageOutboxRowExampleV700, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + MessageOutboxRowNotFound, MessageOutboxRowNotSticky, UnknownError), + apiTagApi :: Nil, + Some(List(canRetryMessageOutbox)), + http4sPartialFunction = Some(retryMessageOutboxRow) + ) + + // ── Create Account ──────────────────────────────────────────────────────── + // v7.0.0 successor of v4.0.0 addAccount (POST, server-generated id) and + // v5.0.0 createAccount (PUT, caller-chosen id). Differences from those: + // the response applies the implicit OBP routing (like every v6.0.0+ read), + // OBP-family schemes are refused in the request body since they are + // derived, never stored, and CanCreateAccount is required unconditionally + // (self-service account opening without the role is deprecated — Account + // Applications are the self-service path), so the docs auto-validate. + + private def createAccountCommon( + user: User, + bank: Bank, + body: JSONFactory700.CreateAccountRequestJsonV700, + accountIdOpt: Option[String], + cc: CallContext + ): Future[JSONFactory700.CreateAccountResponseJsonV700] = { + val bankId = bank.bankId + val routings = body.account_routings.getOrElse(Nil) + for { + _ <- Helper.booleanToFuture( + s"$InvalidAccountRoutings The OBP routing is implicit: scheme OBP (or OBP_ACCOUNT_ID) cannot be supplied in account_routings; it is derived from the account id.", + 400, cc = Some(cc)) { + !routings.exists(r => Constant.isImplicitOBPAccountScheme(r.scheme)) + } + accountId <- accountIdOpt match { + case Some(id) => + for { + _ <- Helper.booleanToFuture(InvalidAccountIdFormat, 400, cc = Some(cc)) { isValidID(id) } + (existing, _) <- BankConnector.connector.vend.checkBankAccountExists(bankId, AccountId(id), Some(cc)) + _ <- Helper.booleanToFuture(AccountIdAlreadyExists, cc = Some(cc)) { existing.isEmpty } + } yield AccountId(id) + case None => Future.successful(AccountId(APIUtil.generateUUID())) + } + // CanCreateAccount is enforced by ResourceDocMiddleware from the doc. + ownerId = body.user_id.filter(_.trim.nonEmpty).getOrElse(user.userId) + (owner, _) <- NewStyle.function.findByUserId(ownerId, Some(cc)) + initialBalance <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, Some(cc)) { + BigDecimal(body.balance.amount) + } + _ <- Helper.booleanToFuture(InitialBalanceMustBeZero, cc = Some(cc)) { 0 == initialBalance } + _ <- Helper.booleanToFuture(InvalidISOCurrencyCode, cc = Some(cc)) { + isValidCurrencyISOCode(body.balance.currency) + } + _ <- Helper.booleanToFuture( + s"$InvalidAccountRoutings Duplication detected in account routings, please specify only one value per routing scheme", + 400, cc = Some(cc)) { + routings.map(_.scheme).distinct.size == routings.size + } + alreadyExisting <- Future.sequence(routings.map(routing => + NewStyle.function.getAccountRouting(Some(bankId), routing.scheme, routing.address, Some(cc)) + .map(_ => Some(routing)).fallbackTo(Future.successful(None)))) + conflicts = alreadyExisting.collect { + case Some(r) => s"bankId: ${bankId.value}, scheme: ${r.scheme}, address: ${r.address}" + } + _ <- Helper.booleanToFuture(s"$AccountRoutingAlreadyExist (${conflicts.mkString("; ")})", cc = Some(cc)) { + conflicts.isEmpty + } + (bankAccount, _) <- NewStyle.function.createBankAccount( + bankId, accountId, body.product_code, body.label, body.balance.currency, + initialBalance, owner.name, body.branch_id.getOrElse(""), + routings.map(r => AccountRouting(r.scheme, r.address)), Some(cc)) + (productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode( + bankId, ProductCode(body.product_code), Some(cc)) + (accountAttributes, _) <- NewStyle.function.createAccountAttributes( + bankId, accountId, ProductCode(body.product_code), productAttributes, None, Some(cc)) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess(bankId, accountId, owner, Some(cc)) + } yield JSONFactory700.createAccountJsonV700(ownerId, bankAccount, accountAttributes) + } + + val createAccountV700: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory700.CreateAccountRequestJsonV700, JSONFactory700.CreateAccountResponseJsonV700](req) { (user, bank, body, cc) => + createAccountCommon(user, bank, body, None, cc) + } + } + + val createAccountWithIdV700: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr => + EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory700.CreateAccountRequestJsonV700, JSONFactory700.CreateAccountResponseJsonV700](req) { (user, bank, body, cc) => + createAccountCommon(user, bank, body, Some(accountIdStr), cc) + } + } + + val createAccountRequestBodyExampleV700 = JSONFactory700.CreateAccountRequestJsonV700( + user_id = Some("9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1"), + label = "My Account", + product_code = "OPEN_CORRIDOR", + balance = AmountOfMoneyJsonV121("EUR", "0"), + branch_id = Some(""), + account_routings = Some(List(AccountRoutingJsonV121("IBAN", "DE91100000000123456789"))) + ) + val createAccountResponseExampleV700 = JSONFactory700.CreateAccountResponseJsonV700( + account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + bank_id = "gh.29.uk", + user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + label = "My Account", + product_code = "OPEN_CORRIDOR", + balance = AmountOfMoneyJsonV121("EUR", "0"), + branch_id = "", + account_routings = List( + AccountRoutingJsonV121("OBP", "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0"), + AccountRoutingJsonV121("IBAN", "DE91100000000123456789") + ), + account_attributes = Nil + ) + + val createAccountDescriptionV700 = + """Create an Account at the bank specified by BANK_ID. + | + |The logged-in user must have the Role CanCreateAccount at BANK_ID. Unlike the v4.0.0/v5.0.0 Create Account, creating an account for yourself does not waive the Role: self-service account opening is deprecated in v7.0.0 — use Account Applications for customer-initiated account opening. + | + |The body USER_ID is optional; when present the created Account is owned by the User specified by USER_ID, otherwise by the logged-in User. + | + |The `product_code` SHOULD be a product_code from Product. If it matches one, Account Attributes are created from the Product Attributes. + | + |`account_routings` carries external routings only (e.g. IBAN). The OBP-family schemes (`OBP`, `OBP_ACCOUNT_ID`) are refused: the canonical `{"scheme": "OBP", "address": ""}` routing is implicit and included in every response, never stored. One routing per scheme; a routing address already registered at the bank is refused. + | + |The balance amount MUST be zero. + | + |Authentication is Required.""".stripMargin + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createAccountV700), + "POST", + "/banks/BANK_ID/accounts", + "Create Account (POST)", + s"""$createAccountDescriptionV700 + | + |The ACCOUNT_ID is generated by the server and returned in the response. To specify the ACCOUNT_ID yourself, use the PUT variant.""".stripMargin, + createAccountRequestBodyExampleV700, + createAccountResponseExampleV700, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UserNotFoundById, + UserHasMissingRoles, InvalidAccountRoutings, AccountRoutingAlreadyExist, + InvalidAccountInitialBalance, InitialBalanceMustBeZero, InvalidISOCurrencyCode, UnknownError), + apiTagAccount :: apiTagOnboarding :: Nil, + Some(List(canCreateAccount)), + http4sPartialFunction = Some(createAccountV700) + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createAccountWithIdV700), + "PUT", + "/banks/BANK_ID/accounts/NEW_ACCOUNT_ID", + "Create Account (PUT)", + s"""$createAccountDescriptionV700 + | + |The Account is created with the NEW_ACCOUNT_ID given in the URL, which must not already exist at the bank. To let the server generate the ACCOUNT_ID, use the POST variant.""".stripMargin, + createAccountRequestBodyExampleV700, + createAccountResponseExampleV700, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UserNotFoundById, + UserHasMissingRoles, InvalidAccountIdFormat, AccountIdAlreadyExists, + InvalidAccountRoutings, AccountRoutingAlreadyExist, + InvalidAccountInitialBalance, InitialBalanceMustBeZero, InvalidISOCurrencyCode, UnknownError), + apiTagAccount :: apiTagOnboarding :: Nil, + Some(List(canCreateAccount)), + http4sPartialFunction = Some(createAccountWithIdV700) + ) + // ── OPEN_CORRIDOR per-bank broker registry (admin) ──────────────────────── // Operator endpoints for the per-bank RabbitMQ publish registry: each onboarded // bank's Bank Node consumes on its own vhost, so Interface C publishing needs // the bank's broker coordinates. Passwords are write-only (never echoed). - val setOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ PUT -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => - EndpointHelpers.withUserAndBankAndBody[JSONFactory700.PostOpenCorridorBankBrokerJsonV700, JSONFactory700.OpenCorridorBankBrokerJsonV700](req) { (_, bank, body, cc) => + val setAmqpBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "amqp-broker" => + EndpointHelpers.withUserAndBankAndBody[JSONFactory700.PostAmqpBankBrokerJsonV700, JSONFactory700.AmqpBankBrokerJsonV700](req) { (_, bank, body, cc) => for { _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue host, virtual_host and username must be non-empty and port must be positive", cc = Some(cc)) { body.host.trim.nonEmpty && body.virtual_host.trim.nonEmpty && body.username.trim.nonEmpty && body.port > 0 } broker <- scala.concurrent.Future { - code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - bank.bankId.value, body.host, body.port, body.virtual_host, body.username, body.password, body.use_ssl, - body.settlement_address + code.amqpbroker.AmqpBankBroker.upsert( + bank.bankId.value, body.host, body.port, body.virtual_host, body.username, body.password, body.use_ssl ) } - } yield JSONFactory700.OpenCorridorBankBrokerJsonV700( + } yield JSONFactory700.AmqpBankBrokerJsonV700( bank_id = broker.bankId, host = broker.host, port = broker.port, - virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl, - settlement_address = broker.settlementAddress + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl ) } } - val getOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => + val getAmqpBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "amqp-broker" => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => scala.concurrent.Future { - code.bankconnectors.opencorridor.OpenCorridorBankBroker.findByBankId(bank.bankId.value) match { + code.amqpbroker.AmqpBankBroker.findByBankId(bank.bankId.value) match { case net.liftweb.common.Full(broker) => - JSONFactory700.OpenCorridorBankBrokerJsonV700( + JSONFactory700.AmqpBankBrokerJsonV700( bank_id = broker.bankId, host = broker.host, port = broker.port, - virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl, - settlement_address = broker.settlementAddress + virtual_host = broker.virtualHost, username = broker.username, use_ssl = broker.useSsl ) case _ => - throw new RuntimeException(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: ${bank.bankId.value}") + throw new RuntimeException(s"$AmqpBankBrokerNotConfigured BANK_ID: ${bank.bankId.value}") } } } } - val deleteOpenCorridorBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ DELETE -> `prefixPath` / "banks" / _ / "open-corridor" / "broker" => + val deleteAmqpBankBroker: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "amqp-broker" => EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => scala.concurrent.Future { - code.bankconnectors.opencorridor.OpenCorridorBankBroker.deleteByBankId(bank.bankId.value) + code.amqpbroker.AmqpBankBroker.deleteByBankId(bank.bankId.value) } } } - val openCorridorBrokerBodyExample = JSONFactory700.PostOpenCorridorBankBrokerJsonV700( + val openCorridorBrokerBodyExample = JSONFactory700.PostAmqpBankBrokerJsonV700( host = "rabbitmq.bank.example.com", port = 5672, virtual_host = "/bank.gh.29.uk", username = "obp-api", password = "***", - use_ssl = false, - settlement_address = "addr_test1vqn7sn79x6k9a2353l458mk2gccmwqk7nza93zydpuvl7lquy6jcl" + use_ssl = false ) - val openCorridorBrokerResponseExample = JSONFactory700.OpenCorridorBankBrokerJsonV700( + val openCorridorBrokerResponseExample = JSONFactory700.AmqpBankBrokerJsonV700( bank_id = "gh.29.uk", host = "rabbitmq.bank.example.com", port = 5672, virtual_host = "/bank.gh.29.uk", username = "obp-api", - use_ssl = false, - settlement_address = "addr_test1vqn7sn79x6k9a2353l458mk2gccmwqk7nza93zydpuvl7lquy6jcl" + use_ssl = false ) resourceDocs += ResourceDoc( implementedInApiVersion, - nameOf(setOpenCorridorBankBroker), + nameOf(setAmqpBankBroker), "PUT", - "/banks/BANK_ID/open-corridor/broker", - "Set Open Corridor Bank Broker", - """Register (or replace) the RabbitMQ broker coordinates for a bank in the Open Corridor per-bank publish registry. + "/banks/BANK_ID/amqp-broker", + "Set AMQP Bank Broker", + """Register (or replace) the AMQP broker coordinates for a bank — where OBP-API publishes messages destined for that bank's own infrastructure. Named by transport, not by consumer; Open Corridor Interface C is the first consumer. | - |Each onboarded bank's Bank Node consumes Interface C messages on its own vhost with its own credentials; OBP-API publishes `obp_credit_notification` to the creditor bank's vhost and `obp_settlement_instruction` to the debtor bank's vhost using the coordinates registered here. One registration per bank (upsert semantics). + |Each onboarded bank's Bank Node consumes Interface C messages on its own vhost with its own credentials; OBP-API publishes `obp_credit_notification` to the creditor bank's vhost and `obp_settlement_instruction` / `obp_settlement_advice` using the coordinates registered here. One registration per bank (upsert semantics). | |The password is write-only and never returned by any endpoint. | + |This record carries transport coordinates only. The bank's on-chain settlement address is NOT part of it: it is the `CARDANO` account routing on the bank's `OBP-INCOMING-SETTLEMENT-ACCOUNT` (manage it via Update Account / Create Account). + | |Authentication is Required.""".stripMargin, openCorridorBrokerBodyExample, openCorridorBrokerResponseExample, List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, InvalidJsonFormat, InvalidJsonValue, UnknownError), apiTagBank :: Nil, - Some(List(canConfigureOpenCorridorBroker)), - http4sPartialFunction = Some(setOpenCorridorBankBroker) + Some(List(canConfigureAmqpBankBroker)), + http4sPartialFunction = Some(setAmqpBankBroker) ) resourceDocs += ResourceDoc( implementedInApiVersion, - nameOf(getOpenCorridorBankBroker), + nameOf(getAmqpBankBroker), "GET", - "/banks/BANK_ID/open-corridor/broker", - "Get Open Corridor Bank Broker", + "/banks/BANK_ID/amqp-broker", + "Get AMQP Bank Broker", """Get the registered Open Corridor RabbitMQ broker coordinates for a bank (password omitted). | |Authentication is Required.""".stripMargin, EmptyBody, openCorridorBrokerResponseExample, - List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, OpenCorridorBankBrokerNotConfigured, UnknownError), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, AmqpBankBrokerNotConfigured, UnknownError), apiTagBank :: Nil, - Some(List(canConfigureOpenCorridorBroker)), - http4sPartialFunction = Some(getOpenCorridorBankBroker) + Some(List(canConfigureAmqpBankBroker)), + http4sPartialFunction = Some(getAmqpBankBroker) ) resourceDocs += ResourceDoc( implementedInApiVersion, - nameOf(deleteOpenCorridorBankBroker), + nameOf(deleteAmqpBankBroker), "DELETE", - "/banks/BANK_ID/open-corridor/broker", - "Delete Open Corridor Bank Broker", + "/banks/BANK_ID/amqp-broker", + "Delete AMQP Bank Broker", """Remove a bank's Open Corridor RabbitMQ broker registration. Idempotent. | |Authentication is Required.""".stripMargin, @@ -3636,55 +3894,64 @@ object Http4s700 { EmptyBody, List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), apiTagBank :: Nil, - Some(List(canConfigureOpenCorridorBroker)), - http4sPartialFunction = Some(deleteOpenCorridorBankBroker) + Some(List(canConfigureAmqpBankBroker)), + http4sPartialFunction = Some(deleteAmqpBankBroker) ) - // ── OPEN_CORRIDOR settle-pair (the netting trigger) ─────────────────────── + // ── OPEN_CORRIDOR settlements (the netting trigger + status resource) ───── // Bilateral settle-on-demand: nets the pair's PENDING OPEN_CORRIDOR_PROMISE // TRs (SUM(A→B) − SUM(B→A)), posts ONE net Transaction between the pair's // settlement accounts via an internal OPEN_CORRIDOR_SETTLEMENT TR, discharges // the covered promises, and enqueues the Interface C messages in the same DB // transaction (transactional outbox; the relay publishes them). - val settleOpenCorridorPair: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ POST -> `prefixPath` / "open-corridor" / "settle" => - EndpointHelpers.withUserAndBodyCreated[JSONFactory700.PostOpenCorridorSettleJsonV700, JSONFactory700.OpenCorridorSettleResultJsonV700](req) { (user, body, cc) => + // The URL bank is one side of the pair; CanSettleOpenCorridor is bank-scoped + // and checked there, so a bank can only settle corridors it is party to. + val createOpenCorridorSettlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "open-corridor" / "settlements" => + EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory700.PostOpenCorridorSettlementJsonV700, JSONFactory700.OpenCorridorSettleResultJsonV700](req) { (user, bank, body, cc) => for { _ <- code.util.Helper.booleanToFuture(OpenCorridorDisabled, cc = Some(cc)) { APIUtil.getPropsAsBoolValue("open_corridor_enabled", false) } - _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue bank_id_a, bank_id_b and currency must be non-empty and the banks must differ", cc = Some(cc)) { - body.bank_id_a.trim.nonEmpty && body.bank_id_b.trim.nonEmpty && - body.currency.trim.nonEmpty && body.bank_id_a != body.bank_id_b + _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue other_bank_id and currency must be non-empty", cc = Some(cc)) { + body.other_bank_id.trim.nonEmpty && body.currency.trim.nonEmpty } - (_, _) <- NewStyle.function.getBank(BankId(body.bank_id_a), Some(cc)) - (_, _) <- NewStyle.function.getBank(BankId(body.bank_id_b), Some(cc)) + _ <- code.util.Helper.booleanToFuture(s"$OpenCorridorSameBankNotAllowed", cc = Some(cc)) { + body.other_bank_id != bank.bankId.value + } + (_, _) <- NewStyle.function.getBank(BankId(body.other_bank_id), Some(cc)) (result, _) <- code.bankconnectors.opencorridor.OpenCorridorSettlement.settlePair( - user, body.bank_id_a, body.bank_id_b, body.currency, Some(cc)) + user, bank.bankId.value, body.other_bank_id, body.currency, Some(cc)) } yield result } } resourceDocs += ResourceDoc( implementedInApiVersion, - nameOf(settleOpenCorridorPair), + nameOf(createOpenCorridorSettlement), "POST", - "/open-corridor/settle", - "Settle Open Corridor Pair", - """Trigger bilateral Open Corridor netting for a bank pair and currency. + "/banks/BANK_ID/open-corridor/settlements", + "Create Open Corridor Settlement", + """Trigger bilateral Open Corridor netting between BANK_ID and the other bank (`other_bank_id`), and create the settlement resource that tracks it. + | + |This creates the settlement; it does not mean value has moved when the call returns. The OBP ledger side completes here (netting, promise discharge, the one net ledger Transaction), while the value leg is executed asynchronously by the debtor bank's node on its settlement rail. Poll the settlement with GET /banks/BANK_ID/open-corridor/settlements/SETTLEMENT_ID to observe SETTLING → SUBMITTED → FINAL. | |Computes `net = SUM(PENDING A→B promises) − SUM(PENDING B→A promises)`, mints one internal OPEN_CORRIDOR_SETTLEMENT Transaction Request between the pair's settlement accounts whose execution posts ONE net Transaction (debtor's outgoing settlement account → creditor's incoming), records that Transaction's id on each covered promise in the `settled_by_transaction_ids` attribute (and the settlement TR's id in `settled_by_transaction_request_id`), and sets the covered promises to COMPLETED. N promises collapse into one settlement — that compression is the netting. | - |In the same database transaction, the Interface C messages are written to the transactional outbox: one `obp_credit_notification` per covered promise to its beneficiary bank (relaying the commit–reveal evidence triplet), and one `obp_settlement_instruction` for the net amount to the debtor bank. The outbox relay publishes them and records each bank's reply. + |Only promises whose on-chain evidence has been attached are covered: an unevidenced promise generated no credit notification and no beneficiary payout, so netting it would move value for a payment nobody delivered — it stays PENDING for a later cycle. + | + |In the same database transaction, the Interface C messages are written to the transactional outbox: one `obp_settlement_advice` per beneficiary bank listing the covered promise ids it already paid out against (credit notifications travel at promise-report-back time, not here), and one `obp_settlement_instruction` for the net amount to the debtor bank. The outbox relay publishes them and records each bank's reply. | |NOTE: the posted net Transaction deliberately does not mirror any single covered promise — it can differ in direction, amount and accounts. Reconciliation must follow the `settled_by_transaction_ids` linkage, never assume the Transaction matches the promise body. | - |A trigger for a pair with no PENDING promises is a no-op. When the flows offset exactly (net zero) the promises are discharged with no Transaction posted and no settlement instruction sent — the credit notifications still go out. + |A trigger for a pair with no PENDING evidenced promises is a no-op. When the flows offset exactly (net zero) the promises are discharged with no Transaction posted and no settlement instruction sent — the settlement advices still go out. + | + |`net_amount` is always the absolute value; direction is carried by `debtor_bank_id` → `creditor_bank_id` (assigned from the sign of the net). Either bank in the pair may trigger settlement — the role is checked at the URL's BANK_ID, and who ends up debtor is decided by the net, not by who called. | - |Requires `open_corridor_enabled=true` on this instance. + |Requires `open_corridor_enabled=true` on this instance and the `CanSettleOpenCorridor` role at BANK_ID. | |Authentication is Required.""".stripMargin, - JSONFactory700.PostOpenCorridorSettleJsonV700(bank_id_a = "gh.29.uk", bank_id_b = "ke.01.kcs", currency = "KES"), + JSONFactory700.PostOpenCorridorSettlementJsonV700(other_bank_id = "ke.01.kcs", currency = "KES"), JSONFactory700.OpenCorridorSettleResultJsonV700( settlement_id = "6bb27397-6c9b-4c5c-b28f-b19f26d1c6f4", settlement_transaction_request_id = "6bb27397-6c9b-4c5c-b28f-b19f26d1c6f4", @@ -3694,14 +3961,76 @@ object Http4s700 { currency = "KES", net_amount = "2500.00", covered_transaction_request_ids = List("4050046c-63b3-4868-8a22-14b4181d33a6"), - credit_notifications_enqueued = 3, + settlement_advices_enqueued = 1, settlement_instructions_enqueued = 1 ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, OpenCorridorDisabled, InvalidJsonFormat, InvalidJsonValue, - $BankNotFound, OpenCorridorBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), + OpenCorridorSameBankNotAllowed, + $BankNotFound, AmqpBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), + apiTagTransactionRequest :: Nil, + Some(List(canSettleOpenCorridor)), + http4sPartialFunction = Some(createOpenCorridorSettlement) + ) + + // The settlement resource's read side: ledger fields from the + // OPEN_CORRIDOR_SETTLEMENT TR, rail status from the settlement-instruction + // outbox row (the node's last reply — redelivery doubles as the poll). + val getOpenCorridorSettlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "open-corridor" / "settlements" / settlementId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + _ <- code.util.Helper.booleanToFuture(OpenCorridorDisabled, cc = Some(cc)) { + APIUtil.getPropsAsBoolValue("open_corridor_enabled", false) + } + (result, _) <- code.bankconnectors.opencorridor.OpenCorridorSettlement.getSettlementStatus( + bank.bankId.value, settlementId, Some(cc)) + } yield result + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getOpenCorridorSettlement), + "GET", + "/banks/BANK_ID/open-corridor/settlements/SETTLEMENT_ID", + "Get Open Corridor Settlement", + """Read one Open Corridor settlement. BANK_ID must be a party (debtor or creditor) of the settlement — other banks get a 404. + | + |The two status fields deliberately separate the two layers: + | + |* `ledger_status` — the OBP-side OPEN_CORRIDOR_SETTLEMENT Transaction Request (COMPLETED at settle time: netting, promise discharge and the net ledger Transaction are done). + |* `settlement_status` — the value leg on the rail, as last reported by the debtor bank's node: `NET_ZERO` (nothing to move), `INSTRUCTED` (no node reply yet), `SETTLING` / `SUBMITTED` (in flight, with `settlement_depth` = confirmation depth when reported), `FINAL` (node reported finality), `ERROR` (non-retryable node error; operator reconciliation — see the message's `last_error`). + | + |`messages` lists the settlement's Interface C outbox rows (settlement advices and the settlement instruction) with their delivery state. + | + |Requires `open_corridor_enabled=true` on this instance and the `CanSettleOpenCorridor` role at BANK_ID. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.OpenCorridorSettlementStatusJsonV700( + settlement_id = "6bb27397-6c9b-4c5c-b28f-b19f26d1c6f4", + debtor_bank_id = "gh.29.uk", + creditor_bank_id = "ke.01.kcs", + currency = "KES", + net_amount = "2500.00", + transaction_id = "902ba3bb-dedd-45e7-9319-2fd3f2cd98a1", + ledger_status = "COMPLETED", + settlement_status = "SUBMITTED", + settlement_depth = Some(2), + covered_transaction_request_ids = List("4050046c-63b3-4868-8a22-14b4181d33a6"), + messages = List(JSONFactory700.OpenCorridorSettlementMessageJsonV700( + operation_name = "obp_settlement_instruction", + target_bank_id = "gh.29.uk", + delivery_status = "PENDING", + attempts = 3, + last_error = "" + )) + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, OpenCorridorDisabled, $BankNotFound, + OpenCorridorSettlementNotFound, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canSettleOpenCorridor)), - http4sPartialFunction = Some(settleOpenCorridorPair) + http4sPartialFunction = Some(getOpenCorridorSettlement) ) // ── End OPEN_CORRIDOR_PROMISE ───────────────────────────────────────────── 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 bd67968653..d26877cd90 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 @@ -10,7 +10,8 @@ import code.customer.CustomerX import code.metrics.{MappedMetric, MetricArchive, MetricsArchiveRun, MetricsProps} import code.util.Helper.MdcLoggable import code.views.Views -import com.openbankproject.commons.model.{AccountId, AccountRoutingJsonV121, AmountOfMoneyJsonV121, BankId, BankIdAccountId, CoreAccount, TransactionRequest, TransactionRequestCommonBodyJSON, User} +import code.api.v3_1_0.{AccountAttributeResponseJson, JSONFactory310} +import com.openbankproject.commons.model.{AccountAttribute, AccountId, AccountRoutingJsonV121, AmountOfMoneyJsonV121, BankAccount, BankId, BankIdAccountId, CoreAccount, TransactionRequest, TransactionRequestCommonBodyJSON, User} import com.openbankproject.commons.util.ApiVersion import java.util.Date import net.liftweb.common.Full @@ -1113,34 +1114,123 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { reported_at: String ) + // ─── Create Account ──────────────────────────────────────────────────────── + + /** Request body for POST /banks/BANK_ID/accounts (server-generated id) and + * PUT /banks/BANK_ID/accounts/ACCOUNT_ID (caller-chosen id). The OBP-family + * routing schemes (OBP, OBP_ACCOUNT_ID) are implicit — supplying one in + * account_routings is refused; the canonical OBP routing is derived from + * the account id on every read. */ + case class CreateAccountRequestJsonV700( + user_id: Option[String], + label: String, + product_code: String, + balance: AmountOfMoneyJsonV121, + branch_id: Option[String], + account_routings: Option[List[AccountRoutingJsonV121]] + ) + + case class CreateAccountResponseJsonV700( + account_id: String, + bank_id: String, + user_id: String, + label: String, + product_code: String, + balance: AmountOfMoneyJsonV121, + branch_id: String, + account_routings: List[AccountRoutingJsonV121], + account_attributes: List[AccountAttributeResponseJson] + ) + + def createAccountJsonV700( + userId: String, + account: BankAccount, + accountAttributes: List[AccountAttribute] + ): CreateAccountResponseJsonV700 = + CreateAccountResponseJsonV700( + account_id = account.accountId.value, + bank_id = account.bankId.value, + user_id = userId, + label = account.label, + product_code = account.accountType, + balance = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), + branch_id = account.branchId, + account_routings = Constant.accountRoutingsWithImplicitOBP( + account.accountId.value, + account.accountRoutings.map(r => AccountRoutingJsonV121(r.scheme, r.address)) + ), + account_attributes = accountAttributes.map(JSONFactory310.createAccountAttributeJson) + ) + // ─── OPEN_CORRIDOR per-bank broker registry (admin) ──────────────────────── - case class PostOpenCorridorBankBrokerJsonV700( + // Transport coordinates only. The settlement address is NOT part of the broker + // record: it is the CARDANO account routing on OBP-INCOMING-SETTLEMENT-ACCOUNT. + case class PostAmqpBankBrokerJsonV700( host: String, port: Int, virtual_host: String, username: String, password: String, - use_ssl: Boolean, - settlement_address: String + use_ssl: Boolean ) // The password is write-only: never echoed on any response. - case class OpenCorridorBankBrokerJsonV700( + case class AmqpBankBrokerJsonV700( bank_id: String, host: String, port: Int, virtual_host: String, username: String, - use_ssl: Boolean, - settlement_address: String - ) + use_ssl: Boolean + ) + + // ─── Message outbox (operator) ───────────────────────────────────────────── + + /** One message-outbox row. `subject_id`/`subject_id_type` name the business + * object the message is about (NOT the per-REST-call Correlation-Id). The + * wire payload is deliberately NOT exposed here: it can carry commit–reveal + * evidence and originator PII. */ + case class MessageOutboxRowJsonV700( + outbox_id: Long, + outbox_type: String, + subject_id: String, + subject_id_type: String, + operation_name: String, + target_id: String, + status: String, + attempts: Int, + last_error: String, + created_at: String, + updated_at: String + ) + + case class MessageOutboxJsonV700(rows: List[MessageOutboxRowJsonV700]) + + def createMessageOutboxRowJson( + row: code.messageoutbox.MessageOutbox + ): MessageOutboxRowJsonV700 = + MessageOutboxRowJsonV700( + outbox_id = row.id.get, + outbox_type = row.outboxType, + subject_id = row.subjectId, + subject_id_type = row.subjectIdType, + operation_name = row.operationName, + target_id = row.targetId, + status = row.status, + attempts = row.attempts, + last_error = row.LastError.get, + created_at = APIUtil.DateWithMsFormat.format(row.CreatedAt.get), + updated_at = APIUtil.DateWithMsFormat.format(row.UpdatedAt.get) + ) - // ─── OPEN_CORRIDOR settle-pair ───────────────────────────────────────────── + // ─── OPEN_CORRIDOR settlements ───────────────────────────────────────────── - case class PostOpenCorridorSettleJsonV700( - bank_id_a: String, - bank_id_b: String, + /** POST /banks/BANK_ID/open-corridor/settlements: the URL bank is one side of + * the pair, the body names the other. The caller's role is checked at the + * URL bank, so a bank can only trigger settlement of corridors it is party to. */ + case class PostOpenCorridorSettlementJsonV700( + other_bank_id: String, currency: String ) @@ -1163,10 +1253,47 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { currency: String, net_amount: String, covered_transaction_request_ids: List[String], - credit_notifications_enqueued: Int, + settlement_advices_enqueued: Int, settlement_instructions_enqueued: Int ) + /** One Interface C outbox message belonging to a settlement, for the GET + * status view. `delivery_status` is the outbox row lifecycle + * (PENDING / DELIVERED / STICKY), not the rail state. */ + case class OpenCorridorSettlementMessageJsonV700( + operation_name: String, + target_bank_id: String, + delivery_status: String, + attempts: Int, + last_error: String + ) + + /** + * GET view of one settlement. The ledger side (`ledger_status`) completes at + * settle time; the rail side (`settlement_status`) completes only when the + * debtor bank's node reports FINAL via the outbox relay's redelivery poll: + * NET_ZERO — flows offset exactly; nothing to move on any rail + * INSTRUCTED — instruction enqueued, no node reply recorded yet + * SETTLING / SUBMITTED — the node's last reported rail state (with + * `settlement_depth` = confirmation depth when reported) + * FINAL — the node reported finality; the instruction row is DELIVERED + * ERROR — the node replied with a non-retryable error (row STICKY); + * operator reconciliation required, see the message's last_error + */ + case class OpenCorridorSettlementStatusJsonV700( + settlement_id: String, + debtor_bank_id: String, + creditor_bank_id: String, + currency: String, + net_amount: String, + transaction_id: String, + ledger_status: String, + settlement_status: String, + settlement_depth: Option[Int], + covered_transaction_request_ids: List[String], + messages: List[OpenCorridorSettlementMessageJsonV700] + ) + // Build the originator block for a TR response. Returns None when there's no // explicit originator and no customer_account_link for the from-account — the // outer JSON wrapper emits `originator: null` in that case. diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala deleted file mode 100644 index eb86f4c114..0000000000 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorBankBroker.scala +++ /dev/null @@ -1,77 +0,0 @@ -package code.bankconnectors.opencorridor - -import net.liftweb.common.Box -import net.liftweb.mapper._ - -/** - * Per-bank RabbitMQ broker coordinates for Open Corridor Interface C publishing. - * - * Each onboarded bank's Bank Node consumes on its OWN vhost (e.g. `/bank.ke.01.kcs`) - * with its own credentials — permission isolation is enforced at the broker level. - * Even a single corridor involves two vhosts (`obp_credit_notification` goes to the - * creditor bank's vhost, `obp_settlement_instruction` to the debtor's), so a single - * global broker connection cannot serve the flow; publishing is keyed by bank_id - * through this registry (populated at onboarding). - */ -class OpenCorridorBankBroker extends LongKeyedMapper[OpenCorridorBankBroker] with IdPK with CreatedUpdated { - def getSingleton = OpenCorridorBankBroker - - object BankId extends MappedString(this, 255) - object Host extends MappedString(this, 255) - object Port extends MappedInt(this) { - override def defaultValue = 5672 - } - object VirtualHost extends MappedString(this, 255) - object Username extends MappedString(this, 255) - object Password extends MappedString(this, 255) - object UseSsl extends MappedBoolean(this) { - override def defaultValue = false - } - /** The bank's settlement-rail (e.g. Cardano bech32) receiving address. Used as - * `creditor_address` in `obp_settlement_instruction` when this bank is the - * creditor of a netted settle (open decision §8.5 of the publish plan: the - * onboarding row carries it, not an account attribute). */ - object SettlementAddress extends MappedString(this, 255) - - def bankId: String = BankId.get - def host: String = Host.get - def port: Int = Port.get - def virtualHost: String = VirtualHost.get - def username: String = Username.get - def password: String = Password.get - def useSsl: Boolean = UseSsl.get - def settlementAddress: String = SettlementAddress.get -} - -object OpenCorridorBankBroker extends OpenCorridorBankBroker with LongKeyedMetaMapper[OpenCorridorBankBroker] { - override def dbIndexes: List[BaseIndex[OpenCorridorBankBroker]] = UniqueIndex(BankId) :: super.dbIndexes - - def findByBankId(bankId: String): Box[OpenCorridorBankBroker] = - OpenCorridorBankBroker.find(By(OpenCorridorBankBroker.BankId, bankId)) - - /** Upsert the broker coordinates for a bank (one row per bank, enforced by the unique index). */ - def upsert( - bankId: String, - host: String, - port: Int, - virtualHost: String, - username: String, - password: String, - useSsl: Boolean, - settlementAddress: String - ): OpenCorridorBankBroker = { - val row = findByBankId(bankId).getOrElse(OpenCorridorBankBroker.create.BankId(bankId)) - row - .Host(host) - .Port(port) - .VirtualHost(virtualHost) - .Username(username) - .Password(password) - .UseSsl(useSsl) - .SettlementAddress(settlementAddress) - .saveMe() - } - - def deleteByBankId(bankId: String): Boolean = - OpenCorridorBankBroker.bulkDelete_!!(By(OpenCorridorBankBroker.BankId, bankId)) -} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala deleted file mode 100644 index 67c758f89a..0000000000 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutbox.scala +++ /dev/null @@ -1,80 +0,0 @@ -package code.bankconnectors.opencorridor - -import net.liftweb.mapper._ - -/** - * Transactional outbox for Open Corridor Interface C messages. - * - * The settle-pair step commits money movement (the net Transaction, the promise - * discharges) in one DB transaction; the RabbitMQ publishes must survive a crash - * between that commit and the publish. So the outbound messages are written as - * rows here in the SAME transaction, and `OpenCorridorOutboxRelay` publishes - * them afterwards, recording each reply. Publish-after-commit without this - * outbox would lose credit notifications and settlement instructions on a crash - * — with real money that is not acceptable. The Bank Node side is - * idempotent-friendly (`idempotency_key`, evidence upserts), so redelivery from - * here is always safe. - * - * Row lifecycle: - * PENDING — not yet delivered; the relay keeps publishing with backoff. - * For `obp_settlement_instruction` a reply of SUBMITTED/SETTLING - * keeps the row PENDING deliberately: redelivery doubles as the - * SUBMITTED → FINAL status poll (locked wire contract §4.4). - * DELIVERED — the bank replied success (credit notification acked, or - * settlement reported FINAL). - * STICKY — the bank replied with a business error that retrying cannot fix - * (e.g. COMMITMENT-MISMATCH). Needs operator reconciliation; the - * error and full reply are on the row. Never silently swallowed. - */ -class OpenCorridorOutbox extends LongKeyedMapper[OpenCorridorOutbox] with IdPK with CreatedUpdated { - def getSingleton = OpenCorridorOutbox - - /** The settle event this message belongs to (TR B's transaction request id). */ - object SettlementId extends MappedString(this, 64) - /** AMQP messageId: obp_credit_notification / obp_settlement_instruction. */ - object MessageId extends MappedString(this, 64) - /** The bank whose vhost this message is published to. */ - object TargetBankId extends MappedString(this, 255) - /** The flat lower_snake_case wire body, serialized at settle time. */ - object PayloadJson extends MappedText(this) - object Status extends MappedString(this, 16) { - override def defaultValue = OpenCorridorOutbox.STATUS_PENDING - } - object Attempts extends MappedInt(this) { - override def defaultValue = 0 - } - object LastError extends MappedString(this, 2000) - /** The bank's last §4.2 reply envelope, verbatim, for audit/reconciliation. */ - object LastReplyJson extends MappedText(this) - - def settlementId: String = SettlementId.get - def messageId: String = MessageId.get - def targetBankId: String = TargetBankId.get - def payloadJson: String = PayloadJson.get - def status: String = Status.get - def attempts: Int = Attempts.get -} - -object OpenCorridorOutbox extends OpenCorridorOutbox with LongKeyedMetaMapper[OpenCorridorOutbox] { - val STATUS_PENDING = "PENDING" - val STATUS_DELIVERED = "DELIVERED" - val STATUS_STICKY = "STICKY" - - override def dbIndexes: List[BaseIndex[OpenCorridorOutbox]] = - Index(Status) :: Index(SettlementId) :: super.dbIndexes - - def enqueue(settlementId: String, messageId: String, targetBankId: String, payloadJson: String): OpenCorridorOutbox = - OpenCorridorOutbox.create - .SettlementId(settlementId) - .MessageId(messageId) - .TargetBankId(targetBankId) - .PayloadJson(payloadJson) - .Status(STATUS_PENDING) - .saveMe() - - def pending(): List[OpenCorridorOutbox] = - OpenCorridorOutbox.findAll(By(OpenCorridorOutbox.Status, STATUS_PENDING)) - - def bySettlementId(settlementId: String): List[OpenCorridorOutbox] = - OpenCorridorOutbox.findAll(By(OpenCorridorOutbox.SettlementId, settlementId)) -} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala deleted file mode 100644 index 81c5ef7008..0000000000 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorOutboxRelay.scala +++ /dev/null @@ -1,129 +0,0 @@ -package code.bankconnectors.opencorridor - -import code.actorsystem.ObpActorSystem -import code.util.Helper.MdcLoggable -import net.liftweb.common.{Box, Failure, Full} -import org.json4s._ -import org.json4s.native.Serialization - -import java.util.concurrent.TimeUnit -import scala.concurrent.Await -import scala.concurrent.duration._ - -/** - * Publishes Open Corridor outbox rows to the target banks' vhosts and records - * the replies. Runs on the actor-system scheduler (started from Boot when - * `open_corridor_enabled=true`), one pass per tick, rows processed serially — - * throughput is not the concern here, at-least-once delivery with a recorded - * audit trail is. - * - * Reply handling (locked wire contract §4.2/§4.4): - * - transport failure / timeout / broker unregistered → row stays PENDING, - * attempts+1 (retried next tick; exponential backoff by attempts). - * - errorCode == "" on a credit notification → DELIVERED. - * - errorCode == "" on a settlement instruction → DELIVERED only when the - * bank reports status FINAL; SUBMITTED / SETTLING keep the row PENDING — - * redelivery IS the status poll, and the Bank Node never pays twice for the - * same idempotency_key. - * - OBP-BANK-NODE-SETTLEMENT-FAILED → stays PENDING: the node allows a retry - * when the failure provably never reached the chain, and repeats the - * recorded error otherwise, so redelivery is safe and the error stays - * visible on the row either way. - * - any other OBP-BANK-NODE-* error (COMMITMENT-MISMATCH, CBS-DELIVERY-FAILED, - * BAD-MESSAGE, NOT-IMPLEMENTED, SETTLEMENT-NOT-CONFIGURED) → STICKY: retry - * cannot fix it; it needs an operator. The error + full reply are recorded — - * never swallowed. - */ -object OpenCorridorOutboxRelay extends MdcLoggable { - - private implicit val formats = code.api.util.CustomJsonFormats.nullTolerateFormats - - /** Base backoff between attempts for a row; doubles per attempt, capped. */ - private val baseBackoff = 10.seconds - private val maxBackoff = 10.minutes - /** Cap on how long one row's publish may block the (serial) relay pass. */ - private val perRowTimeout = 60.seconds - - private val stickyErrorCodes = Set( - "OBP-BANK-NODE-COMMITMENT-MISMATCH", - "OBP-BANK-NODE-CBS-DELIVERY-FAILED", - "OBP-BANK-NODE-BAD-MESSAGE", - "OBP-BANK-NODE-NOT-IMPLEMENTED", - "OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED" - ) - - def start(intervalSeconds: Long): Unit = { - implicit val executor = ObpActorSystem.localActorSystem.dispatcher - ObpActorSystem.localActorSystem.scheduler.schedule( - initialDelay = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), - interval = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), - runnable = new Runnable { - def run(): Unit = - try relayOnePass() - catch { case e: Throwable => logger.error("Open Corridor outbox relay pass failed", e) } - } - ) - logger.info(s"Open Corridor outbox relay started (interval ${intervalSeconds}s)") - } - - /** One pass over the PENDING rows that are due (backoff by attempts). */ - def relayOnePass(): Unit = { - val now = System.currentTimeMillis() - val due = OpenCorridorOutbox.pending().filter { row => - val backoff = (baseBackoff * math.pow(2, math.min(row.attempts, 6)).toLong).min(maxBackoff) - row.updatedAt.get.getTime + backoff.toMillis <= now || row.attempts == 0 - } - if (due.nonEmpty) logger.debug(s"Open Corridor outbox relay: ${due.size} row(s) due") - due.foreach(relayRow) - } - - def relayRow(row: OpenCorridorOutbox): Unit = { - val replyBox: Box[com.openbankproject.commons.dto.InBoundOpenCorridorReply] = - try { - Await.result( - OpenCorridorPublisher.publishRawAndAwaitReply(row.targetBankId, row.messageId, row.payloadJson), - perRowTimeout - ) - } catch { - case e: Throwable => Failure(s"publish await failed: ${e.getMessage}") - } - - replyBox match { - case Full(reply) => - val replyJson = Serialization.write(reply) - val errorCode = reply.status.errorCode - if (errorCode.isEmpty) { - val settlementStatus = - if (row.messageId == "obp_settlement_instruction") - (reply.data \ "status").extractOpt[String].getOrElse("") - else "" - if (row.messageId == "obp_settlement_instruction" && settlementStatus != "FINAL") { - // Broadcast but not final — keep polling by redelivery (§4.4). - row.Attempts(row.attempts + 1).LastError("").LastReplyJson(replyJson).saveMe() - logger.info(s"Open Corridor outbox row ${row.id.get}: settlement ${row.settlementId} status '$settlementStatus' — will re-poll") - } else { - row.Status(OpenCorridorOutbox.STATUS_DELIVERED).LastError("").LastReplyJson(replyJson).saveMe() - logger.info(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} DELIVERED") - } - } else if (stickyErrorCodes.exists(errorCode.startsWith)) { - row.Status(OpenCorridorOutbox.STATUS_STICKY).Attempts(row.attempts + 1) - .LastError(errorCode).LastReplyJson(replyJson).saveMe() - logger.error(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + - s"STICKY error $errorCode — operator reconciliation required (settlement ${row.settlementId})") - } else { - // Retryable business failure (e.g. SETTLEMENT-FAILED) — keep redelivering. - row.Attempts(row.attempts + 1).LastError(errorCode).LastReplyJson(replyJson).saveMe() - logger.warn(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + - s"replied $errorCode — will retry") - } - case failure => - val error = failure match { - case Failure(msg, _, _) => msg - case _ => "no reply" - } - row.Attempts(row.attempts + 1).LastError(error.take(2000)).saveMe() - logger.warn(s"Open Corridor outbox row ${row.id.get}: ${row.messageId} to ${row.targetBankId} " + - s"transport failure (attempt ${row.attempts}): $error") - } - } -} diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala index 15598269d4..78d38c0fb3 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorProcessor.scala @@ -8,9 +8,14 @@ import code.api.util.{APIUtil, CallContext, NewStyle} import code.api.v7_0_0.JSONFactory700.{OpenCorridorPromiseJsonV700, PostOpenCorridorPromiseJsonV700, TransactionRequestBodyOpenCorridorJsonV700} import code.util.Helper import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.dto.{OpenCorridorMoneyValue, OpenCorridorOriginator, OutBoundOpenCorridorCreditNotification} import com.openbankproject.commons.model._ import com.openbankproject.commons.model.enums.ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE import com.openbankproject.commons.model.enums.{TransactionRequestAttributeType, TransactionRequestStatus, TransactionRequestTypes} +import code.messageoutbox.MessageOutbox +import code.transactionrequests.MappedTransactionRequest +import net.liftweb.common.Box +import net.liftweb.mapper.By import java.util.Date import org.json4s.native.Serialization.write @@ -76,7 +81,50 @@ object OpenCorridorProcessor { otherAccountSecondaryRoutingAddress = body.to.other_account_secondary_routing_address, callContext ) - (toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, callContext) + // Resolve the far BANK only — it must be registered here (the corridor + // registry is what OBP-API authoritatively knows), and its id is stamped + // on the TR as mTo_BankId, which the settle-pair netting selects by. The + // beneficiary ACCOUNT is deliberately NOT resolved: it lives in the far + // bank's CBS and is validated by the beneficiary Bank Node at credit + // time — requiring it to exist in OBP-API would demand an integration to + // the far bank's account list. + (toBank, callContext) <- resolveFarBank( + StringHelpers.snakify(body.to.other_bank_routing_scheme).toUpperCase, + body.to.other_bank_routing_address, + callContext + ) + // A corridor is inter-bank by definition: a same-bank "promise" needs no + // Travel-Rule relay and can never be settled (settlement is pairwise). + _ <- Helper.booleanToFuture(s"$OpenCorridorSameBankNotAllowed", cc = callContext) { + toBank.bankId.value != bankId.value + } + // Routing-only carrier for the TR row and charge plumbing. No Transaction + // ever posts against it: promises are held at PENDING (getStatus) and the + // net later moves between the settlement accounts, which the settle-pair + // step resolves separately. + toAccount = BankAccountCommons( + accountId = AccountId(body.to.other_account_routing_address), + accountType = "", + balance = 0, + currency = body.value.currency, + name = body.to.name, + label = "", + number = "", + bankId = toBank.bankId, + lastUpdate = new Date(), + branchId = "", + accountRoutings = List( + AccountRouting( + StringHelpers.snakify(body.to.other_account_routing_scheme).toUpperCase, + body.to.other_account_routing_address)) ++ + (if (body.to.other_account_secondary_routing_scheme.trim.nonEmpty) + List(AccountRouting( + StringHelpers.snakify(body.to.other_account_secondary_routing_scheme).toUpperCase, + body.to.other_account_secondary_routing_address)) + else Nil), + accountRules = List.empty, + accountHolder = body.to.name + ) _ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc = callContext) { toCounterparty.isBeneficiary } @@ -105,6 +153,24 @@ object OpenCorridorProcessor { } yield (createdTransactionRequest, callContext) } + // The far bank must exist in the corridor registry. OBP-scheme routing names + // the bank id directly; any other scheme (BIC, ...) is matched against the + // registered banks' bank routing. + private def resolveFarBank( + scheme: String, + address: String, + callContext: Option[CallContext] + ): Future[(Bank, Option[CallContext])] = { + if (scheme == "OBP" || scheme == "OBP_BANK_ID") + NewStyle.function.getBank(BankId(address), callContext) + else + NewStyle.function.getBanks(callContext).map { case (banks, cc) => + val bank = banks.find(b => + b.bankRoutingScheme.equalsIgnoreCase(scheme) && b.bankRoutingAddress == address) + (APIUtil.unboxFullOrFail(Box(bank), cc, s"$BankNotFound bank_routing: $scheme $address", 404), cc) + } + } + // ─── Promise report-back (salt relay intake) ──────────────────────────────── // // Transaction Request attribute names carrying the on-chain promise evidence. @@ -187,6 +253,11 @@ object OpenCorridorProcessor { NewStyle.function.createTransactionRequestAttributes( bankId, transactionRequestId, attributes, isPersonal = false, callContext ) map { case (_, callContext) => + // First attach only (idempotent redeliveries skip this branch): the + // promise now exists on-chain, so the beneficiary bank gets its + // evidence-bearing credit notification immediately — the promise is + // what gives it the confidence to pay out ahead of settlement. + enqueueCreditNotification(transactionRequestId, submittedEvidence) (buildPromiseJson(tr, submittedEvidence, user.userId, reportedAt), callContext) } } else { @@ -198,6 +269,38 @@ object OpenCorridorProcessor { } yield (promiseJson, callContext) } + private implicit val wireFormats: Formats = Serialization.formats(NoTypeHints) + + /** Build and enqueue the `obp_credit_notification` for a promise whose evidence + * was just attached. The outbox row's correlation id is the promise TR id + * (settlement-scoped messages use the settlement id there instead). */ + private def enqueueCreditNotification( + transactionRequestId: TransactionRequestId, + evidence: Map[String, String] + ): Unit = + MappedTransactionRequest + .find(By(MappedTransactionRequest.mTransactionRequestId, transactionRequestId.value)) + .foreach { row => + val wireBody = OutBoundOpenCorridorCreditNotification( + transaction_request_id = transactionRequestId.value, + value = OpenCorridorMoneyValue(row.mBody_Value_Currency.get, row.mBody_Value_Amount.get), + description = Option(row.mBody_Description.get).filter(_.nonEmpty), + originator = Option(row.mOriginator_Name.get).filter(_.nonEmpty).map(name => + OpenCorridorOriginator(name, Option(row.mOriginator_Address.get).filter(_.nonEmpty))), + netting_snapshot_id = None, + promise_id = evidence.get(PromiseAttributeTxHash), + promise_blockchain = evidence.get(PromiseAttributeBlockchain), + promise_commitment = evidence.get(PromiseAttributeCommitment), + promise_salt = evidence.get(PromiseAttributeSalt), + promise_preimage = evidence.get(PromiseAttributePreimage) + ) + MessageOutbox.enqueue( + MessageOutbox.TYPE_OPEN_CORRIDOR, transactionRequestId.value, + MessageOutbox.SUBJECT_TYPE_TRANSACTION_REQUEST_ID, + "obp_credit_notification", row.mTo_BankId.get, + Serialization.write(wireBody)) + } + private def buildPromiseJson( tr: TransactionRequest, evidence: Map[String, String], diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala index 1974e7fd2d..62d51817f1 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorPublisher.scala @@ -1,5 +1,6 @@ package code.bankconnectors.opencorridor +import code.amqpbroker.AmqpBankBroker import code.api.util.APIUtil import code.api.util.ErrorMessages._ import code.bankconnectors.rabbitmq.ResponseCallback @@ -18,7 +19,7 @@ import scala.concurrent.Future /** * Open Corridor Interface C publisher: server-initiated publish-and-await-reply to a - * BANK's own RabbitMQ vhost, keyed by bank_id through the OpenCorridorBankBroker + * BANK's own RabbitMQ vhost, keyed by bank_id through the AmqpBankBroker * registry. * * Structurally the same RPC shape as `RabbitMQUtils.sendRequestUndGetResponseFromRabbitMQ` @@ -56,7 +57,7 @@ object OpenCorridorPublisher extends MdcLoggable { * RabbitMQUtils. */ private val connections = new ConcurrentHashMap[String, Connection]() - private def connectionFor(broker: OpenCorridorBankBroker): Connection = { + private def connectionFor(broker: AmqpBankBroker): Connection = { connections.compute(broker.bankId, (_, existing) => { if (existing != null && existing.isOpen) existing else { @@ -94,13 +95,13 @@ object OpenCorridorPublisher extends MdcLoggable { /** Same, but with an already-serialized wire body (the outbox stores payloads as JSON). */ def publishRawAndAwaitReply(bankId: String, messageId: String, bodyJson: String): Future[Box[InBoundOpenCorridorReply]] = { - OpenCorridorBankBroker.findByBankId(bankId) match { + AmqpBankBroker.findByBankId(bankId) match { case Full(broker) => publishToBroker(broker, messageId, bodyJson) - case _ => Future.successful(Failure(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankId")) + case _ => Future.successful(Failure(s"$AmqpBankBrokerNotConfigured BANK_ID: $bankId")) } } - private def publishToBroker(broker: OpenCorridorBankBroker, messageId: String, bodyJson: String): Future[Box[InBoundOpenCorridorReply]] = { + private def publishToBroker(broker: AmqpBankBroker, messageId: String, bodyJson: String): Future[Box[InBoundOpenCorridorReply]] = { val replyJsonFuture: Future[String] = try { val connection = connectionFor(broker) @@ -135,7 +136,11 @@ object OpenCorridorPublisher extends MdcLoggable { .build() logger.info(s"Open Corridor publish: bank=${broker.bankId} messageId=$messageId correlationId=$correlationId replyTo=$replyQueueName") - logger.debug(s"Open Corridor publish body: $bodyJson") + // Body content is never logged: credit notifications carry the commit–reveal + // evidence (promise_salt / promise_preimage, with the payment instruction + // embedded in the preimage) plus originator PII, none of which + // SecureLogging.maskSensitive knows how to mask. + logger.debug(s"Open Corridor publish body: ${bodyJson.length} chars (content not logged)") channel.basicPublish("", RPC_QUEUE_NAME, props, bodyJson.getBytes("UTF-8")) val responseCallback = new ResponseCallback(correlationId, channel) @@ -157,12 +162,19 @@ object OpenCorridorPublisher extends MdcLoggable { } replyJsonFuture.map { replyJson => - logger.debug(s"Open Corridor reply: bank=${broker.bankId} messageId=$messageId body=$replyJson") + logger.debug(s"Open Corridor reply: bank=${broker.bankId} messageId=$messageId body=${replyJson.length} chars (content not logged)") net.liftweb.util.Helpers.tryo( org.json4s.native.JsonMethods.parse(replyJson).extract[InBoundOpenCorridorReply] ) match { - case Full(reply) => Full(reply) - case _ => Failure(s"$InvalidConnectorResponse Open Corridor reply did not parse as the inbound envelope. Body: $replyJson") + case Full(reply) => + logger.debug(s"Open Corridor reply parsed: bank=${broker.bankId} messageId=$messageId errorCode='${reply.status.errorCode}'") + Full(reply) + case _ => + // The raw body stays out of the Failure message — it propagates to callers + // and can surface in API error responses. Log it at debug only, truncated, + // where the SecureLogging funnel at least applies. + logger.debug(s"Open Corridor reply did not parse as the inbound envelope: bank=${broker.bankId} messageId=$messageId body=${replyJson.take(500)}") + Failure(s"$InvalidConnectorResponse Open Corridor reply did not parse as the inbound envelope. bank=${broker.bankId} messageId=$messageId") } }.recover { case e: Throwable => Failure(s"$OpenCorridorPublishFailed bank=${broker.bankId} messageId=$messageId Details: ${e.getMessage}") diff --git a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala index 1b09d06065..f293e8a53c 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala @@ -1,11 +1,14 @@ package code.bankconnectors.opencorridor +import code.amqpbroker.AmqpBankBroker import code.api.Constant.{INCOMING_SETTLEMENT_ACCOUNT_ID, OUTGOING_SETTLEMENT_ACCOUNT_ID} -import code.api.util.APIUtil.generateUUID +import code.api.util.APIUtil.{generateUUID, unboxFullOrFail} import code.api.util.ErrorMessages._ import code.api.util.{CallContext, NewStyle} -import code.api.v7_0_0.JSONFactory700.OpenCorridorSettleResultJsonV700 +import code.api.v7_0_0.JSONFactory700.{OpenCorridorSettleResultJsonV700, OpenCorridorSettlementMessageJsonV700, OpenCorridorSettlementStatusJsonV700} import code.bankconnectors.DoobieTransactionRequestQueries +import code.messageoutbox.MessageOutbox +import code.transactionRequestAttribute.TransactionRequestAttribute import code.transactionrequests.{MappedTransactionRequest, TransactionRequests} import code.util.Helper import code.util.Helper.MdcLoggable @@ -38,7 +41,7 @@ import scala.concurrent.Future * * The outbound Interface C messages (credit notification per covered promise to * its beneficiary bank, the net settlement instruction to the debtor) are - * written to the OpenCorridorOutbox in the SAME request DB transaction — the + * written to the message_outbox in the SAME request DB transaction — the * ResourceDocMiddleware transaction wrapper makes the whole settle atomic: a * crash rolls back money movement and outbox rows together. * @@ -73,6 +76,11 @@ object OpenCorridorSettlement extends MdcLoggable { ) for { + // Settlement is pairwise between two DIFFERENT banks; a same-bank pair + // would fetch the same promise rows in both directions. + _ <- Helper.booleanToFuture(s"$OpenCorridorSameBankNotAllowed", cc = callContext) { + bankIdA != bankIdB + } // Candidate discovery, then row-lock each candidate and re-read its status // under the lock — a concurrent settle may have completed it in between. candidates <- Future { @@ -84,6 +92,13 @@ object OpenCorridorSettlement extends MdcLoggable { if (DoobieTransactionRequestQueries.lockTransactionRequest(trId).isEmpty) { logger.warn(s"Open Corridor settle: could not lock promise TR $trId — skipping") None + } else if (!hasPromiseEvidence(trId)) { + // No on-chain evidence yet means the beneficiary bank was never + // notified and never paid out — netting it would move value between + // banks for a payment nobody delivered. It stays PENDING for a + // later cycle, once the originating node reports back. + logger.info(s"Open Corridor settle: promise TR $trId has no on-chain evidence yet — skipping") + None } else { MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, trId)) .filter(_.mStatus.get == TransactionRequestStatus.PENDING.toString) @@ -102,7 +117,7 @@ object OpenCorridorSettlement extends MdcLoggable { currency = currency, net_amount = "0", covered_transaction_request_ids = Nil, - credit_notifications_enqueued = 0, + settlement_advices_enqueued = 0, settlement_instructions_enqueued = 0 ), callContext)) } else { @@ -134,16 +149,11 @@ object OpenCorridorSettlement extends MdcLoggable { // Fail fast BEFORE mutating anything: both banks need a broker registration // (credit notifications go to each beneficiary's vhost), and a non-zero net // needs the creditor's settlement address for the instruction. - _ <- Helper.booleanToFuture(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankIdA", cc = callContext) { - OpenCorridorBankBroker.findByBankId(bankIdA).isDefined + _ <- Helper.booleanToFuture(s"$AmqpBankBrokerNotConfigured BANK_ID: $bankIdA", cc = callContext) { + AmqpBankBroker.findByBankId(bankIdA).isDefined } - _ <- Helper.booleanToFuture(s"$OpenCorridorBankBrokerNotConfigured BANK_ID: $bankIdB", cc = callContext) { - OpenCorridorBankBroker.findByBankId(bankIdB).isDefined - } - creditorSettlementAddress = OpenCorridorBankBroker.findByBankId(creditorBankId) - .map(_.settlementAddress).getOrElse("") - _ <- Helper.booleanToFuture(s"$OpenCorridorSettlementAddressMissing BANK_ID: $creditorBankId", cc = callContext) { - netAbs == 0 || creditorSettlementAddress.trim.nonEmpty + _ <- Helper.booleanToFuture(s"$AmqpBankBrokerNotConfigured BANK_ID: $bankIdB", cc = callContext) { + AmqpBankBroker.findByBankId(bankIdB).isDefined } // The settlement accounts (created at boot for every bank). @@ -152,6 +162,15 @@ object OpenCorridorSettlement extends MdcLoggable { (creditorIncoming, callContext) <- NewStyle.function.getBankAccount( BankId(creditorBankId), AccountId(INCOMING_SETTLEMENT_ACCOUNT_ID), callContext) + // The creditor's on-chain receiving address is the CARDANO routing on its + // incoming settlement account (the broker row is transport only). Checked + // before anything is mutated: a non-zero net needs somewhere to send funds. + creditorSettlementAddress = creditorIncoming.accountRoutings + .find(_.scheme.equalsIgnoreCase("CARDANO")).map(_.address).getOrElse("") + _ <- Helper.booleanToFuture(s"$OpenCorridorSettlementAddressMissing BANK_ID: $creditorBankId", cc = callContext) { + netAbs == 0 || creditorSettlementAddress.trim.nonEmpty + } + // Mint TR B — the settle event's audit object and the settlement_id. settlementTrId = generateUUID() commonBody = TransactionRequestCommonBodyJSONCommons( @@ -217,12 +236,24 @@ object OpenCorridorSettlement extends MdcLoggable { }) // Enqueue the Interface C messages in this same DB transaction (the outbox). - creditNotifications <- Future.sequence(covered.map(row => buildCreditNotification(row, callContext))) - _ <- Future { - creditNotifications.foreach { case (beneficiaryBankId, wireBody) => - OpenCorridorOutbox.enqueue( - settlementTrId, "obp_credit_notification", beneficiaryBankId, Serialization.write(wireBody)) - } + // Credit notifications went to each beneficiary at promise-report-back time + // (OpenCorridorProcessor); settlement sends each beneficiary an advice so + // its already-paid-out credits get marked settled. + settlementAdviceCount <- Future { + covered.groupBy(_.mTo_BankId.get).map { case (beneficiaryBankId, rows) => + val advice = OutBoundOpenCorridorSettlementAdvice( + settlement_id = settlementTrId, + currency = currency, + net_amount = netAbs.toString(), + debtor_bank_id = debtorBankId, + creditor_bank_id = creditorBankId, + covered_transaction_request_ids = rows.map(_.mTransactionRequestId.get), + idempotency_key = settlementTrId + ) + MessageOutbox.enqueue( + MessageOutbox.TYPE_OPEN_CORRIDOR, settlementTrId, MessageOutbox.SUBJECT_TYPE_SETTLEMENT_ID, + "obp_settlement_advice", beneficiaryBankId, Serialization.write(advice)) + }.size } settlementInstructionCount <- Future { if (netAbs > 0) { @@ -236,8 +267,9 @@ object OpenCorridorSettlement extends MdcLoggable { creditor_address = creditorSettlementAddress, idempotency_key = settlementTrId ) - OpenCorridorOutbox.enqueue( - settlementTrId, "obp_settlement_instruction", debtorBankId, Serialization.write(instruction)) + MessageOutbox.enqueue( + MessageOutbox.TYPE_OPEN_CORRIDOR, settlementTrId, MessageOutbox.SUBJECT_TYPE_SETTLEMENT_ID, + "obp_settlement_instruction", debtorBankId, Serialization.write(instruction)) 1 } else 0 } @@ -254,39 +286,100 @@ object OpenCorridorSettlement extends MdcLoggable { currency = currency, net_amount = netAbs.toString(), covered_transaction_request_ids = covered.map(_.mTransactionRequestId.get), - credit_notifications_enqueued = creditNotifications.size, + settlement_advices_enqueued = settlementAdviceCount, settlement_instructions_enqueued = settlementInstructionCount ), callContext) } } - /** Build the credit notification for one covered promise, addressed to its - * beneficiary (to-side) bank, relaying the §5.1 evidence attributes verbatim. */ - private def buildCreditNotification( - row: MappedTransactionRequest, + /** True once the promise's on-chain evidence was attached (report-back done) — + * the precondition for the beneficiary having been notified and paid out. */ + private def hasPromiseEvidence(trId: String): Boolean = + TransactionRequestAttribute.find( + By(TransactionRequestAttribute.Name, OpenCorridorProcessor.PromiseAttributeCommitment), + By(TransactionRequestAttribute.TransactionRequestId, trId) + ).isDefined + + /** + * The GET view of one settlement (the resource minted by settlePair). + * + * Visibility: the URL bank must be a party — debtor or creditor — of the + * settlement; anything else is a 404 (existence is not disclosed to third + * banks). The ledger side is final at settle time; the rail side is read off + * the settlement-instruction outbox row, whose LastReplyJson holds the debtor + * node's most recent §4.2 reply (redelivery-as-polling, publish plan §4.4): + * no instruction row → NET_ZERO (nothing to move) + * row PENDING, no reply yet → INSTRUCTED + * row PENDING, node replied → the node's reported status + * (SETTLING / SUBMITTED) + depth + * row DELIVERED → FINAL + * row STICKY → ERROR (operator reconciliation) + */ + def getSettlementStatus( + bankId: String, + settlementId: String, callContext: Option[CallContext] - ): Future[(String, OutBoundOpenCorridorCreditNotification)] = { - val promiseTrId = TransactionRequestId(row.mTransactionRequestId.get) - for { - (attributes, _) <- NewStyle.function.getTransactionRequestAttributes( - BankId(row.mFrom_BankId.get), promiseTrId, callContext) - } yield { - def attr(name: String): Option[String] = - attributes.find(_.name == name).map(_.value).filter(_.nonEmpty) - val wireBody = OutBoundOpenCorridorCreditNotification( - transaction_request_id = promiseTrId.value, - value = OpenCorridorMoneyValue(row.mBody_Value_Currency.get, row.mBody_Value_Amount.get), - description = Option(row.mBody_Description.get).filter(_.nonEmpty), - originator = Option(row.mOriginator_Name.get).filter(_.nonEmpty).map(name => - OpenCorridorOriginator(name, Option(row.mOriginator_Address.get).filter(_.nonEmpty))), - netting_snapshot_id = None, - promise_id = attr(OpenCorridorProcessor.PromiseAttributeTxHash), - promise_blockchain = attr(OpenCorridorProcessor.PromiseAttributeBlockchain), - promise_commitment = attr(OpenCorridorProcessor.PromiseAttributeCommitment), - promise_salt = attr(OpenCorridorProcessor.PromiseAttributeSalt), - promise_preimage = attr(OpenCorridorProcessor.PromiseAttributePreimage) - ) - (row.mTo_BankId.get, wireBody) + ): Future[(OpenCorridorSettlementStatusJsonV700, Option[CallContext])] = Future { + val settlementTr = unboxFullOrFail( + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, settlementId)) + .filter(_.mType.get == TransactionRequestTypes.OPEN_CORRIDOR_SETTLEMENT.toString) + .filter(row => row.mFrom_BankId.get == bankId || row.mTo_BankId.get == bankId), + callContext, OpenCorridorSettlementNotFound, 404) + + val outboxRows = MessageOutbox.bySubjectId(settlementId) + val coveredTrIds = TransactionRequestAttribute.findAll( + By(TransactionRequestAttribute.Name, AttrSettledByTransactionRequestId), + By(TransactionRequestAttribute.`Value`, settlementId) + ).map(_.TransactionRequestId.get).distinct + + val instructionRow = outboxRows.find(_.operationName == "obp_settlement_instruction") + val (settlementStatus, settlementDepth) = instructionRow match { + case None => ("NET_ZERO", None) + case Some(row) => row.status match { + case MessageOutbox.STATUS_DELIVERED => ("FINAL", nodeReportedDepth(row)) + case MessageOutbox.STATUS_STICKY => ("ERROR", nodeReportedDepth(row)) + case _ => nodeReportedField(row, "status").filter(_.nonEmpty) + .map(status => (status, nodeReportedDepth(row))) + .getOrElse(("INSTRUCTED", None)) + } + } + + (OpenCorridorSettlementStatusJsonV700( + settlement_id = settlementId, + debtor_bank_id = settlementTr.mFrom_BankId.get, + creditor_bank_id = settlementTr.mTo_BankId.get, + currency = settlementTr.mBody_Value_Currency.get, + net_amount = settlementTr.mBody_Value_Amount.get, + transaction_id = settlementTr.mTransactionIDs.get, + ledger_status = settlementTr.mStatus.get, + settlement_status = settlementStatus, + settlement_depth = settlementDepth, + covered_transaction_request_ids = coveredTrIds, + messages = outboxRows.map(row => OpenCorridorSettlementMessageJsonV700( + operation_name = row.operationName, + target_bank_id = row.targetId, + delivery_status = row.status, + attempts = row.attempts, + last_error = row.LastError.get + )) + ), callContext) + } + + /** Extract one field of the node's last reply (`data.` of the §4.2 + * envelope recorded on the outbox row); None when no reply is recorded. */ + private def nodeReportedField(row: MessageOutbox, field: String): Option[String] = { + Option(row.LastReplyJson.get).filter(_.nonEmpty).flatMap { replyJson => + scala.util.Try(org.json4s.native.JsonMethods.parse(replyJson) \ "data" \ field).toOption + }.flatMap { + case org.json4s.JString(s) => Some(s) + case org.json4s.JInt(i) => Some(i.toString) + case _ => None } } + + private def nodeReportedDepth(row: MessageOutbox): Option[Int] = + nodeReportedField(row, "depth").flatMap(s => scala.util.Try(s.toInt).toOption) + + /** Build the credit notification for one covered promise, addressed to its + * beneficiary (to-side) bank, relaying the §5.1 evidence attributes verbatim. */ } diff --git a/obp-api/src/main/scala/code/entitlement/Entilement.scala b/obp-api/src/main/scala/code/entitlement/Entilement.scala index e0a4efe07b..68fa07ae10 100644 --- a/obp-api/src/main/scala/code/entitlement/Entilement.scala +++ b/obp-api/src/main/scala/code/entitlement/Entilement.scala @@ -39,7 +39,11 @@ trait EntitlementProvider { userId: String, roleName: String, createdByProcess: String = "manual", - grantorUserId: Option[String] = None, + // Audit only — who granted (the logged-in granter, or the user + // themselves on self-grant flows). None for system processes, where + // createdByProcess carries the provenance. Authorization is the + // calling endpoint's responsibility, not this method's. + grantedByUserId: Option[String] = None, groupId: Option[String] = None, process: Option[String] = None ): Box[Entitlement] @@ -59,4 +63,9 @@ trait Entitlement { def entitlementRequestId: Option[String] def groupId: Option[String] def process: Option[String] + + /** user_id of the granter, when the grant was made by a person (directly + * or as a self-grant). None for system-process grants and virtual + * entitlements. */ + def grantedByUserId: Option[String] } diff --git a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala index 653f787593..da3c09b385 100644 --- a/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala +++ b/obp-api/src/main/scala/code/entitlement/MappedEntitlements.scala @@ -162,16 +162,22 @@ object MappedEntitlementsProvider extends EntitlementProvider { userId: String, roleName: String, createdByProcess: String = "manual", - grantorUserId: Option[String] = None, + grantedByUserId: Option[String] = None, groupId: Option[String] = None, process: Option[String] = None ): Box[Entitlement] = { + // grantedByUserId is audit metadata, stored as-is: authorization is the + // calling endpoint's responsibility. (Until 2026-08-09 an unused + // grantorUserId parameter gated on the grantor's granting roles here — + // no caller ever passed it, and the check ignored super admins, whose + // granting rights are virtual and have no rows to find.) def addEntitlementToUser(): Box[MappedEntitlement] = { val entitlement = MappedEntitlement.create .mBankId(bankId) .mUserId(userId) .mRoleName(roleName) .mCreatedByProcess(createdByProcess) + grantedByUserId.foreach(g => entitlement.mGrantedByUserId(g)) groupId.foreach(gid => entitlement.mGroupId(gid)) process.foreach(p => entitlement.mProcess(p)) tryo(entitlement.saveMe()) match { @@ -188,25 +194,7 @@ object MappedEntitlementsProvider extends EntitlementProvider { case other => other } } - // Return a Box so we can handle errors later. - grantorUserId match { - case Some(userId) => - val canCreateEntitlementAtAnyBank = MappedEntitlement - .findAll(By(MappedEntitlement.mUserId, userId)) - .exists(e => e.roleName == CanCreateEntitlementAtAnyBank) - val canCreateEntitlementAtOneBank = MappedEntitlement - .findAll(By(MappedEntitlement.mUserId, userId)) - .exists(e => - e.roleName == CanCreateEntitlementAtOneBank && e.bankId == bankId - ) - if (canCreateEntitlementAtAnyBank || canCreateEntitlementAtOneBank) { - addEntitlementToUser() - } else { - Failure(ErrorMessages.EntitlementCannotBeGrantedGrantorIssue) - } - case None => - addEntitlementToUser() - } + addEntitlementToUser() } } @@ -239,6 +227,11 @@ class MappedEntitlement override def defaultValue = null } + object mGrantedByUserId extends UUIDString(this) { + override def dbColumnName = "granted_by_user_id" + override def defaultValue = "" + } + override def entitlementId: String = mEntitlementId.get.toString override def bankId: String = mBankId.get override def userId: String = mUserId.get @@ -254,14 +247,15 @@ class MappedEntitlement val p = mProcess.get if (p == null || p.isEmpty) None else Some(p) } + override def grantedByUserId: Option[String] = { + val g = mGrantedByUserId.get + if (g == null || g.isEmpty) None else Some(g) + } override def entitlementRequestId: Option[String] = { - entitlement_request_id.get match { - case uuid - if uuid.toString.nonEmpty && uuid.toString != "00000000-0000-0000-0000-000000000000" => - Some(uuid.toString) - case _ => - None - } + // The column defaults to null (only request-born grants set it). + Option(entitlement_request_id.get) + .map(_.toString) + .filter(uuid => uuid.nonEmpty && uuid != "00000000-0000-0000-0000-000000000000") } } diff --git a/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala new file mode 100644 index 0000000000..33b94baad2 --- /dev/null +++ b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala @@ -0,0 +1,147 @@ +package code.messageoutbox + +import net.liftweb.mapper._ + +/** + * Generic transactional outbox for asynchronous messages OBP-API must deliver. + * + * The business event (e.g. an Open Corridor settle) commits in one DB + * transaction; the outbound messages must survive a crash between that commit + * and the publish. So they are written as rows here in the SAME transaction, + * and the relay publishes them afterwards with at-least-once redelivery, + * recording each reply. Publishing to a broker cannot participate in the DB + * transaction — this table is what closes that atomicity gap. + * + * `outbox_type` discriminates message families; each type contributes its own + * publish behavior to the relay. Types so far: + * OPEN_CORRIDOR — Interface C messages to a bank's RabbitMQ vhost + * (`target_id` = bank_id, publisher = OpenCorridorPublisher). + * + * Row lifecycle: + * PENDING — not yet delivered; the relay keeps publishing with backoff. + * DELIVERED — the receiver replied success. + * STICKY — the receiver replied with an error that retrying cannot fix. + * Needs operator reconciliation: visible via + * GET /management/message-outbox, re-queued via its /retry. + */ +class MessageOutbox extends LongKeyedMapper[MessageOutbox] with IdPK { + def getSingleton = MessageOutbox + + /** Message family; decides how the relay publishes the row. */ + object OutboxType extends MappedString(this, 32) { + override def dbColumnName = "outbox_type" + } + /** The id of the business object this message is about. NOT the + * per-REST-call Correlation-Id, and not the AMQP reply correlationId. */ + object SubjectId extends MappedString(this, 64) { + override def dbColumnName = "subject_id" + } + /** The OBP id-field name whose value space subject_id belongs to, e.g. + * transaction_request_id / settlement_id — makes rows self-describing + * instead of relying on per-operation conventions. */ + object SubjectIdType extends MappedString(this, 32) { + override def dbColumnName = "subject_id_type" + } + /** The operation this message performs, e.g. obp_credit_notification / + * obp_settlement_advice. On the OPEN_CORRIDOR wire this becomes the AMQP + * messageId property (locked contract). Named operation_name here to avoid + * colliding with message_id-as-instance-id elsewhere in OBP (e.g. signal + * channel messages). */ + object OperationName extends MappedString(this, 64) { + override def dbColumnName = "operation_name" + } + /** Delivery target, per outbox_type (OPEN_CORRIDOR: the bank id whose + * vhost the message is published to). */ + object TargetId extends MappedString(this, 255) { + override def dbColumnName = "target_id" + } + /** The wire body, serialized at enqueue time. */ + object PayloadJson extends MappedText(this) { + override def dbColumnName = "payload_json" + } + object Status extends MappedString(this, 16) { + override def dbColumnName = "status" + override def defaultValue = MessageOutbox.STATUS_PENDING + } + object Attempts extends MappedInt(this) { + override def dbColumnName = "attempts" + override def defaultValue = 0 + } + object LastError extends MappedString(this, 2000) { + override def dbColumnName = "last_error" + } + /** The receiver's last reply, verbatim, for audit/reconciliation. */ + object LastReplyJson extends MappedText(this) { + override def dbColumnName = "last_reply_json" + } + /** Per-type optional extras; empty for OPEN_CORRIDOR. */ + object MetadataJson extends MappedText(this) { + override def dbColumnName = "metadata_json" + } + object CreatedAt extends MappedDateTime(this) { + override def dbColumnName = "created_at" + override def defaultValue = new java.util.Date() + } + object UpdatedAt extends MappedDateTime(this) { + override def dbColumnName = "updated_at" + override def defaultValue = new java.util.Date() + } + + def outboxType: String = OutboxType.get + def subjectId: String = SubjectId.get + def subjectIdType: String = SubjectIdType.get + def operationName: String = OperationName.get + def targetId: String = TargetId.get + def payloadJson: String = PayloadJson.get + def status: String = Status.get + def attempts: Int = Attempts.get + + // updated_at drives the relay's backoff; stamp it on every save. + override def save: Boolean = { + UpdatedAt(new java.util.Date()) + super.save + } +} + +object MessageOutbox extends MessageOutbox with LongKeyedMetaMapper[MessageOutbox] { + val STATUS_PENDING = "PENDING" + val STATUS_DELIVERED = "DELIVERED" + val STATUS_STICKY = "STICKY" + + val TYPE_OPEN_CORRIDOR = "OPEN_CORRIDOR" + + // subject_id_type holds the OBP id-field name whose value space subject_id + // belongs to (exact snake_case field name, e.g. transaction_request_id, + // settlement_id, consent_id, customer_id ...). + val SUBJECT_TYPE_SETTLEMENT_ID = "settlement_id" + val SUBJECT_TYPE_TRANSACTION_REQUEST_ID = "transaction_request_id" + + override def dbTableName = "message_outbox" + + override def dbIndexes: List[BaseIndex[MessageOutbox]] = + Index(Status) :: Index(SubjectId) :: Index(OutboxType) :: super.dbIndexes + + def enqueue( + outboxType: String, + subjectId: String, + subjectIdType: String, + operationName: String, + targetId: String, + payloadJson: String + ): MessageOutbox = + MessageOutbox.create + .OutboxType(outboxType) + .SubjectId(subjectId) + .SubjectIdType(subjectIdType) + .OperationName(operationName) + .TargetId(targetId) + .PayloadJson(payloadJson) + .Status(STATUS_PENDING) + .saveMe() + + def pending(): List[MessageOutbox] = + MessageOutbox.findAll(By(MessageOutbox.Status, STATUS_PENDING)) + + def bySubjectId(subjectId: String): List[MessageOutbox] = + MessageOutbox.findAll(By(MessageOutbox.SubjectId, subjectId)) +} diff --git a/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala b/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala new file mode 100644 index 0000000000..c0d9de169b --- /dev/null +++ b/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala @@ -0,0 +1,143 @@ +package code.messageoutbox + +import code.actorsystem.ObpActorSystem +import code.bankconnectors.opencorridor.OpenCorridorPublisher +import code.util.Helper.MdcLoggable +import net.liftweb.common.{Box, Failure, Full} +import org.json4s._ +import org.json4s.native.Serialization + +import java.util.concurrent.TimeUnit +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Publishes message_outbox rows and records the replies. Runs on the + * actor-system scheduler (started from Boot), one pass per tick, rows + * processed serially — throughput is not the concern here, at-least-once + * delivery with a recorded audit trail is. + * + * The loop, backoff and PENDING/DELIVERED/STICKY supervision are generic; + * each `outbox_type` contributes its publish + reply interpretation. A row + * whose type has no registered publisher goes STICKY (an operator problem, + * not a retry problem). + * + * OPEN_CORRIDOR reply handling (locked wire contract §4.2/§4.4): + * - transport failure / timeout / broker unregistered → row stays PENDING, + * attempts+1 (retried next tick; exponential backoff by attempts). + * - errorCode == "" → DELIVERED, except a settlement instruction, which is + * DELIVERED only when the bank reports status FINAL; SUBMITTED / SETTLING + * keep the row PENDING — redelivery IS the status poll, and the Bank Node + * never pays twice for the same idempotency_key. + * - OBP-BANK-NODE-SETTLEMENT-FAILED and CBS-DELIVERY-FAILED → stay PENDING: + * both are transient on the node side and redelivery is safe (idempotent + * verification; the CBS dedupes on transaction_request_id). + * - COMMITMENT-MISMATCH / BAD-MESSAGE / NOT-IMPLEMENTED / + * SETTLEMENT-NOT-CONFIGURED → STICKY: retry cannot fix it; it needs an + * operator (GET /management/message-outbox + /retry). The error and full + * reply are recorded — never swallowed. + */ +object MessageOutboxRelay extends MdcLoggable { + + private implicit val formats = code.api.util.CustomJsonFormats.nullTolerateFormats + + /** Base backoff between attempts for a row; doubles per attempt, capped. */ + private val baseBackoff = 10.seconds + private val maxBackoff = 10.minutes + /** Cap on how long one row's publish may block the (serial) relay pass. */ + private val perRowTimeout = 60.seconds + + // OPEN_CORRIDOR errors retrying cannot fix. CBS-DELIVERY-FAILED is + // deliberately NOT here: a CBS being down is transient, and with credit + // notifications sent at promise time a sticky classification would park + // every credit that hits a CBS blip. + private val openCorridorStickyErrorCodes = Set( + "OBP-BANK-NODE-COMMITMENT-MISMATCH", + "OBP-BANK-NODE-BAD-MESSAGE", + "OBP-BANK-NODE-NOT-IMPLEMENTED", + "OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED" + ) + + def start(intervalSeconds: Long): Unit = { + implicit val executor = ObpActorSystem.localActorSystem.dispatcher + ObpActorSystem.localActorSystem.scheduler.schedule( + initialDelay = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), + interval = scala.concurrent.duration.Duration(intervalSeconds, TimeUnit.SECONDS), + runnable = new Runnable { + def run(): Unit = + try relayOnePass() + catch { case e: Throwable => logger.error("message outbox relay pass failed", e) } + } + ) + logger.info(s"message outbox relay started (interval ${intervalSeconds}s)") + } + + /** One pass over the PENDING rows that are due (backoff by attempts). */ + def relayOnePass(): Unit = { + val now = System.currentTimeMillis() + val due = MessageOutbox.pending().filter { row => + val backoff = (baseBackoff * math.pow(2, math.min(row.attempts, 6)).toLong).min(maxBackoff) + row.UpdatedAt.get.getTime + backoff.toMillis <= now || row.attempts == 0 + } + if (due.nonEmpty) logger.debug(s"message outbox relay: ${due.size} row(s) due") + due.foreach(relayRow) + } + + def relayRow(row: MessageOutbox): Unit = row.outboxType match { + case MessageOutbox.TYPE_OPEN_CORRIDOR => relayOpenCorridorRow(row) + case other => + row.Status(MessageOutbox.STATUS_STICKY).Attempts(row.attempts + 1) + .LastError(s"no publisher registered for outbox_type '$other'").saveMe() + logger.error(s"message outbox row ${row.id.get}: unknown outbox_type '$other' — STICKY") + } + + private def relayOpenCorridorRow(row: MessageOutbox): Unit = { + val replyBox: Box[com.openbankproject.commons.dto.InBoundOpenCorridorReply] = + try { + Await.result( + OpenCorridorPublisher.publishRawAndAwaitReply(row.targetId, row.operationName, row.payloadJson), + perRowTimeout + ) + } catch { + case e: Throwable => Failure(s"publish await failed: ${e.getMessage}") + } + + replyBox match { + case Full(reply) => + val replyJson = Serialization.write(reply) + val errorCode = reply.status.errorCode + if (errorCode.isEmpty) { + val settlementStatus = + if (row.operationName == "obp_settlement_instruction") + (reply.data \ "status").extractOpt[String].getOrElse("") + else "" + if (row.operationName == "obp_settlement_instruction" && settlementStatus != "FINAL") { + // Broadcast but not final — keep polling by redelivery (§4.4). + row.Attempts(row.attempts + 1).LastError("").LastReplyJson(replyJson).saveMe() + logger.info(s"message outbox row ${row.id.get}: settlement ${row.subjectId} status '$settlementStatus' — will re-poll") + } else { + row.Status(MessageOutbox.STATUS_DELIVERED).LastError("").LastReplyJson(replyJson).saveMe() + logger.info(s"message outbox row ${row.id.get}: ${row.operationName} to ${row.targetId} DELIVERED") + } + } else if (openCorridorStickyErrorCodes.exists(errorCode.startsWith)) { + row.Status(MessageOutbox.STATUS_STICKY).Attempts(row.attempts + 1) + .LastError(errorCode).LastReplyJson(replyJson).saveMe() + logger.error(s"message outbox row ${row.id.get}: ${row.operationName} to ${row.targetId} " + + s"STICKY error $errorCode — operator reconciliation required (subject ${row.subjectId})") + } else { + // Retryable business failure (e.g. SETTLEMENT-FAILED, CBS-DELIVERY-FAILED). + row.Attempts(row.attempts + 1).LastError(errorCode).LastReplyJson(replyJson).saveMe() + logger.warn(s"message outbox row ${row.id.get}: ${row.operationName} to ${row.targetId} " + + s"replied $errorCode — will retry") + } + case failure => + val error = failure match { + case Failure(msg, _, _) => msg + case _ => "no reply" + } + row.Attempts(row.attempts + 1).LastError(error.take(2000)).saveMe() + logger.warn(s"message outbox row ${row.id.get}: ${row.operationName} to ${row.targetId} " + + s"transport failure (attempt ${row.attempts}): $error") + } + } +} diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala index 56030d47cb..9309b80900 100644 --- a/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala +++ b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala @@ -213,8 +213,12 @@ object BankSupportedRoutingScheme object RoutingSchemeValidation { // Server-side guards. Mirrored in glossary + JSON-schema for clients. - private val NameRegex = "^(?:IBAN|BIC|OBP|[A-Z]{2}(?:\\.[A-Z][A-Z0-9_]*)+)$".r - private val GlobalAllowList = Set("IBAN", "BIC", "OBP") + // CARDANO / ETHEREUM: global blockchain rails (country INT). CARDANO carries + // the Open Corridor settlement address as an account routing on + // OBP-INCOMING-SETTLEMENT-ACCOUNT; ETHEREUM is allowlisted for the same use + // when a second rail backend exists. + private val NameRegex = "^(?:IBAN|BIC|OBP|CARDANO|ETHEREUM|[A-Z]{2}(?:\\.[A-Z][A-Z0-9_]*)+)$".r + private val GlobalAllowList = Set("IBAN", "BIC", "OBP", "CARDANO", "ETHEREUM") val ValidCategories: Set[String] = Set("ACCOUNT", "BANK", "BRANCH", "IDENTITY", "BILL", "UTILITY") val ValidStatuses: Set[String] = Set("ACTIVE", "RESERVED", "DEPRECATED", "RETIRED") diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala index 44893f9d43..a5dbc6b2aa 100644 --- a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala +++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala @@ -33,6 +33,24 @@ object RoutingSchemeSeed { downstreamRails: List[String] ) + // Global (unprefixed, country INT) schemes every OBP instance should know: + // the three allow-listed international schemes used by bank/account + // routing pairs (e.g. Open Corridor payment validation). + val globalSeeds: List[Entry] = List( + Entry("OBP", "INT", "ACCOUNT", + "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", "gh.29.uk", + "OBP bank id or account id, as used in OBP account routings.", + Nil), + Entry("IBAN", "INT", "ACCOUNT", + "^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$", "GB29NWBK60161331926819", + "International Bank Account Number (ISO 13616), no spaces.", + Nil), + Entry("BIC", "INT", "BANK", + "^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$", "NWBKGB2LXXX", + "ISO 9362 Business Identifier Code, 8 or 11 characters.", + Nil) + ) + val tzSeeds: List[Entry] = List( Entry("TZ.MSISDN", "TZ", "ACCOUNT", "^255[0-9]{9}$", "255778300336", @@ -90,7 +108,8 @@ object RoutingSchemeSeed { return } val provider = MappedRoutingSchemeProvider - val (inserted, skipped, failed) = tzSeeds.foldLeft((0, 0, 0)) { + val allSeeds = globalSeeds ++ tzSeeds + val (inserted, skipped, failed) = allSeeds.foldLeft((0, 0, 0)) { case ((ins, skp, fld), entry) => provider.getRoutingScheme(entry.scheme) match { case Full(_) => @@ -116,6 +135,6 @@ object RoutingSchemeSeed { } } } - logger.info(s"[RoutingSchemeSeed] inserted=$inserted skipped=$skipped failed=$failed (of ${tzSeeds.size} total seeds)") + logger.info(s"[RoutingSchemeSeed] inserted=$inserted skipped=$skipped failed=$failed (of ${allSeeds.size} total seeds)") } } diff --git a/obp-api/src/main/scala/code/transactionRequestAttribute/TransactionRequestAttribute.scala b/obp-api/src/main/scala/code/transactionRequestAttribute/TransactionRequestAttribute.scala index dc57393293..44085fa23c 100644 --- a/obp-api/src/main/scala/code/transactionRequestAttribute/TransactionRequestAttribute.scala +++ b/obp-api/src/main/scala/code/transactionRequestAttribute/TransactionRequestAttribute.scala @@ -35,7 +35,9 @@ class TransactionRequestAttribute extends TransactionRequestAttributeTrait with object Type extends MappedString(this, 50) - object `Value` extends MappedString(this, 255) + // TEXT, not varchar(255): Open Corridor promise evidence stores the full + // A1.1 preimage JSON here, which exceeds any fixed varchar bound. + object `Value` extends MappedText(this) object IsPersonal extends MappedBoolean(this) 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 57420da991..5595d9b0cd 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 @@ -7,8 +7,8 @@ import code.api.util.http4s.Http4sStandardHeaders import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureOpenCorridorBroker, canSettleOpenCorridor, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OpenCorridorBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSettlementAddressMissing, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -347,6 +347,269 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── createAccount (POST generated id / PUT chosen id) ─────────────────────── + + private def createAccountBody( + userId: Option[String] = None, + routings: List[(String, String)] = Nil + ): String = { + val userField = userId.map(u => s""""user_id": "$u",""").getOrElse("") + val routingsJson = routings + .map { case (scheme, address) => s"""{"scheme": "$scheme", "address": "$address"}""" } + .mkString("[", ",", "]") + s"""{ + | $userField + | "label": "V7 test account", + | "product_code": "OPEN_CORRIDOR", + | "balance": {"currency": "EUR", "amount": "0"}, + | "branch_id": "", + | "account_routings": $routingsJson + |}""".stripMargin + } + + private def routingPairs(json: JValue): List[(String, String)] = + json \ "account_routings" match { + case JArray(items) => items.map { item => + (item \ "scheme", item \ "address") match { + case (JString(scheme), JString(address)) => (scheme, address) + case _ => fail("Expected scheme/address strings in account_routings") + } + } + case _ => fail("Expected account_routings array") + } + + feature("Http4s700 createAccount endpoints") { + + scenario("Reject unauthenticated POST to /banks/BANK_ID/accounts", Http4s700RoutesTag) { + Given("POST with no auth") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", createAccountBody()) + + Then("Response is 401") + statusCode shouldBe 401 + (json \ "message") match { + case JString(msg) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + } + + scenario("Reject an explicit OBP routing in account_routings", Http4s700RoutesTag) { + Given("A body carrying scheme OBP — the routing is implicit in v7.0.0") + addEntitlement(testBankId1.value, resourceUser1.userId, canCreateAccount.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = createAccountBody(routings = List(("OBP", "some-address"))) + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", body, headers) + + Then("Response is 400 with the implicit-routing refusal") + statusCode shouldBe 400 + (json \ "message") match { + case JString(msg) => + msg should include(InvalidAccountRoutings) + msg should include("implicit") + case _ => fail("Expected message field") + } + } + + scenario("Reject OBP_ACCOUNT_ID scheme case-insensitively", Http4s700RoutesTag) { + Given("A body carrying scheme obp_account_id in lower case") + addEntitlement(testBankId1.value, resourceUser1.userId, canCreateAccount.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = createAccountBody(routings = List(("obp_account_id", "some-address"))) + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", body, headers) + + Then("Response is 400 with the implicit-routing refusal") + statusCode shouldBe 400 + (json \ "message") match { + case JString(msg) => msg should include(InvalidAccountRoutings) + case _ => fail("Expected message field") + } + } + + scenario("POST creates a caller-owned account with a generated id and the implicit OBP routing", Http4s700RoutesTag) { + Given("CanCreateAccount granted and a valid body with one IBAN routing, no user_id (owner defaults to the caller)") + addEntitlement(testBankId1.value, resourceUser1.userId, canCreateAccount.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val iban = s"DE-TEST-${APIUtil.generateUUID().take(12)}" + val body = createAccountBody(routings = List(("IBAN", iban))) + + When("POST /banks/BANK_ID/accounts") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", body, headers) + + Then("Response is 201, the id is server-generated, and routings carry OBP + IBAN") + statusCode shouldBe 201 + val accountId = (json \ "account_id") match { + case JString(id) => id should not be empty; id + case _ => fail("Expected account_id") + } + (json \ "bank_id") shouldBe JString(testBankId1.value) + (json \ "user_id") shouldBe JString(resourceUser1.userId) + val pairs = routingPairs(json) + pairs should contain(("OBP", accountId)) + pairs should contain(("IBAN", iban)) + } + + scenario("Return 403 without CanCreateAccount — even when creating for yourself", Http4s700RoutesTag) { + Given("resourceUser2 (no roles granted anywhere in this suite) creates with no user_id in the body") + val headers = Map("DirectLogin" -> s"token=${token2.value}") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", createAccountBody(), headers) + + Then("Response is 403 — v7.0.0 deprecates role-free self-service account creation") + statusCode shouldBe 403 + (json \ "message") match { + case JString(msg) => + msg should include(UserHasMissingRoles) + msg should include(canCreateAccount.toString) + case _ => fail("Expected message field") + } + } + + scenario("Create for another user with CanCreateAccount at the bank", Http4s700RoutesTag) { + Given("resourceUser1 holds CanCreateAccount at the bank and targets resourceUser2") + addEntitlement(testBankId1.value, resourceUser1.userId, canCreateAccount.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val body = createAccountBody(userId = Some(resourceUser2.userId)) + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts", body, headers) + + Then("Response is 201 and the account is owned by resourceUser2") + statusCode shouldBe 201 + (json \ "user_id") shouldBe JString(resourceUser2.userId) + } + + scenario("PUT creates the account under the chosen id; a second PUT is refused", Http4s700RoutesTag) { + Given("CanCreateAccount granted and a caller-chosen account id") + addEntitlement(testBankId1.value, resourceUser1.userId, canCreateAccount.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val chosenId = s"v7-put-${APIUtil.generateUUID().take(12)}" + + When(s"PUT /banks/BANK_ID/accounts/$chosenId") + val (statusCode, json, _) = makeHttpRequestWithBody( + "PUT", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts/$chosenId", createAccountBody(), headers) + + Then("Response is 201 with the chosen id and its implicit OBP routing") + statusCode shouldBe 201 + (json \ "account_id") shouldBe JString(chosenId) + routingPairs(json) should contain(("OBP", chosenId)) + + And("A second PUT under the same id is refused") + val (statusCode2, json2, _) = makeHttpRequestWithBody( + "PUT", s"/obp/v7.0.0/banks/${testBankId1.value}/accounts/$chosenId", createAccountBody(), headers) + statusCode2 should not be 201 + (json2 \ "message") match { + case JString(msg) => msg should include(AccountIdAlreadyExists) + case _ => fail("Expected message field") + } + } + } + + // ─── same-bank corridor guard ───────────────────────────────────────────────── + + feature("Http4s700 OPEN_CORRIDOR same-bank guard") { + + scenario("Refuse an OPEN_CORRIDOR promise whose beneficiary bank is the sending bank", Http4s700RoutesTag) { + setPropsValues("open_corridor_enabled" -> "true") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val currency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + openCorridorPromisePath(testBankId1.value, testAccountId0.value), + openCorridorPromiseBody(currency, amount = "1.00", + beneficiaryBankId = testBankId1.value, beneficiaryAccountId = testAccountId0.value), headers) + statusCode shouldBe 400 + messageOf(json) should include(OpenCorridorSameBankNotAllowed) + } + + scenario("Refuse a settle whose pair is the same bank twice", Http4s700RoutesTag) { + setPropsValues("open_corridor_enabled" -> "true") + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + s"/obp/v7.0.0/banks/${testBankId1.value}/open-corridor/settlements", + s"""{"other_bank_id": "${testBankId1.value}", "currency": "KES"}""", headers) + statusCode shouldBe 400 + messageOf(json) should include(OpenCorridorSameBankNotAllowed) + } + } + + // ─── message outbox (operator) ──────────────────────────────────────────────── + + private def seedOutboxRow(status: String): code.messageoutbox.MessageOutbox = { + val row = code.messageoutbox.MessageOutbox.enqueue( + code.messageoutbox.MessageOutbox.TYPE_OPEN_CORRIDOR, + s"subject-${APIUtil.generateUUID().take(8)}", + code.messageoutbox.MessageOutbox.SUBJECT_TYPE_TRANSACTION_REQUEST_ID, + "obp_credit_notification", testBankId2.value, "{}") + if (status != code.messageoutbox.MessageOutbox.STATUS_PENDING) + row.Status(status).LastError("OBP-BANK-NODE-COMMITMENT-MISMATCH").saveMe() + else row + } + + feature("Http4s700 message outbox operator endpoints") { + + scenario("Reject unauthenticated GET /management/message-outbox", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequest("/obp/v7.0.0/management/message-outbox") + statusCode shouldBe 401 + } + + scenario("Return 403 without CanGetMessageOutbox", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token2.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/management/message-outbox", headers) + statusCode shouldBe 403 + messageOf(json) should include(canGetMessageOutbox.toString) + } + + scenario("List STICKY rows with filters", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canGetMessageOutbox.toString) + val sticky = seedOutboxRow(code.messageoutbox.MessageOutbox.STATUS_STICKY) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest( + "/obp/v7.0.0/management/message-outbox?status=STICKY&outbox_type=OPEN_CORRIDOR", headers) + statusCode shouldBe 200 + (json \ "rows") match { + case JArray(rows) => + val row = rows.find(r => (r \ "outbox_id") == JInt(sticky.id.get)) + .getOrElse(fail("seeded sticky row should be listed")) + (row \ "outbox_type") shouldBe JString("OPEN_CORRIDOR") + (row \ "subject_id_type") shouldBe JString("transaction_request_id") + (row \ "status") shouldBe JString("STICKY") + (row \ "last_error") shouldBe JString("OBP-BANK-NODE-COMMITMENT-MISMATCH") + row match { + case JObject(fields) => fields.map(_.name) should not contain "payload_json" + case _ => fail("row should be an object") + } + case _ => fail("rows should be an array") + } + } + + scenario("Retry re-queues a STICKY row; refuses non-STICKY and unknown ids", Http4s700RoutesTag) { + addEntitlement("", resourceUser1.userId, canRetryMessageOutbox.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + val sticky = seedOutboxRow(code.messageoutbox.MessageOutbox.STATUS_STICKY) + val (retryCode, retryJson, _) = makeHttpRequestWithMethod( + "POST", s"/obp/v7.0.0/management/message-outbox/${sticky.id.get}/retry", headers) + retryCode shouldBe 200 + (retryJson \ "status") shouldBe JString("PENDING") + (retryJson \ "attempts") shouldBe JInt(0) + + val pendingRow = seedOutboxRow(code.messageoutbox.MessageOutbox.STATUS_PENDING) + val (notStickyCode, notStickyJson, _) = makeHttpRequestWithMethod( + "POST", s"/obp/v7.0.0/management/message-outbox/${pendingRow.id.get}/retry", headers) + notStickyCode shouldBe 400 + messageOf(notStickyJson) should include(MessageOutboxRowNotSticky) + + val (notFoundCode, notFoundJson, _) = makeHttpRequestWithMethod( + "POST", "/obp/v7.0.0/management/message-outbox/999999999/retry", headers) + notFoundCode shouldBe 404 + messageOf(notFoundJson) should include(MessageOutboxRowNotFound) + } + } + // ─── scheduler job-locks ────────────────────────────────────────────────────── /** Remove every jobscheduler lock row so a scenario starts from a clean table. */ @@ -1627,10 +1890,9 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { // ─── OPEN_CORRIDOR_PROMISE transaction request ──────────────────────────── /** Full, valid OPEN_CORRIDOR_PROMISE create body. The beneficiary uses OBP routing - * to a real account on the second test bank, so getBankAccountFromCounterparty - * resolves a real destination and the mapped payment path can post both legs - * (a phantom external destination would fail the credit leg: the test DB has no - * settlement accounts to fall back to). */ + * to the second test bank. Only the far BANK must exist — the beneficiary + * account is not resolved (it lives in the far bank's CBS); the default here + * happens to be a real account only for convenience. */ private def openCorridorPromiseBody( currency: String, originatorName: String = "Alice Sender", @@ -1786,6 +2048,50 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { case _ => fail("Expected JSON object") } } + + scenario("Return 201 when the beneficiary account exists only at the far bank's CBS (not in OBP-API)", Http4s700RoutesTag) { + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + // An account id no OBP bank account carries: customer accounts live in the + // far bank's CBS, and the beneficiary Bank Node validates them at credit + // time — OBP-API must not require them to exist here. + val cbsOnlyAccountId = s"cbs-only-${APIUtil.generateUUID().take(8)}" + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + openCorridorPromisePath(testBankId1.value, testAccountId0.value), + openCorridorPromiseBody(acctCurrency, beneficiaryAccountId = cbsOnlyAccountId), headers) + statusCode shouldBe 201 + val trId = json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("status") shouldBe Some(JString("PENDING")) + map.get("id") match { + case Some(JString(id)) if id.nonEmpty => id + case _ => fail("id should be a non-empty string") + } + case _ => fail("Expected JSON object") + } + // The far bank id is stamped on the row — the settle-pair netting selects + // promises by mTo_BankId, so a CBS-only beneficiary must still net. + val row = code.transactionrequests.MappedTransactionRequest + .find(By(code.transactionrequests.MappedTransactionRequest.mTransactionRequestId, trId)) + .openOrThrowException("promise TR row should exist") + row.mTo_BankId.get shouldBe testBankId2.value + row.mTo_AccountId.get shouldBe cbsOnlyAccountId + } + + scenario("Return 404 BankNotFound when the beneficiary bank is not registered", Http4s700RoutesTag) { + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + openCorridorPromisePath(testBankId1.value, testAccountId0.value), + openCorridorPromiseBody(acctCurrency, beneficiaryBankId = s"no-such-bank-${APIUtil.generateUUID().take(8)}"), headers) + statusCode shouldBe 404 + messageOf(json) should include("OBP-30001") + } } // ─── OPEN_CORRIDOR promise report-back (salt relay intake) ──────────────── @@ -1992,17 +2298,16 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { // ─── OPEN_CORRIDOR broker registry + settle-pair ────────────────────────── private def brokerPath(bankId: String): String = - s"/obp/v7.0.0/banks/$bankId/open-corridor/broker" + s"/obp/v7.0.0/banks/$bankId/amqp-broker" - private def brokerBody(settlementAddress: String = "addr_test_creditor"): String = + private def brokerBody(): String = s"""{ | "host": "rabbitmq.bank.example.com", | "port": 5672, | "virtual_host": "/bank.test", | "username": "obp-api", | "password": "secret-not-echoed", - | "use_ssl": false, - | "settlement_address": "$settlementAddress" + | "use_ssl": false |}""".stripMargin private def ensureSettlementAccounts(bankId: String, currency: String): Unit = { @@ -2014,6 +2319,23 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + /** The bank's settlement address is the CARDANO routing on its incoming + * settlement account; empty address removes the routing. */ + private def setIncomingSettlementCardanoAddress(bankId: String, address: String): Unit = { + val existing = BankAccountRouting.find( + By(BankAccountRouting.BankId, bankId), + By(BankAccountRouting.AccountId, code.api.Constant.INCOMING_SETTLEMENT_ACCOUNT_ID), + By(BankAccountRouting.AccountRoutingScheme, "CARDANO")) + if (address.isEmpty) existing.foreach(_.delete_!) + else existing + .getOrElse(BankAccountRouting.create + .BankId(bankId) + .AccountId(code.api.Constant.INCOMING_SETTLEMENT_ACCOUNT_ID) + .AccountRoutingScheme("CARDANO")) + .AccountRoutingAddress(address) + .saveMe() + } + private def promiseStatus(transactionRequestId: String): String = code.transactionrequests.TransactionRequests.transactionRequestProvider.vend .getTransactionRequestFromProvider(com.openbankproject.commons.model.TransactionRequestId(transactionRequestId)) @@ -2036,15 +2358,15 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { statusCode shouldBe 401 } - scenario("Return 403 without CanConfigureOpenCorridorBroker", Http4s700RoutesTag) { + scenario("Return 403 without CanConfigureAmqpBankBroker", Http4s700RoutesTag) { val headers = Map("DirectLogin" -> s"token=${token2.value}") val (statusCode, json, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody(), headers) statusCode shouldBe 403 - messageOf(json) should include("CanConfigureOpenCorridorBroker") + messageOf(json) should include("CanConfigureAmqpBankBroker") } scenario("Broker registry CRUD round-trip; password is never echoed", Http4s700RoutesTag) { - addEntitlement("", resourceUser1.userId, canConfigureOpenCorridorBroker.toString) + addEntitlement("", resourceUser1.userId, canConfigureAmqpBankBroker.toString) val headers = Map("DirectLogin" -> s"token=${token1.value}") When("DELETE clears any previous registration (idempotent)") @@ -2054,7 +2376,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { Then("GET without a registration is refused") val (missingCode, missingJson, _) = makeHttpRequest(brokerPath(testBankId1.value), headers) missingCode shouldBe 400 - messageOf(missingJson) should include(OpenCorridorBankBrokerNotConfigured) + messageOf(missingJson) should include(AmqpBankBrokerNotConfigured) When("PUT registers the broker") val (putCode, putJson, _) = makeHttpRequestWithBody("PUT", brokerPath(testBankId1.value), brokerBody(), headers) @@ -2064,7 +2386,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val map = toFieldMap(fields) map.get("bank_id") shouldBe Some(JString(testBankId1.value)) map.get("host") shouldBe Some(JString("rabbitmq.bank.example.com")) - map.get("settlement_address") shouldBe Some(JString("addr_test_creditor")) + map.keys should not contain "settlement_address" map.keys should not contain "password" case _ => fail("Expected JSON object") } @@ -2088,41 +2410,55 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - feature("Http4s700 settleOpenCorridorPair endpoint (bilateral netting)") { + feature("Http4s700 createOpenCorridorSettlement endpoint (bilateral netting)") { + + def settlementsPath(bankId: String): String = + s"/obp/v7.0.0/banks/$bankId/open-corridor/settlements" - def settleBody(currency: String): String = - s"""{"bank_id_a": "${testBankId1.value}", "bank_id_b": "${testBankId2.value}", "currency": "$currency"}""" + def settleBody(currency: String, otherBankId: String = testBankId2.value): String = + s"""{"other_bank_id": "$otherBankId", "currency": "$currency"}""" def registerBrokers(): Unit = { - code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false, "addr_test_bank_a") - code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false, "addr_test_bank_b") + code.amqpbroker.AmqpBankBroker.upsert( + testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false) + code.amqpbroker.AmqpBankBroker.upsert( + testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false) + setIncomingSettlementCardanoAddress(testBankId1.value, "addr_test_bank_a") + setIncomingSettlementCardanoAddress(testBankId2.value, "addr_test_bank_b") } scenario("Reject unauthenticated POST", Http4s700RoutesTag) { - val (statusCode, _, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR")) + val (statusCode, _, _) = makeHttpRequestWithBody("POST", settlementsPath(testBankId1.value), settleBody("EUR")) statusCode shouldBe 401 } scenario("Return 403 without CanSettleOpenCorridor", Http4s700RoutesTag) { val headers = Map("DirectLogin" -> s"token=${token2.value}") - val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR"), headers) + val (statusCode, json, _) = makeHttpRequestWithBody("POST", settlementsPath(testBankId1.value), settleBody("EUR"), headers) + statusCode shouldBe 403 + messageOf(json) should include("CanSettleOpenCorridor") + } + + scenario("The role is bank-scoped: a grant at another bank does not authorize this bank's URL", Http4s700RoutesTag) { + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + settlementsPath(testBankId2.value), settleBody("EUR", otherBankId = testBankId1.value), headers) statusCode shouldBe 403 messageOf(json) should include("CanSettleOpenCorridor") } scenario("Return 400 when open_corridor_enabled is not set", Http4s700RoutesTag) { - addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/open-corridor/settle", settleBody("EUR"), headers) + val (statusCode, json, _) = makeHttpRequestWithBody("POST", settlementsPath(testBankId1.value), settleBody("EUR"), headers) statusCode shouldBe 400 messageOf(json) should include(OpenCorridorDisabled) } scenario("Net a pair: N promises collapse into one settlement, evidence relayed via outbox", Http4s700RoutesTag) { setPropsValues("open_corridor_enabled" -> "true") - addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) val headers = Map("DirectLogin" -> s"token=${token1.value}") @@ -2152,22 +2488,42 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val promise1 = createPendingPromise(amount = "5.00") val promise2 = createPendingPromise(amount = "2.00") val promise3 = createPendingPromise(testBankId2, testAccountId1, testBankId1.value, testAccountId0.value, "3.00") + val promise4NoEvidence = createPendingPromise(amount = "9.00") assertPromiseRow(promise1, testBankId1.value, testBankId2.value) assertPromiseRow(promise3, testBankId2.value, testBankId1.value) - And("Promise 1 has its on-chain evidence attached (report-back)") - val (evidenceCode, _, _) = makeHttpRequestWithBody("POST", - promiseEvidencePath(testBankId1.value, testAccountId0.value, promise1), - promiseEvidenceBody(), headers) - evidenceCode shouldBe 201 - - When("Settle is triggered while the creditor bank has no settlement address") - code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false, "addr_test_bank_a") - code.bankconnectors.opencorridor.OpenCorridorBankBroker.upsert( - testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false, "") + And("Promises 1-3 have their on-chain evidence attached (report-back); promise 4 has none") + addEntitlement(testBankId2.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + List( + (testBankId1.value, testAccountId0.value, promise1), + (testBankId1.value, testAccountId0.value, promise2), + (testBankId2.value, testAccountId1.value, promise3) + ).foreach { case (bankId, accountId, promiseId) => + val (evidenceCode, _, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(bankId, accountId, promiseId), promiseEvidenceBody(), headers) + evidenceCode shouldBe 201 + } + + Then("Each attach immediately enqueued the beneficiary's credit notification with the evidence") + val promise1CreditRows = code.messageoutbox.MessageOutbox.bySubjectId(promise1) + promise1CreditRows.map(_.operationName) shouldBe List("obp_credit_notification") + promise1CreditRows.head.targetId shouldBe testBankId2.value + val promise1Credit = parse(promise1CreditRows.head.payloadJson) + (promise1Credit \ "promise_salt") shouldBe JString("5f4dcc3b5aa765d61d8327deb882cf99") + (promise1Credit \ "promise_commitment") shouldBe JString("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1") + code.messageoutbox.MessageOutbox.bySubjectId(promise3) + .map(_.targetId) shouldBe List(testBankId1.value) + code.messageoutbox.MessageOutbox.bySubjectId(promise4NoEvidence) shouldBe Nil + + When("Settle is triggered while the creditor bank's incoming settlement account has no CARDANO routing") + code.amqpbroker.AmqpBankBroker.upsert( + testBankId1.value, "localhost", 5672, "/bank.a", "u", "p", false) + code.amqpbroker.AmqpBankBroker.upsert( + testBankId2.value, "localhost", 5672, "/bank.b", "u", "p", false) + setIncomingSettlementCardanoAddress(testBankId1.value, "addr_test_bank_a") + setIncomingSettlementCardanoAddress(testBankId2.value, "") val (noAddressCode, noAddressJson, _) = makeHttpRequestWithBody("POST", - "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + settlementsPath(testBankId1.value), settleBody(currency), headers) noAddressCode shouldBe 400 messageOf(noAddressJson) should include(OpenCorridorSettlementAddressMissing) promiseStatus(promise1) shouldBe "PENDING" @@ -2175,7 +2531,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { Then("With both brokers fully registered the settle succeeds") registerBrokers() val (statusCode, json, _) = makeHttpRequestWithBody("POST", - "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + settlementsPath(testBankId1.value), settleBody(currency), headers) statusCode shouldBe 201 val (settlementId, transactionId) = json match { case JObject(fields) => @@ -2183,7 +2539,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { map.get("net_amount") shouldBe Some(JString("4.00")) map.get("debtor_bank_id") shouldBe Some(JString(testBankId1.value)) map.get("creditor_bank_id") shouldBe Some(JString(testBankId2.value)) - map.get("credit_notifications_enqueued") shouldBe Some(JInt(3)) + map.get("settlement_advices_enqueued") shouldBe Some(JInt(2)) map.get("settlement_instructions_enqueued") shouldBe Some(JInt(1)) map.get("covered_transaction_request_ids") match { case Some(JArray(ids)) => ids.collect { case JString(id) => id }.toSet shouldBe Set(promise1, promise2, promise3) @@ -2197,37 +2553,41 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { case _ => fail("Expected JSON object") } - And("Every covered promise is COMPLETED with the discharge linkage attributes") + And("Every covered promise is COMPLETED with the discharge linkage attributes; the unevidenced one stays PENDING") List(promise1, promise2, promise3).foreach { promiseId => promiseStatus(promiseId) shouldBe "COMPLETED" val attributes = promiseAttributes(promiseId) attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionIds) shouldBe Some(transactionId) attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionRequestId) shouldBe Some(settlementId) } - - And("The outbox holds 3 credit notifications + 1 settlement instruction, evidence relayed verbatim") - val outboxRows = code.bankconnectors.opencorridor.OpenCorridorOutbox.bySettlementId(settlementId) - outboxRows.size shouldBe 4 - val creditRows = outboxRows.filter(_.messageId == "obp_credit_notification") - creditRows.map(_.targetBankId).sorted shouldBe List(testBankId1.value, testBankId2.value, testBankId2.value).sorted - val instructionRow = outboxRows.filter(_.messageId == "obp_settlement_instruction") match { + promiseStatus(promise4NoEvidence) shouldBe "PENDING" + + And("The outbox holds 2 settlement advices + 1 settlement instruction for this settlement") + val outboxRows = code.messageoutbox.MessageOutbox.bySubjectId(settlementId) + outboxRows.size shouldBe 3 + val adviceRows = outboxRows.filter(_.operationName == "obp_settlement_advice") + adviceRows.map(_.targetId).sorted shouldBe List(testBankId1.value, testBankId2.value).sorted + val bank2Advice = adviceRows.find(_.targetId == testBankId2.value) + .map(row => parse(row.payloadJson)) + .getOrElse(fail("bank2's settlement advice should be enqueued")) + (bank2Advice \ "settlement_id") shouldBe JString(settlementId) + (bank2Advice \ "covered_transaction_request_ids") match { + case JArray(ids) => ids.collect { case JString(id) => id }.toSet shouldBe Set(promise1, promise2) + case _ => fail("covered_transaction_request_ids should be an array") + } + val instructionRow = outboxRows.filter(_.operationName == "obp_settlement_instruction") match { case row :: Nil => row case other => fail(s"Expected exactly one settlement instruction row, got ${other.size}") } - instructionRow.targetBankId shouldBe testBankId1.value + instructionRow.targetId shouldBe testBankId1.value val instructionJson = parse(instructionRow.payloadJson) (instructionJson \ "amount") shouldBe JString("4.00") (instructionJson \ "creditor_address") shouldBe JString("addr_test_bank_b") (instructionJson \ "idempotency_key") shouldBe JString(settlementId) - val promise1Credit = creditRows.map(row => parse(row.payloadJson)) - .find(payload => (payload \ "transaction_request_id") == JString(promise1)) - .getOrElse(fail("promise1's credit notification should be enqueued")) - (promise1Credit \ "promise_salt") shouldBe JString("5f4dcc3b5aa765d61d8327deb882cf99") - (promise1Credit \ "promise_commitment") shouldBe JString("9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430ba0d1") And("A re-trigger with nothing pending is a no-op") val (noopCode, noopJson, _) = makeHttpRequestWithBody("POST", - "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + settlementsPath(testBankId1.value), settleBody(currency), headers) noopCode shouldBe 201 noopJson match { case JObject(fields) => @@ -2236,11 +2596,51 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { map.get("settlement_id") shouldBe Some(JString("")) case _ => fail("Expected JSON object") } + + And("GET on the settlement resource shows ledger COMPLETED, rail INSTRUCTED (relay has not run)") + val (getCode, getJson, _) = makeHttpRequest(s"${settlementsPath(testBankId1.value)}/$settlementId", headers) + getCode shouldBe 200 + getJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("settlement_id") shouldBe Some(JString(settlementId)) + map.get("debtor_bank_id") shouldBe Some(JString(testBankId1.value)) + map.get("creditor_bank_id") shouldBe Some(JString(testBankId2.value)) + map.get("net_amount") shouldBe Some(JString("4.00")) + map.get("transaction_id") shouldBe Some(JString(transactionId)) + map.get("ledger_status") shouldBe Some(JString("COMPLETED")) + map.get("settlement_status") shouldBe Some(JString("INSTRUCTED")) + map.get("covered_transaction_request_ids") match { + case Some(JArray(ids)) => ids.collect { case JString(id) => id }.toSet shouldBe Set(promise1, promise2, promise3) + case _ => fail("covered_transaction_request_ids should be an array") + } + map.get("messages") match { + // 2 settlement advices + 1 settlement instruction (credit + // notifications correlate to their promise ids, not the settlement). + case Some(JArray(messages)) => messages.size shouldBe 3 + case _ => fail("messages should be an array") + } + case _ => fail("Expected JSON object") + } + + And("The creditor bank can read the same settlement from its own URL") + addEntitlement(testBankId2.value, resourceUser1.userId, canSettleOpenCorridor.toString) + val (creditorGetCode, creditorGetJson, _) = makeHttpRequest(s"${settlementsPath(testBankId2.value)}/$settlementId", headers) + creditorGetCode shouldBe 200 + creditorGetJson match { + case JObject(fields) => toFieldMap(fields).get("settlement_id") shouldBe Some(JString(settlementId)) + case _ => fail("Expected JSON object") + } + + And("An unknown settlement id is a 404") + val (notFoundCode, notFoundJson, _) = makeHttpRequest(s"${settlementsPath(testBankId1.value)}/does-not-exist", headers) + notFoundCode shouldBe 404 + messageOf(notFoundJson) should include(OpenCorridorSettlementNotFound) } scenario("Exactly offsetting flows discharge at net zero with no Transaction", Http4s700RoutesTag) { setPropsValues("open_corridor_enabled" -> "true") - addEntitlement("", resourceUser1.userId, canSettleOpenCorridor.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) val headers = Map("DirectLogin" -> s"token=${token1.value}") val currency = code.bankconnectors.Connector.connector.vend .getBankAccountLegacy(testBankId1, testAccountId0, None) @@ -2251,16 +2651,26 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val promiseAToB = createPendingPromise(amount = "3.00") val promiseBToA = createPendingPromise(testBankId2, testAccountId1, testBankId1.value, testAccountId0.value, "3.00") + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + addEntitlement(testBankId2.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + List( + (testBankId1.value, testAccountId0.value, promiseAToB), + (testBankId2.value, testAccountId1.value, promiseBToA) + ).foreach { case (bankId, accountId, promiseId) => + val (evidenceCode, _, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(bankId, accountId, promiseId), promiseEvidenceBody(), headers) + evidenceCode shouldBe 201 + } val (statusCode, json, _) = makeHttpRequestWithBody("POST", - "/obp/v7.0.0/open-corridor/settle", settleBody(currency), headers) + settlementsPath(testBankId1.value), settleBody(currency), headers) statusCode shouldBe 201 val settlementId = json match { case JObject(fields) => val map = toFieldMap(fields) map.get("net_amount") shouldBe Some(JString("0.00")) map.get("transaction_id") shouldBe Some(JString("")) - map.get("credit_notifications_enqueued") shouldBe Some(JInt(2)) + map.get("settlement_advices_enqueued") shouldBe Some(JInt(2)) map.get("settlement_instructions_enqueued") shouldBe Some(JInt(0)) map.get("settlement_id").collect { case JString(s) => s }.getOrElse(fail("settlement_id missing")) case _ => fail("Expected JSON object") @@ -2272,8 +2682,20 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionRequestId) shouldBe Some(settlementId) attributes.get(code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionIds) shouldBe None } - code.bankconnectors.opencorridor.OpenCorridorOutbox.bySettlementId(settlementId) - .filter(_.messageId == "obp_settlement_instruction") shouldBe Nil + code.messageoutbox.MessageOutbox.bySubjectId(settlementId) + .filter(_.operationName == "obp_settlement_instruction") shouldBe Nil + + And("GET reports NET_ZERO: nothing to move on any rail") + val (getCode, getJson, _) = makeHttpRequest(s"${settlementsPath(testBankId1.value)}/$settlementId", headers) + getCode shouldBe 200 + getJson match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("settlement_status") shouldBe Some(JString("NET_ZERO")) + map.get("net_amount") shouldBe Some(JString("0.00")) + map.get("transaction_id") shouldBe Some(JString("")) + case _ => fail("Expected JSON object") + } } } diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala b/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala index 673a43fb49..1b5d0e2019 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/dto/OpenCorridorInterfaceC.scala @@ -85,6 +85,30 @@ case class OutBoundOpenCorridorSettlementInstruction( idempotency_key: String ) +/** + * `obp_settlement_advice` — published to each BENEFICIARY bank's vhost after a + * netted settle: "the promises you already paid out against are now covered". + * Purely reconciliatory — no money moves on this message (the debtor's + * `obp_settlement_instruction` does that). One advice per beneficiary bank, + * listing exactly the covered promise ids where that bank was the creditor. + * Credit notifications themselves travel at promise-report-back time, not here. + */ +case class OutBoundOpenCorridorSettlementAdvice( + settlement_id: String, + currency: String, + net_amount: String, + debtor_bank_id: String, + creditor_bank_id: String, + covered_transaction_request_ids: List[String], + idempotency_key: String +) + +/** Reply `data` for `obp_settlement_advice`: the bank marked the listed credits settled. */ +case class InBoundOpenCorridorSettlementAdviceData( + settlement_id: String, + acknowledged: Boolean +) + /** * Reply `data` for `obp_settlement_instruction`. `status` is one of: * - SETTLING — an attempt is in flight (or crashed mid-flight; the node will