From 2afd4aaf839c8e10ec917d746221fbf480c8af26 Mon Sep 17 00:00:00 2001 From: anshul Date: Wed, 30 Sep 2026 17:07:16 +0530 Subject: [PATCH 1/5] Enforce same-user maker-checker restriction --- .../service/CommandSourceService.java | 30 +++----- ...CommandSourceWritePlatformServiceImpl.java | 28 +++++-- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- ...WritePlatformServiceJpaRepositoryImpl.java | 14 +++- ...WritePlatformServiceJpaRepositoryImpl.java | 12 ++- .../tenant/final-changelog-tenant.xml | 1 + ...ally_enabled_maker_checker_permissions.xml | 73 +++++++++++++++++++ .../service/CommandSourceServiceTest.java | 18 +++-- 12 files changed, 188 insertions(+), 48 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 591e46473..1f8ef69fa 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -137,7 +137,16 @@ public class CommandSourceService { String permission = commandSource.getPermissionCode(); boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission); if (isMakerChecker || result.isRollbackTransaction()) { - if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) { + // isApprovedByChecker is only ever true here for a genuine checker approval + // (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run + // validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user + // editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker + // genuinely does not apply there). On the ORIGINAL submission this is always false and `user` + // here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any + // other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's + // request without needing the specific _CHECKER permission, it must never let a maker + // auto-approve their own submission just by holding it. + if (isApprovedByChecker) { commandSource.markAsChecked(user); } else { if (commandSource.isSanitized()) { @@ -151,25 +160,6 @@ public class CommandSourceService { return result; } - private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) { - if (!user.isCheckerSuperUser()) { - return false; - } - if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName()) - || "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) - || "CHARGE".equalsIgnoreCase(commandSource.getEntityName()) - || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) - || "USER".equalsIgnoreCase(commandSource.getEntityName()) - || "ROLE".equalsIgnoreCase(commandSource.getEntityName())) { - return false; - } - return true; - } - private void sanitizeJson(@NonNull CommandSource commandSource, Set sanitizeKeys) { if (sanitizeKeys == null || sanitizeKeys.isEmpty()) { return; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java index 5406ac5b4..f4c7926ba 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/PortfolioCommandSourceWritePlatformServiceImpl.java @@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio private final FromJsonHelper fromApiJsonHelper; private final CommandProcessingService processAndLogCommandService; private final SchedulerJobRunnerReadService schedulerJobRunnerReadService; + // No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user + // can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no + // longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a + // live, independently-configurable global switch (System -> Configurations); deleting this field, + // and by extension deciding that switch should stop existing altogether, is a separate decision + // from closing this specific self-approval loophole. private final ConfigurationDomainService configurationService; @Override @@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio AppUser appUser = this.context.authenticatedUser(); String permissionCode = commandSource.getPermissionCode(); appUser.validateHasCheckerPermissionTo(permissionCode); - if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) { - AppUser maker = commandSource.getMaker(); - if (maker == null) { - throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); - } - if (Objects.equals(appUser.getId(), maker.getId())) { - throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); - } + // Maker-identity rule: the user who submitted a command may never be the one who approves + // (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that + // permission still lets its holder action a DIFFERENT user's request without needing the + // specific _CHECKER permission - see validateHasCheckerPermissionTo() above - it just no + // longer lets them action their own) and not overridable by the enable-same-maker-checker + // global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that + // configuration is left in place and unused for this decision rather than removed, since + // deleting a still-configurable global setting is a separate decision from this one). + AppUser maker = commandSource.getMaker(); + if (maker == null) { + throw new UnsupportedCommandException(permissionCode, "Maker user is missing."); + } + if (Objects.equals(appUser.getId(), maker.getId())) { + throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user."); } return commandSource; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java index d0f945f84..cbb1c1bc8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement elsewhere. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } @@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements .findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement elsewhere. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index 053998bf9..9cd21255f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java index 3abffe3ad..5fda9a977 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java index ca4b88631..8d617f6f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } @@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements .findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java index 54d4f96b7..0f94f146a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsProductWritePlatformServiceJpaRepositoryImpl.java @@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } @@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new SavingsProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new SavingsProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java index 77d7290c9..7eecc2e66 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountEditWritePlatformServiceJpaRepositoryImpl.java @@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, + // it just no longer lets them bypass the hold on their own (matches + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the + // equivalent rule for the classic command pipeline). + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } @@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh .findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request, + // it just no longer lets them bypass the hold on their own (matches + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the + // equivalent rule for the classic command pipeline). + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index b2ce9c6b8..a135ebead 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareProductSelfApprovalNotAllowedException(productId); } @@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP .findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING) .orElseThrow(() -> new ShareProductNoPendingEditException(productId)); - if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) { + // Maker-identity rule: the same user can never approve their own submission, even when they + // hold CHECKER_SUPER_USER - see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the + // equivalent enforcement in the classic command pipeline. + if (pendingEdit.getMaker().getId().equals(checker.getId())) { throw new ShareProductSelfApprovalNotAllowedException(productId); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index d1385c84d..34bf62897 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -156,4 +156,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml new file mode 100644 index 000000000..bbee551d9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_persist_manually_enabled_maker_checker_permissions.xml @@ -0,0 +1,73 @@ + + + + + + + + code IN ( + 'UPDATE_CURRENCY', + 'CREATE_FUND', + 'UPDATE_WORKINGDAYS', + 'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE', + 'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK', + 'UPDATE_BRANCHPROFILE', + 'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE', + 'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE', + 'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE', + 'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT', + 'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK', + 'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT', + 'CREATE_USERAGREEMENT', + 'UPDATE_BATCH_BUSINESS_STEP' + ) + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index c4db9dadf..ee634c61c 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -116,7 +116,11 @@ public class CommandSourceServiceTest { } @Test - public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() { + public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() { + // A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission - + // CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see + // PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This + // replaces the old bypass this test used to assert. NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class); JsonCommand jsonCommand = Mockito.mock(JsonCommand.class); CommandSource commandSource = Mockito.mock(CommandSource.class); @@ -125,6 +129,8 @@ public class CommandSourceServiceTest { Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT"); Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT"); Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW"); + Mockito.when(commandSource.getId()).thenReturn(300L); + Mockito.when(commandSource.getResourceId()).thenReturn(400L); Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true); Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true); @@ -132,12 +138,12 @@ public class CommandSourceServiceTest { .withCommandId(1L).build(); Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult); - org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand, - commandSource, superUser, false); + Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> { + underTest.processCommand(handler, jsonCommand, commandSource, superUser, false); + }); - Assertions.assertNotNull(result); - Mockito.verify(commandSource).markAsChecked(superUser); - Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval(); + Mockito.verify(commandSource).markAsAwaitingApproval(); + Mockito.verify(commandSource, Mockito.never()).markAsChecked(any()); } @Test From 11a0d502be34f95a58f6694d3f2b2eda94510d95 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 1 Oct 2026 12:03:32 +0530 Subject: [PATCH 2/5] Enforce same-user maker-checker restriction --- .../parts/0012_seed_default_platform_api_client.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml index a75be07a2..b10999153 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -27,17 +27,17 @@ #/platform-admin/menu-access works out of the box the first time the app is stood up, without anyone needing shell/DB access to mint the first key by hand. It is deliberately the SAME raw value on every deployment/tenant (requested behaviour) rather than a random - one generated per install -- the trade-off, on purpose: this value is visible to anyone who + one generated per install - the trade-off, on purpose: this value is visible to anyone who can read this source file, exactly like a default admin password shipped in a seed script. It is a bootstrap convenience, not a secret. Rotate it for any deployment that is not purely local/dev: insert a new row with a real random key's hash (see ApiClientCredentialService's - hashing) and either deactivate or DELETE this row -- POST /v1/apiclients is intentionally + hashing) and either deactivate or DELETE this row - POST /v1/apiclients is intentionally disabled (see ApiClientAdminApiResource), so rotation is a direct m_api_client write, same as this seed. Raw key (only shown here, since it is fixed and not a secret to rotate away from): cbsx_default_platform_operator_key_2026 - api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase -- matching + api_key_hash below is SHA-256 of that exact string, hex-encoded, lowercase - matching ApiClientCredentialService#sha256Hex. --> From 16c67f6701dd1953c1cf62353fe9eb6be73180ef Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 09:12:17 +0530 Subject: [PATCH 3/5] feat: Fixed Deposit - premature close logic, deposit account closure type, transaction validation, domain service and read service updates --- .../savings/DepositAccountOnClosureType.java | 15 ++- .../savings/DepositsApiConstants.java | 3 + .../savings/service/SavingsEnumerations.java | 4 + ...epositAccountTransactionDataValidator.java | 22 ++++- .../DepositAccountDomainServiceJpa.java | 96 +++++++++++++++++++ .../savings/domain/FixedDepositAccount.java | 14 ++- ...eMatureCalculationPlatformServiceImpl.java | 14 ++- ...DepositAccountReadPlatformServiceImpl.java | 6 +- .../src/main/resources/application.properties | 4 +- 9 files changed, 167 insertions(+), 11 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java index c4939917b..03a4763dc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java @@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType { TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), // REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), // REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), // - PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); // + PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), // + TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); // private final Integer value; private final String code; @@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType { case 300 -> REINVEST_PRINCIPAL_AND_INTEREST; case 400 -> REINVEST_PRINCIPAL_ONLY; case 500 -> PARTIAL_REINVEST; + case 600 -> TRANSFER_TO_GL; default -> INVALID; }; } @@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType { return this.equals(PARTIAL_REINVEST); } + /** + * Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash. + * Only the fixed-deposit premature-closure handler implements it -- see + * DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it + * in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the + * terminal "pay the balance out in cash" branch. + */ + public boolean isTransferToGl() { + return this.equals(TRANSFER_TO_GL); + } + // TODO: do we really need this?!? public static Object[] integerValues() { return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 2ca00fc8c..fd2b66e0d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -212,6 +212,9 @@ public final class DepositsApiConstants { public static final String onAccountClosureIdParamName = "onAccountClosureId"; public static final String transferDescriptionParamName = "transferDescription"; public static final String toSavingsAccountIdParamName = "toSavingsAccountId"; + // Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are + // credited straight to this GL instead of a savings account or cash. + public static final String toGlAccountIdParamName = "toGlAccountId"; public static final String reinvestAmountParamName = "reinvestAmount"; // Lets a maker waive the product's configured pre-closure penalty for one premature closure -- // see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java index 3ed32ff1c..6c4a4bdc1 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java @@ -801,6 +801,10 @@ public final class SavingsEnumerations { optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(), DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest"); break; + case TRANSFER_TO_GL: + optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(), + DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account"); + break; } return optionData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java index 2aeaa146d..a841f1b1b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java @@ -78,7 +78,8 @@ public class DepositAccountTransactionDataValidator { DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, - DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName)); + DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName, + DepositsApiConstants.prematureClosureReasonIdParamName)); private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList( DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, @@ -247,6 +248,25 @@ public class DepositAccountTransactionDataValidator { } else if (accountOnClosureType.isReinvest() && isPreMatureClose) { baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId) .failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close"); + } else if (accountOnClosureType.isTransferToGl()) { + // Only the FD premature-closure handler books the GL leg. Offering it anywhere else would + // fall through to that handler's terminal "withdraw the whole balance" case and silently pay + // the deposit out in cash, which is exactly what the partial.reinvest guard above prevents. + if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) { + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "transfer.to.gl.not.allowed", + "Transfer to a GL account is only supported for fixed-deposit premature closure"); + } + final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element); + baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId) + .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode()); + } else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) { + // Premature closure proceeds have to be traceable into a savings account, so the cash payout + // is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl) + // and is refused here as well -- otherwise a direct API call could still take the cash route. + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "withdraw.deposit.not.allowed", + "Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account"); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 6e4102a71..80ab253d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -25,6 +25,8 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavin import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; @@ -38,6 +40,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants; import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat; import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper; @@ -48,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.account.PortfolioAccountType; import org.apache.fineract.portfolio.account.data.AccountTransferDTO; import org.apache.fineract.portfolio.account.domain.AccountTransferType; @@ -76,6 +80,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -96,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository; private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository; private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -846,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck); this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); + } else if (closureType.isTransferToGl()) { + final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName); + final BigDecimal proceeds = account.getAccountBalance(); + // Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer + // account rather than cash/vault -- nothing is paid over the counter here. The offset below then + // moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the + // net posting as Dr Savings Control / Cr chosen GL. + final boolean isInternalTransfer = true; + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false, + isRegularTransaction, isInternalTransfer); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate, + command.stringValueOfParameterNamed(transferDescriptionParamName)); } else { final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); @@ -863,6 +884,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi return savingsTransactionId; } + /** + * Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked: + * Dr the Liability Transfer account / Cr that GL account. + *

+ * The withdrawal booked immediately before this was declared an account transfer, so the accounting engine + * has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair + * netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real + * createJournalEntry command rather than written straight to the ledger, so it reverses, reports and + * reconciles exactly like any other manual entry. + */ + private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount, + final LocalDate closedDate, final String description) { + + final Long suspenseGlAccountId = liabilityTransferGlAccountId(); + + final JsonObject debitEntry = new JsonObject(); + debitEntry.addProperty("glAccountId", suspenseGlAccountId); + debitEntry.addProperty("amount", amount); + final JsonArray debits = new JsonArray(); + debits.add(debitEntry); + + final JsonObject creditEntry = new JsonObject(); + creditEntry.addProperty("glAccountId", toGlAccountId); + creditEntry.addProperty("amount", amount); + final JsonArray credits = new JsonArray(); + credits.add(creditEntry); + + final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber()); + if (description != null && !description.isBlank()) { + comments.append(" -- ").append(description); + } + + final String dateFormat = "dd MMMM yyyy"; + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", account.officeId()); + payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH))); + payload.addProperty("dateFormat", dateFormat); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", account.getCurrency().getCode()); + payload.addProperty("comments", comments.toString()); + payload.add("debits", debits); + payload.add("credits", credits); + + final String json = payload.toString(); + final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null, + null, null, null, null, null, null, null, null, null, null, null, null, null); + this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand); + } + + /** + * The GL account the withdrawal above credited, and therefore the exact account this entry has to debit + * to clear it. + *

+ * It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In + * Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal + * as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping + * instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry + * collapse onto whichever account that mapping names. + *

+ * Refusing outright when it is unmapped is deliberate: silently picking another account would put the + * closure's money somewhere nobody chose. + */ + private Long liabilityTransferGlAccountId() { + final List glAccountIds = this.jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC", + Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue()); + if (glAccountIds.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped", + "No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure" + + " proceeds cannot be routed to a GL account. Map it first.", + AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue()); + } + return glAccountIds.get(0); + } + /** * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 788ade745..b43d758ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -946,13 +946,21 @@ public class FixedDepositAccount extends SavingsAccount { * the term restarted belongs to a term that has already been settled. */ private Money totalInterestPosted() { + final boolean renewedTerm = isRenewedTerm(); final LocalDate currentTermStartDate = depositStartDate(); Money interestPostedToDate = Money.zero(this.currency); for (final SavingsAccountTransaction transaction : this.transactions) { - if (transaction.isInterestPostingAndNotReversed() - && !DateUtils.isBefore(transaction.getTransactionDate(), currentTermStartDate)) { - interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); + if (!transaction.isInterestPostingAndNotReversed()) { + continue; } + // Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the + // previous term's closing interest posting is dated the SAME day the new term starts, so an + // inclusive bound still counts it here while the posting-period walk excludes it -- leaving + // the old term's entire interest subtracted from a maturity amount it was never part of. + if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) { + continue; + } + interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); } return interestPostedToDate; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java index 822d6bcb8..b8df20388 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java @@ -70,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType); DepositAccountData accountData = null; - Collection onAccountClosureOptions = SavingsEnumerations - .depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT, - DepositAccountOnClosureType.TRANSFER_TO_SAVINGS }); + // Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed + // deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the + // counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright + // by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it + // either. TRANSFER_TO_GL is offered only for fixed deposits because only + // DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg. + final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit() + ? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS, + DepositAccountOnClosureType.TRANSFER_TO_GL } + : new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS }; + Collection onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService .retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 421ed87ad..914fd86b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue()); + // TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one + // handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in + // DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the + // normal Close and Payment screens -- would only get it refused by validateClosing. Collection onAccountClosureOptions = SavingsEnumerations .depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values()) - .filter(type -> includePartialReinvest || !type.isPartialReinvest()) + .filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl()) .toArray(DepositAccountOnClosureType[]::new)); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..108e7421a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} From aa264097e707e7d711cef41b61a37337d5be5fce Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 11:54:28 +0530 Subject: [PATCH 4/5] fix(savings,accounting): fix premature interest calculation date bounds and load interest payable GL mapping on edit Details of changes: 1. Premature Closure 1-Day Shortfall Fix (FixedDepositAccount & RecurringDepositAccount): - In calculatePreMatureInterest, explicitly reset transaction end-of-day balances to preMatureDate before invoking calculateInterestPayable. - Ensure end-of-day balances are restored to current business local date in a finally block. - Resolves the issue where premature closure missed 1 day of interest (e.g., -17.80 clawback) due to transaction end-of-day balances defaulting to the previous day when premature date matched current business date. 2. Interest Transfer Exclusions (SavingsAccountInterestPostingServiceImpl): - Exclude interest-transfer withdrawal transactions from non-interest posting transactions and end-of-day cumulative balance updates for fixed deposits transferring interest to linked accounts. 3. Interest Payable GL Load on Edit (AccountingConstants & ProductToGLAccountMappingReadPlatformServiceImpl): - Added INTEREST_PAYABLE(17) enum constant to CashAccountsForSavings in AccountingConstants. - Mapped CashAccountsForSavings.INTEREST_PAYABLE to SavingProductAccountingDataParams.INTEREST_PAYABLE in ProductToGLAccountMappingReadPlatformServiceImpl. - Fixes the bug where Liability -> Interest Payable Account GL was not loading in the product edit form (e.g., /products/fixed-deposit-products/:id/edit) even after being saved. --- ...AccountMappingReadPlatformServiceImpl.java | 2 ++ .../common/AccountingConstants.java | 1 + .../savings/domain/FixedDepositAccount.java | 21 +++++++----- .../domain/RecurringDepositAccount.java | 21 +++++++----- ...ingsAccountInterestPostingServiceImpl.java | 32 +++++++++++++++++-- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java index 65b754870..2e87bde94 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java @@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData); } else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) { accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData); + } else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) { + accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData); // Income } else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) { accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData); diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index a5a4ead03..0ce7582f9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -270,6 +270,7 @@ public final class AccountingConstants { INCOME_FROM_INTEREST(12), // LOSSES_WRITTEN_OFF(13), // ESCHEAT_LIABILITY(14), // + INTEREST_PAYABLE(17), // /** * Where the paise dropped from a cash closure are absorbed. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index b43d758ce..5226ede94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -829,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount { final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final MathContext mc = MathContext.DECIMAL64; - final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); + try { + final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - Money interestOnMaturity = Money.zero(this.currency); + Money interestOnMaturity = Money.zero(this.currency); - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); - interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); + interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + } + + return interestOnMaturity; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); } - - return interestOnMaturity; } public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java index c4c07b19e..62cb85a65 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java @@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount { final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final MathContext mc = MathContext.DECIMAL64; - final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); + try { + final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - Money interestOnMaturity = Money.zero(this.currency); + Money interestOnMaturity = Money.zero(this.currency); - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); - interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); + interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + } + this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); + return interestOnMaturity; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); } - this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); - return interestOnMaturity; } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index 4b255ddf0..c936dba03 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return false; } + protected Collection interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) { + if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId()); + } + + private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) { + return savingsAccountData.isTransferInterestToLinkedAccount() + && DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId()); + } + + protected List excludeInterestTransferWithdrawals( + final List transactions, final SavingsAccountData savingsAccountData) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + private List retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) { final List listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData); final List orderedNonInterestPostingTransactions = new ArrayList<>(); for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } } @@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) { + final List filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted, + savingsAccountData); // loop over transactions in reverse LocalDate endOfBalanceDate = interestPostingUpToDate; - for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) { - final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i); + for (int i = filteredTransactions.size() - 1; i >= 0; i--) { + final SavingsAccountTransactionData transaction = filteredTransactions.get(i); if (transaction.isNotReversed() && !transaction.isReversalTransaction() && !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) { transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()), From fd827dec274903e6757c91eee20dae95420fa340 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 4 Oct 2026 16:02:25 +0530 Subject: [PATCH 5/5] fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation --- .../savings/DepositsApiConstants.java | 6 +- .../service/AccountingProcessorHelper.java | 102 +++++++----- ...DepositProductReadPlatformServiceImpl.java | 35 +++- .../savings/data/DepositProductData.java | 19 +++ .../savings/data/FixedDepositProductData.java | 7 +- .../domain/DepositProductAssembler.java | 9 + .../savings/domain/SavingsProduct.java | 4 + .../SavingsSchedularInterestPoster.java | 156 +++++++++++++++--- 8 files changed, 275 insertions(+), 63 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index fd2b66e0d..aa88c1989 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -252,7 +252,7 @@ public final class DepositsApiConstants { SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(), SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName, - SavingsApiConstants.taxGroupIdParamName)); + SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName)); private static final Set PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName)); @@ -390,6 +390,8 @@ public final class DepositsApiConstants { fixedDepositRequestData.add("paymentChannelToFundSourceMappings"); fixedDepositRequestData.add("feeToIncomeAccountMappings"); fixedDepositRequestData.add("penaltyToIncomeAccountMappings"); + fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName); + fixedDepositRequestData.add("otherGlMappingsMap"); return fixedDepositRequestData; } @@ -501,6 +503,8 @@ public final class DepositsApiConstants { recurringDepositRequestData.add("paymentChannelToFundSourceMappings"); recurringDepositRequestData.add("feeToIncomeAccountMappings"); recurringDepositRequestData.add("penaltyToIncomeAccountMappings"); + recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName); + recurringDepositRequestData.add("otherGlMappingsMap"); return recurringDepositRequestData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 1ee9fe013..062bb76a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.closure.domain.GLClosureRepository; @@ -83,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +@Slf4j @RequiredArgsConstructor public class AccountingProcessorHelper { @@ -662,16 +664,25 @@ public class AccountingProcessorHelper { final GLAccount payableAccount = Optional .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) - .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) + .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, + SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account"))) .orElse(controlAccount); - final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) + final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, + SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account")) .orElse(payableAccount); + log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}", + savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(), + controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(), + amount, isReversal); + // A product that names no payable falls back to Savings Control above. The chain below would // then open with Dr Savings Control / Cr Interest On Savings and close with its exact // reverse, the two cancelling: the expense never charged and the member never credited. Such // a product has no liability to draw down, so it keeps the single pair it always posted. if (payableAccount.getId().equals(controlAccount.getId())) { + log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry", + payableAccount.getId()); postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); return; @@ -697,28 +708,34 @@ public class AccountingProcessorHelper { final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType()); final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode); final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO); + log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}", + alreadyAccrued, shortfall, payableIsLiability, isReversal); if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) { - // Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run. + log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]", + interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall); + // Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run. postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, shortfall, false); } - // Out of the payable, into the interest-paid account the product names. - postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - - // A separate paid account, where the product names one, sits between interest-paid and the - // member; otherwise the payout leaves straight from interest-paid. final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId()); if (hasSeparatePaidAccount) { - postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]", + payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount); + // Stage 2: Out of the payable, into the interest-paid transit/clearing account + postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // Stage 3: Out of the interest-paid account, into the member's control account + postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + } else { + log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]", + payableAccount.getName(), controlAccount.getName(), amount); + // Payout directly from payable into member's control account + postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal); } - - // Into the member's own balance. - final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount; - postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, - isReversal); } /** @@ -792,34 +809,37 @@ public class AccountingProcessorHelper { return balance == null ? BigDecimal.ZERO : balance; } - private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { - if (savingsProductId == null) { + private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) { + if (savingsProductId == null || accountNames == null || accountNames.length == 0) { return null; } - // The column holds an empty string on a product saved with no named mappings, and casting - // that to jsonb is an error rather than an empty document - so the cast is done inside a - // subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE - // clause in any order, so guarding at the same query level as the cast would only work by - // luck. - final List found = jdbcTemplate.queryForList(""" - SELECT gl.id - FROM (SELECT other_gl_mappings::jsonb AS doc - FROM m_savings_product - WHERE id = ? - AND other_gl_mappings IS NOT NULL - AND other_gl_mappings <> '' - AND other_gl_mappings LIKE '{%') sp - CROSS JOIN LATERAL jsonb_array_elements( - COALESCE(sp.doc -> 'assets', '[]'::jsonb) - || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) - || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) - || COALESCE(sp.doc -> 'income', '[]'::jsonb) - ) AS m(entry) - JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' - WHERE m.entry ->> 'accountName' = ? - LIMIT 1 - """, Long.class, savingsProductId, accountName); - return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + for (final String accountName : accountNames) { + if (accountName == null) { + continue; + } + final List found = jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, savingsProductId, accountName); + if (!found.isEmpty()) { + return glAccountRepository.findById(found.get(0)).orElse(null); + } + } + return null; } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index 24f1b6aaf..5bb9f4f2c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -19,10 +19,13 @@ package org.apache.fineract.portfolio.savings.service; import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collection; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.AccountingEnumerations; @@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead } } + private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken>>() {}.getType(); + private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken>>>() {}.getType(); + + private static List> parseOtherGlMappings(final String otherGlMappingsJson) { + if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) { + return null; + } + com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class); + if (!element.isJsonArray()) { + return null; + } + return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE); + } + + private static Map>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) { + if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) { + return null; + } + com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class); + if (!element.isJsonObject()) { + return null; + } + return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE); + } + private abstract static class DepositProductMapper implements RowMapper { private final String schemaSql; @@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,"); sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); sqlBuilder.append("sp.accounting_type as accountingType, "); + sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, "); sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, "); sqlBuilder.append("sp.withhold_tax as withHoldTax,"); sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName "); @@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); } - return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate, + final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate, compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType, lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax, taxGroupData); + final String otherGlMappingsJson = rs.getString("otherGlMappings"); + data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson)); + data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson)); + return data; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java index 04c72bf02..85e518bc9 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java @@ -58,6 +58,9 @@ public class DepositProductData { // accounting protected final EnumOptionData accountingRule; + + private List> otherGlMappings; + private Map>> otherGlMappingsMap; protected final Map accountingMappings; protected final Collection paymentChannelToFundSourceMappings; protected final Collection feeToIncomeAccountMappings; @@ -453,4 +456,20 @@ public class DepositProductData { public EnumOptionData getInterestCompoundingPeriodType() { return interestCompoundingPeriodType; } + + public List> getOtherGlMappings() { + return this.otherGlMappings; + } + + public void setOtherGlMappings(List> otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + + public Map>> getOtherGlMappingsMap() { + return this.otherGlMappingsMap; + } + + public void setOtherGlMappingsMap(Map>> otherGlMappingsMap) { + this.otherGlMappingsMap = otherGlMappingsMap; + } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 88004012f..1909f9190 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -244,7 +244,7 @@ public final class FixedDepositProductData extends DepositProductData { final Collection periodFrequencyTypeOptions = null; final Collection taxGroupOptions = null; - return new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName, + final FixedDepositProductData result = new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName, depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate, depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType, depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType, @@ -258,6 +258,9 @@ public final class FixedDepositProductData extends DepositProductData { minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions, depositProductData.withHoldTax, depositProductData.taxGroup, taxGroupOptions); + result.setOtherGlMappings(depositProductData.getOtherGlMappings()); + result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap()); + return result; } public static FixedDepositProductData lookup(final Long id, final String name) { @@ -609,6 +612,8 @@ public final class FixedDepositProductData extends DepositProductData { this.balanceFrequency = src.balanceFrequency; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.renewalGracePeriodDays = src.renewalGracePeriodDays; + this.setOtherGlMappings(src.getOtherGlMappings()); + this.setOtherGlMappingsMap(src.getOtherGlMappingsMap()); } private Boolean withdrawalFeeForTransfers; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 64402d2e7..d65793dc8 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -52,6 +52,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPe import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minBalanceForInterestCalculationParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.otherGlMappingsParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; @@ -247,6 +248,10 @@ public class DepositProductAssembler { fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); } + if (command.hasParameter(otherGlMappingsParamName)) { + fixedDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName)); + } + fixedDepositProduct.validateDomainRules(); return fixedDepositProduct; @@ -402,6 +407,10 @@ public class DepositProductAssembler { recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); } + if (command.hasParameter(otherGlMappingsParamName)) { + recurringDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName)); + } + recurringDepositProduct.validateDomainRules(); return recurringDepositProduct; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index 1f21f0a30..d47b85254 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -1123,6 +1123,10 @@ public class SavingsProduct extends AbstractPersistableCustom { return this.otherGlMappings; } + public void setOtherGlMappings(final String otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + // Enhanced Savings Product Getters and Setters public String getAccountType() { return accountType; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index 6928f730b..0db77af5e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -96,6 +97,63 @@ public class SavingsSchedularInterestPoster { } } + private final Map namedGlAccountCache = new HashMap<>(); + + private Long findNamedGlAccount(final Long savingsProductId, final String... accountNames) { + if (savingsProductId == null || accountNames == null || accountNames.length == 0) { + return null; + } + for (final String accountName : accountNames) { + if (accountName == null) { + continue; + } + final String cacheKey = savingsProductId + ":" + accountName.trim().toLowerCase(); + if (this.namedGlAccountCache.containsKey(cacheKey)) { + final Long cached = this.namedGlAccountCache.get(cacheKey); + if (cached != null) { + return cached; + } + continue; + } + final List found = this.jdbcTemplate.queryForList(""" + SELECT gl.id + FROM (SELECT other_gl_mappings::jsonb AS doc + FROM m_savings_product + WHERE id = ? + AND other_gl_mappings IS NOT NULL + AND other_gl_mappings <> '' + AND other_gl_mappings LIKE '{%') sp + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(sp.doc -> 'assets', '[]'::jsonb) + || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) + || COALESCE(sp.doc -> 'expenses', '[]'::jsonb) + || COALESCE(sp.doc -> 'income', '[]'::jsonb) + ) AS m(entry) + JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' + WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?)) + LIMIT 1 + """, Long.class, savingsProductId, accountName); + if (!found.isEmpty()) { + this.namedGlAccountCache.put(cacheKey, found.get(0)); + return found.get(0); + } else { + this.namedGlAccountCache.put(cacheKey, null); + } + } + return null; + } + + private void addJournalEntryLeg(final List params, final Long accountId, final Long officeId, + final String currencyCode, final String transactionId, final Long savingsTransactionId, + final LocalDate transactionDate, final JournalEntryType entryType, final BigDecimal amount, + final Long savingsAccountId, final OffsetDateTime auditDatetime, final Long userId, + final LocalDate businessDate) { + params.add(new Object[] { accountId, officeId, null, currencyCode, transactionId, + savingsTransactionId, null, false, null, false, transactionDate, entryType.getValue().longValue(), + amount, null, entryType.getValue().longValue(), savingsAccountId, auditDatetime, auditDatetime, false, + BigDecimal.ZERO, BigDecimal.ZERO, null, transactionDate, null, userId, userId, businessDate }); + } + private void batchUpdateJournalEntries(final List savingsAccountDataList, final HashMap savingsAccountTransactionDataHashMap) throws DataAccessException, NullPointerException { @@ -114,26 +172,86 @@ public class SavingsSchedularInterestPoster { savingsAccountTransactionData.setId(dataFromFetch.getId()); if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 && savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) { - OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); - paramsForGLInsertion.add( - new Object[] { savingsAccountTransactionData.getAccountCredit(), savingsAccountData.getOfficeId(), null, - currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, - null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); + final OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final String txnIdentifier = SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(); + final Long savingsTxnId = savingsAccountTransactionData.getId(); + final LocalDate txnDate = savingsAccountTransactionData.getTransactionDate(); + final BigDecimal amount = savingsAccountTransactionData.getAmount(); + final Long savingsAccountId = savingsAccountData.getId(); + final Long officeId = savingsAccountData.getOfficeId(); - paramsForGLInsertion - .add(new Object[] { savingsAccountTransactionData.getAccountDebit(), savingsAccountData.getOfficeId(), null, - currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), - savingsAccountTransactionData.getId(), null, false, null, false, - savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(), - savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, - null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, - DateUtils.getBusinessLocalDate() }); + if (Boolean.TRUE.equals(isOverdraft)) { + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + } else { + final Long expenseAccountId = savingsAccountData.getGlAccountIdForInterestOnSavings(); + final Long controlAccountId = savingsAccountData.getGlAccountIdForSavingsControl(); + Long payableAccountId = savingsAccountData.getGlAccountIdForInterestPayable(); + final Long productId = savingsAccountData.getSavingsProductId() != null + ? savingsAccountData.getSavingsProductId() + : savingsAccountData.getProductId(); + + if (payableAccountId == null || payableAccountId == 0) { + payableAccountId = findNamedGlAccount(productId, + "Fixed Deposit Interest Payable", "Savings Interest Payable", "Recurring Deposit Interest Payable", + "Interest Payable", "Interest Payable Account"); + } + + final Long paidAccountId = findNamedGlAccount(productId, + "Fixed Deposit Interest Paid", "Savings Interest Paid", "Recurring Deposit Interest Paid", + "Interest Paid", "Interest Paid Account"); + + final boolean hasPayable = payableAccountId != null && payableAccountId != 0 && !payableAccountId.equals(controlAccountId); + final boolean hasPaid = paidAccountId != null && paidAccountId != 0 && !paidAccountId.equals(payableAccountId); + + log.info("SavingsSchedularInterestPoster: accountId={}, productId={}, expense={}, payable={}, paid={}, control={}, amount={}", + savingsAccountId, productId, expenseAccountId, payableAccountId, paidAccountId, controlAccountId, amount); + + if (hasPayable && hasPaid) { + // Stage 1: Dr Interest Expense / Cr Interest Payable + addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 2: Dr Interest Payable / Cr Interest Paid + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 3: Dr Interest Paid / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + } else if (hasPayable) { + // Stage 1: Dr Interest Expense / Cr Interest Payable + addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + + // Stage 2: Dr Interest Payable / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier, + savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate); + } else { + // Standard 2-leg entry: Dr Interest Expense / Cr Savings Control + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId, + currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, + savingsAccountId, auditDatetime, userId, businessDate); + } + } } }