FINERACT-2268: Fix GL account deletion logic

This commit is contained in:
Adam Saghy
2025-04-30 14:04:12 +02:00
parent 354f1bdc93
commit aaf1351c4f
12 changed files with 216 additions and 15 deletions
@@ -213,7 +213,7 @@ public class GLAccountsApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(tags = { "General Ledger Account" }, summary = "Delete a GL Account", description = "Deletes a GL Account")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = GLAccountsApiResourceSwagger.DeleteGLAccountsRequest.class)))
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = GLAccountsApiResourceSwagger.DeleteGLAccountsResponse.class)))
public CommandProcessingResult deleteGLAccount(
@PathParam("glAccountId") @Parameter(description = "glAccountId") final Long glAccountId) {
final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteGLAccount(glAccountId).build();
@@ -203,23 +203,23 @@ final class GLAccountsApiResourceSwagger {
public PutGLAccountsResponsechangesSwagger changes;
}
@Schema(description = "DeleteGLAccountsRequest")
public static final class DeleteGLAccountsRequest {
@Schema(description = "DeleteGLAccountsResponse")
public static final class DeleteGLAccountsResponse {
private DeleteGLAccountsRequest() {
private DeleteGLAccountsResponse() {
}
private static final class DeleteGLAccountsRequestchangesSwagger {
private static final class DeleteGLAccountsResponseChangesSwagger {
private DeleteGLAccountsRequestchangesSwagger() {}
private DeleteGLAccountsResponseChangesSwagger() {}
}
@Schema(example = "1")
public Long resourceId;
private DeleteGLAccountsRequestchangesSwagger changes;
private DeleteGLAccountsResponseChangesSwagger changes;
}
}
@@ -28,13 +28,15 @@ public class GLAccountInvalidDeleteException extends AbstractPlatformDomainRuleE
/*** Enum of reasons for invalid delete **/
public enum GlAccountInvalidDeleteReason {
TRANSACTIONS_LOGGED, HAS_CHILDREN;
TRANSACTIONS_LOGGED, HAS_CHILDREN, PRODUCT_MAPPING;
public String errorMessage() {
if (name().equalsIgnoreCase("TRANSACTIONS_LOGGED")) {
return "This GL Account cannot be deleted as it has transactions logged against it";
} else if (name().equalsIgnoreCase("HAS_CHILDREN")) {
return "Cannot delete this Header GL Account without first deleting or reassigning its children";
} else if (name().equalsIgnoreCase("PRODUCT_MAPPING")) {
return "Cannot delete this GL Account as it is mapped to a Product";
}
return name();
}
@@ -44,6 +46,8 @@ public class GLAccountInvalidDeleteException extends AbstractPlatformDomainRuleE
return "error.msg.glaccount.glcode.invalid.delete.transactions.logged";
} else if (name().equalsIgnoreCase("HAS_CHILDREN")) {
return "error.msg.glaccount.glcode.invalid.delete.has.children";
} else if (name().equalsIgnoreCase("PRODUCT_MAPPING")) {
return "error.msg.glaccount.glcode.invalid.delete.product.mapping";
}
return name();
}
@@ -37,6 +37,7 @@ import org.apache.fineract.accounting.glaccount.exception.GLAccountNotFoundExcep
import org.apache.fineract.accounting.glaccount.exception.InvalidParentGLAccountHeadException;
import org.apache.fineract.accounting.glaccount.serialization.GLAccountCommandFromApiJsonDeserializer;
import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository;
import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -61,6 +62,7 @@ public class GLAccountWritePlatformServiceJpaRepositoryImpl implements GLAccount
private final GLAccountRepository glAccountRepository;
private final JournalEntryRepository glJournalEntryRepository;
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
private final GLAccountCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final CodeValueRepositoryWrapper codeValueRepositoryWrapper;
private final JdbcTemplate jdbcTemplate;
@@ -147,7 +149,7 @@ public class GLAccountWritePlatformServiceJpaRepositoryImpl implements GLAccount
if (changesOnly.containsKey(GLAccountJsonInputParams.USAGE.getValue())) {
if (glAccount.isHeaderAccount()) {
final boolean journalEntriesForAccountExist = this.glJournalEntryRepository
.exists((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("glAccountId"), glAccountId));
.exists((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("glAccount").get("id"), glAccountId));
if (journalEntriesForAccountExist) {
throw new GLAccountInvalidUpdateException(GlAccountInvalidUpdateReason.TRANSANCTIONS_LOGGED, glAccountId);
}
@@ -186,16 +188,22 @@ public class GLAccountWritePlatformServiceJpaRepositoryImpl implements GLAccount
.orElseThrow(() -> new GLAccountNotFoundException(glAccountId));
// validate this isn't a header account that has children
if (glAccount.isHeaderAccount() && glAccount.getChildren().size() > 0) {
if (glAccount.isHeaderAccount() && !glAccount.getChildren().isEmpty()) {
throw new GLAccountInvalidDeleteException(GlAccountInvalidDeleteReason.HAS_CHILDREN, glAccountId);
}
// does this account have transactions logged against it
final boolean journalEntriesForAccountExist = this.glJournalEntryRepository
.exists((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("glAccountId"), glAccountId));
.exists((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("glAccount").get("id"), glAccountId));
if (journalEntriesForAccountExist) {
throw new GLAccountInvalidDeleteException(GlAccountInvalidDeleteReason.TRANSACTIONS_LOGGED, glAccountId);
}
// does this account mapped to product
final boolean accountMappingForAccountExists = this.productToGLAccountMappingRepository
.exists((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("glAccount").get("id"), glAccountId));
if (accountMappingForAccountExists) {
throw new GLAccountInvalidDeleteException(GlAccountInvalidDeleteReason.PRODUCT_MAPPING, glAccountId);
}
this.glAccountRepository.delete(glAccount);
return new CommandProcessingResultBuilder().withEntityId(glAccountId).build();
@@ -52,6 +52,8 @@ public class JournalEntryCommand {
private final SingleDebitOrCreditEntryCommand[] credits;
private final SingleDebitOrCreditEntryCommand[] debits;
private final String locale;
private final String dateFormat;
public void validateForCreate() {
@@ -73,7 +73,7 @@ public final class JournalEntryCommandFromApiJsonDeserializer extends AbstractFr
element);
final JsonObject topLevelJsonElement = element.getAsJsonObject();
final Locale locale = this.fromApiJsonHelper.extractLocaleParameter(topLevelJsonElement);
final String localeStr = locale.getLanguage() + "_" + locale.getCountry() + '_' + locale.getVariant();
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalNamed(JournalEntryJsonInputParams.AMOUNT.getValue(), element,
locale);
final Long paymentTypeId = this.fromApiJsonHelper.extractLongNamed(JournalEntryJsonInputParams.PAYMENT_TYPE_ID.getValue(), element);
@@ -97,8 +97,9 @@ public final class JournalEntryCommandFromApiJsonDeserializer extends AbstractFr
debits = populateCreditsOrDebitsArray(topLevelJsonElement, locale, JournalEntryJsonInputParams.DEBITS.getValue());
}
}
String dateFormat = this.fromApiJsonHelper.extractStringNamed(JournalEntryJsonInputParams.DATE_FORMAT.getValue(), element);
return new JournalEntryCommand(officeId, currencyCode, transactionDate, comments, referenceNumber, accountingRuleId, amount,
paymentTypeId, accountNumber, checkNumber, receiptNumber, bankNumber, routingCode, credits, debits);
paymentTypeId, accountNumber, checkNumber, receiptNumber, bankNumber, routingCode, credits, debits, localeStr, dateFormat);
}
/**
@@ -193,4 +193,5 @@
<include file="parts/0172_create_loan_capitalized_income_balance.xml" relativeToChangelogFile="true"/>
<include file="parts/0173_user_change_pwd.xml" relativeToChangelogFile="true" />
<include file="parts/0174_loan_product_add_capitalized_income_type.xml" relativeToChangelogFile="true" />
<include file="parts/0175_add_fk_acc_product_mapping.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<addForeignKeyConstraint baseColumnNames="gl_account_id" baseTableName="acc_product_mapping"
constraintName="FK_acc_product_mapping_gl_account_id" deferrable="false" initiallyDeferred="false"
onDelete="RESTRICT" onUpdate="RESTRICT" referencedColumnNames="id"
referencedTableName="acc_gl_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.integrationtests.accounting;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Calendar;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.client.models.GetGLAccountsResponse;
import org.apache.fineract.client.models.JournalEntryCommand;
import org.apache.fineract.client.models.PostGLAccountsRequest;
import org.apache.fineract.client.models.PostGLAccountsResponse;
import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PutGLAccountsRequest;
import org.apache.fineract.client.models.SingleDebitOrCreditEntryCommand;
import org.apache.fineract.client.util.CallFailedRuntimeException;
import org.apache.fineract.integrationtests.BaseLoanIntegrationTest;
import org.apache.fineract.integrationtests.common.Utils;
import org.apache.fineract.integrationtests.common.accounting.AccountHelper;
import org.apache.fineract.integrationtests.common.accounting.JournalEntryHelper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class GLAccountIntegrationTest extends BaseLoanIntegrationTest {
@Test
public void createUpdateDeleteGLAccountTest() {
// CREATE
String uniqueString = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newAccount = createGLAccount(uniqueString);
GetGLAccountsResponse accountDetails = AccountHelper.getGLAccount(newAccount.getResourceId());
Assertions.assertEquals(uniqueString, accountDetails.getGlCode());
Assertions.assertEquals(uniqueString, accountDetails.getName());
Assertions.assertEquals(true, accountDetails.getManualEntriesAllowed());
Assertions.assertEquals((long) GLAccountType.INCOME.getValue(), accountDetails.getType().getId());
Assertions.assertEquals(1L, accountDetails.getUsage().getId());
Assertions.assertEquals(uniqueString, accountDetails.getDescription());
// UPDATE
AccountHelper.updateGLAccount(newAccount.getResourceId(), new PutGLAccountsRequest().description("newDescription").name("newName")
.glCode("newGLCode").type(GLAccountType.ASSET.getValue()).manualEntriesAllowed(false).usage(2));
accountDetails = AccountHelper.getGLAccount(newAccount.getResourceId());
Assertions.assertEquals("newDescription", accountDetails.getDescription());
Assertions.assertEquals("newName", accountDetails.getName());
Assertions.assertEquals("newGLCode", accountDetails.getGlCode());
Assertions.assertEquals((long) GLAccountType.ASSET.getValue(), accountDetails.getType().getId());
Assertions.assertEquals(2L, accountDetails.getUsage().getId());
// DELETE
AccountHelper.deleteGLAccount(newAccount.getResourceId());
}
@Test
public void testDeleteGLAccountWhileThereAreChildren() {
String uniqueNameForParent = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newParentAccount = AccountHelper
.createGLAccount(new PostGLAccountsRequest().type(GLAccountType.INCOME.getValue()).glCode(uniqueNameForParent)
.manualEntriesAllowed(true).usage(2).description(uniqueNameForParent).name(uniqueNameForParent));
String uniqueNameForChild = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newChildAccount = AccountHelper.createGLAccount(
new PostGLAccountsRequest().type(GLAccountType.INCOME.getValue()).glCode(uniqueNameForChild).manualEntriesAllowed(true)
.usage(1).parentId(newParentAccount.getResourceId()).description(uniqueNameForChild).name(uniqueNameForChild));
// DELETE
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> AccountHelper.deleteGLAccount(newParentAccount.getResourceId()));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("error.msg.glaccount.glcode.invalid.delete.has.children"));
AccountHelper.deleteGLAccount(newChildAccount.getResourceId());
AccountHelper.deleteGLAccount(newParentAccount.getResourceId());
}
@Test
public void testDeleteGLAccountWhileMappedToProduct() {
String uniqueString = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newAccount = createGLAccount(uniqueString);
loanProductHelper.createLoanProduct(create4IProgressive().enableIncomeCapitalization(true)
.capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT)
.capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION)
.deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue())
.incomeFromCapitalizationAccountId(newAccount.getResourceId())
.capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.FEE));
// DELETE
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> AccountHelper.deleteGLAccount(newAccount.getResourceId()));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("error.msg.glaccount.glcode.invalid.delete.product.mapping"));
}
@Test
public void testDeleteGLAccountWhileThereIsJournalEntry() {
runAt("01 January 2024", () -> {
String uniqueString = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newAccount = createGLAccount(uniqueString);
uniqueString = Utils.uniqueRandomStringGenerator("UNIQUE_FEE_INCOME" + Calendar.getInstance().getTimeInMillis(), 5);
final PostGLAccountsResponse newAccount2 = createGLAccount(uniqueString);
JournalEntryHelper.createJournalEntry("", new JournalEntryCommand().amount(BigDecimal.TEN).officeId(1L).currencyCode("USD")
.locale("en").dateFormat("uuuu-MM-dd").transactionDate(LocalDate.of(2024, 1, 1))
.addCreditsItem(new SingleDebitOrCreditEntryCommand().glAccountId(newAccount.getResourceId()).amount(BigDecimal.TEN))
.addDebitsItem(new SingleDebitOrCreditEntryCommand().glAccountId(newAccount2.getResourceId()).amount(BigDecimal.TEN)));
// DELETE
CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class,
() -> AccountHelper.deleteGLAccount(newAccount.getResourceId()));
assertEquals(403, exception.getResponse().code());
assertTrue(exception.getMessage().contains("error.msg.glaccount.glcode.invalid.delete.transactions.logged"));
});
}
private PostGLAccountsResponse createGLAccount(String uniqueString) {
return AccountHelper.createGLAccount(new PostGLAccountsRequest().type(GLAccountType.INCOME.getValue()).glCode(uniqueString)
.manualEntriesAllowed(true).usage(1).description(uniqueString).name(uniqueString));
}
}
@@ -107,7 +107,7 @@ public class ClientHelper {
return Utils.performServerPost(requestSpec, responseSpec, CREATE_CLIENT_URL, requestBody, "clientId");
}
public PostClientsResponse createClient(final PostClientsRequest request) {
public static PostClientsResponse createClient(final PostClientsRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().clients.create6(request));
}
@@ -22,8 +22,12 @@ import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import java.util.ArrayList;
import java.util.HashMap;
import org.apache.fineract.client.models.DeleteGLAccountsResponse;
import org.apache.fineract.client.models.GetGLAccountsResponse;
import org.apache.fineract.client.models.PostGLAccountsRequest;
import org.apache.fineract.client.models.PostGLAccountsResponse;
import org.apache.fineract.client.models.PutGLAccountsRequest;
import org.apache.fineract.client.models.PutGLAccountsResponse;
import org.apache.fineract.client.util.Calls;
import org.apache.fineract.integrationtests.common.FineractClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
@@ -142,8 +146,19 @@ public class AccountHelper {
return accountRunningBalance;
}
public PostGLAccountsResponse createGLAccount(final PostGLAccountsRequest request) {
public static PostGLAccountsResponse createGLAccount(final PostGLAccountsRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().glAccounts.createGLAccount1(request));
}
public static DeleteGLAccountsResponse deleteGLAccount(final Long requestId) {
return Calls.ok(FineractClientHelper.getFineractClient().glAccounts.deleteGLAccount1(requestId));
}
public static PutGLAccountsResponse updateGLAccount(final Long requestId, final PutGLAccountsRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().glAccounts.updateGLAccount1(requestId, request));
}
public static GetGLAccountsResponse getGLAccount(final Long glAccountId) {
return Calls.ok(FineractClientHelper.getFineractClient().glAccounts.retreiveAccount(glAccountId, false));
}
}
@@ -27,7 +27,11 @@ import java.util.ArrayList;
import java.util.HashMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse;
import org.apache.fineract.client.models.JournalEntryCommand;
import org.apache.fineract.client.models.PostJournalEntriesResponse;
import org.apache.fineract.client.util.Calls;
import org.apache.fineract.client.util.JSON;
import org.apache.fineract.integrationtests.common.FineractClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
import org.junit.jupiter.api.Assertions;
@@ -185,4 +189,7 @@ public class JournalEntryHelper {
return GSON.fromJson(response, GetJournalEntriesTransactionIdResponse.class);
}
public static PostJournalEntriesResponse createJournalEntry(String command, JournalEntryCommand request) {
return Calls.ok(FineractClientHelper.getFineractClient().journalEntries.createGLJournalEntry(command, request));
}
}