Skip to content
55 changes: 52 additions & 3 deletions obp-api/src/main/scala/deletion/DeleteAccountCascade.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import code.api.util.APIUtil.fullBoxOrException
import code.api.util.ErrorMessages.CouldNotDeleteCascade
import code.bankconnectors.Connector
import code.cards.MappedPhysicalCard
import code.counterpartyattribute.CounterpartyAttribute
import code.counterpartylimit.CounterpartyLimit
import code.entitlement.MappedEntitlement
import code.metadata.counterparties.{MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata, MappedCounterpartyWhereTag}
import code.model.dataAccess.{BankAccountRouting, MappedBankAccount, MappedBankAccountData}
import code.views.system.{AccountAccess, ViewDefinition}
import code.webhook.MappedAccountWebhook
Expand All @@ -57,8 +60,10 @@ object DeleteAccountCascade {
deleteAccountAttributes(bankId, accountId) ::
deleteAccountWebhooks(bankId, accountId) ::
deleteBankAccountData(bankId, accountId) ::
deleteCards(accountId) ::
deleteCards(bankId, accountId) ::
deleteAccountRoutings(bankId, accountId) ::
deleteCounterparties(bankId, accountId) ::
deleteCounterpartyMetadata(bankId, accountId) ::
deleteAccount(bankId, accountId) ::
Nil
doneTasks.forall(_ == true)
Expand All @@ -81,15 +86,19 @@ object DeleteAccountCascade {
)
}
private def deleteEntitlements(bankId: BankId, accountId: AccountId): Boolean = {
val userIds = AccountAccess.findAll(By(AccountAccess.account_id, accountId.value)).map(_.user_fk.foreign.map(_.userId).getOrElse(""))
val userIds = AccountAccess.findAll(
By(AccountAccess.bank_id, bankId.value),
By(AccountAccess.account_id, accountId.value)
).map(_.user_fk.foreign.map(_.userId).getOrElse(""))
MappedEntitlement.bulkDelete_!!(
By(MappedEntitlement.mBankId, bankId.value),
ByList(MappedEntitlement.mUserId, userIds)
)
}

private def deleteCards(accountId: AccountId): Boolean = {
private def deleteCards(bankId: BankId, accountId: AccountId): Boolean = {
MappedBankAccount.findAll(
By(MappedBankAccount.bank, bankId.value),
By(MappedBankAccount.theAccountId, accountId.value)
) map (
account =>
Expand Down Expand Up @@ -136,6 +145,46 @@ object DeleteAccountCascade {
)
}

// The counterparties this account created, with the rows that hang off each of them.
// Counterparties of other accounts that point at this account are theirs and are kept.
private def deleteCounterparties(bankId: BankId, accountId: AccountId): Boolean = {
val counterparties = MappedCounterparty.findAll(
By(MappedCounterparty.mThisBankId, bankId.value),
By(MappedCounterparty.mThisAccountId, accountId.value)
)
val primaryKeys = counterparties.map(_.id.get)
val counterpartyIds = counterparties.map(_.counterpartyId)
val bespokesDeleted = primaryKeys.isEmpty ||
MappedCounterpartyBespoke.bulkDelete_!!(ByList(MappedCounterpartyBespoke.mCounterparty, primaryKeys))
val attributesDeleted = counterpartyIds.isEmpty ||
CounterpartyAttribute.bulkDelete_!!(ByList(CounterpartyAttribute.CounterpartyId_, counterpartyIds))
val limitsDeleted = CounterpartyLimit.bulkDelete_!!(
By(CounterpartyLimit.BankId, bankId.value),
By(CounterpartyLimit.AccountId, accountId.value)
)
val counterpartiesDeleted = MappedCounterparty.bulkDelete_!!(
By(MappedCounterparty.mThisBankId, bankId.value),
By(MappedCounterparty.mThisAccountId, accountId.value)
)
bespokesDeleted && attributesDeleted && limitsDeleted && counterpartiesDeleted
}

// Metadata covers both the counterparties created on this account and the ones derived from its transactions.
private def deleteCounterpartyMetadata(bankId: BankId, accountId: AccountId): Boolean = {
val metadata = MappedCounterpartyMetadata.findAll(
By(MappedCounterpartyMetadata.thisBankId, bankId.value),
By(MappedCounterpartyMetadata.thisAccountId, accountId.value)
)
val locationIds = metadata.flatMap(m => List(m.physicalLocation.get, m.corporateLocation.get)).filter(_ > 0)
val locationsDeleted = locationIds.isEmpty ||
MappedCounterpartyWhereTag.bulkDelete_!!(ByList(MappedCounterpartyWhereTag.id, locationIds))
val metadataDeleted = MappedCounterpartyMetadata.bulkDelete_!!(
By(MappedCounterpartyMetadata.thisBankId, bankId.value),
By(MappedCounterpartyMetadata.thisAccountId, accountId.value)
)
locationsDeleted && metadataDeleted
}

private def deleteTransactions(bankId: BankId, accountId: AccountId): Boolean = {
val deletedTransactions: Box[List[Boolean]] =
for(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@
import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired}
import code.api.v3_1_0.CreateAccountResponseJsonV310
import code.api.v4_0_0.Http4s400.Implementations4_0_0
import code.entitlement.Entitlement
import code.cards.MappedPhysicalCard
import code.entitlement.{Entitlement, MappedEntitlement}
import code.model.dataAccess.MappedBankAccount
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ErrorMessage}
import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage}
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.mapper.By
import net.liftweb.util.Helpers.randomString
import org.json4s.native.Serialization.write
import org.scalatest.Tag

Expand Down Expand Up @@ -132,13 +136,88 @@
val deleteRequest400 = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId /
"accounts" / account.account_id).DELETE <@(user1)
val deleteResponse400 = makeDeleteRequest(deleteRequest400)
Then("We should get a 200")

Check failure on line 139 in obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "We should get a 200" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaGbfi4yckRAwEXcPIs-&open=AaGbfi4yckRAwEXcPIs-&pullRequest=2953
deleteResponse400.code should equal(200)

When("We try to delete one more time")
makeDeleteRequest(request400).code should equal(404)
}
}


feature(s"test $ApiEndpoint1 version $VersionOfApi - cards") {
scenario("Deleting an account keeps the cards of an account with the same ACCOUNT_ID at another bank", ApiEndpoint1, VersionOfApi) {
Given("Two banks that each have an account with the same ACCOUNT_ID, each with a card")
val bankId = BankId(randomString(10))
val otherBankId = BankId(randomString(10))
createBank(bankId.value)
createBank(otherBankId.value)
val accountId = AccountId(randomString(10))
createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR")
createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR")

def addCard(bank: BankId): String = {
val account = MappedBankAccount.find(
By(MappedBankAccount.bank, bank.value),
By(MappedBankAccount.theAccountId, accountId.value)
).openOrThrowException("test account not found")
MappedPhysicalCard.create
.mBankId(bank.value)
.mAccount(account.id.get)
.mBankCardNumber(randomString(16))
.mSerialNumber(randomString(10))
.saveMe()
.mCardId.get
}
def cardExists(cardId: String): Boolean = MappedPhysicalCard.find(By(MappedPhysicalCard.mCardId, cardId)).isDefined
val card = addCard(bankId)
val otherBankCard = addCard(otherBankId)

When("We delete the account at the first bank with the cascading endpoint")
Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString)
val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value /
"accounts" / accountId.value).DELETE <@ (user1)
val response = makeDeleteRequest(request)

Then("We should get a 200")
response.code should equal(200)

And("The deleted account's card is gone and the other bank's card is kept")
cardExists(card) should equal(false)
cardExists(otherBankCard) should equal(true)
}
}

feature(s"test $ApiEndpoint1 version $VersionOfApi - entitlements") {
scenario("Deleting an account keeps the entitlements of users who only use an account with the same ACCOUNT_ID at another bank", ApiEndpoint1, VersionOfApi) {
Given("Two banks that each have an account with the same ACCOUNT_ID, owned by different users")
val bankId = BankId(randomString(10))
val otherBankId = BankId(randomString(10))
createBank(bankId.value)
createBank(otherBankId.value)
val accountId = AccountId(randomString(10))
createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR")
createAccountRelevantResource(Some(resourceUser2), otherBankId, accountId, "EUR")

And("The other bank's account owner has an entitlement at the first bank")
Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser2.userId, ApiRole.canCreateAccount.toString)
def otherOwnerEntitlements: Long = MappedEntitlement.count(
By(MappedEntitlement.mBankId, bankId.value),
By(MappedEntitlement.mUserId, resourceUser2.userId)
)
otherOwnerEntitlements should equal(1L)

When("We delete the account at the first bank with the cascading endpoint")
Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString)
val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value /
"accounts" / accountId.value).DELETE <@ (user1)
val response = makeDeleteRequest(request)

Then("We should get a 200")
response.code should equal(200)

And("The other bank's account owner keeps the entitlement")
otherOwnerEntitlements should equal(1L)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
Open Bank Project - API
Copyright (C) 2011-2026, TESOBE GmbH.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

Email: contact@tesobe.com
TESOBE GmbH.
Osloer Strasse 16/17
Berlin 13359, Germany

This product includes software developed at
TESOBE (http://www.tesobe.com/)

*/

package code.api.v4_0_0

import code.api.Constant
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole
import code.api.util.ErrorMessages.attemptedToOpenAnEmptyBox
import code.api.v4_0_0.Http4s400.Implementations4_0_0
import code.counterpartyattribute.{CounterpartyAttribute, CounterpartyAttributeX}
import code.counterpartylimit.{CounterpartyLimit, CounterpartyLimitProvider}
import code.entitlement.Entitlement
import code.metadata.counterparties.{Counterparties, MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata, MappedCounterpartyWhereTag}
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.enums.CounterpartyAttributeType
import com.openbankproject.commons.model.{AccountId, BankId, CounterpartyBespoke, CounterpartyId, CounterpartyTrait}
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.mapper.{By, ByList}
import net.liftweb.util.Helpers.randomString
import org.scalatest.Tag

import java.util.Date
import scala.concurrent.Await
import scala.concurrent.duration._

class DeleteCascadeCounterpartiesTest extends V400ServerSetup {

object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString)
object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.deleteAccountCascade))
object ApiEndpoint2 extends Tag(nameOf(Implementations4_0_0.deleteBankCascade))

private val ownerView = Constant.SYSTEM_OWNER_VIEW_ID

private case class Seeded(bankId: BankId, accountId: AccountId, counterparty: CounterpartyTrait, locationIds: List[Long])

/** A counterparty on the given account, with rows in every table that hangs off it. */
private def createCounterpartyWithChildren(bankId: BankId, accountId: AccountId): Seeded = {
val counterparty = Counterparties.counterparties.vend.createCounterparty(
createdByUserId = resourceUser1.userId,
thisBankId = bankId.value,
thisAccountId = accountId.value,
thisViewId = ownerView,
name = randomString(20),
otherAccountRoutingScheme = "IBAN",
otherAccountRoutingAddress = "DE89370400440532013000",
otherBankRoutingScheme = "BIC",
otherBankRoutingAddress = "COBADEFFXXX",
otherBranchRoutingScheme = "OBP",
otherBranchRoutingAddress = "Berlin",
isBeneficiary = true,
otherAccountSecondaryRoutingScheme = "IBAN",
otherAccountSecondaryRoutingAddress = "DE89370400440532013000",
description = "cascade test payee",
currency = "EUR",
bespoke = List(CounterpartyBespoke("key", "value"))
).openOrThrowException(attemptedToOpenAnEmptyBox)

val metadata = Counterparties.counterparties.vend
.getOrCreateMetadata(bankId, accountId, counterparty.counterpartyId, counterparty.name)
.openOrThrowException(attemptedToOpenAnEmptyBox)
metadata.addPhysicalLocation(resourceUser1.userPrimaryKey, new Date(), 13.4, 52.5) should be(true)
metadata.addCorporateLocation(resourceUser1.userPrimaryKey, new Date(), 11.6, 48.1) should be(true)
Await.result(
CounterpartyAttributeX.counterpartyAttributeProvider.vend.createOrUpdateCounterpartyAttribute(
CounterpartyId(counterparty.counterpartyId), None, "TAX_NUMBER", CounterpartyAttributeType.STRING, "123", Some(true)),
10.seconds
).openOrThrowException(attemptedToOpenAnEmptyBox)
Await.result(
CounterpartyLimitProvider.counterpartyLimit.vend.createOrUpdateCounterpartyLimit(
bankId.value, accountId.value, ownerView, counterparty.counterpartyId,
"EUR", BigDecimal(100), BigDecimal(1000), 10, BigDecimal(10000), 100, BigDecimal(100000), 1000),
10.seconds
).openOrThrowException(attemptedToOpenAnEmptyBox)

val storedMetadata = MappedCounterpartyMetadata.find(By(MappedCounterpartyMetadata.counterpartyId, counterparty.counterpartyId))
.openOrThrowException(attemptedToOpenAnEmptyBox)
Seeded(bankId, accountId, counterparty, List(storedMetadata.physicalLocation.get, storedMetadata.corporateLocation.get))
}

/** Rows left in each counterparty table for one seeded counterparty. */
private def remainingRows(seeded: Seeded): Map[String, Long] = {
val Seeded(bankId, accountId, counterparty, locationIds) = seeded
val counterpartyId = counterparty.counterpartyId
val primaryKey = counterparty.asInstanceOf[MappedCounterparty].id.get
Map(
"counterparty" -> MappedCounterparty.count(By(MappedCounterparty.mCounterPartyId, counterpartyId)),
"bespoke" -> MappedCounterpartyBespoke.count(By(MappedCounterpartyBespoke.mCounterparty, primaryKey)),
"metadata" -> MappedCounterpartyMetadata.count(
By(MappedCounterpartyMetadata.thisBankId, bankId.value),
By(MappedCounterpartyMetadata.thisAccountId, accountId.value),
By(MappedCounterpartyMetadata.counterpartyId, counterpartyId)),
"attribute" -> CounterpartyAttribute.count(By(CounterpartyAttribute.CounterpartyId_, counterpartyId)),
"limit" -> CounterpartyLimit.count(By(CounterpartyLimit.CounterpartyId, counterpartyId)),
"location" -> MappedCounterpartyWhereTag.count(ByList(MappedCounterpartyWhereTag.id, locationIds))
)
}

private val nothingLeft =
Map("counterparty" -> 0L, "bespoke" -> 0L, "metadata" -> 0L, "attribute" -> 0L, "limit" -> 0L, "location" -> 0L)
private val untouched =
Map("counterparty" -> 1L, "bespoke" -> 1L, "metadata" -> 1L, "attribute" -> 1L, "limit" -> 1L, "location" -> 2L)

feature(s"test $ApiEndpoint1 version $VersionOfApi - counterparties") {
scenario("Deleting an account deletes its own counterparties and leaves other accounts' counterparties alone", ApiEndpoint1, VersionOfApi) {
Given("Two banks that each have an account with the same ACCOUNT_ID, plus a second account in the first bank")
val bankId = BankId(randomString(10))
val otherBankId = BankId(randomString(10))
createBank(bankId.value)
createBank(otherBankId.value)
val accountId = AccountId(randomString(10))
val siblingAccountId = AccountId(randomString(10))
createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR")
createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR")
createAccountRelevantResource(Some(resourceUser1), bankId, siblingAccountId, "EUR")

And("A counterparty with bespoke fields, metadata with two locations, an attribute and a limit on each account")
val target = createCounterpartyWithChildren(bankId, accountId)
val sameIdOtherBank = createCounterpartyWithChildren(otherBankId, accountId)
val sibling = createCounterpartyWithChildren(bankId, siblingAccountId)
remainingRows(target) should equal(untouched)

When("We delete the first account with the cascading endpoint")
Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString)
val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value /
"accounts" / accountId.value).DELETE <@ (user1)
val response = makeDeleteRequest(request)

Then("We should get a 200")
response.code should equal(200)

And("The account's counterparty and every row that hangs off it are gone")
remainingRows(target) should equal(nothingLeft)

And("The counterparty of the same ACCOUNT_ID at another bank is untouched")
remainingRows(sameIdOtherBank) should equal(untouched)

And("The counterparty of the other account at the same bank is untouched")
remainingRows(sibling) should equal(untouched)
}
}

feature(s"test $ApiEndpoint2 version $VersionOfApi - counterparties") {
scenario("Deleting a bank deletes the counterparties of all its accounts and leaves other banks alone", ApiEndpoint2, VersionOfApi) {
Given("A bank with two accounts, and another bank with an account that has the same ACCOUNT_ID")
val bankId = BankId(randomString(10))
val otherBankId = BankId(randomString(10))
createBank(bankId.value)
createBank(otherBankId.value)
val accountId = AccountId(randomString(10))
val secondAccountId = AccountId(randomString(10))
createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR")
createAccountRelevantResource(Some(resourceUser1), bankId, secondAccountId, "EUR")
createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR")

val first = createCounterpartyWithChildren(bankId, accountId)
val second = createCounterpartyWithChildren(bankId, secondAccountId)
val otherBank = createCounterpartyWithChildren(otherBankId, accountId)

When("We delete the bank with the cascading endpoint")
Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString)
val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value).DELETE <@ (user1)
val response = makeDeleteRequest(request)

Then("We should get a 200")
response.code should equal(200)

And("The counterparties of both accounts and their child rows are gone")
remainingRows(first) should equal(nothingLeft)
remainingRows(second) should equal(nothingLeft)

And("The counterparty at the other bank is untouched")
remainingRows(otherBank) should equal(untouched)
}
}

}
Loading