Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.<CURRENCY>). 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
Expand Down
4 changes: 3 additions & 1 deletion obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.<CURRENCY>` 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.
Expand All @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.<CURRENCY>`; 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),
Expand Down
13 changes: 10 additions & 3 deletions obp-api/src/main/scala/code/messageoutbox/MessageOutboxRelay.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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} " +
Expand Down
62 changes: 61 additions & 1 deletion obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading