diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index f3756634ea..6588c515b0 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -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 @@ -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) @@ -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 => @@ -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( diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index 2bd939c43a..fead73009d 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala @@ -37,10 +37,14 @@ import code.api.util.ApiRole.CanDeleteAccountCascade 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 @@ -139,6 +143,81 @@ class DeleteAccountCascadeTest extends V400ServerSetup { 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) + } + } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala new file mode 100644 index 0000000000..f2cee8514f --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala @@ -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 . + +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) + } + } + +}