Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
deac263
Fix: Make sure that Get Customer Addresses only returns addresses of …
simonredfern Oct 19, 2026
86a68d7
Fix: Make sure that Get Tax Residences only returns the tax residence…
simonredfern Oct 19, 2026
8a17c2d
Fix: Make sure that Create Customer Message v1.4.0 needs CanCreateCus…
simonredfern Oct 19, 2026
53d650f
Fix: Make sure that Create Transaction Type only creates or updates t…
simonredfern Oct 19, 2026
2ca0b1f
Fix: Make sure that Get and Cancel Market Order only find an order of…
simonredfern Oct 19, 2026
08231a0
Fix: Make sure that Delete Customer Address only deletes an address o…
simonredfern Oct 19, 2026
9f8649a
Fix: Make sure that Get Account Application by Id is only answered to…
simonredfern Oct 19, 2026
970b0bf
Fix: Make sure that a v2.1.0 transaction request challenge is only an…
simonredfern Oct 19, 2026
c2c647f
Fix: Make sure that My Correlated Entities does not show other users'…
simonredfern Oct 19, 2026
2d1650b
Fix: Make sure that Revoke/Grant User access to Views only grants vie…
simonredfern Oct 19, 2026
dd6b5fe
Fix: Make sure that Create My Consumer only creates an enabled Consum…
simonredfern Oct 19, 2026
84f3ea8
Fix: Make sure that the IBAN checker returns the bank's own credit tr…
simonredfern Oct 19, 2026
3afb10c
Fix: Make sure that Get Products at a bank in v6.0.0 returns each pro…
simonredfern Oct 19, 2026
d8819bd
Fix: Make sure that Update View in v1.2.1, v2.2.0 and v3.0.0 only giv…
simonredfern Oct 19, 2026
6949097
Fix: Make sure that Get Routing Schemes filtered by rail pages over o…
simonredfern Oct 19, 2026
3e10aa9
Fix: Make sure that a created standing order stores the when detail t…
simonredfern Oct 19, 2026
b21c7b7
Fix: Make sure that Create Customer Address v3.1.0 stores the posted …
simonredfern Oct 19, 2026
5d906df
Fix: Make sure that Update Customer Address v3.1.0 stores the posted …
simonredfern Oct 19, 2026
934ab22
Fix: Make sure that Get Product Attribute only returns an attribute o…
simonredfern Oct 19, 2026
77b3344
Fix: Make sure that Get Meetings and Get Meeting only return meetings…
simonredfern Oct 19, 2026
9aff817
Fix: Make sure that UK Open Banking v2.0 Get Account Transactions ret…
simonredfern Oct 19, 2026
84ef276
Fix: Make sure that the connector metrics cache expires after the num…
simonredfern Oct 19, 2026
a4fa87d
Fix: Make sure that Get Bank ATMS in v4.0.0 requires a logged-in user…
simonredfern Oct 19, 2026
911cf35
fix: Make sure that a customer address returns its stored status as
simonredfern Oct 19, 2026
42af992
displaying UserReference in the glossary
simonredfern Oct 19, 2026
27abc22
Fix: Make sure that Create Customer Social Media Handle only adds a h…
simonredfern Oct 19, 2026
122d180
Fix: Make sure that Get Bank Product in v4.0.0 requires a logged-in u…
simonredfern Oct 19, 2026
6f6372f
Fix: Make sure that Berlin Group Read Transaction Details only answer…
simonredfern Oct 19, 2026
a9fbc06
Fix: Make sure that Request Settlement only accepts a trade whose buy…
simonredfern Oct 19, 2026
223eb1b
Fix: Make sure that Get Product Fees needs a logged-in user when apiO…
simonredfern Oct 20, 2026
f9b72b7
Fix: Make sure that the poster of a transaction comment or image can …
simonredfern Oct 20, 2026
cd9ec95
Fix: Make sure that Get Customer Social Media Handles only returns th…
simonredfern Oct 20, 2026
62fba73
Fix: Make sure that Get Market Trade only finds a trade whose buy or …
simonredfern Oct 20, 2026
b007af4
Fix: Make sure that the Chat, Log Cache, Metrics and Signal event bus…
simonredfern Oct 20, 2026
ea685af
Fix: Make sure that the v3.0.0 and v3.1.0 transaction endpoints only …
simonredfern Oct 20, 2026
eadb218
Fix: Make sure that the Berlin Group v1.3 card account list only show…
simonredfern Oct 20, 2026
12b896b
Fix: Make sure that Create Account v2.2.0 makes the human a Consent a…
simonredfern Oct 20, 2026
74c4dcd
Fix: Make sure that Delete Group revokes the Roles the Group granted,…
simonredfern Oct 20, 2026
54a78c9
Fix: Make sure that Get Product Tree stops at a product already in th…
simonredfern Oct 20, 2026
320a449
Fix: Make sure that Create Transaction Request (CARD) checks the call…
simonredfern Oct 20, 2026
05434a8
Fix: Make sure that Get Products v4.0.0 needs a logged-in user when a…
simonredfern Oct 20, 2026
4300ad7
Update ExplicitTargetConsentUserSweepTest.scala
simonredfern Oct 20, 2026
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
8 changes: 4 additions & 4 deletions ON_BEHALF_OF_USER_ID_PLAN.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ object Http4sUKOBv200AIS extends MdcLoggable {
(transactions, _) <- Future {
BankAccountExtended(account).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(account.bankId, account.accountId), Some(cc), params)
} map { x => unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) }
} yield JSONFactory_UKOpenBanking_200.createTransactionsJson(transactions, Nil)
// The account id comes from the account looked up above, not from the first transaction, which an empty result does not have.
} yield JSONFactory_UKOpenBanking_200.createTransactionsJson(account.accountId.value, transactions, Nil)
}
}
resourceDocs += ResourceDoc(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats {
)
}

def createTransactionsJson(transactions: List[ModeratedTransaction], transactionRequests: List[TransactionRequest]) : TransactionsJsonUKV200 = {
val accountId = transactions.head.bankAccount.get.accountId.value
// The caller passes the account id from the path, so an account with no transactions gives an empty list, not an exception.
def createTransactionsJson(accountId: String, transactions: List[ModeratedTransaction], transactionRequests: List[TransactionRequest]) : TransactionsJsonUKV200 = {
val transactionsInnerJson = transactions.map(
transaction=>TransactionInnerJson(
AccountId = accountId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ object Http4sBGv13AIS extends MdcLoggable {
val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired)
for {
_ <- passesPsd2Aisp(callContext)
availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u)
// only accounts the user reaches through the ReadAccountsBerlinGroup view (403 if none), as getAccountList
// and readCardAccount do, else any private view (owner, accountant, ...) would list a card account
(availablePrivateAccounts, callContext) <- NewStyle.function.getAccountListOfBerlinGroup(u, callContext)
(_, callContext) <- NewStyle.function.getPhysicalCardsForUser(u, callContext)
(accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext)
(canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext)
Expand Down Expand Up @@ -422,6 +424,9 @@ object Http4sBGv13AIS extends MdcLoggable {
val callContext = Some(cc)
val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired)
for {
// The calling TPP must hold a PSD2 certificate with the PSP_AI role, as for the transaction
// list of the same account; else a consumer without it could still read single transactions.
_ <- passesPsd2Aisp(callContext)
(account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext)
viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID)
bankIdAccountId = BankIdAccountId(account.bankId, account.accountId)
Expand Down Expand Up @@ -1245,7 +1250,8 @@ respectively the OAuth2 access token.
}
]
}""")),
List(AuthenticatedUserIsRequired, UnknownError),
// 403 NoViewReadAccountsBerlinGroup when the user has no account through the ReadAccountsBerlinGroup view
List(AuthenticatedUserIsRequired, NoViewReadAccountsBerlinGroup, UnknownError),
ApiTag("Account Information Service (AIS)") :: apiTagMockedData :: Nil,
http4sPartialFunction = Some(getCardAccounts)
)
Expand Down Expand Up @@ -1396,7 +1402,8 @@ of the "Read Transaction List" call within the _links subfield.
}
}
}""")),
List(AuthenticatedUserIsRequired, UnknownError),
// The X509 / regulated-entity errors come from passesPsd2Aisp, when PSD2 certificates are required
List(AuthenticatedUserIsRequired, X509CannotGetCertificate, RegulatedEntityNotFoundByCertificate, X509ActionIsNotAllowed, X509GeneralError, UnknownError),
ApiTag("Account Information Service (AIS)") :: Nil,
http4sPartialFunction = Some(getTransactionDetails)
)
Expand Down
40 changes: 40 additions & 0 deletions obp-api/src/main/scala/code/api/cache/Redis.scala
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,46 @@
jedis
}

private val SubscriberMinBackoffMs = 1000L // first wait after a lost subscriber connection
private val SubscriberMaxBackoffMs = 30000L // longest wait between reconnect attempts while Redis is down

/**
* Run an event bus's blocking subscribe on a dedicated connection for as long as `running` is true.
*
* When the connection fails (e.g. Redis restarts or drops the socket) the subscribe call throws;
* this closes that connection, waits (1s, doubling up to 30s) and subscribes again on a new one,
* so the bus's gRPC streams get messages again once Redis is back, without restarting OBP-API.
* `onConnection` hands each new connection to the bus, so its stop() can close the current one.
*/
def runSubscriber(busName: String, running: => Boolean, onConnection: Jedis => Unit)(subscribe: Jedis => Unit): Unit = {

Check failure on line 116 in obp-api/src/main/scala/code/api/cache/Redis.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaFYYDc-Na3UvKWvSPtz&open=AaFYYDc-Na3UvKWvSPtz&pullRequest=2948
var backoffMs = SubscriberMinBackoffMs // wait before the next attempt; doubles per failure, capped
var failed = false // set after a failure, so the next connection is logged as a reconnect
while (running) { // keep subscribing until stop() clears running
var jedis: Jedis = null
val startedAt = System.currentTimeMillis() // how long this subscription lasted decides the next backoff
try {
jedis = newSubscriberConnection()
onConnection(jedis)
if (failed) logger.info(s"$busName says: Redis subscriber reconnected") // shows streaming resumed after an outage
subscribe(jedis) // blocks until unsubscribed by stop() or the connection fails
} catch {
case e: Throwable if running =>
// a subscription that lasted a while means Redis was up: start again from the shortest wait
if (System.currentTimeMillis() - startedAt >= SubscriberMaxBackoffMs) backoffMs = SubscriberMinBackoffMs
// the connection is gone but the bus is still meant to run: log and retry below
logger.error(s"$busName says: Redis subscriber connection lost, reconnecting in ${backoffMs}ms: ${e.getMessage}")
failed = true
case _: Throwable => // shutting down, ignore
} finally {
try { if (jedis != null) jedis.close() } catch { case _: Throwable => } // release the dead socket before opening a new one
}
if (running) { // wait before retrying, so a Redis outage is not hammered with reconnects
try Thread.sleep(backoffMs) catch { case _: InterruptedException => return } // interrupted: the JVM is shutting down
backoffMs = math.min(backoffMs * 2, SubscriberMaxBackoffMs) // wait longer each time Redis is still down
}
}
}

// Redis startup health check
private def performStartupHealthCheck(): Unit = {
try {
Expand Down
57 changes: 57 additions & 0 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,63 @@ object Glossary extends MdcLoggable {
|<img width="468" alt="OBP Access Control Image" src="$getServerUrl/media/images/glossary/OBP_Consent_Request__3_.png"></img>
|""".stripMargin)

glossaryItems += GlossaryItem(
title = "On Behalf Of",
description = onBehalfOfDescription
)

/**
* This builds the "On Behalf Of" description. The table is rendered from UserReference, which
* holds the policy of every column that stores a user id and which of them are applied yet, so
* the Glossary cannot drift from the code. The table is appended after stripMargin, because its
* rows start with "|" and stripMargin would eat that.
*/
private def onBehalfOfDescription: String = {
import code.users.{AttributionPolicy, UserReference}
import code.users.UserReference.NotYetWired

def policyText(policy: AttributionPolicy): String = policy match {
case AttributionPolicy.UseAuthenticatedUserId => "the caller"
case AttributionPolicy.UseOnBehalfOfUserId => "the User acted for"
case AttributionPolicy.Reject => "refused"
}
def todayText(ref: UserReference): String = UserReference.notYetWired.get(ref) match {
case None => policyText(ref.policy)
case Some(NotYetWired.StoresCaller(_)) if ref.policy == AttributionPolicy.Reject => "the caller (not refused)"
case Some(NotYetWired.StoresCaller(_)) => "the caller"
case Some(NotYetWired.NotReachedByConsentUser(_)) => "not written by a consent user"
}
def cell(text: String): String = text.replace("|", "\\|")

val rows = UserReference.all.map { ref =>
s"| ${ref.name.takeWhile(_ != '_')} | ${ref.fields.map(field => s"`$field`").mkString(", ")} | " +
s"${policyText(ref.policy)} | ${todayText(ref)} | ${cell(ref.note)} |"
}
val delegated = UserReference.all.filter(ref => ref.policy != AttributionPolicy.UseAuthenticatedUserId)
val applied = delegated.count(UserReference.isWired)

s"""When a User grants a ${getGlossaryItemLink("Consent")} to an application, such as an AI agent, the application calls OBP under an identity of its own: a consent user, which OBP creates for that Consent. A call made that way has two Users behind it. `user_id` is the caller, the consent user that authenticated. `on_behalf_of_user_id` is the User the call was made for, the one who granted the Consent. When a User calls OBP for themselves, the two are the same User.
|
|OBP finds the User acted for itself, from the Consent the caller authenticated with. It never takes it from a request body, header or query parameter, so a caller cannot name somebody else to act for.
|
|This matters whenever OBP stores a user id. A consent user belongs to its Consent, so a row stored against it, such as an account holder, belongs to an identity that ends with the Consent, and the User who asked for it can no longer see it. So every database column that stores a user id has a policy, which says whose id to store when a consent user makes the call:
|
|* **the caller**: store the consent user's own id. This suits audit records of who acted, and the rows that carry a Consent's own permissions, which are meant to end with it.
|* **the User acted for**: store the id of the User who granted the Consent, so the row outlives the Consent. A few tables keep both ids, for example a counterparty or a transaction request, where who made the call is worth keeping too; the Note column says so.
|* **refused**: a consent user must not do this at all, because what it would create (a Consent, a Consumer or a token) outlives the Consent and would let the delegation extend itself.
|
|A request that explicitly names a consent user as the target User, for example in a `user_id` field or a `USER_ID` path segment, is answered with 400 `InvalidUserId`.
|
|Where a v7.0.0 response publishes both ids, it names them `user_id` and `on_behalf_of_user_id`, as in the record metadata of a ${getGlossaryItemLink("Dynamic-Entities")} record.
|
|**Every column that stores a user id**
|
|Policy is whose id the column should hold when a consent user makes the call. Today is whose id it holds on this instance now. Of the ${delegated.size} columns whose policy is "the User acted for" or "refused", ${applied} apply it today. "Not written by a consent user" means no API call writes that column with a consent user's id, because the endpoint refuses the consent user first or no endpoint writes the table.
|
|""".stripMargin +
(List("| Record | Column | Policy | Today | Note |", "|---|---|---|---|---|") ++ rows).mkString("\n") + "\n"
}


glossaryItems += GlossaryItem(
title = "Authentication: Consent OBP Flow Example",
Expand Down
9 changes: 6 additions & 3 deletions obp-api/src/main/scala/code/api/util/NewStyle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5103,7 +5103,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = {
Connector.connector.vend.getMarketOrder(bankId, accountId, orderId, callContext) map {
i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2)
// 404: a missing order and another account's order look the same
i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound", 404), i._2)
}
}

Expand All @@ -5114,7 +5115,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = {
Connector.connector.vend.cancelMarketOrder(bankId, accountId, orderId, callContext) map {
i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2)
// 404: a missing order and another account's order look the same
i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound", 404), i._2)
}
}

Expand All @@ -5141,7 +5143,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketTrade] = {
Connector.connector.vend.getMarketTrade(bankId, accountId, tradeId, callContext) map {
i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound"), i._2)
// 404: a missing trade and another account's trade look the same
i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound", 404), i._2)
}
}

Expand Down
10 changes: 9 additions & 1 deletion obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,13 @@ object Http4s121 {
s"${CreateCustomViewError} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views",
cc = Some(cc)
)(anyViewContainsPermission)
// the view may only get actions the caller already has on one of their views of this account, else they grant themselves more (as v5.1.0 does)
callerActions = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user)
.map(_.views.flatMap(_.allowed_actions)).getOrElse(Nil).toSet
_ <- Helper.booleanToFuture(
s"$SourceViewHasLessPermission Current permissions of your views ($callerActions), target view permissions (${updateJsonV121.allowed_actions})",
failCode = 403, cc = Some(cc)
)(updateJsonV121.allowed_actions.toSet.subsetOf(callerActions))
updatedView <- Future(Views.views.vend.updateCustomView(BankIdAccountId(account.bankId, account.accountId), ViewId(viewId), updateViewJson)) map {
unboxFullOrFail(_, Some(cc), CreateCustomViewError)
}
Expand All @@ -694,7 +701,8 @@ object Http4s121 {
|of a view is not editable (it is only set when a view is created)""",
updateViewJsonV121,
viewJSONV121,
List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, UnknownError, "user does not have owner access"),
// SourceViewHasLessPermission: the body asks for actions none of the caller's views on the account has
List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, SourceViewHasLessPermission, UnknownError, "user does not have owner access"),
List(apiTagAccount, apiTagView, apiTagOldStyle),
http4sPartialFunction = Some(updateViewForBankAccount)
)
Expand Down
12 changes: 9 additions & 3 deletions obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ object Http4s140 {
case req @ POST -> `prefixPath` / "banks" / _ / "customer" / customerId / "messages" =>
EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory1_4_0.AddCustomerMessageJson, SuccessMessage](req) { (_, bank, body, cc) =>
for {
(_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
(customer, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
// the customer must be at the bank in the path, else a caller at one bank messages another bank's customers
_ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
customer.bankId == bank.bankId.value
}
(ucl, cc3) <- NewStyle.function.getUserCustomerLinkByCustomerId(customerId, cc2)
(targetUser, cc4) <- NewStyle.function.findByUserId(ucl.userId, cc3)
(_, _) <- NewStyle.function.createMessage(targetUser, bank.bankId, body.message, body.from_department, body.from_person, cc4)
Expand All @@ -185,9 +189,11 @@ object Http4s140 {
"Create a message for the customer specified by CUSTOMER_ID",
addCustomerMessageJson,
successMessage,
List(AuthenticatedUserIsRequired, UnknownError),
// the role, customer and user-link errors this endpoint can return
List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, UserCustomerLinksNotFoundForUser, UnknownError),
List(apiTagMessage, apiTagCustomer, apiTagPerson),
None,
// the caller needs CanCreateCustomerMessage, as in v4.0.0, else any logged-in user writes to any customer's inbox
Some(List(canCreateCustomerMessage)),
http4sPartialFunction = Some(addCustomerMessage)
)

Expand Down
13 changes: 11 additions & 2 deletions obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,10 @@ object Http4s200 {
APIUtil.hasEntitlement(bank.bankId.value, user.userId, canGetSocialMediaHandles)
}
(customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
// the customer must be at the bank in the path, else a Role at one bank reads another bank's customers' handles
_ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
customer.bankId == bank.bankId.value
}
} yield {
val socialMedias = SocialMediaHandle.socialMediaHandleProvider.vend.getSocialMedias(customer.number)
createSocialMediasJSON(socialMedias)
Expand Down Expand Up @@ -644,10 +648,15 @@ object Http4s200 {
_ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddSocialMediaHandle, failCode = 403, cc = Some(cc)) {
APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddSocialMediaHandle)
}
(_, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
(customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
// the customer must be at the bank in the path, else the Role at one bank writes to any bank's customer
_ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
customer.bankId == bank.bankId.value
}
_ <- code.util.Helper.booleanToFuture("Server error: could not add", cc = Some(cc)) {
SocialMediaHandle.socialMediaHandleProvider.vend.addSocialMedias(
body.customer_number, body.`type`, body.handle,
// stored against the number of the customer in the path, not the body's customer_number, so it cannot target another customer
customer.number, body.`type`, body.handle,
body.date_added, body.date_activated)
}
} yield SuccessMessage("Success")
Expand Down
Loading
Loading