From fc8352afb4c6a6e672fa3baf8bc561ebbc075bba Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 10:37:15 +0100 Subject: [PATCH] feat(deletion): cascade account deletion to transaction requests, challenges and double-entry rows Deleting an account by cascade now removes the transaction requests it paid from, including ones without a transaction yet, the expected challenge answers of those requests, and the double-entry book rows on either side of the account. Bank cascade deletion inherits this, since it deletes each account through DeleteAccountCascade. --- .../scala/deletion/DeleteAccountCascade.scala | 38 +++++++++++- .../api/v4_0_0/DeleteAccountCascadeTest.scala | 25 +++++++- .../api/v4_0_0/DeleteBankCascadeTest.scala | 19 ++++++ .../code/api/v4_0_0/V400ServerSetup.scala | 61 ++++++++++++++++++- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index f3756634ea..82ef2e7fb2 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -34,7 +34,9 @@ import code.api.util.ErrorMessages.CouldNotDeleteCascade import code.bankconnectors.Connector import code.cards.MappedPhysicalCard import code.entitlement.MappedEntitlement -import code.model.dataAccess.{BankAccountRouting, MappedBankAccount, MappedBankAccountData} +import code.model.dataAccess.{BankAccountRouting, DoubleEntryBookTransaction, MappedBankAccount, MappedBankAccountData} +import code.transactionChallenge.MappedExpectedChallengeAnswer +import code.transactionrequests.MappedTransactionRequest import code.views.system.{AccountAccess, ViewDefinition} import code.webhook.MappedAccountWebhook import com.openbankproject.commons.model.{AccountId, BankId} @@ -49,8 +51,13 @@ import scala.collection.immutable.List object DeleteAccountCascade { def delete(bankId: BankId, accountId: AccountId): Boolean = { + // The tasks run in list order: the challenges are found through the account's transaction + // requests, so they must go before deleteTransactionRequests and deleteTransactions remove them. val doneTasks = - deleteTransactions(bankId, accountId) :: + deleteTransactionRequestChallenges(bankId, accountId) :: + deleteTransactionRequests(bankId, accountId) :: + deleteDoubleEntryBookTransactions(bankId, accountId) :: + deleteTransactions(bankId, accountId) :: deleteEntitlements(bankId, accountId) :: deleteAccountAccess(bankId, accountId) :: deleteCustomViews(bankId, accountId) :: @@ -153,4 +160,31 @@ object DeleteAccountCascade { false } } + + private def deleteTransactionRequestChallenges(bankId: BankId, accountId: AccountId): Boolean = { + val transactionRequestIds = MappedTransactionRequest.findAll( + By(MappedTransactionRequest.mFrom_BankId, bankId.value), + By(MappedTransactionRequest.mFrom_AccountId, accountId.value) + ).map(_.mTransactionRequestId.get) + MappedExpectedChallengeAnswer.bulkDelete_!!( + ByList(MappedExpectedChallengeAnswer.TransactionRequestId, transactionRequestIds) + ) + } + private def deleteTransactionRequests(bankId: BankId, accountId: AccountId): Boolean = { + MappedTransactionRequest.bulkDelete_!!( + By(MappedTransactionRequest.mFrom_BankId, bankId.value), + By(MappedTransactionRequest.mFrom_AccountId, accountId.value) + ) + } + private def deleteDoubleEntryBookTransactions(bankId: BankId, accountId: AccountId): Boolean = { + val debitSide = DoubleEntryBookTransaction.bulkDelete_!!( + By(DoubleEntryBookTransaction.DebitTransactionBankId, bankId.value), + By(DoubleEntryBookTransaction.DebitTransactionAccountId, accountId.value) + ) + val creditSide = DoubleEntryBookTransaction.bulkDelete_!!( + By(DoubleEntryBookTransaction.CreditTransactionBankId, bankId.value), + By(DoubleEntryBookTransaction.CreditTransactionAccountId, accountId.value) + ) + debitSide && creditSide + } } 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..82a2aacf07 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 @@ -32,7 +32,7 @@ import code.api.Constant import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createViewJsonV300 import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole +import code.api.util.{APIUtil, ApiRole} import code.api.util.ApiRole.CanDeleteAccountCascade import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} import code.api.v3_1_0.CreateAccountResponseJsonV310 @@ -138,6 +138,29 @@ class DeleteAccountCascadeTest extends V400ServerSetup { When("We try to delete one more time") makeDeleteRequest(request400).code should equal(404) } + scenario("We delete an account that has paid by transaction requests", ApiEndpoint1, VersionOfApi) { + val paymentsBankId = createBank(APIUtil.generateUUID()).bankId.value + val (fromAccountId, toAccountId, transactionRequestIds) = createPaymentsForDeleteCascade(paymentsBankId) + doubleEntryBookTransactionsOfAccount(paymentsBankId, fromAccountId) should not be empty + val (transactionRequestsBefore, challengesBefore) = transactionRequestsAndChallenges(transactionRequestIds) + transactionRequestsBefore.size should equal(2) + challengesBefore should not be empty + + When("We grant the role") + Entitlement.entitlement.vend.addEntitlement(paymentsBankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + And("We make a delete cascade request v4.0.0") + val deleteRequest400 = (v4_0_0_Request / "management" / "cascading" / "banks" / paymentsBankId / + "accounts" / fromAccountId).DELETE <@(user1) + makeDeleteRequest(deleteRequest400).code should equal(200) + + Then("The double-entry book rows, transaction requests and challenges of the account are deleted") + doubleEntryBookTransactionsOfAccount(paymentsBankId, fromAccountId) shouldBe empty + val (transactionRequestsAfter, challengesAfter) = transactionRequestsAndChallenges(transactionRequestIds) + transactionRequestsAfter shouldBe empty + challengesAfter shouldBe empty + And("The double-entry book row is gone for the receiving account as well, since it was one row for both sides") + doubleEntryBookTransactionsOfAccount(paymentsBankId, toAccountId) shouldBe empty + } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala index 0e4807db50..65b990002d 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala @@ -158,6 +158,25 @@ class DeleteBankCascadeTest extends V400ServerSetup { val responseBody = response.body.extract[CustomersMinimalJsonV400] responseBody.customers.length equals 0 } + scenario("We delete a bank whose accounts have paid by transaction requests", ApiEndpoint1, VersionOfApi) { + val bankId = createBank(APIUtil.generateUUID()).bankId.value + val (fromAccountId, toAccountId, transactionRequestIds) = createPaymentsForDeleteCascade(bankId) + doubleEntryBookTransactionsOfAccount(bankId, fromAccountId) should not be empty + transactionRequestsAndChallenges(transactionRequestIds)._2 should not be empty + + When("We grant the role") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + And("We make a delete cascade request v4.0.0") + val deleteRequest400 = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId ).DELETE <@(user1) + makeDeleteRequest(deleteRequest400).code should equal(200) + + Then("The double-entry book rows, transaction requests and challenges of its accounts are deleted") + doubleEntryBookTransactionsOfAccount(bankId, fromAccountId) shouldBe empty + doubleEntryBookTransactionsOfAccount(bankId, toAccountId) shouldBe empty + val (transactionRequestsAfter, challengesAfter) = transactionRequestsAndChallenges(transactionRequestIds) + transactionRequestsAfter shouldBe empty + challengesAfter shouldBe empty + } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala b/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala index 4f97200a61..37b24ad00e 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala @@ -50,12 +50,16 @@ import code.metadata.transactionimages.MappedTransactionImage import code.metadata.wheretags.MappedWhereTag import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles} import code.setup.{APIResponse, DefaultUsers, ServerSetupWithTestData} +import code.model.dataAccess.DoubleEntryBookTransaction +import code.transactionChallenge.MappedExpectedChallengeAnswer import code.transactionattribute.MappedTransactionAttribute +import code.transactionrequests.MappedTransactionRequest +import com.openbankproject.commons.model.enums.TransactionRequestStatus import com.openbankproject.commons.model.{AccountId, AccountRoutingJsonV121, AmountOfMoneyJsonV121, BankId, CreateViewJson, ErrorMessage, UpdateViewJSON} import com.openbankproject.commons.util.ApiShortVersions import code.setup.OBPReq import org.json4s.native.Serialization.write -import net.liftweb.mapper.By +import net.liftweb.mapper.{By, ByList} import net.liftweb.util.Helpers.randomString import java.util.concurrent.TimeUnit @@ -457,6 +461,61 @@ trait V400ServerSetup extends ServerSetupWithTestData with DefaultUsers { (bank.bankId.value, fromAccount.account_id, transactionId) } + /** + * Creates two accounts at the bank and two SANDBOX_TAN transaction requests from the first to the second: + * one below the challenge threshold, which is paid at once and booked in the double-entry book, + * and one above it, which stays INITIATED with an expected challenge answer and no transaction. + * Returns (fromAccountId, toAccountId, transactionRequestIds). + */ + def createPaymentsForDeleteCascade(bankId: String): (String, String, List[String]) = { + def addAccountJson = SwaggerDefinitionsJSON.createAccountRequestJsonV310 + .copy(user_id = resourceUser1.userId, balance = AmountOfMoneyJsonV121("EUR","0"), + account_routings = List(AccountRoutingJsonV121(Random.nextString(10), Random.nextString(10)))) + val fromAccount = createAccountViaEndpoint(bankId, addAccountJson, user1) + val toAccount = createAccountViaEndpoint(bankId, addAccountJson, user1) + val customViewJson = createViewJsonV300.copy(name = "_cascade_delete", metadata_view = "_cascade_delete", is_public = false).toCreateViewJson + val customView = createViewViaEndpoint(bankId, fromAccount.account_id, customViewJson, user1) + grantUserAccessToViewViaEndpoint( + bankId, + fromAccount.account_id, + resourceUser1.userId, + user1, + PostViewJsonV400(view_id = customView.id, is_system = false) + ) + def pay(amount: String) = createTransactionRequestViaEndpoint( + fromBankId = bankId, + fromAccountId = fromAccount.account_id, + fromCurrency = fromAccount.balance.currency, + fromViewId = customView.id, + amount = amount, + toBankId = bankId, + toAccountId = toAccount.account_id, + user1 + ) + val completed = pay("10") + completed.status should equal(TransactionRequestStatus.COMPLETED.toString) + val initiated = pay("5000") + initiated.status should equal(TransactionRequestStatus.INITIATED.toString) + (fromAccount.account_id, toAccount.account_id, List(completed.id, initiated.id)) + } + + def doubleEntryBookTransactionsOfAccount(bankId: String, accountId: String): List[DoubleEntryBookTransaction] = { + DoubleEntryBookTransaction.findAll( + By(DoubleEntryBookTransaction.DebitTransactionBankId, bankId), + By(DoubleEntryBookTransaction.DebitTransactionAccountId, accountId) + ) ++ DoubleEntryBookTransaction.findAll( + By(DoubleEntryBookTransaction.CreditTransactionBankId, bankId), + By(DoubleEntryBookTransaction.CreditTransactionAccountId, accountId) + ) + } + + def transactionRequestsAndChallenges(transactionRequestIds: List[String]): (List[MappedTransactionRequest], List[MappedExpectedChallengeAnswer]) = { + ( + MappedTransactionRequest.findAll(ByList(MappedTransactionRequest.mTransactionRequestId, transactionRequestIds)), + MappedExpectedChallengeAnswer.findAll(ByList(MappedExpectedChallengeAnswer.TransactionRequestId, transactionRequestIds)) + ) + } + def saveHistoricalTransactionViaEndpoint(fromBankId: BankId, fromAccountId: AccountId, toBankId: BankId,