From 2ead5e25fe97c99091f16e52b1d33f1903244bee Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 7 Nov 2026 14:21:07 +0100 Subject: [PATCH 1/2] feat: park OBP-BANK-NODE-SETTLEMENT-BELOW-MINIMUM replies as STICKY A Bank Node answers a settlement instruction whose net sizes to less than one Cardano output with OBP-BANK-NODE-SETTLEMENT-BELOW-MINIMUM. The amount is fixed by the instruction, so every resend gets the same answer: the relay now parks the row STICKY at once for an operator instead of retrying it until the attempt limit. The classification is a small package-private function with its own test. --- .../messageoutbox/MessageOutboxRelay.scala | 13 +++++-- .../OpenCorridorReplyClassificationTest.scala | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/test/scala/code/messageoutbox/OpenCorridorReplyClassificationTest.scala diff --git a/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala b/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala index b0b90003d5..5bf36c948d 100644 --- a/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala +++ b/obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala @@ -62,7 +62,7 @@ import scala.concurrent.duration._ * 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 / CBS-REJECTED → STICKY: retry cannot fix it; + * SETTLEMENT-NOT-CONFIGURED / SETTLEMENT-BELOW-MINIMUM / CBS-REJECTED → STICKY: retry cannot fix it; * it needs an operator (GET /management/message-outbox + /retry). The * error and full reply are recorded — never swallowed. CBS-REJECTED is * the bank's CBS refusing the credit itself (unknown account, name @@ -115,9 +115,16 @@ object MessageOutboxRelay extends MdcLoggable { "OBP-BANK-NODE-BAD-MESSAGE", "OBP-BANK-NODE-NOT-IMPLEMENTED", "OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED", - "OBP-BANK-NODE-CBS-REJECTED" + "OBP-BANK-NODE-CBS-REJECTED", + // The net sizes to less than one rail output may carry; the amount is + // fixed by the instruction, so every resend is refused the same way. + "OBP-BANK-NODE-SETTLEMENT-BELOW-MINIMUM" ) + /** Whether a Bank Node error reply means resending cannot help. */ + private[messageoutbox] def isStickyOpenCorridorError(errorCode: String): Boolean = + openCorridorStickyErrorCodes.exists(errorCode.startsWith) + def start(intervalSeconds: Long): Unit = { implicit val executor = ObpActorSystem.localActorSystem.dispatcher ObpActorSystem.localActorSystem.scheduler.schedule( @@ -216,7 +223,7 @@ object MessageOutboxRelay extends MdcLoggable { 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)) { + } else if (isStickyOpenCorridorError(errorCode)) { 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} " + diff --git a/obp-api/src/test/scala/code/messageoutbox/OpenCorridorReplyClassificationTest.scala b/obp-api/src/test/scala/code/messageoutbox/OpenCorridorReplyClassificationTest.scala new file mode 100644 index 0000000000..51c6855bb4 --- /dev/null +++ b/obp-api/src/test/scala/code/messageoutbox/OpenCorridorReplyClassificationTest.scala @@ -0,0 +1,34 @@ +package code.messageoutbox + +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +/** + * This class tests which Bank Node error replies the outbox relay parks as STICKY at once, because + * resending the same message cannot change the answer, and which it keeps retrying. + * + * Everything here is a pure function call: no broker, no database. + */ +class OpenCorridorReplyClassificationTest extends FeatureSpec with Matchers with GivenWhenThen { + + feature("Bank Node error replies") { + + scenario("A settlement below the rail's minimum output is final, so it goes STICKY at once") { + MessageOutboxRelay.isStickyOpenCorridorError("OBP-BANK-NODE-SETTLEMENT-BELOW-MINIMUM") shouldBe true + } + + scenario("Refusals that resending cannot fix are STICKY") { + List( + "OBP-BANK-NODE-COMMITMENT-MISMATCH", + "OBP-BANK-NODE-BAD-MESSAGE", + "OBP-BANK-NODE-NOT-IMPLEMENTED", + "OBP-BANK-NODE-SETTLEMENT-NOT-CONFIGURED", + "OBP-BANK-NODE-CBS-REJECTED" + ).foreach(code => MessageOutboxRelay.isStickyOpenCorridorError(code) shouldBe true) + } + + scenario("Transient failures are retried") { + MessageOutboxRelay.isStickyOpenCorridorError("OBP-BANK-NODE-SETTLEMENT-FAILED") shouldBe false + MessageOutboxRelay.isStickyOpenCorridorError("OBP-BANK-NODE-CBS-DELIVERY-FAILED") shouldBe false + } + } +} From 92c8256ae1a5ec4bbecbb4e3d9eb2be6611eb5b4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 7 Nov 2026 14:21:08 +0100 Subject: [PATCH 2/2] feat: carry Open Corridor nets below a per-currency minimum to a later settle open_corridor.min_settlement_amount. sets the smallest net that is settled in a currency. When a pair's net is above zero but below it, POST .../open-corridor/settlements is refused with OBP-40068 before anything is written: the promises stay PENDING and are covered together by a later settle once their net reaches the minimum. Unset means no minimum, as before. Documented in sample.props.template and on the endpoint. --- .../resources/props/sample.props.template | 7 +++ .../scala/code/api/util/ErrorMessages.scala | 1 + .../scala/code/api/v7_0_0/Http4s700.scala | 4 +- .../opencorridor/OpenCorridorSettlement.scala | 18 ++++++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 62 ++++++++++++++++++- 5 files changed, 90 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 5ae6962c19..f29e66859c 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1432,6 +1432,13 @@ featured_apis=elasticSearchWarehouseV300 # between attempts doubles up to 10 minutes, so the default gives up after roughly a day. # open_corridor.outbox_max_attempts=144 +# The smallest net that is settled in a currency, in its major units. A settle whose net is below it +# nets nothing: the promises stay PENDING and are settled together once the pair's net reaches it +# (OBP-40068). Set it at or above what the rail can pay in one transfer; a Cardano output must carry +# at least 1 ADA, so a KES corridor needs a minimum above 1 ADA in KES, with room for the rate to +# move. Unset means no minimum. +# open_corridor.min_settlement_amount.KES=50 + # -- Scopes ----------------------------------------------------- # Scopes can be used to limit the APIs a Consumer can call. 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 75ad25007c..234613cac4 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -1107,6 +1107,7 @@ object ErrorMessages { 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 OpenCorridorNetBelowMinimum = "OBP-40068: The net of this Open Corridor pair is below the minimum settlement amount for its currency (open_corridor.min_settlement_amount.). Nothing was netted: the promises stay PENDING and are settled together once their net reaches the minimum." 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." // Renumbered from OBP-40058 on merge: develop had meanwhile taken 40058 through 40061 for Open 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 90157ceaf3..dcda1ece31 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 @@ -5382,6 +5382,8 @@ object Http4s700 { | |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. | + |When `open_corridor.min_settlement_amount.` is set and the net is above zero but below it, the call is refused with OBP-40068 and nothing is netted: the promises stay PENDING and are covered by a later trigger once the pair's net reaches the minimum. Set it at or above what the settlement rail can pay in one transfer (a Cardano output must carry at least 1 ADA). + | |`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 and the `CanSettleOpenCorridor` role at BANK_ID. @@ -5405,7 +5407,7 @@ object Http4s700 { settlement_instructions_enqueued = 1 ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, OpenCorridorDisabled, InvalidJsonFormat, InvalidJsonValue, - OpenCorridorSameBankNotAllowed, + OpenCorridorSameBankNotAllowed, OpenCorridorNetBelowMinimum, $BankNotFound, AmqpBankBrokerNotConfigured, OpenCorridorSettlementAddressMissing, UnknownError), apiTagTransactionRequest :: Nil, Some(List(canSettleOpenCorridor)), 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 bb990a3075..b2f780e871 100644 --- a/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala +++ b/obp-api/src/main/scala/code/bankconnectors/opencorridor/OpenCorridorSettlement.scala @@ -173,6 +173,17 @@ object OpenCorridorSettlement extends MdcLoggable { val netAbs = net.abs for { + // A net the rail cannot pay in one transfer (Cardano refuses an output under + // its min-UTxO) is not netted at all: the promises stay PENDING and are + // covered by a later settle once the pair's net has grown. A net of zero + // moves nothing on the rail, so it always discharges. + _ <- minimumSettlementAmount(currency) match { + case Some(minimum) if netAbs > 0 && netAbs < minimum => + Helper.booleanToFuture( + s"$OpenCorridorNetBelowMinimum Net: ${netAbs.bigDecimal.toPlainString} $currency, minimum: ${minimum.bigDecimal.toPlainString} $currency.", + cc = callContext)(false) + case _ => Future.successful(Full(())) + } // 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. @@ -347,6 +358,13 @@ object OpenCorridorSettlement extends MdcLoggable { /** True once the promise's on-chain evidence was attached (report-back done) — * the precondition for the beneficiary having been notified and paid out. */ + /** The smallest net worth settling in this currency, from + * `open_corridor.min_settlement_amount.`; None when unset or not a number. */ + def minimumSettlementAmount(currency: String): Option[BigDecimal] = + code.api.util.APIUtil.getPropsValue(s"open_corridor.min_settlement_amount.${currency.trim.toUpperCase}") + .flatMap(v => scala.util.Try(BigDecimal(v.trim)).toOption) + .filter(_ > 0) + private def hasPromiseEvidence(trId: String): Boolean = TransactionRequestAttribute.find( By(TransactionRequestAttribute.Name, OpenCorridorProcessor.PromiseAttributeCommitment), 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 136647e692..0ebe0028b8 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 @@ -35,7 +35,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canAttachOpenCorridorPromise, canConfigureAmqpBankBroker, canGetMessageOutbox, canRetryMessageOutbox, canSettleOpenCorridor, canCreateAccount, canCreateEntitlementAtAnyBank, canCreateTenant, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteAccountNotificationWebhookAtOneBank, canDeleteEntitlementAtAnyBank, canDeleteTenant, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canDeleteSystemAccountNotificationWebhook, canUpdateSystemView, canGetAccountAccessTrace, canGetAccountDirectoryAtOneBank, canGetTenant, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateTenant, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, DuplicateUsername, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidTenantIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, UserFilterParametersNotSupported, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, NotificationWebhookNotFound, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, TenantAlreadyExists, TenantNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ErrorMessages.{AccountIdAlreadyExists, AuthenticatedUserIsRequired, BankNotFound, DuplicateUsername, EntitlementAlreadyExists, InvalidAccountRoutings, InvalidJsonFormat, InvalidJsonValue, InvalidTenantIdFormat, InvalidPhoneNumber, InvalidRoutingSchemeName, UserFilterParametersNotSupported, InvalidTransactionRequestId, MessageOutboxRowNotFound, MessageOutboxRowNotSticky, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, NotificationWebhookNotFound, AmqpBankBrokerNotConfigured, OpenCorridorDisabled, OpenCorridorNetBelowMinimum, OpenCorridorPromiseEvidenceConflict, OpenCorridorPromiseNotPending, OpenCorridorPromiseTypeMismatch, OpenCorridorSameBankNotAllowed, OpenCorridorSettlementAddressMissing, OpenCorridorSettlementNotFound, TenantAlreadyExists, TenantNotFound, 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 @@ -3840,6 +3840,66 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { case _ => fail("Expected JSON object") } } + + scenario("A net below the currency's minimum settlement amount is not settled; its promises carry over to a later settle", Http4s700RoutesTag) { + setPropsValues("open_corridor_enabled" -> "true") + addEntitlement(testBankId1.value, resourceUser1.userId, canSettleOpenCorridor.toString) + addEntitlement(testBankId1.value, resourceUser1.userId, canAttachOpenCorridorPromise.toString) + 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") + ensureSettlementAccounts(testBankId1.value, currency) + ensureSettlementAccounts(testBankId2.value, currency) + registerBrokers() + setPropsValues("open_corridor_enabled" -> "true", s"open_corridor.min_settlement_amount.$currency" -> "10.00") + + def evidencedPromise(amount: String): String = { + val promiseId = createPendingPromise(amount = amount) + val (evidenceCode, _, _) = makeHttpRequestWithBody("POST", + promiseEvidencePath(testBankId1.value, testAccountId0.value, promiseId), promiseEvidenceBody(), headers) + evidenceCode shouldBe 201 + promiseId + } + + Given("Two evidenced promises A→B netting to 7.00, under a minimum of 10.00") + val small1 = evidencedPromise("4.00") + val small2 = evidencedPromise("3.00") + + When("Settle is triggered") + val (belowCode, belowJson, _) = makeHttpRequestWithBody("POST", + settlementsPath(testBankId1.value), settleBody(currency), headers) + + Then("It is refused, and nothing is netted: the promises stay PENDING and no message is queued") + belowCode shouldBe 400 + messageOf(belowJson) should include(OpenCorridorNetBelowMinimum) + messageOf(belowJson) should include(s"7.00 $currency") + messageOf(belowJson) should include(s"10.00 $currency") + List(small1, small2).foreach { promiseId => + promiseStatus(promiseId) shouldBe "PENDING" + promiseAttributes(promiseId).get( + code.bankconnectors.opencorridor.OpenCorridorSettlement.AttrSettledByTransactionRequestId) shouldBe None + } + + When("A further promise brings the net to 12.00 and settle is triggered again") + val more = evidencedPromise("5.00") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", + settlementsPath(testBankId1.value), settleBody(currency), headers) + + Then("All three are settled together") + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("net_amount") shouldBe Some(JString("12.00")) + map.get("covered_transaction_request_ids") match { + case Some(JArray(ids)) => ids.collect { case JString(id) => id }.toSet shouldBe Set(small1, small2, more) + case _ => fail("covered_transaction_request_ids should be an array") + } + case _ => fail("Expected JSON object") + } + List(small1, small2, more).foreach(promiseStatus(_) shouldBe "COMPLETED") + } } // ─── BULK transaction request ─────────────────────────────────────────────