diff --git a/build.gradle b/build.gradle index fc2ca9941..c2dc2e63f 100644 --- a/build.gradle +++ b/build.gradle @@ -154,7 +154,7 @@ gitVersioning.apply { } } rev { - version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT' + version = '1.15.0-SNAPSHOT' } } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java index 1cfba6559..365ffe441 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformService.java @@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService { Map fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType); + Map fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType); + List fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId); List fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId); 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 8c6ed8881..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 @@ -202,6 +202,20 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product return accountMappingDetails; } + /** A current product's mappings, which live under their own CURRENT product type, laid out as a savings product's. */ + @Override + public Map fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) { + final List mappings = productToGLAccountMappingRepository.findAllRegularMappings(currentProductId, + PortfolioProductType.CURRENT.getValue()); + if (AccountingValidations.isCashBasedAccounting(accountingType)) { + return setCashSavingsProductToGLAccountMaps(mappings); + } + if (AccountingValidations.isAccrualPeriodicBasedAccounting(accountingType)) { + return setAccrualPeriodicSavingsProductToGLAccountMaps(mappings); + } + return null; + } + @Override public List fetchPaymentTypeToFundSourceMappingsForLoanProduct(final Long loanProductId) { return fetchPaymentTypeToFundSourceMappings(PortfolioProductType.LOAN, loanProductId); @@ -463,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-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index 2fa7358c0..cc489048f 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -264,6 +264,13 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); + // Interest Payable is editable on cash too, and optional. Interest posting routes + // through it on either basis, so a product can name one to carry interest declared + // but not yet credited; a product that names none still posts straight to Savings + // Control. Created-or-merged because a product configured before this row existed + // has no mapping to merge into. + createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), + savingsProductId, AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), changes); break; case ACCRUAL_PERIODIC: // asset diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java index 6b4c43e63..ed6891586 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepository.java @@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository, JpaSpecif Collection findTellerByOfficeId(Long officeId); long countByDeletedFalse(); + + long countByOfficeIdAndDeletedFalse(Long officeId); } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java index 494c4069c..b29f3d19c 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/domain/TellerRepositoryWrapper.java @@ -58,4 +58,9 @@ public class TellerRepositoryWrapper { public boolean existsAny() { return this.repository.countByDeletedFalse() > 0; } + + /** Whether the branch already has a (not deleted) teller — each branch keeps one vault of its own. */ + public boolean existsInOffice(final Long officeId) { + return this.repository.countByOfficeIdAndDeletedFalse(officeId) > 0; + } } diff --git a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java index a465aa44d..973eca197 100644 --- a/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java +++ b/fineract-branch/src/main/java/org/apache/fineract/organisation/teller/exception/TellerAlreadyExistsException.java @@ -28,4 +28,9 @@ public class TellerAlreadyExistsException extends AbstractPlatformDomainRuleExce public TellerAlreadyExistsException() { super(ERROR_MESSAGE_CODE, DEFAULT_ERROR_MESSAGE); } + + public TellerAlreadyExistsException(final String officeName) { + super(ERROR_MESSAGE_CODE, "Cannot create teller: " + officeName + " already has a teller. Each branch has one teller.", + officeName); + } } 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-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 2094dda38..98ff9772f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -756,6 +756,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder deleteFund(final Long fundId) { + this.actionName = "DELETE"; + this.entityName = "FUND"; + this.entityId = fundId; + this.href = "/funds/" + fundId; + return this; + } + public CommandWrapperBuilder createReport() { this.actionName = "CREATE"; this.entityName = "REPORT"; @@ -884,6 +892,70 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createPaymentType() { + this.actionName = "CREATE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = null; + this.href = "/paymenttypes/template"; + return this; + } + + public CommandWrapperBuilder updatePaymentType(final Long paymentTypeId) { + this.actionName = "UPDATE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = paymentTypeId; + this.href = "/paymenttypes/" + paymentTypeId; + return this; + } + + public CommandWrapperBuilder deletePaymentType(final Long paymentTypeId) { + this.actionName = "DELETE"; + this.entityName = "PAYMENTTYPE"; + this.entityId = paymentTypeId; + this.href = "/paymenttypes/" + paymentTypeId; + return this; + } + + public CommandWrapperBuilder createSponsorBank() { + this.actionName = "CREATE"; + this.entityName = "SPONSORBANK"; + this.entityId = null; + this.href = "/sponsorbanks"; + return this; + } + + public CommandWrapperBuilder updateSponsorBank(final Long sponsorBankId) { + this.actionName = "UPDATE"; + this.entityName = "SPONSORBANK"; + this.entityId = sponsorBankId; + this.href = "/sponsorbanks/" + sponsorBankId; + return this; + } + + public CommandWrapperBuilder updateSponsorDdGlMapping() { + this.actionName = "UPDATE"; + this.entityName = "SPONSORDDGLMAPPING"; + this.entityId = null; + this.href = "/sponsordd/gl-mapping"; + return this; + } + + public CommandWrapperBuilder updateInstitutionProfile() { + this.actionName = "UPDATE"; + this.entityName = "INSTITUTIONPROFILE"; + this.entityId = null; + this.href = "/institutionprofile"; + return this; + } + + public CommandWrapperBuilder updateBranchProfile(final Long officeId) { + this.actionName = "UPDATE"; + this.entityName = "BRANCHPROFILE"; + this.entityId = officeId; + this.href = "/branchprofile/" + officeId; + return this; + } + public CommandWrapperBuilder createLoanProduct() { this.actionName = "CREATE"; this.entityName = "LOANPRODUCT"; @@ -3398,6 +3470,13 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder updateCurrency() { + this.actionName = "UPDATE"; + this.entityName = "CURRENCY"; + this.href = "/currencies"; + return this; + } + public CommandWrapperBuilder updateExternalServiceProperties(final String externalServiceName) { this.actionName = "UPDATE"; this.entityName = "EXTERNALSERVICES"; @@ -4738,4 +4817,36 @@ public class CommandWrapperBuilder { this.href = "/loans/" + loanId + "/originators/" + originatorId; return this; } + + public CommandWrapperBuilder createSurvey() { + this.actionName = "CREATE"; + this.entityName = "SURVEY"; + this.entityId = null; + this.href = "/surveys"; + return this; + } + + public CommandWrapperBuilder updateSurvey(final Long surveyId) { + this.actionName = "UPDATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } + + public CommandWrapperBuilder activateSurvey(final Long surveyId) { + this.actionName = "ACTIVATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } + + public CommandWrapperBuilder deactivateSurvey(final Long surveyId) { + this.actionName = "DEACTIVATE"; + this.entityName = "SURVEY"; + this.entityId = surveyId; + this.href = "/surveys/" + surveyId; + return this; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index ee5b62605..93e097cc6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -43,6 +43,7 @@ public enum JobName { UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // + SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( "Add Accrual Transactions For Loans With Income Posted As Transactions"), // EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), // @@ -73,6 +74,7 @@ public enum JobName { CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), // AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), // CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), // + SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), // ; // private final String name; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java index ff3ac4e61..953541927 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/api/CurrenciesApiResource.java @@ -27,13 +27,14 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; -import org.apache.fineract.organisation.monetary.command.CurrencyUpdateCommand; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.organisation.monetary.data.CurrencyConfigurationData; import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest; -import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse; import org.apache.fineract.organisation.monetary.service.OrganisationCurrencyReadPlatformService; import org.springframework.stereotype.Component; @@ -44,7 +45,8 @@ import org.springframework.stereotype.Component; public class CurrenciesApiResource { private final OrganisationCurrencyReadPlatformService readPlatformService; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -65,13 +67,12 @@ public class CurrenciesApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update Currency Configuration", description = "Updates the list of currencies permitted for use.") - public CurrencyUpdateResponse updateCurrencies(@Valid CurrencyUpdateRequest request) { - final var command = new CurrencyUpdateCommand(); + public String updateCurrencies(@Valid final CurrencyUpdateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateCurrency() + .withJson(toApiJsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return toApiJsonSerializer.serialize(result); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java new file mode 100644 index 000000000..d4aa3a9e1 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/monetary/handler/UpdateCurrencyCommandHandler.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.organisation.monetary.handler; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.organisation.monetary.data.CurrencyUpdateRequest; +import org.apache.fineract.organisation.monetary.data.CurrencyUpdateResponse; +import org.apache.fineract.organisation.monetary.service.CurrencyWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CURRENCY", action = "UPDATE") +public class UpdateCurrencyCommandHandler implements NewCommandSourceHandler { + + private final CurrencyWritePlatformService writePlatformService; + + @Autowired + public UpdateCurrencyCommandHandler(final CurrencyWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final String[] currencyCodes = command.arrayValueOfParameterNamed("currencies"); + final List currencies = currencyCodes == null ? List.of() : Arrays.asList(currencyCodes); + + final CurrencyUpdateRequest request = CurrencyUpdateRequest.builder().currencies(currencies).build(); + + final CurrencyUpdateResponse response = this.writePlatformService.updateAllowedCurrencies(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .with(Map.of("currencies", response.getCurrencies())) // + .build(); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java index 7b2bf1c4f..2f4895e09 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/PortfolioProductType.java @@ -24,7 +24,10 @@ public enum PortfolioProductType { SAVING(2, "productType.saving"), // CLIENT(5, "productType.client"), // PROVISIONING(3, "productType.provisioning"), // - SHARES(4, "productType.shares"); // + SHARES(4, "productType.shares"), // + // Current products map their GLs under their own type: sharing SAVING with savings products let a savings + // product and a current product with the same id read each other's GL rows. + CURRENT(6, "productType.current"); // private final Integer value; private final String code; @@ -52,17 +55,25 @@ public enum PortfolioProductType { return null; } + // Matched against each constant's own declared value above, not its declaration position -- + // those two orderings diverge (CLIENT is declared 3rd but its value is 5), so a switch written + // by position silently mapped every id past SAVING to the wrong type: 3 (really PROVISIONING) + // returned CLIENT, 4 (really SHARES) returned PROVISIONING, 5 (really CLIENT) returned SHARES, + // and 6 (CURRENT) fell through to null -- a live NPE the moment a Current-product GL entry + // (entity_type_enum=6) reached this on the Journal Entries screen. switch (v) { case 1: return LOAN; case 2: return SAVING; case 3: - return CLIENT; - case 4: return PROVISIONING; - case 5: + case 4: return SHARES; + case 5: + return CLIENT; + case 6: + return CURRENT; default: return null; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java index 4be438b08..6ca135657 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/api/PaymentTypeApiResource.java @@ -31,20 +31,15 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import java.util.List; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeCreateCommand; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeDeleteCommand; -import org.apache.fineract.portfolio.paymenttype.command.PaymentTypeUpdateCommand; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.springframework.stereotype.Component; @@ -55,7 +50,7 @@ import org.springframework.stereotype.Component; public class PaymentTypeApiResource { private final PaymentTypeReadService readPlatformService; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final DefaultToApiJsonSerializer jsonSerializer; @GET @@ -79,14 +74,13 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Create a Payment Type", description = "Creates a new Payment type") - public PaymentTypeCreateResponse createPaymentType(PaymentTypeCreateRequest request) { - final var command = new PaymentTypeCreateCommand(); + public String createPaymentType(final PaymentTypeCreateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().createPaymentType() + .withJson(jsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } @PUT @@ -94,18 +88,16 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update a Payment Type", description = "Updates a Payment Type") - public PaymentTypeUpdateResponse updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, - final PaymentTypeUpdateRequest request) { + public String updatePaymentType(@PathParam("paymentTypeId") final Long paymentTypeId, final PaymentTypeUpdateRequest request) { request.setId(paymentTypeId); - final var command = new PaymentTypeUpdateCommand(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updatePaymentType(paymentTypeId) + .withJson(jsonSerializer.serialize(request)).build(); - command.setPayload(request); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } @DELETE @@ -113,14 +105,12 @@ public class PaymentTypeApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Delete a Payment Type", description = "Deletes payment type") - public PaymentTypeDeleteResponse deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) { + public String deleteCode(@PathParam("paymentTypeId") final Long paymentTypeId) { - final var command = new PaymentTypeDeleteCommand(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().deletePaymentType(paymentTypeId).build(); - command.setPayload(PaymentTypeDeleteRequest.builder().id(paymentTypeId).build()); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - final Supplier response = commandPipeline.send(command); - - return response.get(); + return jsonSerializer.serialize(result); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java index e7604f6e8..292c6bed3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeCreateCommandHandler.java @@ -18,33 +18,50 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeCreateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeCreateCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "CREATE") +public class PaymentTypeCreateCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeCreate", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeCreateResponse handle(Command command) { - return paymentTypeWriteService.createPaymentType(command.getPayload()); + @Autowired + public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeCreateResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined"); + + final PaymentTypeCreateRequest request = PaymentTypeCreateRequest.builder() + .name(command.stringValueOfParameterNamed("name")) + .description(command.stringValueOfParameterNamed("description")) + .isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment")) + .position(command.longValueOfParameterNamed("position")) + .codeName(command.stringValueOfParameterNamed("codeName")) + .isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE) + .build(); + + final PaymentTypeCreateResponse response = this.paymentTypeWriteService.createPaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java index 9c1e9d050..8e8cdc4c6 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeDeleteCommandHandler.java @@ -18,33 +18,41 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeDeleteResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeDeleteCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "DELETE") +public class PaymentTypeDeleteCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeDelete", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeDeleteResponse handle(Command command) { - return paymentTypeWriteService.deletePaymentType(command.getPayload()); + @Autowired + public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeDeleteResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final PaymentTypeDeleteRequest request = PaymentTypeDeleteRequest.builder().id(command.entityId()).build(); + + final PaymentTypeDeleteResponse response = this.paymentTypeWriteService.deletePaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java index 41914c9e5..cc3f50854 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymenttype/handler/PaymentTypeUpdateCommandHandler.java @@ -18,33 +18,51 @@ */ package org.apache.fineract.portfolio.paymenttype.handler; -import io.github.resilience4j.retry.annotation.Retry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateRequest; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeUpdateResponse; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeWriteService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentTypeUpdateCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "PAYMENTTYPE", action = "UPDATE") +public class PaymentTypeUpdateCommandHandler implements NewCommandSourceHandler { private final PaymentTypeWriteService paymentTypeWriteService; - @Retry(name = "commandPaymentTypeUpdate", fallbackMethod = "fallback") - @Override - @Transactional - public PaymentTypeUpdateResponse handle(Command command) { - return paymentTypeWriteService.updatePaymentType(command.getPayload()); + @Autowired + public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) { + this.paymentTypeWriteService = paymentTypeWriteService; } + @Transactional @Override - public PaymentTypeUpdateResponse fallback(Command command, Throwable t) { - return CommandHandler.super.fallback(command, t); + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean isSystemDefined = command.booleanObjectValueOfParameterNamed("isSystemDefined"); + + final PaymentTypeUpdateRequest request = PaymentTypeUpdateRequest.builder() + .id(command.entityId()) + .name(command.stringValueOfParameterNamed("name")) + .description(command.stringValueOfParameterNamed("description")) + .isCashPayment(command.booleanObjectValueOfParameterNamed("isCashPayment")) + .position(command.longValueOfParameterNamed("position")) + .codeName(command.stringValueOfParameterNamed("codeName")) + .isSystemDefined(isSystemDefined != null ? isSystemDefined : Boolean.FALSE) + .build(); + + final PaymentTypeUpdateResponse response = this.paymentTypeWriteService.updatePaymentType(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(response.getResourceId()) // + .build(); } + } 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 c5704438a..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 @@ -143,6 +143,9 @@ public final class DepositsApiConstants { public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm"; public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId"; public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType"; + // Days after maturity a depositor still has to walk in and renew at the original maturity date -- + // null/0 means this product keeps the instant, same-night auto-renewal every FD product had before. + public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays"; public static final String depositAmountParamName = "depositAmount"; public static final String depositMinAmountParamName = "minDepositAmount"; @@ -209,7 +212,18 @@ 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 + // per-request flag rather than a stored account field. + public static final String waivePreClosurePenaltyParamName = "waivePreClosurePenalty"; + // Why the deposit was closed before maturity -- see PrematureClosureReasonType. Persisted (unlike + // waivePreClosurePenaltyParamName above) as the RBI audit trail of which closures waived their + // penalty and why. + public static final String prematureClosureReasonIdParamName = "prematureClosureReasonId"; public static final String savingsAccounts = "savingsAccounts"; public static final String maturityInstructionIdParamName = "maturityInstructionId"; public static final String transferToSavingsIdParamName = "transferToSavingsId"; @@ -238,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)); @@ -248,11 +262,13 @@ public final class DepositsApiConstants { private static final Set DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName, - inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); + inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName, + renewalGracePeriodDaysParamName)); private static final Set RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName, @@ -374,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; } @@ -485,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-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java new file mode 100644 index 000000000..4c6708970 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/PrematureClosureReasonType.java @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings; + +import lombok.Getter; + +/** + * Why a Fixed/Recurring Deposit was closed before maturity -- captured on every premature closure, not + * just the ones that waive the penalty, so the audit trail always shows a reason rather than only + * recording one when it happens to matter. {@link #isAutoWaived()} is the RBI-driven business rule this + * type exists to encode: a maker cannot leave the platform's own penalty-waiver decision to memory or a + * free-text note for these four reasons -- picking the reason IS the waiver decision, enforced on the + * server regardless of what a client happened to also send for the plain waive-penalty flag. + */ +@Getter +public enum PrematureClosureReasonType { + + INVALID(0, "prematureClosureReasonType.invalid", false), // + NORMAL(100, "prematureClosureReasonType.normal", false), // + PREMATURE_EXTENSION(200, "prematureClosureReasonType.prematureExtension", true), // + CONVERSION_TO_ANOTHER_TD(300, "prematureClosureReasonType.conversionToAnotherTd", true), // + DECEASED_SETTLEMENT(400, "prematureClosureReasonType.deceasedSettlement", true), // + NOMINEE_LEGAL_HEIR_CLOSURE(500, "prematureClosureReasonType.nomineeLegalHeirClosure", true); // + + private final Integer value; + private final String code; + /** True when this reason, by itself, waives the pre-closure penalty regardless of any manual + * waive-penalty flag also sent -- see the class javadoc. */ + private final boolean autoWaived; + + PrematureClosureReasonType(final Integer value, final String code, final boolean autoWaived) { + this.value = value; + this.code = code; + this.autoWaived = autoWaived; + } + + public static PrematureClosureReasonType fromInt(final Integer v) { + if (v == null) { + return INVALID; + } + return switch (v) { + case 100 -> NORMAL; + case 200 -> PREMATURE_EXTENSION; + case 300 -> CONVERSION_TO_ANOTHER_TD; + case 400 -> DECEASED_SETTLEMENT; + case 500 -> NOMINEE_LEGAL_HEIR_CLOSURE; + default -> INVALID; + }; + } + + public boolean isInvalid() { + return this.equals(INVALID); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java index f47a04cf5..0dd86849f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/SavingsApiConstants.java @@ -286,6 +286,15 @@ public class SavingsApiConstants { public static final String avgBalanceCalcPeriodParamName = "avgBalanceCalcPeriod"; public static final String validForPeriodParamName = "validForPeriod"; public static final String odAccountClosedZeroBalanceInDayClosingParamName = "odAccountClosedZeroBalanceInDayClosing"; + // Overdraft scheme rules a sanction on an account is held to: overdraftLimit is the ceiling (an + // amount), odMinLimit the floor, odSecurityType whether the limit may be clean, secured, or either. + public static final String odMinLimitParamName = "odMinLimit"; + public static final String odSecurityTypeParamName = "odSecurityType"; + // Excess drawing: whether an account may go past its sanctioned limit, by how much (a percentage of the + // limit), and the penal charge (% p.a.) levied on the excess alone for the days it stays excess. + public static final String odExcessAllowedParamName = "odExcessAllowed"; + public static final String odExcessLimitPercentParamName = "odExcessLimitPercent"; + public static final String odPenalRateParamName = "odPenalRate"; // Status of Account fields public static final String accountStatusParamName = "accountStatus"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java index 0c74cde3c..f508d3227 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductData.java @@ -152,6 +152,11 @@ public final class SavingsProductData implements Serializable { @Getter @Setter private String avgBalanceCalcPeriod; @Getter @Setter private String validForPeriod; @Getter @Setter private Boolean odAccountClosedZeroBalanceInDayClosing; + @Getter @Setter private BigDecimal odMinLimit; + @Getter @Setter private String odSecurityType; + @Getter @Setter private Boolean odExcessAllowed; + @Getter @Setter private BigDecimal odExcessLimitPercent; + @Getter @Setter private BigDecimal odPenalRate; // Complex objects wrapped as Maps/Lists so Jackson converts them into the exact JSON your UI expects @Getter @Setter private Map accountFacilityConfig; @@ -382,6 +387,11 @@ public final class SavingsProductData implements Serializable { target.setAvgBalanceCalcPeriod(source.getAvgBalanceCalcPeriod()); target.setValidForPeriod(source.getValidForPeriod()); target.setOdAccountClosedZeroBalanceInDayClosing(source.getOdAccountClosedZeroBalanceInDayClosing()); + target.setOdMinLimit(source.getOdMinLimit()); + target.setOdSecurityType(source.getOdSecurityType()); + target.setOdExcessAllowed(source.getOdExcessAllowed()); + target.setOdExcessLimitPercent(source.getOdExcessLimitPercent()); + target.setOdPenalRate(source.getOdPenalRate()); target.setOtherGlMappingsMap(source.getOtherGlMappingsMap()); target.setAccountFacilityConfig(source.getAccountFacilityConfig()); target.setAccountFacilityLimits(source.getAccountFacilityLimits()); 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-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java index 38bf40627..2a72e7916 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccount.java @@ -76,7 +76,8 @@ public class CurrentAccount extends AbstractAuditableCustom { @Column(name = "status_enum", nullable = false) private Integer statusEnum = 100; - // SubStatus: 0=None, 100=Inactive, 200=Dormant, 300=Block, 400=BlockDebit, 500=BlockCredit + // SubStatus, same codes as savings: 0=None, 100=Inactive, 200=Dormant, 300=Escheat, 400=Block, 500=BlockCredit, + // 600=BlockDebit @Column(name = "sub_status_enum", nullable = false) private Integer subStatusEnum = 0; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java index 4c5617f97..944afff22 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountRepository.java @@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository findActiveByClientId(@Param("clientId") Long clientId); - @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum != 600") + // statusEnum != 600 (CLOSED) let a client re-apply for the same product only once every prior + // account on it was closed -- but 400 (WITHDRAWN) and 500 (REJECTED), see CurrentAccount.withdraw() + // /.reject(), are equally terminal and were still being counted as "still has one", blocking a + // fresh application the client should have been free to make. 100/200/300 (submitted, approved, + // active) are the only statuses a second application on the same product should actually collide + // with. + @Query("SELECT COUNT(ca) FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.productId = :productId AND ca.statusEnum IN (100, 200, 300)") long countActiveByClientAndProduct(@Param("clientId") Long clientId, @Param("productId") Long productId); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java index 60f4eb073..bf9c21709 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/domain/CurrentAccountTransaction.java @@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { @JoinColumn(name = "office_id", nullable = false) private Office office; - // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal + // 1=Deposit, 2=Withdrawal, 3=InterestPosting, 4=Charge, 5=ChargeWaiver, 6=Transfer, 7=Reversal, 19=Escheat @Column(name = "transaction_type_enum", nullable = false) private Integer transactionTypeEnum; @@ -176,6 +176,20 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom { return txn; } + // transactionTypeEnum = 19 (Escheat) -- the whole balance moved to the escheat liability, same code as savings + public static CurrentAccountTransaction escheat(CurrentAccount account, Office office, + BigDecimal amount, LocalDate transactionDate) { + CurrentAccountTransaction txn = new CurrentAccountTransaction(); + txn.currentAccount = account; + txn.office = office; + txn.transactionTypeEnum = 19; + txn.amount = amount; + txn.transactionDate = transactionDate; + txn.remarks = "Escheat: dormant for the product's escheat period, balance moved to the escheat liability"; + txn.submittedOnDate = transactionDate; + return txn; + } + // transactionTypeEnum = 7 (Reversal / Undo) public static CurrentAccountTransaction reversal(CurrentAccount account, Office office, BigDecimal amount, LocalDate transactionDate, Long originalTransactionId) { diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java index d860b7fb0..2533688f7 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentaccount/service/CurrentAccountTransactionWritePlatformService.java @@ -36,4 +36,10 @@ public interface CurrentAccountTransactionWritePlatformService { CommandProcessingResult reverseTransaction(Long accountId, Long transactionId, boolean isReversal, JsonCommand command); CommandProcessingResult adjustTransaction(Long accountId, Long transactionId, JsonCommand command); + + /** + * Escheats a dormant current account: moves its whole balance to the escheat liability and closes it. Run by the + * nightly dormancy job; returns false, leaving the account untouched, where it cannot safely be done. + */ + boolean escheat(Long accountId); } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java index 8d09d6b64..859841b83 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/data/CurrentProductDataValidator.java @@ -44,7 +44,13 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minRequi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateOverdraftParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.overdraftLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; 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; @@ -136,7 +142,17 @@ public class CurrentProductDataValidator { SavingsApiConstants.balanceSwapOptionParamName, SavingsApiConstants.msmeParamName, SavingsApiConstants.autoDebitFacilityParamName, SavingsApiConstants.avgBalanceCalcPeriodParamName, SavingsApiConstants.validForPeriodParamName, SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName, - SavingsApiConstants.glAccountIdParamName)); + odMinLimitParamName, odSecurityTypeParamName, odExcessAllowedParamName, odExcessLimitPercentParamName, + odPenalRateParamName, SavingsApiConstants.glAccountIdParamName)); + + /** How long an OD sanction on this scheme runs before it has to be renewed. */ + private static final List OD_VALIDITY_PERIODS = List.of("3M", "6M", "1Y", "2Y", "3Y"); + /** CLEAN: no security needed; SECURED: backed by FD lien, property etc.; BOTH: decided per sanction. */ + private static final List OD_SECURITY_TYPES = List.of("CLEAN", "SECURED", "BOTH"); + /** A sanity ceiling on the OD rate, catching a limit keyed into the rate field. */ + private static final BigDecimal MAX_OVERDRAFT_RATE = new BigDecimal("50"); + /** Excess drawing is a small tolerance over the limit, never a second limit. */ + private static final BigDecimal MAX_EXCESS_PERCENT = new BigDecimal("25"); public void validateForCreate(final String json) { if (StringUtils.isBlank(json)) { @@ -260,10 +276,10 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, true); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, true); } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, null); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -358,7 +374,7 @@ public class CurrentProductDataValidator { if (AccountingValidations.isCashBasedAccounting(accountingRuleType) || AccountingValidations.isAccrualPeriodicBasedAccounting(accountingRuleType)) { savingsProductAccountingDataValidator.evaluateProductAccountingData(accountingRuleType, isDormancyActive, element, - baseDataValidator, DepositAccountType.SAVINGS_DEPOSIT, false); + baseDataValidator, DepositAccountType.CURRENT_DEPOSIT, false); } if (null != isDormancyActive && isDormancyActive) { @@ -380,7 +396,7 @@ public class CurrentProductDataValidator { } } - validateOverdraftParams(baseDataValidator, element); + validateOverdraftParams(baseDataValidator, element, product); if (this.fromApiJsonHelper.parameterExists(minBalanceForInterestCalculationParamName, element)) { final BigDecimal minBalanceForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minBalanceForInterestCalculationParamName, element); @@ -398,23 +414,103 @@ public class CurrentProductDataValidator { } } - private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + /** + * The overdraft section of a current product is the scheme every account's OD sanction is held to, so when OD is + * allowed it has to be complete: a ceiling (overdraftLimit, an amount, not a percentage), an optional floor, a rate, + * a sanction validity and whether the limit may be clean or must be secured. Interest on an OD is applied monthly, + * as RBI requires for advances, or at most quarterly. + * + * On update, product is the stored product and fills in any value the request leaves out, so a partial edit is + * checked against the scheme it will produce; on create it is null. + */ + private void validateOverdraftParams(final DataValidatorBuilder baseDataValidator, final JsonElement element, + final CurrentProduct product) { if (this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element)) { final Boolean allowOverdraft = this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element); baseDataValidator.reset().parameter(allowOverdraftParamName).value(allowOverdraft).ignoreIfNull().validateForBooleanValue(); } - if (this.fromApiJsonHelper.parameterExists(overdraftLimitParamName, element)) { - final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - baseDataValidator.reset().parameter(overdraftLimitParamName).value(overdraftLimit).ignoreIfNull().zeroOrPositiveAmount(); + final boolean allowOverdraft = this.fromApiJsonHelper.parameterExists(allowOverdraftParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element)) + : product != null && product.isAllowOverdraft(); + + final BigDecimal maxLimit = bigDecimalOrStored(element, overdraftLimitParamName, + product == null ? null : product.getOverdraftLimit()); + final BigDecimal minLimit = bigDecimalOrStored(element, odMinLimitParamName, product == null ? null : product.getOdMinLimit()); + final BigDecimal rate = bigDecimalOrStored(element, nominalAnnualInterestRateOverdraftParamName, + product == null ? null : product.getNominalAnnualInterestRateOverdraft()); + final BigDecimal minOdForInterest = bigDecimalOrStored(element, minOverdraftForInterestCalculationParamName, + product == null ? null : product.getMinOverdraftForInterestCalculation()); + + if (!allowOverdraft) { + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).ignoreIfNull() + .zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + return; } - if (this.fromApiJsonHelper.parameterExists(nominalAnnualInterestRateOverdraftParamName, element)) { - final BigDecimal nominalAnnualInterestRateOverdraft = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(nominalAnnualInterestRateOverdraftParamName, element); - baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(nominalAnnualInterestRateOverdraft).ignoreIfNull().zeroOrPositiveAmount(); + + baseDataValidator.reset().parameter(overdraftLimitParamName).value(maxLimit).notNull().positiveAmount(); + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit).ignoreIfNull().zeroOrPositiveAmount(); + if (minLimit != null && maxLimit != null && minLimit.compareTo(maxLimit) > 0) { + baseDataValidator.reset().parameter(odMinLimitParamName).value(minLimit) + .failWithCodeAndMessage("cannot.exceed.maximum.overdraft.limit", "Minimum OD limit cannot exceed the maximum OD limit."); } - if (this.fromApiJsonHelper.parameterExists(minOverdraftForInterestCalculationParamName, element)) { - final BigDecimal minOverdraftForInterestCalculation = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(minOverdraftForInterestCalculationParamName, element); - baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOverdraftForInterestCalculation).ignoreIfNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(nominalAnnualInterestRateOverdraftParamName).value(rate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); + baseDataValidator.reset().parameter(minOverdraftForInterestCalculationParamName).value(minOdForInterest).ignoreIfNull() + .zeroOrPositiveAmount(); + + final String validity = stringOrStored(element, validForPeriodParamName, product == null ? null : product.getValidForPeriod()); + baseDataValidator.reset().parameter(validForPeriodParamName).value(validity).notBlank() + .isOneOfTheseStringValues(OD_VALIDITY_PERIODS); + + final String securityType = stringOrStored(element, odSecurityTypeParamName, + product == null ? null : product.getOdSecurityType()); + baseDataValidator.reset().parameter(odSecurityTypeParamName).value(securityType).notBlank() + .isOneOfTheseStringValues(OD_SECURITY_TYPES); + + // Excess drawing past the sanctioned limit. Off means a withdrawal beyond the limit is refused. On, the + // excess is capped at a percentage of the limit and carries a penal charge on the excess alone -- a separate + // charge, as RBI's 2023 penal-charges directions require, not a higher rate on the whole balance. + final boolean excessAllowed = this.fromApiJsonHelper.parameterExists(odExcessAllowedParamName, element) + ? BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(odExcessAllowedParamName, element)) + : product != null && product.isOdExcessAllowed(); + if (excessAllowed) { + final BigDecimal excessPercent = bigDecimalOrStored(element, odExcessLimitPercentParamName, + product == null ? null : product.getOdExcessLimitPercent()); + baseDataValidator.reset().parameter(odExcessLimitPercentParamName).value(excessPercent).notNull().positiveAmount() + .notGreaterThanMax(MAX_EXCESS_PERCENT); + final BigDecimal penalRate = bigDecimalOrStored(element, odPenalRateParamName, + product == null ? null : product.getOdPenalRate()); + baseDataValidator.reset().parameter(odPenalRateParamName).value(penalRate).notNull().positiveAmount() + .notGreaterThanMax(MAX_OVERDRAFT_RATE); } + + final Integer postingPeriod = this.fromApiJsonHelper.parameterExists(interestPostingPeriodTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestPostingPeriodTypeParamName, element) + : product == null ? null : product.getInterestPostingPeriodType(); + baseDataValidator.reset().parameter(interestPostingPeriodTypeParamName).value(postingPeriod).ignoreIfNull() + .isOneOfTheseValues(SavingsPostingInterestPeriodType.MONTHLY.getValue(), + SavingsPostingInterestPeriodType.QUATERLY.getValue()); + + final Integer daysInYear = this.fromApiJsonHelper.parameterExists(interestCalculationDaysInYearTypeParamName, element) + ? this.fromApiJsonHelper.extractIntegerSansLocaleNamed(interestCalculationDaysInYearTypeParamName, element) + : product == null ? null : product.getInterestCalculationDaysInYearType(); + baseDataValidator.reset().parameter(interestCalculationDaysInYearTypeParamName).value(daysInYear).ignoreIfNull() + .isOneOfTheseValues(SavingsInterestCalculationDaysInYearType.DAYS_360.getValue(), + SavingsInterestCalculationDaysInYearType.DAYS_365.getValue()); + } + + private BigDecimal bigDecimalOrStored(final JsonElement element, final String paramName, final BigDecimal stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) + ? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(paramName, element) + : stored; + } + + private String stringOrStored(final JsonElement element, final String paramName, final String stored) { + return this.fromApiJsonHelper.parameterExists(paramName, element) ? this.fromApiJsonHelper.extractStringNamed(paramName, element) + : stored; } private void validateLienParams(final DataValidatorBuilder baseDataValidator, final JsonElement element) { @@ -430,7 +526,7 @@ public class CurrentProductDataValidator { && BooleanUtils.isTrue(this.fromApiJsonHelper.extractBooleanNamed(allowOverdraftParamName, element))) { final BigDecimal maxAllowedLienLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(maxAllowedLienLimitParamName, element); final BigDecimal overdraftLimit = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(overdraftLimitParamName, element); - if (overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { + if (overdraftLimit != null && maxAllowedLienLimit != null && overdraftLimit.compareTo(maxAllowedLienLimit) > 0) { baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("Overdraft.limit.can.not.be.greater.than.lien.limit"); } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java index 947885750..4a632868f 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProduct.java @@ -68,6 +68,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import com.google.gson.JsonArray; import jakarta.persistence.CascadeType; @@ -290,6 +295,25 @@ public class CurrentProduct extends AbstractPersistableCustom { @Column(name = "od_account_closed_zero_balance_in_day_closing") private boolean odAccountClosedZeroBalanceInDayClosing = false; + // The smallest limit an account on this scheme may be sanctioned; overdraftLimit is the largest. + @Column(name = "od_min_limit", scale = 6, precision = 19) + private BigDecimal odMinLimit; + + // CLEAN, SECURED or BOTH: whether a sanction must be backed by security (FD lien, property...). + @Column(name = "od_security_type", length = 20) + private String odSecurityType; + + // Excess drawing past the sanctioned limit: off means it is blocked. When on, odExcessLimitPercent caps the excess + // as a percentage of the limit and odPenalRate (% p.a.) is charged on the excess alone. + @Column(name = "od_excess_allowed") + private boolean odExcessAllowed = false; + + @Column(name = "od_excess_limit_percent", scale = 6, precision = 19) + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate", scale = 6, precision = 19) + private BigDecimal odPenalRate; + @OneToOne(cascade = CascadeType.ALL, mappedBy = "currentProduct", orphanRemoval = true) private CurrentProductFacilityConfig facilityConfig; @@ -804,11 +828,45 @@ public class CurrentProduct extends AbstractPersistableCustom { actualChanges.put(odAccountClosedZeroBalanceInDayClosingParamName, newValue); this.odAccountClosedZeroBalanceInDayClosing = newValue; } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odMinLimitParamName, this.odMinLimit)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odMinLimitParamName); + actualChanges.put(odMinLimitParamName, newValue); + this.odMinLimit = newValue; + } + if (command.isChangeInStringParameterNamed(odSecurityTypeParamName, this.odSecurityType)) { + final String newValue = command.stringValueOfParameterNamed(odSecurityTypeParamName); + actualChanges.put(odSecurityTypeParamName, newValue); + this.odSecurityType = newValue; + } + if (command.isChangeInBooleanParameterNamed(odExcessAllowedParamName, this.odExcessAllowed)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + actualChanges.put(odExcessAllowedParamName, newValue); + this.odExcessAllowed = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odExcessLimitPercentParamName, this.odExcessLimitPercent)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odExcessLimitPercentParamName); + actualChanges.put(odExcessLimitPercentParamName, newValue); + this.odExcessLimitPercent = newValue; + } + if (command.isChangeInBigDecimalParameterNamedDefaultingZeroToNull(odPenalRateParamName, this.odPenalRate)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero(odPenalRateParamName); + actualChanges.put(odPenalRateParamName, newValue); + this.odPenalRate = newValue; + } + if (!this.odExcessAllowed) { + this.odExcessLimitPercent = null; + this.odPenalRate = null; + } // Clear OD-specific fields when overdraft is disabled if (!this.allowOverdraft) { this.avgBalanceCalcPeriod = null; this.validForPeriod = null; this.odAccountClosedZeroBalanceInDayClosing = false; + this.odMinLimit = null; + this.odSecurityType = null; + this.odExcessAllowed = false; + this.odExcessLimitPercent = null; + this.odPenalRate = null; } return actualChanges; @@ -890,4 +948,14 @@ public class CurrentProduct extends AbstractPersistableCustom { public void setValidForPeriod(String validForPeriod) { this.validForPeriod = validForPeriod; } public boolean isOdAccountClosedZeroBalanceInDayClosing() { return odAccountClosedZeroBalanceInDayClosing; } public void setOdAccountClosedZeroBalanceInDayClosing(boolean val) { this.odAccountClosedZeroBalanceInDayClosing = val; } + public BigDecimal getOdMinLimit() { return odMinLimit; } + public void setOdMinLimit(BigDecimal odMinLimit) { this.odMinLimit = odMinLimit; } + public String getOdSecurityType() { return odSecurityType; } + public void setOdSecurityType(String odSecurityType) { this.odSecurityType = odSecurityType; } + public boolean isOdExcessAllowed() { return odExcessAllowed; } + public void setOdExcessAllowed(boolean odExcessAllowed) { this.odExcessAllowed = odExcessAllowed; } + public BigDecimal getOdExcessLimitPercent() { return odExcessLimitPercent; } + public void setOdExcessLimitPercent(BigDecimal odExcessLimitPercent) { this.odExcessLimitPercent = odExcessLimitPercent; } + public BigDecimal getOdPenalRate() { return odPenalRate; } + public void setOdPenalRate(BigDecimal odPenalRate) { this.odPenalRate = odPenalRate; } } diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java index 4bdecc85c..29ed7c5ee 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/domain/CurrentProductAssembler.java @@ -43,6 +43,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebi import static org.apache.fineract.portfolio.savings.SavingsApiConstants.avgBalanceCalcPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.validForPeriodParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odAccountClosedZeroBalanceInDayClosingParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odMinLimitParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odSecurityTypeParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessAllowedParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odExcessLimitPercentParamName; +import static org.apache.fineract.portfolio.savings.SavingsApiConstants.odPenalRateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName; @@ -314,6 +319,23 @@ public class CurrentProductAssembler { if (command.parameterExists(odAccountClosedZeroBalanceInDayClosingParamName)) { product.setOdAccountClosedZeroBalanceInDayClosing(command.booleanPrimitiveValueOfParameterNamed(odAccountClosedZeroBalanceInDayClosingParamName)); } + if (command.parameterExists(odMinLimitParamName)) { + product.setOdMinLimit(command.bigDecimalValueOfParameterNamed(odMinLimitParamName)); + } + if (command.parameterExists(odSecurityTypeParamName)) { + product.setOdSecurityType(command.stringValueOfParameterNamed(odSecurityTypeParamName)); + } + final boolean excessAllowed = command.parameterExists(odExcessAllowedParamName) + && command.booleanPrimitiveValueOfParameterNamed(odExcessAllowedParamName); + product.setOdExcessAllowed(excessAllowed); + if (excessAllowed) { + if (command.parameterExists(odExcessLimitPercentParamName)) { + product.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed(odExcessLimitPercentParamName)); + } + if (command.parameterExists(odPenalRateParamName)) { + product.setOdPenalRate(command.bigDecimalValueOfParameterNamed(odPenalRateParamName)); + } + } } return product; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java new file mode 100644 index 000000000..b41d3abcc --- /dev/null +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductGlMappingWriter.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.currentproduct.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.SavingProductAccountingParams; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Writes a current product's GL mappings to acc_product_mapping under {@link PortfolioProductType#CURRENT}. + * + *

+ * They used to be written by the savings mapping writer under SAVING, keyed only by product id, so a current product + * and a savings product with the same id shared rows: two Current Control rows came back for one lookup and the + * account's GL posting failed outright, leaving the balance moved with no journal entry. Written under their own type, + * the two can never meet. + * + *

+ * A parameter present in the request replaces that one mapping (null clears it); an absent one is left alone, so a + * partial edit changes only what it names. Accounting rule NONE clears every mapping. + */ +@Component +@RequiredArgsConstructor +public class CurrentProductGlMappingWriter { + + /** Request parameter -> financial account type (CashAccountsForSavings / AccrualAccountsForSavings). */ + private static final Map PARAM_TO_FINANCIAL_TYPE = new LinkedHashMap<>(); + + static { + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), 1); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.SAVINGS_CONTROL.getValue(), 2); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), 3); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_FEES.getValue(), 4); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), 5); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), 10); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), 11); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), 12); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), 13); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), 14); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), 15); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), 16); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), 17); + PARAM_TO_FINANCIAL_TYPE.put(SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), 18); + } + + private static final int CURRENT = PortfolioProductType.CURRENT.getValue(); + + private final JdbcTemplate jdbcTemplate; + + /** + * Applies the request's mappings to the product and returns what changed, keyed by parameter name. + * + * @param accountingRule + * the product's accounting rule after this request (1 = NONE) + */ + public Map write(final Long productId, final JsonCommand command, final Integer accountingRule) { + final Map changes = new LinkedHashMap<>(); + if (accountingRule == null || accountingRule == 1) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + return changes; + } + final JsonElement parsed = command.parsedJson(); + final JsonObject json = parsed != null && parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + for (final Map.Entry entry : PARAM_TO_FINANCIAL_TYPE.entrySet()) { + final String param = entry.getKey(); + if (!json.has(param)) { + continue; + } + final Long glAccountId = json.get(param).isJsonNull() ? null : json.get(param).getAsLong(); + final Long existing = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", + Long.class, CURRENT, productId, entry.getValue()).stream().findFirst().orElse(null); + if (java.util.Objects.equals(existing, glAccountId)) { + continue; + } + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ? AND financial_account_type = ?" + + " AND payment_type IS NULL AND charge_id IS NULL", CURRENT, productId, entry.getValue()); + if (glAccountId != null) { + jdbcTemplate.update("INSERT INTO acc_product_mapping (gl_account_id, product_id, product_type, financial_account_type)" + + " VALUES (?, ?, ?, ?)", glAccountId, productId, CURRENT, entry.getValue()); + } + changes.put(param, glAccountId); + } + return changes; + } + + /** Removes every mapping of a deleted product. */ + public void deleteAll(final Long productId) { + jdbcTemplate.update("DELETE FROM acc_product_mapping WHERE product_type = ? AND product_id = ?", CURRENT, productId); + } +} diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java index e0f22d7e5..befebaa50 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductReadPlatformServiceImpl.java @@ -268,6 +268,9 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead sqlBuilder.append("cp.balance_swap_option as balanceSwapOption, cp.msme as msme, cp.auto_debit_facility as autoDebitFacility, "); sqlBuilder.append("cp.avg_balance_calc_period as avgBalanceCalcPeriod, cp.valid_for_period as validForPeriod, "); sqlBuilder.append("cp.od_account_closed_zero_balance_in_day_closing as odAccountClosedZeroBalanceInDayClosing, "); + sqlBuilder.append("cp.od_min_limit as odMinLimit, cp.od_security_type as odSecurityType, "); + sqlBuilder.append("cp.od_excess_allowed as odExcessAllowed, cp.od_excess_limit_percent as odExcessLimitPercent, "); + sqlBuilder.append("cp.od_penal_rate as odPenalRate, "); sqlBuilder.append("exists(select 1 from m_saving_product_temp spt where spt.product_id = cp.id " + "and spt.product_type = 'CURRENT' and spt.status = 'PENDING') as isPendingApproval "); @@ -393,6 +396,11 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead productData.setAvgBalanceCalcPeriod(rs.getString("avgBalanceCalcPeriod")); productData.setValidForPeriod(rs.getString("validForPeriod")); productData.setOdAccountClosedZeroBalanceInDayClosing(rs.getBoolean("odAccountClosedZeroBalanceInDayClosing")); + productData.setOdMinLimit(rs.getBigDecimal("odMinLimit")); + productData.setOdSecurityType(rs.getString("odSecurityType")); + productData.setOdExcessAllowed(rs.getBoolean("odExcessAllowed")); + productData.setOdExcessLimitPercent(rs.getBigDecimal("odExcessLimitPercent")); + productData.setOdPenalRate(rs.getBigDecimal("odPenalRate")); productData.setIsPendingApproval(rs.getBoolean("isPendingApproval")); return productData; diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java index 7ef44cd2c..02b747fb2 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/service/CurrentProductWritePlatformServiceJpaRepositoryImpl.java @@ -98,6 +98,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final FromJsonHelper fromApiJsonHelper; private final CommandSourceRepository commandSourceRepository; + private final CurrentProductGlMappingWriter glMappingWriter; private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause, final Exception dae) { String msgCode = "error.msg." + DepositsApiConstants.CURRENT_PRODUCT_RESOURCE_NAME; @@ -161,8 +162,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr this.depositLimitKycRepository.saveAndFlush(depositLimitKyc); } - this.accountMappingWritePlatformService.createSavingProductToGLAccountMapping(product.getId(), command, - DepositAccountType.SAVINGS_DEPOSIT); + // Under the product's own CURRENT type, never SAVING: see CurrentProductGlMappingWriter. + this.glMappingWriter.write(product.getId(), command, product.getAccountingType()); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // @@ -258,10 +259,8 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr } // accounting related changes - final boolean accountingTypeChanged = changes.containsKey(accountingRuleParamName); - final Map accountingMappingChanges = this.accountMappingWritePlatformService - .updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(), - DepositAccountType.SAVINGS_DEPOSIT); + final Map accountingMappingChanges = this.glMappingWriter.write(product.getId(), command, + product.getAccountingType()); changes.putAll(accountingMappingChanges); if (!changes.isEmpty()) { @@ -504,6 +503,7 @@ public class CurrentProductWritePlatformServiceJpaRepositoryImpl implements Curr .orElseThrow(() -> new SavingsProductNotFoundException(productId)); this.currentProductRepository.delete(product); + this.glMappingWriter.deleteAll(productId); return new CommandProcessingResultBuilder() // .withEntityId(product.getId()) // diff --git a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java index 8b7af3502..4a59f90d2 100644 --- a/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java +++ b/fineract-current-product/src/main/java/org/apache/fineract/portfolio/currentproduct/starter/CurrentProductConfiguration.java @@ -33,6 +33,7 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductDeposit import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityConfigRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductFacilityLimitRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.portfolio.currentproduct.service.CurrentProductGlMappingWriter; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformService; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductReadPlatformServiceImpl; import org.apache.fineract.portfolio.currentproduct.service.CurrentProductWritePlatformService; @@ -78,11 +79,11 @@ public class CurrentProductConfiguration { ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, FromJsonHelper fromApiJsonHelper, - CommandSourceRepository commandSourceRepository) { + CommandSourceRepository commandSourceRepository, CurrentProductGlMappingWriter glMappingWriter) { return new CurrentProductWritePlatformServiceJpaRepositoryImpl(context, currentProductRepository, currentProductDataValidator, currentProductAssembler, accountMappingWritePlatformService, currentFacilityConfigRepository, currentFacilityLimitRepository, currentDepositLimitKycRepository, productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, - pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository); + pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, glMappingWriter); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java index 08b7cea5a..0c324de1e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/glaccount/validation/GLAccountTransactionGuard.java @@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard { "glAccountId", glAccountId); } } + + /** + * Refuses to draw more out of a holding account than it holds. + * + *

+ * A suspense or sundry account exists to hold money on its way somewhere else, so what is in it + * is the whole of what can come out. Drawing more leaves it with a negative balance, and a + * liability with a negative balance is not a small number - it says the bank owes less than + * nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw, + * and the figure is small enough to sit unnoticed inside a head that totals positive. + * + *

+ * Checked here rather than at each call site because the accounts this protects are reached by + * several features posting directly through JDBC, and a rule enforced in one of them is a rule + * the next one added will not have. + * + * @param glAccountId the account being drawn on. + * @param amount how much the caller wants to take out. + * @param what the account's role in the caller's own words, for the message a clerk reads. + */ + public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId, + final java.math.BigDecimal amount, final String what) { + if (glAccountId == null || amount == null || amount.signum() <= 0) { + return; + } + // Credits less debits: these are liability-natured holding accounts, so the balance is what + // is still owed onward. Reversed entries and the entries they reverse both drop out - a + // reversal pair nets to nothing, and counting either side alone moves the balance by the + // whole amount. + final java.math.BigDecimal held = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0) + FROM acc_gl_journal_entry + WHERE account_id = ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """, java.math.BigDecimal.class, glAccountId); + final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held; + if (balance.compareTo(amount) < 0) { + throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance", + what + " holds " + balance + " but " + amount + " was asked of it. Drawing the " + + "difference would leave the account with a negative balance, which a " + + "holding account cannot have.", + "glAccountId", glAccountId, amount); + } + } } 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 2c6e8888c..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 @@ -1,1618 +1,1746 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.accounting.journalentry.service; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.apache.fineract.accounting.closure.domain.GLClosure; -import org.apache.fineract.accounting.closure.domain.GLClosureRepository; -import org.apache.fineract.accounting.common.AccountingConstants; -import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; -import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; -import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; -import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; -import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; -import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; -import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.LoanDTO; -import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsDTO; -import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.SharesDTO; -import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; -import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; -import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; -import org.apache.fineract.infrastructure.core.data.EnumOptionData; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.MathUtil; -import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; -import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.account.PortfolioAccountType; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; -import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; -import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; -import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; -import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; -import org.springframework.dao.DataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; - -@RequiredArgsConstructor -public class AccountingProcessorHelper { - - public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; - public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; - public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; - public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; - public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; - public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; - public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; - - /** - * Reserved names on the savings product's Accounting tab, naming the two accounts an interest - * posting is routed through. The names are the contract with the product screen; the GL codes - * behind them are the bank's choice, so no code is named here. - */ - public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; - public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; - - private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); - - public static void setDepositAccountTypeContext(int depositAccountType) { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); - } - - public static void clearDepositAccountTypeContext() { - DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); - } - - private String getSavingsTransactionIdentifier() { - return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { - case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; - case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; - default -> SAVINGS_TRANSACTION_IDENTIFIER; - }; - } - - private final JournalEntryRepository glJournalEntryRepository; - private final ProductToGLAccountMappingRepository accountMappingRepository; - private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; - private final GLClosureRepository closureRepository; - private final GLAccountRepository glAccountRepository; - private final OfficeRepository officeRepository; - private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; - private final ChargeRepositoryWrapper chargeRepositoryWrapper; - private final BusinessEventNotifierService businessEventNotifierService; - // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a - // JSON document rather than in acc_product_mapping, so they are read directly. See - // namedSavingsGlAccount. - private final JdbcTemplate jdbcTemplate; - - public LoanDTO populateLoanDtoFromDTO( - final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { - final Long loanId = accountingBridgeData.getLoanId(); - final Long loanProductId = accountingBridgeData.getLoanProductId(); - final Long officeId = accountingBridgeData.getOfficeId(); - final String currencyCode = accountingBridgeData.getCurrencyCode(); - final List newLoanTransactions = new ArrayList<>(); - boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); - boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); - boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); - final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); - final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); - final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); - final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); - final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); - final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); - - final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); - - for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { - final Long transactionOfficeId = loanTxnDto.getOfficeId(); - final String transactionId = loanTxnDto.getId().toString(); - final LocalDate transactionDate = loanTxnDto.getDate(); - final LoanTransactionEnumData transactionType = loanTxnDto.getType(); - final BigDecimal amount = loanTxnDto.getAmount(); - final BigDecimal principal = loanTxnDto.getPrincipalPortion(); - final BigDecimal interest = loanTxnDto.getInterestPortion(); - final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); - final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); - final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); - final boolean reversed = loanTxnDto.isReversed(); - final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); - final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); - final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); - - final List feePaymentDetails = new ArrayList<>(); - final List penaltyPaymentDetails = new ArrayList<>(); - // extract charge payment details (if exists) - if (loanTxnDto.getLoanChargesPaid() != null) { - List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); - for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { - final Long chargeId = loanChargePaid.getChargeId(); - final Long loanChargeId = loanChargePaid.getLoanChargeId(); - final boolean isPenalty = loanChargePaid.getIsPenalty(); - final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPaymentDetails.add(chargePaymentDTO); - } else { - feePaymentDetails.add(chargePaymentDTO); - } - } - } - - boolean localIsAccountTransfer = isAccountTransfer; - if (!localIsAccountTransfer) { - localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.LOAN); - } - - BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); - BigDecimal feePaid = loanTxnDto.getFeePaid(); - BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); - - final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, - penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, - feePaid, penaltyPaid); - - transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); - newLoanTransactions.add(transaction); - } - - return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, - periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, - chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, - accountingBridgeData.getBuydownFeeClassificationCodeValue(), - accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); - } - - public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long chargeOffReasonId) { - return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); - } - - public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - Long writeOffReasonId) { - return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); - } - - public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, - final Long classificationId, final String classificationType) { - if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { - return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); - } else { - return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), - classificationId); - } - } - - public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long loanId = (Long) accountingBridgeData.get("savingsId"); - final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") - ? (Integer) accountingBridgeData.get("depositAccountType") - : 100; - final List newSavingsTransactions = new ArrayList<>(); - boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final boolean reversed = (Boolean) map.get("reversed"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); - - final List feePayments = new ArrayList<>(); - final List penaltyPayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("savingsChargesPaid")) { - @SuppressWarnings("unchecked") - final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); - for (final Map loanChargePaid : savingsChargesPaidData) { - final Long chargeId = (Long) loanChargePaid.get("chargeId"); - final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); - final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - if (isPenalty) { - penaltyPayments.add(chargePaymentDTO); - } else { - feePayments.add(chargePaymentDTO); - } - } - } - - final List taxPayments = new ArrayList<>(); - if (map.containsKey("taxDetails")) { - @SuppressWarnings("unchecked") - final List> taxDataList = (List>) map.get("taxDetails"); - for (final Map taxData : taxDataList) { - final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); - final Long creditAccountId = (Long) taxData.get("creditAccountId"); - final Long debitAccountId = (Long) taxData.get("debitAccountId"); - taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); - } - } - - if (!isAccountTransfer) { - isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), - PortfolioAccountType.SAVINGS); - } - final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, - taxPayments); - - newSavingsTransactions.add(transaction); - - } - - return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, - newSavingsTransactions, depositAccountType); - } - - public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, - final boolean accrualBasedAccountingEnabled) { - final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); - final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); - final Long officeId = (Long) accountingBridgeData.get("officeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final List newTransactions = new ArrayList<>(); - - @SuppressWarnings("unchecked") - final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); - - for (final Map map : newTransactionsMap) { - final Long transactionOfficeId = (Long) map.get("officeId"); - final String transactionId = ((Long) map.get("id")).toString(); - final LocalDate transactionDate = ((LocalDate) map.get("date")); - final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); - final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); - final BigDecimal amount = (BigDecimal) map.get("amount"); - final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); - final Long paymentTypeId = (Long) map.get("paymentTypeId"); - - final List feePayments = new ArrayList<>(); - // extract charge payment details (if exists) - if (map.containsKey("chargesPaid")) { - @SuppressWarnings("unchecked") - final List> chargesPaidData = (List>) map.get("chargesPaid"); - for (final Map chargePaid : chargesPaidData) { - final Long chargeId = (Long) chargePaid.get("chargeId"); - final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); - final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); - final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); - feePayments.add(chargePaymentDTO); - } - } - final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, - transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); - - newTransactions.add(transaction); - - } - - return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, - accrualBasedAccountingEnabled, newTransactions); - } - - public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { - - final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); - final Long clientId = (Long) accountingBridgeData.get("clientId"); - final Long transactionId = (Long) accountingBridgeData.get("id"); - final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); - final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); - final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); - final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); - final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); - final String currencyCode = (String) accountingBridgeData.get("currencyCode"); - final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); - - final List clientChargePaymentDTOs = new ArrayList<>(); - // extract client charge payment details (if exists) - if (accountingBridgeData.containsKey("clientChargesPaid")) { - @SuppressWarnings("unchecked") - final List> clientChargesPaidData = (List>) accountingBridgeData - .get("clientChargesPaid"); - for (final Map clientChargePaid : clientChargesPaidData) { - final Long chargeId = (Long) clientChargePaid.get("chargeId"); - final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); - final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); - final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); - final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); - final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, - isPenalty, incomeAccountId); - clientChargePaymentDTOs.add(clientChargePaymentDTO); - } - } - - return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, - currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); - - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * loanProductId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - final Map creditDetailsMap = new LinkedHashMap<>(); - final Map debitDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, - chargeId); - final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); - final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // aggregate amounts by account for credit entries - creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); - - // aggregate amounts by account for debit entries - debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - BigDecimal totalDebitedAmount = BigDecimal.ZERO; - - // Create credit journal entries - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - // Create debit journal entries using charge-specific debit accounts - for (final Map.Entry entry : debitDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalDebitedAmount = totalDebitedAmount.add(amount); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", - "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalCreditedAmount, totalAmount); - } - - if (totalAmount.compareTo(totalDebitedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", - "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " - + loanId + ", transaction " + transactionId, - totalDebitedAmount, totalAmount); - } - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - */ - public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - // reverse debits and credits for reversals - if (isReversal) { - accountTypeToDebitId = accountTypeToBeCredited; - accountTypeToCreditId = accountTypeToBeDebited; - } - createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, - loanId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param loanProductId - * @param paymentTypeId - * @param loanId - * @param transactionId - * @param transactionDate - * @param amount - */ - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - int accountTypeToCreditId = accountTypeToBeCredited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, - final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - int accountTypeToDebitId = accountTypeToBeDebited; - createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, - transactionId, transactionDate, amount); - } - - public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, - JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, - LocalDate transactionDate) { - splitAccountsHolder.forEach(journalItemHolder -> { - if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), - paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, - journalItemHolder.getAmount()); - } - }); - if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { - final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), - paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, - totalAccountHolder.getAmount()); - } - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, - final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final int accountMappingTypeId = accountMappingType.getValue(); - createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, - transactionDate, amount); - } - - /** - * @param latestGLClosure - * @param transactionDate - */ - public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { - // check if an accounting closure has happened for this branch after the transaction Date - if (latestGLClosure != null) { - if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { - throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), - null, null); - } - } - } - - public GLClosure getLatestClosureByBranch(final long officeId) { - return this.closureRepository.getLatestGLClosureByBranch(officeId); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, - final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); - createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); - } - - private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - - /** - * Interest credited to a member, routed through the payable and paid accounts the product names. - * - *

-     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
-     *   Cr  Savings Interest Payable   (liability)   and now owes it
-     *
-     *   Dr  Savings Interest Payable
-     *   Cr  Savings Interest Paid                    the debt is on its way out
-     *
-     *   Dr  Savings Interest Paid
-     *   Cr  Savings Control                          and reaches the member
-     * 
- * - *

- * The two middle accounts net to nil across the three steps, so the trial balance is unchanged - * from the plain two-legged entry; what they add is a reading of how much interest has been - * declared but not yet credited, which a single Dr/Cr pair cannot show. - * - *

- * Both are read from the product's Accounting tab and neither is required. A product that names - * no paid account collapses the middle step, and one that names no payable falls back to its own - * Savings Control - either way the posting degrades to exactly the two-legged entry it was - * before, so products configured before these mappings existed are unaffected. - * - *

- * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} - * does for the two-legged case. - */ - public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { - - final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); - - // The payable comes from the Interest Payable row on the product's Accounting tab, which is - // where a bank actually picks it. The reserved name in the product's other GL mappings is - // consulted only after that, for a product configured before that row was mapped, and the - // Savings Control last of all - interest owed is a liability either way. - final GLAccount payableAccount = Optional - .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, - AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) - .or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING))) - .orElse(controlAccount); - final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) - .orElse(payableAccount); - - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. - // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the - // product names no paid account both resolve to the payable and this says nothing, so it is - // skipped and the payout comes straight out of the payable. - final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); - if (!payableIsPaid) { - postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - } - - // Stage 2: into the member's own balance. - final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; - postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, - isReversal); - } - - /** - * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. - * - *

- * A leg whose two sides are the same account writes nothing. That is not a defensive check but - * the mechanism by which the three stages above collapse: a product that names no payable and no - * paid account resolves all three to its Savings Control, and what should remain is the plain - * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping - * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and - * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's - * debits and credits unequal per account and shows up on the trial balance as movement that never - * happened. Deciding it here rather than at each call site means every collapse works the same - * way, whichever of the two names a product happens to leave unset. - */ - private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, - final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final boolean isReversal) { - if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { - return; - } - final GLAccount toDebit = isReversal ? creditAccount : debitAccount; - final GLAccount toCredit = isReversal ? debitAccount : creditAccount; - createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); - } - - /** - * A GL account the savings product maps under one of the reserved names on its Accounting tab. - * - *

- * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of - * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in - * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's - * fixed financial account types. The name is matched across all four groups: which group a bank - * files it under is a presentation choice, and an account configured under the wrong heading - * should still be honoured rather than silently ignored. - * - * @return the account, or null where the product does not configure this name - */ - private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { - if (savingsProductId == null) { - 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); - } - - /** - * Creates a direct transfer journal entry between two savings-type accounts (savings or current), - * each with their own product GL mapping and entity ID so the correct account number appears per entry. - * - * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited - * @param debitEntityId entity (account) ID shown on the debit entry - * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited - * @param creditEntityId entity (account) ID shown on the credit entry - */ - public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, - final Long debitProductId, final Long debitEntityId, - final Long creditProductId, final Long creditEntityId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, - CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * @param currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * @param paymentTypeId - * @param savingsId - * @param transactionId - * @param transactionDate - * @param amount - * @param isReversal - * @param taxDetails - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), - savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); - } - } - } - createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, - paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal, final List taxDetails) { - for (TaxPaymentDTO taxPaymentDTO : taxDetails) { - if (taxPaymentDTO.getAmount() != null) { - if (taxPaymentDTO.getCreditAccountId() == null) { - createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } else { - createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, - taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), - isReversal); - } - } - } - createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); - } - - public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - if (isReversal) { - createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } else { - createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, - transactionId, transactionDate, amount); - } - } - - public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, - final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount, final Boolean isReversal) { - // reverse debits and credits for reversals - final GLAccount creditAccount = getGLAccountById(creditAccountId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - } - - private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, true); - } - - public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs) { - createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, - transactionDate, totalAmount, chargePaymentDTOs, false); - } - - /** - * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. - * - * The target accounts for debits and credits are switched in case of a reversal - * - * @param office - * office - * @param currencyCode - * currencyCode - * @param accountTypeToBeDebited - * Enum of the placeholder GLAccount to be debited - * @param accountTypeToBeCredited - * Enum of the placeholder of the GLAccount to be credited - * @param savingsProductId - * savingsProductId - * @param paymentTypeId - * paymentTypeId - * @param loanId - * loanId - * @param transactionId - * transactionId - * @param transactionDate - * transactionDate - * @param totalAmount - * totalAmount - * @param isReversal - * isReversal - * @param chargePaymentDTOs - * chargePaymentDTOs - */ - public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, - final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, - final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, - final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, - final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { - // TODO Vishwas: Remove this validation, as and when appropriate Junit - // tests are written for accounting - /** - * Accounting module currently supports a single charge per transaction, throw an error if this is not the case - * here so any developers changing the expected portfolio behavior would also take care of modifying the - * accounting code appropriately - **/ - if (chargePaymentDTOs.size() != 1) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); - } - ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); - GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - - final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), - paymentTypeId); - if (isReversal) { - createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - } else { - createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, - totalAmount); - createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, - totalAmount); - } - } - - public Office getOfficeById(final long officeId) { - return this.officeRepository.getReferenceById(officeId); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); - createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); - } - - public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - - private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) - throws DataAccessException { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, - GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, - String currencyCode, GLAccount account, BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, - null, null, null, null, null); - persistJournalEntry(journalEntry); - } - - public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long loanTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - loanTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, - loanTransactionId, null, null, null); - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, - final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long savingsAccountTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - savingsAccountTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, - savingsAccountTransactionId, null, null); - - persistJournalEntry(journalEntry); - } - - private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, - transactionId, null); - persistJournalEntry(journalEntry); - } - - public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, - transactionId, transactionDate, amount); - } - - public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); - createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal amount) { - final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); - createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); - } - - public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, - final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, - final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { - - createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, - shareAccountId, transactionId, transactionDate, totalAmount); - createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, - transactionId, transactionDate, totalAmount, chargePaymentDTOs); - } - - public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, - final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, - final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, - final List chargePaymentDTOs) { - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), - chargePaymentDTO.getChargeId()); - BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in the - // map - if (creditDetailsMap.containsKey(chargeSpecificAccount)) { - final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = entry.getKey(); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); - } - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", - "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); - } - } - - private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, - final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { - final boolean manualEntry = false; - Long shareTransactionId = null; - String modifiedTransactionId = transactionId; - if (StringUtils.isNumeric(transactionId)) { - shareTransactionId = Long.parseLong(transactionId); - modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; - } - final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, - transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, - null, null, shareTransactionId); - persistJournalEntry(journalEntry); - } - - public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, - AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); - - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, - PortfolioProductType.LOAN.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Check for charge-specific mappings for all account types (not just income accounts) - // This allows charge-specific GL account mappings for debit accounts as well - if (chargeId != null) { - final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificAccountMapping != null) { - accountMapping = chargeSpecificAccountMapping; - } - } - return accountMapping.getGlAccount(); - } - - private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, - final Long chargeId) { - - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - // Vishwas TODO: remove this condition as it should always be true - - if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() - || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { - GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); - if (glAccount != null) { - return glAccount; - } - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - - accountMapping = chargeSpecificIncomeAccountMapping; - } - } - - return accountMapping.getGlAccount(); - } - - /** - * One row of the savings product's Accounting tab, or null where the bank has not filled it in. - * - *

- * Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is - * required. Interest Payable is offered on the product screen but a product configured before it - * existed has none, and that has to fall through to the next choice rather than stopping the - * account from being closed. - */ - private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - if (savingsProductId == null) { - return null; - } - // Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a - // missing mapping that method builds its message through CashAccountsForSavings.fromInt, - // which returns null for a row the cash enum does not carry - Interest Payable among them - - // and the lookup would die on a NullPointerException instead of the exception meant for it. - final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - return mapping == null ? null : mapping.getGlAccount(); - } - - private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId); - /**** - * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source - * placeholder ID would be same for both cash and accrual accounts - ***/ - if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, - PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, - CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, - final Long paymentTypeId) { - GLAccount glAccount; - if (isOrganizationAccount(accountMappingTypeId)) { - FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); - glAccount = financialActivityAccount.getGlAccount(); - } else { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - - if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { - final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository - .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); - if (paymentChannelSpecificAccountMapping != null) { - accountMapping = paymentChannelSpecificAccountMapping; - } - } - // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can - // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so - // this has to fail with a clean, named error instead of NPEing on accountMapping. - if (accountMapping == null) { - throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId, - CashAccountsForShares.fromInt(accountMappingTypeId).toString()); - } - glAccount = accountMapping.getGlAccount(); - } - return glAccount; - } - - private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { - ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, - PortfolioProductType.SHARES.getValue(), accountMappingTypeId); - /***** - * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the - * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both - * cash and accrual based accounts - *****/ - - final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository - .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), - accountMappingTypeId, chargeId); - if (chargeSpecificIncomeAccountMapping != null) { - accountMapping = chargeSpecificIncomeAccountMapping; - } - return accountMapping.getGlAccount(); - } - - private boolean isOrganizationAccount(final int accountMappingTypeId) { - return FinancialActivity.fromInt(accountMappingTypeId) != null; - } - - public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, - final List clientChargePaymentDTOs) { - /*** - * Map to track each account affected and the net credit to be made for a particular account - ***/ - final Map creditDetailsMap = new LinkedHashMap<>(); - for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { - if (clientChargePaymentDTO.getIncomeAccountId() != null) { - final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); - BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); - - // adjust net credit amount if the account is already present in - // the map - if (creditDetailsMap.containsKey(accountId)) { - final BigDecimal existingAmount = creditDetailsMap.get(accountId); - chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); - } - creditDetailsMap.put(accountId, chargeSpecificAmount); - } - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - for (final Map.Entry entry : creditDetailsMap.entrySet()) { - final GLAccount account = getGLAccountById(entry.getKey()); - final BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - if (isReversal) { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - return totalCreditedAmount; - } - - public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, - final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, - final Boolean isReversal) { - final GLAccount account = financialActivityAccountRepository - .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); - if (isReversal) { - createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); - } - } - - private GLAccount getGLAccountById(final Long accountId) { - return this.glAccountRepository.getReferenceById(accountId); - } - - public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { - if (chargeRefundChargeType == null - || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { - String errorValue; - errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); - throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", - "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); - } - Integer incomeAccount; - if (chargeRefundChargeType.equalsIgnoreCase("P")) { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); - } else { - incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); - - } - return incomeAccount; - } - - public JournalEntry persistJournalEntry(JournalEntry journalEntry) { - boolean isNew = journalEntry.isNew(); - JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); - if (isNew && journalEntry.getLoanTransactionId() != null) { - businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); - } - return savedJournalEntry; - } - - private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, - final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, - final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { - final Map creditDetailsMap = new LinkedHashMap<>(); - - for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { - final Long chargeId = chargePaymentDTO.getChargeId(); - final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); - BigDecimal amount = chargePaymentDTO.getAmount(); - - creditDetailsMap.merge(account, amount, BigDecimal::add); - } - - BigDecimal totalCreditedAmount = BigDecimal.ZERO; - - for (Map.Entry entry : creditDetailsMap.entrySet()) { - GLAccount account = entry.getKey(); - BigDecimal amount = entry.getValue(); - totalCreditedAmount = totalCreditedAmount.add(amount); - - if (isCredit) { - createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } else { - createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); - } - } - - if (totalAmount.compareTo(totalCreditedAmount) != 0) { - throw new PlatformDataIntegrityException( - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", - totalCreditedAmount, totalAmount); - } - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.accounting.journalentry.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +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; +import org.apache.fineract.accounting.common.AccountingConstants; +import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; +import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingParams; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository; +import org.apache.fineract.accounting.glaccount.domain.GLAccountType; +import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO; +import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.LoanDTO; +import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsDTO; +import org.apache.fineract.accounting.journalentry.data.SavingsTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.SharesDTO; +import org.apache.fineract.accounting.journalentry.data.SharesTransactionDTO; +import org.apache.fineract.accounting.journalentry.data.TaxPaymentDTO; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException; +import org.apache.fineract.accounting.journalentry.exception.JournalEntryInvalidException.GlJournalEntryInvalidReason; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingNotFoundException; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.infrastructure.event.business.domain.journalentry.LoanJournalEntryCreatedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepository; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.account.PortfolioAccountType; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; +import org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeLoanTransactionDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargeData; +import org.apache.fineract.portfolio.loanaccount.data.LoanChargePaidByDTO; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData; +import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionEnumData; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +@Slf4j +@RequiredArgsConstructor +public class AccountingProcessorHelper { + + public static final String LOAN_TRANSACTION_IDENTIFIER = "L"; + public static final String SAVINGS_TRANSACTION_IDENTIFIER = "S"; + public static final String FIXED_DEPOSIT_TRANSACTION_IDENTIFIER = "FD"; + public static final String RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER = "RD"; + public static final String CLIENT_TRANSACTION_IDENTIFIER = "C"; + public static final String PROVISIONING_TRANSACTION_IDENTIFIER = "P"; + public static final String SHARE_TRANSACTION_IDENTIFIER = "SH"; + + /** + * Reserved names on the savings product's Accounting tab, naming the two accounts an interest + * posting is routed through. The names are the contract with the product screen; the GL codes + * behind them are the bank's choice, so no code is named here. + */ + public static final String SAVINGS_INTEREST_PAYABLE_MAPPING = "Savings Interest Payable"; + public static final String SAVINGS_INTEREST_PAID_MAPPING = "Savings Interest Paid"; + + private static final ThreadLocal DEPOSIT_ACCOUNT_TYPE_CONTEXT = ThreadLocal.withInitial(() -> 100); + + public static void setDepositAccountTypeContext(int depositAccountType) { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.set(depositAccountType); + } + + public static void clearDepositAccountTypeContext() { + DEPOSIT_ACCOUNT_TYPE_CONTEXT.remove(); + } + + private String getSavingsTransactionIdentifier() { + return switch (DEPOSIT_ACCOUNT_TYPE_CONTEXT.get()) { + case 200 -> FIXED_DEPOSIT_TRANSACTION_IDENTIFIER; + case 300 -> RECURRING_DEPOSIT_TRANSACTION_IDENTIFIER; + default -> SAVINGS_TRANSACTION_IDENTIFIER; + }; + } + + private final JournalEntryRepository glJournalEntryRepository; + private final ProductToGLAccountMappingRepository accountMappingRepository; + private final FinancialActivityAccountRepositoryWrapper financialActivityAccountRepository; + private final GLClosureRepository closureRepository; + private final GLAccountRepository glAccountRepository; + private final OfficeRepository officeRepository; + private final AccountTransfersReadPlatformService accountTransfersReadPlatformService; + private final ChargeRepositoryWrapper chargeRepositoryWrapper; + private final BusinessEventNotifierService businessEventNotifierService; + // The savings interest payable/paid accounts live in m_savings_product.other_gl_mappings as a + // JSON document rather than in acc_product_mapping, so they are read directly. See + // namedSavingsGlAccount. + private final JdbcTemplate jdbcTemplate; + + public LoanDTO populateLoanDtoFromDTO( + final org.apache.fineract.portfolio.loanaccount.data.AccountingBridgeDataDTO accountingBridgeData) { + final Long loanId = accountingBridgeData.getLoanId(); + final Long loanProductId = accountingBridgeData.getLoanProductId(); + final Long officeId = accountingBridgeData.getOfficeId(); + final String currencyCode = accountingBridgeData.getCurrencyCode(); + final List newLoanTransactions = new ArrayList<>(); + boolean isAccountTransfer = accountingBridgeData.isAccountTransfer(); + boolean isLoanMarkedAsChargeOff = accountingBridgeData.isChargeOff(); + boolean isLoanMarkedAsFraud = accountingBridgeData.isFraud(); + final Long chargeOffReasonCodeValue = accountingBridgeData.getChargeOffReasonCodeValue(); + final boolean isLoanMarkedAsWrittenOff = accountingBridgeData.isWrittenOff(); + final boolean cashBasedAccountingEnabled = accountingBridgeData.isCashBasedAccountingEnabled(); + final boolean upfrontAccrualBasedAccountingEnabled = accountingBridgeData.isUpfrontAccrualBasedAccountingEnabled(); + final boolean periodicAccrualBasedAccountingEnabled = accountingBridgeData.isPeriodicAccrualBasedAccountingEnabled(); + final boolean merchantBuyDownFee = accountingBridgeData.isMerchantBuyDownFee(); + + final List loanTransactionDTOs = accountingBridgeData.getNewLoanTransactions(); + + for (final AccountingBridgeLoanTransactionDTO loanTxnDto : loanTransactionDTOs) { + final Long transactionOfficeId = loanTxnDto.getOfficeId(); + final String transactionId = loanTxnDto.getId().toString(); + final LocalDate transactionDate = loanTxnDto.getDate(); + final LoanTransactionEnumData transactionType = loanTxnDto.getType(); + final BigDecimal amount = loanTxnDto.getAmount(); + final BigDecimal principal = loanTxnDto.getPrincipalPortion(); + final BigDecimal interest = loanTxnDto.getInterestPortion(); + final BigDecimal fees = loanTxnDto.getFeeChargesPortion(); + final BigDecimal penalties = loanTxnDto.getPenaltyChargesPortion(); + final BigDecimal overPayments = loanTxnDto.getOverPaymentPortion(); + final boolean reversed = loanTxnDto.isReversed(); + final Long paymentTypeId = loanTxnDto.getPaymentTypeId(); + final String chargeRefundChargeType = loanTxnDto.getChargeRefundChargeType(); + final LoanChargeData loanChargeData = loanTxnDto.getLoanChargeData(); + + final List feePaymentDetails = new ArrayList<>(); + final List penaltyPaymentDetails = new ArrayList<>(); + // extract charge payment details (if exists) + if (loanTxnDto.getLoanChargesPaid() != null) { + List loanChargesPaidData = loanTxnDto.getLoanChargesPaid(); + for (final LoanChargePaidByDTO loanChargePaid : loanChargesPaidData) { + final Long chargeId = loanChargePaid.getChargeId(); + final Long loanChargeId = loanChargePaid.getLoanChargeId(); + final boolean isPenalty = loanChargePaid.getIsPenalty(); + final BigDecimal chargeAmountPaid = loanChargePaid.getAmount(); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPaymentDetails.add(chargePaymentDTO); + } else { + feePaymentDetails.add(chargePaymentDTO); + } + } + } + + boolean localIsAccountTransfer = isAccountTransfer; + if (!localIsAccountTransfer) { + localIsAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.LOAN); + } + + BigDecimal principalPaid = loanTxnDto.getPrincipalPaid(); + BigDecimal feePaid = loanTxnDto.getFeePaid(); + BigDecimal penaltyPaid = loanTxnDto.getPenaltyPaid(); + + final LoanTransactionDTO transaction = new LoanTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, principal, interest, fees, penalties, overPayments, reversed, + penaltyPaymentDetails, feePaymentDetails, localIsAccountTransfer, chargeRefundChargeType, loanChargeData, principalPaid, + feePaid, penaltyPaid); + + transaction.setLoanToLoanTransfer(loanTxnDto.isLoanToLoanTransfer()); + newLoanTransactions.add(transaction); + } + + return new LoanDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, upfrontAccrualBasedAccountingEnabled, + periodicAccrualBasedAccountingEnabled, newLoanTransactions, isLoanMarkedAsChargeOff, isLoanMarkedAsFraud, + chargeOffReasonCodeValue, isLoanMarkedAsWrittenOff, merchantBuyDownFee, + accountingBridgeData.getBuydownFeeClassificationCodeValue(), + accountingBridgeData.getCapitalizedIncomeClassificationCodeValue(), accountingBridgeData.getWriteOffReasonCodeValue()); + } + + public ProductToGLAccountMapping getChargeOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long chargeOffReasonId) { + return accountMappingRepository.findChargeOffReasonMapping(loanProductId, productType.getValue(), chargeOffReasonId); + } + + public ProductToGLAccountMapping getWriteOffMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + Long writeOffReasonId) { + return accountMappingRepository.findWriteOffReasonMapping(loanProductId, productType.getValue(), writeOffReasonId); + } + + public ProductToGLAccountMapping getClassificationMappingByCodeValue(Long loanProductId, PortfolioProductType productType, + final Long classificationId, final String classificationType) { + if (LoanProductAccountingParams.BUYDOWN_FEE_CLASSIFICATION_TO_INCOME_ACCOUNT_MAPPINGS.getValue().equals(classificationType)) { + return accountMappingRepository.findBuydownFeeClassificationMapping(loanProductId, productType.getValue(), classificationId); + } else { + return accountMappingRepository.findCapitalizedIncomeClassificationMapping(loanProductId, productType.getValue(), + classificationId); + } + } + + public SavingsDTO populateSavingsDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long loanId = (Long) accountingBridgeData.get("savingsId"); + final Long loanProductId = (Long) accountingBridgeData.get("savingsProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final int depositAccountType = accountingBridgeData.containsKey("depositAccountType") + ? (Integer) accountingBridgeData.get("depositAccountType") + : 100; + final List newSavingsTransactions = new ArrayList<>(); + boolean isAccountTransfer = (Boolean) accountingBridgeData.get("isAccountTransfer"); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newSavingsTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final SavingsAccountTransactionEnumData transactionType = (SavingsAccountTransactionEnumData) map.get("type"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final boolean reversed = (Boolean) map.get("reversed"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + final BigDecimal overdraftAmount = (BigDecimal) map.get("overdraftAmount"); + + final List feePayments = new ArrayList<>(); + final List penaltyPayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("savingsChargesPaid")) { + @SuppressWarnings("unchecked") + final List> savingsChargesPaidData = (List>) map.get("savingsChargesPaid"); + for (final Map loanChargePaid : savingsChargesPaidData) { + final Long chargeId = (Long) loanChargePaid.get("chargeId"); + final Long loanChargeId = (Long) loanChargePaid.get("savingsChargeId"); + final boolean isPenalty = (Boolean) loanChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) loanChargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + if (isPenalty) { + penaltyPayments.add(chargePaymentDTO); + } else { + feePayments.add(chargePaymentDTO); + } + } + } + + final List taxPayments = new ArrayList<>(); + if (map.containsKey("taxDetails")) { + @SuppressWarnings("unchecked") + final List> taxDataList = (List>) map.get("taxDetails"); + for (final Map taxData : taxDataList) { + final BigDecimal taxAmount = (BigDecimal) taxData.get("amount"); + final Long creditAccountId = (Long) taxData.get("creditAccountId"); + final Long debitAccountId = (Long) taxData.get("debitAccountId"); + taxPayments.add(new TaxPaymentDTO(debitAccountId, creditAccountId, taxAmount)); + } + } + + if (!isAccountTransfer) { + isAccountTransfer = this.accountTransfersReadPlatformService.isAccountTransfer(Long.parseLong(transactionId), + PortfolioAccountType.SAVINGS); + } + final SavingsTransactionDTO transaction = new SavingsTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, amount, reversed, feePayments, penaltyPayments, overdraftAmount, isAccountTransfer, + taxPayments); + + newSavingsTransactions.add(transaction); + + } + + return new SavingsDTO(loanId, loanProductId, officeId, currencyCode, cashBasedAccountingEnabled, accrualBasedAccountingEnabled, + newSavingsTransactions, depositAccountType); + } + + public SharesDTO populateSharesDtoFromMap(final Map accountingBridgeData, final boolean cashBasedAccountingEnabled, + final boolean accrualBasedAccountingEnabled) { + final Long shareAccountId = (Long) accountingBridgeData.get("shareAccountId"); + final Long shareProductId = (Long) accountingBridgeData.get("shareProductId"); + final Long officeId = (Long) accountingBridgeData.get("officeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final List newTransactions = new ArrayList<>(); + + @SuppressWarnings("unchecked") + final List> newTransactionsMap = (List>) accountingBridgeData.get("newTransactions"); + + for (final Map map : newTransactionsMap) { + final Long transactionOfficeId = (Long) map.get("officeId"); + final String transactionId = ((Long) map.get("id")).toString(); + final LocalDate transactionDate = ((LocalDate) map.get("date")); + final ShareAccountTransactionEnumData transactionType = (ShareAccountTransactionEnumData) map.get("type"); + final ShareAccountTransactionEnumData transactionStatus = (ShareAccountTransactionEnumData) map.get("status"); + final BigDecimal amount = (BigDecimal) map.get("amount"); + final BigDecimal chargeAmount = (BigDecimal) map.get("chargeAmount"); + final Long paymentTypeId = (Long) map.get("paymentTypeId"); + + final List feePayments = new ArrayList<>(); + // extract charge payment details (if exists) + if (map.containsKey("chargesPaid")) { + @SuppressWarnings("unchecked") + final List> chargesPaidData = (List>) map.get("chargesPaid"); + for (final Map chargePaid : chargesPaidData) { + final Long chargeId = (Long) chargePaid.get("chargeId"); + final Long loanChargeId = (Long) chargePaid.get("sharesChargeId"); + final BigDecimal chargeAmountPaid = (BigDecimal) chargePaid.get("amount"); + final ChargePaymentDTO chargePaymentDTO = new ChargePaymentDTO(chargeId, chargeAmountPaid, loanChargeId); + feePayments.add(chargePaymentDTO); + } + } + final SharesTransactionDTO transaction = new SharesTransactionDTO(transactionOfficeId, paymentTypeId, transactionId, + transactionDate, transactionType, transactionStatus, amount, chargeAmount, feePayments); + + newTransactions.add(transaction); + + } + + return new SharesDTO(shareAccountId, shareProductId, officeId, currencyCode, cashBasedAccountingEnabled, + accrualBasedAccountingEnabled, newTransactions); + } + + public ClientTransactionDTO populateClientTransactionDtoFromMap(final Map accountingBridgeData) { + + final Long transactionOfficeId = (Long) accountingBridgeData.get("officeId"); + final Long clientId = (Long) accountingBridgeData.get("clientId"); + final Long transactionId = (Long) accountingBridgeData.get("id"); + final LocalDate transactionDate = ((LocalDate) accountingBridgeData.get("date")); + final EnumOptionData transactionType = (EnumOptionData) accountingBridgeData.get("type"); + final BigDecimal amount = (BigDecimal) accountingBridgeData.get("amount"); + final boolean reversed = (Boolean) accountingBridgeData.get("reversed"); + final Long paymentTypeId = (Long) accountingBridgeData.get("paymentTypeId"); + final String currencyCode = (String) accountingBridgeData.get("currencyCode"); + final Boolean accountingEnabled = (Boolean) accountingBridgeData.get("accountingEnabled"); + + final List clientChargePaymentDTOs = new ArrayList<>(); + // extract client charge payment details (if exists) + if (accountingBridgeData.containsKey("clientChargesPaid")) { + @SuppressWarnings("unchecked") + final List> clientChargesPaidData = (List>) accountingBridgeData + .get("clientChargesPaid"); + for (final Map clientChargePaid : clientChargesPaidData) { + final Long chargeId = (Long) clientChargePaid.get("chargeId"); + final Long clientChargeId = (Long) clientChargePaid.get("clientChargeId"); + final boolean isPenalty = (Boolean) clientChargePaid.get("isPenalty"); + final BigDecimal chargeAmountPaid = (BigDecimal) clientChargePaid.get("amount"); + final Long incomeAccountId = (Long) clientChargePaid.get("incomeAccountId"); + final ClientChargePaymentDTO clientChargePaymentDTO = new ClientChargePaymentDTO(chargeId, chargeAmountPaid, clientChargeId, + isPenalty, incomeAccountId); + clientChargePaymentDTOs.add(clientChargePaymentDTO); + } + } + + return new ClientTransactionDTO(clientId, transactionOfficeId, paymentTypeId, transactionId, transactionDate, transactionType, + currencyCode, amount, reversed, accountingEnabled, clientChargePaymentDTOs); + + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Accrual Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * loanProductId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createJournalEntriesForLoanCharges(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + final Map creditDetailsMap = new LinkedHashMap<>(); + final Map debitDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount chargeSpecificCreditAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeCredited, + chargeId); + final GLAccount chargeSpecificDebitAccount = getLinkedGLAccountForLoanCharges(loanProductId, accountTypeToBeDebited, chargeId); + final BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // aggregate amounts by account for credit entries + creditDetailsMap.merge(chargeSpecificCreditAccount, chargeSpecificAmount, BigDecimal::add); + + // aggregate amounts by account for debit entries + debitDetailsMap.merge(chargeSpecificDebitAccount, chargeSpecificAmount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + BigDecimal totalDebitedAmount = BigDecimal.ZERO; + + // Create credit journal entries + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + // Create debit journal entries using charge-specific debit accounts + for (final Map.Entry entry : debitDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalDebitedAmount = totalDebitedAmount.add(amount); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge credits does not equal the total transaction amount", + "Sum of charge credits (" + totalCreditedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalCreditedAmount, totalAmount); + } + + if (totalAmount.compareTo(totalDebitedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charge debits does not equal the total transaction amount", + "Sum of charge debits (" + totalDebitedAmount + ") does not equal transaction total (" + totalAmount + ") for loan " + + loanId + ", transaction " + transactionId, + totalDebitedAmount, totalAmount); + } + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + */ + public void createCashBasedJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Integer accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + // reverse debits and credits for reversals + if (isReversal) { + accountTypeToDebitId = accountTypeToBeCredited; + accountTypeToCreditId = accountTypeToBeDebited; + } + createJournalEntriesForSavings(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, savingsProductId, paymentTypeId, + loanId, transactionId, transactionDate, amount); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param loanProductId + * @param paymentTypeId + * @param loanId + * @param transactionId + * @param transactionDate + * @param amount + */ + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final Integer accountTypeToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + int accountTypeToCreditId = accountTypeToBeCredited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountTypeToCreditId, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createJournalEntriesForLoan(final Office office, final String currencyCode, final Integer accountTypeToBeDebited, + final GLAccount accountToBeCredited, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + int accountTypeToDebitId = accountTypeToBeDebited; + createJournalEntriesForLoan(office, currencyCode, accountTypeToDebitId, accountToBeCredited, loanProductId, paymentTypeId, loanId, + transactionId, transactionDate, amount); + } + + public void createSplitJournalEntriesForLoan(Office office, String currencyCode, List splitAccountsHolder, + JournalAmountHolder totalAccountHolder, Long loanProductId, Long paymentTypeId, Long loanId, String transactionId, + LocalDate transactionDate) { + splitAccountsHolder.forEach(journalItemHolder -> { + if (MathUtil.isGreaterThanZero(journalItemHolder.getAmount())) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, journalItemHolder.getAccountType(), + paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, + journalItemHolder.getAmount()); + } + }); + if (MathUtil.isGreaterThanZero(totalAccountHolder.getAmount())) { + final GLAccount totalAccount = getLinkedGLAccountForLoanProduct(loanProductId, totalAccountHolder.getAccountType(), + paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, totalAccount, loanId, transactionId, transactionDate, + totalAccountHolder.getAmount()); + } + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final CashAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, + final AccrualAccountsForLoan accountMappingType, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final int accountMappingTypeId = accountMappingType.getValue(); + createCreditJournalEntryForLoan(office, currencyCode, accountMappingTypeId, loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + + /** + * @param latestGLClosure + * @param transactionDate + */ + public void checkForBranchClosures(final GLClosure latestGLClosure, final LocalDate transactionDate) { + // check if an accounting closure has happened for this branch after the transaction Date + if (latestGLClosure != null) { + if (!DateUtils.isBefore(latestGLClosure.getClosingDate(), transactionDate)) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.ACCOUNTING_CLOSED, latestGLClosure.getClosingDate(), + null, null); + } + } + } + + public GLClosure getLatestClosureByBranch(final long officeId) { + return this.closureRepository.getLatestGLClosureByBranch(officeId); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForLoan(final Office office, final String currencyCode, final int accountTypeToDebitId, + final GLAccount creditAccount, final Long loanProductId, final Long paymentTypeId, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForLoanProduct(loanProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, debitAccount, loanId, transactionId, transactionDate, amount); + createCreditJournalEntryForLoan(office, currencyCode, creditAccount, loanId, transactionId, transactionDate, amount); + } + + private void createJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + + /** + * Interest credited to a member, routed through the payable and paid accounts the product names. + * + *

+     *   Dr  Interest On Savings        (expense)     the bank incurs the cost
+     *   Cr  Savings Interest Payable   (liability)   and now owes it
+     *
+     *   Dr  Savings Interest Payable
+     *   Cr  Savings Interest Paid                    the debt is on its way out
+     *
+     *   Dr  Savings Interest Paid
+     *   Cr  Savings Control                          and reaches the member
+     * 
+ * + *

+ * The two middle accounts net to nil across the three steps, so the trial balance is unchanged + * from the plain two-legged entry; what they add is a reading of how much interest has been + * declared but not yet credited, which a single Dr/Cr pair cannot show. + * + *

+ * Both are read from the product's Accounting tab and neither is required. A product that names + * no paid account collapses the middle step, and one that names no payable falls back to its own + * Savings Control - either way the posting degrades to exactly the two-legged entry it was + * before, so products configured before these mappings existed are unaffected. + * + *

+ * Reversals swap every leg, the same way {@code createCashBasedJournalEntriesAndReversalsForSavings} + * does for the two-legged case. + */ + public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) { + + final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); + + // The payable comes from the Interest Payable row on the product's Accounting tab, which is + // where a bank actually picks it. The reserved name in the product's other GL mappings is + // consulted only after that, for a product configured before that row was mapped, and the + // Savings Control last of all - interest owed is a liability either way. + final GLAccount payableAccount = Optional + .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, + AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) + .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, "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; + } + + // The interest is charged to expense and raised as a liability first, for whatever part of + // it no accrual has already covered. + // + // The chain below draws the payable DOWN, so it assumes an accrual already credited it for + // the period. Where none ran - a product posting on cash accounting, or a period the accrual + // job never covered - the payable went negative by the interest and the expense netted to + // nothing across the next two legs, leaving the charge off the P&L entirely. A liability + // account cannot hold a negative balance: the bank does not owe less than nothing. + // + // So the shortfall is raised here rather than assumed away. Where the accrual DID run, the + // payable already carries the interest and this leg writes nothing - the balance below is + // enough, and raising it again would double the liability. + // + // Only a liability payable is topped up. A bank that maps an expense GL as the payable books + // the cost there directly: payableBalance reads credit minus debit, so an expense's normal + // debit balance would come back negative and every posting would raise a shortfall larger + // than the interest itself. + 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) { + 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); + } + + final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId()); + if (hasSeparatePaidAccount) { + 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); + } + } + + /** + * One Dr/Cr pair, with both sides swapped when the transaction is a reversal. + * + *

+ * A leg whose two sides are the same account writes nothing. That is not a defensive check but + * the mechanism by which the three stages above collapse: a product that names no payable and no + * paid account resolves all three to its Savings Control, and what should remain is the plain + * {@code Dr Interest On Savings / Cr Savings Control} pair Fineract has always posted. Skipping + * the middle stage by hand was not enough — stage 3 still resolved to Control against Control and + * wrote a pair of entries against one account, which nets to nothing yet leaves the transaction's + * debits and credits unequal per account and shows up on the trial balance as movement that never + * happened. Deciding it here rather than at each call site means every collapse works the same + * way, whichever of the two names a product happens to leave unset. + */ + private void postSavingsLeg(final Office office, final String currencyCode, final GLAccount debitAccount, + final GLAccount creditAccount, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final boolean isReversal) { + if (debitAccount == null || creditAccount == null || debitAccount.getId().equals(creditAccount.getId())) { + return; + } + final GLAccount toDebit = isReversal ? creditAccount : debitAccount; + final GLAccount toCredit = isReversal ? debitAccount : creditAccount; + createDebitJournalEntryForSavings(office, currencyCode, toDebit, savingsId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, toCredit, savingsId, transactionId, transactionDate, amount); + } + + /** + * A GL account the savings product maps under one of the reserved names on its Accounting tab. + * + *

+ * These live in {@code m_savings_product.other_gl_mappings} as a JSON document of + * {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in + * {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's + * fixed financial account types. The name is matched across all four groups: which group a bank + * files it under is a presentation choice, and an account configured under the wrong heading + * should still be honoured rather than silently ignored. + * + * @return the account, or null where the product does not configure this name + */ + /** + * What the interest payable account currently holds for this office, on its own liability side. + * + *

+ * Read so an interest posting can tell an accrual that already ran from one that did not. The + * posting chain draws the payable down; where nothing had raised it, the account went negative + * and the expense netted to nothing, so the charge never reached the P&L at all. The caller + * raises only the shortfall, which is nil when the accrual did run. + * + *

+ * Credits less debits, because a payable is a liability: the balance is what the bank still + * owes. Floored at zero by the caller rather than here - a negative balance is a real state to + * report, and the caller's arithmetic needs the true figure, not a clamped one. + */ + private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) { + if (payableAccount == null || payableAccount.getId() == null) { + return BigDecimal.ZERO; + } + // Reversed entries and the entries they reverse are both excluded: a reversal pair nets to + // nothing, and counting either side alone would move the balance by the whole amount. + final BigDecimal balance = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0) + FROM acc_gl_journal_entry + WHERE account_id = ? + AND office_id = ? + AND currency_code = ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """, BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode); + return balance == null ? BigDecimal.ZERO : balance; + } + + private GLAccount namedSavingsGlAccount(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 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; + } + + /** + * Creates a direct transfer journal entry between two savings-type accounts (savings or current), + * each with their own product GL mapping and entity ID so the correct account number appears per entry. + * + * @param debitProductId product ID whose SAVINGS_CONTROL GL is debited + * @param debitEntityId entity (account) ID shown on the debit entry + * @param creditProductId product ID whose SAVINGS_CONTROL GL is credited + * @param creditEntityId entity (account) ID shown on the credit entry + */ + public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode, + final Long debitProductId, final Long debitEntityId, + final Long creditProductId, final Long creditEntityId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId, + CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** + * The same direct transfer where either side may be a current account. A current product maps its GLs under + * {@link PortfolioProductType#CURRENT}, so each side's control GL is read under its own product type -- reading a + * current product under SAVING picked up the GLs of the savings product that happens to share its id. + */ + public void createDirectTransferJournalEntries(final Office office, final String currencyCode, final Long debitProductId, + final boolean debitIsCurrent, final Long debitEntityId, final Long creditProductId, final boolean creditIsCurrent, + final Long creditEntityId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = controlGlAccount(debitProductId, debitIsCurrent); + final GLAccount creditAccount = controlGlAccount(creditProductId, creditIsCurrent); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount); + } + + /** Dr a current product's Current Control, Cr the GL mapped to {@code financialActivity}. */ + public void createCurrentControlToFinancialActivityJournalEntries(final Office office, final String currencyCode, + final Long currentProductId, final int financialActivity, final Long currentAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount controlAccount = controlGlAccount(currentProductId, true); + final GLAccount activityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(financialActivity).getGlAccount(); + createDebitJournalEntryForSavings(office, currencyCode, controlAccount, currentAccountId, transactionId, transactionDate, amount); + createCreditJournalEntryForSavings(office, currencyCode, activityAccount, currentAccountId, transactionId, transactionDate, amount); + } + + private GLAccount controlGlAccount(final Long productId, final boolean isCurrent) { + if (!isCurrent) { + return getLinkedGLAccountForSavingsProduct(productId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null); + } + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(productId, + PortfolioProductType.CURRENT.getValue(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (mapping == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.product.control.gl.not.mapped", + "Current product " + productId + " has no Current Control GL mapped; map it on the product's Accounting tab.", + productId); + } + return mapping.getGlAccount(); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * @param currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * @param paymentTypeId + * @param savingsId + * @param transactionId + * @param transactionDate + * @param amount + * @param isReversal + * @param taxDetails + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, final CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createCashBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, taxPaymentDTO.getCreditAccountId(), + savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), isReversal); + } + } + } + createCashBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), savingsProductId, + paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsTax(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal, final List taxDetails) { + for (TaxPaymentDTO taxPaymentDTO : taxDetails) { + if (taxPaymentDTO.getAmount() != null) { + if (taxPaymentDTO.getCreditAccountId() == null) { + createAccrualBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeCredited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } else { + createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(office, currencyCode, + taxPaymentDTO.getCreditAccountId(), savingsId, transactionId, transactionDate, taxPaymentDTO.getAmount(), + isReversal); + } + } + } + createAccrualBasedDebitJournalEntriesAndReversalsForSavings(office, currencyCode, accountTypeToBeDebited.getValue(), + savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + } + + public void createCashBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createCashBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedDebitJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeDebited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeDebited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Integer accountTypeToBeCredited, final Long savingsProductId, final Long paymentTypeId, final Long savingsId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + if (isReversal) { + createDebitJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } else { + createCreditJournalEntriesForSavings(office, currencyCode, accountTypeToBeCredited, savingsProductId, paymentTypeId, savingsId, + transactionId, transactionDate, amount); + } + } + + public void createAccrualBasedBasedCreditJournalEntriesAndReversalsForSavings(final Office office, final String currencyCode, + final Long creditAccountId, final Long savingsId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount, final Boolean isReversal) { + // reverse debits and credits for reversals + final GLAccount creditAccount = getGLAccountById(creditAccountId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + } + + private void createDebitJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForSavings(office, currencyCode, debitAccount, savingsId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntriesForSavings(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long chargeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, true); + } + + public void createDebitJournalEntryForLoanCharges(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs) { + createJournalEntriesForLoanChargesInternal(office, currencyCode, accountMappingTypeId, loanProductId, loanId, transactionId, + transactionDate, totalAmount, chargePaymentDTOs, false); + } + + /** + * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. + * + * The target accounts for debits and credits are switched in case of a reversal + * + * @param office + * office + * @param currencyCode + * currencyCode + * @param accountTypeToBeDebited + * Enum of the placeholder GLAccount to be debited + * @param accountTypeToBeCredited + * Enum of the placeholder of the GLAccount to be credited + * @param savingsProductId + * savingsProductId + * @param paymentTypeId + * paymentTypeId + * @param loanId + * loanId + * @param transactionId + * transactionId + * @param transactionDate + * transactionDate + * @param totalAmount + * totalAmount + * @param isReversal + * isReversal + * @param chargePaymentDTOs + * chargePaymentDTOs + */ + public void createCashBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final CashAccountsForSavings accountTypeToBeDebited, CashAccountsForSavings accountTypeToBeCredited, + final Long savingsProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final Boolean isReversal, + final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public void createAccrualBasedJournalEntriesAndReversalsForSavingsCharges(final Office office, final String currencyCode, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeDebited, + final AccountingConstants.AccrualAccountsForSavings accountTypeToBeCredited, final Long savingsProductId, + final Long paymentTypeId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final Boolean isReversal, final List chargePaymentDTOs) { + // TODO Vishwas: Remove this validation, as and when appropriate Junit + // tests are written for accounting + /** + * Accounting module currently supports a single charge per transaction, throw an error if this is not the case + * here so any developers changing the expected portfolio behavior would also take care of modifying the + * accounting code appropriately + **/ + if (chargePaymentDTOs.size() != 1) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for Savings have Broken the accounting code"); + } + ChargePaymentDTO chargePaymentDTO = chargePaymentDTOs.get(0); + GLAccount chargeSpecificAccount = getLinkedGLAccountForSavingsCharges(savingsProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + + final GLAccount savingsControlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, accountTypeToBeDebited.getValue(), + paymentTypeId); + if (isReversal) { + createDebitJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + } else { + createDebitJournalEntryForSavings(office, currencyCode, savingsControlAccount, loanId, transactionId, transactionDate, + totalAmount); + createCreditJournalEntryForSavings(office, currencyCode, chargeSpecificAccount, loanId, transactionId, transactionDate, + totalAmount); + } + } + + public Office getOfficeById(final long officeId) { + return this.officeRepository.getReferenceById(officeId); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long paymentTypeId, final Long loanId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount account = getLinkedGLAccountForLoanProduct(loanProductId, accountMappingTypeId, paymentTypeId); + createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, amount, account); + } + + public void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount, final GLAccount account) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + + private void createCreditJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) + throws DataAccessException { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningDebitJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, String currencyCode, + GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createProvisioningCreditJournalEntry(LocalDate transactionDate, Long provisioningEntryId, Office office, + String currencyCode, GLAccount account, BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = PROVISIONING_TRANSACTION_IDENTIFIER + provisioningEntryId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.PROVISIONING.getValue(), provisioningEntryId, + null, null, null, null, null); + persistJournalEntry(journalEntry); + } + + public void createDebitJournalEntryForLoan(final Office office, final String currencyCode, final GLAccount account, final Long loanId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long loanTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + loanTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = LOAN_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.LOAN.getValue(), loanId, null, + loanTransactionId, null, null, null); + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForSavings(final Office office, final String currencyCode, final GLAccount account, + final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long savingsAccountTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + savingsAccountTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = getSavingsTransactionIdentifier() + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SAVING.getValue(), savingsId, null, null, + savingsAccountTransactionId, null, null); + + persistJournalEntry(journalEntry); + } + + private void createDebitJournalEntryForClientPayments(final Office office, final String currencyCode, final GLAccount account, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + String modifiedTransactionId = CLIENT_TRANSACTION_IDENTIFIER + transactionId; + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.CLIENT.getValue(), clientId, null, null, null, + transactionId, null); + persistJournalEntry(journalEntry); + } + + public void createJournalEntriesForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final int accountTypeToCreditId, final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + createDebitJournalEntryForShares(office, currencyCode, accountTypeToDebitId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + createCreditJournalEntryForShares(office, currencyCode, accountTypeToCreditId, shareProductId, paymentTypeId, shareAccountId, + transactionId, transactionDate, amount); + } + + public void createDebitJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToDebitId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount debitAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToDebitId, paymentTypeId); + createDebitJournalEntryForShares(office, currencyCode, debitAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCreditJournalEntryForShares(final Office office, final String currencyCode, final int accountTypeToCreditId, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal amount) { + final GLAccount creditAccount = getLinkedGLAccountForShareProduct(shareProductId, accountTypeToCreditId, paymentTypeId); + createCreditJournalEntryForShares(office, currencyCode, creditAccount, shareAccountId, transactionId, transactionDate, amount); + } + + public void createCashBasedJournalEntriesForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeDebited, final CashAccountsForShares accountTypeToBeCredited, + final Long shareProductId, final Long paymentTypeId, final Long shareAccountId, final String transactionId, + final LocalDate transactionDate, final BigDecimal totalAmount, final List chargePaymentDTOs) { + + createDebitJournalEntryForShares(office, currencyCode, accountTypeToBeDebited.getValue(), shareProductId, paymentTypeId, + shareAccountId, transactionId, transactionDate, totalAmount); + createCashBasedJournalEntryForSharesCharges(office, currencyCode, accountTypeToBeCredited, shareProductId, shareAccountId, + transactionId, transactionDate, totalAmount, chargePaymentDTOs); + } + + public void createCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createCreditJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + public void revertCashBasedJournalEntryForSharesCharges(final Office office, final String currencyCode, + final CashAccountsForShares accountTypeToBeCredited, final Long shareProductId, final Long shareAccountId, + final String transactionId, final LocalDate transactionDate, final BigDecimal totalAmount, + final List chargePaymentDTOs) { + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final GLAccount chargeSpecificAccount = getLinkedGLAccountForShareCharges(shareProductId, accountTypeToBeCredited.getValue(), + chargePaymentDTO.getChargeId()); + BigDecimal chargeSpecificAmount = chargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in the + // map + if (creditDetailsMap.containsKey(chargeSpecificAccount)) { + final BigDecimal existingAmount = creditDetailsMap.get(chargeSpecificAccount); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(chargeSpecificAccount, chargeSpecificAmount); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = entry.getKey(); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + createDebitJournalEntryForShares(office, currencyCode, account, shareAccountId, transactionId, transactionDate, amount); + } + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException("Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code", + "Recent Portfolio changes w.r.t Charges for shares have Broken the accounting code"); + } + } + + private void createDebitJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.DEBIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + private void createCreditJournalEntryForShares(final Office office, final String currencyCode, final GLAccount account, + final Long shareAccountId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) { + final boolean manualEntry = false; + Long shareTransactionId = null; + String modifiedTransactionId = transactionId; + if (StringUtils.isNumeric(transactionId)) { + shareTransactionId = Long.parseLong(transactionId); + modifiedTransactionId = SHARE_TRANSACTION_IDENTIFIER + transactionId; + } + final JournalEntry journalEntry = JournalEntry.createNew(office, null, account, currencyCode, modifiedTransactionId, manualEntry, + transactionDate, JournalEntryType.CREDIT, amount, null, PortfolioProductType.SHARES.getValue(), shareAccountId, null, null, + null, null, shareTransactionId); + persistJournalEntry(journalEntry); + } + + public GLAccount getLinkedGLAccountForLoanProduct(final Long loanProductId, final int accountMappingTypeId, final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForLoan.FUND_SOURCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.LOAN, loanProductId, + AccrualAccountsForLoan.fromInt(accountMappingTypeId).toString()); + + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForLoanCharges(final Long loanProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(loanProductId, + PortfolioProductType.LOAN.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Check for charge-specific mappings for all account types (not just income accounts) + // This allows charge-specific GL account mappings for debit accounts as well + if (chargeId != null) { + final ProductToGLAccountMapping chargeSpecificAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(loanProductId, PortfolioProductType.LOAN.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificAccountMapping != null) { + accountMapping = chargeSpecificAccountMapping; + } + } + return accountMapping.getGlAccount(); + } + + private GLAccount getLinkedGLAccountForSavingsCharges(final Long savingsProductId, final int accountMappingTypeId, + final Long chargeId) { + + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + // Vishwas TODO: remove this condition as it should always be true + + if (accountMappingTypeId == CashAccountsForSavings.INCOME_FROM_FEES.getValue() + || accountMappingTypeId == CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()) { + GLAccount glAccount = chargeRepositoryWrapper.findOneWithNotFoundDetection(chargeId).getAccount(); + if (glAccount != null) { + return glAccount; + } + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(savingsProductId, PortfolioProductType.SAVING.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + + accountMapping = chargeSpecificIncomeAccountMapping; + } + } + + return accountMapping.getGlAccount(); + } + + /** + * One row of the savings product's Accounting tab, or null where the bank has not filled it in. + * + *

+ * Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is + * required. Interest Payable is offered on the product screen but a product configured before it + * existed has none, and that has to fall through to the next choice rather than stopping the + * account from being closed. + */ + private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + if (savingsProductId == null) { + return null; + } + // Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a + // missing mapping that method builds its message through CashAccountsForSavings.fromInt, + // which returns null for a row the cash enum does not carry - Interest Payable among them - + // and the lookup would die on a NullPointerException instead of the exception meant for it. + final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + return mapping == null ? null : mapping.getGlAccount(); + } + + private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId); + /**** + * Get more specific mapping for FUND source accounts (based on payment channels). Note that fund source + * placeholder ID would be same for both cash and accrual accounts + ***/ + if (accountMappingTypeId == CashAccountsForSavings.SAVINGS_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(savingsProductId, + PortfolioProductType.SAVING.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId, + CashAccountsForSavings.fromInt(accountMappingTypeId).toString()); + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForShareProduct(final Long shareProductId, final int accountMappingTypeId, + final Long paymentTypeId) { + GLAccount glAccount; + if (isOrganizationAccount(accountMappingTypeId)) { + FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); + glAccount = financialActivityAccount.getGlAccount(); + } else { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + + if (accountMappingTypeId == CashAccountsForShares.SHARES_REFERENCE.getValue()) { + final ProductToGLAccountMapping paymentChannelSpecificAccountMapping = this.accountMappingRepository + .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId, paymentTypeId); + if (paymentChannelSpecificAccountMapping != null) { + accountMapping = paymentChannelSpecificAccountMapping; + } + } + // Matches getLinkedGLAccountForSavingsProduct's own guard just above: a share product can + // now genuinely have no SHARES_REFERENCE mapping (it stopped being a mandatory field), so + // this has to fail with a clean, named error instead of NPEing on accountMapping. + if (accountMapping == null) { + throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SHARES, shareProductId, + CashAccountsForShares.fromInt(accountMappingTypeId).toString()); + } + glAccount = accountMapping.getGlAccount(); + } + return glAccount; + } + + private GLAccount getLinkedGLAccountForShareCharges(final Long shareProductId, final int accountMappingTypeId, final Long chargeId) { + ProductToGLAccountMapping accountMapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(shareProductId, + PortfolioProductType.SHARES.getValue(), accountMappingTypeId); + /***** + * Get more specific mappings for Charges and penalties (based on the actual charge /penalty coupled with the + * loan product). Note the income from fees and income from penalties placeholder ID would be the same for both + * cash and accrual based accounts + *****/ + + final ProductToGLAccountMapping chargeSpecificIncomeAccountMapping = this.accountMappingRepository + .findProductIdAndProductTypeAndFinancialAccountTypeAndChargeId(shareProductId, PortfolioProductType.SHARES.getValue(), + accountMappingTypeId, chargeId); + if (chargeSpecificIncomeAccountMapping != null) { + accountMapping = chargeSpecificIncomeAccountMapping; + } + return accountMapping.getGlAccount(); + } + + private boolean isOrganizationAccount(final int accountMappingTypeId) { + return FinancialActivity.fromInt(accountMappingTypeId) != null; + } + + public BigDecimal createCreditJournalEntryOrReversalForClientPayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final Boolean isReversal, + final List clientChargePaymentDTOs) { + /*** + * Map to track each account affected and the net credit to be made for a particular account + ***/ + final Map creditDetailsMap = new LinkedHashMap<>(); + for (final ClientChargePaymentDTO clientChargePaymentDTO : clientChargePaymentDTOs) { + if (clientChargePaymentDTO.getIncomeAccountId() != null) { + final Long accountId = clientChargePaymentDTO.getIncomeAccountId(); + BigDecimal chargeSpecificAmount = clientChargePaymentDTO.getAmount(); + + // adjust net credit amount if the account is already present in + // the map + if (creditDetailsMap.containsKey(accountId)) { + final BigDecimal existingAmount = creditDetailsMap.get(accountId); + chargeSpecificAmount = chargeSpecificAmount.add(existingAmount); + } + creditDetailsMap.put(accountId, chargeSpecificAmount); + } + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + for (final Map.Entry entry : creditDetailsMap.entrySet()) { + final GLAccount account = getGLAccountById(entry.getKey()); + final BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + if (isReversal) { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + return totalCreditedAmount; + } + + public void createDebitJournalEntryOrReversalForClientChargePayments(final Office office, final String currencyCode, + final Long clientId, final Long transactionId, final LocalDate transactionDate, final BigDecimal amount, + final Boolean isReversal) { + final GLAccount account = financialActivityAccountRepository + .findByFinancialActivityTypeWithNotFoundDetection(FinancialActivity.ASSET_FUND_SOURCE.getValue()).getGlAccount(); + if (isReversal) { + createCreditJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForClientPayments(office, currencyCode, account, clientId, transactionId, transactionDate, amount); + } + } + + private GLAccount getGLAccountById(final Long accountId) { + return this.glAccountRepository.getReferenceById(accountId); + } + + public Integer getValueForFeeOrPenaltyIncomeAccount(final String chargeRefundChargeType) { + if (chargeRefundChargeType == null + || !(chargeRefundChargeType.equalsIgnoreCase("P") || chargeRefundChargeType.equalsIgnoreCase("F"))) { + String errorValue; + errorValue = Objects.requireNonNullElse(chargeRefundChargeType, "Null"); + throw new PlatformDataIntegrityException("error.msg.chargeRefundChargeType.can.only.be.P.or.F", + "chargeRefundChargeType can only be P (Penalty) or F(Fee) - Value is: " + errorValue); + } + Integer incomeAccount; + if (chargeRefundChargeType.equalsIgnoreCase("P")) { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_PENALTIES.getValue(); + } else { + incomeAccount = AccrualAccountsForLoan.INCOME_FROM_FEES.getValue(); + + } + return incomeAccount; + } + + public JournalEntry persistJournalEntry(JournalEntry journalEntry) { + boolean isNew = journalEntry.isNew(); + JournalEntry savedJournalEntry = this.glJournalEntryRepository.saveAndFlush(journalEntry); + if (isNew && journalEntry.getLoanTransactionId() != null) { + businessEventNotifierService.notifyPostBusinessEvent(new LoanJournalEntryCreatedBusinessEvent(savedJournalEntry)); + } + return savedJournalEntry; + } + + private void createJournalEntriesForLoanChargesInternal(final Office office, final String currencyCode, final int accountMappingTypeId, + final Long loanProductId, final Long loanId, final String transactionId, final LocalDate transactionDate, + final BigDecimal totalAmount, final List chargePaymentDTOs, final boolean isCredit) { + final Map creditDetailsMap = new LinkedHashMap<>(); + + for (final ChargePaymentDTO chargePaymentDTO : chargePaymentDTOs) { + final Long chargeId = chargePaymentDTO.getChargeId(); + final GLAccount account = getLinkedGLAccountForLoanCharges(loanProductId, accountMappingTypeId, chargeId); + BigDecimal amount = chargePaymentDTO.getAmount(); + + creditDetailsMap.merge(account, amount, BigDecimal::add); + } + + BigDecimal totalCreditedAmount = BigDecimal.ZERO; + + for (Map.Entry entry : creditDetailsMap.entrySet()) { + GLAccount account = entry.getKey(); + BigDecimal amount = entry.getValue(); + totalCreditedAmount = totalCreditedAmount.add(amount); + + if (isCredit) { + createCreditJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } else { + createDebitJournalEntryForLoan(office, currencyCode, account, loanId, transactionId, transactionDate, amount); + } + } + + if (totalAmount.compareTo(totalCreditedAmount) != 0) { + throw new PlatformDataIntegrityException( + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + "Meltdown in advanced accounting...sum of all charges is not equal to the fee charge for a transaction", + totalCreditedAmount, totalAmount); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java index 748daf5d0..82d124e58 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java @@ -173,12 +173,28 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc } else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) { - // Post journal entry if earned interest amount is greater than - // zero - if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { - this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode, - AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(), - savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, amount, isReversal); + // A premature FD closure that reprices interest downward (see + // FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE + // interest-posting transaction -- a clawback of interest already recognised in earlier + // periods, not "zero interest, nothing to post". Skipping it here (the original + // "amount > 0" guard) left the FD subledger showing the clawback while the GL never + // recorded it, silently going out of sync. Treating a negative amount as a mirror-image + // reversal -- positive magnitude, debit/credit legs swapped, same mechanism + // createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two + // in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the + // cash-based path. + final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO); + if (amountSign != 0) { + final boolean isClawback = amountSign < 0; + final BigDecimal postingAmount = isClawback ? amount.abs() : amount; + final boolean effectiveIsReversal = isReversal ^ isClawback; + // Routed through the same helper the cash processor uses, so a posting states + // the whole chain the product is configured for -- out of the payable, through + // the interest-paid account, into the member's balance -- rather than the single + // payable-to-control pair that used to be here, which skipped interest-paid + // entirely and left the expense off the P&L. + this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId, + savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index d6a6034e4..1ca1ad249 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -167,15 +167,26 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess } else if (savingsTransactionDTO.getTransactionType().isInterestPosting()) { - // Post journal entry if earned interest amount is greater than - // zero - if (savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO) > 0) { + // A premature FD closure that reprices interest downward (see + // FixedDepositAccount#postPreMaturityInterest) posts the difference as a NEGATIVE + // interest-posting transaction -- a clawback of interest already recognised in earlier + // periods, not "zero interest, nothing to post". Skipping it here (the original + // "amount > 0" guard) left the FD subledger showing the clawback while the GL never + // recorded it, silently going out of sync. Treating a negative amount as a mirror-image + // reversal -- positive magnitude, debit/credit legs swapped, same mechanism + // createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two + // in sync instead. + final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO); + if (amountSign != 0) { + final boolean isClawback = amountSign < 0; + final BigDecimal postingAmount = isClawback ? amount.abs() : amount; + final boolean effectiveIsReversal = isReversal ^ isClawback; // Routed through the payable and paid accounts the product names, so the society // can read interest declared but not yet credited as its own balance. Collapses // to the plain Dr Interest On Savings / Cr Savings Control pair for a product // that names neither. this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId, - savingsId, transactionId, transactionDate, amount, isReversal); + savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 10fe6b8b8..7169ba0ab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -105,7 +105,23 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" journalEntry.currency_code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" eao.external_id as externalAssetOwner, ") + // entity_type_enum=6 (PortfolioProductType.CURRENT) unambiguously names a row in + // m_current_account -- newer write paths tag current-account entries with it + // instead of reusing SAVING's type, specifically so this case needs no guessing. + // + // entity_type_enum=2 (PortfolioProductType.SAVING) is still written for BOTH savings + // and current-account entries by older write paths not yet updated to the above -- + // current products used to deliberately share SAVING's GL type space (see + // PortfolioProductType.CURRENT's own comment) -- and m_savings_account/ + // m_current_account each have their own id sequence, so an entity_id can be a real + // row in both, making a plain COALESCE(ca.account_no, sa.account_no) pick the current + // account's number even for an entry that is genuinely a savings one. + // savings_transaction_id is the one unambiguous signal available for those older + // entries -- only a real savings transaction sets it -- so it decides first; only + // when it's absent does the current-account guess apply. .append(" CASE ") + .append(" WHEN journalEntry.entity_type_enum = 6 THEN ca.account_no ") + .append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.savings_transaction_id IS NOT NULL THEN sa.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ") .append(" ELSE NULL ") .append(" END as savingsAccountNo "); @@ -130,7 +146,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat .append(" left join m_external_asset_owner_journal_entry_mapping eajem on eajem.journal_entry_id = journalEntry.id ") .append(" left join m_external_asset_owner eao on eao.id = eajem.owner_id ") .append(" left join m_savings_account sa on sa.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ") - .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 "); + .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum IN (2, 6) "); if (associationParametersData.isTransactionDetailsRequired()) { sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ") diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index 35af580f8..ef33e9074 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -329,6 +329,18 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc break; case CASH_BASED: saveSavingsBaseAccountMapping(savingProductId, accountType, command, element); + // Interest Payable is saved on cash too, and optionally: interest posting routes + // through it on either basis (see AccountingProcessorHelper + // .createInterestPostingJournalEntriesForSavings), so that interest declared but not + // yet credited to the member reads as the bank's own liability rather than appearing + // only at the moment it is paid. Mapped only when the screen supplies it - a product + // naming none still posts straight to Savings Control, as it always did. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), + element)) { + this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element, + SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), savingProductId, + AccrualAccountsForSavings.INTEREST_PAYABLE.getValue()); + } break; case ACCRUAL_PERIODIC: diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java index f29866125..448ed615d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/LoanCOBCatchUpApiResource.java @@ -35,6 +35,7 @@ import org.apache.fineract.cob.data.OldestCOBProcessedLoanDTO; import org.apache.fineract.cob.service.LoanCOBCatchUpService; import org.apache.fineract.infrastructure.core.exception.JobIsNotFoundOrNotEnabledException; import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.springframework.stereotype.Component; @Path("/v1/loans") @@ -44,6 +45,7 @@ import org.springframework.stereotype.Component; public class LoanCOBCatchUpApiResource { private final Optional loanCOBCatchUpServiceOp; + private final PlatformSecurityContext context; @GET @Path("oldest-cob-closed") @@ -64,6 +66,7 @@ public class LoanCOBCatchUpApiResource { @ApiResponse(responseCode = "202", description = "Catch Up has been started") @ApiResponse(responseCode = "400", description = "Catch Up is already running") public Response executeLoanCOBCatchUp() { + this.context.authenticatedUser().validateHasPermissionTo("EXECUTE_LOANCOBCATCHUP"); return loanCOBCatchUpServiceOp.map(loanCOBCatchUpService -> { if (loanCOBCatchUpService.isCatchUpRunning().isCatchUpRunning()) { return Response.status(Response.Status.BAD_REQUEST).build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index b918422a1..73e2f73f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -165,9 +165,11 @@ public class SecurityConfig { // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll() // Client account summary, third-party callers — a partner integration calls this - // with an X-Api-Key header instead of a Fineract username/password (issued via - // POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the - // outer gate stays open for this one narrow GET path, and + // with an X-Api-Key header instead of a Fineract username/password. The key itself + // is issued by inserting directly into m_api_client in the tenant-store database + // (see ApiClientAdminApiResource — its POST /v1/apiclients is deliberately disabled, + // since any bank's own super user reaching it could mint a platform-wide key). Same + // posture as IMPS/NPCI above: the outer gate stays open for this one narrow GET path, and // ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates // the caller (ApiClientCredentialService checks the key's hash, active flag and // IP allowlist) — a request with no key and no Fineract login still gets nothing, @@ -222,7 +224,10 @@ public class SecurityConfig { // currency .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currencies")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_CURRENCY") - .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currencies")) + // the actual currency-update endpoint is PUT (CurrenciesApiResource has no @POST method) - + // this matcher previously targeted POST, which meant PUT fell through to the generic + // fullyAuthenticated() catch-all with no permission check at all. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currencies")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_CURRENCY") // notes: read .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/*/notes")) @@ -355,11 +360,18 @@ public class SecurityConfig { .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/import/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_IMPORT") // payment type + // NOTE: POST previously required "UPDATE_PAYMENTTYPE" (wrong authority for create), with a + // second, unreachable matcher for the same POST pattern requiring "DELETE_PAYMENTTYPE" - and + // no PUT/DELETE matcher existed at all, so those requests fell through to the generic + // fullyAuthenticated() catch-all below with no permission check. Corrected to one matcher + // per method, each requiring its own correct permission code. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_PAYMENTTYPE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_PAYMENTTYPE") + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_PAYMENTTYPE") - .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/paymenttypes")) + .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/paymenttypes/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_PAYMENTTYPE") // mix: taxonomy .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/mixtaxonomy/*")) @@ -390,12 +402,41 @@ public class SecurityConfig { .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/mixreport/*")) .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_MIX_REPORT") // working days + // NOTE: previously "READ_WORKING_DAYS"/"UPDATE_WORKING_DAYS" (with an underscore between + // WORKING and DAYS), which did not match the actual seeded permission codes + // (READ_WORKINGDAYS / UPDATE_WORKINGDAYS) - corrected to the real codes. .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS") .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/workingdays/template")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_WORKINGDAYS") .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/workingdays")) - .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKING_DAYS") + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_WORKINGDAYS") + // sponsor banks + // NOTE: no matcher previously existed at all for this path - POST/PUT both fell + // through to the generic fullyAuthenticated() catch-all below with no permission + // check whatsoever. + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsorbanks")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SPONSORBANK") + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/sponsorbanks/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORBANK") + // sponsor DD GST/GL settings + // NOTE: no matcher previously existed at all for this path either. gl-mapping is a + // genuine upsert (one call both creates and updates the single settings row), so it is + // covered by one UPDATE_SPONSORDDGLMAPPING matcher rather than a separate CREATE one. + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/sponsordd/gl-mapping")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SPONSORDDGLMAPPING") + // bank profile (institution profile) + // NOTE: no matcher previously existed at all for this path - the PUT relied solely on + // the in-resource validateHasPermissionTo() check, which this change replaces with the + // command-source pipeline. Added here for the same defense-in-depth already applied to + // Currency/Payment Type/Working Days. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/institutionprofile")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_INSTITUTIONPROFILE") + // branch profile + // NOTE: no matcher previously existed at all for this path either, same rationale as + // institution profile above. + .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/branchprofile/*")) + .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_BRANCHPROFILE") .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated() .requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated() diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java index 0c469621a..a6bca7aa2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java @@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.security.apiclient; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; @@ -35,17 +34,26 @@ import jakarta.ws.rs.core.Response; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; -import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; /** * Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates. - * This resource itself requires a normal Fineract login, same as everything else -- ordinary - * username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted - * key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that - * key in the first place is not something to hand to just anyone. + * List/deactivate/reactivate still require a normal Fineract login restricted to a super user + * (ALL_FUNCTIONS) -- but minting a NEW key ({@link #create}) is disabled here entirely, on purpose. + * + * {@code ALL_FUNCTIONS} is a per-TENANT role grant -- and {@code TenantMenuAccessApiResource}'s whole + * point is that a bank's own super user (or a role simply misconfigured to carry ALL_FUNCTIONS, as the + * "Clerk" role was found to in two tenants this session) must never be able to lift its own menu-access + * ceiling. If this endpoint minted keys for any ALL_FUNCTIONS holder, that same bank super user could + * call {@code POST /v1/apiclients} and hand themselves a brand-new platform-operator credential -- one + * that authenticates against every tenant, not just their own -- completely bypassing the ceiling this + * key mechanism exists to enforce. A super user being able to read/deactivate the client list is fine + * (visibility and revocation are not privilege escalation); being able to mint a fresh one is exactly + * the escalation path being closed. New keys are issued the same way this fix issued the first one: a + * direct insert into {@code m_api_client} (see {@link ApiClientCredentialService#createClient}), by + * whoever already has direct database access -- never over HTTP. */ @Path("/v1/apiclients") @Component @@ -55,20 +63,18 @@ public class ApiClientAdminApiResource { private final PlatformSecurityContext context; private final ApiClientCredentialService credentialService; - private final FromJsonHelper fromJsonHelper; private final Gson gson = new GsonBuilder().serializeNulls().create(); + /** Disabled -- see the class comment. Minting a key is a direct-database operation only. */ @POST @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public String create(final String body) { - final AppUser user = requireSuperUser(); - final JsonElement root = fromJsonHelper.parse(body); - final String clientName = fromJsonHelper.extractStringNamed("clientName", root); - final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root); - - final Map result = credentialService.createClient(clientName, allowedIps, user.getId()); - return gson.toJson(result); + requireSuperUser(); + throw new WebApplicationException(Response.status(403) + .entity("{\"message\":\"Minting a new platform API key over HTTP is disabled. " + + "Insert directly into m_api_client in the tenant-store database instead.\"}") + .type(MediaType.APPLICATION_JSON).build()); } @GET diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java index 2f05b3d4a..0af6f6811 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerManagementReadPlatformServiceImpl.java @@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement @Override public Collection getTellers(Long officeId) { - return retrieveAllTellers(false); + if (officeId == null) { + return retrieveAllTellers(false); + } + // The tellers of that one branch. The officeId used to be ignored, so the head office, whose + // hierarchy covers every branch, counted each branch's vault and cashiers as its own at day close. + return retrieveAllTellers(false).stream().filter(t -> officeId.equals(t.getOfficeId())).toList(); } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java index f4aaf9f4a..e8fe22949 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/teller/service/TellerWritePlatformServiceJpaImpl.java @@ -70,10 +70,6 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer try { this.context.authenticatedUser(); - if (this.tellerRepositoryWrapper.existsAny()) { - throw new TellerAlreadyExistsException(); - } - final Long officeId = command.longValueOfParameterNamed("officeId"); this.fromApiJsonDeserializer.validateForCreateAndUpdateTeller(command.json()); @@ -81,6 +77,12 @@ public class TellerWritePlatformServiceJpaImpl implements TellerWritePlatformSer // final Office parent = // validateUserPriviledgeOnOfficeAndRetrieve(currentUser, officeId); final Office tellerOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + + // One teller (vault) per branch, not one for the whole bank: each branch holds and settles + // its own cash, so a second branch needs a vault of its own. + if (this.tellerRepositoryWrapper.existsInOffice(officeId)) { + throw new TellerAlreadyExistsException(tellerOffice.getName()); + } final Teller teller = Teller.fromJson(tellerOffice, command); // pre save to generate id for use in office hierarchy diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java index d58dd0bfa..7134ee9ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/api/WorkingDaysApiResource.java @@ -27,15 +27,15 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.time.Instant; -import java.util.function.Supplier; import lombok.RequiredArgsConstructor; -import org.apache.fineract.command.core.CommandPipeline; -import org.apache.fineract.organisation.workingdays.command.WorkingDaysUpdateCommand; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.organisation.workingdays.data.WorkingDaysData; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequestValidator; -import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse; import org.apache.fineract.organisation.workingdays.service.WorkingDaysReadPlatformService; import org.springframework.stereotype.Component; @@ -50,7 +50,8 @@ public class WorkingDaysApiResource { private final WorkingDaysReadPlatformService workingDaysReadPlatformService; private final WorkingDaysUpdateRequestValidator workingDaysUpdateRequestValidator; - private final CommandPipeline commandPipeline; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -65,17 +66,13 @@ public class WorkingDaysApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update a Working Day", description = "Mandatory Fields\n" + "recurrence,repaymentRescheduleType,extendTermForDailyRepayments,locale") - public WorkingDaysUpdateResponse update(@Valid WorkingDaysUpdateRequest request) { + public String update(@Valid final WorkingDaysUpdateRequest request) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateWorkingDays() + .withJson(toApiJsonSerializer.serialize(request)).build(); - final var command = new WorkingDaysUpdateCommand(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); - command.setCommandId(System.currentTimeMillis()); - command.setCreatedAt(Instant.now()); - command.setPayload(request); - - final Supplier response = commandPipeline.send(command); - - return response.get(); + return toApiJsonSerializer.serialize(result); } @GET diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java index 2528d818f..821516099 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/workingdays/handler/UpdateWorkingDaysCommandHandler.java @@ -18,38 +18,46 @@ */ package org.apache.fineract.organisation.workingdays.handler; -import java.util.Collections; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.command.core.Command; -import org.apache.fineract.command.core.CommandHandler; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateRequest; -import org.apache.fineract.organisation.workingdays.data.WorkingDaysUpdateResponse; import org.apache.fineract.organisation.workingdays.service.WorkingDaysWritePlatformService; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class UpdateWorkingDaysCommandHandler implements CommandHandler { +@Service +@CommandType(entity = "WORKINGDAYS", action = "UPDATE") +public class UpdateWorkingDaysCommandHandler implements NewCommandSourceHandler { private final WorkingDaysWritePlatformService workingDaysWritePlatformService; + @Autowired + public UpdateWorkingDaysCommandHandler(final WorkingDaysWritePlatformService workingDaysWritePlatformService) { + this.workingDaysWritePlatformService = workingDaysWritePlatformService; + } + @Transactional @Override - public WorkingDaysUpdateResponse handle(Command command) { - WorkingDaysUpdateRequest request = command.getPayload(); - Map changes = this.workingDaysWritePlatformService.updateWorkingDays(request); - if (changes == null) { - changes = Collections.emptyMap(); - } + public CommandProcessingResult processCommand(final JsonCommand command) { - return WorkingDaysUpdateResponse.builder().resourceId((Long) changes.get("resourceId")).changes(changes) - .recurrence(request.getRecurrence()).repaymentRescheduleType(request.getRepaymentRescheduleType()) - .extendTermForDailyRepayments(request.getExtendTermForDailyRepayments()) - .extendTermForRepaymentsOnHolidays(request.getExtendTermForRepaymentsOnHolidays()).build(); + final WorkingDaysUpdateRequest request = WorkingDaysUpdateRequest.builder() + .recurrence(command.stringValueOfParameterNamed("recurrence")) + .repaymentRescheduleType(command.integerValueSansLocaleOfParameterNamed("repaymentRescheduleType")) + .extendTermForDailyRepayments(command.booleanObjectValueOfParameterNamed("extendTermForDailyRepayments")) + .extendTermForRepaymentsOnHolidays(command.booleanObjectValueOfParameterNamed("extendTermForRepaymentsOnHolidays")) + .build(); + + final Map changes = this.workingDaysWritePlatformService.updateWorkingDays(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .with(changes) // + .build(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java new file mode 100644 index 000000000..f47c5b129 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.service; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +/** + * A book debit: money leaves a savings or current account by book entry, not as cash. + * + *

+ * Posts NO journal entry. The caller posts the one entry that describes the real movement (a DD, a collection...). + * Debiting through a cash withdrawal instead made the withdrawal post its own Dr control / Cr Cash-at-Vault entry -- + * cash that never moved -- which callers then hid by flagging it reversed, leaving an entry in the ledger that no + * report shows. + * + *

+ * It keeps the rules a withdrawal applies: the account must be active, the amount must fit in the available balance + * (balance less funds on hold), and a dormant account or a lapsed re-KYC stops the debit. Joins the caller's + * transaction, so a later failure rolls the debit back with everything else. + */ +@Service +@RequiredArgsConstructor +public class AccountBookDebitService { + + private final JdbcTemplate jdbcTemplate; + private final KycRestrictionGuard kycRestrictionGuard; + + /** Debits a savings account; returns the m_savings_account_transaction id. */ + public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId) { + final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount); + kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction " + + "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, " + + "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, " + + "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)", + new String[] { "id" }); + ps.setLong(1, accountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setDate(5, sqlDate); + ps.setString(6, refNo()); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setLong(8, userId); + ps.setLong(9, userId); + ps.setObject(10, now); + ps.setObject(11, now); + return ps; + }, kh); + jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** Debits a current account; returns the m_current_account_transaction id. */ + public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate, + final Long userId, final String note) { + final BigDecimal balance = checkedBalance("m_current_account", "Current", accountId, amount); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); + + final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate); + final String remarks = note == null || note.isBlank() ? "Book debit" : (note.length() > 490 ? note.substring(0, 490) : note); + final KeyHolder kh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement("INSERT INTO m_current_account_transaction " + + "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, is_reversed, is_reversal, " + + "is_manual, is_lien_transaction, ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) " + + "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)", new String[] { "id" }); + ps.setLong(1, accountId); + ps.setLong(2, officeId); + ps.setDate(3, sqlDate); + ps.setBigDecimal(4, amount); + ps.setString(5, refNo()); + ps.setDate(6, sqlDate); + ps.setBigDecimal(7, balance.subtract(amount)); + ps.setString(8, remarks); + ps.setLong(9, userId); + return ps; + }, kh); + jdbcTemplate.update("UPDATE m_current_account SET account_balance_derived = account_balance_derived - ?, " + + "total_withdrawals_derived = COALESCE(total_withdrawals_derived, 0) + ? WHERE id = ?", amount, amount, accountId); + return Objects.requireNonNull(kh.getKey()).longValue(); + } + + /** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */ + private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList( + "SELECT status_enum, account_balance_derived, on_hold_funds_derived FROM " + table + " WHERE id = ?", accountId); + if (rows.isEmpty()) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", kind + " account " + accountId + " not found.", + accountId); + } + final int status = ((Number) rows.get(0).get("status_enum")).intValue(); + if (status != 300) { + throw new GeneralPlatformDomainRuleException("error.msg.account.not.active", + kind + " account " + accountId + " is not active.", accountId); + } + final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("account_balance_derived"); + final BigDecimal onHold = rows.get(0).get("on_hold_funds_derived") == null ? BigDecimal.ZERO + : (BigDecimal) rows.get(0).get("on_hold_funds_derived"); + final BigDecimal available = balance.subtract(onHold); + if (available.compareTo(amount) < 0) { + throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance", + "Insufficient available balance in " + kind.toLowerCase() + " account " + accountId + ". Available: " + available + + ", Requested: " + amount, + accountId); + } + return balance; + } + + private static String refNo() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 20); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 1ae645c56..9a34bd589 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -57,6 +57,7 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction; import org.apache.fineract.portfolio.account.domain.AccountTransferType; import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction; @@ -78,6 +79,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -100,6 +102,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer private final CurrentAccountRepository currentAccountRepository; private final CurrentAccountTransactionRepository currentAccountTransactionRepository; private final AccountingProcessorHelper accountingProcessorHelper; + private final KycRestrictionGuard kycRestrictionGuard; + private final SavingsInterestTdsService savingsInterestTdsService; @Transactional @Override @@ -410,6 +414,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer toSavingsAccount, withdrawal, deposit); this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails); transferTransactionId = accountTransferDetails.getId(); + // FD/RD interest reaching a savings account is where TDS is tracked and, once due, deducted. + this.savingsInterestTdsService.onTransfer(fromSavingsAccount, toSavingsAccount, deposit, + AccountTransferType.fromInt(accountTransferDTO.getTransferType())); } else if (isLoanToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) { @@ -472,7 +479,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toSavingsAccount.productId(), toSavingsAccount.getId()); + toSavingsAccount.productId(), false, toSavingsAccount.getId()); // skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts // DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number. @@ -517,7 +524,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromSavingsAccount.productId(), fromSavingsAccount.getId()); + fromSavingsAccount.productId(), false, fromSavingsAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer( @@ -545,10 +552,10 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - toCurrentAccount.getProductId(), toCurrentAccount.getId()); + toCurrentAccount.getProductId(), true, toCurrentAccount.getId()); performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(), - fromCurrentAccount.getProductId(), fromCurrentAccount.getId()); + fromCurrentAccount.getProductId(), true, fromCurrentAccount.getId()); if (accountTransferDetails == null) { accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer( @@ -681,6 +688,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount, final LocalDate txnDate) { + // Money leaving a current account by transfer is refused on a dormant one, as a withdrawal is. + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -699,10 +708,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer currentAccountRepository.save(account); if (account.getProductId() != null && txn.getId() != null) { final String currentTxnId = "CA" + txn.getId(); - accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(), - account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(), - FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null, - account.getId(), currentTxnId, txnDate, amount, false); + accountingProcessorHelper.createCurrentControlToFinancialActivityJournalEntries(account.getOffice(), + account.getCurrencyCode(), account.getProductId(), FinancialActivity.LIABILITY_TRANSFER.getValue(), + account.getId(), currentTxnId, txnDate, amount); } return txn; } @@ -712,7 +720,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient. */ private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.TRANSFER); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO; final BigDecimal available = balance.subtract(onHold); @@ -730,7 +740,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -738,7 +749,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer * Deposit amount into a current account and save the transaction. */ private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount, - final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) { + final LocalDate txnDate, final Long counterpartProductId, final boolean counterpartIsCurrent, + final Long counterpartEntityId) { + kycRestrictionGuard.checkCurrentAccount(account.getId(), KycRestrictionGuard.Operation.CREDIT); final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO; final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null); final BigDecimal newBalance = balance.add(amount); @@ -749,7 +762,8 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer (account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount)); currentAccountRepository.save(account); // Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number - postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId); + postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartIsCurrent, + counterpartEntityId); return txn; } @@ -783,7 +797,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer */ private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn, final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal, - final Long counterpartProductId, final Long counterpartEntityId) { + final Long counterpartProductId, final boolean counterpartIsCurrent, final Long counterpartEntityId) { if (account.getProductId() == null || txn.getId() == null) { return; } @@ -794,17 +808,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer final String currentTxnId = "CA" + txn.getId(); if (isWithdrawal) { // DR: current account GL CR: counterpart (savings/current) GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - account.getProductId(), account.getId(), - counterpartProductId, counterpartEntityId, + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + account.getProductId(), true, account.getId(), counterpartProductId, counterpartIsCurrent, counterpartEntityId, currentTxnId, txnDate, amount); } else { // DR: counterpart (savings/current) GL CR: current account GL - accountingProcessorHelper.createDirectTransferJournalEntriesForSavings( - account.getOffice(), account.getCurrencyCode(), - counterpartProductId, counterpartEntityId, - account.getProductId(), account.getId(), + accountingProcessorHelper.createDirectTransferJournalEntries(account.getOffice(), account.getCurrencyCode(), + counterpartProductId, counterpartIsCurrent, counterpartEntityId, account.getProductId(), true, account.getId(), currentTxnId, txnDate, amount); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java index 58be11f63..eee9b3400 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/starter/AccountConfiguration.java @@ -1,140 +1,144 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.account.starter; - -import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; -import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; -import org.apache.fineract.infrastructure.core.config.FineractProperties; -import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; -import org.apache.fineract.infrastructure.core.service.PaginationHelper; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.security.service.SqlValidator; -import org.apache.fineract.infrastructure.security.utils.ColumnValidator; -import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; -import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; -import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; -import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; -import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; -import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; -import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; -import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; -import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; -import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; -import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; -import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; -import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; -import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; -import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; -import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; -import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; -import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; -import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.jdbc.core.JdbcTemplate; - -@Configuration -public class AccountConfiguration { - - @Bean - @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) - public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { - return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) - public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, - SqlValidator sqlValidator) { - return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); - } - - @Bean - @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) - public AccountTransfersWritePlatformService accountTransfersWritePlatformService( - AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, - AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, - SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, - LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, - AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, - GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, - FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, - CurrentAccountTransactionRepository currentAccountTransactionRepository, - AccountingProcessorHelper accountingProcessorHelper) { - return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, - accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, - loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, - gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, - currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper); - } - - @Bean - @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) - public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, - DatabaseSpecificSQLGenerator sqlGenerator) { - return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) - public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, - ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) - public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, - ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, - PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, - DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, - DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { - return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, - portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); - } - - @Bean - @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) - public StandingInstructionWritePlatformService standingInstructionWritePlatformService( - StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, - AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { - return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, - accountTransferDetailRepository, standingInstructionRepository); - } -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.account.starter; + +import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.config.FineractProperties; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.PaginationHelper; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository; +import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; +import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; +import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; +import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository; +import org.apache.fineract.portfolio.account.domain.AccountTransferRepository; +import org.apache.fineract.portfolio.account.domain.StandingInstructionAssembler; +import org.apache.fineract.portfolio.account.domain.StandingInstructionRepository; +import org.apache.fineract.portfolio.account.mapper.AccountTransfersMapper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformService; +import org.apache.fineract.portfolio.account.service.PortfolioAccountReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionHistoryReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformServiceImpl; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionWritePlatformServiceImpl; +import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; +import org.apache.fineract.portfolio.common.service.DropdownReadPlatformService; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; +import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; +import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; +import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; +import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; +import org.apache.fineract.portfolio.tds.service.SavingsInterestTdsService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class AccountConfiguration { + + @Bean + @ConditionalOnMissingBean(AccountAssociationsReadPlatformService.class) + public AccountAssociationsReadPlatformService accountAssociationsReadPlatformService(JdbcTemplate jdbcTemplate) { + return new AccountAssociationsReadPlatformServiceImpl(jdbcTemplate); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersReadPlatformService.class) + public AccountTransfersReadPlatformService accountTransfersReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, AccountTransfersMapper accountTransfersMapper, PaginationHelper paginationHelper, + SqlValidator sqlValidator) { + return new AccountTransfersReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, columnValidator, sqlGenerator, accountTransfersMapper, paginationHelper, sqlValidator); + } + + @Bean + @ConditionalOnMissingBean(AccountTransfersWritePlatformService.class) + public AccountTransfersWritePlatformService accountTransfersWritePlatformService( + AccountTransfersDataValidator accountTransfersDataValidator, AccountTransferAssembler accountTransferAssembler, + AccountTransferRepository accountTransferRepository, SavingsAccountAssembler savingsAccountAssembler, + SavingsAccountDomainService savingsAccountDomainService, LoanAssembler loanAccountAssembler, + LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, + AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, + GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, + FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository, + CurrentAccountTransactionRepository currentAccountTransactionRepository, + AccountingProcessorHelper accountingProcessorHelper, KycRestrictionGuard kycRestrictionGuard, + SavingsInterestTdsService savingsInterestTdsService) { + return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, + accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, + loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, + gsimRepository, configurationDomainService, externalIdFactory, fineractProperties, + currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard, + savingsInterestTdsService); + } + + @Bean + @ConditionalOnMissingBean(PortfolioAccountReadPlatformService.class) + public PortfolioAccountReadPlatformService portfolioAccountReadPlatformService(JdbcTemplate jdbcTemplate, + DatabaseSpecificSQLGenerator sqlGenerator) { + return new PortfolioAccountReadPlatformServiceImpl(jdbcTemplate, sqlGenerator); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionHistoryReadPlatformService.class) + public StandingInstructionHistoryReadPlatformService standingInstructionHistoryReadPlatformService(JdbcTemplate jdbcTemplate, + ColumnValidator columnValidator, DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionHistoryReadPlatformServiceImpl(jdbcTemplate, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionReadPlatformService.class) + public StandingInstructionReadPlatformService standingInstructionReadPlatformService(JdbcTemplate jdbcTemplate, + ClientReadPlatformService clientReadPlatformService, OfficeReadPlatformService officeReadPlatformService, + PortfolioAccountReadPlatformService portfolioAccountReadPlatformService, + DropdownReadPlatformService dropdownReadPlatformService, ColumnValidator columnValidator, + DatabaseSpecificSQLGenerator sqlGenerator, PaginationHelper paginationHelper) { + return new StandingInstructionReadPlatformServiceImpl(jdbcTemplate, clientReadPlatformService, officeReadPlatformService, + portfolioAccountReadPlatformService, dropdownReadPlatformService, columnValidator, sqlGenerator, paginationHelper); + } + + @Bean + @ConditionalOnMissingBean(StandingInstructionWritePlatformService.class) + public StandingInstructionWritePlatformService standingInstructionWritePlatformService( + StandingInstructionDataValidator standingInstructionDataValidator, StandingInstructionAssembler standingInstructionAssembler, + AccountTransferDetailRepository accountTransferDetailRepository, StandingInstructionRepository standingInstructionRepository) { + return new StandingInstructionWritePlatformServiceImpl(standingInstructionDataValidator, standingInstructionAssembler, + accountTransferDetailRepository, standingInstructionRepository); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java index db4ec204d..0e1e65ff3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/JournalEntryReferralServiceImpl.java @@ -68,6 +68,15 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ * The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL * posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable. */ + // m_savings_account and m_current_account each keep their own identity sequence, so the same + // entity_id can be a real row in both -- entity_type_enum=2 (PortfolioProductType.SAVING) used to + // be written for both savings and current-account entries alike, so esa/eca could both match the + // same id and a plain COALESCE(esa.client_id, eca.client_id) would attribute an alert to whichever + // client happened to hold the same-numbered account on the OTHER ledger -- exactly the kind of + // misattribution an AML referral cannot afford. entity_type_enum=6 (PortfolioProductType.CURRENT) + // is what newer write paths tag current-account entries with instead, unambiguously; for older + // entries still tagged 2, savings_transaction_id is the one reliable signal (only a genuine + // savings transaction sets it), checked before the ambiguous current/savings guess. private static final String RESOLVE_SQL = """ SELECT je.amount AS amount, je.currency_code AS currencyCode, @@ -75,8 +84,9 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ je.loan_transaction_id AS loanTxnId, je.entity_type_enum AS entityType, COALESCE( - esa.client_id, - eca.client_id, + CASE WHEN je.entity_type_enum = 2 AND je.savings_transaction_id IS NOT NULL THEN sa.client_id END, + CASE WHEN je.entity_type_enum = 6 THEN eca.client_id END, + CASE WHEN je.entity_type_enum = 2 THEN COALESCE(eca.client_id, esa.client_id) END, eloan.client_id, CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END, sa.client_id, @@ -85,7 +95,7 @@ public class JournalEntryReferralServiceImpl implements JournalEntryReferralServ ) AS clientId FROM acc_gl_journal_entry je LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2 - LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2 + LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum IN (2, 6) LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1 LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index cbfc2ee8e..82224e25c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -69,10 +71,26 @@ import org.springframework.transaction.annotation.Transactional; @Produces({ MediaType.APPLICATION_JSON }) public class BranchDayRegisterApiResource { + /** + * The branch whose counter took a breakdown {@code std}: the teller office of its officer's cashier + * allocation if that allocation already existed when the cash was counted, otherwise the officer's + * own office. A person moved to another branch's teller keeps what they took at the old branch + * there. + */ + private static final String COUNTER_OFFICE_OF_STD = """ + COALESCE((SELECT t.office_id FROM m_appuser au JOIN m_cashiers mc ON mc.staff_id = au.staff_id + JOIN m_tellers t ON t.id = mc.teller_id + WHERE au.id = std.officer_id""" + CountedCashLedger.SINCE_ALLOCATION + """ + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = std.officer_id)) + """; + private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; private final VaultPositionService vaultPositionService; + private final SavingsInterestPostingReportService interestPostingReportService; + private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; private final Gson gson = new Gson(); // ── GET /v1/branchday ── the register itself ────────────────────────────── @@ -104,11 +122,26 @@ public class BranchDayRegisterApiResource { result.put("officeId", officeId); result.put("canOperate", canOperateDay(currentUser)); result.put("days", mapDays(rows)); - result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString()); + result.put("today", DateUtils.getBusinessLocalDate().toString()); result.put("openDay", findOpenDay(officeId)); return gson.toJson(result); } + // ── GET /v1/branchday/today ── the bank's "today" ───────────────────────── + + /** + * The business date while enable-business-date is on, the tenant's own date while it is off. + * Every screen that defaults a date to "today" reads this, rather than the browser clock or the + * business-date API (which returns the stored date even when the feature is off). Open to any + * signed-in user — cashiers and clerks cannot read the configuration that says which applies. + */ + @GET + @Path("today") + public String today() { + this.context.authenticatedUser(); + return gson.toJson(Map.of("today", DateUtils.getBusinessLocalDate().toString())); + } + // ── GET /v1/branchday/missed ── days left open ──────────────────────────── /** @@ -121,7 +154,7 @@ public class BranchDayRegisterApiResource { public String missed(@QueryParam("officeId") final Long officeIdParam) { final AppUser currentUser = this.context.authenticatedUser(); final Long officeId = resolveOffice(officeIdParam, currentUser); - final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate today = DateUtils.getBusinessLocalDate(); final List> rows = jdbcTemplate.queryForList(""" SELECT r.id, r.office_id, o.name AS office_name, r.business_date, @@ -191,8 +224,8 @@ public class BranchDayRegisterApiResource { // Scoped to this office through the officer who took the cash (appuser -> staff -> cashier -> // teller -> office). Unscoped, a branch's own "does the physical count agree with the books" // cross-check was being compared against every branch's counted cash at once, which makes it - // wrong at every office but the first. A row whose officer maps to no cashier is still - // counted, for the same reason as the withdrawal legs in bookBreakdown. + // wrong at every office but the first. A row whose officer has no cashier allocation counts + // at the officer's own office (see COUNTER_OFFICE_OF_STD), not at every office. // // Which rows are real counted cash is CountedCashLedger's rule, not this screen's -- see the // note there on why a manual GL entry's decoded reference is kept and a stamped millisecond @@ -200,24 +233,16 @@ public class BranchDayRegisterApiResource { final BigDecimal countedIn = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT' - AND """ + CountedCashLedger.REAL_TXN_ID + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND """ + CountedCashLedger.COUNTED_ROW + """ + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); final BigDecimal countedOut = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std WHERE std.txn_date = ? AND std.txn_type = 'WITHDRAWAL' - AND """ + CountedCashLedger.REAL_TXN_ID + """ - AND (std.officer_id IS NULL - OR NOT EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - WHERE au.id = std.officer_id) - OR EXISTS (SELECT 1 FROM m_appuser au JOIN m_cashiers c ON c.staff_id = au.staff_id - JOIN m_tellers t ON t.id = c.teller_id - WHERE au.id = std.officer_id AND t.office_id = ?)) + AND """ + CountedCashLedger.COUNTED_ROW + """ + AND (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """ + ) = ?) """, BigDecimal.class, Date.valueOf(businessDate), officeId)); result.put("denominationIn", countedIn); result.put("denominationOut", countedOut); @@ -243,7 +268,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -437,7 +462,7 @@ public class BranchDayRegisterApiResource { final var root = this.fromJsonHelper.parse(body); final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser); final LocalDate businessDate = extractDate(root, "businessDate", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + DateUtils.getBusinessLocalDate()); final List> denominations = extractDenominations(root, "denominations"); final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root); @@ -589,6 +614,32 @@ public class BranchDayRegisterApiResource { "Business day " + businessDate + " is already closed."); } + // ── This branch's savings interest reports must be approved before it closes ── + // Not overridable by a force-close reason: the branch posts its own customers' interest when + // it closes, and a report nobody approved has nothing to post, so they would go unpaid. + final List interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate); + if (!interestPending.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending", + "Cannot close the day — this branch's savings interest posting report is not approved for: " + + String.join(", ", interestPending) + + ". Approve it under Reports → Interest Posting Reports, then close the day."); + } + + // ── The head office closes last ── + // Every branch that opened the day closes first and posts its own interest; a branch that + // never opened is not waited for. + final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId); + if (isHeadOffice) { + // Any day still open counts, not only today's: a branch whose earlier day was never closed is + // still working and has not posted its interest. + final List stillOpen = interestPostingReportService.branchesStillOpen(businessDate, officeId); + if (!stillOpen.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open", + "Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen) + + ". The head office closes last."); + } + } + // ── Ensure every cashier holding cash has been reconciled AND settled ── // Find all cashiers under tellers belonging to this office, check if they have a cash position, // and if so, verify reconciliation and settlement records exist for the business date. @@ -687,6 +738,28 @@ public class BranchDayRegisterApiResource { final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash"); final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null; + // ── The branch posts its own approved savings interest as it closes ── + // The head office also posts what it approved for branches that did not open today. Entries + // are raised in the customers' own branch either way. + // + // Posted here, before the close is written, and synchronously, so the outcome is never + // silent: each report posts in a transaction of its own and commits, and if any fails the + // close is refused with the reason — the day stays open, the report stays APPROVED, and + // closing again once the cause is fixed posts it. + final List toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate)); + if (isHeadOffice) { + toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId)); + } + if (!toPost.isEmpty()) { + log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size()); + final List interestFailures = savingsDailyInterestOrchestrator.postReports(toPost); + if (!interestFailures.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed", + "Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures) + + ". The report is still approved; fix the cause and close the day again."); + } + } + // "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and // this write are two separate statements, so two managers pressing Close at once (or one // double-click) both got past the check. The second UPDATE then overwrote the first's @@ -743,7 +816,7 @@ public class BranchDayRegisterApiResource { final Long officeId = resolveOffice(officeIdParam, currentUser); final LocalDate businessDate = (businessDateParam != null && !businessDateParam.isBlank()) ? LocalDate.parse(businessDateParam) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final List registerIds = jdbcTemplate.queryForList( "SELECT id FROM m_branch_day_register WHERE office_id = ? AND business_date = ?", @@ -1004,6 +1077,9 @@ public class BranchDayRegisterApiResource { final Map r = rows.get(0); final Map m = new LinkedHashMap<>(); m.put("id", r.get("id")); + // The close screen fetches this office's tellers with it; left out, it fetched every teller + // the user can see, and the head office counted each branch's vault as its own. + m.put("officeId", r.get("office_id")); m.put("businessDate", str(r.get("business_date"))); m.put("openedByName", r.get("opened_by_name")); m.put("openedAt", str(r.get("opened_at"))); @@ -1057,11 +1133,13 @@ public class BranchDayRegisterApiResource { (rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null); final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate); + // By the counter that took the cash. A savings deposit carries no office_id, and the old + // "OR office_id IS NULL" counted every such deposit in the bank at every branch. final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request WHERE status = 'APPROVED' AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ? - AND (office_id = ? OR office_id IS NULL) + AND COALESCE(counter_office_id, office_id) = ? """, BigDecimal.class, date, officeId)); // Cash paid over the counter to buy a demand draft is real cash arriving at the branch too -- diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 25b268f90..9ffd57359 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -166,8 +166,8 @@ public class CashierDepositRequestApiResource { client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id, counter_office_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -179,7 +179,7 @@ public class CashierDepositRequestApiResource { "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), denominationData, activityLog, Timestamp.from(Instant.now()), - ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId, counterOfficeId(currentUser) ); // Globally unique voucher number from the central m_voucher register @@ -192,7 +192,7 @@ public class CashierDepositRequestApiResource { // reject() will delete these records so the vault position is reversed. if (denominationData != null && !denominationData.isBlank()) { savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId, - currentUser.getId(), currentUser.getUsername(), paymentTypeName); + currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate); } return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo)); @@ -364,6 +364,17 @@ public class CashierDepositRequestApiResource { updatedLog, Timestamp.from(Instant.now()), id ); + // Rejection deleted this request's notes (the cash went back to the customer). Resubmitting + // means the cashier took the cash again, so the notes are recorded again -- without this an + // approved resubmission credited the account while the drawer never received its notes. + // Cleared first so a resubmit can never leave the old breakdown alongside the new one. + jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id + PENDING_DEPOSIT_TXN_ID_OFFSET); + if (denominationData != null && !denominationData.isBlank()) { + savePendingDenomination(denominationData, id, toLong(record.get("savings_account_id")), + (String) record.get("account_no"), toLong(record.get("client_id")), + currentUser.getId(), currentUser.getUsername(), paymentTypeName, txnDate); + } + // Keep the central voucher register in sync with the edited amount voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount); @@ -423,7 +434,13 @@ public class CashierDepositRequestApiResource { // even for current accounts (submitted before the frontend fix), and FD/RD accounts share // the very same m_savings_account table as a plain savings account, distinguished only by // deposit_type_enum -- so this is looked up fresh rather than trusted from the request. - final String depositCategory = resolveDepositAccountCategory(savingsAccountId); + // The account_no is also checked -- m_current_account and m_savings_account each keep + // their own identity sequence, so the same numeric id can legitimately belong to a + // current account AND an unrelated savings account at once. An id match alone once sent + // a real deposit into a stranger's current account instead of the savings account the + // request actually named; account_no is the one value that can't collide between them. + final String requestAccountNo = (String) record.get("account_no"); + final String depositCategory = resolveDepositAccountCategory(savingsAccountId, requestAccountNo); final boolean isCurrentAccount = "CURRENT".equals(depositCategory); txnId = switch (depositCategory) { case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description); @@ -578,7 +595,8 @@ public class CashierDepositRequestApiResource { final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null; final String description = (String) record.get("description"); - if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) { + final String requestAccountNo = (String) record.get("account_no"); + if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId, requestAccountNo)) { throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits."); } @@ -673,6 +691,20 @@ public class CashierDepositRequestApiResource { .orElse(Map.of()); } + /** + * The branch whose counter took the cash: the office of the teller the user is allocated to as a + * cashier, otherwise the user's own office. office_id cannot say this -- a savings deposit carries + * none, and a GL deposit's is the GL's branch -- and the day close totals each branch's cash by it. + */ + private Long counterOfficeId(final AppUser user) { + final List tellerOffice = user.getStaff() == null ? List.of() + : jdbcTemplate.queryForList(""" + SELECT t.office_id FROM m_cashiers c JOIN m_tellers t ON t.id = c.teller_id + WHERE c.staff_id = ? LIMIT 1 + """, Long.class, user.getStaff().getId()); + return tellerOffice.isEmpty() ? user.getOffice().getId() : tellerOffice.get(0); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, voucher_no, account_no, account_type, savings_account_id, @@ -695,7 +727,7 @@ public class CashierDepositRequestApiResource { final Long savingsAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -729,7 +761,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -758,7 +790,7 @@ public class CashierDepositRequestApiResource { final Long accountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -812,7 +844,7 @@ public class CashierDepositRequestApiResource { return; } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -835,7 +867,7 @@ public class CashierDepositRequestApiResource { final Long currentAccountId, final BigDecimal amount, final Object paymentTypeId, final String txnDateStr, final String note) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; final JsonObject payload = new JsonObject(); @@ -863,11 +895,30 @@ public class CashierDepositRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - /** Returns true if this account ID belongs to m_current_account (not m_savings_account). */ - private boolean isCurrentAccountById(final Long accountId) { + /** + * Returns true if this account ID belongs to m_current_account (not m_savings_account). + * + * m_current_account and m_savings_account each keep their own identity sequence, so id=1 (say) + * can be a real row in BOTH tables at once — two completely unrelated accounts, possibly two + * different clients, sharing nothing but a coincidental number. An id-only check here once sent + * a savings deposit into a stranger's current account: id=1 existed in m_current_account, so + * this returned true, even though the request was for m_savings_account id=1. + * + * expectedAccountNo — the account_no the caller actually meant — closes that gap: an id match is + * only trusted once the account_no on that current-account row confirms it is truly the same + * account, not a same-numbered stranger. Pass null to skip the check (only safe where the caller + * has no account_no to compare against, e.g. an id already known to be unambiguous). + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; @@ -880,12 +931,15 @@ public class CashierDepositRequestApiResource { * m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a * plain "not current → savings" check would post an FD/RD deposit through the generic savings * deposit command, which does not accept a term-deposit account id. + * + * expectedAccountNo guards the current-account check against an id collision with + * m_savings_account — see {@link #isCurrentAccountById}. */ - private String resolveDepositAccountCategory(final Long accountId) { + private String resolveDepositAccountCategory(final Long accountId, final String expectedAccountNo) { if (accountId == null) { return "SAVINGS"; } - if (isCurrentAccountById(accountId)) { + if (isCurrentAccountById(accountId, expectedAccountNo)) { return "CURRENT"; } final List depositTypes = jdbcTemplate.queryForList( @@ -922,7 +976,7 @@ public class CashierDepositRequestApiResource { final Object paymentTypeId, final String txnDateStr, final String note, final boolean isDeposit) { - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; // Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra) @@ -975,9 +1029,14 @@ public class CashierDepositRequestApiResource { */ // Called at submit time — uses request id as temporary txn_id. // approve() updates txn_id to real Fineract id; reject() deletes these records. + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + private void savePendingDenomination(final String denominationData, final Long requestId, final Long savingsAccountId, final String accountNo, - final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName) { + final Long clientId, final Long cashierId, final String cashierUsername, final String paymentTypeName, + final String txnDate) { final JsonObject denomMap; try { denomMap = gson.fromJson(denominationData, JsonObject.class); @@ -1008,10 +1067,15 @@ public class CashierDepositRequestApiResource { INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'DEPOSIT', ?, ?, ?) """, requestId + PENDING_DEPOSIT_TXN_ID_OFFSET, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), + // Dated on the request's own date, which is what the branch day's book figure reads. + // CURRENT_DATE is the database clock, so a deposit keyed on one date could land in the + // drawer on another and leave both days' cash wrong. No date falls back to the + // business date for the same reason. + Date.valueOf(txnDate != null && !txnDate.isBlank() ? LocalDate.parse(txnDate) : DateUtils.getBusinessLocalDate()), cashierId, cashierUsername, paymentTypeName ); } @@ -1116,7 +1180,7 @@ public class CashierDepositRequestApiResource { final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE); return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } catch (Exception e) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + return DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 8f3cb2437..954adbd8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -90,10 +90,10 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'DEPOSIT' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND " + CountedCashLedger.REAL_TXN_ID + + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Out: settlements to vault @@ -120,10 +120,10 @@ public class CashReconciliationApiResource { + "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id " + "WHERE std.txn_type = 'WITHDRAWAL' " + "AND std.txn_date <= CAST(? AS DATE) " - + "AND " + CountedCashLedger.REAL_TXN_ID + + "AND " + CountedCashLedger.COUNTED_ROW + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // In: cash exchange — customer gave denomination TO cashier (RECEIVED direction) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index edde51cf2..20d360fa0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -60,34 +60,11 @@ public class CashReportApiResource { * * *

- * Which ids are real and which are stamped timestamps is {@link CountedCashLedger}'s rule, shared - * with the day register, the reconciliation screen and the withdrawal stock check, so a drawer - * reads the same on all four. + * The rule itself, including the mis-keyed-breakdown test, is {@link CountedCashLedger#COUNTED_ROW}, + * shared with the day register, the reconciliation screen and the withdrawal stock check, so a + * drawer reads the same on all four. */ - private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS (" - + " SELECT 1 FROM m_savings_account_transaction rt " - + " WHERE rt.id = std.txn_id AND rt.is_reversed = true) " - + "AND " + CountedCashLedger.REAL_TXN_ID - // A breakdown has to add up to the transaction it belongs to. Where it does not, the - // notes were mis-keyed and the transaction amount is the fact -- ₹1,60,000 of notes - // recorded against a ₹918 deposit is not ₹1,60,000 of cash in anybody's drawer. Seven - // such transactions in this database were carrying ₹1.68 lakh that never existed. - // - // The tolerance is sub-rupee, not exact: Indian cash has no paisa coins in circulation, - // so a paisa-exact ledger amount (e.g. 12998.56) is legitimately handed over as the - // nearest whole rupee (12999) -- a 44-paisa gap is real rounding, not a mis-keyed - // breakdown, and treating it as one silently dropped an entire genuine withdrawal (and - // everything counted after it) out of the cashier's position. - // - // Compared per transaction inside a correlated subquery rather than as a join, so a - // breakdown with no transaction behind it -- a Demand Draft keyed by its request id, or a - // manual GL entry keyed by its decoded reference -- is untouched by this test and left to - // CountedCashLedger's rule above. - + "AND NOT EXISTS (" - + " SELECT 1 FROM m_savings_account_transaction mt " - + " WHERE mt.id = std.txn_id" - + " AND ABS(mt.amount - (SELECT SUM(sd.count * sd.denomination_value)" - + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id)) >= 1) "; + private static final String COUNTER_NOT_REVERSED = "AND " + CountedCashLedger.COUNTED_ROW; /** * Day book — every voucher raised on one date, in one list, newest first. @@ -106,7 +83,7 @@ public class CashReportApiResource { public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side, @QueryParam("status") final String status) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final List> rows = new ArrayList<>(); @@ -493,7 +470,7 @@ public class CashReportApiResource { @GET @Path("closing") public String closing(@QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); // A closing report for a cashier is that one drawer, so it is scoped the way `excess` and // `cashierPosition` already are; a head cashier or admin gets null and still sees the branch. @@ -694,25 +671,25 @@ public class CashReportApiResource { // day itself: taken from the vault, in over the counter, out over the counter, settled // back. What is left is what should physically be in the drawer. result.put("todayVaultIn", dayLegTotal( - "SELECT COALESCE(SUM(ctd.count * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + "SELECT COALESCE(SUM(CAST(ctd.count AS BIGINT) * ctd.denomination_value), 0) FROM m_cash_transfer_detail ctd" + " JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id" + " WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ct.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todaySettledOut", dayLegTotal( - "SELECT COALESCE(SUM(csd.count * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + "SELECT COALESCE(SUM(CAST(csd.count AS BIGINT) * csd.denomination_value), 0) FROM m_cash_settlement_detail csd" + " JOIN m_cash_settlement cs ON cs.id = csd.settlement_id" + " WHERE cs.from_cashier_id = ? AND cs.settlement_date = CAST(? AS DATE)" + " AND COALESCE(cs.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierIn", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.to_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", scopedCashierId, txnDate)); result.put("todayInterCashierOut", dayLegTotal( - "SELECT COALESCE(SUM(icd.count * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + "SELECT COALESCE(SUM(CAST(icd.count AS BIGINT) * icd.denomination_value), 0) FROM m_cash_inter_cashier_detail icd" + " JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id" + " WHERE ic.from_cashier_id = ? AND ic.transfer_date = CAST(? AS DATE)" + " AND COALESCE(ic.is_reversed, false) = false", @@ -744,7 +721,7 @@ public class CashReportApiResource { final Long scopedCashierId) { final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : ""; + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " : ""; final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : ""; final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : ""; @@ -961,7 +938,7 @@ public class CashReportApiResource { return gson.toJson(result); } - final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String today = DateUtils.getBusinessLocalDate().toString(); // Find the earliest transfer date before today for which no settlement exists on or after that date final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled " @@ -1024,7 +1001,7 @@ public class CashReportApiResource { @Path("cashier-position") public String cashierPosition(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); // CASHIER role: always scope to their own position only final Long scopedId = cashierRoleHelper.getCashierIdForFiltering(); @@ -1055,7 +1032,7 @@ public class CashReportApiResource { @GET @Path("cashier-position/me") public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) { - final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : DateUtils.getBusinessLocalDate().toString(); Long appUserId; try { appUserId = context.authenticatedUser().getId(); @@ -1093,7 +1070,7 @@ public class CashReportApiResource { public String vaultPosition(@QueryParam("tellerId") final Long tellerId, @QueryParam("asOfDate") final String asOfDate) { final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + : DateUtils.getBusinessLocalDate().toString(); if (tellerId == null) { final List tellerIds = jdbcTemplate.queryForList("SELECT id FROM m_tellers ORDER BY id", Long.class); @@ -1115,7 +1092,7 @@ public class CashReportApiResource { @Path("cashier-morning") public String cashierMorning(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); if (cashierId == null) { Map empty = new LinkedHashMap<>(); empty.put("cashierId", null); @@ -1194,7 +1171,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Settlements back to vault @@ -1220,7 +1197,7 @@ public class CashReportApiResource { + "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) " + "AND std.officer_id IN (" + " SELECT au.id FROM m_appuser au " - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ") " + COUNTER_NOT_REVERSED + "GROUP BY std.denomination_id, std.denomination_value, dm.kind"; // Exchange IN: customer gave denomination to cashier (RECEIVED direction) @@ -1327,7 +1304,7 @@ public class CashReportApiResource { @QueryParam("offset") Integer offset, @QueryParam("limit") Integer limit) { - final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String txnDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); final int resolvedLimit = (limit != null && limit > 0) ? limit : 20; final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0; @@ -1338,13 +1315,13 @@ public class CashReportApiResource { if (cashierId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " - + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"; + + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, cashierId }; } else if (tellerId != null) { officerFilter = "AND std.officer_id IN (" + "SELECT au.id FROM m_appuser au " + "JOIN m_cashiers mc ON mc.staff_id = au.staff_id " - + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?)"; + + "JOIN m_tellers mt ON mt.id = mc.teller_id WHERE mt.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")"; baseParams = new Object[] { txnDate, tellerId }; } else { officerFilter = "AND std.officer_id IN (" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 847ea66a8..61d6bbab8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -89,7 +89,7 @@ public class CashSettlementApiResource { } final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull() - ? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + ? LocalDate.parse(json.get("settlementDate").getAsString()) : DateUtils.getBusinessLocalDate(); final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString() : null; final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() @@ -226,7 +226,7 @@ public class CashSettlementApiResource { } glDispatchService.allocateCashToCashier(glTellerId, settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR", - LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash settlement #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -247,7 +247,7 @@ public class CashSettlementApiResource { throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required", "cashierId query param is required."); } - final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); + final String settlementDate = (date != null && !date.isBlank()) ? date : DateUtils.getBusinessLocalDate().toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)", Integer.class, cashierId, settlementDate); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 5f5c64b62..abe44dc8d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -146,7 +146,7 @@ public class CashTransferApiResource { } catch (Exception ignored) { // intentionally ignored } - final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate txnDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total, currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE), note != null ? note : ""); @@ -206,7 +206,7 @@ public class CashTransferApiResource { } glDispatchService.settleCashFromCashier(glTellerId, transfer.getToCashierId(), transfer.getTransferAmount(), "INR", - java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE), + DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), "Reversal of cash transfer #" + id + (reason != null ? ": " + reason : "")); } catch (Exception ignored) { // intentionally ignored @@ -291,7 +291,7 @@ public class CashTransferApiResource { "Vault/teller " + fromTellerId + " was not found."); } - final LocalDate businessDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate businessDate = transferDate != null ? transferDate : DateUtils.getBusinessLocalDate(); final java.sql.Date sqlBusinessDate = java.sql.Date.valueOf(businessDate); final List registerIds = jdbcTemplate.query(""" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java index 002638999..6aaa12dfb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashierGlDenominationApiResource.java @@ -91,7 +91,7 @@ public class CashierGlDenominationApiResource { final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root); final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank()) ? LocalDate.parse(txnDateStr) - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) { return "{\"result\":\"skipped\"}"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index b3164ea4a..9a02c384d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -889,7 +889,7 @@ public class ShareAdvanceCollectionApiResource { final Long resolvedClientId = toLong(dep.get("client_id")); final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); // What the cashier actually typed at save time, when they typed anything -- that is what // the Apply Share screen's Description column exists to show. Falls back to a plain @@ -927,7 +927,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String voucherNo = (String) req.get("voucher_no"); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1006,7 +1006,7 @@ public class ShareAdvanceCollectionApiResource { final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId); final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR"; final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); // What the cashier actually typed at save time, when they typed anything -- same preference // as autoAdoptShareCollectionTagged, so a receipt reads identically whether it got here by @@ -1093,7 +1093,7 @@ public class ShareAdvanceCollectionApiResource { final Object submittedAt = req.get("submitted_at"); final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() - : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + : DateUtils.getBusinessLocalDate(); final String receiptNo = nextReceiptNo(); final String reqDescription = (String) req.get("description"); @@ -1338,7 +1338,7 @@ public class ShareAdvanceCollectionApiResource { private LocalDate parseDate(final String raw) { if (raw == null || raw.isBlank()) { - return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + return DateUtils.getBusinessLocalDate(); } try { return LocalDate.parse(raw); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java index 5c128b357..1954371b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/chequebook/service/ChequeBookIssuanceChargeService.java @@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.chequebook.domain.ChequeAccountType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; @@ -252,7 +253,7 @@ public class ChequeBookIssuanceChargeService { } postChargeJournalEntry(office.getId(), charge.getCurrencyCode(), today, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId, - accountId); + accountId, accountType); raiseChargeVoucher(accountType, accountId, ownAccountGlId, incomeGlId, chargeAmount, chequeBookId); log.info("Deducted cheque book issuance charge {} from {} account {} for book {}", chargeAmount, accountType, accountId, chequeBookId); @@ -340,13 +341,14 @@ public class ChequeBookIssuanceChargeService { private Long getCurrentControlGlId(final Long currentAccountId) { final List> rows = this.jdbcTemplate.queryForList("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, - final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId) { + final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId, + final ChequeAccountType accountType) { final JsonArray debitArr = new JsonArray(); final JsonObject debit = new JsonObject(); debit.addProperty("glAccountId", debitGlId); @@ -383,12 +385,21 @@ public class ChequeBookIssuanceChargeService { // link it back to the customer's account, same as ClerkDisbursementRequestApiResource's direct debits do. final String glTxnId = result.getTransactionId(); if (glTxnId != null) { - linkDebitEntryToAccount(glTxnId, accountId); + linkDebitEntryToAccount(glTxnId, accountId, accountType); } } - /** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */ - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an + * Account No. + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same numeric id can + * be a real row in both -- tagging every entry entity_type_enum=2 (PortfolioProductType.SAVING) regardless of + * accountType made the Journal Entries screen resolve a current account's charge to a same-numbered savings + * account instead (or vice versa). entity_type_enum=6 (PortfolioProductType.CURRENT) is the one that actually + * means "this id is in m_current_account" -- use it whenever accountType says so. + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final ChequeAccountType accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -397,8 +408,11 @@ public class ChequeBookIssuanceChargeService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = accountType == ChequeAccountType.CURRENT + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java index 5fd98fe17..964c29498 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingGlAccountResolverService.java @@ -50,7 +50,7 @@ public class ClearingGlAccountResolverService { if ("CURRENT".equalsIgnoreCase(accountType)) { return querySingleLong("SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", accountId); + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", accountId); } return null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java index 9180b5bf6..535ce45c9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clearing/service/ClearingJournalService.java @@ -34,6 +34,7 @@ import org.apache.fineract.commands.service.SynchronousCommandProcessingService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; @@ -118,7 +119,7 @@ public class ClearingJournalService { } final String glTxnId = result.getTransactionId(); if (glTxnId != null && linkAccountId != null) { - linkDebitEntryToAccount(glTxnId, linkAccountId); + linkDebitEntryToAccount(glTxnId, linkAccountId, linkAccountType); } raiseClearingVoucher(glTxnId, debitGlId, creditGlId, amount, comments, linkAccountId, linkAccountType); return glTxnId; @@ -191,7 +192,13 @@ public class ClearingJournalService { return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(Map.of()); } - private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) { + /** + * Same savings-vs-current ambiguity {@link #accountHolderForVoucher} already guards against: the two + * account tables number independently, so entity_type_enum must say which one accountId names, not + * assume SAVING(2) for both. entity_type_enum=6 (PortfolioProductType.CURRENT) is what actually means + * "this id is in m_current_account". + */ + private void linkDebitEntryToAccount(final String glTxnId, final Long accountId, final String accountType) { // ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in // case the id is ever reused (e.g. after a sequence reset) rather than latching onto a // stale, unrelated journal entry. @@ -200,8 +207,11 @@ public class ClearingJournalService { + " ORDER BY id DESC LIMIT 1", Long.class, glTxnId); if (!rowIds.isEmpty()) { - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId, - rowIds.get(0)); + final int entityType = "CURRENT".equalsIgnoreCase(accountType) + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); + this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", entityType, + accountId, rowIds.get(0)); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 08d596d26..00e292a9a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -66,6 +66,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHold import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.voucher.service.VoucherService; @@ -173,9 +174,25 @@ public class ClerkDisbursementRequestApiResource { // stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps // working, since nothing about a restricted customer should prevent them being paid. // A GL leg carries no savingsAccountId and the guard ignores it. + // A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against + // m_current_account -- checking it as a savings id would test some unrelated savings account. if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { - kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), - "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); + final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER + : KycRestrictionGuard.Operation.CREDIT; + if ("CURRENT".equals(entryAccountType)) { + kycRestrictionGuard.checkCurrentAccount(entry.get("savingsAccountId").getAsLong(), operation); + } else { + kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), operation); + } + } + // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should + // not learn the account can't pay out until a manager tries to approve it and the whole + // request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would + // refuse this later -- inactive, closed, or still inside its lock-in period -- is checked + // here too, against the same columns, so the clerk sees the real reason at entry time. + if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType) + && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt); } if ("Dr".equals(dbCr)) { totalDr = totalDr.add(entryAmt); @@ -513,6 +530,7 @@ public class ClerkDisbursementRequestApiResource { // collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode). // disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode). final List collectionGlEntries = new ArrayList<>(); + final List collectionCreditGlEntries = new ArrayList<>(); final List disbursementGlEntries = new ArrayList<>(); // Compensation list: each successfully posted Fineract transaction gets an undo lambda. @@ -534,8 +552,11 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString()); final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?"; final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrent = "CURRENT".equals( - entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank + // transfer posts and moves money immediately, with no maker-checker step to catch + // a wrong label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -548,7 +569,7 @@ public class ClerkDisbursementRequestApiResource { ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btTotalDr = btTotalDr.add(entryAmt); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); @@ -557,7 +578,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } @@ -610,7 +631,15 @@ public class ClerkDisbursementRequestApiResource { return ps; }, btLogKh); final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); - final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal); + // submit() already raised this request's voucher (every request type does, so it + // shows in My Vouchers the moment a clerk submits it, before any checker acts) -- + // minting a second one here for Bank Transfer specifically double-counted the same + // transfer under two different names, "Entered by" whoever raised it AND whoever + // approved it. Reuse that voucher; only fall back to raising a fresh one if this + // request somehow reached approval without it (defensive, not the normal path). + final Object existingVoucherNo = record.get("voucher_no"); + final String btVoucherNo = existingVoucherNo != null ? (String) existingVoucherNo + : generateBtVoucherNo(btLogId, currentUser, finalBtTotal); jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId); } @@ -633,8 +662,9 @@ public class ClerkDisbursementRequestApiResource { // Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description); } else { - // Collection: CR GL is the target for the combined savings debit entry + // Collection: each CR GL takes its own amount in the one combined entry collectionTargetGlId = glId; + collectionCreditGlEntries.add(new GlEntry(glId, entryAmt, acNo, null)); } } txnRefs.add("GL:" + acNo); @@ -642,7 +672,7 @@ public class ClerkDisbursementRequestApiResource { } final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrentAccount = isCurrentAccountById(accountId); + final boolean isCurrentAccount = isCurrentAccountById(accountId, acNo); String txnId = null; if ("Cr".equals(dbCr)) { @@ -660,7 +690,7 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(directCaTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId, true)); undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId)); } else { // Real CBS book transfer: direct credit to savings account. @@ -675,32 +705,30 @@ public class ClerkDisbursementRequestApiResource { accountId, entryAmt, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(directTxnId)); depositSavingsTotal = depositSavingsTotal.add(entryAmt); - disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId)); + disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId, false)); undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId)); } } else { - try { - txnId = isCurrentAccount - ? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description) - : executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description); - } catch (IdempotentCommandProcessSucceedException idempotent) { - txnId = extractResourceIdFromResponse(idempotent.getResponse()); + // Collection: a direct book debit, exactly as a bank transfer debits. It used to run a + // cash withdrawal, whose own auto-entry (DR control / CR CashVault -- cash that never + // moved) was then hidden by flagging it reversed, and a current account's auto-entry was + // not even hidden, crediting CashVault instead of the target GL. Now nothing is posted + // here; the one combined entry below is the only GL posting. + final Long ctrlGl = isCurrentAccount ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == null) { + throw new RuntimeException("No control GL mapped for account " + acNo + + ". Map it on the account's product Accounting tab."); } - if (txnId != null) { - registerUndo(undoOps, accountId, txnId, isCurrentAccount); - txnRefs.add(txnId); - if (!isCurrentAccount) { - final String fullTxnId = "S" + txnId; - withdrawalSavingsTxnRefs.add(txnId); - withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); - // Savings withdrawal auto-creates: DR SavingsControl / CR CashVault. - // Collect the SavingsControl GL (DR side, type_enum=2) so we can - // post a direct DR SavingsControl / CR targetGL combined entry. - final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2); - if (savingsCtrlGl != null) { - collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId)); - } - } + final long debitTxnId = isCurrentAccount + ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); + txnRefs.add(String.valueOf(debitTxnId)); + withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt); + collectionGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrentAccount)); + if (isCurrentAccount) { + undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, debitTxnId)); + } else { + undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, debitTxnId)); } } } @@ -723,39 +751,22 @@ public class ClerkDisbursementRequestApiResource { } // ── Collection GL entry ─────────────────────────────────────────────── - // Goal: DR SavingsControl(per account) / CR targetGL — one combined entry. - // Fineract withdrawals auto-create: DR SavingsControl / CR CashVault. - // We mark those auto-entries reversed (hidden from GL reports) and post - // the direct combined entry. Fallback: CashVault bridge if GL lookup fails. - if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) { - if (!collectionGlEntries.isEmpty() - && collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) { - markTxnGlEntriesReversed(collectionGlEntries); - final List snapC = new ArrayList<>(collectionGlEntries); - undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC)); - final String collGlTxnId = postCombinedJournalEntry( - collectionGlEntries, - List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)), - officeId, currencyCode, txnDateStr, - buildGlEntryComment("Collection", collectionGlEntries, description)); - // Link each DR entry to its savings account so ACCOUNT NO shows in the GL view. - if (collGlTxnId != null) { - linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); - } - approvedGlTxnId = collGlTxnId; - } else { - final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); - if (cashVaultGlId != null) { - approvedGlTxnId = postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal, - officeId, currencyCode, txnDateStr, description); - } else if (!withdrawalSavingsTxnRefs.isEmpty()) { - // No bridging entry was needed: the withdrawal's own auto-posted credit - // leg already lands on the target GL directly (the savings product's own - // payment-type mapping points there). That withdrawal's own transaction is - // therefore the real posting -- there is no second one to remember instead. - approvedGlTxnId = "S" + withdrawalSavingsTxnRefs.get(0); - } + // One combined entry and nothing else: DR each account's own product control GL / CR each + // target GL. The accounts were debited directly above, so there is no auto-posted entry to + // hide and no CashVault leg to bridge. + if (!collectionGlEntries.isEmpty()) { + if (collectionCreditGlEntries.isEmpty()) { + throw new RuntimeException("Collection has no credit GL to receive " + withdrawalSavingsTotal + "."); } + final String collGlTxnId = postCombinedJournalEntry( + collectionGlEntries, collectionCreditGlEntries, + officeId, currencyCode, txnDateStr, + buildGlEntryComment("Collection", collectionGlEntries, description)); + // Link each DR entry to its savings/current account so ACCOUNT NO shows in the GL view. + if (collGlTxnId != null) { + linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); + } + approvedGlTxnId = collGlTxnId; } } // end else (disbursement/collection mode) @@ -960,8 +971,11 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById: an id can + // exist in both m_current_account and m_savings_account at once, and a bank transfer + // posts and moves money immediately, with no maker-checker step to catch a wrong + // label before it does. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -974,7 +988,7 @@ public class ClerkDisbursementRequestApiResource { : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); txnRefs.add(String.valueOf(txnId)); - debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); } else { @@ -987,8 +1001,8 @@ public class ClerkDisbursementRequestApiResource { final Long accountId = e.get("savingsAccountId").getAsLong(); final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; - final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; - final boolean isCurrent = "CURRENT".equals(acTyp); + // Not trusted from entry.accountType alone -- see isCurrentAccountById. + final boolean isCurrent = isCurrentAccountById(accountId, acNo); final Long ctrlGl = isCurrent ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); @@ -1000,7 +1014,7 @@ public class ClerkDisbursementRequestApiResource { ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); txnRefs.add(String.valueOf(txnId)); - creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); if (isCurrent) { undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); } else { @@ -1179,6 +1193,51 @@ public class ClerkDisbursementRequestApiResource { .findFirst().orElse(null); } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** + * The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not + * active, or still inside its lock-in period -- checked here against the same columns, so the + * clerk sees the real reason when the request is raised rather than a generic failure once a + * manager tries to approve it. Balance is checked too, against the account's own minimum, since + * an entry that can never post is no more usable sitting in the approval queue than one refused + * outright. + */ + private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived, + min_required_balance, enforce_min_required_balance + FROM m_savings_account WHERE id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere + } + final Map row = rows.get(0); + final String accountNo = (String) row.get("account_no"); + final Number statusEnum = (Number) row.get("status_enum"); + if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active."); + } + final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived"); + if (lockedUntil != null) { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate lockedUntilDate = lockedUntil.toLocalDate(); + if (today.isBefore(lockedUntilDate)) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + + ": account is locked-in until " + lockedUntilDate + "."); + } + } + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance (" + + balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ")."); + } + } + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, final String requestType) { @@ -1317,10 +1376,25 @@ public class ClerkDisbursementRequestApiResource { commandsSourceWritePlatformService.logCommandSource(cmd); } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here has already sent a + * real deposit into a stranger's current account instead of the savings account the entry + * actually named. expectedAccountNo -- the entry's own accountNo -- closes that gap: an id match + * is only trusted once the account_no on that current-account row confirms it. Pass null to skip + * the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank() || "?".equals(expectedAccountNo)) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; @@ -1503,20 +1577,27 @@ public class ClerkDisbursementRequestApiResource { final BigDecimal amount; final String accountRef; // savings account number shown in GL comments; null for source/target GL final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries - final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries + final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries + final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId) { - this(glAccountId, amount, accountRef, txnId, null); + this(glAccountId, amount, accountRef, txnId, null, false); } GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, final Long savingsAccountId) { + this(glAccountId, amount, accountRef, txnId, savingsAccountId, false); + } + + GlEntry(final Long glAccountId, final BigDecimal amount, final String accountRef, final String txnId, + final Long savingsAccountId, final boolean isCurrentAccount) { this.glAccountId = glAccountId; this.amount = amount; this.accountRef = accountRef; this.txnId = txnId; this.savingsAccountId = savingsAccountId; + this.isCurrentAccount = isCurrentAccount; } } @@ -1536,39 +1617,18 @@ public class ClerkDisbursementRequestApiResource { return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue(); } - /** - * Marks all Fineract auto-created journal entries for these transactions as reversed=true. - * Reversed entries are excluded from GL reports — only our direct combined entry is visible. - * Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails. - */ - private void markTxnGlEntriesReversed(final List entries) { - for (final GlEntry e : entries) { - if (e.txnId != null) { - // Guarded by reversed = false: if transaction_id text is ever reused, this can - // only flip rows that are currently live, never re-touch an already-reversed - // unrelated transaction's rows. - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - e.txnId); - } - } - } - - /** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */ - private void unmarkTxnGlEntriesReversed(final List entries) { - for (final GlEntry e : entries) { - if (e.txnId != null) { - jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true", - e.txnId); - } - } - } - /** * After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1) - * row to its savings account so the GL view shows ACCOUNT NO instead of "—". + * row to its savings OR current account so the GL view shows ACCOUNT NO instead of "—". * Rows are matched to entries by insertion order (Fineract inserts in JSON array order). + * + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both -- this used to tag every entry entity_type_enum=2 + * (PortfolioProductType.SAVING) regardless of which table the id actually named, so the GL view + * would resolve a current account's entry to a same-numbered savings account or vice versa. + * entity_type_enum=6 (PortfolioProductType.CURRENT) exists precisely so a current account's + * entries can be told apart from a savings account's; GlEntry.isCurrentAccount says which one + * this entry's id actually is, decided once already, where the account was first resolved. */ private void linkGlEntriesToSavingsAccounts(final String glTxnId, final List entries, final int typeEnum) { @@ -1582,11 +1642,15 @@ public class ClerkDisbursementRequestApiResource { Long.class, glTxnId, typeEnum, entries.size()); java.util.Collections.reverse(rowIds); for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) { - final Long savingsAccId = entries.get(i).savingsAccountId; + final GlEntry entry = entries.get(i); + final Long savingsAccId = entry.savingsAccountId; if (savingsAccId != null) { + final int entityType = entry.isCurrentAccount + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", - savingsAccId, rowIds.get(i)); + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = ?", + entityType, savingsAccId, rowIds.get(i)); } } } @@ -1681,7 +1745,7 @@ public class ClerkDisbursementRequestApiResource { final List> rows = jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index dc9690edf..e7dedf034 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -451,8 +451,18 @@ public class ClerkShareWithdrawalRequestApiResource { // not-applicable rather than failed for a GL request — a share account with no linked // savings is a perfectly valid GL-settlement case. final boolean glSettlementMode = "GL".equals(record.get("settlement_mode")); - checks.add(glSettlementMode - ? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer") + // 'NONE' is the third mode approve() handles, and submit() produces it on its own: a clerk who + // names the product's own Share Sundry GL as the destination is saying "stop here", so the + // request is downgraded from GL to NONE and approve() posts Dr Share Capital / Cr Share Sundry + // and nothing further. Like a GL settlement it never reaches a savings account, so the + // savings-side checks below do not apply to it either — running them reports a missing fund + // source mapping that no posting in this mode is ever going to read. + final boolean sundryOnlySettlement = "NONE".equals(record.get("settlement_mode")); + final boolean noSavingsLeg = glSettlementMode || sundryOnlySettlement; + checks.add(noSavingsLeg + ? checkResult("Linked savings account is Active", true, sundryOnlySettlement + ? "Not applicable — the redemption rests in Share Sundry" + : "Not applicable — settlement is a GL book-transfer") : checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, savingsAccountId == null ? "No linked savings account configured" : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); @@ -541,6 +551,9 @@ public class ClerkShareWithdrawalRequestApiResource { final boolean settlementGlPresent = glAccountIdObj != null; checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent, settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing")); + } else if (sundryOnlySettlement) { + checks.add(checkResult("Settlement destination recorded", true, + "Share Sundry — the redemption stops there, so no fund source mapping is read")); } else { boolean glMappingOk = false; String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; @@ -709,6 +722,21 @@ public class ClerkShareWithdrawalRequestApiResource { + "applied to it, so these shares have not actually been paid for and cannot be redeemed."); } + // The Share Sundry account the redemption draws on must actually hold the money. + // + // collectionApplied above says A collection was applied to THIS account, not that the + // proceeds are still sitting in Sundry - the same holding account serves every share + // account, and an earlier redemption may already have drawn it down. Approving past that + // takes Sundry negative, which is a liability owing less than nothing, and the overdraw + // lands inside a balance-sheet head that totals positive, where it does not look wrong. + final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares); + if (redemptionValue != null) { + final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class, + shareAccountId); + GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue, + "The Share Sundry (Suspense) account"); + } + final JsonObject payload = new JsonObject(); payload.addProperty("locale", "en"); payload.addProperty("dateFormat", "dd MMMM yyyy"); @@ -958,6 +986,33 @@ public class ClerkShareWithdrawalRequestApiResource { * text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a * reset id sequence in a dev environment can violate. */ + /** + * What a redemption of this many shares will draw out of Share Sundry. + * + *

+ * Priced at the unit price the member's own shares were issued at, which is the price the + * redemption itself uses. The product's nominal price is not consulted: a member who subscribed + * at an earlier price redeems at theirs, and pricing the guard differently from the posting + * would either block a redemption that fits or let one through that does not. + * + * @return the value, or null where no price can be established - the guard then stands aside + * rather than blocking on a figure it had to invent. + */ + private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) { + if (shareAccountId == null || requestedShares == null || requestedShares <= 0) { + return null; + } + // The purchase that issued the holding - type 500. Its unit price is what the member paid. + final BigDecimal unitPrice = jdbcTemplate + .queryForList(""" + SELECT unit_price FROM m_share_account_transactions + WHERE account_id = ? AND is_active = true AND type_enum = 500 AND unit_price IS NOT NULL + ORDER BY id DESC LIMIT 1 + """, BigDecimal.class, shareAccountId) + .stream().findFirst().orElse(null); + return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares)); + } + private Long redeemDebitGlOf(final Long shareTransactionId) { if (shareTransactionId == null) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 8e2891297..d579ec92e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -470,7 +470,7 @@ public class ClerkWithdrawalRequestApiResource { String txnId = null; if (savingsAccountId != null) { - final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); + final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) : executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description); @@ -722,10 +722,13 @@ public class ClerkWithdrawalRequestApiResource { INSERT INTO m_savings_txn_denomination (txn_id, savings_account_id, account_no, client_id, denomination_id, denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(CAST(? AS DATE), CURRENT_DATE), 'WITHDRAWAL', ?, ?, ?) """, txnId, savingsAccountId, accountNo, clientId, denomId, value, count, BigDecimal.valueOf((long) value * count), + // The request's own date, which the branch day's paid-out figure reads -- not the + // database clock, which put the notes on a different day from the withdrawal. + record.get("txn_date"), cashier.getId(), cashier.getUsername(), paymentTypeName ); } @@ -872,10 +875,25 @@ public class ClerkWithdrawalRequestApiResource { return result.getResourceId() != null ? result.getResourceId().toString() : null; } - private boolean isCurrentAccountById(final Long accountId) { + /** + * m_current_account and m_savings_account each keep their own identity sequence, so the same + * numeric id can be a real row in both at once -- two unrelated accounts, possibly two different + * clients, sharing nothing but a coincidental number. An id-only check here can send a real + * withdrawal against a stranger's current account instead of the savings account the request + * actually named. expectedAccountNo -- the request's own account_no -- closes that gap: an id + * match is only trusted once the account_no on that current-account row confirms it. Pass null + * to skip the check where the caller has no account_no to compare against. + */ + private boolean isCurrentAccountById(final Long accountId, final String expectedAccountNo) { try { + if (expectedAccountNo == null || expectedAccountNo.isBlank()) { + final Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + return count != null && count > 0; + } final Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId); + "SELECT COUNT(*) FROM m_current_account WHERE id = ? AND account_no = ?", + Integer.class, accountId, expectedAccountNo); return count != null && count > 0; } catch (Exception e) { return false; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java index ba568c32c..912af3e3e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java @@ -108,8 +108,20 @@ public class KycRestrictionGuard { checkKycExpiry(savingsAccountId, operation); } + /** + * The same two restrictions for a current account, read off the current account and its own current product + * (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's. + */ + public void checkCurrentAccount(final Long currentAccountId, final Operation operation) { + checkCurrentAccountDormancy(currentAccountId, operation); + enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation); + } + private void checkKycExpiry(final Long savingsAccountId, final Operation operation) { - final Long daysOverdue = daysOverdue(savingsAccountId); + enforceKycStage(daysOverdue(savingsAccountId), operation); + } + + private void enforceKycStage(final Long daysOverdue, final Operation operation) { if (daysOverdue == null) { return; } @@ -158,6 +170,28 @@ public class KycRestrictionGuard { JOIN m_savings_product sp ON sp.id = sa.product_id WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0 """, savingsAccountId); + refuseIfDormant(rows, operation, "savings"); + } + + private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) { + if (currentAccountId == null || operation == Operation.CREDIT) { + return; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT cp.days_to_dormancy, + (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') + - GREATEST(ca.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = ca.client_id AND k.verified_on IS NOT NULL), + ca.activatedon_date)) AS days_idle + FROM m_current_account ca + JOIN m_current_product cp ON cp.id = ca.product_id + WHERE ca.id = ? AND cp.is_dormancy_tracking_active = true AND cp.days_to_dormancy > 0 + """, currentAccountId); + refuseIfDormant(rows, operation, "current"); + } + + private void refuseIfDormant(final List> rows, final Operation operation, final String accountKind) { if (rows.isEmpty()) { return; } @@ -168,11 +202,30 @@ public class KycRestrictionGuard { } final long years = threshold.longValue() / 365; final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days"; - throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(), + throw new PlatformDataIntegrityException("error.msg." + accountKind + ".account.dormant." + operation.name().toLowerCase(), operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for " + period + ". It must be reactivated after the customer's identity is re-verified (re-KYC)."); } + private Long currentAccountDaysOverdue(final Long currentAccountId) { + if (currentAccountId == null) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue + FROM m_current_account ca + JOIN m_client_kyc k ON k.client_id = ca.client_id + WHERE ca.id = ? AND k.expiry_date IS NOT NULL + ORDER BY k.expiry_date DESC + LIMIT 1 + """, currentAccountId); + if (rows.isEmpty()) { + return null; + } + final Number daysOverdue = (Number) rows.get(0).get("days_overdue"); + return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue(); + } + /** * Days past the customer's re-KYC due date, or null where there is nothing overdue to measure. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java index 3fa7bcd11..9fe4d41da 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/CountedCashLedger.java @@ -87,4 +87,63 @@ public final class CountedCashLedger { + " OR CAST(to_timestamp(std.txn_id / 1000.0) AS DATE) NOT BETWEEN" + " COALESCE(std.txn_date, CAST(std.created_at AS DATE)) - " + SAME_DAY_TOLERANCE_DAYS + " AND COALESCE(std.txn_date, CAST(std.created_at AS DATE)) + " + SAME_DAY_TOLERANCE_DAYS + ") "; + + /** + * {@link #REAL_TXN_ID} plus the two tests a real id can still fail, as one predicate over the alias + * {@code std}. Every drawer figure reads this, not {@code REAL_TXN_ID} alone. + * + *

+ * Only the cashier position report used to apply the two extra tests, so it and the reconciliation + * screen disagreed: a reversed ₹200 deposit left the settlement screen expecting three ₹100 notes + * while the settlement check, reading the position, allowed one — and refused the hand-over. + * + *

    + *
  • a breakdown whose savings transaction is reversed -- the money went back, so the notes did too. + * The table has no reversal marker of its own, so it is read off the transaction; + *
  • a breakdown that does not add up to its transaction -- ₹1,60,000 of notes against a ₹918 + * deposit is mis-keyed, not cash. Sub-rupee tolerance, because a paisa-exact amount is handed + * over as the nearest whole rupee. + *
+ * + *

+ * The transaction is found by id and account number together. Savings and current + * transactions keep separate id sequences, and a current-account deposit writes its own + * transaction id into {@code txn_id}. Matched on id alone, a ₹210 current-account deposit was + * judged against an unrelated savings transaction that happened to share its id, failed the + * amount test, and vanished from the cashier's drawer. A row with no account number (older + * writers) is still matched on id alone, as before. + * + *

+ * All tests are correlated {@code NOT EXISTS} rather than joins, so a breakdown with no account + * transaction behind it (a Demand Draft, a manual GL entry) is untouched by them. + */ + public static final String COUNTED_ROW = " (NOT EXISTS (" + + " SELECT 1 FROM m_savings_account_transaction rt" + + " JOIN m_savings_account rsa ON rsa.id = rt.savings_account_id" + + " WHERE rt.id = std.txn_id" + + " AND (std.account_no IS NULL OR rsa.account_no = std.account_no)" + + " AND (rt.is_reversed = true OR " + breakdownDiffersFrom("rt.amount") + "))" + + " AND NOT EXISTS (" + + " SELECT 1 FROM m_current_account_transaction rct" + + " JOIN m_current_account rca ON rca.id = rct.current_account_id" + + " WHERE rct.id = std.txn_id AND rca.account_no = std.account_no" + + " AND (rct.is_reversed = true OR " + breakdownDiffersFrom("rct.amount") + "))" + + " AND" + REAL_TXN_ID + ") "; + + /** + * Over the aliases {@code std} and {@code mc} (the cashier a breakdown's officer maps to): only what + * the login counted once this allocation existed. A breakdown is tied to a cashier through its + * officer's login, so without this a person moved from one branch's teller to another's carried + * every rupee they had ever taken -- at the old branch too -- into the new drawer. An allocation + * with no recorded creation time keeps its whole history. + */ + public static final String SINCE_ALLOCATION = " AND (mc.created_at IS NULL OR std.created_at IS NULL" + + " OR std.created_at >= mc.created_at)"; + + /** Sub-rupee tolerance: a paisa-exact amount is handed over as the nearest whole rupee. */ + private static String breakdownDiffersFrom(final String amountColumn) { + return "ABS(" + amountColumn + " - (SELECT SUM(CAST(sd.count AS BIGINT) * sd.denomination_value)" + + " FROM m_savings_txn_denomination sd WHERE sd.txn_id = std.txn_id" + + " AND sd.account_no IS NOT DISTINCT FROM std.account_no)) >= 1"; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index 3d22b4fdf..8fa7c4fd7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -286,9 +286,9 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -310,9 +310,9 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" @@ -407,9 +407,9 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'DEPOSIT'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)" @@ -431,9 +431,9 @@ public class DenominationBreakdownWriter { + " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)" + " FROM m_savings_txn_denomination std" + " WHERE std.txn_type = 'WITHDRAWAL'" - + " AND " + CountedCashLedger.REAL_TXN_ID + + " AND " + CountedCashLedger.COUNTED_ROW + " AND std.officer_id IN (SELECT au.id FROM m_appuser au" - + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)" + + " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?" + CountedCashLedger.SINCE_ALLOCATION + ")" + " GROUP BY std.denomination_id" + " UNION ALL" + " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java index 94d7dd76e..3b6706fe2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResource.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; @@ -114,4 +115,16 @@ public class FundsApiResource { final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); return toApiJsonSerializer.serialize(result); } + + @DELETE + @Path("{fundId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Delete a Fund", description = "Deletes a fund outright. Fails if the fund is still referenced by any loan or loan product.") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FundsApiResourceSwagger.DeleteFundsFundIdResponse.class))) + public String deleteFund(@PathParam("fundId") @Parameter(description = "fundId") final Long fundId) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().deleteFund(fundId).build(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + return toApiJsonSerializer.serialize(result); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java index 746a65c35..0756f2c92 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/api/FundsApiResourceSwagger.java @@ -55,4 +55,13 @@ final class FundsApiResourceSwagger { public Long resourceId; public PutFundsFundIdRequest changes; } + + @Schema(description = "DeleteFundsFundIdResponse") + public static final class DeleteFundsFundIdResponse { + + private DeleteFundsFundIdResponse() {} + + @Schema(example = "1") + public Long resourceId; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java new file mode 100644 index 000000000..34765a911 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/handler/DeleteFundCommandHandler.java @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.fund.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.fund.service.FundWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "FUND", action = "DELETE") +public class DeleteFundCommandHandler implements NewCommandSourceHandler { + + private final FundWritePlatformService writePlatformService; + + @Autowired + public DeleteFundCommandHandler(final FundWritePlatformService writePlatformService) { + this.writePlatformService = writePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + return this.writePlatformService.deleteFund(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java index 3eeddd82b..3d2d8a5be 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformService.java @@ -26,4 +26,6 @@ public interface FundWritePlatformService { CommandProcessingResult createFund(JsonCommand command); CommandProcessingResult updateFund(Long fundId, JsonCommand command); + + CommandProcessingResult deleteFund(Long fundId, JsonCommand command); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java index 550e8aaa9..b6e7232db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/service/FundWritePlatformServiceJpaRepositoryImpl.java @@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.fund.exception.FundNotFoundException; import org.apache.fineract.portfolio.fund.serialization.FundCommandFromApiJsonDeserializer; import org.springframework.cache.annotation.CacheEvict; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +46,7 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf private final PlatformSecurityContext context; private final FundCommandFromApiJsonDeserializer fromApiJsonDeserializer; private final FundRepository fundRepository; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -99,6 +101,45 @@ public class FundWritePlatformServiceJpaRepositoryImpl implements FundWritePlatf } } + @Transactional + @Override + @CacheEvict(value = "funds", key = "T(org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil).getTenant().getTenantIdentifier().concat('fn')") + public CommandProcessingResult deleteFund(final Long fundId, final JsonCommand command) { + + this.context.authenticatedUser(); + + final Fund fund = this.fundRepository.findById(fundId).orElseThrow(() -> new FundNotFoundException(fundId)); + + requireNoneReference(fundId, "m_loan", "fund_id", "loan", "loans", + "Reassign or remove the loans using this fund before deleting it."); + requireNoneReference(fundId, "m_product_loan", "fund_id", "loan product", "loan products", + "Remove this fund from the loan products using it before deleting it."); + + try { + this.fundRepository.delete(fund); + this.fundRepository.flush(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references", + "This fund cannot be deleted: other records still reference it."); + } catch (final PersistenceException dve) { + throw ErrorHandler.getMappable(dve, "error.msg.fund.cannot.delete.has.references", + "This fund cannot be deleted: other records still reference it."); + } + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(fundId).build(); + } + + private void requireNoneReference(final Long fundId, final String table, final String column, final String singular, + final String plural, final String remedy) { + final Long count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE " + column + " = ?", Long.class, + fundId); + if (count != null && count > 0) { + final String noun = count == 1 ? singular : plural; + throw new PlatformDataIntegrityException("error.msg.fund.cannot.delete.still.referenced", + "This fund cannot be deleted: it is still referenced by " + count + " " + noun + ". " + remedy, "fundId", fundId); + } + } + /* * Guaranteed to throw an exception no matter what the data integrity issue is. */ diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java index dfc9dae3a..450e0fcf9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fund/starter/FundConfiguration.java @@ -42,7 +42,7 @@ public class FundConfiguration { @Bean @ConditionalOnMissingBean(FundWritePlatformService.class) public FundWritePlatformService fundWritePlatformService(PlatformSecurityContext context, - FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository) { - return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository); + FundCommandFromApiJsonDeserializer fromApiJsonDeserializer, FundRepository fundRepository, JdbcTemplate jdbcTemplate) { + return new FundWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, fundRepository, jdbcTemplate); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java index 55f7dd7e1..7c4ee8fcb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/BranchProfileApiResource.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.orgsetup.api; import com.google.gson.Gson; +import com.google.gson.JsonObject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; @@ -27,17 +28,18 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; -import java.time.Instant; -import java.sql.Timestamp; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -57,6 +59,7 @@ public class BranchProfileApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new Gson(); @GET @@ -84,68 +87,23 @@ public class BranchProfileApiResource { @PUT @Path("{officeId}") @Consumes({ MediaType.APPLICATION_JSON }) - public String update(@PathParam("officeId") final Long officeId, final String body) { - this.context.authenticatedUser().validateHasPermissionTo("UPDATE_BRANCHPROFILE"); - - final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", - Integer.class, officeId); - if (officeExists == null || officeExists == 0) { - throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", - "Office " + officeId + " was not found."); - } - + public CommandProcessingResult update(@PathParam("officeId") final Long officeId, final String body) { + // The write itself (office-exists check, branch-code uniqueness check, m_office_profile + // upsert) now lives in BranchProfileWritePlatformService, invoked via + // UpdateBranchProfileCommandHandler so it goes through the generic Maker-Checker gate. The + // permission check that used to happen here explicitly is now enforced by SecurityConfig's + // matcher plus the command-source pipeline itself, the same as every other endpoint already + // migrated to this pipeline. A "locale" key is added if the body does not already carry one - + // JsonCommand's BigDecimal extractor (for latitude/longitude) requires it, and the frontend's + // payload does not currently send one. final var root = this.fromJsonHelper.parse(body); - final String branchCode = this.fromJsonHelper.extractStringNamed("branchCode", root); - final String branchType = this.fromJsonHelper.extractStringNamed("branchType", root); - final String address = this.fromJsonHelper.extractStringNamed("address", root); - final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); - final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); - final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); - final String workingHoursStart = this.fromJsonHelper.extractStringNamed("workingHoursStart", root); - final String workingHoursEnd = this.fromJsonHelper.extractStringNamed("workingHoursEnd", root); - final String workingDays = this.fromJsonHelper.extractStringNamed("workingDays", root); - final String ifscCode = this.fromJsonHelper.extractStringNamed("ifscCode", root); - final String micrCode = this.fromJsonHelper.extractStringNamed("micrCode", root); - final java.math.BigDecimal latitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("latitude", root); - final java.math.BigDecimal longitude = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("longitude", root); - - if (branchCode != null && !branchCode.isBlank()) { - final Integer dupe = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, - branchCode, officeId); - if (dupe != null && dupe > 0) { - throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", - "Branch code '" + branchCode + "' is already used by another branch."); - } + final JsonObject json = root.getAsJsonObject(); + if (!json.has("locale")) { + json.addProperty("locale", "en"); } - - final AppUser currentUser = this.context.authenticatedUser(); - final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", - Integer.class, officeId); - if (existing != null && existing > 0) { - jdbcTemplate.update(""" - UPDATE m_office_profile - SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, - working_hours_start = ?, working_hours_end = ?, working_days = ?, - ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, - updated_by_username = ?, updated_at = ? - WHERE office_id = ? - """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, - workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), - Timestamp.from(Instant.now()), officeId); - } else { - jdbcTemplate.update(""" - INSERT INTO m_office_profile - (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, - working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, - latitude, longitude, updated_by_username, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, - workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), - Timestamp.from(Instant.now())); - } - - return list(officeId); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateBranchProfile(officeId) + .withJson(this.gson.toJson(json)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } private String baseSql() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java index 7bcf6fc7d..a13e9a84d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/api/InstitutionProfileApiResource.java @@ -25,14 +25,16 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import java.sql.Date; import java.sql.Timestamp; import java.time.Instant; -import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.useradministration.domain.AppUser; @@ -60,6 +62,7 @@ public class InstitutionProfileApiResource { private final JdbcTemplate jdbcTemplate; private final PlatformSecurityContext context; private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new Gson(); @GET @@ -89,100 +92,17 @@ public class InstitutionProfileApiResource { @PUT @Consumes({ MediaType.APPLICATION_JSON }) - public String update(final String body) { - this.context.authenticatedUser().validateHasPermissionTo("UPDATE_INSTITUTIONPROFILE"); - ensureRowExists(); - - final var root = this.fromJsonHelper.parse(body); - final String bankName = this.fromJsonHelper.extractStringNamed("bankName", root); - final String legalName = this.fromJsonHelper.extractStringNamed("legalName", root); - final String shortName = this.fromJsonHelper.extractStringNamed("shortName", root); - final String bankCode = this.fromJsonHelper.extractStringNamed("bankCode", root); - final String institutionCode = this.fromJsonHelper.extractStringNamed("institutionCode", root); - final String bankType = this.fromJsonHelper.extractStringNamed("bankType", root); - final String ownershipType = this.fromJsonHelper.extractStringNamed("ownershipType", root); - final String parentInstitutionName = this.fromJsonHelper.extractStringNamed("parentInstitutionName", root); - final String registrationNumber = this.fromJsonHelper.extractStringNamed("registrationNumber", root); - final String licenseNumber = this.fromJsonHelper.extractStringNamed("licenseNumber", root); - final LocalDate establishedDate = parseDate(this.fromJsonHelper.extractStringNamed("establishedDate", root)); - final String status = this.fromJsonHelper.extractStringNamed("status", root); - final String country = this.fromJsonHelper.extractStringNamed("country", root); - final String regulatoryAuthority = this.fromJsonHelper.extractStringNamed("regulatoryAuthority", root); - final LocalDate registrationDate = parseDate(this.fromJsonHelper.extractStringNamed("registrationDate", root)); - final String licenseStatus = this.fromJsonHelper.extractStringNamed("licenseStatus", root); - final LocalDate licenseExpiryDate = parseDate(this.fromJsonHelper.extractStringNamed("licenseExpiryDate", root)); - final String swiftBic = this.fromJsonHelper.extractStringNamed("swiftBic", root); - final String addressLine1 = this.fromJsonHelper.extractStringNamed("addressLine1", root); - final String addressLine2 = this.fromJsonHelper.extractStringNamed("addressLine2", root); - final String city = this.fromJsonHelper.extractStringNamed("city", root); - final String district = this.fromJsonHelper.extractStringNamed("district", root); - final String state = this.fromJsonHelper.extractStringNamed("state", root); - final String pincode = this.fromJsonHelper.extractStringNamed("pincode", root); - final Boolean headOfficeSame = this.fromJsonHelper.parameterExists("headOfficeSameAsRegistered", root) - ? this.fromJsonHelper.extractBooleanNamed("headOfficeSameAsRegistered", root) : Boolean.TRUE; - final String headOfficeAddress = this.fromJsonHelper.extractStringNamed("headOfficeAddress", root); - final Boolean communicationSame = this.fromJsonHelper.parameterExists("communicationSameAsRegistered", root) - ? this.fromJsonHelper.extractBooleanNamed("communicationSameAsRegistered", root) : Boolean.TRUE; - final String communicationAddress = this.fromJsonHelper.extractStringNamed("communicationAddress", root); - final String contactPhone = this.fromJsonHelper.extractStringNamed("contactPhone", root); - final String contactEmail = this.fromJsonHelper.extractStringNamed("contactEmail", root); - final String customerCareNumber = this.fromJsonHelper.extractStringNamed("customerCareNumber", root); - final String grievanceEmail = this.fromJsonHelper.extractStringNamed("grievanceEmail", root); - final String website = this.fromJsonHelper.extractStringNamed("website", root); - final String pan = this.fromJsonHelper.extractStringNamed("pan", root); - final String tan = this.fromJsonHelper.extractStringNamed("tan", root); - final String gstin = this.fromJsonHelper.extractStringNamed("gstin", root); - final String statementHeaderName = this.fromJsonHelper.extractStringNamed("statementHeaderName", root); - final String passbookHeaderName = this.fromJsonHelper.extractStringNamed("passbookHeaderName", root); - final String receiptHeaderName = this.fromJsonHelper.extractStringNamed("receiptHeaderName", root); - final String termsAndConditions = this.fromJsonHelper.extractStringNamed("termsAndConditions", root); - final String authorizedPersonName = this.fromJsonHelper.extractStringNamed("authorizedPersonName", root); - final String authorizedPersonDesignation = this.fromJsonHelper.extractStringNamed("authorizedPersonDesignation", - root); - final String authorizedPersonContact = this.fromJsonHelper.extractStringNamed("authorizedPersonContact", root); - final String authorizedPersonEmail = this.fromJsonHelper.extractStringNamed("authorizedPersonEmail", root); - - // A status flip is stamped automatically the first time it happens, rather than asking the - // user to also remember to set a date — the previous row's own status is what "the first - // time" is judged against. - final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", - String.class); - final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); - final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); - final LocalDate today = LocalDate.now(); - - final AppUser currentUser = this.context.authenticatedUser(); - jdbcTemplate.update(""" - UPDATE m_institution_profile - SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, - bank_type = ?, ownership_type = ?, parent_institution_name = ?, - registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, - regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, - swift_bic = ?, - address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, - head_office_same_as_registered = ?, head_office_address = ?, - communication_same_as_registered = ?, communication_address = ?, - contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, - website = ?, pan = ?, tan = ?, gstin = ?, - statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, - terms_and_conditions = ?, - authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, - authorized_person_email = ?, - activation_date = CASE WHEN ? THEN ? ELSE activation_date END, - deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, - updated_by_username = ?, updated_at = ? - WHERE id = 1 - """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, - parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(establishedDate), status, country, - regulatoryAuthority, toSqlDate(registrationDate), licenseStatus, toSqlDate(licenseExpiryDate), swiftBic, - addressLine1, addressLine2, city, district, state, pincode, headOfficeSame, headOfficeAddress, - communicationSame, communicationAddress, contactPhone, contactEmail, customerCareNumber, grievanceEmail, - website, pan, tan, gstin, statementHeaderName, passbookHeaderName, receiptHeaderName, termsAndConditions, - authorizedPersonName, authorizedPersonDesignation, authorizedPersonContact, authorizedPersonEmail, - turningActive, toSqlDate(today), turningInactive, toSqlDate(today), currentUser.getUsername(), - Timestamp.from(Instant.now())); - - return get(); + public CommandProcessingResult update(final String body) { + // The write itself (the JDBC UPDATE of m_institution_profile) now lives in + // InstitutionProfileWritePlatformService, invoked via UpdateInstitutionProfileCommandHandler so + // it goes through the generic Maker-Checker gate. The permission check that used to happen here + // explicitly is now enforced by SecurityConfig's matcher plus the command-source pipeline + // itself, the same as every other endpoint already migrated to this pipeline (Currency, Working + // Days, Payment Type). `body` is forwarded to the command as-is - it already carries the + // `locale` key the frontend sends, which JsonCommand's BigDecimal/date extractors would + // otherwise require. + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateInstitutionProfile().withJson(body).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } private void ensureRowExists() { @@ -204,14 +124,6 @@ public class InstitutionProfileApiResource { return codes.isEmpty() ? null : codes.get(0); } - private LocalDate parseDate(final String raw) { - return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; - } - - private Date toSqlDate(final LocalDate d) { - return d == null ? null : Date.valueOf(d); - } - private Map toResult(final Map row) { final Map out = new LinkedHashMap<>(); out.put("bankName", row.get("bank_name")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java new file mode 100644 index 000000000..e4b3bb09e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateBranchProfileCommandHandler.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.orgsetup.service.BranchProfileWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "BRANCHPROFILE", action = "UPDATE") +public class UpdateBranchProfileCommandHandler implements NewCommandSourceHandler { + + private final BranchProfileWritePlatformService branchProfileWritePlatformService; + + @Autowired + public UpdateBranchProfileCommandHandler(final BranchProfileWritePlatformService branchProfileWritePlatformService) { + this.branchProfileWritePlatformService = branchProfileWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Long officeId = command.entityId(); + + final CommandProcessingResult result = this.branchProfileWritePlatformService.updateBranchProfile(officeId, + command.stringValueOfParameterNamed("branchCode"), command.stringValueOfParameterNamed("branchType"), + command.stringValueOfParameterNamed("address"), command.stringValueOfParameterNamed("pincode"), + command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"), + command.stringValueOfParameterNamed("workingHoursStart"), + command.stringValueOfParameterNamed("workingHoursEnd"), command.stringValueOfParameterNamed("workingDays"), + command.stringValueOfParameterNamed("ifscCode"), command.stringValueOfParameterNamed("micrCode"), + command.bigDecimalValueOfParameterNamed("latitude"), command.bigDecimalValueOfParameterNamed("longitude")); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java new file mode 100644 index 000000000..2d7270d4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/handler/UpdateInstitutionProfileCommandHandler.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.orgsetup.service.InstitutionProfileWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INSTITUTIONPROFILE", action = "UPDATE") +public class UpdateInstitutionProfileCommandHandler implements NewCommandSourceHandler { + + private final InstitutionProfileWritePlatformService institutionProfileWritePlatformService; + + @Autowired + public UpdateInstitutionProfileCommandHandler( + final InstitutionProfileWritePlatformService institutionProfileWritePlatformService) { + this.institutionProfileWritePlatformService = institutionProfileWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Boolean headOfficeSame = command.parameterExists("headOfficeSameAsRegistered") + ? command.booleanObjectValueOfParameterNamed("headOfficeSameAsRegistered") : Boolean.TRUE; + final Boolean communicationSame = command.parameterExists("communicationSameAsRegistered") + ? command.booleanObjectValueOfParameterNamed("communicationSameAsRegistered") : Boolean.TRUE; + + final CommandProcessingResult result = this.institutionProfileWritePlatformService.updateInstitutionProfile( + command.stringValueOfParameterNamed("bankName"), command.stringValueOfParameterNamed("legalName"), + command.stringValueOfParameterNamed("shortName"), command.stringValueOfParameterNamed("bankCode"), + command.stringValueOfParameterNamed("institutionCode"), command.stringValueOfParameterNamed("bankType"), + command.stringValueOfParameterNamed("ownershipType"), + command.stringValueOfParameterNamed("parentInstitutionName"), + command.stringValueOfParameterNamed("registrationNumber"), + command.stringValueOfParameterNamed("licenseNumber"), command.stringValueOfParameterNamed("establishedDate"), + command.stringValueOfParameterNamed("status"), command.stringValueOfParameterNamed("country"), + command.stringValueOfParameterNamed("regulatoryAuthority"), + command.stringValueOfParameterNamed("registrationDate"), + command.stringValueOfParameterNamed("licenseStatus"), + command.stringValueOfParameterNamed("licenseExpiryDate"), command.stringValueOfParameterNamed("swiftBic"), + command.stringValueOfParameterNamed("addressLine1"), command.stringValueOfParameterNamed("addressLine2"), + command.stringValueOfParameterNamed("city"), command.stringValueOfParameterNamed("district"), + command.stringValueOfParameterNamed("state"), command.stringValueOfParameterNamed("pincode"), headOfficeSame, + command.stringValueOfParameterNamed("headOfficeAddress"), communicationSame, + command.stringValueOfParameterNamed("communicationAddress"), + command.stringValueOfParameterNamed("contactPhone"), command.stringValueOfParameterNamed("contactEmail"), + command.stringValueOfParameterNamed("customerCareNumber"), + command.stringValueOfParameterNamed("grievanceEmail"), command.stringValueOfParameterNamed("website"), + command.stringValueOfParameterNamed("pan"), command.stringValueOfParameterNamed("tan"), + command.stringValueOfParameterNamed("gstin"), command.stringValueOfParameterNamed("statementHeaderName"), + command.stringValueOfParameterNamed("passbookHeaderName"), + command.stringValueOfParameterNamed("receiptHeaderName"), + command.stringValueOfParameterNamed("termsAndConditions"), + command.stringValueOfParameterNamed("authorizedPersonName"), + command.stringValueOfParameterNamed("authorizedPersonDesignation"), + command.stringValueOfParameterNamed("authorizedPersonContact"), + command.stringValueOfParameterNamed("authorizedPersonEmail")); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java new file mode 100644 index 000000000..f80ff95a3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformService.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import java.math.BigDecimal; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface BranchProfileWritePlatformService { + + CommandProcessingResult updateBranchProfile(Long officeId, String branchCode, String branchType, String address, + String pincode, String contactPhone, String contactEmail, String workingHoursStart, String workingHoursEnd, + String workingDays, String ifscCode, String micrCode, BigDecimal latitude, BigDecimal longitude); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java new file mode 100644 index 000000000..386b9b61a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/BranchProfileWritePlatformServiceImpl.java @@ -0,0 +1,103 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code BranchProfileApiResource.update()} so the write can be invoked from a + * {@code NewCommandSourceHandler} for Maker-Checker support. The resource's own {@code list()} (read) + * method is untouched. + */ +@Service +public class BranchProfileWritePlatformServiceImpl implements BranchProfileWritePlatformService { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Autowired + public BranchProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) { + this.jdbcTemplate = jdbcTemplate; + this.context = context; + } + + @Transactional + @Override + public CommandProcessingResult updateBranchProfile(final Long officeId, final String branchCode, final String branchType, + final String address, final String pincode, final String contactPhone, final String contactEmail, + final String workingHoursStart, final String workingHoursEnd, final String workingDays, final String ifscCode, + final String micrCode, final BigDecimal latitude, final BigDecimal longitude) { + + final Integer officeExists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ?", Integer.class, + officeId); + if (officeExists == null || officeExists == 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.office.not.found", + "Office " + officeId + " was not found."); + } + + if (branchCode != null && !branchCode.isBlank()) { + final Integer dupe = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_office_profile WHERE branch_code = ? AND office_id <> ?", Integer.class, + branchCode, officeId); + if (dupe != null && dupe > 0) { + throw new PlatformDataIntegrityException("error.msg.branchprofile.code.duplicate", + "Branch code '" + branchCode + "' is already used by another branch."); + } + } + + final AppUser currentUser = this.context.authenticatedUser(); + final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office_profile WHERE office_id = ?", + Integer.class, officeId); + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_office_profile + SET branch_code = ?, branch_type = ?, address = ?, pincode = ?, contact_phone = ?, contact_email = ?, + working_hours_start = ?, working_hours_end = ?, working_days = ?, + ifsc_code = ?, micr_code = ?, latitude = ?, longitude = ?, + updated_by_username = ?, updated_at = ? + WHERE office_id = ? + """, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now()), officeId); + } else { + jdbcTemplate.update(""" + INSERT INTO m_office_profile + (office_id, branch_code, branch_type, address, pincode, contact_phone, contact_email, + working_hours_start, working_hours_end, working_days, ifsc_code, micr_code, + latitude, longitude, updated_by_username, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, officeId, branchCode, branchType, address, pincode, contactPhone, contactEmail, workingHoursStart, + workingHoursEnd, workingDays, ifscCode, micrCode, latitude, longitude, currentUser.getUsername(), + Timestamp.from(Instant.now())); + } + + return new CommandProcessingResultBuilder().withEntityId(officeId).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java new file mode 100644 index 000000000..4a544c5c2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformService.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface InstitutionProfileWritePlatformService { + + CommandProcessingResult updateInstitutionProfile(String bankName, String legalName, String shortName, String bankCode, + String institutionCode, String bankType, String ownershipType, String parentInstitutionName, + String registrationNumber, String licenseNumber, String establishedDate, String status, String country, + String regulatoryAuthority, String registrationDate, String licenseStatus, String licenseExpiryDate, + String swiftBic, String addressLine1, String addressLine2, String city, String district, String state, + String pincode, Boolean headOfficeSameAsRegistered, String headOfficeAddress, + Boolean communicationSameAsRegistered, String communicationAddress, String contactPhone, String contactEmail, + String customerCareNumber, String grievanceEmail, String website, String pan, String tan, String gstin, + String statementHeaderName, String passbookHeaderName, String receiptHeaderName, String termsAndConditions, + String authorizedPersonName, String authorizedPersonDesignation, String authorizedPersonContact, + String authorizedPersonEmail); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java new file mode 100644 index 000000000..44b1209bf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/orgsetup/service/InstitutionProfileWritePlatformServiceImpl.java @@ -0,0 +1,134 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.orgsetup.service; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code InstitutionProfileApiResource.update()} so the write can be invoked + * from a {@code NewCommandSourceHandler} for Maker-Checker support. {@code ensureRowExists()}, + * {@code parseDate()} and {@code toSqlDate()} are copies of the resource's own private helpers rather + * than shared ones - the resource's untouched {@code get()} method keeps its own copies so this change + * does not have to touch that unrelated read path at all. + */ +@Service +public class InstitutionProfileWritePlatformServiceImpl implements InstitutionProfileWritePlatformService { + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + + @Autowired + public InstitutionProfileWritePlatformServiceImpl(final JdbcTemplate jdbcTemplate, final PlatformSecurityContext context) { + this.jdbcTemplate = jdbcTemplate; + this.context = context; + } + + @Transactional + @Override + public CommandProcessingResult updateInstitutionProfile(final String bankName, final String legalName, + final String shortName, final String bankCode, final String institutionCode, final String bankType, + final String ownershipType, final String parentInstitutionName, final String registrationNumber, + final String licenseNumber, final String establishedDate, final String status, final String country, + final String regulatoryAuthority, final String registrationDate, final String licenseStatus, + final String licenseExpiryDate, final String swiftBic, final String addressLine1, final String addressLine2, + final String city, final String district, final String state, final String pincode, + final Boolean headOfficeSameAsRegistered, final String headOfficeAddress, + final Boolean communicationSameAsRegistered, final String communicationAddress, final String contactPhone, + final String contactEmail, final String customerCareNumber, final String grievanceEmail, final String website, + final String pan, final String tan, final String gstin, final String statementHeaderName, + final String passbookHeaderName, final String receiptHeaderName, final String termsAndConditions, + final String authorizedPersonName, final String authorizedPersonDesignation, final String authorizedPersonContact, + final String authorizedPersonEmail) { + + ensureRowExists(); + + // A status flip is stamped automatically the first time it happens, rather than asking the + // user to also remember to set a date — the previous row's own status is what "the first + // time" is judged against. + final String previousStatus = jdbcTemplate.queryForObject("SELECT status FROM m_institution_profile WHERE id = 1", + String.class); + final boolean turningActive = "ACTIVE".equalsIgnoreCase(status) && !"ACTIVE".equalsIgnoreCase(previousStatus); + final boolean turningInactive = "INACTIVE".equalsIgnoreCase(status) && !"INACTIVE".equalsIgnoreCase(previousStatus); + final LocalDate today = LocalDate.now(); + + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + UPDATE m_institution_profile + SET bank_name = ?, legal_name = ?, short_name = ?, bank_code = ?, institution_code = ?, + bank_type = ?, ownership_type = ?, parent_institution_name = ?, + registration_number = ?, license_number = ?, established_date = ?, status = ?, country = ?, + regulatory_authority = ?, registration_date = ?, license_status = ?, license_expiry_date = ?, + swift_bic = ?, + address_line1 = ?, address_line2 = ?, city = ?, district = ?, state = ?, pincode = ?, + head_office_same_as_registered = ?, head_office_address = ?, + communication_same_as_registered = ?, communication_address = ?, + contact_phone = ?, contact_email = ?, customer_care_number = ?, grievance_email = ?, + website = ?, pan = ?, tan = ?, gstin = ?, + statement_header_name = ?, passbook_header_name = ?, receipt_header_name = ?, + terms_and_conditions = ?, + authorized_person_name = ?, authorized_person_designation = ?, authorized_person_contact = ?, + authorized_person_email = ?, + activation_date = CASE WHEN ? THEN ? ELSE activation_date END, + deactivation_date = CASE WHEN ? THEN ? ELSE deactivation_date END, + updated_by_username = ?, updated_at = ? + WHERE id = 1 + """, bankName, legalName, shortName, bankCode, institutionCode, bankType, ownershipType, + parentInstitutionName, registrationNumber, licenseNumber, toSqlDate(parseDate(establishedDate)), status, country, + regulatoryAuthority, toSqlDate(parseDate(registrationDate)), licenseStatus, toSqlDate(parseDate(licenseExpiryDate)), + swiftBic, addressLine1, addressLine2, city, district, state, pincode, headOfficeSameAsRegistered, + headOfficeAddress, communicationSameAsRegistered, communicationAddress, contactPhone, contactEmail, + customerCareNumber, grievanceEmail, website, pan, tan, gstin, statementHeaderName, passbookHeaderName, + receiptHeaderName, termsAndConditions, authorizedPersonName, authorizedPersonDesignation, + authorizedPersonContact, authorizedPersonEmail, turningActive, toSqlDate(today), turningInactive, + toSqlDate(today), currentUser.getUsername(), Timestamp.from(Instant.now())); + + return new CommandProcessingResultBuilder().withEntityId(1L).build(); + } + + private void ensureRowExists() { + final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_institution_profile WHERE id = 1", + Integer.class); + if (count == null || count == 0) { + final AppUser currentUser = this.context.authenticatedUser(); + jdbcTemplate.update(""" + INSERT INTO m_institution_profile (id, status, created_by_username, created_at) + VALUES (1, 'ACTIVE', ?, ?) + """, currentUser.getUsername(), Timestamp.from(Instant.now())); + } + } + + private LocalDate parseDate(final String raw) { + return (raw != null && !raw.isBlank()) ? LocalDate.parse(raw) : null; + } + + private Date toSqlDate(final LocalDate d) { + return d == null ? null : Date.valueOf(d); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java new file mode 100644 index 000000000..8e297fafb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/ossreturn/api/OssReturnApiResource.java @@ -0,0 +1,850 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.ossreturn.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Live computation of the RBI off-site surveillance returns OSS 1 (RAL) and OSS 2 (RoE). + * + *

What these returns are

+ * + * OSS 1 is the bank's balance sheet restated on RBI's own line structure; OSS 2 is its profit and + * loss account on the same footing. Both are quarterly, both are denominated in thousands of + * rupees, and both are filed through RBI's CIMS portal. + * + *

Where every figure comes from

+ * + * Nothing here is a constant. Each return line names a role; the GL accounts pinned to that role + * decide the figure, and the ledger decides its value: + * + *
    + *
  • Balance-sheet roles (OSS 1) take the closing balance as at the quarter end - every + * journal entry on or before that date, on the account's own natural side.
  • + *
  • Earnings roles (OSS 2) take the movement over the quarter, because an income or + * expense line reports what was earned or spent in the period, not a balance carried forward.
  • + *
  • Counts (depositors, borrowers, members) come from the portfolio tables, since a + * headcount has no ledger balance to read.
  • + *
+ * + * The bank's own identity - name, RBI bank code, regional office, signatories - is read from + * {@code m_institution_profile}, the single place the institution's particulars are maintained. + * Nothing about the bank is written into this file. + * + *

Blank is not zero

+ * + * A role with no accounts pinned to it reports {@code null} and says so in + * {@code unmetRequirements}. It never reports 0.00. To RBI a nil figure is an assertion that the + * bank holds nothing under that head; an unmapped role is an assertion about nothing at all, and + * the two must not be filed as though they were the same statement. + */ +@Path("/v1/ossreturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class OssReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN"; + + /** Returns are denominated in thousands of rupees; the ledger is in rupees. */ + private static final BigDecimal RETURN_SCALE = BigDecimal.valueOf(1000); + + /** + * acc_gl_account.classification_enum. Only the debit-natured classifications are named, since + * they are what decides which way round a balance is read; everything else is credit-natured. + */ + private static final int ASSET = 1; + private static final int INCOME = 4; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + /** + * Pseudo-role for the year's own result. Not a configurable role: it is derived from whatever + * income and expense accounts exist, so it cannot be left unmapped and cannot be mapped wrong. + */ + private static final String CURRENT_YEAR_RESULT = "__CURRENT_YEAR_RESULT__"; + + /** m_savings_account.status_enum for an active account. */ + private static final int SAVINGS_ACTIVE = 300; + + /** m_loan.loan_status_id for an active loan. */ + private static final int LOAN_ACTIVE = 300; + + /** + * m_savings_account.deposit_type_enum, per DepositAccountType. RBI's "Term Deposits" head + * covers both fixed and recurring deposits, so the two are counted together there. + */ + private static final int SAVINGS_DEPOSIT = 100; + private static final List TERM_DEPOSIT_TYPES = List.of(200, 300); + + /** How RBI prints dates on the General Information sheet. */ + private static final DateTimeFormatter RBI_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + // ─────────────────────────── OSS 1 — RAL ─────────────────────────── + + /** + * OSS 1 - Return on Assets and Liabilities, return code R070. + * + * @param quarterEndDateParam + * any date in the quarter being reported; the quarter end is derived from it, so a + * caller never has to know which day a quarter ends on. Defaults to the current + * business date. + */ + @GET + @Path("oss1") + public String oss1(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R070", "Return on Assets and Liabilities (RAL)", quarter, + officeId); + response.put("basis", Map.of("asOnDate", quarter.end.toString(), "note", + "Balance-sheet figures are the closing position as at " + quarter.end + + "; every journal entry on or before that date is included.")); + + // Liabilities and assets, in the order the RBI workbook prints them. Each line names the + // role whose mapped accounts produce it; a line with no role of its own is a subtotal and + // is summed from the lines it covers, never mapped separately. + final List> liabilities = new ArrayList<>(); + final RoleFigures figures = new RoleFigures(quarter, officeId); + + liabilities.add(balanceLine("1. Paid-up Capital", "OSS1_PAID_UP_CAPITAL", figures)); + liabilities.add(balanceLine("2. Reserve Fund and other Reserves", "OSS1_RESERVES", figures)); + liabilities.add(balanceLine("3. Profit (+) / Loss (-)", "OSS1_PROFIT_LOSS", figures)); + + // The year's own result, which the ledger has not appropriated to reserves yet. RBI prints + // it as its own head for exactly that reason: between appropriations the balance sheet is + // short by what the bank has earned or lost so far, and without this line the return would + // not balance even though the books do. Derived from the income and expense accounts rather + // than mapped, since it is not a balance any account carries. + final BigDecimal currentYearResult = currentYearResult(quarter, officeId); + liabilities.add(derivedLine( + currentYearResult.signum() >= 0 ? "3 iii. Operating Surplus in Current year" + : "3 iv. Operating Deficit in Current year", + CURRENT_YEAR_RESULT, currentYearResult)); + + liabilities.add(subtotal("4. Total Capital And Reserves", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT)); + liabilities.add(balanceLine("5. Subordinated Debt (Tier II Capital)", "OSS1_SUBORDINATED_DEBT", figures)); + liabilities.add(balanceLine("6. Deposits and other Accounts", "OSS1_DEPOSITS", figures)); + liabilities.add(countLine("Total No of Depositors", countDepositors(officeId, quarter.end, null))); + liabilities.add(balanceLine("i. Term Deposits (including recurring deposits)", "OSS1_DEPOSITS_TERM", figures)); + liabilities.add( + countLine("No. of Term Depositors", countDepositors(officeId, quarter.end, TERM_DEPOSIT_TYPES))); + liabilities.add(balanceLine("ii. Savings Bank Deposits", "OSS1_DEPOSITS_SAVINGS", figures)); + liabilities.add(countLine("No of Savings Depositors", + countDepositors(officeId, quarter.end, List.of(SAVINGS_DEPOSIT)))); + liabilities.add(balanceLine("iii. Current Deposits", "OSS1_DEPOSITS_CURRENT", figures)); + liabilities.add(balanceLine("7. Borrowings", "OSS1_BORROWINGS", figures)); + liabilities.add(balanceLine("8. Other Liabilities and Provisions", "OSS1_OTHER_LIABILITIES", figures)); + // The deposit sub-heads are a breakdown of OSS1_DEPOSITS, not additions to it, so the total + // names the head and leaves them out rather than counting the same money twice. + liabilities.add(subtotal("15. Total Liabilities", liabilities, "OSS1_PAID_UP_CAPITAL", "OSS1_RESERVES", + "OSS1_PROFIT_LOSS", CURRENT_YEAR_RESULT, "OSS1_SUBORDINATED_DEBT", "OSS1_DEPOSITS", "OSS1_BORROWINGS", + "OSS1_OTHER_LIABILITIES")); + + final List> assets = new ArrayList<>(); + assets.add(balanceLine("1. Cash in Hand", "OSS1_CASH_IN_HAND", figures)); + assets.add(balanceLine("2. Balances with Banks", "OSS1_BALANCE_WITH_BANKS", figures)); + assets.add(balanceLine("3. Investments", "OSS1_INVESTMENTS", figures)); + assets.add(balanceLine("4. Loans and Advances", "OSS1_LOANS_ADVANCES", figures)); + assets.add(countLine("Total No of Borrowers", borrowerCount(officeId, quarter.end))); + assets.add(balanceLine("5. Fixed Assets", "OSS1_FIXED_ASSETS", figures)); + assets.add(balanceLine("6. Other Assets", "OSS1_OTHER_ASSETS", figures)); + assets.add(subtotal("12. Total Assets", assets, "OSS1_CASH_IN_HAND", "OSS1_BALANCE_WITH_BANKS", + "OSS1_INVESTMENTS", "OSS1_LOANS_ADVANCES", "OSS1_FIXED_ASSETS", "OSS1_OTHER_ASSETS")); + + response.put("liabilities", liabilities); + response.put("assets", assets); + + // Liabilities must equal assets. Reported rather than asserted: an imbalance is a real + // condition of the books that the filer has to see, not something to hide behind an error. + final BigDecimal totalLiabilities = amountOf(liabilities, "15. Total Liabilities"); + final BigDecimal totalAssets = amountOf(assets, "12. Total Assets"); + response.put("totalLiabilities", totalLiabilities); + response.put("totalAssets", totalAssets); + response.put("balanced", totalLiabilities.compareTo(totalAssets) == 0); + response.put("imbalance", totalAssets.subtract(totalLiabilities)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── OSS 2 — RoE ─────────────────────────── + + /** + * OSS 2 - Return on Earnings, return code R071. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the + * period matters here in a way it does not on OSS 1: the same account read as a balance would + * carry forward every earlier quarter's income as well. + */ + @GET + @Path("oss2") + public String oss2(@QueryParam("quarterEndDate") final String quarterEndDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = parseOrBusinessDate(quarterEndDateParam); + final Quarter quarter = Quarter.containing(requested); + + final Map response = baseResponse("R071", "Return on Earnings (RoE)", quarter, officeId); + response.put("basis", Map.of("fromDate", quarter.start.toString(), "toDate", quarter.end.toString(), "note", + "Earnings are the movement over " + quarter.start + " to " + quarter.end + + ", not a closing balance: an income or expense line reports what was earned or spent " + + "in the quarter.")); + + final RoleFigures figures = new RoleFigures(quarter, officeId); + + final List> earnings = new ArrayList<>(); + earnings.add(movementLine("2. Interest/discount received on loans and advances", "OSS2_INTEREST_ON_ADVANCES", + figures)); + earnings.add(movementLine("3. Interest on market lending (to banks)", "OSS2_INTEREST_ON_MARKET_LENDING", + figures)); + earnings.add(movementLine("4. Interest on investments", "OSS2_INTEREST_ON_INVESTMENTS", figures)); + earnings.add(movementLine("5. Interest on balances with RBI and inter-bank deposits", + "OSS2_INTEREST_ON_BANK_BALANCES", figures)); + earnings.add(subtotal("1. Interest/discount received (2+3+4+5)", earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES")); + earnings.add(movementLine("6. Commission, Exchange and Brokerage", "OSS2_COMMISSION_EXCHANGE_BROKERAGE", + figures)); + earnings.add(movementLine("7. Profit on sale of investments", "OSS2_PROFIT_ON_INVESTMENTS", figures)); + earnings.add(movementLine("8. Other income", "OSS2_OTHER_INCOME", figures)); + + final List> expenses = new ArrayList<>(); + expenses.add(movementLine("10. Interest paid on deposits", "OSS2_INTEREST_PAID_ON_DEPOSITS", figures)); + expenses.add(movementLine("11. Interest paid on borrowings", "OSS2_INTEREST_PAID_ON_BORROWINGS", figures)); + expenses.add(movementLine("12. Salaries, allowances and provident fund", "OSS2_STAFF_COST", figures)); + expenses.add(movementLine("13. Rent, taxes, insurance and lighting", "OSS2_RENT_TAXES_INSURANCE", figures)); + expenses.add(movementLine("14. Depreciation on bank's property", "OSS2_DEPRECIATION", figures)); + expenses.add(movementLine("15. Provisions and contingencies", "OSS2_PROVISIONS", figures)); + expenses.add(movementLine("16. Other operating expenses", "OSS2_OTHER_OPERATING_EXPENSES", figures)); + + response.put("earnings", earnings); + response.put("expenses", expenses); + + // Item 1 is the sum of items 2 to 5, so total income counts the four components and the + // standalone heads - never item 1 as well, which would count that interest twice. + final BigDecimal totalIncome = sumOfRoles(earnings, "OSS2_INTEREST_ON_ADVANCES", + "OSS2_INTEREST_ON_MARKET_LENDING", "OSS2_INTEREST_ON_INVESTMENTS", "OSS2_INTEREST_ON_BANK_BALANCES", + "OSS2_COMMISSION_EXCHANGE_BROKERAGE", "OSS2_PROFIT_ON_INVESTMENTS", "OSS2_OTHER_INCOME"); + final BigDecimal totalExpenditure = sumOfRoles(expenses, "OSS2_INTEREST_PAID_ON_DEPOSITS", + "OSS2_INTEREST_PAID_ON_BORROWINGS", "OSS2_STAFF_COST", "OSS2_RENT_TAXES_INSURANCE", + "OSS2_DEPRECIATION", "OSS2_PROVISIONS", "OSS2_OTHER_OPERATING_EXPENSES"); + response.put("totalIncome", totalIncome); + response.put("totalExpenditure", totalExpenditure); + response.put("operatingProfit", totalIncome.subtract(totalExpenditure)); + + response.put("unmetRequirements", figures.unmet()); + response.put("computable", figures.anyConfigured()); + return gson.toJson(response); + } + + // ─────────────────────────── shared response scaffolding ─────────────────────────── + + /** + * The General Information block every OSS return carries, built from the institution's own + * particulars so OSS 1 and OSS 2 can never state a different bank from one another. + */ + private Map baseResponse(final String returnCode, final String returnName, final Quarter quarter, + final Long officeId) { + final Map response = new LinkedHashMap<>(); + response.put("returnCode", returnCode); + response.put("returnName", returnName); + response.put("reportingFrequency", "Quarterly"); + + final Map profile = institutionProfile(); + final List> generalInfo = new ArrayList<>(); + generalInfo.add(info("Return Name", returnName)); + generalInfo.add(info("Return Code", returnCode)); + generalInfo.add(info("Bank Name", text(profile.get("bank_name")))); + generalInfo.add(info("Bank Code", text(profile.get("bank_code")))); + generalInfo.add(info("RO Name", text(profile.get("city")))); + generalInfo.add(info("Institution Type", text(profile.get("bank_type")))); + generalInfo.add(info("Reporting Frequency", "Quarterly")); + generalInfo.add(info("Start Date", quarter.start.format(RBI_DATE))); + generalInfo.add(info("Quarter End Date", quarter.end.format(RBI_DATE))); + response.put("generalInfo", generalInfo); + + response.put("quarter", Map.of("label", quarter.label, "startDate", quarter.start.toString(), "endDate", + quarter.end.toString())); + response.put("officeId", officeId); + response.put("officeName", officeName(officeId)); + response.put("signatories", signatories(profile, quarter)); + return response; + } + + /** + * The institution's particulars. Absent columns are tolerated rather than fatal: the return + * names what is missing on the General Information sheet, which is more useful than refusing to + * compute figures that are themselves correct. + */ + private Map institutionProfile() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT bank_name, bank_code, bank_type, city, state, + authorized_person_name, authorized_person_designation, authorized_person_email, + contact_phone + FROM m_institution_profile WHERE id = 1 + """); + return rows.isEmpty() ? Map.of() : rows.get(0); + } + + /** + * The signatory block, taken from the institution's authorised person. Dated to the quarter + * end rather than to today, so a return reprinted later still reads as filed for its own + * period. + */ + private List> signatories(final Map profile, final Quarter quarter) { + final Map signatory = new LinkedHashMap<>(); + signatory.put("heading", "Authorised Reporting Official"); + signatory.put("name", text(profile.get("authorized_person_name"))); + signatory.put("designation", text(profile.get("authorized_person_designation"))); + signatory.put("email", text(profile.get("authorized_person_email"))); + signatory.put("telephoneOffice", text(profile.get("contact_phone"))); + signatory.put("place", text(profile.get("city"))); + signatory.put("date", quarter.end.format(RBI_DATE)); + return List.of(signatory); + } + + private String officeName(final Long officeId) { + if (officeId == null) { + return "All offices (consolidated)"; + } + final List names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, + officeId); + return names.isEmpty() ? "Office " + officeId : names.get(0); + } + + private static Map info(final String label, final String value) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + // An unset particular is reported as such rather than as an empty cell, so a reviewer can + // see that it needs maintaining in the institution profile. + out.put("value", value.isBlank() ? "Not set in Institution Profile" : value); + out.put("configured", !value.isBlank()); + return out; + } + + private static String text(final Object value) { + return value == null ? "" : value.toString().trim(); + } + + // ─────────────────────────── the quarter ─────────────────────────── + + /** The reporting quarter a date falls in. RBI quarters run Apr-Jun, Jul-Sep, Oct-Dec, Jan-Mar. */ + private static final class Quarter { + + final LocalDate start; + final LocalDate end; + final String label; + + private Quarter(final LocalDate start, final LocalDate end, final String label) { + this.start = start; + this.end = end; + this.label = label; + } + + static Quarter containing(final LocalDate date) { + final int month = date.getMonthValue(); + final int startMonth = ((month - 1) / 3) * 3 + 1; + final LocalDate start = date.withMonth(startMonth).withDayOfMonth(1); + final LocalDate end = start.plusMonths(2).with(TemporalAdjusters.lastDayOfMonth()); + return new Quarter(start, end, "Q" + ((startMonth - 1) / 3 + 1) + " " + start.getYear()); + } + } + + // ─────────────────────────── figures from the ledger ─────────────────────────── + + /** + * Resolves return lines to ledger figures, remembering which roles were unmapped or partly + * unusable so the return can report them once at the end. + */ + private final class RoleFigures { + + private final Quarter quarter; + private final Long officeId; + private final List reasons = new ArrayList<>(); + private boolean anyConfigured; + + RoleFigures(final Quarter quarter, final Long officeId) { + this.quarter = quarter; + this.officeId = officeId; + } + + /** Closing balance as at the quarter end - for the balance-sheet lines of OSS 1. */ + RoleFigure closing(final String roleKey) { + return figureFor(roleKey, null, quarter.end, officeId, this); + } + + /** Movement over the quarter - for the earnings lines of OSS 2. */ + RoleFigure movement(final String roleKey) { + return figureFor(roleKey, quarter.start, quarter.end, officeId, this); + } + + /** + * Named by the line as the return prints it rather than by the role key: the reader is + * looking at a return, and "Investments" is what they will look for on the configuration + * screen, not OSS1_INVESTMENTS. + */ + void noteUnmapped(final String roleKey) { + reasons.add(lineName(roleKey) + " — no GL accounts mapped, so this line reports nothing."); + } + + void noteExcluded(final String roleKey, final List excluded) { + reasons.add(lineName(roleKey) + " — leaves out " + String.join(", ", excluded) + + ", mapped but no longer postable. The figure shown is short by them."); + } + + void noteConfigured() { + anyConfigured = true; + } + + boolean anyConfigured() { + return anyConfigured; + } + + List unmet() { + return reasons; + } + } + + /** A role's figure, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String roleKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + final List excluded = new ArrayList<>(); + + RoleFigure(final String roleKey) { + this.roleKey = roleKey; + } + } + + /** + * Sums the GL accounts pinned to a role. + * + * With {@code fromDate} null the figure is the closing balance as at {@code toDate}; with a + * from-date it is the movement between the two, which is what an earnings line reports. + * + * Every pinned account is read back, usable or not. Filtering the unusable ones out in SQL + * would shrink the figure with nothing on the return to say an account had dropped out, so + * they are carried through and named instead. + */ + private RoleFigure figureFor(final String roleKey, final LocalDate fromDate, final LocalDate toDate, + final Long officeId, final RoleFigures owner) { + final RoleFigure figure = new RoleFigure(roleKey); + + final List> accounts = jdbcTemplate.queryForList(""" + SELECT a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code + """, roleKey); + if (accounts.isEmpty()) { + owner.noteUnmapped(roleKey); + return figure; + } + figure.configured = true; + owner.noteConfigured(); + + for (final Map account : accounts) { + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = text(account.get("gl_code")); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into + // a header whose balance is the sum of its children and would double-count them. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded + .add(glCode + " — " + text(account.get("name")) + (disabled ? " (disabled)" : " (header)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", text(account.get("name"))); + skipped.put("balance", null); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final BigDecimal balance = balanceOf(accountId, classification, fromDate, toDate, officeId); + figure.amount = figure.amount.add(balance); + + final Map detail = new LinkedHashMap<>(); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", text(account.get("name"))); + detail.put("balance", scaled(balance)); + figure.accounts.add(detail); + } + + if (!figure.excluded.isEmpty()) { + owner.noteExcluded(roleKey, figure.excluded); + } + return figure; + } + + /** + * One account's ledger figure, on its own natural side so a liability reports positive rather + * than as a negative asset. + * + * Reversed entries and the reversal entries that cancel them are both excluded, so a reversal + * nets to nothing rather than to twice the original. + */ + private BigDecimal balanceOf(final Long accountId, final int classification, final LocalDate fromDate, + final LocalDate toDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_journal_entry + WHERE account_id = ? AND entry_date <= ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(); + args.add(accountId); + args.add(java.sql.Date.valueOf(toDate)); + if (fromDate != null) { + sql.append(" AND entry_date >= ?"); + args.add(java.sql.Date.valueOf(fromDate)); + } + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = toDecimal(row.get("total_debit")); + final BigDecimal credit = toDecimal(row.get("total_credit")); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + return debitNatured ? debit.subtract(credit) : credit.subtract(debit); + } + + /** + * A role's name as the return prints it, for the caveats. Falls back to the key so a role + * added without a name here degrades to something readable rather than to nothing. + */ + private static String lineName(final String roleKey) { + return LINE_NAMES.getOrDefault(roleKey, roleKey); + } + + private static final Map LINE_NAMES = new LinkedHashMap<>(); + static { + LINE_NAMES.put("OSS1_PAID_UP_CAPITAL", "Paid-up Capital"); + LINE_NAMES.put("OSS1_RESERVES", "Reserve Fund and other Reserves"); + LINE_NAMES.put("OSS1_PROFIT_LOSS", "Profit / Loss"); + LINE_NAMES.put("OSS1_SUBORDINATED_DEBT", "Subordinated Debt (Tier II Capital)"); + LINE_NAMES.put("OSS1_DEPOSITS", "Deposits and other Accounts"); + LINE_NAMES.put("OSS1_DEPOSITS_TERM", "Term Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_SAVINGS", "Savings Bank Deposits"); + LINE_NAMES.put("OSS1_DEPOSITS_CURRENT", "Current Deposits"); + LINE_NAMES.put("OSS1_BORROWINGS", "Borrowings"); + LINE_NAMES.put("OSS1_OTHER_LIABILITIES", "Other Liabilities and Provisions"); + LINE_NAMES.put("OSS1_CASH_IN_HAND", "Cash in Hand"); + LINE_NAMES.put("OSS1_BALANCE_WITH_BANKS", "Balances with Banks"); + LINE_NAMES.put("OSS1_INVESTMENTS", "Investments"); + LINE_NAMES.put("OSS1_LOANS_ADVANCES", "Loans and Advances"); + LINE_NAMES.put("OSS1_FIXED_ASSETS", "Fixed Assets"); + LINE_NAMES.put("OSS1_OTHER_ASSETS", "Other Assets"); + + LINE_NAMES.put("OSS2_INTEREST_ON_ADVANCES", "Interest on loans and advances"); + LINE_NAMES.put("OSS2_INTEREST_ON_MARKET_LENDING", "Interest on market lending"); + LINE_NAMES.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest on investments"); + LINE_NAMES.put("OSS2_INTEREST_ON_BANK_BALANCES", "Interest on bank balances"); + LINE_NAMES.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "Commission, Exchange and Brokerage"); + LINE_NAMES.put("OSS2_PROFIT_ON_INVESTMENTS", "Profit on sale of investments"); + LINE_NAMES.put("OSS2_OTHER_INCOME", "Other income"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid on deposits"); + LINE_NAMES.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings"); + LINE_NAMES.put("OSS2_STAFF_COST", "Salaries, allowances and provident fund"); + LINE_NAMES.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting"); + LINE_NAMES.put("OSS2_DEPRECIATION", "Depreciation"); + LINE_NAMES.put("OSS2_PROVISIONS", "Provisions and contingencies"); + LINE_NAMES.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses"); + } + + /** JDBC returns SUM() as whichever numeric type the driver picks; normalise it. */ + private static BigDecimal toDecimal(final Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()); + } + + /** Rupees to the thousands the return is denominated in. */ + private static BigDecimal scaled(final BigDecimal rupees) { + return rupees.divide(RETURN_SCALE, 2, RoundingMode.HALF_UP); + } + + // ─────────────────────────── headcounts ─────────────────────────── + + /** + * Depositors as at a date - distinct clients holding a deposit account open on that date. + * Counted per client rather than per account, because a customer with three accounts is one + * depositor on the return. + * + * An account is open on the date if it was active by then and had not yet closed. Reading it + * this way, rather than by today's status, is what lets an earlier quarter be reprinted and + * still report the headcount that was true then. + */ + private Long countDepositors(final Long officeId, final LocalDate asOn, final List depositTypes) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT sa.client_id) + FROM m_savings_account sa + JOIN m_client c ON c.id = sa.client_id + WHERE sa.status_enum = ? + AND sa.activatedon_date IS NOT NULL + AND sa.activatedon_date <= ? + AND (sa.closedon_date IS NULL OR sa.closedon_date > ?) + """); + final List args = new ArrayList<>(); + args.add(SAVINGS_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + args.add(java.sql.Date.valueOf(asOn)); + if (depositTypes != null && !depositTypes.isEmpty()) { + sql.append(" AND sa.deposit_type_enum IN (") + .append(String.join(", ", depositTypes.stream().map(t -> "?").toList())).append(")"); + args.addAll(depositTypes); + } + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + /** + * The financial year's result up to the quarter end: income earned less expenses incurred. + * + * Read across every income and expense account rather than the mapped ones, because this line + * exists to close the gap between the balance sheet and the books. A figure limited to mapped + * accounts would leave the return short by whatever was not mapped, which is the very problem + * the line is here to solve. + * + * The year starts on 1 April, as the Indian financial year does. + */ + private BigDecimal currentYearResult(final Quarter quarter, final Long officeId) { + final LocalDate yearStart = financialYearStart(quarter.end); + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN a.classification_enum = ? + THEN CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END + ELSE CASE WHEN je.type_enum = 2 THEN -je.amount ELSE je.amount END + END), 0) AS result + FROM acc_gl_journal_entry je + JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.classification_enum IN (?, ?) + AND je.entry_date >= ? AND je.entry_date <= ? + AND (je.reversed IS NULL OR je.reversed = false) + AND je.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(); + args.add(INCOME); + args.add(INCOME); + args.add(EXPENSE); + args.add(java.sql.Date.valueOf(yearStart)); + args.add(java.sql.Date.valueOf(quarter.end)); + if (officeId != null) { + sql.append(" AND je.office_id = ?"); + args.add(officeId); + } + + final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray()); + return result == null ? BigDecimal.ZERO : result; + } + + /** 1 April of the financial year a date falls in. */ + private static LocalDate financialYearStart(final LocalDate date) { + final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return LocalDate.of(year, 4, 1); + } + + /** Borrowers as at a date - distinct clients with a loan outstanding on that date. */ + private Long borrowerCount(final Long officeId, final LocalDate asOn) { + final StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT l.client_id) + FROM m_loan l + JOIN m_client c ON c.id = l.client_id + WHERE l.loan_status_id = ? + AND l.disbursedon_date IS NOT NULL + AND l.disbursedon_date <= ? + """); + final List args = new ArrayList<>(); + args.add(LOAN_ACTIVE); + args.add(java.sql.Date.valueOf(asOn)); + if (officeId != null) { + sql.append(" AND c.office_id = ?"); + args.add(officeId); + } + final Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, args.toArray()); + return count == null ? 0L : count; + } + + // ─────────────────────────── line builders ─────────────────────────── + + private Map balanceLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.closing(roleKey)); + } + + private Map movementLine(final String label, final String roleKey, final RoleFigures figures) { + return line(label, figures.movement(roleKey)); + } + + private static Map line(final String label, final RoleFigure figure) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", figure.roleKey); + out.put("configured", figure.configured); + // Null rather than zero when unmapped: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? scaled(figure.amount) : null); + out.put("accounts", figure.accounts); + out.put("subtotal", false); + return out; + } + + /** + * A figure the return computes for itself rather than reading from mapped accounts. + * + * It carries a role key so the subtotals can name it like any other component, but that key is + * not a configurable role: nothing can leave it unmapped, and nothing can map it wrong. + */ + private static Map derivedLine(final String label, final String roleKey, + final BigDecimal rupees) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", roleKey); + out.put("configured", true); + out.put("amount", scaled(rupees)); + out.put("accounts", List.of()); + out.put("derived", true); + out.put("subtotal", false); + return out; + } + + private static Map countLine(final String label, final Long count) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", count); + out.put("accounts", List.of()); + out.put("count", true); + out.put("subtotal", false); + return out; + } + + /** + * A derived row, summed from the roles it names. Subtotals are never mapped to accounts of + * their own - a total and its components mapped separately would disagree the moment one side + * changed - and they name their components explicitly rather than summing whatever happens to + * sit above them, so inserting a line cannot silently change a total. + */ + private static Map subtotal(final String label, final List> section, + final String... ofRoles) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("roleKey", null); + out.put("configured", true); + out.put("amount", sumOfRoles(section, ofRoles)); + out.put("accounts", List.of()); + out.put("components", List.of(ofRoles)); + out.put("subtotal", true); + return out; + } + + /** The amount already computed for a labelled row, so a total is never derived twice. */ + private static BigDecimal amountOf(final List> section, final String label) { + for (final Map line : section) { + if (label.equals(line.get("label")) && line.get("amount") instanceof BigDecimal decimal) { + return decimal; + } + } + return BigDecimal.ZERO; + } + + /** + * Sums the named roles out of a section. + * + * A component that is unmapped contributes nothing, which is why the return still reports + * {@code unmetRequirements}: the total is arithmetically right for what is mapped, and short by + * whatever is not. + */ + private static BigDecimal sumOfRoles(final List> section, final String... roles) { + final Set wanted = new LinkedHashSet<>(Arrays.asList(roles)); + BigDecimal total = BigDecimal.ZERO; + for (final Map line : section) { + // Subtotals and headcounts carry no role of their own, so their key is null. Matched by + // the role rather than by a null-tolerant lookup, so a derived row can never be summed + // into the total that derives it. + final Object roleKey = line.get("roleKey"); + if (roleKey == null || !wanted.contains(roleKey.toString())) { + continue; + } + if (line.get("amount") instanceof BigDecimal decimal) { + total = total.add(decimal); + } + } + return total; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java new file mode 100644 index 000000000..184a4f990 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiFormAFilingApiResource.java @@ -0,0 +1,352 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Saved Form A / Form 9 returns: keeping a generated return so it can be read again later exactly as + * it was generated. + * + * A saved return is served from its stored snapshot, never recomputed. Recomputing would quietly + * produce a different statement from the one that was reviewed or filed - a backdated journal entry, + * a changed GL mapping or a corrected rate all move figures that were already reported - and a + * report whose figures change after it was filed is worse than no report at all. + * + * Deliberately not maker-checker: saving a snapshot changes no balance and posts no entry. The + * filing lifecycle (draft, checked, approved, submitted) is what carries the approval, and that is + * recorded on the row's status rather than in a separate command queue. + */ +@Path("/v1/rbiformafiling") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiFormAFilingApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIFORMAFILING"; + + /** + * The filing lifecycle RBI's own timetable implies: a return is drafted, checked and approved + * inside the bank, sent provisionally within a week of the fortnight ending and finally within + * twenty days, and revised if a figure turns out wrong after that. + */ + private static final List STATUSES = List.of("DRAFT", "GENERATED", "CHECKED", "APPROVED", + "PROVISIONAL_SUBMITTED", "FINAL_SUBMITTED", "REVISED"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + /** Saved returns, newest first. The stored snapshot is left out - the list does not need it. */ + @GET + public String retrieveAll(@QueryParam("formCode") final String formCode) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final StringBuilder sql = new StringBuilder(""" + SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end, + f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained, + f.rate_percentage, f.remarks, f.submitted_on, f.created_on, u.username AS created_by_name + FROM m_rbi_form_a_filing f + LEFT JOIN m_appuser u ON u.id = f.created_by + """); + final List args = new ArrayList<>(); + if (formCode != null && !formCode.isBlank()) { + sql.append(" WHERE f.form_code = ?"); + args.add(formCode); + } + sql.append(" ORDER BY f.reporting_date DESC, f.id DESC"); + + final List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + out.add(toSummary(row)); + } + return gson.toJson(out); + } + + /** One saved return, with the snapshot, so the screen can render exactly what was saved. */ + @GET + @Path("{filingId}") + public String retrieveOne(@PathParam("filingId") final Long filingId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT f.id, f.form_code, f.reporting_date, f.fortnight_end, f.period_start, f.period_end, + f.status, f.ndtl_amount, f.required_amount, f.maintained_amount, f.average_maintained, + f.rate_percentage, f.remarks, f.submitted_on, f.created_on, f.report_json, + u.username AS created_by_name + FROM m_rbi_form_a_filing f + LEFT JOIN m_appuser u ON u.id = f.created_by + WHERE f.id = ? + """, filingId); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + final Map row = rows.get(0); + final Map out = toSummary(row); + + // The snapshot is embedded as JSON rather than as a string, so the screen receives the same + // shape the live endpoint returns and can render it through the same code path. + final String json = (String) row.get("report_json"); + out.put("report", json == null || json.isBlank() ? null : gson.fromJson(json, JsonObject.class)); + return gson.toJson(out); + } + + /** + * Saves a generated return. Re-saving the same form for the same reporting date replaces that + * snapshot: regenerating a return for a date already saved is a correction of it, not a second + * statement for the same day. + */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public String create(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String formCode = this.fromJsonHelper.extractStringNamed("formCode", root); + final LocalDate reportingDate = this.fromJsonHelper.extractLocalDateNamed("reportingDate", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + final Long officeId = this.fromJsonHelper.extractLongNamed("officeId", root); + + if (formCode == null || formCode.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.form.code", + "The form being saved must be named."); + } + if (reportingDate == null) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.reporting.date", + "A reporting date is required."); + } + + // The return itself, as the screen received it. Stored verbatim: the point of a saved return + // is that it does not change afterwards, so nothing here re-derives a figure from it. + final JsonObject report = root.getAsJsonObject().getAsJsonObject("report"); + if (report == null) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.no.report", + "The generated return must be supplied with the save."); + } + + // The statutory form states its figures per column inside Part B rather than once at the + // top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be + // read out of it. Without this a filed statutory return listed as a row of dashes, and the + // list could not be scanned for the one return somebody was looking for. + final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull() + && "FORM_1_STATUTORY".equals(report.get("formCode").getAsString()); + + // The statutory form's period runs between its two fortnight closes, which is what the + // saved list prints as the row's period. + final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart"); + final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd"); + // The current fortnight's figures are the second column - the close the form is made up to. + final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount"); + final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount"); + final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained") + : decimal(report, "maintainedAmount"); + final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage"); + BigDecimal average = null; + if (report.has("maintenance") && report.get("maintenance").isJsonObject()) { + average = decimal(report.getAsJsonObject("maintenance"), "average"); + } + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class, + formCode, java.sql.Date.valueOf(reportingDate)); + + if (existing != null && existing > 0) { + jdbcTemplate.update(""" + UPDATE m_rbi_form_a_filing + SET report_json = ?, ndtl_amount = ?, required_amount = ?, maintained_amount = ?, + average_maintained = ?, rate_percentage = ?, remarks = ?, office_id = ?, + period_start = ?, period_end = ?, fortnight_end = ?, + lastmodified_on = now(), lastmodified_by = ? + WHERE form_code = ? AND reporting_date = ? + """, gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId, + sqlDate(periodStart), sqlDate(periodEnd), sqlDate(periodEnd == null ? reportingDate : periodEnd), + this.context.authenticatedUser().getId(), formCode, java.sql.Date.valueOf(reportingDate)); + } else { + jdbcTemplate.update(""" + INSERT INTO m_rbi_form_a_filing + (form_code, reporting_date, fortnight_end, period_start, period_end, status, + report_json, ndtl_amount, required_amount, maintained_amount, average_maintained, + rate_percentage, remarks, office_id, created_on, created_by) + VALUES (?, ?, ?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, ?, ?, now(), ?) + """, formCode, java.sql.Date.valueOf(reportingDate), + sqlDate(periodEnd == null ? reportingDate : periodEnd), sqlDate(periodStart), sqlDate(periodEnd), + gson.toJson(report), ndtl, required, maintained, average, rate, remarks, officeId, + this.context.authenticatedUser().getId()); + } + return retrieveAll(formCode); + } + + /** + * Moves a saved return along its filing lifecycle. The snapshot is untouched: a return that has + * been checked and submitted must still show the figures it was checked on. + */ + @POST + @Path("{filingId}/status") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateStatus(@PathParam("filingId") final Long filingId, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String status = this.fromJsonHelper.extractStringNamed("status", root); + final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root); + + if (status == null || !STATUSES.contains(status)) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.unknown.status", + "Status must be one of " + STATUSES + "."); + } + final Integer exists = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE id = ?", + Integer.class, filingId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + + // The submission date is stamped when a return is actually sent, and only then - an earlier + // stage has nothing to date, and re-stamping on a later stage would lose when it was sent. + final boolean submitted = "PROVISIONAL_SUBMITTED".equals(status) || "FINAL_SUBMITTED".equals(status); + jdbcTemplate.update(""" + UPDATE m_rbi_form_a_filing + SET status = ?, + remarks = COALESCE(?, remarks), + submitted_on = CASE WHEN ? THEN COALESCE(submitted_on, CURRENT_DATE) ELSE submitted_on END, + lastmodified_on = now(), lastmodified_by = ? + WHERE id = ? + """, status, remarks, submitted, this.context.authenticatedUser().getId(), filingId); + return retrieveAll(null); + } + + /** Discards a saved return. Only ever removes the snapshot; nothing in the ledger is touched. */ + @DELETE + @Path("{filingId}") + public String delete(@PathParam("filingId") final Long filingId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final Integer removed = jdbcTemplate.update("DELETE FROM m_rbi_form_a_filing WHERE id = ?", filingId); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.rbiformafiling.not.found", + "Saved return " + filingId + " was not found."); + } + return retrieveAll(null); + } + + private Map toSummary(final Map row) { + final Map out = new LinkedHashMap<>(); + out.put("id", row.get("id")); + out.put("formCode", row.get("form_code")); + out.put("reportingDate", str(row.get("reporting_date"))); + out.put("periodStart", str(row.get("period_start"))); + out.put("periodEnd", str(row.get("period_end"))); + out.put("status", row.get("status")); + out.put("ndtlAmount", row.get("ndtl_amount")); + out.put("requiredAmount", row.get("required_amount")); + out.put("maintainedAmount", row.get("maintained_amount")); + out.put("averageMaintained", row.get("average_maintained")); + out.put("ratePercentage", row.get("rate_percentage")); + out.put("remarks", row.get("remarks")); + out.put("submittedOn", str(row.get("submitted_on"))); + out.put("createdOn", str(row.get("created_on"))); + out.put("createdBy", row.get("created_by_name")); + return out; + } + + private static String str(final Object value) { + return value == null ? null : String.valueOf(value); + } + + private static java.sql.Date sqlDate(final LocalDate date) { + return date == null ? null : java.sql.Date.valueOf(date); + } + + private static BigDecimal decimal(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + return object.get(field).getAsBigDecimal(); + } + + private static LocalDate localDate(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + final String value = object.get(field).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } + + /** + * One of a statutory part's per-column figures, taken at the column the form is made up to. + * + * The form carries three columns and the saved list has one cell per figure, so it takes the + * second - the close the return reports. Index 1 rather than "the last": the third column is + * the base the ratios are charged on, not a later position, and listing it would show a + * requirement against liabilities from a fortnight nobody filed. + */ + private static BigDecimal partFigure(final JsonObject report, final String part, final String field) { + if (!report.has(part) || !report.get(part).isJsonObject()) { + return null; + } + final JsonObject partObject = report.getAsJsonObject(part); + if (!partObject.has(field) || !partObject.get(field).isJsonArray()) { + return null; + } + final var values = partObject.getAsJsonArray(field); + if (values.size() < 2 || values.get(1).isJsonNull()) { + return null; + } + return values.get(1).getAsBigDecimal(); + } + + /** One of the statutory form's column dates, for the period a saved row reports. */ + private static LocalDate columnDate(final JsonObject report, final int index) { + if (!report.has("columns") || !report.get("columns").isJsonArray()) { + return null; + } + final var columns = report.getAsJsonArray("columns"); + if (columns.size() <= index || columns.get(index).isJsonNull()) { + return null; + } + final String value = columns.get(index).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java new file mode 100644 index 000000000..afa3a01a9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -0,0 +1,725 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for RBI Form 1 (CRR) and Form 9 (SLR) configuration - the CRR/SLR + * percentages and the GL accounts the returns read their figures from. + * + * Both halves live on one screen because they are one decision: a percentage is meaningless without + * knowing which accounts it applies to, and an administrator who sets one and forgets the other + * produces a return that looks complete and is wrong. + * + * Roles are fixed (see {@link #ROLE_DESCRIPTIONS}); accounts are pinned to them. Unlike + * m_clearing_gl_config, a role takes MANY accounts - a bank holds current accounts at a dozen other + * banks and each counts toward the balances it maintains. + */ +@Path("/v1/rbireserveconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** + * The roles a return can read, in the order the screen presents them. Held here rather than + * seeded as rows because they are structural - a role only exists if code computes something + * from it, so adding one is a code change either way. + */ + private static final Map ROLE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + ROLE_DESCRIPTIONS.put("CASH_IN_HAND", + "Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained."); + ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS", + "Current account balances the bank holds with other banks. Counts toward CRR maintained. " + + "Pin every such account - a bank typically banks with several."); + ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES", + "Deposit liabilities forming the DTL base that CRR and SLR are charged on. " + + "Pin the deposit accounts and any interest accrued on them."); + ROLE_DESCRIPTIONS.put("SLR_INVESTMENTS", + "SLR-eligible investments - central and state government securities. " + + "Counts toward SLR maintained on Form 9. Exclude anything marked non-SLR."); + + // OSS 1 - Return on Assets and Liabilities. The balance-sheet heads, each read as its + // closing balance at the quarter end. + ROLE_DESCRIPTIONS.put("OSS1_PAID_UP_CAPITAL", + "Share capital paid up by members. OSS 1 liability head 1."); + ROLE_DESCRIPTIONS.put("OSS1_RESERVES", + "Reserve fund and other reserves - statutory, building, dividend equalisation, " + + "provisions for standard assets. OSS 1 liability head 2."); + ROLE_DESCRIPTIONS.put("OSS1_PROFIT_LOSS", + "Accumulated profit or loss carried on the balance sheet. OSS 1 liability head 3."); + ROLE_DESCRIPTIONS.put("OSS1_SUBORDINATED_DEBT", + "Subordinated debt counted as Tier II capital. OSS 1 liability head 5."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS", + "All deposit liabilities. OSS 1 liability head 6. Pin the deposit control accounts; " + + "the term/savings/current heads below break this same total down and are not added to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_TERM", + "Term deposits, including recurring deposits and matured term deposits. " + + "A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_SAVINGS", + "Savings bank deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_DEPOSITS_CURRENT", + "Current account deposits. A breakdown of OSS1_DEPOSITS, not an addition to it."); + ROLE_DESCRIPTIONS.put("OSS1_BORROWINGS", + "Borrowings from banks and other institutions. OSS 1 liability head 7."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_LIABILITIES", + "Other liabilities and provisions - bills payable, interest accrued, sundry creditors."); + ROLE_DESCRIPTIONS.put("OSS1_CASH_IN_HAND", + "Cash in hand on the asset side of OSS 1. Usually the same accounts as CASH_IN_HAND."); + ROLE_DESCRIPTIONS.put("OSS1_BALANCE_WITH_BANKS", + "Balances with banks - current, savings and fixed. " + + "Usually the same accounts as BALANCE_WITH_OTHER_BANKS, plus any fixed placements."); + ROLE_DESCRIPTIONS.put("OSS1_INVESTMENTS", + "All investments, SLR and non-SLR together. OSS 1 asset head 3."); + ROLE_DESCRIPTIONS.put("OSS1_LOANS_ADVANCES", + "Loans and advances to members and others. OSS 1 asset head 4."); + ROLE_DESCRIPTIONS.put("OSS1_FIXED_ASSETS", + "Premises, furniture and other fixed assets, net of depreciation."); + ROLE_DESCRIPTIONS.put("OSS1_OTHER_ASSETS", "Other assets - interest receivable, sundry debtors, stationery."); + + // OSS 2 - Return on Earnings. These read the movement over the quarter, not a balance: an + // earnings line reports what was earned or spent in the period. + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_ADVANCES", + "Interest and discount earned on loans and advances. OSS 2 item 2."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_MARKET_LENDING", + "Interest on lending to banks - call and short notice money. OSS 2 item 3."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_INVESTMENTS", "Interest earned on investments. OSS 2 item 4."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_ON_BANK_BALANCES", + "Interest on balances with RBI, inter-bank deposits and placements. OSS 2 item 5."); + ROLE_DESCRIPTIONS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", + "Commission, exchange and brokerage earned. OSS 2 item 6."); + ROLE_DESCRIPTIONS.put("OSS2_PROFIT_ON_INVESTMENTS", + "Profit on sale of investments. Pin the income account only; losses belong under provisions."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_INCOME", "Other income not covered by the heads above."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "Interest paid or accrued on deposits. OSS 2 item 10."); + ROLE_DESCRIPTIONS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "Interest paid on borrowings. OSS 2 item 11."); + ROLE_DESCRIPTIONS.put("OSS2_STAFF_COST", + "Salaries, allowances, provident fund and other staff cost. OSS 2 item 12."); + ROLE_DESCRIPTIONS.put("OSS2_RENT_TAXES_INSURANCE", "Rent, taxes, insurance and lighting. OSS 2 item 13."); + ROLE_DESCRIPTIONS.put("OSS2_DEPRECIATION", "Depreciation on the bank's own property. OSS 2 item 14."); + ROLE_DESCRIPTIONS.put("OSS2_PROVISIONS", + "Provisions and contingencies, including bad debt provision and loss on investments."); + ROLE_DESCRIPTIONS.put("OSS2_OTHER_OPERATING_EXPENSES", "Other operating expenses. OSS 2 item 16."); + } + + /** + * A readable name per role. Held next to the descriptions rather than in the screen, so a role + * added here cannot show up in the UI as a raw key nobody wrote a label for. + */ + private static final Map ROLE_LABELS = new LinkedHashMap<>(); + static { + ROLE_LABELS.put("CASH_IN_HAND", "Cash in hand"); + ROLE_LABELS.put("BALANCE_WITH_OTHER_BANKS", "Balances with other banks"); + ROLE_LABELS.put("DEMAND_TIME_LIABILITIES", "Demand and Time Liabilities (DTL)"); + ROLE_LABELS.put("SLR_INVESTMENTS", "SLR-eligible investments"); + + ROLE_LABELS.put("OSS1_PAID_UP_CAPITAL", "1. Paid-up Capital"); + ROLE_LABELS.put("OSS1_RESERVES", "2. Reserve Fund and other Reserves"); + ROLE_LABELS.put("OSS1_PROFIT_LOSS", "3. Profit / Loss"); + ROLE_LABELS.put("OSS1_SUBORDINATED_DEBT", "5. Subordinated Debt (Tier II)"); + ROLE_LABELS.put("OSS1_DEPOSITS", "6. Deposits and other Accounts"); + ROLE_LABELS.put("OSS1_DEPOSITS_TERM", "6 i. Term Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_SAVINGS", "6 ii. Savings Bank Deposits"); + ROLE_LABELS.put("OSS1_DEPOSITS_CURRENT", "6 iii. Current Deposits"); + ROLE_LABELS.put("OSS1_BORROWINGS", "7. Borrowings"); + ROLE_LABELS.put("OSS1_OTHER_LIABILITIES", "8. Other Liabilities and Provisions"); + ROLE_LABELS.put("OSS1_CASH_IN_HAND", "Assets 1. Cash in Hand"); + ROLE_LABELS.put("OSS1_BALANCE_WITH_BANKS", "Assets 2. Balances with Banks"); + ROLE_LABELS.put("OSS1_INVESTMENTS", "Assets 3. Investments"); + ROLE_LABELS.put("OSS1_LOANS_ADVANCES", "Assets 4. Loans and Advances"); + ROLE_LABELS.put("OSS1_FIXED_ASSETS", "Assets 5. Fixed Assets"); + ROLE_LABELS.put("OSS1_OTHER_ASSETS", "Assets 6. Other Assets"); + + ROLE_LABELS.put("OSS2_INTEREST_ON_ADVANCES", "2. Interest on loans and advances"); + ROLE_LABELS.put("OSS2_INTEREST_ON_MARKET_LENDING", "3. Interest on market lending"); + ROLE_LABELS.put("OSS2_INTEREST_ON_INVESTMENTS", "4. Interest on investments"); + ROLE_LABELS.put("OSS2_INTEREST_ON_BANK_BALANCES", "5. Interest on bank balances"); + ROLE_LABELS.put("OSS2_COMMISSION_EXCHANGE_BROKERAGE", "6. Commission, Exchange and Brokerage"); + ROLE_LABELS.put("OSS2_PROFIT_ON_INVESTMENTS", "7. Profit on sale of investments"); + ROLE_LABELS.put("OSS2_OTHER_INCOME", "8. Other income"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_DEPOSITS", "10. Interest paid on deposits"); + ROLE_LABELS.put("OSS2_INTEREST_PAID_ON_BORROWINGS", "11. Interest paid on borrowings"); + ROLE_LABELS.put("OSS2_STAFF_COST", "12. Salaries, allowances and PF"); + ROLE_LABELS.put("OSS2_RENT_TAXES_INSURANCE", "13. Rent, taxes, insurance and lighting"); + ROLE_LABELS.put("OSS2_DEPRECIATION", "14. Depreciation"); + ROLE_LABELS.put("OSS2_PROVISIONS", "15. Provisions and contingencies"); + ROLE_LABELS.put("OSS2_OTHER_OPERATING_EXPENSES", "16. Other operating expenses"); + } + + /** + * Which return each role belongs to, and how it is used there, so the screen can separate a + * long list into the forms it affects and state the consequence of leaving one unmapped. + */ + private static final Map ROLE_GROUPS = new LinkedHashMap<>(); + private static final Map ROLE_GROUP_LABELS = new LinkedHashMap<>(); + private static final Map ROLE_USAGE = new LinkedHashMap<>(); + static { + ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)"); + ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities"); + ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings"); + ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)"); + + for (final String role : ROLE_DESCRIPTIONS.keySet()) { + if (role.startsWith("OSS1_")) { + ROLE_GROUPS.put(role, "OSS1"); + ROLE_USAGE.put(role, "OSS 1 (RAL) — closing balance at quarter end"); + } else if (role.startsWith("OSS2_")) { + ROLE_GROUPS.put(role, "OSS2"); + ROLE_USAGE.put(role, "OSS 2 (RoE) — movement over the quarter"); + } else { + ROLE_GROUPS.put(role, "RESERVE"); + // Both ratios are settled on Form 1 - Part B is section 18, Part C section 24 - so + // the part is named rather than a separate return that does not exist. + if ("SLR_INVESTMENTS".equals(role)) { + ROLE_USAGE.put(role, "Form 1 Part C (SLR) — maintained"); + } else if ("DEMAND_TIME_LIABILITIES".equals(role)) { + ROLE_USAGE.put(role, "Form 1 — base for both ratios"); + } else { + ROLE_USAGE.put(role, "Form 1 Part B (CRR) — maintained"); + } + } + } + } + + /** + * SAVINGS_DEMAND is the share of savings balances RBI treats as a demand liability. It sits + * beside CRR and SLR because it behaves like them: set by RBI, changed by circular, and + * effective-dated so a refiled return reproduces the split that was in force. + */ + private static final List RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND"); + + /** + * The treatments a head can be given, each naming a branch the returns already have. + * + * ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive + * at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be + * subtracted from the liabilities of a return that has no such base. + */ + private static final List ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET", + "ASSET", "MEMO"); + + private static final Map USAGE_DESCRIPTIONS = new LinkedHashMap<>(); + static { + USAGE_DESCRIPTIONS.put("LIABILITY", "Added into the DTL base, under its Demand or Time head"); + USAGE_DESCRIPTIONS.put("ASSET_NET", "Netted off the base to arrive at NDTL"); + USAGE_DESCRIPTIONS.put("MAINTAINED", "Counted toward CRR maintained (Form 1)"); + // Separate from MAINTAINED because the two returns admit different assets: CRR is met in + // cash and balances, SLR in cash, gold and unencumbered approved securities. One usage for + // both would let a head meant for one return quietly satisfy the other. + USAGE_DESCRIPTIONS.put("SLR_ASSET", "Counted toward SLR maintained (Form 9)"); + USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total"); + } + + /** + * How a head is consumed, in the terms of the return it belongs to. + * + * LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the + * head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no + * base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL + * wording would tell a Form IX filer their head feeds a calculation that does not exist on + * their return. + */ + private static String usedBy(final String group, final String usage) { + if ("FORM9".equals(group)) { + return switch (usage == null ? "" : usage) { + case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities"; + case "ASSET" -> "Form IX — an asset; enters Total Assets"; + case "MEMO" -> "Form IX — stated on the form but entering no total"; + default -> usage; + }; + } + return USAGE_DESCRIPTIONS.getOrDefault(usage, usage); + } + + /** How each built-in role is treated, so the screen can describe built-ins and additions alike. */ + private static final Map BUILT_IN_USAGE = new LinkedHashMap<>(); + static { + BUILT_IN_USAGE.put("DEMAND_TIME_LIABILITIES", "LIABILITY"); + BUILT_IN_USAGE.put("BALANCE_WITH_OTHER_BANKS", "MAINTAINED"); + BUILT_IN_USAGE.put("CASH_IN_HAND", "MAINTAINED"); + BUILT_IN_USAGE.put("SLR_INVESTMENTS", "MAINTAINED"); + } + + /** Roles whose accounts carry a Demand/Time class. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + @GET + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final Map out = new LinkedHashMap<>(); + out.put("rates", rates()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + // Header accounts are excluded deliberately. A header carries no journal entry of its own - + // its balance is the sum of its children - so pinning both a header and any child would + // double-count that child. Only postable detail accounts are offered. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code, classification_enum + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 + ORDER BY gl_code + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + o.put("classification", a.get("classification_enum")); + options.add(o); + } + return gson.toJson(Map.of("glAccountOptions", options, "rateTypes", RATE_TYPES)); + } + + private List> rates() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, rate_type, percentage, effective_from, is_seed_default + FROM m_rbi_reserve_rate + ORDER BY rate_type, effective_from DESC + """); + final List> out = new ArrayList<>(); + for (final Map row : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", row.get("id")); + o.put("rateType", row.get("rate_type")); + o.put("percentage", row.get("percentage")); + o.put("effectiveFrom", String.valueOf(row.get("effective_from"))); + // A placeholder shipped with the migration, not a figure this bank confirmed. + o.put("seedDefault", row.get("is_seed_default")); + out.add(o); + } + return out; + } + + /** Every role, including those with nothing pinned - the screen must show the gaps. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id, c.config_key, c.gl_account_id, c.weight_percentage, c.liability_class, + a.name AS gl_account_name, a.gl_code + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + ORDER BY c.config_key, a.gl_code, c.liability_class + """); + + // Seeded with every key the configuration knows, built in or added, so a mapping against a + // head an administrator created lands in a bucket rather than being dropped as unknown. + final Map>> byKey = new LinkedHashMap<>(); + for (final String key : knownRoleKeys()) { + byKey.put(key, new ArrayList<>()); + } + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + // A row whose key is no longer a known role is ignored rather than surfaced - it cannot + // affect any figure, because nothing reads that key. + final List> bucket = byKey.get(key); + if (bucket == null) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("id", row.get("id")); + account.put("glAccountId", row.get("gl_account_id")); + account.put("glAccountName", row.get("gl_account_name")); + account.put("glCode", row.get("gl_code")); + // The portion of the balance this mapping takes, and which head it falls under. Sent + // back so the screen edits what is stored rather than re-deriving a default that would + // overwrite a deliberate split on the next save. + account.put("weightPercentage", row.get("weight_percentage")); + account.put("liabilityClass", row.get("liability_class")); + bucket.add(account); + } + + final List> out = new ArrayList<>(); + for (final Map.Entry role : ROLE_DESCRIPTIONS.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("description", role.getValue()); + o.put("label", ROLE_LABELS.getOrDefault(role.getKey(), role.getKey())); + // Which return reads this role, so the screen can group a long list by the form it + // affects rather than presenting every role as equally relevant to every return. + final String group = ROLE_GROUPS.get(role.getKey()); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.get(group)); + o.put("usedBy", ROLE_USAGE.get(role.getKey())); + // Built-in roles are wired into the arithmetic by name, so they cannot be renamed or + // removed - the screen greys those controls rather than offering an action that would + // have to fail. + o.put("builtIn", true); + o.put("usage", BUILT_IN_USAGE.getOrDefault(role.getKey(), "MEMO")); + o.put("classified", LIABILITY_ROLES.contains(role.getKey())); + o.put("accounts", byKey.get(role.getKey())); + out.add(o); + } + // Heads an administrator added. Appended after the built-ins so the structural roles keep + // their fixed order at the top of each group regardless of what was added later. + for (final Map row : customRoleRows()) { + final String key = (String) row.get("config_key"); + final String group = (String) row.get("role_group"); + final String usage = (String) row.get("role_usage"); + final Map o = new LinkedHashMap<>(); + o.put("configKey", key); + o.put("description", row.get("description")); + o.put("label", row.get("label")); + o.put("group", group); + o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group)); + o.put("usedBy", usedBy(group, usage)); + o.put("builtIn", false); + o.put("usage", usage); + // A liability head carries the demand/time split; nothing else does. Not on Form IX, + // where the demand and time heads are separate lines of the form itself - asking for a + // class on top of that would let a head declared under 5(a) Fixed Deposits be marked + // DEMAND, which the form has no way to report. + o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group)); + o.put("roleId", row.get("id")); + o.put("accounts", byKey.getOrDefault(key, new ArrayList<>())); + out.add(o); + } + return out; + } + + /** Administrator-defined heads, in the order the screen presents them. */ + private List> customRoleRows() { + return jdbcTemplate.queryForList(""" + SELECT id, config_key, label, description, role_group, role_usage, position + FROM m_rbi_reserve_role + ORDER BY role_group, position, label + """); + } + + /** Every role key the configuration currently knows - built in or added. */ + private java.util.Set knownRoleKeys() { + final java.util.Set keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet()); + for (final Map row : customRoleRows()) { + keys.add((String) row.get("config_key")); + } + return keys; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Replaces the whole set of accounts pinned to one role. Sending the full set rather than + * add/remove deltas keeps the screen and the stored configuration in step even if two + * administrators have the page open at once - the last save wins wholesale, instead of the two + * interleaving into a set neither of them chose. + */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + if (!knownRoleKeys().contains(configKey)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.role", + "'" + configKey + "' is not a known RBI reserve configuration role."); + } + final boolean classified = isClassifiedRole(configKey); + + final JsonElement root = this.fromJsonHelper.parse(body); + + // Two shapes are accepted. "mappings" carries a weight and a class per account and is what + // the screen sends; "glAccountIds" is the flat list the first version of this screen sent, + // still honoured so an older client keeps working - its accounts are taken whole, which is + // exactly what they meant before weights existed. + final List mappings = new ArrayList<>(); + final com.google.gson.JsonArray rows = root.getAsJsonObject().has("mappings") + ? root.getAsJsonObject().getAsJsonArray("mappings") + : null; + if (rows != null) { + for (final JsonElement element : rows) { + final com.google.gson.JsonObject row = element.getAsJsonObject(); + final Long accountId = row.get("glAccountId").getAsLong(); + final BigDecimal weight = row.has("weightPercentage") && !row.get("weightPercentage").isJsonNull() + ? row.get("weightPercentage").getAsBigDecimal() + : BigDecimal.valueOf(100); + final String liabilityClass = classified && row.has("liabilityClass") + && !row.get("liabilityClass").isJsonNull() ? row.get("liabilityClass").getAsString() : null; + mappings.add(new Mapping(accountId, weight, liabilityClass)); + } + } else { + final String[] ids = this.fromJsonHelper.extractArrayNamed("glAccountIds", root); + if (ids != null) { + for (final String id : ids) { + mappings.add(new Mapping(Long.valueOf(id), BigDecimal.valueOf(100), null)); + } + } + } + + final java.util.Set seen = new java.util.LinkedHashSet<>(); + for (final Mapping mapping : mappings) { + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM acc_gl_account WHERE id = ? AND disabled = false AND account_usage = 1", + Integer.class, mapping.accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.gl.account.not.found", + "GL account " + mapping.accountId + " was not found, is disabled, or is a header account."); + } + // A weight outside 0-100 is not a split of a balance. Above 100 it would report more + // than the account holds; below zero it would subtract a liability from the base. + if (mapping.weight == null || mapping.weight.compareTo(BigDecimal.ZERO) < 0 + || mapping.weight.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.weight.out.of.range", + "The portion taken from an account must be between 0 and 100 percent."); + } + if (mapping.liabilityClass != null && !DEMAND.equals(mapping.liabilityClass) + && !TIME.equals(mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.liability.class", + "A liability must be classified as " + DEMAND + " or " + TIME + "."); + } + // The same account may appear twice under one role only if the two rows are different + // heads - a demand slice and a time slice. Twice under the same head is a double count. + if (!seen.add(mapping.accountId + "/" + mapping.liabilityClass)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.duplicate.mapping", + "GL account " + mapping.accountId + + " is mapped to this role twice under the same head; it would be counted twice."); + } + } + + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", configKey); + for (final Mapping mapping : mappings) { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_gl_config + (config_key, gl_account_id, weight_percentage, liability_class, created_on) + VALUES (?, ?, ?, ?, now()) + """, configKey, mapping.accountId, mapping.weight, mapping.liabilityClass); + } + return retrieveAll(); + } + + /** One account pinned to a role: which account, how much of it, and under which head. */ + private record Mapping(Long accountId, BigDecimal weight, String liabilityClass) {} + + /** Whether a role's accounts carry a Demand/Time class - built in or administrator-defined. */ + private boolean isClassifiedRole(final String configKey) { + if (LIABILITY_ROLES.contains(configKey)) { + return true; + } + final List> found = jdbcTemplate + .queryForList("SELECT role_usage FROM m_rbi_reserve_role WHERE config_key = ?", configKey); + return !found.isEmpty() && "LIABILITY".equals(found.get(0).get("role_usage")); + } + + /** + * Adds a head to a return - a reserve, a tax payable, a bills-payable account, anything the + * bank carries that the built-in roles do not name. + * + * The head needs no new code because it declares how it is consumed: a LIABILITY head enters the + * base under its demand or time class, an ASSET_NET head is netted off it, a MAINTAINED head + * counts toward the requirement, a MEMO head is shown and totals nothing. Each of those is a + * branch the returns already have. + */ + @POST + @Path("role") + @Consumes({ MediaType.APPLICATION_JSON }) + public String createRole(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String label = this.fromJsonHelper.extractStringNamed("label", root); + final String description = this.fromJsonHelper.extractStringNamed("description", root); + final String group = this.fromJsonHelper.extractStringNamed("group", root); + final String usage = this.fromJsonHelper.extractStringNamed("usage", root); + + if (label == null || label.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.no.label", + "A name is required for the head."); + } + if (group == null || !ROLE_GROUP_LABELS.containsKey(group)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.group", + "The head must belong to one of " + ROLE_GROUP_LABELS.keySet() + "."); + } + if (usage == null || !ROLE_USAGES.contains(usage)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.unknown.usage", + "How the head is used must be one of " + ROLE_USAGES + "."); + } + + // The key is derived from the name rather than typed, so an administrator naming a head does + // not also have to invent a stable identifier - and cannot collide with a built-in role by + // choosing one of its names. + final String base = "CUSTOM_" + label.toUpperCase(java.util.Locale.ROOT).replaceAll("[^A-Z0-9]+", "_") + .replaceAll("^_|_$", ""); + String key = base.length() > 50 ? base.substring(0, 50) : base; + int suffix = 2; + while (knownRoleKeys().contains(key)) { + final String candidate = base + "_" + suffix++; + key = candidate.length() > 50 ? candidate.substring(0, 50) : candidate; + } + + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_role + (config_key, label, description, role_group, role_usage, position, created_on, created_by) + VALUES (?, ?, ?, ?, ?, ?, now(), ?) + """, key, label.trim(), description, group, usage, nextPosition(group), + this.context.authenticatedUser().getId()); + return retrieveAll(); + } + + /** Where a new head sits in its group: after the ones already there. */ + private int nextPosition(final String group) { + final Integer max = jdbcTemplate.queryForObject( + "SELECT COALESCE(MAX(position), 100) FROM m_rbi_reserve_role WHERE role_group = ?", Integer.class, + group); + return (max == null ? 100 : max) + 1; + } + + /** + * Removes an administrator-defined head, together with the accounts pinned to it. + * + * Built-in roles are refused: the returns reference them by name, so a deleted one would not + * leave a smaller return but a broken computation. + */ + @DELETE + @Path("role/{roleId}") + public String deleteRole(@PathParam("roleId") final Long roleId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT config_key, label FROM m_rbi_reserve_role WHERE id = ?", roleId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.role.not.found", + "Head " + roleId + " was not found, or is a built-in role that cannot be removed."); + } + final String key = (String) found.get(0).get("config_key"); + // The mappings go with it. Left behind they would reference a head nothing reads, and would + // silently reappear under a later head that happened to derive the same key. + jdbcTemplate.update("DELETE FROM m_rbi_reserve_gl_config WHERE config_key = ?", key); + jdbcTemplate.update("DELETE FROM m_rbi_reserve_role WHERE id = ?", roleId); + return retrieveAll(); + } + + /** + * Sets a rate with effect from a date. Restating a rate for a date it already carries edits that + * row; a new date adds one. Earlier rows are never rewritten, so a return filed for a past + * fortnight keeps reporting the rate that was actually in force then. + */ + @POST + @Path("rate") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertRate(final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final JsonElement root = this.fromJsonHelper.parse(body); + final String rateType = this.fromJsonHelper.extractStringNamed("rateType", root); + final BigDecimal percentage = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("percentage", root); + final LocalDate effectiveFrom = this.fromJsonHelper.extractLocalDateNamed("effectiveFrom", root); + + if (rateType == null || !RATE_TYPES.contains(rateType)) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.unknown.rate.type", + "Rate type must be one of " + RATE_TYPES + "."); + } + if (percentage == null || percentage.compareTo(BigDecimal.ZERO) < 0 + || percentage.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.out.of.range", + "Percentage must be between 0 and 100."); + } + if (effectiveFrom == null) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.no.effective.date", + "An effective-from date is required."); + } + + final Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ? AND effective_from = ?", Integer.class, + rateType, java.sql.Date.valueOf(effectiveFrom)); + if (exists != null && exists > 0) { + // Saving over a seeded placeholder makes it this bank's own figure, so the flag clears + // and the returns stop captioning it as a default. + jdbcTemplate.update(""" + UPDATE m_rbi_reserve_rate SET percentage = ?, is_seed_default = false + WHERE rate_type = ? AND effective_from = ? + """, percentage, rateType, java.sql.Date.valueOf(effectiveFrom)); + } else { + jdbcTemplate.update(""" + INSERT INTO m_rbi_reserve_rate (rate_type, percentage, effective_from, created_on, created_by, + is_seed_default) + VALUES (?, ?, ?, now(), ?, false) + """, rateType, percentage, java.sql.Date.valueOf(effectiveFrom), + this.context.authenticatedUser().getId()); + } + return retrieveAll(); + } + + /** + * Removes one effective-dated rate row. The last remaining row for a type is kept: with none at + * all, every return of that type would stop computing, which is a harsher outcome than an + * administrator deleting the wrong row expects. + */ + @DELETE + @Path("rate/{rateId}") + public String deleteRate(@PathParam("rateId") final Long rateId) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + + final List> found = jdbcTemplate + .queryForList("SELECT rate_type FROM m_rbi_reserve_rate WHERE id = ?", rateId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.not.found", + "Rate " + rateId + " was not found."); + } + final String rateType = (String) found.get(0).get("rate_type"); + final Integer remaining = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = ?", + Integer.class, rateType); + if (remaining != null && remaining <= 1) { + throw new PlatformDataIntegrityException("error.msg.rbireserveconfig.rate.last.row", + "The only " + rateType + " rate on record cannot be removed - edit it instead."); + } + jdbcTemplate.update("DELETE FROM m_rbi_reserve_rate WHERE id = ?", rateId); + return retrieveAll(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java new file mode 100644 index 000000000..2009a31a8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -0,0 +1,2605 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Live computation of RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly). + * + *

What the returns say

+ * + * Both returns answer the same shape of question: against a liability base (Demand and Time + * Liabilities), a percentage set by RBI must be maintained in a defined class of assets. Form 1 + * maintains CRR in cash and balances with banks; Form 9 maintains SLR in government securities. + * + *

The lag basis, which is the part worth reading twice

+ * + * The base and the holdings are NOT read as at the same date. RBI reckons the requirement on the + * DTL of an earlier reporting Friday and compares it against what is actually held now - that lag + * is what gives a bank time to arrange its position. So: + * + *
+ *   DTL       as at the reporting Friday on or before (reporting date - 45 days)
+ *   Required  = DTL x rate in force
+ *   Held      as at the reporting date itself
+ *   Surplus   = Held - Required
+ * 
+ * + * The 45 days come from the reporting requirement; snapping back to a Friday is what makes it a + * reporting basis rather than an arbitrary calendar day, since a Sunday has no close of business to + * reckon a balance at. Both dates are returned so the basis is auditable rather than implied - a + * reviewer can see the raw 45-day date and the Friday actually used. + * + *

Reading balances

+ * + * Balances are read the way every other report in this codebase reads them (see + * BalanceSheetApiResource, ProfitAndLossApiResource): on entry_date, excluding reversed entries AND + * the counter entries that Fineract's own revertJournalEntry posts against them. Dropping only the + * original leg of that second kind would leave its counter entry standing alone and swing the + * balance by the full amount in the wrong direction. + * + *

When something is not configured

+ * + * A role with no GL accounts pinned reports as unconfigured, and the return refuses to present a + * total that silently treats it as zero. A CRR return that reports zero holdings because nobody + * mapped the cash account is worse than one that says it cannot compute. + */ +@Path("/v1/rbireservereturn") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class RbiReserveReturnApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "RBIRESERVECONFIG"; + + /** Days the DTL base lags the reporting date, before snapping to a reporting Friday. */ + private static final int DTL_LAG_DAYS = 45; + + /** + * The share of the requirement that must be held on EVERY day of the fortnight, not merely on + * average across it. RBI sets this daily floor precisely so a bank cannot run the fortnight + * near-empty and make the average up on the closing day. + */ + private static final BigDecimal DAILY_MINIMUM_PERCENT = BigDecimal.valueOf(90); + + private static final int ASSET = 1; + private static final int EXPENSE = 5; + + /** acc_gl_account.account_usage for a postable (detail) account; anything else is a header. */ + private static final int POSTABLE_ACCOUNT = 1; + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + /** The two heads liabilities are reported under. */ + private static final String DEMAND = "DEMAND"; + private static final String TIME = "TIME"; + + /** Roles where the demand/time split applies. Elsewhere an unclassified row is not a gap. */ + private static final java.util.Set LIABILITY_ROLES = java.util.Set.of("DEMAND_TIME_LIABILITIES"); + + /** How an administrator-defined head is consumed; see m_rbi_reserve_role.role_usage. */ + private static final String USAGE_LIABILITY = "LIABILITY"; + private static final String USAGE_ASSET_NET = "ASSET_NET"; + private static final String USAGE_MAINTAINED = "MAINTAINED"; + /** + * Counts toward SLR rather than CRR. The two are separate because the returns admit different + * assets: CRR is met in cash and balances with banks, SLR in cash, gold and unencumbered + * approved securities. A single "maintained" usage would let a head meant for one return + * quietly satisfy the other. + */ + private static final String USAGE_SLR_ASSET = "SLR_ASSET"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final Gson gson = new Gson(); + + /** + * Form 1 - CRR, filed twice a month. + * + * @param reportingDateParam + * defaults to the current business date. + */ + @GET + @Path("form1") + public String form1(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("baseDate") final String baseDateParam, @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate reportingDate = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate rawLagDate = reportingDate.minusDays(DTL_LAG_DAYS); + // A reporting Friday that falls on a holiday has no close of business to strike a balance + // at, so the position is taken at the preceding working day instead. + final LocalDate nominalDtlDate = reportingFridayOnOrBefore(rawLagDate); + // The base date can be given outright. The 45-day lag lands where it lands, and a bank + // reading an earlier fortnight - or correcting a filing - needs to name the date the base + // is struck at rather than work backwards from it. Given explicitly it is used as given; + // otherwise it is derived from the lag as before. + final boolean baseGiven = baseDateParam != null && !baseDateParam.isBlank(); + final LocalDate dtlDate = baseGiven + ? requirePastOrPresent(LocalDate.parse(baseDateParam), "The base date") + : precedingWorkingDay(nominalDtlDate); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_1"); + response.put("formName", "Form 1 - Cash Reserve Ratio (CRR)"); + response.put("frequency", "Fortnightly"); + response.put("reportingDate", reportingDate.toString()); + response.putAll(fortnightOf(reportingDate)); + + // The lag basis, spelled out so a reviewer can check it rather than trust it. A base date + // the filer chose is said to be theirs: the note must never imply the lag produced a date + // somebody typed. + final boolean shifted = !baseGiven && !dtlDate.equals(nominalDtlDate); + final Map basis = new LinkedHashMap<>(); + basis.put("lagDays", DTL_LAG_DAYS); + basis.put("rawLagDate", rawLagDate.toString()); + basis.put("reportingFriday", nominalDtlDate.toString()); + basis.put("dtlAsOnDate", dtlDate.toString()); + basis.put("holdingsAsOnDate", reportingDate.toString()); + basis.put("shiftedForHoliday", shifted); + basis.put("baseDateChosen", baseGiven); + basis.put("note", baseGiven + ? "DTL is taken as at " + dtlDate + ", the base date entered for this return; balances maintained " + + "are taken as at " + reportingDate + "." + : "DTL is taken as at the reporting Friday on or before " + rawLagDate + " (" + DTL_LAG_DAYS + + " days before the reporting date)" + + (shifted + ? "; that Friday (" + nominalDtlDate + + ") was a holiday, so the position is taken at the preceding working day " + + dtlDate + : "") + + "; balances maintained are taken as at " + reportingDate + "."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", dtlDate, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", reportingDate, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", reportingDate, officeId); + + final Rate rate = rateInForce("CRR", reportingDate); + response.put("rateType", "CRR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // Inter-bank assets, read on the DTL date rather than the reporting date: they are netted + // off the liability base, so they belong to the same close of business the base is struck + // at. Read on the reporting date they would net a later position against an earlier base. + final RoleFigure interBankAtDtlDate = figureFor("BALANCE_WITH_OTHER_BANKS", dtlDate, officeId); + + // Heads the bank added itself - a reserve, a tax payable, anything its chart carries that + // the four built-in roles do not name. Each declares how it is consumed, so they fold into + // the same three totals below without the arithmetic knowing what any of them is called. + final List heads = customHeads("RESERVE", dtlDate, reportingDate, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = interBankAtDtlDate.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // NDTL = DTL - inter-bank assets. CRR and SLR are charged on the NET figure; charging the + // gross would overstate the requirement by the rate times these balances. + // + // Floored at zero. The subtraction can go negative where inter-bank assets exceed the + // liabilities, and a negative base yields a negative required amount - which would make any + // holding at all, including none, report as a surplus. There is no negative reserve + // requirement: below zero the requirement is nil. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(withComparison(line("Demand and Time Liabilities (DTL)", dtl, dtlDate), + "DEMAND_TIME_LIABILITIES", dtlDate, reportingDate, officeId)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(withComparison(line(head.label, head.figure, dtlDate), head.figure.configKey, dtlDate, + reportingDate, officeId)); + } + } + // The two heads the liability total is built from, each stated on the face of the return as + // RBI reports them rather than left implicit inside the total above. + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, dtlDate)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, dtlDate)); + lines.add(withComparison( + line("Less: balances with other banks — deducted from the DTL base", interBankAtDtlDate, dtlDate), + "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(withComparison(line("Less: " + head.label, head.figure, dtlDate), head.figure.configKey, + dtlDate, reportingDate, officeId)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, dtlDate)); + lines.add(withComparison(line("Cash in hand — counts toward CRR maintained", cash, reportingDate), + "CASH_IN_HAND", dtlDate, reportingDate, officeId)); + lines.add(withComparison(line("Balances with other banks — counts toward CRR maintained", banks, reportingDate), + "BALANCE_WITH_OTHER_BANKS", dtlDate, reportingDate, officeId)); + for (final CustomHead head : heads) { + // A head belonging to the Memorandum or an Annexure prints under that section's own + // heading below, not here: listing it in both places would show one figure twice and + // invite a reader to add it in. + if (inNamedSection(head)) { + continue; + } + if (!USAGE_LIABILITY.equals(head.usage) && !USAGE_ASSET_NET.equals(head.usage)) { + lines.add(withComparison( + line(head.label + (USAGE_MAINTAINED.equals(head.usage) ? "" : " (memo)"), head.figure, + reportingDate), + head.figure.configKey, dtlDate, reportingDate, officeId)); + } + } + response.put("lines", lines); + // Named so the screen can head its two columns without re-deriving either date. + response.put("baseDate", dtlDate.toString()); + response.put("currentDate", reportingDate.toString()); + + final boolean computable = rate != null && dtl.configured && (cash.configured || banks.configured); + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal held = cash.amount.add(banks.amount).add(totalOf(heads, USAGE_MAINTAINED)); + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + // What the bank actually holds, as a percentage of the same base the requirement uses - + // directly comparable with the rate above. + response.put("maintainedPercentage", percentageOf(held, ndtl)); + + // Compliance over the fortnight, not on one day. RBI requires the full requirement on + // average across the fortnight AND a daily minimum on every single day of it, so a bank + // that held nothing for thirteen days and topped up on the last one fails even though + // its closing position looks ample. + // The maintenance table covers the whole calendar month, not the fortnight alone: a + // reviewer comparing two filings of the same month otherwise has to stitch two + // fortnights together by hand to see how the position moved across it. + final LocalDate fortnightStart = reportingDate.withDayOfMonth(1); + final LocalDate fortnightEnd = reportingDate.with(TemporalAdjusters.lastDayOfMonth()); + final List maintainedRoles = new ArrayList<>(List.of("CASH_IN_HAND", "BALANCE_WITH_OTHER_BANKS")); + maintainedRoles.addAll(customKeysWithUsage("RESERVE", USAGE_MAINTAINED)); + + // Only up to the reporting date: days after it have not happened, and averaging an + // unfinished fortnight over its whole length would understate what is held so far. + final LocalDate averageTo = reportingDate.isBefore(fortnightEnd) ? reportingDate : fortnightEnd; + final Map daily = dailyMaintained(fortnightStart, averageTo, officeId, maintainedRoles, + required); + + final BigDecimal average = (BigDecimal) daily.get("average"); + final BigDecimal lowest = (BigDecimal) daily.get("lowest"); + final BigDecimal dailyFloor = required.multiply(DAILY_MINIMUM_PERCENT).divide(HUNDRED, 2, + RoundingMode.HALF_UP); + + daily.put("periodStart", fortnightStart.toString()); + daily.put("periodEnd", fortnightEnd.toString()); + daily.put("periodLabel", fortnightStart.getMonth().getDisplayName(java.time.format.TextStyle.FULL, + java.util.Locale.ENGLISH) + " " + fortnightStart.getYear()); + daily.put("fortnightComplete", !reportingDate.isBefore(fortnightEnd)); + daily.put("requiredAverage", required); + daily.put("dailyMinimumPercent", DAILY_MINIMUM_PERCENT); + daily.put("dailyMinimumAmount", dailyFloor); + daily.put("averageMet", average.compareTo(required) >= 0); + daily.put("dailyMinimumMet", lowest.compareTo(dailyFloor) >= 0); + daily.put("averageShortfall", average.subtract(required)); + response.put("maintenance", daily); + } + // The Memorandum and the two Annexures Form A carries alongside the main statement. They are + // sections of the return rather than extra lines on it, so they are returned separately and + // print under their own headings rather than being mistaken for figures entering the CRR + // arithmetic - which none of them do. + response.put("memorandum", sectionOf(heads, "Memorandum - ")); + response.put("annexureA", sectionOf(heads, "Annexure A - ")); + response.put("annexureB", sectionOf(heads, "Annexure B - ")); + + response.put("unmetRequirements", unmet(rate, "CRR", dtl, cash, banks)); + + return gson.toJson(response); + } + + /** + * The heads belonging to one titled section of the return, with the prefix stripped from each + * label so "Memorandum - Reserves" prints as "Reserves" under the Memorandum heading. + * + * Selected by label prefix rather than by a column, because a section is a presentational + * grouping: a head moves between sections by being renamed, and an administrator adding a head + * to the Memorandum does so by naming it that way rather than by learning a second concept. + */ + /** Prefixes that move a head out of the main statement and into its own titled section. */ + private static final List SECTION_PREFIXES = List.of("Memorandum - ", "Annexure A - ", "Annexure B - "); + + private static boolean inNamedSection(final CustomHead head) { + if (head.label == null) { + return false; + } + return SECTION_PREFIXES.stream().anyMatch(head.label::startsWith); + } + + private static List> sectionOf(final List heads, final String prefix) { + final List> rows = new ArrayList<>(); + for (final CustomHead head : heads) { + if (head.label == null || !head.label.startsWith(prefix)) { + continue; + } + final Map row = new LinkedHashMap<>(); + row.put("label", head.label.substring(prefix.length())); + row.put("configKey", head.figure.configKey); + row.put("configured", head.figure.configured); + row.put("amount", head.figure.configured ? head.figure.amount : null); + row.put("accounts", head.figure.accounts); + rows.add(row); + } + return rows; + } + + /** + * Form 9 - SLR, filed at the end of the month. + * + * Unlike Form 1, SLR is reported on the month-end position: base and holdings are both taken as + * at the month end, with no lag, so the return states the position actually held at close. + */ + @GET + @Path("form9") + public String form9(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + // Run inside the current month, the month end has not arrived. Reading balances at it + // returned today's figures under the month-end heading, so a month still running reported + // as a closed one - with nothing on the return to say the month was not over. + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate monthEnd = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9"); + response.put("formName", "Form 9 - Statutory Liquidity Ratio (SLR)"); + response.put("frequency", "Monthly"); + response.put("requestedDate", requested.toString()); + response.put("reportingDate", monthEnd.toString()); + // Stated so a return read before the month closes cannot be mistaken for the filed one. + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); + response.put("monthStart", requested.withDayOfMonth(1).toString()); + + final Map basis = new LinkedHashMap<>(); + basis.put("dtlAsOnDate", monthEnd.toString()); + basis.put("holdingsAsOnDate", monthEnd.toString()); + basis.put("note", monthComplete + ? "SLR is reported on the month-end position: both the DTL base and the " + + "SLR-eligible investments are taken as at " + monthEnd + "." + // Said plainly rather than left to the dates: a provisional return that reads like a + // filed one is the whole risk here. + : "The month ending " + nominalMonthEnd + " has not closed. Both the DTL base and " + + "the SLR-eligible investments are taken as at " + monthEnd + + ", the position so far, and will move before the month ends."); + response.put("basis", basis); + + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", monthEnd, officeId); + final RoleFigure investments = figureFor("SLR_INVESTMENTS", monthEnd, officeId); + final RoleFigure cash = figureFor("CASH_IN_HAND", monthEnd, officeId); + final RoleFigure banks = figureFor("BALANCE_WITH_OTHER_BANKS", monthEnd, officeId); + + final Rate rate = rateInForce("SLR", monthEnd); + response.put("rateType", "SLR"); + response.put("ratePercentage", rate == null ? null : rate.percentage); + response.put("rateIsSeedDefault", rate != null && rate.seedDefault); + + // The same administrator-defined heads Form 1 reads. Both dates are month-end here, so the + // base and the holdings are struck at one close of business. + final List heads = customHeads("RESERVE", monthEnd, monthEnd, officeId); + + final BigDecimal liabilities = dtl.amount.add(totalOf(heads, USAGE_LIABILITY)); + final BigDecimal demand = dtl.demandAmount.add(liabilitySliceOf(heads, true)); + final BigDecimal time = dtl.timeAmount.add(liabilitySliceOf(heads, false)); + final BigDecimal netted = banks.amount.add(totalOf(heads, USAGE_ASSET_NET)); + + // SLR is charged on the same NET base as CRR. Both sides are month-end here, so the + // inter-bank figure is the one already read above rather than a second read at another date. + final BigDecimal ndtl = liabilities.subtract(netted).max(BigDecimal.ZERO); + + final List> lines = new ArrayList<>(); + lines.add(line("Demand and Time Liabilities (DTL)", dtl, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + lines.add(line(head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("of which: Demand Liabilities", demand, dtl.configured, monthEnd)); + lines.add(subLine("of which: Time Liabilities", time, dtl.configured, monthEnd)); + lines.add(line("Less: balances with other banks — deducted from the DTL base", banks, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_ASSET_NET.equals(head.usage)) { + lines.add(line("Less: " + head.label, head.figure, monthEnd)); + } + } + lines.add(subLine("Net Demand and Time Liabilities (NDTL)", ndtl, dtl.configured, monthEnd)); + lines.add(line("SLR-eligible investments (government securities)", investments, monthEnd)); + // The heads that DO count toward SLR - gold, approved securities, eligible cash - each on + // its own line, so what makes up the maintained figure is visible rather than a single total + // a reader has to take on trust. + for (final CustomHead head : heads) { + if (USAGE_SLR_ASSET.equals(head.usage) && !inNamedSection(head)) { + lines.add(line(head.label, head.figure, monthEnd)); + } + } + // Shown for completeness of the month-end liquid position; not added into SLR maintained. + lines.add(line("Cash in hand (memo)", cash, monthEnd)); + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage) || USAGE_ASSET_NET.equals(head.usage) + || USAGE_SLR_ASSET.equals(head.usage) || inNamedSection(head)) { + continue; + } + // Every remaining head is a memo on THIS return, including one marked MAINTAINED: that + // usage means CRR, so the label must not suggest the figure was added into SLR. + lines.add(line(head.label + " (memo)", head.figure, monthEnd)); + } + response.put("lines", lines); + + // Form A's Memorandum and Annexures appear on Form 9 too - the same heads, under the same + // headings, so a reviewer reading either return finds them in the same place. + response.put("memorandum", sectionOf(heads, "Memorandum - ")); + response.put("annexureA", sectionOf(heads, "Annexure A - ")); + response.put("annexureB", sectionOf(heads, "Annexure B - ")); + + // Heads the bank declared as SLR assets - gold, approved securities, eligible cash. Summed + // here rather than on the built-in investments role alone, because SLR is maintained in + // more than government securities and a bank holding gold has no other place to state it. + final BigDecimal slrHeads = totalOf(heads, USAGE_SLR_ASSET); + final boolean anySlrAsset = investments.configured || hasUsage(heads, USAGE_SLR_ASSET); + + final boolean computable = rate != null && dtl.configured && anySlrAsset; + response.put("computable", computable); + + if (computable) { + final BigDecimal required = ndtl.multiply(rate.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + // A head marked MAINTAINED is deliberately NOT added: that usage means CRR, which is met + // in cash and balances with banks. Letting it count here would allow a head configured + // for Form 1 to inflate the SLR position - exactly the error this return exists to + // detect. Only SLR_ASSET heads and the built-in investments role count. + final BigDecimal held = investments.amount.add(slrHeads); + response.put("dtlAmount", liabilities); + response.put("demandLiabilities", dtl.configured ? demand : null); + response.put("timeLiabilities", dtl.configured ? time : null); + response.put("interBankAssets", netted); + response.put("ndtlAmount", ndtl); + response.put("requiredAmount", required); + response.put("maintainedAmount", held); + response.put("surplusOrShortfall", held.subtract(required)); + response.put("compliant", held.compareTo(required) >= 0); + response.put("maintainedPercentage", percentageOf(held, ndtl)); + } + // The investments role is only reported as missing when nothing else stands in for it: a + // bank that maintains SLR in gold, or in securities mapped to its own heads, has configured + // the return even though that one built-in role is empty. + final List slrUnmet = anySlrAsset ? unmet(rate, "SLR", dtl) : unmet(rate, "SLR", dtl, investments); + response.put("unmetRequirements", slrUnmet); + + return gson.toJson(response); + } + + // ─────────────────────────── computation ─────────────────────────── + + /** A role's balance, plus whether it was configured at all - zero and unset are different. */ + private static final class RoleFigure { + + final String configKey; + boolean configured; + BigDecimal amount = BigDecimal.ZERO; + /** + * The same total split by liability class, for the roles where RBI reports the two heads + * separately. Both stay zero on asset roles, where the distinction has no meaning. + */ + BigDecimal demandAmount = BigDecimal.ZERO; + BigDecimal timeAmount = BigDecimal.ZERO; + final List> accounts = new ArrayList<>(); + /** Accounts pinned to the role but left out of the total, with why. */ + final List excluded = new ArrayList<>(); + /** + * Liability rows carrying no DEMAND/TIME class. They are counted in the role total, so the + * return still adds up, but they cannot be placed under either head - reported so the gap is + * visible rather than showing as a total its own two halves fall short of. + */ + final List unclassified = new ArrayList<>(); + + RoleFigure(final String configKey) { + this.configKey = configKey; + } + } + + /** + * Sums the balances of every GL account pinned to a role, as at a date. + * + * Each account's balance is taken on its own natural side, so a liability role such as DTL + * reports as a positive number rather than as a negative asset-side figure. + * + * A mapping row contributes its configured PORTION of the balance, not the whole of it. RBI + * treats part of a savings balance as a demand liability and the rest as time, so the same + * account is mapped twice under one role - once per class, each with its own weight - and the + * two slices are accumulated under their respective heads. + */ + private RoleFigure figureFor(final String configKey, final LocalDate asOnDate, final Long officeId) { + final RoleFigure figure = new RoleFigure(configKey); + + // Every account pinned to the role is read back, usable or not. Excluding the unusable ones + // in SQL would shrink the maintained figure with nothing on the return to say an account had + // dropped out, so they are carried through here and reported by unmet() instead. + // + // Keyed on the mapping row rather than the account: one account can appear more than once + // under a role, and grouping by account would collapse a demand slice and a time slice into + // a single row whose weight is neither. + final List> accounts = jdbcTemplate.queryForList(""" + SELECT c.id AS mapping_id, c.weight_percentage, c.liability_class, + a.id, a.name, a.gl_code, a.classification_enum, a.disabled, a.account_usage + FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE c.config_key = ? + ORDER BY a.gl_code, c.liability_class + """, configKey); + if (accounts.isEmpty()) { + return figure; + } + figure.configured = true; + + for (final Map account : accounts) { + // The mapping row, not the account. One account can be pinned to a role twice - a + // savings balance split into its demand and time slices - and the comparison below + // matches rows across the two dates by this, so the two slices stay apart. + final Long mappingId = ((Number) account.get("mapping_id")).longValue(); + final Long accountId = ((Number) account.get("id")).longValue(); + final int classification = ((Number) account.get("classification_enum")).intValue(); + final String glCode = (String) account.get("gl_code"); + final String liabilityClass = (String) account.get("liability_class"); + // NOT NULL in the schema, but read defensively: a row that somehow arrived without one + // must not multiply a balance by zero and silently drop it out of the return. + final BigDecimal weight = account.get("weight_percentage") == null ? HUNDRED + : (BigDecimal) account.get("weight_percentage"); + + // A pinned account can stop being usable after it was mapped: disabled, or turned into a + // header whose balance is the sum of its children and would double-count them. Either + // way its balance must not silently enter the total - the account is named on the return + // as excluded, so the figure can be read for what it is. + final boolean disabled = Boolean.TRUE.equals(account.get("disabled")); + final boolean header = account.get("account_usage") != null + && ((Number) account.get("account_usage")).intValue() != POSTABLE_ACCOUNT; + if (disabled || header) { + figure.excluded.add(glCode + " — " + account.get("name") + (disabled ? " (disabled)" : " (header account)")); + final Map skipped = new LinkedHashMap<>(); + skipped.put("mappingId", mappingId); + skipped.put("glAccountId", accountId); + skipped.put("glCode", glCode); + skipped.put("name", account.get("name")); + skipped.put("balance", null); + skipped.put("weightPercentage", weight); + skipped.put("liabilityClass", liabilityClass); + skipped.put("excluded", disabled ? "disabled" : "header"); + figure.accounts.add(skipped); + continue; + } + + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_journal_entry + WHERE account_id = ? AND entry_date <= ? + AND (reversed IS NULL OR reversed = false) + AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(accountId, java.sql.Date.valueOf(asOnDate))); + if (officeId != null) { + sql.append(" AND office_id = ?"); + args.add(officeId); + } + + final Map row = jdbcTemplate.queryForMap(sql.toString(), args.toArray()); + final BigDecimal debit = (BigDecimal) row.get("total_debit"); + final BigDecimal credit = (BigDecimal) row.get("total_credit"); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit); + + // The role takes its configured portion of the balance. Rounded to paise at the slice, + // so the demand and time halves shown on the return are the very numbers that were + // added together - rounding only the total would leave the two heads failing to sum to + // it by a paisa, which on a filed return reads as an error. + final BigDecimal contribution = weight.compareTo(HUNDRED) == 0 ? balance + : balance.multiply(weight).divide(HUNDRED, 2, RoundingMode.HALF_UP); + + figure.amount = figure.amount.add(contribution); + if (DEMAND.equals(liabilityClass)) { + figure.demandAmount = figure.demandAmount.add(contribution); + } else if (TIME.equals(liabilityClass)) { + figure.timeAmount = figure.timeAmount.add(contribution); + } else if (LIABILITY_ROLES.contains(configKey)) { + figure.unclassified.add(glCode + " — " + account.get("name")); + } + + final Map detail = new LinkedHashMap<>(); + detail.put("mappingId", mappingId); + detail.put("glAccountId", accountId); + detail.put("glCode", glCode); + detail.put("name", account.get("name")); + // Both the account's own balance and the slice taken from it. A reviewer checking a + // weighted line against the ledger needs the figure it was derived from, not only the + // result - otherwise a wrong weight and a wrong balance look identical on the return. + detail.put("accountBalance", balance); + detail.put("weightPercentage", weight); + detail.put("liabilityClass", liabilityClass); + detail.put("balance", contribution); + figure.accounts.add(detail); + } + return figure; + } + + /** An administrator-defined head, with the figure it evaluated to. */ + private static final class CustomHead { + + final String label; + final String usage; + final RoleFigure figure; + + CustomHead(final String label, final String usage, final RoleFigure figure) { + this.label = label; + this.usage = usage; + this.figure = figure; + } + } + + /** + * The heads an administrator added to a return, each evaluated as at the date its treatment + * calls for. + * + * Liabilities are read on the base date and assets on the holdings date, for the same reason the + * built-in roles are: the base is struck at an earlier close of business than the position held + * against it, and reading both at one date would compare figures from different days. + */ + /** + * The keys of administrator-defined heads with one usage - so the daily average counts whatever + * the bank declared as counting toward the requirement, not only the two built-in roles. + */ + private List customKeysWithUsage(final String group, final String usage) { + return jdbcTemplate.queryForList(""" + SELECT config_key FROM m_rbi_reserve_role + WHERE role_group = ? AND role_usage = ? + ORDER BY position, id + """, String.class, group, usage); + } + + private List customHeads(final String group, final LocalDate baseDate, final LocalDate holdingsDate, + final Long officeId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT config_key, label, role_usage + FROM m_rbi_reserve_role + WHERE role_group = ? + ORDER BY position, label + """, group); + final List heads = new ArrayList<>(); + for (final Map row : rows) { + final String key = (String) row.get("config_key"); + final String usage = (String) row.get("role_usage"); + final LocalDate asOn = USAGE_LIABILITY.equals(usage) || USAGE_ASSET_NET.equals(usage) ? baseDate + : holdingsDate; + final RoleFigure figure = figureFor(key, asOn, officeId); + // A head with nothing mapped is left off the return entirely rather than printed as a + // nil line. It was added but never finished, and an empty row invites the reader to + // treat a gap in configuration as a balance of zero. + if (figure.configured) { + heads.add(new CustomHead((String) row.get("label"), usage, figure)); + } + } + return heads; + } + + /** The total a set of custom heads contributes under one treatment. */ + /** Whether any head was declared with one usage, so "nothing mapped" can be told from "nil". */ + private static boolean hasUsage(final List heads, final String usage) { + return heads.stream().anyMatch(h -> usage.equals(h.usage) && h.figure.configured); + } + + private static BigDecimal totalOf(final List heads, final String usage) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (head.usage.equals(usage)) { + total = total.add(head.figure.amount); + } + } + return total; + } + + /** The demand or time slice those heads contribute, for the split shown on the face of the return. */ + private static BigDecimal liabilitySliceOf(final List heads, final boolean demand) { + BigDecimal total = BigDecimal.ZERO; + for (final CustomHead head : heads) { + if (USAGE_LIABILITY.equals(head.usage)) { + total = total.add(demand ? head.figure.demandAmount : head.figure.timeAmount); + } + } + return total; + } + + /** A rate and whether it is still the placeholder the migration seeded. */ + private static final class Rate { + + final BigDecimal percentage; + final boolean seedDefault; + + Rate(final BigDecimal percentage, final boolean seedDefault) { + this.percentage = percentage; + this.seedDefault = seedDefault; + } + } + + /** + * The rate in force on a date - the most recent one effective on or before it. Null when the + * date precedes every rate on record, which stops the return computing rather than defaulting to + * a rate nobody set. + */ + private Rate rateInForce(final String rateType, final LocalDate onDate) { + final List> found = jdbcTemplate.queryForList(""" + SELECT percentage, is_seed_default FROM m_rbi_reserve_rate + WHERE rate_type = ? AND effective_from <= ? + ORDER BY effective_from DESC LIMIT 1 + """, rateType, java.sql.Date.valueOf(onDate)); + if (found.isEmpty()) { + return null; + } + final Map row = found.get(0); + return new Rate((BigDecimal) row.get("percentage"), Boolean.TRUE.equals(row.get("is_seed_default"))); + } + + /** + * The reporting Friday on or before a date. RBI reckons fortnightly balances at the close of + * business on alternate Fridays; a raw calendar offset can land on a Sunday, which has no close + * of business to read a balance at. + */ + private static LocalDate reportingFridayOnOrBefore(final LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.FRIDAY)); + } + + /** + * The date itself when the bank was open, otherwise the working day before it. + * + * A reporting Friday that is a declared holiday has no close of business, so there is no balance + * to strike: the statutory framework takes the preceding working day instead. Sundays are + * treated as non-working alongside declared holidays; Saturdays are not, because a reporting + * Friday never falls on one and a holiday shifted back from Friday legitimately lands on + * Thursday. + * + * Bounded at ten days. A chain of holidays longer than that is a data problem rather than a + * calendar, and walking back indefinitely would silently report a balance from weeks earlier. + */ + private LocalDate precedingWorkingDay(final LocalDate date) { + LocalDate candidate = date; + for (int i = 0; i < 10; i++) { + if (!isNonWorkingDay(candidate)) { + return candidate; + } + candidate = candidate.minusDays(1); + } + return date; + } + + private boolean isNonWorkingDay(final LocalDate date) { + if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { + return true; + } + // status_enum 300 is an activated holiday; a draft or deleted one does not close the bank. + final Integer holidays = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_holiday + WHERE status_enum = 300 AND ? BETWEEN from_date AND to_date + """, Integer.class, java.sql.Date.valueOf(date)); + return holidays != null && holidays > 0; + } + + /** + * The average of what was maintained across every day of the fortnight, which is the figure CRR + * compliance actually turns on. + * + * RBI does not read a single day's balance: a bank must keep at least a daily minimum and average + * the full requirement over the fortnight. A return that reported only the reporting date's + * position would call a bank compliant that held nothing for thirteen days and topped up on the + * last one. + * + * Every day is read, including holidays and Sundays: the balance stands over a closed day, and + * excluding those days would let a fortnight ending on a long weekend average away a shortfall. + */ + private Map dailyMaintained(final LocalDate from, final LocalDate to, final Long officeId, + final List maintainedRoles, final BigDecimal required) { + final List> days = new ArrayList<>(); + BigDecimal total = BigDecimal.ZERO; + BigDecimal lowest = null; + LocalDate lowestOn = null; + + for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) { + BigDecimal held = BigDecimal.ZERO; + for (final String role : maintainedRoles) { + held = held.add(figureFor(role, day, officeId).amount); + } + total = total.add(held); + if (lowest == null || held.compareTo(lowest) < 0) { + lowest = held; + lowestOn = day; + } + final Map row = new LinkedHashMap<>(); + row.put("date", day.toString()); + // Each day carries the requirement it was measured against and the gap, so a reader can + // see WHY a day passed or failed without recomputing the percentage themselves. + row.put("required", required); + row.put("amount", held); + row.put("surplus", held.subtract(required)); + days.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("days", days); + out.put("dayCount", days.size()); + out.put("average", days.isEmpty() ? BigDecimal.ZERO + : total.divide(BigDecimal.valueOf(days.size()), 2, RoundingMode.HALF_UP)); + out.put("lowest", lowest == null ? BigDecimal.ZERO : lowest); + out.put("lowestOn", lowestOn == null ? null : lowestOn.toString()); + return out; + } + + /** Which half of the month a reporting date falls in - Form 1 is filed once per fortnight. */ + private static Map fortnightOf(final LocalDate date) { + final boolean first = date.getDayOfMonth() <= 15; + final LocalDate start = first ? date.withDayOfMonth(1) : date.withDayOfMonth(16); + final LocalDate end = first ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth()); + final Map out = new LinkedHashMap<>(); + out.put("fortnight", first ? 1 : 2); + out.put("fortnightLabel", (first ? "First" : "Second") + " fortnight"); + out.put("fortnightStart", start.toString()); + out.put("fortnightEnd", end.toString()); + return out; + } + + /** + * A figure as a percentage of a base, or null where that percentage would not mean anything. + * + * Null on a base of zero OR LESS, not only on zero: the base is now NDTL, which is a subtraction + * and can come out negative where inter-bank assets exceed the liabilities - an unusual position, + * but one a small bank can genuinely hold. Dividing by it yields a negative "maintained %" that + * reads as a shortfall on a return where holdings are ample, which is worse than showing nothing. + */ + private static BigDecimal percentageOf(final BigDecimal amount, final BigDecimal base) { + if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + return amount.multiply(HUNDRED).divide(base, 4, RoundingMode.HALF_UP); + } + + /** + * A derived row - a split of a role's total, or a figure computed from several roles. It has no + * accounts of its own because it is not read from the ledger: its parts are already listed under + * the lines it was derived from, and repeating them here would show the same balance twice. + */ + private static Map subLine(final String label, final BigDecimal amount, final boolean configured, + final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", null); + out.put("configured", configured); + out.put("asOnDate", asOnDate.toString()); + out.put("amount", configured ? amount : null); + out.put("derived", true); + out.put("accounts", List.of()); + return out; + } + + private static Map line(final String label, final RoleFigure figure, final LocalDate asOnDate) { + final Map out = new LinkedHashMap<>(); + out.put("label", label); + out.put("configKey", figure.configKey); + out.put("configured", figure.configured); + out.put("asOnDate", asOnDate.toString()); + // Null rather than zero when unconfigured: the screen must be able to tell "nothing mapped" + // from "mapped and genuinely nil". + out.put("amount", figure.configured ? figure.amount : null); + out.put("accounts", figure.accounts); + return out; + } + + /** + * Fills in a line's figure at the OTHER of the return's two dates, so every row can be read + * across both columns rather than only at the one date its own figure belongs to. + * + *

+ * The return is built from figures struck at two different dates - the base at its lagged date, + * the holdings at the reporting date - and a reader comparing them has no way to tell a real + * movement from the two dates simply being different. Reading each line at both dates makes the + * movement visible: the DTL line then shows what the liabilities were on the base date AND what + * they are now, and the cash line the same. + * + *

+ * The line's own {@code amount} is left exactly as it was. That is the figure the return is + * computed from, and nothing here may move it. + */ + private Map withComparison(final Map row, final String configKey, + final LocalDate baseDate, final LocalDate reportingDate, final Long officeId) { + if (configKey == null) { + return row; + } + final String ownDate = (String) row.get("asOnDate"); + final boolean ownIsBase = baseDate.toString().equals(ownDate); + final LocalDate otherDate = ownIsBase ? reportingDate : baseDate; + + final RoleFigure other = figureFor(configKey, otherDate, officeId); + row.put("comparisonDate", otherDate.toString()); + row.put("comparisonAmount", other.configured ? other.amount : null); + + // Stated as base-then-current whichever date the line's own figure belongs to, so a column + // means the same thing on every row of the return. + final BigDecimal own = (BigDecimal) row.get("amount"); + row.put("baseAmount", ownIsBase ? own : other.amount); + row.put("currentAmount", ownIsBase ? other.amount : own); + if (own != null) { + final BigDecimal from = ownIsBase ? own : other.amount; + final BigDecimal to = ownIsBase ? other.amount : own; + row.put("movement", to.subtract(from)); + } + + // The accounts behind the line get the same treatment as the line itself. Without this the + // breakdown states each account at one date and leaves the other column blank, so a reader + // who can see a head has moved cannot see WHICH account moved - which is the one question + // the breakdown exists to answer. + // + // Matched on the mapping row, so a savings balance split into a demand slice and a time + // slice keeps the two apart instead of giving both the same figure. + final Map> otherAccounts = new LinkedHashMap<>(); + for (final Map account : other.accounts) { + final Object key = account.get("mappingId"); + if (key != null) { + otherAccounts.put(((Number) key).longValue(), account); + } + } + @SuppressWarnings("unchecked") + final List> ownAccounts = (List>) row.get("accounts"); + if (ownAccounts != null) { + for (final Map account : ownAccounts) { + final Object key = account.get("mappingId"); + final Map match = key == null ? null + : otherAccounts.get(((Number) key).longValue()); + // An account mapped since the other date has no row there. Left null rather than + // shown as zero: it did not hold nothing, it did not exist on the return. + final BigDecimal otherBalance = match == null ? null : (BigDecimal) match.get("balance"); + final BigDecimal ownBalance = (BigDecimal) account.get("balance"); + account.put("baseBalance", ownIsBase ? ownBalance : otherBalance); + account.put("currentBalance", ownIsBase ? otherBalance : ownBalance); + // The account's own unweighted balance at the other date too, so a weighted row + // still reconciles against the ledger in both columns. + final BigDecimal otherAccountBalance = match == null ? null + : (BigDecimal) match.get("accountBalance"); + final BigDecimal ownAccountBalance = (BigDecimal) account.get("accountBalance"); + account.put("baseAccountBalance", ownIsBase ? ownAccountBalance : otherAccountBalance); + account.put("currentAccountBalance", ownIsBase ? otherAccountBalance : ownAccountBalance); + if (ownBalance != null && otherBalance != null) { + final BigDecimal from = ownIsBase ? ownBalance : otherBalance; + final BigDecimal to = ownIsBase ? otherBalance : ownBalance; + account.put("movement", to.subtract(from)); + } + } + } + return row; + } + + /** + * Human-readable reasons the return could not be computed, for the screen to show verbatim. + * + * A rate still carrying the seeded placeholder is listed here too. It does not stop the return + * computing - the figures are arithmetically right for that rate - but the rate itself is one + * nobody at the bank confirmed, so the return says so rather than presenting a required amount + * derived from a placeholder as though it were settled. + */ + private static List unmet(final Rate rate, final String rateType, final RoleFigure... figures) { + final List reasons = new ArrayList<>(); + if (rate == null) { + reasons.add("No " + rateType + " rate is on record effective on or before the reporting date. " + + "Set one under Organization > RBI Reserve Configuration."); + } else if (rate.seedDefault) { + reasons.add("The " + rateType + " rate of " + rate.percentage + + "% is the placeholder shipped with this installation, not a rate this bank has confirmed. " + + "Confirm the rate in force under Organization > RBI Reserve Configuration before filing."); + } + for (final RoleFigure figure : figures) { + if (!figure.configured) { + reasons.add("No GL accounts are mapped to " + figure.configKey + + ". Map them under Organization > RBI Reserve Configuration."); + } else if (!figure.unclassified.isEmpty()) { + // Counted in the base but placed under neither head, so the demand and time lines do + // not sum to the total printed above them. On a filed return that reads as an + // arithmetic error, so the accounts responsible are named. + reasons.add(String.join(", ", figure.unclassified) + + " — mapped to the liability base but not marked Demand or Time, so the figure is counted in " + + "NDTL while appearing under neither head. Classify them under Organization > " + + "RBI Reserve Configuration."); + } + if (!figure.excluded.isEmpty()) { + // The figure is short by these accounts' balances. Saying so is the difference + // between a reviewable return and one that under-reports without a trace. + reasons.add(figure.configKey + " leaves out " + String.join(", ", figure.excluded) + + " — mapped but no longer postable, so the balance shown excludes them. " + + "Review the mapping under Organization > RBI Reserve Configuration."); + } + } + return reasons; + } + + private static LocalDate parseOrBusinessDate(final String param) { + return (param == null || param.isBlank()) ? DateUtils.getBusinessLocalDate() : LocalDate.parse(param); + } + + /** + * Refuses a date the bank has not reached yet. + * + * A balance is summed as {@code entry_date <= date}, so a future date does not fail and does not + * come back empty - it returns TODAY'S balance under tomorrow's heading. The return then looks + * complete and is wrong in the one way nothing on its face can show: a fortnight that has not + * closed reported as though it had, with figures that will still move before it does. + * + * Checked against the business date rather than the wall clock, because that is the date every + * figure on the return is read at. + */ + private static LocalDate requirePastOrPresent(final LocalDate date, final String what) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + // A year the ledger could not hold. A date field hands over whatever has been typed so far, + // so a half-typed year arrives as a real date - "0026-09-30" parses cleanly - and the return + // then reads a period no bank has entries for and reports every line nil. + if (date.getYear() < 1900) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.year.incomplete", + what + " (" + date + ") has a year below 1900, which looks like a date still being " + + "typed rather than a period to report.", + what, date.toString()); + } + if (date.isAfter(today)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.future.date", + what + " (" + date + ") is in the future; the bank's business date is " + today + + ". A period that has not closed cannot be reported.", + what, date.toString(), today.toString()); + } + return date; + } + + /** The earlier of two dates. */ + private static LocalDate minimum(final LocalDate a, final LocalDate b) { + return a.isAfter(b) ? b : a; + } + + // ══════════════ Statutory Form 1 ══════════════ + + /** + * Form 1 as a co-operative bank actually files it - Banking Regulation (Co-operative Societies) + * Rules 1966, Form 1 under Rule 5, sections 18(1) and 24(3). + * + *

+ * This is a different statement from the {@code form1} endpoint above, not a reformatting of + * it. Three differences matter: + * + *

    + *
  • Three columns. The form reports two alternate Fridays side by side with the + * reporting date, so a reviewer sees the position move across the fortnight rather than a + * single snapshot.
  • + *
  • The base date. Both ratios are charged on the liabilities of the last Friday of + * the SECOND PRECEDING fortnight, not on the column's own liabilities. That lag is what gives a + * bank time to arrange its position, and reading the rate off the current column would report a + * requirement the bank never had.
  • + *
  • One form, both ratios. Part B settles section 18 (cash reserve) and Part C + * section 24 (liquid assets) on the same liabilities, so they are computed together.
  • + *
+ * + *

+ * Every figure is read from the heads an administrator mapped; nothing is hardcoded to a GL + * code. A head left unmapped reports as nil, because on this form a nil line is a real answer - + * a bank with no balances at the State Co-operative Bank files that line empty. + */ + @GET + @Path("form1-statutory") + public String form1Statutory(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("firstColumnDate") final String firstColumnDateParam, + @QueryParam("secondColumnDate") final String secondColumnDateParam, + @QueryParam("firstBaseDate") final String firstBaseDateParam, + @QueryParam("secondBaseDate") final String secondBaseDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requestedDate = parseOrBusinessDate(reportingDateParam); + + // The two alternate Fridays the form reports, and the reporting date itself. The filed form + // shows the earlier Friday first, so the columns read left to right in time order. + // Fortnights run 1st-15th and 16th-month end, and each closes on its last day. Those closes + // are what the form's columns report, not alternate Fridays: a filed Form 1 for August + // heads its columns 15/08 and 31/08, which are the fortnight ends however the weekdays + // fall. The reporting date itself is the third column. + // + // Both closes can be given outright. A bank filing August wants 15/08 and 31/08 on the face + // of the form whatever today is, and deriving them from one date cannot produce a pair that + // spans a month boundary or skips a fortnight - which is exactly what a late or corrected + // filing needs. Given explicitly they are used as given; otherwise they are derived as + // before. + // + // Both are refused if they have not happened yet: the figures under a future close are + // today's, and a fortnight still running would be filed as though it had closed. + final LocalDate currentClose = secondColumnDateParam == null || secondColumnDateParam.isBlank() + // Derived, the close can legitimately fall ahead of today - it is the fortnight the + // requested date sits inside. Pulled back to the business date so the form reports + // the position as far as it exists rather than refusing its own default. + ? minimum(fortnightCloseOnOrAfter(requestedDate), DateUtils.getBusinessLocalDate()) + : requirePastOrPresent(LocalDate.parse(secondColumnDateParam), "The second column's date"); + final LocalDate previousClose = firstColumnDateParam == null || firstColumnDateParam.isBlank() + ? fortnightCloseBefore(currentClose) + : requirePastOrPresent(LocalDate.parse(firstColumnDateParam), "The first column's date"); + + // The form is made up to the later of its two closes. Taking the reporting date separately + // let a caller pass one that belonged to a different fortnight than the columns, and the + // rate in force was then looked up on that stray date - so a form could be headed one + // period and computed at another period's rate, with nothing on it to show the mismatch. + final LocalDate reportingDate = currentClose; + // The form reads left to right as the period advances. Reversed, every movement across it + // carries the wrong sign while the form still looks properly filled in. + if (!previousClose.isBefore(currentClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.columns.out.of.order", + "The first column (" + previousClose + ") must fall before the second (" + currentClose + ").", + previousClose.toString(), currentClose.toString()); + } + + // The third column is the base the current fortnight's ratios are charged on - the last + // matching close one month earlier - not the reporting date. + // + // It was the reporting date. Since the return is now made up to the second column, that + // made the third column a copy of the second: two identically dated columns carrying + // identical figures, which reads as a filled form and says nothing. The base belongs on the + // face of the form because every required amount below is computed from it, and a reviewer + // cannot check a requirement whose base is not stated. + // Each close has its own base, and either can be given outright. + // + // Derived, the base is the matching close one month earlier, which is + // what the rules fix it at. But a bank migrating onto the system, or filing a corrected + // return for a period whose base fell before its ledger starts, has a base date that the + // rule produces and the ledger cannot answer - and the ratio then reads nil against real + // liabilities. Given explicitly, the date entered is used and stated on the form, so a + // reader can see which base each column was charged on. + final LocalDate previousBase = firstBaseDateParam == null || firstBaseDateParam.isBlank() + ? formOneBaseDate(previousClose) + : requirePastOrPresent(LocalDate.parse(firstBaseDateParam), "The first column's base date"); + final LocalDate currentBase = secondBaseDateParam == null || secondBaseDateParam.isBlank() + ? formOneBaseDate(currentClose) + : requirePastOrPresent(LocalDate.parse(secondBaseDateParam), "The second column's base date"); + + // A base dated after the close it serves would charge the ratio on liabilities the bank had + // not yet taken on - the figure would be arithmetically fine and mean nothing. + if (previousBase.isAfter(previousClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The first column's base date (" + previousBase + ") falls after the close it is charged on (" + + previousClose + ").", + previousBase.toString(), previousClose.toString()); + } + if (currentBase.isAfter(currentClose)) { + throw new PlatformDataIntegrityException("error.msg.rbireservereturn.base.after.close", + "The second column's base date (" + currentBase + ") falls after the close it is charged on (" + + currentClose + ").", + currentBase.toString(), currentClose.toString()); + } + + // Two columns, one per fortnight close. The base dates are not columns of their own: they + // are stated against the lines they settle, and a third column headed with one printed the + // base's own balances beside the closes', which read as a third period the bank was + // reporting rather than as the figures the ratios were charged on. + final List columnDates = List.of(previousClose, currentClose); + // One base per column, in the same order, so each column is charged on its own base rather + // than both on one. + final List baseDates = List.of(previousBase, currentBase); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_1_STATUTORY"); + response.put("formName", "Form 1 (Rule 5) — Sections 18(1) and 24(3)"); + response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); + response.put("reportingDate", reportingDate.toString()); + response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + // Stated beside the columns so a reader can see which date each ratio was charged on, + // without the base needing a column of its own. + response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList()); + + final Rate crr = rateInForce("CRR", reportingDate); + final Rate slr = rateInForce("SLR", reportingDate); + response.put("crrPercentage", crr == null ? null : crr.percentage); + response.put("slrPercentage", slr == null ? null : slr.percentage); + + final List> parts = new ArrayList<>(); + parts.add(formOnePartA(columnDates, baseDates, officeId, crr, slr)); + response.put("partA", parts.get(0)); + response.put("partB", formOnePartB(columnDates, baseDates, officeId, crr)); + response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + + // Every day of the month the closes fall in, charged on each day's own base. The form + // reports two closes; compliance turns on every day between them. + response.put("daily", formOneDaily(previousClose.withDayOfMonth(1), currentClose, officeId, crr, slr)); + + final List unmet = new ArrayList<>(); + if (crr == null) { + unmet.add("No CRR rate is on record effective on or before the reporting date."); + } + if (slr == null) { + unmet.add("No SLR rate is on record effective on or before the reporting date."); + } + + // A nil base makes both required amounts nil, and the form then reads as a bank with no + // reserve requirement at all - which is what a bank in breach would also look like. The + // base is dated well before the columns, so a ledger that only starts part-way through can + // have real liabilities on its face and nothing behind the ratio, and nothing on the form + // says why. Named here with the date, so the nil is read as "no liabilities on that day" + // rather than as "nothing required". + final BigDecimal baseLiabilities = formOneNetLiabilities(currentBase, officeId); + if (baseLiabilities.compareTo(BigDecimal.ZERO) == 0) { + unmet.add("Both ratios are charged on the net liabilities of " + currentBase + + ", and the ledger holds none on that date. The required amounts are therefore " + + "nil — nil because the base is nil, not because nothing is required."); + } + + response.put("unmetRequirements", unmet); + return gson.toJson(response); + } + + /** Part A: the liabilities and assets the two ratios are worked out from. */ + private Map formOnePartA(final List dates, final List bases, final Long officeId, final Rate crr, + final Rate slr) { + final List> lines = new ArrayList<>(); + + lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM")); + lines.add(formOneHeading("(a) Demand Liabilities")); + final List iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks", + "FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2); + final List iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system", + "FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2); + final List ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS", + dates, officeId, 1); + final List totalI = sumOf(iai, iaii, ib); + lines.add(formOneTotal("TOTAL OF I", totalI)); + + lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS")); + // Demand and time come from the one DTL head, split by the class each mapped account + // carries - the same split the fortnightly return reports. + final List iiA = new ArrayList<>(); + final List iiB = new ArrayList<>(); + for (final LocalDate date : dates) { + final RoleFigure dtl = figureFor("DEMAND_TIME_LIABILITIES", date, officeId); + iiA.add(dtl.demandAmount); + iiB.add(dtl.timeAmount); + } + lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1)); + lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1)); + final List totalII = sumOf(iiA, iiB); + lines.add(formOneTotal("TOTAL OF II", totalII)); + + lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM")); + final List iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks", + "FORM1_III_A_SBI_CURRENT", dates, officeId, 1); + lines.add(formOneHeading("(B) Other assets with the banking system")); + final List iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)", + "FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2); + final List iiiBii = formOneLine(lines, "(ii) Money at call and short notice", + "FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2); + final List iiiBiv = formOneLine(lines, "(iv) Advances and any other assets", + "FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2); + final List totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv); + lines.add(formOneTotal("TOTAL OF III", totalIII)); + + // IV: the banking system nets out, and what is left is added to the liabilities to others. + // Floored at zero per column - the form's own wording, "(I - III) + II if minus figure", + // says the netting cannot push the base below the liabilities to others. + final List iv = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + iv.add(totalI.get(i).subtract(totalIII.get(i)).max(BigDecimal.ZERO).add(totalII.get(i))); + } + // The form carries IV twice, and the difference between the two is the whole of how the + // ratios work. (a) is this column's own liabilities - what the bank owed on the close being + // reported. (b) is the base: the same figure read at the earlier close, and the only one the + // requirement is actually charged on. A form showing one IV reads as though the bank had to + // hold a ratio of today's liabilities, which is not what either section says. + lines.add(formOneTotal( + "IV. a) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv)); + final List ivBase = new ArrayList<>(); + final List ivBaseDates = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + final LocalDate base = baseFor(bases, dates, i); + ivBaseDates.add(base.toString()); + ivBase.add(formOneNetLiabilities(base, officeId)); + } + final Map ivBaseRow = formOneTotal( + "IV. b) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]", + ivBase); + ivBaseRow.put("baseDates", ivBaseDates); + lines.add(ivBaseRow); + + final List v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0); + + lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH")); + final List viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates, + officeId, 1); + final List viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned", + "FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1); + final List viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned", + "FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1); + final List totalVI = sumOf(viA, viB, viC); + lines.add(formOneTotal("TOTAL OF VI", totalVI)); + + lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH")); + final List viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned", + "FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1); + final List viiB = formOneLine(lines, "(b) District Central Co-operative Bank", + "FORM1_VII_B_DCCB_OTHER", dates, officeId, 1); + final List viiC = formOneLine(lines, "(c) Investment in Government securities", + "FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1); + final List totalVII = sumOf(viiA, viiB, viiC); + lines.add(formOneTotal("TOTAL OF VII", totalVII)); + + // VIII: only the excess counts. Where the banking system's claim on this bank exceeds the + // bank's own balance there is nothing to carry into the cash reserve, so it floors at zero. + final List viii = new ArrayList<>(); + for (int i = 0; i < dates.size(); i++) { + viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO)); + } + lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii)); + + // The base liabilities are IV(b) above - it used to be repeated as a row of its own at the + // foot of the part, which put the same figure on the form twice under two different names. + final Map part = new LinkedHashMap<>(); + part.put("title", "PART A"); + part.put("lines", lines); + part.put("baseDates", ivBaseDates); + part.put("baseDateLiabilities", ivBase); + return part; + } + + /** Part B: compliance with section 18 — the cash reserve. */ + private Map formOnePartB(final List dates, final List bases, final Long officeId, final Rate crr) { + final List> lines = new ArrayList<>(); + final List required = new ArrayList<>(); + final List maintained = new ArrayList<>(); + + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); + required.add(crr == null ? BigDecimal.ZERO + : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP)); + // X = V + VI + VIII, the form's own definition. + final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount; + final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount) + .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount); + final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO); + maintained.add(cash.add(currentAccounts).add(netCurrent)); + } + + // The rate is named against IV(b), not IV, because that is the figure it is charged on. The + // template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's + // base rule is the matching close of the previous month, so the line says IV(b) and leaves + // the base date itself to the column heading rather than claiming a Friday it did not use. + lines.add(formOneTotal("IX. CRR REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"), + required)); + lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained)); + + final Map part = new LinkedHashMap<>(); + part.put("title", "PART B — Compliance with Section 18"); + part.put("lines", lines); + part.put("required", required); + part.put("maintained", maintained); + part.put("surplus", differenceOf(maintained, required)); + return part; + } + + /** Part C: compliance with section 24 — the liquid assets. */ + private Map formOnePartC(final List dates, final List bases, final Long officeId, final Rate slr) { + final List> lines = new ArrayList<>(); + final List required = new ArrayList<>(); + final List cashAndOther = new ArrayList<>(); + final List gold = new ArrayList<>(); + final List securities = new ArrayList<>(); + + for (int i = 0; i < dates.size(); i++) { + final LocalDate date = dates.get(i); + final BigDecimal base = formOneNetLiabilities(baseFor(bases, dates, i), officeId); + final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO + : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + required.add(slrRequired); + + // XII(a) is the cash reserve held OVER the section 18 requirement, plus the other + // balances of part VII. Only the excess counts: the part of the cash reserve that meets + // section 18 is already committed and cannot also meet section 24. + final BigDecimal crrRequired = formOnePartBRequired(date, officeId); + final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId); + final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO); + final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount + .add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount) + .add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount); + cashAndOther.add(excessCash.add(otherBalances)); + gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount); + securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount); + } + + // Charged on IV(b) like the cash reserve above, and named the same way. + lines.add(formOneTotal("XI. SLR REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"), + required)); + lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED")); + lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1)); + lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1)); + lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1)); + final List totalXII = sumOf(cashAndOther, gold, securities); + lines.add(formOneTotal("TOTAL OF XII", totalXII)); + + final Map part = new LinkedHashMap<>(); + part.put("title", "PART C — Compliance with Section 24"); + part.put("lines", lines); + part.put("required", required); + part.put("maintained", totalXII); + part.put("surplus", differenceOf(totalXII, required)); + return part; + } + + /** + * Every day of the period the form covers, with what each ratio required that day and what was + * held against it. + * + *

+ * The form itself reports two closes, and a bank that held nothing for thirteen days and topped + * up on the fourteenth has a form that reads as compliant. Both ratios are reckoned over the + * whole period, so the day-by-day position is what the compliance actually turns on - and it is + * the figure an inspector asks for first. + * + *

+ * Each day is charged on ITS OWN base, read through the same rule the columns use, so a day in + * the first fortnight is measured against a different requirement from one in the second. A + * single required amount applied across the month would show every day of one fortnight passing + * or failing for the wrong reason. + * + * @param from the first day reported, usually the start of the month the closes fall in. + * @param to the last, which is the later close. + */ + private Map formOneDaily(final LocalDate from, final LocalDate to, final Long officeId, + final Rate crr, final Rate slr) { + final List> days = new ArrayList<>(); + BigDecimal crrTotal = BigDecimal.ZERO; + BigDecimal slrTotal = BigDecimal.ZERO; + BigDecimal crrLowest = null; + BigDecimal slrLowest = null; + LocalDate crrLowestOn = null; + LocalDate slrLowestOn = null; + int crrShortDays = 0; + int slrShortDays = 0; + + for (LocalDate day = from; !day.isAfter(to); day = day.plusDays(1)) { + final BigDecimal base = formOneNetLiabilities(formOneBaseDate(day), officeId); + final BigDecimal crrRequired = crr == null ? BigDecimal.ZERO + : base.multiply(crr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal slrRequired = slr == null ? BigDecimal.ZERO + : base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP); + + final BigDecimal crrHeld = formOnePartBMaintained(day, officeId); + // SLR counts the cash held OVER the section 18 requirement, plus the other balances and + // the securities - the same definition Part C uses, so the two cannot disagree. + final BigDecimal excessCash = crrHeld.subtract(crrRequired).max(BigDecimal.ZERO); + final BigDecimal slrHeld = excessCash + .add(figureFor("FORM1_VII_A_STATE_COOP_OTHER", day, officeId).amount) + .add(figureFor("FORM1_VII_B_DCCB_OTHER", day, officeId).amount) + .add(figureFor("FORM1_VII_C_GOVT_SECURITIES", day, officeId).amount) + .add(figureFor("FORMA_SLR_GOLD", day, officeId).amount) + .add(figureFor("SLR_INVESTMENTS", day, officeId).amount); + + final BigDecimal crrSurplus = crrHeld.subtract(crrRequired); + final BigDecimal slrSurplus = slrHeld.subtract(slrRequired); + if (crrSurplus.compareTo(BigDecimal.ZERO) < 0) { + crrShortDays++; + } + if (slrSurplus.compareTo(BigDecimal.ZERO) < 0) { + slrShortDays++; + } + + crrTotal = crrTotal.add(crrHeld); + slrTotal = slrTotal.add(slrHeld); + if (crrLowest == null || crrHeld.compareTo(crrLowest) < 0) { + crrLowest = crrHeld; + crrLowestOn = day; + } + if (slrLowest == null || slrHeld.compareTo(slrLowest) < 0) { + slrLowest = slrHeld; + slrLowestOn = day; + } + + final Map row = new LinkedHashMap<>(); + row.put("date", day.toString()); + row.put("baseDate", formOneBaseDate(day).toString()); + // The liabilities the day was charged on, stated rather than left to be divided back out + // of the requirement: the CIMS daily sheets carry it as a column of its own, and on a day + // whose rate is unset the requirement is zero, which no division recovers a base from. + row.put("baseLiabilities", base); + // The day's OWN liabilities, which the base above does not show. The base holds still + // for a whole fortnight, so a table carrying only it cannot say whether the bank's + // liabilities rose or fell on any particular day - every row of the fortnight reads the + // same. This moves daily, and the gap between the two is the lag the rules build in. + row.put("ownLiabilities", formOneNetLiabilities(day, officeId)); + row.put("crrRequired", crrRequired); + row.put("crrMaintained", crrHeld); + row.put("crrSurplus", crrSurplus); + row.put("slrRequired", slrRequired); + row.put("slrMaintained", slrHeld); + row.put("slrSurplus", slrSurplus); + days.add(row); + } + + final Map out = new LinkedHashMap<>(); + out.put("from", from.toString()); + out.put("to", to.toString()); + out.put("days", days); + out.put("dayCount", days.size()); + final BigDecimal count = BigDecimal.valueOf(Math.max(days.size(), 1)); + out.put("crrAverage", crrTotal.divide(count, 2, RoundingMode.HALF_UP)); + out.put("slrAverage", slrTotal.divide(count, 2, RoundingMode.HALF_UP)); + out.put("crrLowest", crrLowest == null ? BigDecimal.ZERO : crrLowest); + out.put("crrLowestOn", crrLowestOn == null ? null : crrLowestOn.toString()); + out.put("slrLowest", slrLowest == null ? BigDecimal.ZERO : slrLowest); + out.put("slrLowestOn", slrLowestOn == null ? null : slrLowestOn.toString()); + // Counted rather than left for a reader to spot: a single day short is a breach, and on a + // table of thirty rows it is exactly what the eye skips. + out.put("crrShortDays", crrShortDays); + out.put("slrShortDays", slrShortDays); + return out; + } + + private BigDecimal formOnePartBRequired(final LocalDate date, final Long officeId) { + final Rate crr = rateInForce("CRR", date); + if (crr == null) { + return BigDecimal.ZERO; + } + return formOneNetLiabilities(formOneBaseDate(date), officeId).multiply(crr.percentage).divide(HUNDRED, 2, + RoundingMode.HALF_UP); + } + + private BigDecimal formOnePartBMaintained(final LocalDate date, final Long officeId) { + final BigDecimal cash = figureFor("CASH_IN_HAND", date, officeId).amount; + final BigDecimal currentAccounts = figureFor("FORM1_VI_A_RBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_VI_B_STATE_COOP_CURRENT", date, officeId).amount) + .add(figureFor("FORM1_VI_C_DCCB_CURRENT", date, officeId).amount); + final BigDecimal netCurrent = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .subtract(figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount).max(BigDecimal.ZERO); + return cash.add(currentAccounts).add(netCurrent); + } + + /** + * The base date a column's ratios are charged on. + * + *

+ * Taken from the list the caller supplied, so a base entered by hand reaches every part of the + * form rather than only the column heading. Where no list was given - or it is shorter than the + * columns - the statutory rule applies. + */ + private LocalDate baseFor(final List bases, final List dates, final int index) { + if (bases != null && index < bases.size() && bases.get(index) != null) { + return bases.get(index); + } + return formOneBaseDate(dates.get(index)); + } + + /** + * The date a column's ratios are charged on: the previous month's matching fortnight close. + * + *

+ * Which fortnight the column falls in settles it, not the day it carries. The 1st to the 15th + * charge on the 15th of the previous month; anything later charges on the previous month's END, + * whatever day that is. So 30 September charges on 31 August - and so does 20 September, since + * both sit in the second fortnight and the second fortnight closes with the month. + * + *

+ * Keyed on the fortnight rather than on the day number because the two closes are not the same + * kind of date: the 15th is fixed, the month end moves between 28 and 31. Matching day for day + * would charge 30 September on 30 August, a date that closes nothing. + * + *

+ * RBI's own wording is "the last Friday of the second preceding fortnight", and that rule has + * been tried here twice. Both times it was taken out again for the same reason: it puts the base + * on a weekday that moves and roughly six weeks back, and the dates it produces do not match the + * returns this bank has already filed by hand. The fortnight closes this bank files on are the + * 15th and the month end. Where a particular return needs some other base, it can be entered by + * hand through {@code baseFor} above, which is why that path exists. + */ + private LocalDate formOneBaseDate(final LocalDate columnDate) { + final LocalDate previousMonthEnd = columnDate.withDayOfMonth(1).minusDays(1); + // Which fortnight the column falls in settles the base, not the day it carries. The first + // fortnight closes on the 15th; everything after it closes on the month end. + return columnDate.getDayOfMonth() <= 15 ? previousMonthEnd.withDayOfMonth(15) : previousMonthEnd; + } + + /** + * The close of the fortnight a date falls in: the 15th for the first half of a month, the last + * day of the month for the second. + */ + private static LocalDate fortnightCloseOnOrAfter(final LocalDate date) { + return date.getDayOfMonth() <= 15 ? date.withDayOfMonth(15) : date.with(TemporalAdjusters.lastDayOfMonth()); + } + + /** The close of the fortnight before the one ending on the given close. */ + private static LocalDate fortnightCloseBefore(final LocalDate close) { + return close.getDayOfMonth() <= 15 + // First half of a month: the fortnight before it ends with the previous month. + ? close.minusMonths(1).with(TemporalAdjusters.lastDayOfMonth()) + // Second half: the fortnight before it ends on the 15th of the same month. + : close.withDayOfMonth(15); + } + + /** Item IV computed for one date: (I − III floored at zero) + II. */ + private BigDecimal formOneNetLiabilities(final LocalDate date, final Long officeId) { + final BigDecimal toBankingSystem = figureFor("FORM1_I_A_I_SBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_I_A_II_OTHER_DEMAND_BS", date, officeId).amount) + .add(figureFor("FORM1_I_B_TIME_BS", date, officeId).amount); + final BigDecimal withBankingSystem = figureFor("FORM1_III_A_SBI_CURRENT", date, officeId).amount + .add(figureFor("FORM1_III_B_I_OTHER_BALANCES", date, officeId).amount) + .add(figureFor("FORM1_III_B_II_MONEY_AT_CALL_BS", date, officeId).amount) + .add(figureFor("FORM1_III_B_IV_ADVANCES_BS", date, officeId).amount); + final BigDecimal toOthers = figureFor("DEMAND_TIME_LIABILITIES", date, officeId).amount; + return toBankingSystem.subtract(withBankingSystem).max(BigDecimal.ZERO).add(toOthers); + } + + // ── Form 1 row builders ── + + private static Map formOneHeading(final String label) { + final Map row = new LinkedHashMap<>(); + row.put("label", label); + row.put("heading", true); + return row; + } + + private static Map formOneRow(final String label, final String configKey, + final List values, final int indent) { + final Map row = new LinkedHashMap<>(); + row.put("label", label); + row.put("configKey", configKey); + row.put("indent", indent); + row.put("values", values); + return row; + } + + private static Map formOneTotal(final String label, final List values) { + final Map row = formOneRow(label, null, values, 0); + row.put("total", true); + return row; + } + + /** Reads one head across every column and appends its row. */ + private List formOneLine(final List> lines, final String label, + final String configKey, final List dates, final Long officeId, final int indent) { + final List values = new ArrayList<>(); + boolean configured = false; + for (final LocalDate date : dates) { + final RoleFigure figure = figureFor(configKey, date, officeId); + configured |= figure.configured; + values.add(figure.amount); + } + final Map row = formOneRow(label, configKey, values, indent); + row.put("configured", configured); + lines.add(row); + return values; + } + + @SafeVarargs + private static List sumOf(final List... columns) { + final List out = new ArrayList<>(); + final int size = columns.length == 0 ? 0 : columns[0].size(); + for (int i = 0; i < size; i++) { + BigDecimal total = BigDecimal.ZERO; + for (final List column : columns) { + total = total.add(column.get(i)); + } + out.add(total); + } + return out; + } + + private static List differenceOf(final List a, final List b) { + final List out = new ArrayList<>(); + for (int i = 0; i < a.size(); i++) { + out.add(a.get(i).subtract(b.get(i))); + } + return out; + } + + /** + * Form 9 in the shape it is filed - the statutory statement of compliance with section 24. + * + *

+ * The same Part A and Part C the statutory Form 1 carries, on this return's own dates. Form 1 + * reports two fortnight closes because it is filed fortnightly; Form 9 is filed monthly, so it + * reports the month it covers and the month before, and charges the ratio on the last Friday of + * the second preceding fortnight exactly as Form 1 does. + * + *

+ * Part B is left out. That part settles section 18, which is the cash reserve - Form 1's + * subject, not this one's. Printing it here would put a CRR compliance statement on an SLR + * return, where a reader has no reason to expect one. + * + * @param reportingDate any date in the month; the return reports that month's end. + * @param officeId Optional - scopes every figure to one office. + */ + @GET + @Path("form9-statutory") + public String form9Statutory(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + final LocalDate today = DateUtils.getBusinessLocalDate(); + // A month end that has not arrived would report today's balances under its heading - a month + // still running filed as a closed one. + final LocalDate currentClose = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); + final LocalDate previousClose = requested.withDayOfMonth(1).minusDays(1); + final LocalDate base = formOneBaseDate(currentClose); + + // Two columns, as on Form 1. The base is stated against the lines it settles rather than + // given a column, where its balances would read as a third period being reported. + final List columnDates = List.of(previousClose, currentClose); + final List baseDates = List.of(formOneBaseDate(previousClose), base); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9_STATUTORY"); + response.put("formName", "Form 9 - Statement of Compliance with Section 24 (SLR)"); + response.put("statutoryReference", "Banking Regulation (Co-operative Societies) Rules, 1966"); + response.put("frequency", "Monthly"); + response.put("reportingDate", currentClose.toString()); + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); + response.put("columns", columnDates.stream().map(LocalDate::toString).toList()); + response.put("baseColumns", baseDates.stream().map(LocalDate::toString).toList()); + + final Rate crr = rateInForce("CRR", currentClose); + final Rate slr = rateInForce("SLR", currentClose); + response.put("crrPercentage", crr == null ? null : crr.percentage); + response.put("slrPercentage", slr == null ? null : slr.percentage); + + // Part A establishes the liabilities both ratios are charged on, so it is needed here even + // though only section 24 is settled below: without it the required amount at XI is a figure + // with nothing behind it. + response.put("partA", formOnePartA(columnDates, baseDates, officeId, crr, slr)); + response.put("partC", formOnePartC(columnDates, baseDates, officeId, slr)); + // Every day of the month, as on Form 1. Section 24 is reckoned over the period too, so a + // month-end position that passes says nothing about the days behind it. + response.put("daily", formOneDaily(currentClose.withDayOfMonth(1), currentClose, officeId, crr, slr)); + + final List unmet = new ArrayList<>(); + if (slr == null) { + unmet.add("No SLR rate is on record effective on or before the reporting date."); + } + if (crr == null) { + // Not an aside: XII(a) counts only the cash reserve held OVER the section 18 + // requirement, so without a CRR rate that line takes the whole reserve and overstates + // what is available to meet section 24. + unmet.add("No CRR rate is on record. The cash counted at XII(a) is the reserve held over " + + "the section 18 requirement, so without a CRR rate that line is overstated."); + } + if (!monthComplete) { + unmet.add("The month ending " + nominalMonthEnd + " has not closed. This is the position " + + "as at " + currentClose + ", not the month-end position the return reports."); + } + // As on Form 1: a nil base makes the requirement nil, and the form then reads the same as a + // bank with nothing to maintain. Said with the date so the nil can be read for what it is. + if (formOneNetLiabilities(base, officeId).compareTo(BigDecimal.ZERO) == 0) { + unmet.add("The ratio is charged on the net liabilities of " + base + + ", and the ledger holds none on that date. The required amount is therefore " + + "nil, because the base is nil."); + } + response.put("unmetRequirements", unmet); + return gson.toJson(response); + } + + // ══════════════ Form IX - Return on Assets and Liabilities (R408) ══════════════ + + /** Rupees to rupees-thousands. Form IX reports in thousands; Form 1 reports in rupees. */ + private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); + + /** role_group for Form IX heads, so they sit apart from Form 1's on the configuration screen. */ + private static final String GROUP_FORM9 = "FORM9"; + + private static final String USAGE_ASSET = "ASSET"; + + /** + * Form IX - the Return on Assets and Liabilities a co-operative bank files monthly, return code + * R408. + * + *

+ * A different return from {@code form9} above despite the name. That one reports a ratio: the + * liquid assets held against the liabilities, and whether the bank complied. This one reports + * the bank's whole balance sheet - every class of liability and every class of asset - and its + * two sides must agree. + * + *

+ * Three things about it differ from every other return here: + * + *

    + *
  • Thousands. Every figure is in rupees thousands. The conversion happens once, on a + * finished total, never per account - see {@code thousands}.
  • + *
  • Unmapped heads still print. Elsewhere a head with nothing mapped is left off. Here + * a nil line is a real answer - a bank with no borrowings from the EXIM Bank files that line + * empty - so every head appears, with whether it was configured stated beside it.
  • + *
  • Both sides are totalled independently and their difference reported. The filed + * return this was built from balanced its sides by subtracting the difference from the side + * that was already short, and printed "DIFF ADJUSTED" rather than saying the balance did not + * hold. A figure is never adjusted here to make the form balance.
  • + *
+ * + * @param reportingDate any date in the month; the return reports that month's end. + * @param officeId Optional - scopes every figure to one office. + */ + @GET + @Path("form9-return") + public String form9Return(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate requested = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), + "The reporting date"); + final LocalDate nominalMonthEnd = requested.with(TemporalAdjusters.lastDayOfMonth()); + final LocalDate today = DateUtils.getBusinessLocalDate(); + // Run inside the current month, the month end has not arrived. Reading balances at it would + // return today's figures under the month-end heading - a month still running reported as a + // closed one, with nothing on the return to say so. + final LocalDate asOn = minimum(nominalMonthEnd, today); + final boolean monthComplete = !nominalMonthEnd.isAfter(today); + + final Map response = new LinkedHashMap<>(); + response.put("formCode", "FORM_9_RETURN"); + response.put("formName", "Form IX - Return on Assets and Liabilities"); + response.put("returnCode", "R408"); + response.put("statutoryReference", "The Banking Regulation Act - Form IX"); + response.put("frequency", "Monthly"); + response.put("unit", "Rs. in Thousands"); + response.put("reportingDate", asOn.toString()); + response.put("monthEnd", nominalMonthEnd.toString()); + response.put("monthComplete", monthComplete); + response.put("note", monthComplete + ? "Every figure is a closing balance as at " + asOn + ", in rupees thousands." + : "The month ending " + nominalMonthEnd + " has not closed. Figures are the position " + + "as at " + asOn + " and will move before the month ends."); + + // Every Form IX head, mapped or not, in the order the form prints them. + final List> roles = jdbcTemplate.queryForList(""" + SELECT config_key, label, role_usage + FROM m_rbi_reserve_role + WHERE role_group = ? + ORDER BY position, label + """, GROUP_FORM9); + + final Map> byKey = new LinkedHashMap<>(); + BigDecimal liabilityTotal = BigDecimal.ZERO; + BigDecimal assetTotal = BigDecimal.ZERO; + final List> liabilityLines = new ArrayList<>(); + final List> assetLines = new ArrayList<>(); + final List> memoLines = new ArrayList<>(); + int unmappedCount = 0; + + for (final Map role : roles) { + final String key = (String) role.get("config_key"); + final String usage = (String) role.get("role_usage"); + final RoleFigure figure = figureFor(key, asOn, officeId); + + final Map row = new LinkedHashMap<>(); + row.put("configKey", key); + row.put("label", role.get("label")); + row.put("usage", usage); + row.put("configured", figure.configured); + // Rupees as well as thousands. The rupee figure is what the ledger actually holds, and a + // reviewer checking a line against the trial balance needs it: a thousands figure alone + // cannot be reconciled, because the rounding already happened. + row.put("amountInRupees", figure.configured ? figure.amount : null); + row.put("amount", figure.configured ? thousands(figure.amount) : null); + row.put("accounts", accountsInThousands(figure)); + byKey.put(key, row); + + if (!figure.configured) { + unmappedCount++; + } + + if (USAGE_LIABILITY.equals(usage)) { + liabilityLines.add(row); + liabilityTotal = liabilityTotal.add(figure.amount); + } else if (USAGE_ASSET.equals(usage)) { + assetLines.add(row); + assetTotal = assetTotal.add(figure.amount); + } else { + memoLines.add(row); + } + } + + // A loss belongs on the asset side at item 12, not on the liability side as a negative + // item 9. The form carries two separate lines because the year's result goes to one of them + // or the other, never to both: a bank that made a loss files item 9 empty and item 12 with + // the figure. Reported as a negative liability instead it pulls the liability total DOWN by + // the loss, when a loss makes the two sides agree by being added to the assets - so the + // return came out short by twice the loss and said the balance did not hold. + // + // Moved rather than left with a caveat, because this one has a right answer: the figure is + // correct, only its side is wrong, and the form itself says which side that is. + // ── The year's own result ── + // + // Income and expense accounts hold the year's trading, and no balance-sheet head maps them + // because they are not balance-sheet accounts. But their NET is - it is the profit or loss + // the balance sheet closes with, and without it the two sides cannot agree by exactly that + // amount. + // + // What the equity profit accounts hold is LAST year's result and what has been appropriated + // out of it - a year-end closing entry moved it there when the books were closed, and the + // dividend and reserve transfers drew it down. That is a balance-sheet figure in its own + // right and is already reported through the accounts mapped to this head. + // + // This year's trading has not been closed to anywhere, so it is added on top. Adding it to + // what the equity accounts hold is not double counting: the year-end entry that would move + // it there has not run, which is exactly why the two sides were out by this amount. + // + // The whole figure - brought forward plus this year - then goes to ONE of the two lines the + // form carries for it. Positive it is item 9 on the liability side, negative it is item 12 + // on the asset side, and never a share of each. Two passes were tried, one adding the result + // and a second moving a negative across, and the second subtracted the whole balance from a + // total the first had already adjusted - so the return moved by the result twice. + final BigDecimal tradingResult = unclosedTradingResult(asOn, officeId); + final Map profitRow = byKey.get("FORM9_L9_BALANCE_OF_PROFIT"); + final Map lossRow = byKey.get("FORM9_A12_BALANCE_OF_LOSS"); + if (profitRow != null) { + final BigDecimal broughtForward = profitRow.get("amountInRupees") == null ? BigDecimal.ZERO + : (BigDecimal) profitRow.get("amountInRupees"); + final BigDecimal result = broughtForward.add(tradingResult); + + // Whatever the profit head contributed to the liability total in the loop above is taken + // back out, so the result can be placed once, on whichever side it belongs. + liabilityTotal = liabilityTotal.subtract(broughtForward); + + // Two figures, each named: what the equity accounts hold, and what the income and + // expense accounts still hold because no year-end entry has closed them. A reviewer + // checking this line against the ledger needs both, since neither account alone shows it. + final String provenance = "Equity accounts mapped here: " + rupees(broughtForward) + + ". Income and expense accounts not yet closed to equity: " + rupees(tradingResult) + "."; + + if (result.compareTo(BigDecimal.ZERO) < 0 && lossRow != null) { + final BigDecimal loss = result.negate(); + assetTotal = assetTotal.add(loss); + + // The accounts move WITH the figure, they are not copied to both lines. Item 9 now + // reports nil and has nothing to show; listing its accounts under a nil total said + // the bank held a profit made up of two balances that came to nothing. Worse, they + // were also printed under item 12 with their signs flipped, so the same account + // appeared twice with opposite figures and read as counted twice. + final Object accountsBehind = profitRow.get("accounts"); + profitRow.put("amountInRupees", BigDecimal.ZERO); + profitRow.put("amount", BigDecimal.ZERO); + profitRow.put("accounts", List.of()); + profitRow.put("note", "Nil — the bank stands in loss, so the figure is at item 12 on " + + "the asset side. " + provenance); + + lossRow.put("configured", true); + lossRow.put("amountInRupees", loss); + lossRow.put("amount", thousands(loss)); + // Exactly as the ledger holds them, signs and all. A loss IS a debit balance on an + // equity account, so showing it as one is the honest reading - and the unclosed + // trading is added as its own row because no account carries it. The three add to + // the loss, read as negatives, which is what a reviewer tracing the figure expects. + lossRow.put("accounts", withTradingRow(accountsBehind, tradingResult)); + // The rows below carry the OPPOSITE sign to the head, because a loss reported as a + // positive figure on the asset side is made up of balances that are negative on the + // liability side. Saying so lets the screen head the column "as the ledger holds + // it", rather than leaving a reader to wonder how three negatives make a positive. + lossRow.put("accountsMirrorHead", true); + // Short, because the row above the balances already says they read the other way + // round. What belongs here is where the figure came from, not how to read it. + lossRow.put("note", "The bank stands in loss, so the figure is reported here rather " + + "than as a negative profit at item 9. " + provenance); + } else { + profitRow.put("configured", true); + profitRow.put("amountInRupees", result); + profitRow.put("amount", thousands(result)); + profitRow.put("note", provenance); + liabilityTotal = liabilityTotal.add(result); + } + } + + // ── Overdrawn holding accounts ── + // + // A liability that has been drawn past nil is not a liability at all: the bank has paid out + // more than it owed, and what is left is money owed BACK to it. On a balance sheet that is + // an asset, and the form has a line for exactly this - 11(i) Other Assets, tangible. + // + // Reported there rather than left as a negative liability. A negative on the liability side + // pulls the liability total down by the overdraw AND leaves the asset it really is off the + // asset side, so the balance breaks by twice the figure - the same way a loss did before it + // was moved to item 12. It is also unreadable: minus one thousand under "Other Demand + // Liabilities" looks like a small liability rather than a debt owed to the bank. + final Map otherAssets = byKey.get("FORM9_A11_I_TANGIBLE_ASSETS"); + if (otherAssets != null) { + BigDecimal overdrawn = BigDecimal.ZERO; + final List overdrawnAccounts = new ArrayList<>(); + for (final Map.Entry> keyed : byKey.entrySet()) { + // The profit head is exempt. Its accounts read negative because the bank is in + // loss, not because anyone drew them past nil, and the return has already carried + // that figure to item 12. Counting it here as well put the loss on the asset side + // twice - once at item 12 and once inside Other Assets - and inflated both sides of + // the return by it while still, by luck, agreeing. + if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey())) { + continue; + } + final Map row = keyed.getValue(); + if (!USAGE_LIABILITY.equals(row.get("usage"))) { + continue; + } + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + if (accounts == null) { + continue; + } + for (final Map account : accounts) { + final BigDecimal balance = (BigDecimal) account.get("balanceInRupees"); + if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) { + continue; + } + overdrawn = overdrawn.add(balance.negate()); + overdrawnAccounts.add(account.get("glCode") + " " + account.get("name")); + } + } + + if (overdrawn.compareTo(BigDecimal.ZERO) > 0) { + // Both totals move by the same amount and in the same direction: the liability side + // loses a negative, the asset side gains the positive it always was. + liabilityTotal = liabilityTotal.add(overdrawn); + assetTotal = assetTotal.add(overdrawn); + + final BigDecimal existing = otherAssets.get("amountInRupees") == null ? BigDecimal.ZERO + : (BigDecimal) otherAssets.get("amountInRupees"); + final BigDecimal total = existing.add(overdrawn); + otherAssets.put("configured", true); + otherAssets.put("amountInRupees", total); + otherAssets.put("amount", thousands(total)); + otherAssets.put("note", "Includes " + rupees(overdrawn) + " rupees drawn out of holding accounts " + + "beyond what they held (" + String.join(", ", overdrawnAccounts) + "). A liability " + + "paid past nil is money owed back to the bank, which is an asset."); + } + } + + response.put("liabilityLines", liabilityLines); + response.put("assetLines", assetLines); + response.put("memoLines", memoLines); + + // ── Sub-totals the form states on its face ── + // + // Summed in RUPEES and converted once. Adding up figures that were each already rounded to + // thousands is what put the two sides of the filed return 2 thousand apart. + final Map totals = new LinkedHashMap<>(); + totals.put("paidUpCapital", sumThousands(byKey, "FORM9_L1_A_CAPITAL_INDIVIDUALS", + "FORM9_L1_B_CAPITAL_COOP_SOCIETIES", "FORM9_L1_C_CAPITAL_STATE_GOVT", "FORM9_L1_D_CAPITAL_OTHERS")); + totals.put("reserveFund", sumThousands(byKey, "FORM9_L2_A_STATUTORY_RESERVE", "FORM9_L2_B_AGRI_CREDIT_FUND", + "FORM9_L2_C_DIVIDEND_EQUALISATION", "FORM9_L2_D_SPECIAL_BAD_DEBT", "FORM9_L2_E_BAD_DOUBTFUL_RESERVE", + "FORM9_L2_F_INVESTMENT_DEPRECIATION", "FORM9_L2_G_OTHER_FUNDS")); + totals.put("partnershipFund", sumThousands(byKey, "FORM9_L3_A_PARTNERSHIP_CCB", "FORM9_L3_B_PARTNERSHIP_PACS", + "FORM9_L3_C_PARTNERSHIP_OTHER")); + + final BigDecimal demandRupees = sumRupees(byKey, "FORM9_L4_A1_CURRENT_CCB", "FORM9_L4_A2_CURRENT_PCB", + "FORM9_L4_A3_CURRENT_SOCIETIES", "FORM9_L4_A4_CURRENT_INDIVIDUALS", "FORM9_L4_A5_CURRENT_RESERVE_FUND", + "FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS", "FORM9_L4_B_SAVINGS_DEMAND_OTHER", + "FORM9_L4_C_BORROWINGS_DEMAND", "FORM9_L4_D_OTHER_DEMAND"); + final BigDecimal timeRupees = sumRupees(byKey, "FORM9_L5_A1_FD_CCB", "FORM9_L5_A2_FD_PCB", + "FORM9_L5_A3_FD_SOCIETIES", "FORM9_L5_A4_FD_INDIVIDUALS", "FORM9_L5_A5_FD_RESERVE_FUND", + "FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS", "FORM9_L5_B_SAVINGS_TIME_OTHER", "FORM9_L5_C_BORROWINGS_TIME", + "FORM9_L5_D_CASH_CERT_RD", "FORM9_L5_E_OTHER_TIME"); + final BigDecimal branchRupees = rupeesOf(byKey, "FORM9_L6_BRANCH_ADJUSTMENTS"); + + totals.put("demandLiabilities", thousands(demandRupees)); + totals.put("timeLiabilities", thousands(timeRupees)); + totals.put("branchAdjustments", thousands(branchRupees)); + // Item 8 of the form: demand + time + branch adjustments, the figure CRR and SLR are charged + // on elsewhere. Converted from the rupee sum, not from the three thousands figures above. + totals.put("totalDemandAndTime", thousands(demandRupees.add(timeRupees).add(branchRupees))); + totals.put("balanceOfProfit", sumThousands(byKey, "FORM9_L9_BALANCE_OF_PROFIT")); + totals.put("totalLiabilities", thousands(liabilityTotal)); + + totals.put("cashInHand", sumThousands(byKey, "FORM9_A1_CASH_IN_HAND")); + totals.put("balanceWithBanksCurrent", sumThousands(byKey, "FORM9_A2_A_CUR_RBI", "FORM9_A2_B_CUR_SBI", + "FORM9_A2_C_CUR_BANKING_SYSTEM", "FORM9_A2_D_CUR_STATE_COOP", "FORM9_A2_E_CUR_DCCB", + "FORM9_A2_F_CUR_OTHER_COOP")); + totals.put("balanceWithBanksSavings", sumThousands(byKey, "FORM9_A2_SAV_BANKING_SYSTEM", "FORM9_A2_SAV_COOP")); + totals.put("moneyAtCall", sumThousands(byKey, "FORM9_A3_MONEY_AT_CALL")); + totals.put("investments", sumThousands(byKey, "FORM9_A4_A1_CENTRAL_GOVT_SEC", "FORM9_A4_A2_STATE_GOVT_SEC", + "FORM9_A4_B_LAND_MORTGAGE_DEBENTURES", "FORM9_A4_C_OTHER_TRUSTEE_SEC", "FORM9_A4_D_SHARES_STATE_COOP", + "FORM9_A4_E_SHARES_DCCB", "FORM9_A4_F_SHARES_OTHER_COOP", "FORM9_A4_G1_FD_SBI", + "FORM9_A4_G2_FD_BANKING_SYSTEM", "FORM9_A4_G3_FD_STATE_COOP", "FORM9_A4_G4_FD_DCCB", + "FORM9_A4_G5_FD_OTHER_COOP", "FORM9_A4_H_OTHER_INVESTMENTS")); + final BigDecimal advancesRupees = sumRupees(byKey, "FORM9_A6_A_SEASONAL_AGRI", "FORM9_A6_B_MARKETING_CROPS", + "FORM9_A6_C_MEDIUM_TERM_AGRI", "FORM9_A6_D_WEAVERS_SOCIETIES", "FORM9_A6_E_INDUSTRIAL_SHORT", + "FORM9_A6_E_INDUSTRIAL_MEDIUM", "FORM9_A6_F_OTHER_SHORT", "FORM9_A6_F_OTHER_MEDIUM", + "FORM9_A6_F_OTHER_LONG"); + totals.put("loansAndAdvances", thousands(advancesRupees)); + totals.put("otherAssets", sumThousands(byKey, "FORM9_A11_I_TANGIBLE_ASSETS", "FORM9_A11_II_INTANGIBLE_ASSETS")); + totals.put("totalAssets", thousands(assetTotal)); + + // The form's two explanation percentages, computed from the advances they qualify. + final BigDecimal overdueRupees = rupeesOf(byKey, "FORM9_A6_G_OVERDUE_ADVANCES"); + final BigDecimal priorityRupees = rupeesOf(byKey, "FORM9_A6_G_PRIORITY_SECTOR"); + totals.put("overduePercentage", percentageOf(overdueRupees, advancesRupees)); + totals.put("prioritySectorPercentage", percentageOf(priorityRupees, advancesRupees)); + response.put("totals", totals); + + // ── Reconciliation ── + // + // Stated whether or not it holds. A return whose sides disagree is a return with something + // unmapped or double-mapped, and saying so is the only way anyone finds out which. + final BigDecimal differenceRupees = liabilityTotal.subtract(assetTotal); + final Map reconciliation = new LinkedHashMap<>(); + reconciliation.put("totalLiabilities", thousands(liabilityTotal)); + reconciliation.put("totalAssets", thousands(assetTotal)); + reconciliation.put("differenceInRupees", differenceRupees); + reconciliation.put("difference", thousands(differenceRupees)); + reconciliation.put("balanced", differenceRupees.compareTo(BigDecimal.ZERO) == 0); + response.put("reconciliation", reconciliation); + + final List caveats = new ArrayList<>(); + if (differenceRupees.compareTo(BigDecimal.ZERO) != 0) { + caveats.add("The two sides of the return do not agree: liabilities exceed assets by " + + rupees(differenceRupees) + " rupees. A head is unmapped, mapped twice, or mapped to the " + + "wrong side. The figures are left as the ledger reports them."); + } + + // A liability with a negative balance, or an asset with one, is always a ledger fault - the + // bank cannot owe less than nothing, nor hold less than nothing. It arrives as an ordinary + // figure on the face of the return and reads as a small number rather than as a broken one, + // and it is exactly the kind of thing that puts the two sides out of agreement. Named here + // so whoever reads the return knows which account to look at. + for (final Map.Entry> keyed : byKey.entrySet()) { + // The profit and loss heads are exempt. A bank that has made a loss holds it on an + // equity account as a debit, and that account reads negative for a perfectly good + // reason - the return has already moved the figure to item 12 on the asset side, where + // it belongs. Flagging it would report the year's loss as a ledger fault. + if ("FORM9_L9_BALANCE_OF_PROFIT".equals(keyed.getKey()) + || "FORM9_A12_BALANCE_OF_LOSS".equals(keyed.getKey())) { + continue; + } + final Map row = keyed.getValue(); + final String usage = (String) row.get("usage"); + final String side = USAGE_LIABILITY.equals(usage) ? "A liability" : USAGE_ASSET.equals(usage) ? "An asset" : "A head"; + + // Each ACCOUNT, not only the head's total. A negative account inside a head that totals + // positive is invisible in the total - "4(d) Other Demand Liabilities" reads 140,826 + // while one of the four accounts under it sits at minus a thousand - and that is the + // case most worth catching, because nothing about the printed figure looks wrong. + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + boolean namedAnAccount = false; + if (accounts != null) { + for (final Map account : accounts) { + final BigDecimal balance = (BigDecimal) account.get("balanceInRupees"); + if (balance == null || balance.compareTo(BigDecimal.ZERO) >= 0) { + continue; + } + namedAnAccount = true; + final String where = USAGE_LIABILITY.equals(usage) + // Already carried to the asset side above, so the note says where it + // went rather than asking the reader to go and find it. + ? " It is reported at 11(i) Other Assets, because a liability paid past " + + "nil is money owed back to the bank." + : ""; + caveats.add(side + " has been drawn past nil: " + account.get("glCode") + " " + + account.get("name") + ", under \"" + row.get("label") + "\", stands at " + + rupees(balance) + " rupees. Either an entry was posted to it that should have " + + "gone elsewhere, or the entry that should have raised it never ran." + + where); + } + } + + // The head itself, where no account under it accounts for the sign - a derived figure, + // or a head whose accounts are individually fine but sum below zero. + final BigDecimal headBalance = (BigDecimal) row.get("amountInRupees"); + if (!namedAnAccount && headBalance != null && headBalance.compareTo(BigDecimal.ZERO) < 0) { + caveats.add(side + " cannot hold a negative balance: \"" + row.get("label") + "\" reports " + + rupees(headBalance) + " rupees. Either an entry was posted to it that should have gone " + + "elsewhere, or the entry that should have raised it never ran."); + } + } + // Named, not counted. A difference tells a filer THAT something is missing; this tells them + // WHICH account, which is the only form of the answer they can act on. Without it the gap + // has to be hunted down by reading the trial balance against the return line by line - and + // it comes back every time somebody opens a GL account and forgets to map it. + for (final Map account : unmappedAccountsWithBalances(asOn, officeId, byKey)) { + caveats.add("Not on this return: " + account.get("glCode") + " " + account.get("name") + " holds " + + rupees((java.math.BigDecimal) account.get("balance")) + " rupees but is mapped to no head, so it is counted on " + + "neither side. Map it under Admin, or the return stays out by this amount."); + } + + if (unmappedCount > 0) { + // Not an error. A nil line is a real answer on this return, so this says what it is - + // how much of the form has been configured - rather than calling it a failure. + caveats.add(unmappedCount + " of " + roles.size() + " heads have no GL account mapped and " + + "report nil. On this return a nil line is a real answer; map any head the bank " + + "does hold a balance under."); + } + if (!monthComplete) { + caveats.add("The month has not closed. This is the position so far, not the month-end " + + "position the return reports."); + } + response.put("caveats", caveats); + response.put("computable", true); + + return gson.toJson(response); + } + + /** + * Rupees to rupees-thousands, rounded half-up. + * + *

+ * Called on a finished total and never on an individual account. Rounding each account and then + * adding the results drifts by up to half a thousand per account, and on the filed return this + * was built from that drift put the two sides 2 thousand apart - which the report then hid + * behind a "DIFF ADJUSTED" line. + */ + /** + * Every balance-sheet account with a balance, and which head of this return reports it. + * + *

+ * A trial balance read through the return's own eyes. It exists because a difference between the + * two sides can only be chased by comparing what the ledger holds against what the return counts, + * and doing that by hand across a chart of accounts is where the time goes. Every figure here is + * read by the same code the return itself uses, so the two cannot disagree about an account. + */ + @GET + @Path("form9-trial-balance") + public String form9TrialBalance(@QueryParam("reportingDate") final String reportingDateParam, + @QueryParam("officeId") final Long officeId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + + final LocalDate asOn = requirePastOrPresent(parseOrBusinessDate(reportingDateParam), "The reporting date") + .with(TemporalAdjusters.lastDayOfMonth()); + + // Which head, if any, claims each account - an account can be claimed by more than one, and + // that is itself worth seeing. + final Map> heads = new LinkedHashMap<>(); + for (final Map row : jdbcTemplate.queryForList(""" + SELECT c.gl_account_id, r.config_key, c.weight_percentage + FROM m_rbi_reserve_gl_config c + JOIN m_rbi_reserve_role r ON r.config_key = c.config_key + WHERE r.role_group = ? + """, GROUP_FORM9)) { + final Long accountId = ((Number) row.get("gl_account_id")).longValue(); + heads.computeIfAbsent(accountId, k -> new ArrayList<>()) + .add(row.get("config_key") + " @" + row.get("weight_percentage") + "%"); + } + + final StringBuilder sql = new StringBuilder(""" + SELECT a.id, a.gl_code, a.name, a.classification_enum, + COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE 0 END), 0) AS total_debit, + COALESCE(SUM(CASE WHEN j.type_enum = 1 THEN j.amount ELSE 0 END), 0) AS total_credit + FROM acc_gl_account a + JOIN acc_gl_journal_entry j ON j.account_id = a.id + WHERE a.account_usage = 1 + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code"); + + final List> accounts = new ArrayList<>(); + BigDecimal assets = BigDecimal.ZERO; + BigDecimal liabilities = BigDecimal.ZERO; + BigDecimal equity = BigDecimal.ZERO; + BigDecimal income = BigDecimal.ZERO; + BigDecimal expense = BigDecimal.ZERO; + + for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) { + final int classification = ((Number) row.get("classification_enum")).intValue(); + final BigDecimal debit = (BigDecimal) row.get("total_debit"); + final BigDecimal credit = (BigDecimal) row.get("total_credit"); + final boolean debitNatured = classification == ASSET || classification == EXPENSE; + final BigDecimal balance = debitNatured ? debit.subtract(credit) : credit.subtract(debit); + if (balance.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + switch (classification) { + case 1 -> assets = assets.add(balance); + case 2 -> liabilities = liabilities.add(balance); + case 3 -> equity = equity.add(balance); + case 4 -> income = income.add(balance); + default -> expense = expense.add(balance); + } + final Long id = ((Number) row.get("id")).longValue(); + final Map out = new LinkedHashMap<>(); + out.put("glCode", row.get("gl_code")); + out.put("name", row.get("name")); + out.put("classification", switch (classification) { + case 1 -> "ASSET"; + case 2 -> "LIABILITY"; + case 3 -> "EQUITY"; + case 4 -> "INCOME"; + default -> "EXPENSE"; + }); + out.put("balance", balance); + out.put("reportedUnder", heads.getOrDefault(id, List.of())); + accounts.add(out); + } + + final Map response = new LinkedHashMap<>(); + response.put("asOnDate", asOn.toString()); + response.put("accounts", accounts); + final Map totals = new LinkedHashMap<>(); + totals.put("assets", assets); + totals.put("liabilities", liabilities); + totals.put("equity", equity); + totals.put("income", income); + totals.put("expense", expense); + totals.put("tradingResult", income.subtract(expense)); + // Assets must equal liabilities plus equity plus the year's result. Where they do not, the + // ledger itself is out, and no return built on it can balance. + totals.put("assetsLessLiabilitiesEquityAndResult", + assets.subtract(liabilities).subtract(equity).subtract(income.subtract(expense))); + response.put("totals", totals); + return gson.toJson(response); + } + + /** + * The trading result the books have not yet closed: income less expense, as at the given date. + * + *

+ * Read from the income and expense accounts themselves rather than from any equity account. No + * balance-sheet head maps them, and rightly so - they are not balance-sheet accounts - but their + * net IS the profit or loss the balance sheet closes with. Left out, the two sides of the return + * disagree by exactly the year's result, and the figure that is missing is the one a reader + * would most want. + * + *

+ * No date range, and no rule about which year an entry belongs to. What matters is what the + * accounts still HOLD: a year-end closing entry nets the account it closes back to zero, so + * whatever has been closed already contributes nothing here, and whatever has not is still owed + * a place on the balance sheet however long ago it was earned. The ledger itself says which is + * which, and two attempts to say it another way were both wrong - filtering from 1 April left + * out earlier earnings that were never closed, and skipping entries with an equity leg also + * skipped the original income, whose own posting often carries one. + * + * @return income minus expense; negative is a loss. + */ + /** + * Balance-sheet accounts holding a balance that no head on this return maps. + * + *

+ * Every such account is a hole in the return of exactly its own balance, and the two sides + * cannot agree while one exists. The return already reports the difference; this says which + * account caused it, which is the only form of the answer anyone can act on. + * + *

+ * Income and expense accounts are excluded deliberately. They are not balance-sheet accounts and + * no head maps them; their net is reported as the year's result instead. + */ + private List> unmappedAccountsWithBalances(final LocalDate asOn, final Long officeId, + final Map> byKey) { + // Everything this return already accounts for, however many heads it is spread across. + final Set mapped = new HashSet<>(); + for (final Map row : byKey.values()) { + @SuppressWarnings("unchecked") + final List> accounts = (List>) row.get("accounts"); + if (accounts == null) { + continue; + } + for (final Map account : accounts) { + final Object id = account.get("glAccountId"); + if (id != null) { + mapped.add(((Number) id).longValue()); + } + } + } + + final StringBuilder sql = new StringBuilder(""" + SELECT a.id, a.gl_code, a.name, a.classification_enum, + COALESCE(SUM(CASE WHEN j.type_enum = 2 THEN j.amount ELSE -j.amount END), 0) AS debit_less_credit + FROM acc_gl_account a + JOIN acc_gl_journal_entry j ON j.account_id = a.id + WHERE a.classification_enum IN (1, 2, 3) + AND a.account_usage = 1 + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + sql.append(" GROUP BY a.id, a.gl_code, a.name, a.classification_enum ORDER BY a.gl_code"); + + final List> out = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), args.toArray())) { + final Long id = ((Number) row.get("id")).longValue(); + if (mapped.contains(id)) { + continue; + } + final int classification = ((Number) row.get("classification_enum")).intValue(); + final BigDecimal debitLessCredit = (BigDecimal) row.get("debit_less_credit"); + // On its natural side, so the figure reads the way the account itself does. + final BigDecimal balance = classification == ASSET ? debitLessCredit : debitLessCredit.negate(); + if (balance.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + final Map account = new LinkedHashMap<>(); + account.put("glCode", row.get("gl_code")); + account.put("name", row.get("name")); + account.put("balance", balance); + out.add(account); + } + return out; + } + + private BigDecimal unclosedTradingResult(final LocalDate asOn, final Long officeId) { + // Income is credit-natured and expense debit-natured, so each is netted on its own side and + // the expense side subtracted: a credit to income adds to the result, a debit to expense + // takes from it. + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(SUM( + CASE WHEN a.classification_enum = 4 + THEN CASE WHEN j.type_enum = 1 THEN j.amount ELSE -j.amount END + ELSE CASE WHEN j.type_enum = 2 THEN -j.amount ELSE j.amount END + END), 0) + FROM acc_gl_journal_entry j + JOIN acc_gl_account a ON a.id = j.account_id + WHERE a.classification_enum IN (4, 5) + AND j.entry_date <= ? + AND (j.reversed IS NULL OR j.reversed = false) + AND j.id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL) + """); + final List args = new ArrayList<>(List.of(java.sql.Date.valueOf(asOn))); + if (officeId != null) { + sql.append(" AND j.office_id = ?"); + args.add(officeId); + } + final BigDecimal result = jdbcTemplate.queryForObject(sql.toString(), BigDecimal.class, args.toArray()); + return result == null ? BigDecimal.ZERO : result; + } + + /** + * A head's accounts as the ledger holds them, with the unclosed trading result added as a row + * of its own. + * + *

+ * The balances are not restated. Flipping their signs to match the side the head is reported on + * was tried and reads as double counting: the same account then appears under item 9 at -512 and + * under item 12 at +512, and nothing on the form says they are one balance. A loss genuinely IS + * a debit balance on an equity account, so showing it as one is the honest reading, and the + * head's own total already states the figure the right way up for the side it sits on. + * + *

+ * The trading result appears as its own row because no account holds it: it is the net of the + * income and expense accounts, which the books have not closed to equity. Without it the rows + * are short by exactly that, and a reviewer adding them up finds a figure that is not the head. + */ + private static List> withTradingRow(final Object source, final BigDecimal tradingResult) { + final List> out = new ArrayList<>(); + if (source instanceof List accounts) { + for (final Object item : accounts) { + if (item instanceof Map account) { + final Map row = new LinkedHashMap<>(); + for (final Map.Entry field : account.entrySet()) { + row.put(String.valueOf(field.getKey()), field.getValue()); + } + out.add(row); + } + } + } + if (tradingResult != null && tradingResult.compareTo(BigDecimal.ZERO) != 0) { + final Map row = new LinkedHashMap<>(); + row.put("glAccountId", null); + row.put("glCode", "—"); + row.put("name", "Income and expense accounts not yet closed to equity"); + row.put("balanceInRupees", tradingResult); + row.put("balance", thousands(tradingResult)); + out.add(row); + } + return out; + } + + /** + * A rupee figure for a sentence a person reads. + * + *

+ * Trimmed to paise. A BigDecimal carries whatever scale the arithmetic that produced it left + * behind, and a note that says "-247784.820000 rupees" reads as a machine's working rather than + * as an amount of money. + */ + private static String rupees(final BigDecimal amount) { + return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static BigDecimal thousands(final BigDecimal rupees) { + return rupees == null ? null : rupees.divide(THOUSAND, 0, RoundingMode.HALF_UP); + } + + /** One head's rupee figure, or zero where it is unmapped. */ + private static BigDecimal rupeesOf(final Map> byKey, final String configKey) { + final Map row = byKey.get(configKey); + if (row == null) { + return BigDecimal.ZERO; + } + final BigDecimal amount = (BigDecimal) row.get("amountInRupees"); + return amount == null ? BigDecimal.ZERO : amount; + } + + /** Several heads added in rupees, for a total that is converted once afterwards. */ + private static BigDecimal sumRupees(final Map> byKey, final String... configKeys) { + BigDecimal total = BigDecimal.ZERO; + for (final String key : configKeys) { + total = total.add(rupeesOf(byKey, key)); + } + return total; + } + + /** Several heads added in rupees and stated in thousands. */ + private static BigDecimal sumThousands(final Map> byKey, final String... configKeys) { + return thousands(sumRupees(byKey, configKeys)); + } + + /** + * A head's accounts with their balances restated in thousands. + * + *

+ * These are the rows of the form's "specify" sheets - Other Funds and Reserves, Other Demand + * Liabilities, Other Time Liabilities, Other Investments. Each account mapped to such a head IS + * a row on its sheet, under its own name, so no separate machinery is needed to build them. + */ + private static List> accountsInThousands(final RoleFigure figure) { + final List> out = new ArrayList<>(); + for (final Map account : figure.accounts) { + final Map row = new LinkedHashMap<>(account); + final BigDecimal balance = (BigDecimal) account.get("balance"); + // Both kept: the rupee balance is what reconciles against the ledger, the thousands + // figure is what the sheet prints. An account row rounds on its own because it is + // printed on its own - it is never added into a total here. + row.put("balanceInRupees", balance); + row.put("balance", thousands(balance)); + out.add(row); + } + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java new file mode 100644 index 000000000..82b87cddc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnJobConfig.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SaveRbiFormAReturnJobConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private RbiFormAAutoSaveService rbiFormAAutoSaveService; + + @Bean + public SaveRbiFormAReturnTasklet saveRbiFormAReturnTasklet() { + return new SaveRbiFormAReturnTasklet(rbiFormAAutoSaveService); + } + + @Bean + protected Step saveRbiFormAReturnStep() { + return new StepBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository) + .tasklet(saveRbiFormAReturnTasklet(), transactionManager).build(); + } + + @Bean + public Job saveRbiFormAReturnJob() { + return new JobBuilder(JobName.SAVE_RBI_FORM_A_RETURN.name(), jobRepository).start(saveRbiFormAReturnStep()) + .incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java new file mode 100644 index 000000000..41ba2c192 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/jobs/saveformareturn/SaveRbiFormAReturnTasklet.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.jobs.saveformareturn; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.rbireserve.service.RbiFormAAutoSaveService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; + +/** + * Runs the Form A auto-save. The service decides whether today actually closes a fortnight, so the + * tasklet stays a thin trigger and the rule lives in one place. + */ +@RequiredArgsConstructor +public class SaveRbiFormAReturnTasklet implements Tasklet { + + private final RbiFormAAutoSaveService service; + + @Override + public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) { + service.saveCurrentFortnight(); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java new file mode 100644 index 000000000..0131fc00e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/service/RbiFormAAutoSaveService.java @@ -0,0 +1,201 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.rbireserve.service; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.rbireserve.api.RbiReserveReturnApiResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Keeps a copy of each fortnight's Form A without anyone having to remember to. + * + * Form 1 is filed twice a month, and what it reports is a position that cannot be recovered later: a + * backdated journal entry, a re-mapped GL account or a corrected rate all move figures that were + * already reported, so a fortnight nobody saved is a fortnight whose filed statement no longer + * exists anywhere. The job runs on the days the fortnight closes and stores the return as it stands + * at that close. + * + * It saves as DRAFT and nothing more. Checking, approving and submitting stay human acts - the job + * exists so the figures survive, not so a return files itself. + * + * It never overwrites: a fortnight already saved, by hand or by an earlier run, is left exactly as + * it is. Re-running the job must not replace a statement somebody has already reviewed. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RbiFormAAutoSaveService { + + // The statutory form, because that is the Form 1 the screen shows and the one that gets filed. + // Saving the working report's shape left rows in the list that opened as a blank statement: + // the viewer built for the statutory form found none of the fields it reads. + private static final String FORM_CODE = "FORM_1_STATUTORY"; + + private final JdbcTemplate jdbcTemplate; + private final RbiReserveReturnApiResource returnApiResource; + private final Gson gson = new Gson(); + + /** + * Saves the fortnight that closes on the business date, if it has not been saved already. + * + * Scheduled for the 15th and the last day of each month, but it does not trust the schedule: it + * checks the date itself, so a mis-set cron or a manual run on the wrong day cannot file a + * fortnight that has not closed. + */ + @Transactional + public void saveCurrentFortnight() { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth()); + + final boolean fortnightCloses = today.getDayOfMonth() == 15 || today.equals(monthEnd); + if (!fortnightCloses) { + log.debug("RBI Form A auto-save: {} is not a fortnight end, nothing to save.", today); + return; + } + + final Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_rbi_form_a_filing WHERE form_code = ? AND reporting_date = ?", Integer.class, + FORM_CODE, java.sql.Date.valueOf(today)); + if (existing != null && existing > 0) { + log.info("RBI Form A auto-save: {} is already saved, leaving it untouched.", today); + return; + } + + final JsonObject report; + try { + // Computed through the same endpoint the screen uses, so an auto-saved return and one + // saved by hand cannot come out differently. + // No column dates passed: today IS a fortnight close - the check above returned + // otherwise - so the form derives its own pair from it, which is the pair an unattended + // run should file. A filer needing a different period enters both dates on the screen. + // No base dates passed either: an unattended run files the base the rules fix, which is + // what a saved return should carry. A filer needing a different base enters it on screen. + report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null, null, null), + JsonObject.class); + } catch (final RuntimeException e) { + // A return that cannot be computed is not an error worth failing the batch over - an + // unmapped role or a missing rate is a configuration gap, and the next run picks it up + // once it is filled. Logged rather than thrown so one tenant's gap does not stop others. + log.warn("RBI Form A auto-save: could not compute the return for {}: {}", today, e.getMessage()); + return; + } + + // The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil + // on it - a bank with no balances at the State Co-operative Bank files that line empty - so + // the form is filed as it stands. What it cannot do without is a rate, because every + // required amount below is a percentage of one, and saving a return whose requirement is + // zero for want of configuration would put a compliant-looking statement on the record. + if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) { + log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.", + today); + return; + } + + // The form's period runs between its two fortnight closes, and its headline figures sit per + // column inside the parts rather than once at the top. + final LocalDate periodStart = column(report, 0); + final LocalDate periodEnd = column(report, 1); + final BigDecimal average = null; + + jdbcTemplate.update(""" + INSERT INTO m_rbi_form_a_filing + (form_code, reporting_date, fortnight_end, period_start, period_end, status, report_json, + ndtl_amount, required_amount, maintained_amount, average_maintained, rate_percentage, + remarks, created_on) + VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now()) + """, FORM_CODE, java.sql.Date.valueOf(today), + java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd), + gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"), + partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average, + decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight."); + + log.info("RBI Form A auto-save: saved the return for {} as a draft.", today); + } + + /** Offices the return could be scoped to. Unused today - the job saves the consolidated return. */ + @SuppressWarnings("unused") + private List> offices() { + return jdbcTemplate.queryForList("SELECT id, name FROM m_office ORDER BY id"); + } + + private static java.sql.Date sqlDate(final LocalDate date) { + return date == null ? null : java.sql.Date.valueOf(date); + } + + private static BigDecimal decimal(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + return object.get(field).getAsBigDecimal(); + } + + /** + * One of the statutory form's per-column figures, at the close the return is made up to. + * + * Index 1, the second column: the third is the base the ratios are charged on, not a later + * position, and listing it would show a requirement against a fortnight nobody filed. + */ + private static BigDecimal partFigure(final JsonObject report, final String part, final String field) { + if (!report.has(part) || !report.get(part).isJsonObject()) { + return null; + } + final JsonObject partObject = report.getAsJsonObject(part); + if (!partObject.has(field) || !partObject.get(field).isJsonArray()) { + return null; + } + final var values = partObject.getAsJsonArray(field); + if (values.size() < 2 || values.get(1).isJsonNull()) { + return null; + } + return values.get(1).getAsBigDecimal(); + } + + /** One of the form's column dates. */ + private static LocalDate column(final JsonObject report, final int index) { + if (!report.has("columns") || !report.get("columns").isJsonArray()) { + return null; + } + final var columns = report.getAsJsonArray("columns"); + if (columns.size() <= index || columns.get(index).isJsonNull()) { + return null; + } + final String value = columns.get(index).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } + + @SuppressWarnings("unused") + private static LocalDate date(final JsonObject object, final String field) { + if (object == null || !object.has(field) || object.get(field).isJsonNull()) { + return null; + } + final String value = object.get(field).getAsString(); + return value == null || value.isBlank() ? null : LocalDate.parse(value); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java index 9cbe38744..6e60a2d2b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentProductsApiResource.java @@ -227,14 +227,14 @@ public class CurrentProductsApiResource { final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters()); if (savingProductData.hasAccountingEnabled()) { + // Read under the product's own CURRENT type. Read as a savings product it returned the GLs of whichever + // savings product shares this id. Payment-channel and per-charge mappings are savings features a current + // product does not write, so none are returned. final Map accountingMappings = accountMappingReadPlatformService - .fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId()); - final Collection paymentChannelToFundSourceMappings = accountMappingReadPlatformService - .fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId); - Collection feeToGLAccountMappings = accountMappingReadPlatformService - .fetchFeeToIncomeAccountMappingsForSavingsProduct(productId); - Collection penaltyToGLAccountMappings = accountMappingReadPlatformService - .fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId); + .fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId()); + final Collection paymentChannelToFundSourceMappings = null; + final Collection feeToGLAccountMappings = null; + final Collection penaltyToGLAccountMappings = null; savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings, paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java new file mode 100644 index 000000000..dd5191542 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic + * `payment` command (the same one the account's own Account Actions menu already uses) -- so the + * Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options, + * Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the + * gap-period interest credited first when the product's renewal grace window has passed. A no-op + * (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all -- + * see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.") +@RequiredArgsConstructor +public class FixedDepositGapInterestApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String creditGapInterest(@PathParam("accountId") final Long accountId) { + // Posts a real interest transaction when past grace, so it needs write/update, not just read. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java new file mode 100644 index 000000000..9ab7d736f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -0,0 +1,102 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * FD Dashboard's data source: every Fixed Deposit still sitting on its own account -- ACTIVE, or the + * real MATURED(800) status the nightly maturity job sets once it's past maturity_date with no + * closure instruction to auto-process -- with just the fields the maturity ladder needs + * (account/client/product identity, maturity date, deposit and maturity amounts). A MATURED FD stays + * here until it's actually renewed or closed, since that's exactly the "needs manual attention" + * state the ladder exists to surface. + * + * Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that + * endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency, + * m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/ + * closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it + * needed for the single-account and full-list screens it serves, none of it needed here. Neither + * m_savings_account.deposit_type_enum/status_enum nor + * m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed + * directly against pg_indexes), so that query was a full table scan on top of being wide. This + * endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause + * this feature added (see migration 0571). + */ +@Path("/v1/fixeddepositaccounts/maturity-dashboard") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.") +@RequiredArgsConstructor +public class FixedDepositMaturityDashboardApiResource { + + /** Fineract's DepositAccountType.FIXED_DEPOSIT. */ + private static final int FIXED_DEPOSIT = 200; + /** SavingsAccountStatusType.ACTIVE. */ + private static final int ACTIVE = 300; + /** SavingsAccountStatusType.MATURED -- set by the nightly job once past maturity_date. */ + private static final int MATURED = 800; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String retrieve() { + // Same permission as the single-account/full-list FD endpoints -- this is a narrower read of + // the same resource, not a new one, so no new permission is introduced for it. + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sa.id AS "id", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + sp.name AS "productName", + sp.fd_type AS "fdType", + TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", + datp.deposit_amount AS "depositAmount", + datp.maturity_amount AS "maturityAmount", + datp.on_account_closure_enum AS "onClosureType", + sa.linked_account_id AS "linkedAccountId", + dptp.renewal_grace_period_days AS "renewalGracePeriodDays" + FROM m_savings_account sa + JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id + JOIN m_savings_product sp ON sp.id = sa.product_id + LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id + LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) + ORDER BY datp.maturity_date ASC + """, FIXED_DEPOSIT, ACTIVE, MATURED); + + return this.gson.toJson(rows); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java new file mode 100644 index 000000000..419e6247f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the + * original maturity date within the product's renewal grace window (0 days if none is configured), or + * from today with a gap-period interest credit (at the linked Savings account's rate) once the grace + * window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is + * made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored + * maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not + * consulted. All the actual renewal and gap-interest logic lives in + * DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same + * collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renew") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.") +@RequiredArgsConstructor +public class FixedDepositRenewalApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String renew(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + // A renewal moves money (or credits gap-period interest), so it needs the write/update + // permission on this resource, not just read -- unlike the dashboard's own read-only endpoint. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject() + : JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull()) + ? body.get("reinvestType").getAsInt() + : null; + final Map result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java new file mode 100644 index 000000000..7c3715caf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalPreviewApiResource.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.api; + +import com.google.gson.Gson; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; + +/** + * Read-only: what a Renew from the FD Dashboard is about to do, before the staff member confirms + * anything. The Payment dialog calls this on open (dashboardRenewal mode) to show the current + * balance, the linked account's rate, the gap-day count, the computed gap-period interest, and the + * balance the new term will actually start from -- exactly the numbers + * {@code creditGapInterestIfPastGrace} would post, just not posted yet. Nothing here mutates the + * account, so this only needs read permission, unlike its posting sibling. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renewal-preview") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: read-only preview of a Renew action's gap-interest math.") +@RequiredArgsConstructor +public class FixedDepositRenewalPreviewApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("accountId") final Long accountId) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.previewFdRenewal(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index f3d98a4a1..f66b2485a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; /** - * Runs the day: accrue for every account, then pay out any product whose cycle ended today. + * Runs the day's savings interest: accrue for every account, and post interest only from approved + * interest posting reports ({@link SavingsInterestPostingReportService}). * * Accrual and posting are separated on purpose. Accrual touches no money and can be repeated * freely; posting moves money and must not be. Keeping them apart means a posting that fails can @@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator { private final JdbcTemplate jdbcTemplate; private final SavingsDailyInterestService accrualService; private final SavingsPooledInterestPostingService postingService; + private final SavingsInterestPostingReportService reportService; + private final PlatformTransactionManager transactionManager; /** - * Accrue for the day, then post for any product whose posting date has arrived. + * The nightly run: settles any approved interest report still unposted, then accrues the day. * - * Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the - * posting then reads and updates those same rows in a transaction of its own - * ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back - * another's payment). Holding an outer transaction across both would mean this thread still owns - * the row locks the accrual took while the posting's separate transaction waits for them — each - * waiting on the other, and the job hanging silently with nothing in the log. So each step - * commits on its own before the next begins. + *

+ * This run no longer decides to pay anybody. Interest is paid only from an approved posting + * report, normally when the last branch closes the day. What is left here is the safety net for a + * report that was approved but could not be posted then, and it runs BEFORE the accrual on + * purpose: by the time this job runs the business date has already moved past the posting date, + * and the accrual rolls each account's ledger row into the next cycle, which would leave nothing + * for the posting to settle. + * + *

+ * Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the + * accrual then commits on its own, so neither holds the other's row locks. */ public void runForDate(final LocalDate businessDate) { clearStaleRunningFlag(); + final List failures = postApprovedReports(businessDate); accrualService.accrueForDate(businessDate); enrolNewProducts(); - final List> schedules = jdbcTemplate.queryForList(""" - SELECT s.savings_product_id, s.posting_period_enum - FROM m_savings_interest_posting_schedule s - JOIN m_savings_product p ON p.id = s.savings_product_id - WHERE s.is_active = true AND p.deposit_type_enum = 100 - ORDER BY s.savings_product_id - """); - - // Products that could not be paid, collected so the run can fail loudly at the end. - // - // Carrying on past one product is right — a mapping missing on one product must not stop - // every member of every other from being paid. Reporting the job as successful afterwards is - // not: the scheduler recorded 'success', the operator saw a green run, and the only trace - // that nobody had been paid was a log line and a row in m_savings_interest_posting_run that - // nothing reads. On a night when every product fails, that is a whole day of interest - // silently unpaid. - final List failures = new java.util.ArrayList<>(); - - for (final Map schedule : schedules) { - final long productId = ((Number) schedule.get("savings_product_id")).longValue(); - - // Whether anything is due is decided inside postForProduct, not here. - // - // This used to pre-check with its own COUNT over m_savings_daily_interest, and that - // deadlocked the run: accrueForDate above is @Transactional and has just written every - // ledger row, so its transaction still holds them; the COUNT then read those rows on - // this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a - // transaction of its own — sat waiting for a lock this thread was holding and would only - // release after postForProduct returned. Each waited on the other and the job hung - // silently, with nothing in the log and the flag left saying it was still running. - // - // postForProduct already selects what is due and returns zero when there is nothing, so - // the pre-check bought nothing but the deadlock. Its selection is by posting_date rather - // than by the run date, which is what makes a missed posting date catch up on a later - // run instead of being skipped for good. - try { - final BigDecimal paid = postingService.postForProduct(productId, businessDate); - if (paid.signum() == 0) { - updateSchedule(productId, businessDate, null); - continue; - } - updateSchedule(productId, businessDate, "COMPLETED"); - log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate); - } catch (final RuntimeException e) { - // The product rolled back on its own; record why and let the others run. - log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e); - // An UPDATE, so it writes nothing when the product threw before openRun() inserted - // the row — which is exactly what a product with no GL mapping does. The failure is - // therefore collected in memory as well, so it cannot be lost with the breadcrumb. - jdbcTemplate.update(""" - UPDATE m_savings_interest_posting_run - SET status = 'FAILED', failure_reason = ? - WHERE savings_product_id = ? AND posting_date = ? - """, truncate(e.getMessage()), productId, businessDate); - updateSchedule(productId, businessDate, "FAILED"); - failures.add("product " + productId + ": " + e.getMessage()); - } + // Keeps each product's next posting date on the timetable in step with the ledger, which + // the posting loop used to do as a side effect of looking for work. + for (final Long productId : jdbcTemplate.queryForList( + "SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) { + updateSchedule(productId, businessDate, null); } - // Fails the job so the scheduler records it as failed and an operator is told. Every product - // that could be paid has already been paid and committed by this point — this reports what - // happened, it does not undo it. + // Fails the job so the scheduler records it as failed and an operator is told. Every report + // that could be paid has already been paid and committed by this point. if (!failures.isEmpty()) { throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for " - + failures.size() + " product(s): " + String.join("; ", failures)); + + failures.size() + " report(s): " + String.join("; ", failures)); } } + /** + * Posts every approved interest posting report dated on or before the date. + * + *

+ * Each report posts in a new transaction of its own. That is required, not a preference: this is + * called from the day close's after-commit callback, where the closing transaction has finished + * but is still bound to the thread, and joining it would write nothing that ever commits. It also + * keeps one product's failure from rolling back another's payment. A report that fails stays + * APPROVED with the reason recorded, and the nightly run tries it again. + * + * @return one message per report that could not be posted + */ + public List postApprovedReports(final LocalDate upToDate) { + return postReports(reportService.approvedReportIds(upToDate)); + } + + /** + * Posts the given approved reports, each in a new transaction of its own (see + * {@link #postApprovedReports}). Used by the day close for one branch's reports. + * + * @return one message per report that could not be posted + */ + public List postReports(final List reportIds) { + final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager); + newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + final List failures = new java.util.ArrayList<>(); + for (final Long reportId : reportIds) { + final Map report = jdbcTemplate.queryForMap( + "SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId); + final long productId = ((Number) report.get("savings_product_id")).longValue(); + final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + try { + final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId)); + newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED")); + log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate); + } catch (final RuntimeException e) { + log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e); + newTransaction.executeWithoutResult(status -> { + reportService.recordPostingFailure(reportId, e.getMessage()); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_run + SET status = 'FAILED', failure_reason = ? + WHERE report_id = ? + """, truncate(e.getMessage()), reportId); + updateSchedule(productId, postingDate, "FAILED"); + }); + failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage()); + } + } + return failures; + } + /** * Releases this job's own currently_running flag if an earlier run left it set. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index dbd2fd400..937c3eb54 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -243,7 +243,7 @@ public class SavingsDailyInterestService { carriedInterest = backfill.cumulativeInterest(); } else { final Map prev = previous.get(0); - openingBalance = (BigDecimal) prev.get("closing_balance"); + final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate(); final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day")); final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate(); // A cycle is finished either because it was paid out or because its end date has gone @@ -251,11 +251,24 @@ public class SavingsDailyInterestService { // run was missed, or the account was accrued again after a break - and treating it as // unfinished would leave the row in a cycle that ended before the day it records. if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) { - carriedInterest = BigDecimal.ZERO; cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1; cycleStart = prevCycleEnd.plusDays(1); cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod, financialYearBeginningMonth); + // The new cycle may already be days old: the business date moved on by more than a + // day, or nights were missed. Those days earned too. Starting the cycle at zero and + // counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of + // October. They are replayed from the account's transactions, as a first row is, and + // the opening balance comes from the same replay so money moved in the gap counts. + if (cycleStart.isBefore(businessDate)) { + final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, + minBalance, averageDailyBalanceOf(calcType)); + openingBalance = gap.closingBalance(); + carriedInterest = gap.cumulativeInterest(); + } else { + openingBalance = (BigDecimal) prev.get("closing_balance"); + carriedInterest = BigDecimal.ZERO; + } } else { cycleNumber = ((Number) prev.get("cycle_number")).intValue(); cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate(); @@ -274,12 +287,15 @@ public class SavingsDailyInterestService { // So the gap is replayed from the account's own transactions, the same way a first // row is built, and the result stands in for the running total the ledger would be // holding had it run every night. - final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate(); if (prevBalanceDate.plusDays(1).isBefore(businessDate)) { final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear, minBalance, averageDailyBalanceOf(calcType)); + // The replay's balance, not the stale row's: deposits made during the gap were + // otherwise left out of every balance from here on. + openingBalance = gap.closingBalance(); carriedInterest = gap.cumulativeInterest(); } else { + openingBalance = (BigDecimal) prev.get("closing_balance"); carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest"); } } @@ -506,7 +522,11 @@ public class SavingsDailyInterestService { final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) { if (!cycleStart.isBefore(upTo)) { - return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO); + // No day of the cycle to replay — it opens today — but the money held going into it is + // still there. Returning a zero balance made every re-accrual on a cycle's first day (a + // deposit, or the noon report, on the 1st) open the account at nil: an account holding + // 11,503.15 then earned only on what was deposited that day. + return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO); } // Everything on or before the eve of the ledger's first day, keyed by day. The balance before @@ -560,7 +580,7 @@ public class SavingsDailyInterestService { final BigDecimal balance = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END), 0) FROM m_savings_account_transaction t WHERE t.savings_account_id = ? @@ -577,7 +597,7 @@ public class SavingsDailyInterestService { SELECT t.transaction_date AS d, SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END) AS net FROM m_savings_account_transaction t WHERE t.savings_account_id = ? @@ -593,7 +613,7 @@ public class SavingsDailyInterestService { final BigDecimal movement = jdbcTemplate.queryForObject(""" SELECT COALESCE(SUM(CASE WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount - WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount + WHEN t.transaction_type_enum IN (2, 4, 5, 7, 18) THEN -t.amount ELSE 0 END), 0) FROM m_savings_account_transaction t WHERE t.savings_account_id = ? diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java new file mode 100644 index 000000000..cc6c6206c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsInterestPostingReportService.java @@ -0,0 +1,677 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * The interest posting report: what each savings account of one branch will be paid on its product's + * posting date, put in front of that branch before any money moves. + * + *

+ * One report per branch and product. On a posting date the noon job generates one for every branch + * holding accounts with interest due; a user of that branch may generate or regenerate it at any time + * — the previous report is superseded, not overwritten — and approves it. The branch's approved + * reports are posted when that branch closes its day, and until they are approved it cannot close. + * The head office closes last, once every branch open that day has closed. + * + *

+ * Only the branch acts on its own reports. The single exception is a branch that did not open that + * day: the head office may then generate and approve its report on its behalf, and posts it at the + * head office's own close. Every entry is still raised in the branch the customers belong to. + * + *

+ * The figures are the daily ledger's own: each account's running interest for the cycle, rounded to + * two decimals, with the paise the rounding loses given to the largest balance so the report's total + * is the branch's pooled interest exactly. + */ +@Service +@Slf4j +public class SavingsInterestPostingReportService { + + public static final String GENERATED = "GENERATED"; + public static final String APPROVED = "APPROVED"; + public static final String POSTED = "POSTED"; + public static final String SUPERSEDED = "SUPERSEDED"; + + private static final int MONEY_SCALE = 2; + private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN; + /** m_savings_account.status_enum for an active account. */ + private static final int ACTIVE = 300; + + /** A product with interest due in one branch. */ + private record DueKey(Long productId, Long officeId) {} + + private final JdbcTemplate jdbcTemplate; + private final SavingsDailyInterestService accrualService; + private final SavingsPooledInterestPostingService postingService; + private final TransactionTemplate transactionTemplate; + + public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService, + final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.accrualService = accrualService; + this.postingService = postingService; + // Always a transaction of its own. The noon job runs inside the batch step's transaction, and + // generating in it would read the accrual through that uncommitted transaction and hold its + // row locks for the whole run; a report committed on its own is also visible to the screen at once. + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + // ── generation ─────────────────────────────────────────────────────────────────────────────── + + /** + * The noon job: brings the ledger up to the business date, then generates a report for every + * branch and product with interest due that does not already have one. + * + *

+ * Each report is generated in its own transaction, so one that cannot be — a GL mapping missing, + * say — does not stop the others. The failures are raised together at the end so the job is + * recorded as failed. + * + * @return the ids of the reports generated + */ + public List generateDue(final LocalDate postingDate) { + // So each account's figure includes the posting date itself, on the balance it holds now. + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate)); + + final List generated = new ArrayList<>(); + final List failures = new ArrayList<>(); + for (final DueKey due : dueKeys(postingDate, null)) { + if (activeReportId(due.productId(), due.officeId(), postingDate) != null) { + continue; + } + try { + generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null))); + } catch (final RuntimeException e) { + log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(), + due.officeId(), postingDate, e); + failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for " + + failures.size() + " report(s): " + String.join("; ", failures)); + } + return generated; + } + + /** + * Generates the report afresh for one branch and product, on the user's request. + * + *

+ * Allowed at any time of day; only the noon job waits for noon. Not for a date after the business + * date, whose interest has not been earned yet. A posted report cannot be regenerated: its money + * has moved. An approved one can, and has to be approved again. + */ + public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date", + "The report for " + postingDate + " cannot be generated before that date."); + } + authorize(user, officeId, businessDate); + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate)); + return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId())); + } + + /** + * Approves a generated report. A user of the report's branch approves it; the head office may + * approve it only for a branch that did not open that day, and the report records that it did. + */ + public void approve(final Long reportId, final AppUser user) { + final List> found = jdbcTemplate + .queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found", + "Interest posting report " + reportId + " does not exist."); + } + final Long officeId = ((Number) found.get(0).get("office_id")).longValue(); + final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate()); + final int approved = jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report + SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ? + WHERE id = ? AND status = 'GENERATED' + """, user.getId(), onBehalf, reportId); + if (approved == 0) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated", + "Only a report in GENERATED status can be approved. Reload the report — it may have been approved, " + + "regenerated or posted already."); + } + } + + // ── day close ──────────────────────────────────────────────────────────────────────────────── + + /** + * What stops one branch from closing the day: a product with interest due in the branch and no + * approved (or posted) report for the date, and any older report of the branch still awaiting + * approval — one left from a day the branch did not open. A report that was never generated + * blocks the close too. + */ + public List reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) { + final Set blocking = new LinkedHashSet<>(); + for (final DueKey due : dueKeys(businessDate, officeId)) { + final Long settled = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED') + """, Long.class, due.productId(), officeId, businessDate); + if (settled == null || settled == 0) { + blocking.add(productName(due.productId()) + " (" + businessDate + ")"); + } + } + for (final Map pending : jdbcTemplate.queryForList(""" + SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ? + ORDER BY r.posting_date + """, officeId, businessDate)) { + blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")"); + } + return new ArrayList<>(blocking); + } + + /** The branch's approved reports, posted when it closes the day. */ + public List approvedReportIds(final Long officeId, final LocalDate upToDate) { + return jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ? + ORDER BY posting_date, savings_product_id + """, Long.class, officeId, upToDate); + } + + /** + * Approved reports of branches that did not open on the date — what the head office posts at its + * own close, the branches having no close of their own that day. + */ + public List approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) { + return jdbcTemplate.queryForList(""" + SELECT r.id FROM m_savings_interest_posting_report r + WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ? + AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id + AND (b.business_date = ? OR (b.closed_at IS NULL AND b.business_date <= ?))) + ORDER BY r.posting_date, r.office_id, r.savings_product_id + """, Long.class, businessDate, headOfficeId, businessDate, businessDate); + } + + /** Every approved report still waiting, for the nightly safety net. */ + public List approvedReportIds(final LocalDate upToDate) { + return jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE status = 'APPROVED' AND posting_date <= ? + ORDER BY posting_date, office_id, savings_product_id + """, Long.class, upToDate); + } + + /** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */ + public void recordPostingFailure(final Long reportId, final String reason) { + jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?", + reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId); + } + + // ── reading ────────────────────────────────────────────────────────────────────────────────── + + /** + * Reports for the screen. {@code scope} "own" (the default) is the user's own branch; "others", for a + * head office user, is every other branch (or one, with officeId). canAct says whether this user + * may regenerate or approve the report, and blockedReason why not. + */ + public List> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId, + final String scope, final AppUser user) { + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + final Long branch = others ? officeId : userOffice; + + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", + o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest", + r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", + gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy", + r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE 1 = 1 + """); + final List params = new ArrayList<>(); + if (postingDate != null) { + sql.append(" AND r.posting_date = ?"); + params.add(postingDate); + } + if (status != null && !status.isBlank()) { + sql.append(" AND r.status = ?"); + params.add(status.trim().toUpperCase()); + } else { + sql.append(" AND r.status <> 'SUPERSEDED'"); + } + if (productId != null) { + sql.append(" AND r.savings_product_id = ?"); + params.add(productId); + } + if (branch != null) { + sql.append(" AND r.office_id = ?"); + params.add(branch); + } + if (others) { + sql.append(" AND r.office_id <> ?"); + params.add(userOffice); + } + sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC"); + + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + final List> rows = new ArrayList<>(); + for (final Map row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) { + final Map out = new LinkedHashMap<>(row); + final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue(); + final String blocked = reportOffice == null ? "No branch on this report." : actBlockedReason(userOffice, reportOffice, businessDate); + out.put("canAct", blocked == null); + out.put("blockedReason", blocked); + rows.add(out); + } + return rows; + } + + public Map detail(final Long reportId) { + final List> found = jdbcTemplate.queryForList(""" + SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId", + o.name AS "officeName", r.posting_date AS "postingDate", + r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount", + r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy", + r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf", + r.posted_at AS "postedAt", r.failure_reason AS "failureReason", + pg.gl_code AS "payableGlCode", pg.name AS "payableGlName", + dg.gl_code AS "paidGlCode", dg.name AS "paidGlName", + eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser gu ON gu.id = r.generated_by + LEFT JOIN m_appuser au ON au.id = r.approved_by + LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id + LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id + LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2 + AND em.financial_account_type = 3 AND em.payment_type IS NULL + LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id + WHERE r.id = ? + """, reportId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found", + "Interest posting report " + reportId + " does not exist."); + } + final Map report = new LinkedHashMap<>(found.get(0)); + report.put("lines", jdbcTemplate.queryForList(""" + SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName", + l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore", + l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId" + FROM m_savings_interest_posting_report_line l + WHERE l.report_id = ? + ORDER BY l.account_no + """, reportId)); + return report; + } + + /** + * What each savings account has earned so far in its current cycle — what it would be paid if + * interest were posted today. Read-only: nothing is saved, approved or posted. The ledger is first + * brought up to the business date (which moves no money) so the figure includes today. + * + *

+ * A branch user sees their own branch; a head office user every branch, or one with officeId. + */ + public List> preview(final Long officeId, final AppUser user) { + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate)); + + final Long userOffice = officeOf(user); + final Long branch = isHeadOffice(userOffice) ? officeId : userOffice; + final StringBuilder sql = new StringBuilder(""" + SELECT COALESCE(c.office_id, g.office_id) AS "officeId", o.name AS "officeName", + p.id AS "productId", p.name AS "productName", sa.id AS "savingsAccountId", sa.account_no AS "accountNo", + COALESCE(c.display_name, g.display_name) AS "clientName", d.closing_balance AS "balance", + d.interest_rate AS "rate", d.cycle_start_date AS "cycleStart", d.posting_date AS "postingDate", + d.balance_date AS "asOf", ROUND(d.cycle_cumulative_interest, 2) AS "interest" + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + JOIN m_savings_product p ON p.id = d.savings_product_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + LEFT JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id) + WHERE p.deposit_type_enum = 100 + AND sa.status_enum = ? + AND d.savings_transaction_id IS NULL + """); + final List params = new ArrayList<>(List.of(ACTIVE)); + if (branch != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(branch); + } + sql.append(" ORDER BY o.name, p.name, sa.account_no"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * Branch and product interest for the head office: per report, how many customers and how much + * interest, and where it stands. Superseded reports are left out — each line is the report that + * counts for that branch, product and date. + */ + public List> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) { + final StringBuilder sql = new StringBuilder(""" + SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName", + r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount", + r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy", + r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt" + FROM m_savings_interest_posting_report r + JOIN m_savings_product p ON p.id = r.savings_product_id + LEFT JOIN m_office o ON o.id = r.office_id + LEFT JOIN m_appuser au ON au.id = r.approved_by + WHERE r.status <> 'SUPERSEDED' + """); + final List params = new ArrayList<>(); + if (fromDate != null) { + sql.append(" AND r.posting_date >= ?"); + params.add(fromDate); + } + if (toDate != null) { + sql.append(" AND r.posting_date <= ?"); + params.add(toDate); + } + if (productId != null) { + sql.append(" AND r.savings_product_id = ?"); + params.add(productId); + } + sql.append(" ORDER BY r.posting_date DESC, p.name, o.name"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()); + } + + /** + * Branch and product pairs with interest due by the date that have no report yet — the noon job ran + * before they had anything due, or failed. + * + *

+ * {@code scope} "own" (the default) lists the user's own branch. "others", for a head office user, + * lists the other branches — each with canAct, and where the head office may not act for it, why. + */ + public List> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) { + final List> out = new ArrayList<>(); + // A future date has nothing due yet, and offering to generate for it would label this cycle's + // part-earned interest with a date it has not reached. + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + if (postingDate.isAfter(businessDate)) { + return out; + } + final Long userOffice = officeOf(user); + final boolean others = "others".equalsIgnoreCase(scope) && isHeadOffice(userOffice); + for (final DueKey due : dueKeys(postingDate, others ? null : userOffice)) { + if (others == due.officeId().equals(userOffice)) { + continue; + } + if (activeReportId(due.productId(), due.officeId(), postingDate) == null) { + final Map row = new LinkedHashMap<>(); + row.put("productId", due.productId()); + row.put("productName", productName(due.productId())); + row.put("officeId", due.officeId()); + row.put("officeName", officeName(due.officeId())); + row.put("postingDate", postingDate.toString()); + final String blocked = actBlockedReason(userOffice, due.officeId(), businessDate); + row.put("canAct", blocked == null); + row.put("blockedReason", blocked); + out.add(row); + } + } + return out; + } + + // ── who may act ────────────────────────────────────────────────────────────────────────────── + + public boolean isHeadOffice(final Long officeId) { + final List parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId); + return !parent.isEmpty() && parent.get(0) == null; + } + + /** + * Whether the branch is working on the date: it opened that day, or a day it opened earlier is still + * not closed. A branch with yesterday's day still open is open — it closes that day and goes on. + */ + public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) { + final Long rows = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_branch_day_register + WHERE office_id = ? AND (business_date = ? OR (closed_at IS NULL AND business_date <= ?)) + """, Long.class, officeId, businessDate, businessDate); + return rows != null && rows > 0; + } + + /** The branches, other than those given, that still have a day open on or before the date. */ + public List branchesStillOpen(final LocalDate businessDate, final Long... excludeOfficeIds) { + final StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id + WHERE r.closed_at IS NULL AND r.business_date <= ? + """); + final List params = new ArrayList<>(List.of(businessDate)); + for (final Long exclude : excludeOfficeIds) { + sql.append(" AND r.office_id <> ?"); + params.add(exclude); + } + sql.append(" ORDER BY o.name"); + return jdbcTemplate.queryForList(sql.toString(), String.class, params.toArray()); + } + + /** + * Null where the user may generate or approve the branch's report, otherwise why not. + * + *

+ * A branch always acts for itself. The head office acts for another branch only when that branch + * has not opened at all, and only once every branch that did open has closed — the head office's + * own close being the last of the day, so nothing is taken out of a branch that may still open. + */ + private String actBlockedReason(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + if (userOffice == null) { + return "No branch is set for this user."; + } + if (userOffice.equals(reportOffice)) { + return null; + } + final String branch = officeName(reportOffice); + if (!isHeadOffice(userOffice)) { + return "Only " + branch + " can generate or approve its own interest posting report."; + } + if (branchOpenedOn(reportOffice, businessDate)) { + return branch + " is open — it generates and approves its own report."; + } + final List stillOpen = branchesStillOpen(businessDate, userOffice, reportOffice); + if (!stillOpen.isEmpty()) { + return "The head office can act for " + branch + " only after every open branch has closed. Still open: " + + String.join(", ", stillOpen) + "."; + } + return null; + } + + private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) { + return actBlockedReason(userOffice, reportOffice, businessDate) == null; + } + + /** @return true where the head office is acting on a branch's behalf */ + private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) { + final Long userOffice = officeOf(user); + final String blocked = actBlockedReason(userOffice, reportOffice, businessDate); + if (blocked != null) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch", blocked); + } + return !reportOffice.equals(userOffice); + } + + private String officeName(final Long officeId) { + return jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, officeId); + } + + private static Long officeOf(final AppUser user) { + return user == null || user.getOffice() == null ? null : user.getOffice().getId(); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + /** + * Branch and product pairs with interest due on or before the date: a savings product on the + * daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in + * accounts belonging to that branch. + */ + private List dueKeys(final LocalDate postingDate, final Long officeId) { + final StringBuilder sql = new StringBuilder(""" + SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id + FROM m_savings_daily_interest d + JOIN m_savings_product p ON p.id = d.savings_product_id + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true + WHERE p.deposit_type_enum = 100 + AND sa.status_enum = ? + AND d.posting_date <= ? + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND COALESCE(c.office_id, g.office_id) IS NOT NULL + """); + final List params = new ArrayList<>(List.of(ACTIVE, postingDate)); + if (officeId != null) { + sql.append(" AND COALESCE(c.office_id, g.office_id) = ?"); + params.add(officeId); + } + sql.append(" ORDER BY 2, 1"); + return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream() + .map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue())) + .toList(); + } + + private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) { + final List ids = jdbcTemplate.queryForList(""" + SELECT id FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + """, Long.class, productId, officeId, postingDate); + return ids.isEmpty() ? null : ids.get(0); + } + + private String productName(final Long productId) { + return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId); + } + + /** Runs inside the caller's transaction. */ + private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) { + final List> previous = jdbcTemplate.queryForList(""" + SELECT id, status FROM m_savings_interest_posting_report + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED' + FOR UPDATE + """, productId, officeId, postingDate); + for (final Map report : previous) { + if (POSTED.equals(report.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted", + "Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be" + + " regenerated."); + } + } + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED' + WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED') + """, productId, officeId, postingDate); + + final List> due = jdbcTemplate.queryForList(""" + SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest, + sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name + FROM m_savings_daily_interest d + JOIN m_savings_account sa ON sa.id = d.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE d.savings_product_id = ? AND d.posting_date <= ? + AND d.savings_transaction_id IS NULL + AND d.cycle_cumulative_interest > 0 + AND sa.status_enum = ? + AND COALESCE(c.office_id, g.office_id) = ? + ORDER BY d.savings_account_id + """, productId, postingDate, ACTIVE, officeId); + if (due.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due", + "No interest is due for this product in this branch on " + postingDate + "."); + } + + // The GL accounts as the product maps them now; a product missing one is refused here, before + // anybody approves figures that could never be posted. + final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId); + + // Round each customer's share, then make the rounded shares add up to the rounded pool: the + // residual goes to the largest balance, where a paisa is proportionally smallest. + BigDecimal grossTotal = BigDecimal.ZERO; + BigDecimal roundedTotal = BigDecimal.ZERO; + for (final Map row : due) { + final BigDecimal gross = (BigDecimal) row.get("gross_interest"); + final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); + row.put("rounded_interest", rounded); + grossTotal = grossTotal.add(gross); + roundedTotal = roundedTotal.add(rounded); + } + final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal residual = pooled.subtract(roundedTotal); + if (residual.signum() != 0) { + final Map largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))) + .orElseThrow(); + largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); + } + + final Long reportId = jdbcTemplate.queryForObject(""" + INSERT INTO m_savings_interest_posting_report + (savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id, + paid_gl_account_id, generation_mode, generated_by, generated_at) + VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId); + + for (final Map row : due) { + final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING); + final BigDecimal interest = (BigDecimal) row.get("rounded_interest"); + jdbcTemplate.update(""" + INSERT INTO m_savings_interest_posting_report_line + (report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before, + balance_after, ledger_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest, + before, before.add(interest), row.get("ledger_id")); + } + + log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId, + postingDate, mode, pooled, due.size()); + return reportId; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index d2fc838bb..2082c2c46 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; -import java.util.Comparator; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId; import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional; * a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere * holds the figure that would say so. * - * This settles the debt the accrual has already recognised: the product's total moves out of the - * liability the product names as its Savings Interest Payable, into the account it is paid out of, - * and only then to the members: + * The product's total is raised as an expense and credited to the liability the product names as its + * Savings Interest Payable, moves from there into the clearing account it is paid out of, and only + * then to the members: * *
- *   Dr  Savings Interest Payable   (liability, from the product)
- *   Cr  Savings Interest Paid                                         on its way out
+ *   Dr  Interest On Savings        (expense, from the product)       cash-accounted products only
+ *   Cr  Savings Interest Payable   (liability, from the product)
+ *
+ *   Dr  Savings Interest Payable
+ *   Cr  Savings Interest Paid      (liability clearing)               on its way out
  *
  *   Dr  Savings Interest Paid      (per member)
  *   Cr  Savings Control            (per member, from the product)     in the member's hands
  * 
* - * The interest expense is not raised here. It is recognised as the interest accrues, day by day, and - * this is the settlement of what that recognition already booked - raising the expense again at - * payout would put the same interest on the P&L twice. + * The payable and paid accounts each close the posting at nil, so the only lasting effect is the + * expense against the members' balances, and a liability report reading the payable is unaffected. + * The first pair is skipped for an accrual-accounted product, whose accruals already raised it. * * Every account named above is read from the product's own GL configuration; none is written into * this class. The payable comes from the Interest Payable row on the product's Accounting tab, and @@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService { * Accounting tab, which is where a bank picks the account interest is owed from. */ private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17; + /** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */ + private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3; + /** m_savings_product.accounting_type for cash-based accounting. */ + private static final int ACCOUNTING_TYPE_CASH = 2; private final JdbcTemplate jdbcTemplate; + /** The payable and paid accounts a product's interest passes through. */ + public record PostingGlAccounts(Long payableGl, Long paidGl) {} + /** - * Posts one product's interest for one posting date. - * - * @return the amount distributed, or zero if there was nothing to do + * The payable and paid accounts, as the product maps them now. * *

- * REQUIRED, not REQUIRES_NEW. + * The product's Accounting tab is the authority: the standard Interest Payable row first, then + * the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls + * back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and + * leaves the two-legged posting a product without these mappings has always had. + */ + public PostingGlAccounts resolvePostingGlAccounts(final long productId) { + // All three are required. Falling back to Savings Control when one was left blank posted the + // interest straight from expense to the member with no payable or paid leg at all, and nobody + // could tell from the entries that the product had simply lost its mappings. + mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS); + final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE), + namedGlAccount(productId, MAPPING_INTEREST_PAYABLE)); + final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID); + if (payableGl == null || paidGl == null) { + final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, + productId); + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '" + + productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid") + + " GL mapped. Set it under the product's Accounting and generate the report again."); + } + return new PostingGlAccounts(payableGl, paidGl); + } + + /** + * Posts one approved interest posting report, paying each account exactly what was approved. * - * One product's failure must not roll back another's payment, and REQUIRED gives that already: - * the orchestrator calls this outside any transaction of its own, so each product opens and - * commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is - * running and waits for locks that transaction may hold. The accrual step immediately before - * writes every account's ledger row, and this method reads and updates those same rows: with - * REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding, - * and the job hung with nothing in the log and its currently_running flag left set. + *

+ * A report belongs to one branch and one product, and every leg it raises is in that branch, so + * the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil + * in its own books: + * + *

+     *   Dr  Interest On Savings  (branch total)  →  Cr  Savings Interest Payable
+     *   Dr  Savings Interest Payable              →  Cr  Savings Interest Paid
+     *   Dr  Savings Interest Paid  (once)         →  Cr  each customer of the branch
+     * 
+ * + *

+ * Nothing is recomputed. The report's lines are the authority: interest that accrued after the + * report was generated is not added, because the approver signed off on these figures and not on + * others. The payable and paid accounts are those recorded on the report when it was generated. + * + *

+ * Everything is dated the day it is actually posted — the business date — not the cycle's end: a + * branch that was shut on its posting date posts when it next closes, into a day that is still + * open, and the narration names the cycle the interest is for. + * + *

+ * The whole report settles in one transaction, so a failure leaves nobody half paid and the report + * still APPROVED for the next attempt. + * + * @return the amount distributed */ @Transactional - public BigDecimal postForProduct(final long productId, final LocalDate postingDate) { - final Long completed = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) FROM m_savings_interest_posting_run - WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED' - """, Long.class, productId, postingDate); - if (completed != null && completed > 0) { - log.info("Savings interest posting: product {} already completed for {}", productId, postingDate); - return BigDecimal.ZERO; + public BigDecimal postReport(final long reportId) { + final List> reports = jdbcTemplate.queryForList(""" + SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id, + paid_gl_account_id + FROM m_savings_interest_posting_report + WHERE id = ? + FOR UPDATE + """, reportId); + if (reports.isEmpty()) { + throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist"); + } + final Map report = reports.get(0); + if (!"APPROVED".equals(report.get("status"))) { + throw new IllegalStateException( + "Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED"); + } + final long productId = ((Number) report.get("savings_product_id")).longValue(); + final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate(); + final LocalDate postedOn = DateUtils.getBusinessLocalDate(); + final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING); + + final List> lines = jdbcTemplate.queryForList(""" + SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id, + sa.currency_code + FROM m_savings_interest_posting_report_line l + JOIN m_savings_account sa ON sa.id = l.savings_account_id + WHERE l.report_id = ? + ORDER BY l.savings_account_id + """, reportId); + if (lines.isEmpty()) { + throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay"); + } + final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue() + : ((Number) lines.get(0).get("office_id")).longValue(); + + final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size()); + + final PostingGlAccounts current = resolvePostingGlAccounts(productId); + final Long payableGl = report.get("payable_gl_account_id") != null + ? ((Number) report.get("payable_gl_account_id")).longValue() + : current.payableGl(); + final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue() + : current.paidGl(); + + final String currency = (String) lines.get(0).get("currency_code"); + // One per report, and deterministic: a retried posting carries the same reference as the legs a + // failed attempt rolled back, and two branches posting the same product never share one. + final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId); + final String narration = "Savings interest for cycle ending " + cycleDate; + + // Stage 0: the interest cost is raised and the total credited to the payable. Nothing else + // credits it on a cash-accounted product — the daily accrual writes no journal entry. An + // accrual-accounted product has already done this through its accrual postings. + if (isCashAccounted(productId)) { + final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS); + journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration); + journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration); } - // Every account whose posting date has arrived or passed and has not yet been paid. - // - // Read by posting_date rather than by the run date: the ledger keeps one row per account and - // overwrites it daily, so on the day after a quarter end the row saying "this was a posting - // day" no longer exists. Matching on the run date alone meant a quarter end the job did not - // run on was never paid at all — the cycle simply rolled over with the interest carried into - // it. A posting that is owed stays owed until it is made, which is how an overnight batch has - // to behave when a night is missed. - // - // Anything already carrying a posting transaction is skipped, so a resumed run cannot pay - // somebody a second time. - // - // A savings account has no office of its own: it belongs to a client, or to a group when - // there is no client, and the office is theirs. The journal entries have to be raised - // against that office or they land in the wrong branch's books. - final List> due = jdbcTemplate.queryForList(""" - SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, - d.cycle_cumulative_interest AS gross_interest, - COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code, - sa.currency_digits - FROM m_savings_daily_interest d - JOIN m_savings_account sa ON sa.id = d.savings_account_id - LEFT JOIN m_client c ON c.id = sa.client_id - LEFT JOIN m_group g ON g.id = sa.group_id - WHERE d.savings_product_id = ? AND d.posting_date <= ? - AND d.savings_transaction_id IS NULL - AND d.cycle_cumulative_interest > 0 - AND COALESCE(c.office_id, g.office_id) IS NOT NULL - ORDER BY d.savings_account_id - """, productId, postingDate); - - if (due.isEmpty()) { - log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate); - return BigDecimal.ZERO; - } - - final long runId = openRun(productId, postingDate, due.size()); - - // Round each member's share, then make the rounded shares add up. The residual is the - // difference between what the pool holds and what the members would otherwise receive. - BigDecimal grossTotal = BigDecimal.ZERO; - BigDecimal roundedTotal = BigDecimal.ZERO; - for (final Map row : due) { - final BigDecimal gross = (BigDecimal) row.get("gross_interest"); - final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING); - row.put("rounded_interest", rounded); - grossTotal = grossTotal.add(gross); - roundedTotal = roundedTotal.add(rounded); - } - final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING); - final BigDecimal residual = pooled.subtract(roundedTotal); - - if (residual.compareTo(BigDecimal.ZERO) != 0) { - // To the largest balance: the account where a paisa is proportionally smallest. - final Map largest = due.stream() - .max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow(); - largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual)); - log.info("Savings interest posting: residual {} assigned to account {}", residual, - largest.get("savings_account_id")); - } - - // The three accounts the bill passes through on its way to the members. Every one is read - // from the product's own configuration, so a bank on its own chart of accounts is posted to - // the accounts it chose. No GL code is named in this class. - // - // The product's Accounting tab is the authority, and it is consulted first for all three: - // Interest On Savings for the expense, and the standard Interest Payable row for the - // payable. Those are the rows a bank actually fills in on the product screen. - // - // The reserved names in the product's other GL mappings are consulted only after that, as a - // fallback for a product configured before Interest Payable was mapped, and for the paid leg, - // which has no standard row of its own. - // - // Where a product sets none of them the bill still has somewhere to go: the payable falls - // back to the product's Savings Control (interest owed is a liability either way), and the - // paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged - // posting a product without these mappings has always had. - final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE), - namedGlAccount(productId, MAPPING_INTEREST_PAYABLE), - mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL)); - final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl); - - final Long officeId = ((Number) due.get(0).get("office_id")).longValue(); - final String currency = (String) due.get(0).get("currency_code"); - // Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted, - // and the resumed legs have to carry the same reference as the legs already posted, or one - // interest posting ends up split across two transaction ids. - final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate, - productId); - - // Stage 1: the debt the accrual has been building moves out of the payable and into the - // account the payout is made from, so what is still merely owed and what is on its way to - // the members are two separate balances rather than one. - // - // The interest expense itself is not raised here. It is recognised as the interest accrues, - // day by day, and posting is the settlement of that recognised debt — raising the expense - // again at payout would count the same interest twice on the P&L. - // - // Skipped where the product names no separate paid account: both legs then resolve to the - // payable and this would debit and credit one account for the same amount, saying nothing. - final boolean payableIsPaid = payableGl.equals(paidGl); - final boolean stage1Applies = !payableIsPaid; + // Stage 1: the total moves out of the payable and into the account the payout is made from. + // Skipped where both are the same account, which would debit and credit it for nothing. + final boolean stage1Applies = !payableGl.equals(paidGl); if (stage1Applies) { - journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null); - journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null); + journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration); + journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration); } - - // Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2 - // draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw - // from there instead — debiting the paid account would take the money out of an account - // nothing put it into, leaving the payable standing as a debt that was in fact settled. final Long payoutFromGl = stage1Applies ? paidGl : payableGl; - // Stage 2: out to the members, one at a time. + // Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg + // carries the report's one transaction id, and every credit names its account and interest + // transaction, so who was paid what reads back from the journal alone. + journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration); BigDecimal distributed = BigDecimal.ZERO; - for (final Map row : due) { - final BigDecimal amount = (BigDecimal) row.get("rounded_interest"); - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - continue; - } - final long accountId = ((Number) row.get("savings_account_id")).longValue(); - final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount); - // The member's own branch, which need not be the branch the pool was raised in. - final long accountOfficeId = ((Number) row.get("office_id")).longValue(); - - final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount, - newBalance); - - journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference, - accountId); - creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference, - accountId, transactionId); - - // closing_balance is raised here because the interest transaction has just been written - // and the accrual for this day has already run: nothing else will fold it in before the - // next day reads this row as its opening. - jdbcTemplate.update(""" - UPDATE m_savings_daily_interest - SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, - closing_balance = closing_balance + ? - WHERE id = ? - """, amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue()); - - // Both interest columns move together, and that is not a duplicate. - // - // total_interest_earned_derived is the interest the account has earned to date; - // total_interest_posted_derived is how much of that has been paid into the balance. - // Screens and validate()'s own check read the unposted interest as earned - posted, so - // raising only `posted` drives that difference negative: an account paid 26.52 with - // `earned` still at zero reports its unposted interest as -26.52, and the account screen - // showed exactly that. The accrual writes pending interest to the ledger and never to - // `earned`, so this posting is the only place `earned` is realised. - jdbcTemplate.update(""" - UPDATE m_savings_account - SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, - total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, - total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ? - WHERE id = ? - """, amount, amount, amount, accountId); - - distributed = distributed.add(amount); + for (final Map line : lines) { + distributed = distributed + .add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId)); } - // The whole point of the two control accounts: if this does not hold, nothing commits. + // If the credits do not add up to the debit, nothing commits. if (pooled.compareTo(distributed) != 0) { throw new IllegalStateException(String.format( - "Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s", - productId, postingDate, pooled, distributed)); + "Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed)); } + return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size()); + } + + /** + * One customer's credit: the interest transaction, the Savings Control credit naming the account, + * and the account, ledger and report line brought up to date. The branch debit is raised by the + * caller. + * + * @return the amount credited, zero for a line with nothing to pay + */ + private BigDecimal payMember(final Map line, final long productId, final long officeId, + final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference, + final String narration, final long runId) { + final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + final long accountId = ((Number) line.get("savings_account_id")).longValue(); + + // A resumed or duplicated attempt must not pay anybody twice. The line itself records the + // payment, so this holds even where one account has two cycles posted on the same day. + if (line.get("savings_transaction_id") != null) { + throw new IllegalStateException("Savings account " + accountId + " was already paid by this report"); + } + + // The balance as it stands now, not as the report showed it: money moved since the report + // was generated, and the transaction's running balance has to follow the account. + final BigDecimal currentBalance = jdbcTemplate.queryForObject( + "SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId); + final BigDecimal newBalance = currentBalance.add(amount); + + final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance); + + final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL); + journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration); + + // Settles the ledger row where it still holds the cycle the report was drawn from. Where the + // nightly run has already moved it into the next cycle — a branch that was shut on its posting + // date and posts days later — that row is the new cycle's and must not be marked paid; it only + // takes the money, because the next day's accrual opens from its closing balance. + final int settled = jdbcTemplate.update(""" + UPDATE m_savings_daily_interest + SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?, + closing_balance = closing_balance + ? + WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ? + """, amount, transactionId, runId, amount, accountId, cycleDate); + if (settled == 0) { + jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?", + amount, accountId); + } + + // Earned is only ever raised to what has now been posted, never added to: the engine may + // already have counted this interest as earned when the account was last recalculated, + // and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the + // unposted interest as earned - posted, so earned must not fall below posted either. + jdbcTemplate.update(""" + UPDATE m_savings_account + SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?, + total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?, + total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0), + COALESCE(total_interest_posted_derived, 0) + ?) + WHERE id = ? + """, amount, amount, amount, accountId); + + jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?", + transactionId, ((Number) line.get("line_id")).longValue()); + + return amount; + } + + private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled, + final BigDecimal distributed, final long runId, final int accountCount) { jdbcTemplate.update(""" UPDATE m_savings_interest_posting_run - SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?, + SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0, status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP WHERE id = ? - """, pooled, distributed, residual, runId); + """, pooled, distributed, runId); + jdbcTemplate.update(""" + UPDATE m_savings_interest_posting_report + SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL + WHERE id = ? + """, runId, reportId); - log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId, - postingDate, distributed, due.size(), residual); + log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId, + cycleDate, distributed, accountCount); return distributed; } - private long openRun(final long productId, final LocalDate postingDate, final int accountCount) { + /** One run per report. A retry of the same report reuses its run. */ + private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate, + final int accountCount) { jdbcTemplate.update(""" INSERT INTO m_savings_interest_posting_run - (savings_product_id, posting_date, account_count, status) - VALUES (?, ?, ?, 'PENDING') - ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET + (report_id, savings_product_id, office_id, posting_date, account_count, status) + VALUES (?, ?, ?, ?, ?, 'PENDING') + ON CONFLICT (report_id) DO UPDATE SET account_count = EXCLUDED.account_count, status = 'PENDING', started_at = CURRENT_TIMESTAMP, failure_reason = NULL - """, productId, postingDate, accountCount); - return jdbcTemplate.queryForObject(""" - SELECT id FROM m_savings_interest_posting_run - WHERE savings_product_id = ? AND posting_date = ? - """, Long.class, productId, postingDate); + """, reportId, productId, officeId, cycleDate, accountCount); + return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId); } private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date, @@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService { """, Long.class, accountId, date, INTEREST_POSTING); } - /** The member's own leg: the product's Savings Control account, credited per account. */ - private void creditMemberSavingsControl(final long officeId, final String currency, final long productId, - final LocalDate date, final BigDecimal amount, final String reference, final long accountId, - final long transactionId) { - final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL); - journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId); - } - - private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, - final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) { - journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null); - } - /** * One journal entry leg. * @@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService { */ private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId, - final Long transactionId) { + final Long transactionId, final String narration) { GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); jdbcTemplate.update(""" INSERT INTO acc_gl_journal_entry @@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService { created_on_utc, last_modified_on_utc, submitted_on_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) - """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, - debit ? "Savings interest posting" : "Savings interest posted", + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(), date); } @@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService { } /** The first configured account of those given, in order of preference. */ + private boolean isCashAccounted(final long productId) { + final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?", + Integer.class, productId); + return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH; + } + private static Long firstNonNull(final Long... candidates) { for (final Long candidate : candidates) { if (candidate != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java new file mode 100644 index 000000000..a6a9b1bab --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsInterestPostingReportApiResource.java @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.api; + +import com.google.gson.Gson; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.stereotype.Component; + +/** + * The savings interest posting reports, per branch and product: list, detail, generate, approve, the + * day-close check, and the head office's branch-wise summary. + * + *

+ * Posting is not offered here. A branch's approved reports are posted when that branch closes the + * day, so there is exactly one way money leaves the payable. + */ +@Path("/v1/savingsinterestpostingreports") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class SavingsInterestPostingReportApiResource { + + private static final String RESOURCE = "SAVINGSINTERESTREPORT"; + + private final SavingsInterestPostingReportService reportService; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + /** A branch user sees their own branch; a head office user every branch, or one with officeId. */ + @GET + public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status, + @QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId, + @QueryParam("scope") final String scope) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream().map(this::plain).toList()); + } + + /** Who is looking: their branch, and whether it is the head office (which gets the Other branches tab). */ + @GET + @Path("/context") + public String userContext() { + final AppUser user = context.authenticatedUser(); + final Long officeId = user.getOffice().getId(); + final Map result = new LinkedHashMap<>(); + result.put("officeId", officeId); + result.put("officeName", user.getOffice().getName()); + result.put("headOffice", reportService.isHeadOffice(officeId)); + return gson.toJson(result); + } + + /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */ + @GET + @Path("/due-without-report") + public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + return gson.toJson(reportService.dueWithoutReport(date, user, scope)); + } + + /** + * What stops a branch closing the business day: its own reports due and not yet approved. The + * day-close screen reads it to warn before the close is attempted. + */ + @GET + @Path("/day-close-check") + public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) { + final AppUser user = context.authenticatedUser(); + final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date); + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final List blocking = reportService.reportsBlockingDayClose(officeId, businessDate); + final Map result = new LinkedHashMap<>(); + result.put("businessDate", businessDate.toString()); + result.put("canClose", blocking.isEmpty()); + result.put("pendingProducts", blocking); + return gson.toJson(result); + } + + /** Interest each account has earned so far this cycle, as if posted today. View only — nothing is saved. */ + @GET + @Path("/preview") + public String preview(@QueryParam("officeId") final Long officeId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + return gson.toJson(reportService.preview(officeId, user).stream().map(this::plain).toList()); + } + + /** The head office's view: per branch and product, customers paid and interest, over a date range. */ + @GET + @Path("/summary") + public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, + @QueryParam("productId") final Long productId) { + final AppUser user = context.authenticatedUser(); + user.validateHasReadPermission(RESOURCE); + if (!reportService.isHeadOffice(user.getOffice().getId())) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only", + "The branch-wise interest summary is for the head office."); + } + return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList()); + } + + @GET + @Path("/{reportId}") + public String detail(@PathParam("reportId") final Long reportId) { + context.authenticatedUser().validateHasReadPermission(RESOURCE); + final Map report = plain(reportService.detail(reportId)); + @SuppressWarnings("unchecked") + final List> lines = (List>) report.get("lines"); + report.put("lines", lines.stream().map(this::plain).toList()); + return gson.toJson(report); + } + + /** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + public String command(@QueryParam("command") final String command, final String body) { + if (!"regenerate".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command", + "Unsupported command: " + command); + } + final AppUser user = context.authenticatedUser(); + user.validateHasPermissionTo("REGENERATE_" + RESOURCE); + final var root = fromJsonHelper.parse(body); + final Long productId = fromJsonHelper.extractLongNamed("productId", root); + final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root); + final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root); + if (productId == null) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?"); + } + final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId(); + final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate); + final Long reportId = reportService.regenerate(productId, officeId, date, user); + return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED)); + } + + /** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */ + @POST + @Path("/{reportId}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) { + if (!"approve".equalsIgnoreCase(command)) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command", + "Unsupported command: " + command); + } + final AppUser user = context.authenticatedUser(); + user.validateHasPermissionTo("APPROVE_" + RESOURCE); + reportService.approve(reportId, user); + return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED)); + } + + private static LocalDate parseDate(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (final RuntimeException e) { + throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date", + "Dates are expected as yyyy-MM-dd, not " + raw); + } + } + + /** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */ + private Map plain(final Map row) { + final Map out = new LinkedHashMap<>(); + row.forEach((key, value) -> { + if (value instanceof java.sql.Date d) { + out.put(key, d.toLocalDate().toString()); + } else if (value instanceof Timestamp t) { + out.put(key, t.toLocalDateTime().toString()); + } else { + out.put(key, value); + } + }); + return out; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java new file mode 100644 index 000000000..134bafe04 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportConfig.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SavingsInterestPostingReportConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) { + return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository) + .tasklet(savingsInterestPostingReportTasklet, transactionManager).build(); + } + + @Bean + public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) { + return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository) + .start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java new file mode 100644 index 000000000..287b00e6d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/jobs/SavingsInterestPostingReportTasklet.java @@ -0,0 +1,45 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.dailyinterest.jobs; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * Noon on the business date: generates the interest posting report for every savings product whose + * interest falls due, so it can be reviewed and approved before the branches close the day. + */ +@RequiredArgsConstructor +@Component +public class SavingsInterestPostingReportTasklet implements Tasklet { + + private final SavingsInterestPostingReportService reportService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + reportService.generateDue(DateUtils.getBusinessLocalDate()); + return RepeatStatus.FINISHED; + } +} 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 841776e50..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 @@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator { Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, - bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName)); + bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, + 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)); + private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList( + DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, + DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName)); private static final Set DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, @@ -245,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/data/DormantAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java index afede6a65..9f0a4a43f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DormantAccountData.java @@ -36,6 +36,8 @@ import lombok.Getter; @Builder public class DormantAccountData { + /** SAVINGS or CURRENT: which kind of account accountId refers to. */ + private final String accountType; private final Long accountId; private final String accountNo; private final Long clientId; 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 62d049e95..c6ba74a3f 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 @@ -19,10 +19,14 @@ package org.apache.fineract.portfolio.savings.domain; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName; 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; @@ -36,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; @@ -46,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; @@ -65,6 +71,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; @@ -72,7 +79,9 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; 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.portfolio.tds.service.SavingsInterestTdsService; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -93,6 +102,9 @@ 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; + private final SavingsInterestTdsService savingsInterestTdsService; @Transactional @Override @@ -267,9 +279,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi updateAlreadyPostedTransactions(existingTransactionIds, account); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer); } else { + // Paid out in cash or to a GL, so the interest never reaches savings: TDS is tracked and taken here. + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.close(user, command, changes); @@ -303,16 +319,29 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest()) { + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isReinvestPrincipal()) { // Renews on the SAME account, so the customer keeps one account number however many times - // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new - // term's principal, so there is no withdrawal, no deposit and no journal entry for the - // rollover itself -- which also removes the transfer-suspense round trip the old - // close-and-reopen flow needed. + // the deposit rolls over -- Reinvest Principal Only renews just the closing principal here + // (below), with only the leftover interest actually leaving, rather than the legacy + // close-and-reopen this branch used to use, which left the customer with a second account + // number for something that renews here just as cleanly as Reinvest Principal + Interest + // does. Full reinvest itself still moves nothing: the whole matured balance simply becomes + // the new term's principal, so there is no withdrawal, no deposit and no journal entry for + // the rollover itself. final BigDecimal closingPrincipal = account.getDepositAmount(); final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null : maturedBalance.subtract(closingPrincipal); + final BigDecimal renewedAmount = onClosureType.isReinvestPrincipal() ? closingPrincipal : maturedBalance; + // Whatever doesn't renew is interest the customer earned and has to actually be paid out + // somewhere -- validated before anything below runs, so a missing target fails cleanly with + // nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(renewedAmount); + if (onClosureType.isReinvestPrincipal() && leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } // The active CBS base interest-rate version for this FD type: the same rate card the Base // Interest Rates screen shows and the account-opening wizard prices from. Used for both the @@ -337,52 +366,32 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer closingTenure = account.depositPeriod(); final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); - account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); this.savingsAccountRepository.save(account); - recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, closingTenure, closingTenureFrequencyEnum); changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - } else if (onClosureType.isReinvest()) { - // "Re-invest principal only" still closes and reopens, because only the principal carries - // forward and the interest has to leave the deposit -- which needs a payout destination that - // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - ExternalId externalId = this.externalIdFactory.create(); - - FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - // Renewal moves the balance internally into the new deposit -- no cash leaves the branch -- - // so this leg is posted as an account transfer (against the transfer-suspense GL) rather - // than as a counter withdrawal, which would wrongly credit the cash/vault account. - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - // handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as - // already accounted for -- exactly as the transfer-to-savings branch below does -- or the - // postJournalEntries() call at the end of this method posts it a second time. - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - // Reached from the maturity job processing the account's standing maturity instruction. - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + // Only Reinvest Principal Only leaves anything here -- Full Reinvest's leftoverInterest is + // zero (renewedAmount already equals maturedBalance), so this stays a no-op for it. + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, + "Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null, + AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, @@ -397,10 +406,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.updateClosedStatus(); account.updateOnAccountClosureStatus(onClosureType); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } // if(!processMaturityInstructionOnly) @@ -480,9 +492,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.close(user, command, changes); @@ -723,13 +738,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + // Only on this first-ever close of this term -- otherwise a term that renews more than once + // (e.g. this account renewed, then that renewal itself later undone/retried) re-archives the + // same chart against the same closingRow every time, piling up duplicate slab rows for a + // chart that only ever needs archiving once. + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); } closingRow.recordOutcome(closingInterestEarned, maturedBalance); closingRow.markMatured(); this.fixedDepositRenewalHistoryRepository.save(closingRow); - archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); - // The new term's own interest/maturity stay unset until it closes in turn. this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, @@ -816,6 +834,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); Long savingsTransactionId = null; + // Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored + // preClosurePenalApplicable config -- this is a one-time override for this closure only, read + // fresh here since the preview and the actual submit are two separate requests. + final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName) + && command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName); + // RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see + // PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual + // closure always matches what the preview showed for the same reason. + final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType + .fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)); + account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived()); + // post interest account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); @@ -835,13 +865,34 @@ 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); + // Taken before the proceeds are read, so the GL receives the payout net of TDS. + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + "GL"); + 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); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); + postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate, + command.stringValueOfParameterNamed(transferDescriptionParamName)); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + "CASH"); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); // handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already // accounted for or the postJournalEntries() call below posts it a second time. updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.prematureClosure(user, command, changes); @@ -852,13 +903,92 @@ 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 * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest - * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through - * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what - * the existing manual Close command still uses today. + * transfers out to a savings account picked at the moment of the request. Reinvest Principal + Interest and + * Reinvest Principal Only both go through the same {@code renewInPlace} mechanism too -- Principal Only is just + * Partial Reinvest with the split fixed at exactly the closing principal, so it renews on this same account with + * only the interest actually leaving, rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest} + * this branch used to use (still what the existing manual Close command uses today, and what the scheduled job's + * own closure path -- {@link #handleFDAccountMaturityClosure} -- still uses for an account with an + * already-stored instruction). *

* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as @@ -925,10 +1055,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { - // These two branches keep the account open, so they never reach close()/prematureClosure() and - // would otherwise accept any date at all -- including one behind already-posted interest, which - // the renewal would then turn into a pivot that reverses and re-posts it. + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { + // These three branches keep the account open, so they never reach close()/prematureClosure() + // and would otherwise accept any date at all -- including one behind already-posted interest, + // which the renewal would then turn into a pivot that reverses and re-posts it. account.validateRenewalDate(closedDate); final BigDecimal closingPrincipal = account.getDepositAmount(); @@ -944,6 +1074,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); } + } else if (onClosureType.isReinvestPrincipal()) { + // Exactly the closing term's own principal -- the fixed point on the same split Partial + // Reinvest lets staff pick freely. Renews in place same as the other two, on just this + // account, with only the interest actually leaving (below) -- no second account, unlike the + // legacy close-and-reopen this branch used to do. + renewedAmount = closingPrincipal; } else { renewedAmount = maturedBalance; } @@ -976,74 +1112,25 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi changes.put("renewedInPlace", Boolean.TRUE); changes.put("renewalCycleNo", account.renewalCycleNo()); - if (onClosureType.isPartialReinvest()) { + if (onClosureType.isPartialReinvest() || onClosureType.isReinvestPrincipal()) { // The kept portion never left the account -- renewInPlace() just relabels it as the new - // term's principal -- so only the remainder is an actual money movement, posted the same - // way the Transfer to Savings branch below posts its full-balance transfer. + // term's principal -- so only the remainder (the interest, for Reinvest Principal Only) is + // an actual money movement, posted the same way the Transfer to Savings branch below posts + // its full-balance transfer. final BigDecimal remainder = maturedBalance.subtract(renewedAmount); - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS, - PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null, - AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, - isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - } else if (onClosureType.isReinvestPrincipal()) { - // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- - // the interest has to leave the deposit, which needs a payout destination an in-place renewal - // has nowhere to send. - final BigDecimal maturedBalance = account.getAccountBalance(); - final BigDecimal reInvestAmount = account.getDepositAmount(); - final ExternalId externalId = this.externalIdFactory.create(); - - final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); - this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); - reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); - autoGenerateAccountNumber(reinvestedDeposit); - final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, - false, isRegularTransaction, isInternalTransferLeg); - savingsTransactionId = withdrawal.getId(); - updateAlreadyPostedTransactions(existingTransactionIds, account); - - changes.put("reinvestedDepositId", reinvestedDeposit.getId()); - reinvestedDeposit.approveAndActivateApplication(closedDate, user); - this.savingsAccountRepository.save(reinvestedDeposit); - - recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, - FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); - - // Only the principal moved into the new deposit -- the interest earned on this term is still - // sitting in this account's balance. Pay it out to the chosen savings account now, before - // closing, rather than stranding it here with nowhere left to go. - final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); - if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { - final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); - final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); - final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, - DepositAccountType.SAVINGS_DEPOSIT); - final boolean isExceptionForBalanceCheck = false; - final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder, - PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, - null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, - toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); - this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); - updateAlreadyPostedTransactions(existingTransactionIds, account); - } - - if (alreadyMatured) { - account.close(user, command, changes); - } else { - account.prematureClosure(user, command, changes); + if (remainder.compareTo(BigDecimal.ZERO) > 0) { + final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); + final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, + null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, + toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } } } else if (onClosureType.isTransferToSavings()) { @@ -1066,10 +1153,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi } } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(onClosureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); updateAlreadyPostedTransactions(existingTransactionIds, account); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); if (alreadyMatured) { account.close(user, command, changes); @@ -1127,9 +1217,12 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); updateAlreadyPostedTransactions(existingTransactionIds, account); } else { + final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate, + directPayoutKind(closureType)); final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), paymentDetail, false, isRegularTransaction); savingsTransactionId = withdrawal.getId(); + this.savingsInterestTdsService.recordDirectPayout(tds, withdrawal, existingTransactionIds); } account.prematureClosure(user, command, changes); @@ -1154,6 +1247,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData); } + /** How a closure that does not go to savings paid out, for the TDS tracking record. */ + private static String directPayoutKind(final DepositAccountOnClosureType closureType) { + return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH"; + } + private void updateAlreadyPostedTransactions(final Set existingTransactionIds, final SavingsAccount savingsAccount) { // accountTransfersWritePlatformService.transferFunds() already posts accounting for the // withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal), 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 d1bbdc50c..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 @@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; -import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType; @@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount { protected InterestRateChartAssembler chartAssembler; @Transient private ConfigurationDomainService configurationDomainService; + /** + * Set once per request, immediately before a premature-closure preview or the real closure, from the + * maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is + * a one-time decision for this specific closure rather than a product/account setting. The product's + * own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so + * every other premature closure of this same product still applies its penalty as normal; this only + * suppresses it for the one request that explicitly asked to waive it. + */ + @Transient + private boolean waivePreClosurePenaltyForThisRequest; protected FixedDepositAccount() { // @@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount { if (this.chart != null) { boolean applyPreMaturePenalty = false; BigDecimal penalInterest = BigDecimal.ZERO; - LocalDate depositCloseDate = calculateMaturityDate(); + // Rate-selection date is always calculateMaturityDate() below -- premature closure prices off + // the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one + // every regular scheduled interest posting this account ever makes (see this method's 2-arg + // overload), so a closure's freshly-recomputed total always matches what the account was + // already accruing all along. No separate "actual period held" slab lookup -- deliberately + // simple, one rate for the account's whole life. + final LocalDate depositCloseDate = calculateMaturityDate(); if (isPreMatureClosure) { - if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) { + if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) { applyPreMaturePenalty = true; penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest(); - final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure - .depositPreClosureDetail().preClosurePenalInterestOnType(); - if (preClosurePenalInterestOnType.isWholeTerm()) { - depositCloseDate = interestCalculatedUpto(); - } else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) { - depositCloseDate = interestPostingUpToDate; - } } } @@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount { boolean isInterestTransfer = false; final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( - transactions); + transactionsForCurrentTermInterestCalculation(transactions)); for (final LocalDateInterval periodInterval : postingPeriodIntervals) { boolean isUserPosting = false; if (postedAsOnTransactionDates.contains(periodInterval.endDate())) { @@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount { final boolean isInterestTransfer = false; final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); final List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( - transactions); + transactionsForCurrentTermInterestCalculation(transactions)); for (final LocalDateInterval periodInterval : postingPeriodIntervals) { final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate()); @@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount { final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); + // RBI audit trail: record why, not just that, this closure happened -- see + // PrematureClosureReasonType. Absent entirely on a request from before this field existed, + // which resolves to INVALID/null here rather than a misleading "Normal". + final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName); + this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId)); + /* * // withdraw deposit amount before closing the account final Money transactionAmountMoney = * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = @@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount { } + /** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview) + * or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the + * same interest calculation this waives a part of. */ + public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) { + this.waivePreClosurePenaltyForThisRequest = waive; + } + public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { @@ -807,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, @@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount { return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null; } + /** + * Interest already posted for the term currently running, NOT the account's whole lifetime. + *

+ * A renewed account keeps every prior term's transactions in {@code this.transactions} -- including + * whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg) + * for a term that has since closed. Summing all of them here double-counted that stale interest: a + * premature closure of the CURRENT term subtracted it from the maturity amount + * ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never + * part of this term's balance to begin with, silently shortchanging the customer by exactly that + * amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its + * deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason + * {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before + * 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()) { - 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; @@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount { return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate()); } + /** + * A renewed term's opening balance is seeded straight to the term's own principal (see + * calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF + * depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing + * transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed + * and the new one opened in the same stroke), so without this filter it still falls inside the new + * term's posting-period walk and gets applied a SECOND time on top of the already-net seed -- + * understating the very first day's balance by the old term's entire payout, which silently corrupts + * that day's interest for as long as the term keeps re-including it (every premature-closure preview, + * and any later maturity recalculation). Scoping to transactions strictly after the term start avoids + * the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway. + */ + private List transactionsForCurrentTermInterestCalculation( + final List transactions) { + if (!isRenewedTerm()) { + return transactions; + } + final LocalDate currentTermStartDate = depositStartDate(); + final List scoped = new ArrayList<>(); + for (final SavingsAccountTransaction transaction : transactions) { + if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) { + scoped.add(transaction); + } + } + return scoped; + } + private LocalDate interestCalculatedUpto() { LocalDate uptoMaturityDate = calculateMaturityDate(); if (uptoMaturityDate != null) { 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/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java index 29c751cd6..ef48cf1c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatesavingsdormantaccounts/UpdateSavingsDormantAccountsTasklet.java @@ -23,6 +23,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.savings.service.CurrentAccountDormancyJobService; import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.springframework.batch.core.StepContribution; @@ -38,6 +39,7 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { private final SavingsAccountReadPlatformService savingAccountReadPlatformService; private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final CurrentAccountDormancyJobService currentAccountDormancyJobService; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { @@ -63,6 +65,9 @@ public class UpdateSavingsDormantAccountsTasklet implements Tasklet { savingsAccountWritePlatformService.escheat(savingsId); } } + + // Current accounts run the same ladder on their own current product's settings. + currentAccountDormancyJobService.run(tenantLocalDate); return RepeatStatus.FINISHED; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java new file mode 100644 index 000000000..a45768f2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountCashGlResolver.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccount; +import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepository; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.springframework.stereotype.Service; + +/** + * The cash GL a current account's deposit or withdrawal posts against. + * + *

+ * A current product maps only the customer's liability (Current Control); the cash side is the branch's cash, which + * is the same GL the cashier's Transaction Entry screen uses: the Financial Activity mapping, Cash at Main Vault first + * and Cash at Teller after it -- the order that screen picks in, so the cashier and the ledger always name the same GL. + */ +@Service +@RequiredArgsConstructor +public class CurrentAccountCashGlResolver { + + private final FinancialActivityAccountRepository financialActivityAccountRepository; + + /** The branch cash GL, or null when neither cash activity is mapped. */ + public GLAccount resolve() { + for (final FinancialActivity activity : new FinancialActivity[] { FinancialActivity.CASH_AT_MAINVAULT, + FinancialActivity.CASH_AT_TELLER }) { + final FinancialActivityAccount mapping = financialActivityAccountRepository.findByFinancialActivityType(activity.getValue()); + if (mapping != null && mapping.getGlAccount() != null) { + return mapping.getGlAccount(); + } + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java new file mode 100644 index 000000000..27b395edc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountDormancyJobService.java @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * The dormancy ladder for current accounts, run by the same nightly job as savings. + * + *

+ * Current accounts live in m_current_account, which the savings job never reads, so until now a current account was + * never marked inactive or dormant and never escheated. This walks the same three rungs -- Inactive, Dormant, Escheat + * -- against each account's own current product (m_current_product: is_dormancy_tracking_active and its three day + * counts), measuring idle days with DormancyClock exactly as savings does, so the two kinds of account can never + * disagree about the same customer. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountDormancyJobService { + + private static final int SUB_NONE = 0; + private static final int SUB_INACTIVE = 100; + private static final int SUB_DORMANT = 200; + + private final JdbcTemplate jdbcTemplate; + private final DatabaseSpecificSQLGenerator sqlGenerator; + private final CurrentAccountTransactionWritePlatformService currentAccountTransactionWritePlatformService; + + public void run(final LocalDate businessDate) { + // A re-KYC restarts the clock (DormancyClock), so an account stamped Inactive/Dormant whose idle days are now + // back under the inactive threshold has been reactivated: clear the stamp before climbing the ladder again. + final int reactivated = jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0 WHERE id IN (" + + "SELECT ca.id FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum IN (100, 200) AND cp.days_to_inactive > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " < cp.days_to_inactive)", businessDate); + if (reactivated > 0) { + log.info("[Dormancy] {} current accounts reactivated after re-KYC", reactivated); + } + final int inactive = stamp(businessDate, SUB_NONE, "days_to_inactive", SUB_INACTIVE); + final int dormant = stamp(businessDate, SUB_INACTIVE, "days_to_dormancy", SUB_DORMANT); + int escheated = 0; + for (final Long accountId : pending(businessDate, SUB_DORMANT, "days_to_escheat")) { + try { + if (currentAccountTransactionWritePlatformService.escheat(accountId)) { + escheated++; + } + } catch (RuntimeException e) { + log.error("[Dormancy] Escheat failed for current account {}: {}", accountId, e.getMessage(), e); + } + } + log.info("[Dormancy] Current accounts: {} marked inactive, {} marked dormant, {} escheated", inactive, dormant, escheated); + } + + /** Moves every account on rung {@code from} whose idle days reached {@code daysColumn} up to {@code to}. */ + private int stamp(final LocalDate businessDate, final int from, final String daysColumn, final int to) { + final List ids = pending(businessDate, from, daysColumn); + for (final Long accountId : ids) { + jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = ? WHERE id = ? AND status_enum = 300 AND sub_status_enum = ?", + to, accountId, from); + } + return ids.size(); + } + + private List pending(final LocalDate businessDate, final int subStatus, final String daysColumn) { + final String sql = "SELECT ca.id FROM m_current_account ca" + + " JOIN m_current_product cp ON cp.id = ca.product_id AND cp.is_dormancy_tracking_active = true" + + " WHERE ca.status_enum = 300 AND ca.sub_status_enum = ? AND cp." + daysColumn + " > 0" + + " AND " + sqlGenerator.dateDiff("?", DormancyClock.lastLiveOn("ca")) + " >= cp." + daysColumn; + return jdbcTemplate.queryForList(sql, Long.class, subStatus, businessDate); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java index cbc3c5a63..1e0b19d61 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountReadPlatformServiceImpl.java @@ -23,12 +23,14 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotFoundException; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountReadPlatformService; import org.springframework.jdbc.core.JdbcTemplate; @@ -71,9 +73,61 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead response.put("charges", retrieveCharges(accountId)); response.put("transactions", retrieveTransactions(accountId)); response.put("accountFacilityConfig", retrieveFacilityConfig(accountId)); + addDormancy(accountId, response); return response; } + /** + * The dormancy countdown the account screen's Dormancy Risk Monitor reads, measured exactly as a savings account's + * is: from DormancyClock's last-live date (activation or the latest re-KYC), against the product's thresholds and + * the business date. + * + * The product's dormancyAlert decides whether the screen shows the monitor at all. subStatus is replaced by the + * stage the elapsed days have earned, as the savings read does, because no job stamps current accounts yet; a + * Block stamp (300 and above) is not a dormancy stage and is left alone. + */ + private void addDormancy(final Long accountId, final Map response) { + final String sql = "SELECT cp.dormancy_alert, cp.is_dormancy_tracking_active, cp.days_to_inactive, cp.days_to_dormancy," + + " cp.days_to_escheat, ca.status_enum, ca.sub_status_enum, " + DormancyClock.lastLiveOn("ca") + " AS last_live_on" + + " FROM m_current_account ca JOIN m_current_product cp ON cp.id = ca.product_id WHERE ca.id = ?"; + final List> rows = jdbcTemplate.queryForList(sql, accountId); + if (rows.isEmpty()) { + return; + } + final Map row = rows.get(0); + final boolean tracking = toBoolean(row.get("is_dormancy_tracking_active")); + response.put("dormancyAlert", toBoolean(row.get("dormancy_alert"))); + response.put("isDormancyTrackingActive", tracking); + + final LocalDate lastLive = row.get("last_live_on") instanceof java.sql.Date d ? d.toLocalDate() : null; + response.put("lastActiveTransactionDate", lastLive == null ? null : lastLive.toString()); + if (!tracking || lastLive == null || toInt(row.get("status_enum"), 100) != 300) { + return; + } + + final Integer toInactive = row.get("days_to_inactive") == null ? null : toInt(row.get("days_to_inactive"), 0); + final Integer toDormant = row.get("days_to_dormancy") == null ? null : toInt(row.get("days_to_dormancy"), 0); + final Integer toEscheat = row.get("days_to_escheat") == null ? null : toInt(row.get("days_to_escheat"), 0); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final long idle = ChronoUnit.DAYS.between(lastLive, today); + + int subStatus = toInt(row.get("sub_status_enum"), 0); + if (subStatus < 300) { + subStatus = toDormant != null && toDormant > 0 && idle >= toDormant ? 200 + : toInactive != null && toInactive > 0 && idle >= toInactive ? 100 : 0; + response.put("subStatus", buildSubStatus(subStatus)); + } + if (subStatus < 100 && toInactive != null) { + response.put("daysToInactive", ChronoUnit.DAYS.between(today, lastLive.plusDays(toInactive))); + } + if (subStatus < 200 && toDormant != null) { + response.put("daysToDormancy", ChronoUnit.DAYS.between(today, lastLive.plusDays(toDormant))); + } + if (toEscheat != null) { + response.put("daysToEscheat", ChronoUnit.DAYS.between(today, lastLive.plusDays(toEscheat))); + } + } + private Map retrieveFacilityConfig(Long accountId) { final String sql = "SELECT * FROM m_current_account_facility_config WHERE current_account_id = ?"; List> rows = jdbcTemplate.query(sql, (rs, rowNum) -> { @@ -207,7 +261,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead tt.put("rejectTransfer", false); tt.put("writtenoff", false); tt.put("overdraftFee", false); - tt.put("escheat", false); + tt.put("escheat", typeEnum == 19); tt.put("amountHold", typeEnum == 8); tt.put("amountRelease", typeEnum == 9); tt.put("accrual", false); @@ -219,6 +273,7 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead case 7 -> { tt.put("code", "savingsTransactionType.payCharge"); tt.put("value", "Charge Deduction"); } case 8 -> { tt.put("code", "savingsTransactionType.onHold"); tt.put("value", "Amount on hold"); } case 9 -> { tt.put("code", "savingsTransactionType.release"); tt.put("value", "Release Amount"); } + case 19 -> { tt.put("code", "savingsTransactionType.escheat"); tt.put("value", "Escheat"); } default -> { tt.put("code", "savingsTransactionType.deposit"); tt.put("value", "Deposit"); } } return tt; @@ -477,6 +532,8 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead out.put("allowOverdraft", row.get("allow_overdraft")); out.put("overdraftLimit", row.get("overdraft_limit")); out.put("minOverdraftForInterestCalculation", row.get("min_overdraft_for_interest_calculation")); + out.put("nominalAnnualInterestRateOverdraft", row.get("nominal_annual_interest_rate_overdraft")); + out.put("validForPeriod", row.get("valid_for_period")); out.put("minRequiredBalance", row.get("min_required_balance")); out.put("enforceMinRequiredBalance", row.get("enforce_min_required_balance")); out.put("onHoldFunds", row.get("on_hold_funds_derived")); @@ -576,12 +633,14 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead Map s = new LinkedHashMap<>(); s.put("id", subStatusEnum); String value = switch (subStatusEnum) { + // Same codes as savings (SavingsAccountSubStatusEnum), so both kinds of account read alike. case 0 -> "None"; case 100 -> "Inactive"; case 200 -> "Dormant"; - case 300 -> "Block"; - case 400 -> "Block Debit"; + case 300 -> "Escheat"; + case 400 -> "Block"; case 500 -> "Block Credit"; + case 600 -> "Block Debit"; default -> "None"; }; s.put("code", "SavingsAccountSubStatusEnum." + value.replace(" ", "")); @@ -589,9 +648,10 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead s.put("none", subStatusEnum == 0); s.put("inactive", subStatusEnum == 100); s.put("dormant", subStatusEnum == 200); - s.put("block", subStatusEnum == 300); - s.put("blockDebit", subStatusEnum == 400); + s.put("escheat", subStatusEnum == 300); + s.put("block", subStatusEnum == 400); s.put("blockCredit", subStatusEnum == 500); + s.put("blockDebit", subStatusEnum == 600); return s; } @@ -689,13 +749,15 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead }; } + // SavingsPostingInterestPeriodType codes -- the ones the product form saves and the account copies. private String interestPostingPeriodValue(int id) { return switch (id) { - case 1 -> "Monthly"; - case 2 -> "Quarterly"; - case 3 -> "Biannual"; - case 4 -> "Annually"; - default -> "Annually"; + case 1 -> "Daily"; + case 4 -> "Monthly"; + case 5 -> "Quarterly"; + case 6 -> "Biannual"; + case 7 -> "Annually"; + default -> "Monthly"; }; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java index c359556d1..3b9ee3487 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java @@ -34,6 +34,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; @@ -45,7 +46,9 @@ import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper; import org.apache.fineract.infrastructure.core.service.DateUtils; @Slf4j @@ -63,6 +66,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; private final JournalEntryRepository journalEntryRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final KycRestrictionGuard kycRestrictionGuard; + private final AppUserRepositoryWrapper appUserRepositoryWrapper; + private final CurrentAccountCashGlResolver cashGlResolver; @Transactional @Override @@ -77,6 +83,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "deposit"); + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.CREDIT); this.denominationBreakdownWriter.validatePreviousDayClosed(); this.denominationBreakdownWriter.validateCashierHasMorningTransfer(); this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT"); @@ -123,6 +130,9 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); validatePositiveAmount(amount, accountId, "withdrawal"); + // A dormant account (its current product's dormancy days passed with no re-KYC) takes no debit + // until the customer is re-verified; the same rule the savings counter applies. + kycRestrictionGuard.checkCurrentAccount(accountId, KycRestrictionGuard.Operation.DEBIT); final BigDecimal currentBalance = safeBalance(account.getAccountBalanceDerived()); final BigDecimal onHold = safeBalance(account.getOnHoldFundsDerived()); @@ -159,7 +169,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate, "WITHDRAWAL"); - // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out) + // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR branch cash GL (cash out) postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal"); return new CommandProcessingResultBuilder() @@ -391,6 +401,59 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren .build(); } + /** + * Escheat, as the savings dormancy ladder does it: the balance goes to the product's escheat liability (Dr Current + * Control, Cr Escheat Liability) and the account is closed with sub-status Escheat (300) by the system user. + * + * Its own transaction, so one account that cannot be escheated never rolls back the rest of the night's batch. + * Refused -- logged, account untouched -- where the move would be wrong or unrecorded: a debit balance is money the + * customer owes, not unclaimed money; a hold is a lien someone else has on the funds; and with cash accounting a + * balance must not leave the account unless the escheat GL exists to receive it. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Override + public boolean escheat(final Long accountId) { + final CurrentAccount account = currentAccountRepository.findById(accountId).orElse(null); + if (account == null || !account.isActive()) { + return false; + } + final BigDecimal balance = safeBalance(account.getAccountBalanceDerived()); + if (balance.signum() < 0 || safeBalance(account.getOnHoldFundsDerived()).signum() > 0) { + log.warn("[Escheat] Current account {} skipped: debit balance {} or funds on hold {}", accountId, balance, + account.getOnHoldFundsDerived()); + return false; + } + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + final boolean accounted = product != null && product.getAccountingType() != null && product.getAccountingType() != 1; + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount escheatAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); + if (accounted && balance.signum() > 0 && (controlAccount == null || escheatAccount == null)) { + log.warn("[Escheat] Current account {} skipped: product {} has no Current Control or Escheat Liability GL mapped", + accountId, account.getProductId()); + return false; + } + + final LocalDate today = DateUtils.getBusinessLocalDate(); + if (balance.signum() > 0) { + final CurrentAccountTransaction txn = CurrentAccountTransaction.escheat(account, account.getOffice(), balance, today); + txn.setRunningBalanceDerived(BigDecimal.ZERO); + currentAccountTransactionRepository.saveAndFlush(txn); + account.setAccountBalanceDerived(BigDecimal.ZERO); + account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(balance)); + if (accounted) { + final String ref = "CAT" + txn.getId(); + final String description = "Current Account Escheat"; + postGlEntry(account, controlAccount, JournalEntryType.DEBIT, balance, today, ref, description); + postGlEntry(account, escheatAccount, JournalEntryType.CREDIT, balance, today, ref, description); + } + } + account.setSubStatusEnum(300); + account.close(today, appUserRepositoryWrapper.fetchSystemUser()); + currentAccountRepository.saveAndFlush(account); + log.info("[Escheat] Current account {} escheated, balance {} moved to escheat liability", accountId, balance); + return true; + } + // ── Helpers ───────────────────────────────────────────────────────────────── private CurrentAccount findActiveAccount(final Long accountId) { @@ -443,37 +506,51 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren } } - // GL: cashEntryType=DEBIT/CREDIT for SAVINGS_REFERENCE; controlEntryType for SAVINGS_CONTROL + /** + * GL: cashEntryType for the branch cash GL, controlEntryType for the account's own product's Current Control. + * + *

+ * Mandatory when the product keeps accounts: a GL that cannot be resolved or posted throws, rolling the whole + * transaction back, so a balance can never move without its journal entry. It used to log and return, and a + * deposit whose Current Control lookup failed moved the balance with no entry at all. + */ private void postTransactionGl(final CurrentAccount account, final CurrentAccountTransaction txn, final JournalEntryType cashEntryType, final JournalEntryType controlEntryType, final String description) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - final Integer accountingType = product.getAccountingType(); - if (accountingType == null || accountingType == 1) return; // NONE = 1 - - final GLAccount cashAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), - CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mapping for current account product id={}", account.getProductId()); - return; - } - - final String ref = "CAT" + txn.getId(); - postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); - } catch (Exception e) { - log.error("[GL] Failed GL posting for CA transaction id={}: {}", txn.getId(), e.getMessage(), e); + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.product.not.found", + "Current product " + account.getProductId() + " of account " + account.getAccountNo() + " not found.", + account.getId()); } + final Integer accountingType = product.getAccountingType(); + if (accountingType == null || accountingType == 1) { + return; // NONE = 1: the product keeps no accounts + } + + // Cash side: the branch cash GL from the Financial Activity mapping -- the same one the cashier screen uses. + // Liability side: the account's own current product's Current Control, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + if (cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + + final String ref = "CAT" + txn.getId(); + postGlEntry(account, cashAccount, cashEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); + postGlEntry(account, controlAccount, controlEntryType, txn.getAmount(), txn.getTransactionDate(), ref, description); } private GLAccount findGlAccount(final Long productId, final int financialAccountType) { final var mapping = productToGLAccountMappingRepository .findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); return mapping != null ? mapping.getGlAccount() : null; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java index 95216e8e9..0804c7ad7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountWritePlatformServiceImpl.java @@ -1,764 +1,783 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.savings.service; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; -import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.organisation.office.domain.Office; -import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; -import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; -import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; -import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; -import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; -import org.apache.fineract.useradministration.domain.AppUser; -import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; -import org.apache.fineract.accounting.common.AccountingRuleType; -import org.apache.fineract.accounting.glaccount.domain.GLAccount; -import org.apache.fineract.accounting.journalentry.domain.JournalEntry; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; -import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; -import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; -import org.apache.fineract.portfolio.PortfolioProductType; -import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; -import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { - - private final PlatformSecurityContext context; - private final CurrentAccountRepository currentAccountRepository; - private final CurrentProductRepository currentProductRepository; - private final ClientRepositoryWrapper clientRepository; - private final OfficeRepositoryWrapper officeRepository; - private final CurrentAccountNumberGenerator accountNumberGenerator; - private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; - private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; - private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; - private final JournalEntryRepository journalEntryRepository; - private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; - - @Override - @Transactional - public CommandProcessingResult submitApplication(final JsonCommand command) { - context.authenticatedUser(); - - final JsonObject json = command.parsedJson().getAsJsonObject(); - - final Long clientId = getLong(json, "clientId"); - final Long productId = getLong(json, "productId"); - final Long officeId = getLong(json, "officeId"); - - final Client client = clientRepository.findOneWithNotFoundDetection(clientId); - final CurrentProduct product = currentProductRepository.findById(productId) - .orElseThrow(() -> new SavingsProductNotFoundException(productId)); - - if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.product.for.client", - "Client already has an active Current Account with the same product.", - "productId", productId); - } - final Office office = officeId != null - ? officeRepository.findOneWithNotFoundDetection(officeId) - : client.getOffice(); - - final String dateFormat = getString(json, "dateFormat"); - final String locale = getString(json, "locale"); - - LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); - if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - - // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 - Integer accountTypeEnum = 1; - // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" - String cbsAccountType = null; - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAccountType = json.get("accountType").getAsString(); - try { - accountTypeEnum = Integer.parseInt(rawAccountType); - } catch (Exception ignored) { - // It's a descriptive CBS account type string, not an enum int - cbsAccountType = rawAccountType; - } - } - - boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) - ? json.get("msme").getAsBoolean() : product.isMsme(); - boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); - boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); - - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = CurrentAccount.createNew( - client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, - product.currency().getCode(), product.currency().getDigitsAfterDecimal(), - product.currency().getInMultiplesOf(), - product.nominalAnnualInterestRate(), - product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), - product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), - product.isAllowOverdraft(), product.getOverdraftLimit(), - msme, autoDebitFacility, balanceSwapOption, - product.getCustomerSegment(), product.isAutoPost(), - product.getInterestCalculateSelector(), product.getAveragePeriod(), - product.getAvgBalanceCalcPeriod()); - account.setSubmittedOnByUser(user); - - boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); - account.setShareLinkingEnabled(shareLinkingEnabled); - - // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) - if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); - - // Copy product-level fields that are not in createNew() - account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); - account.setMinRequiredBalance(product.getMinRequiredBalance()); - account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); - account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); - account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); - account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); - account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); - account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); - account.setFrequencyFixDay(product.getFrequencyFixDay()); - account.setEnableLockinPeriod(product.isEnableLockinPeriod()); - account.setBalanceFrequency(product.getBalanceFrequency()); - account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); - account.setValidForPeriod(product.getValidForPeriod()); - account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); - account.setLienAllowed(product.isLienAllowed()); - account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); - account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); - account.setWithholdTax(product.withHoldTax()); - - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); - } - if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { - account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); - } - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - } - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - } - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { - account.setIntroducerName(json.get("introducerName").getAsString()); - } - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - } - // UI may send "introducerClientId" or "introducerId" - Long introducerClientId = getLong(json, "introducerClientId"); - if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); - if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) { - final String extId = json.get("externalId").getAsString().trim(); - account.setExternalId(extId.isEmpty() ? null : extId); - } - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { - account.setAccountStatus(json.get("accountStatus").getAsString()); - } - // Override customerSegment from request if provided - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { - account.setCustomerSegment(json.get("customerSegment").getAsString()); - } - - // Save first to get ID - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) - final String accountNo = accountNumberGenerator.generate(account); - account.updateAccountNo(accountNo); - - // Save nominees if provided in payload (same transaction) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); - } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { - clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); - } - - // Every digital facility starts disabled at account opening, regardless of anything the - // request sends or the product defaults to -- they're switched on deliberately, one at a - // time, later, through the account's own Edit Nominee & Features screen. - account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account)); - - // Joint holders - if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // Partners (non-individual) - if (json.has("partners") && json.get("partners").isJsonArray()) { - assemblePartners(account, json.getAsJsonArray("partners")); - } - - try { - currentAccountRepository.saveAndFlush(account); - } catch (RuntimeException e) { - throwIfDuplicateExternalId(e, account.getExternalId()); - throw e; - } - - log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", - account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); - - return new CommandProcessingResultBuilder() - .withEntityId(account.getId()) - .withClientId(clientId) - .withOfficeId(office.getId()) - .build(); - } - - @Override - @Transactional - public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.approve(approvedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.setStatusEnum(100); - account.setApprovedOnDate(null); - account.setApprovedOnByUser(null); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - - account.activate(activatedOnDate, user); - - // Activation posts no transaction of its own -- no opening-balance deposit, no - // digital-facility charges applied or collected, regardless of what the account's facility - // config or product defaults say. Funding and any facility charges happen strictly - // afterwards, through separate transactions a clerk processes once the account is already - // active. - currentAccountRepository.saveAndFlush(account); - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.reject(rejectedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.withdraw(withdrawnOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { - final AppUser user = context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); - if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); - account.close(closedOnDate, user); - currentAccountRepository.saveAndFlush(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - final JsonObject json = command.parsedJson().getAsJsonObject(); - - if (account.getStatusEnum() != null) { - if (account.getStatusEnum() == 500) { - account.setStatusEnum(100); - account.setRejectedOnDate(null); - account.setRejectedOnByUser(null); - } else if (account.getStatusEnum() == 400) { - account.setStatusEnum(100); - account.setWithdrawnOnDate(null); - account.setWithdrawnOnByUser(null); - } - } - - - if (json.has("externalId") && !json.get("externalId").isJsonNull()) - account.setExternalId(json.get("externalId").getAsString()); - if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) - account.setAccountStatus(json.get("accountStatus").getAsString()); - if (json.has("accountType") && !json.get("accountType").isJsonNull()) { - final String rawAt = json.get("accountType").getAsString(); - try { Integer.parseInt(rawAt); } catch (Exception ignored) { - // descriptive CBS type — save it - account.setCbsAccountType(rawAt); - } - } - if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) - account.setCustomerSegment(json.get("customerSegment").getAsString()); - if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) - account.setModeOfOperation(json.get("modeOfOperation").getAsString()); - if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) - account.setSignatoryRights(json.get("signatoryRights").getAsString()); - if (json.has("msme") && !json.get("msme").isJsonNull()) - account.setMsme(json.get("msme").getAsBoolean()); - if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) - account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); - if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) - account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); - if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) - account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); - if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) - account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); - if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) - account.setIntroducerName(json.get("introducerName").getAsString()); - if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) - account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); - - // dates - final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() - ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; - if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { - try { - account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { - try { - account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), - DateTimeFormatter.ofPattern(dateFormat))); - } catch (Exception ignored) { - // intentionally ignored - } - } - - // facility config - JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() - ? json.getAsJsonObject("accountFacilityConfig") : null; - if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) - fcJson = json.getAsJsonObject("facilityConfig"); - if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); - - // joint holders — clear and re-add - if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { - account.getJointHolders().clear(); - assembleJointHolders(account, json.getAsJsonArray("jointHolders")); - } - - // partners — clear and re-add - if (json.has("partners") && json.get("partners").isJsonArray()) { - account.getPartners().clear(); - assemblePartners(account, json.getAsJsonArray("partners")); - } - - currentAccountRepository.saveAndFlush(account); - - // nominees — replace existing with new (done after flush to ensure accountNo is stable) - if (json.has("nominees") && !json.get("nominees").isJsonNull()) { - clientNomineeWritePlatformService.replaceNomineesForAccount( - account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); - } - - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult deleteApplication(final Long accountId) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - currentAccountRepository.delete(account); - return new CommandProcessingResultBuilder().withEntityId(accountId).build(); - } - - @Override - @Transactional - public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { - context.authenticatedUser(); - final CurrentAccount account = findAccount(accountId); - account.terminate(); - currentAccountRepository.saveAndFlush(account); - - final java.util.Map changes = new java.util.LinkedHashMap<>(); - changes.put("terminateTrue", true); - changes.put("status", 900); - - final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; - final Long clientId = account.getClient() != null ? account.getClient().getId() : null; - - return new CommandProcessingResultBuilder() - .withEntityId(accountId) - .withOfficeId(officeId) - .withClientId(clientId) - .withSavingsId(accountId) - .with(changes) - .build(); - } - - // ─── helpers ────────────────────────────────────────────────────────────── - - private CurrentAccount findAccount(Long accountId) { - return currentAccountRepository.findById(accountId) - .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); - } - - private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { - CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); - if (cfg == null) { - cfg = CurrentAccountFacilityConfig.createFor(account); - } - cfg.setAtmFacility(getBool(fcJson, "atmFacility")); - cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); - cfg.setUpiFacility(getBool(fcJson, "upiFacility")); - cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); - cfg.setChequeBook(getBool(fcJson, "chequeBook")); - cfg.setImpsFacility(getBool(fcJson, "impsFacility")); - cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); - cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); - cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); - cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); - cfg.setNeftFacility(getBool(fcJson, "neftFacility")); - cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); - cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); - cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); - cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); - cfg.setInternetBanking(getBool(fcJson, "internetBanking")); - cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); - return cfg; - } - - private void assembleJointHolders(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); - holder.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) holder.setClientId(cid); - if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); - if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); - if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); - if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getJointHolders().add(holder); - } - } - - private void assemblePartners(CurrentAccount account, JsonArray arr) { - for (JsonElement el : arr) { - final JsonObject jo = el.getAsJsonObject(); - final CurrentAccountPartner partner = new CurrentAccountPartner(); - partner.setCurrentAccount(account); - Long cid = getLong(jo, "clientId"); - if (cid != null) partner.setClientId(cid); - if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); - if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); - if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { - try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { - // intentionally ignored - } - } - if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); - account.getPartners().add(partner); - } - } - - private Long getLong(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - try { - String val = obj.get(key).getAsString().trim(); - if (val.isEmpty()) return null; - return Long.parseLong(val); - } catch (Exception e) { - return null; - } - } - - private String getString(JsonObject obj, String key) { - if (!obj.has(key) || obj.get(key).isJsonNull()) return null; - String val = obj.get(key).getAsString().trim(); - return val.isEmpty() ? null : val; - } - - private boolean getBool(JsonObject obj, String key) { - return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); - } - - private LocalDate parseDate(String dateStr, String dateFormat, String locale) { - if (dateStr == null || dateStr.isBlank()) return null; - try { - String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, - locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); - } catch (Exception e) { - try { - return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); - } catch (Exception ex) { - log.warn("Could not parse date: {}", dateStr); - return null; - } - } - } - - // Wrapper to carry a charge transaction together with its charge entity for GL posting - private record ChargeGlEntry( - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} - - /** - * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a - * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL - * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) -- - * activation itself no longer collects any charges. Returns the resulting balance. - */ - private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, - final java.util.List charges, - java.math.BigDecimal runningBalance, final LocalDate transactionDate, - final java.util.List chargeEntriesOut) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { - if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { - continue; - } - charge.setAmountPaidDerived(charge.getAmount()); - charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); - charge.setPaid(true); - runningBalance = runningBalance.subtract(charge.getAmount()); - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction - .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); - chargeTxn.setRunningBalanceDerived(runningBalance); - currentAccountTransactionRepository.saveAndFlush(chargeTxn); - chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); - log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", - charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); - } - return runningBalance; - } - - @Override - @Transactional - public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { - if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { - return; - } - final CurrentAccount account = findAccount(currentAccountId); - final java.util.List newCharges = new java.util.ArrayList<>(); - - for (final String facilityType : newlyEnabledFacilityTypes) { - final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); - // An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's - // requireMappedCharge for why. - if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) { - throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", - "A charge has not been mapped for the '" + facilityType - + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", - facilityType); - } - final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); - if (charge.isDeleted() || !charge.isActive()) { - throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", - "The charge mapped to the '" + facilityType - + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", - facilityType); - } - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = - org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); - account.getCharges().add(newCharge); - newCharges.add(newCharge); - } - - if (newCharges.isEmpty()) { - return; - } - - java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() - : java.math.BigDecimal.ZERO; - final java.util.List chargeEntries = new java.util.ArrayList<>(); - currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); - - account.setAccountBalanceDerived(currentBalance); - currentAccountRepository.saveAndFlush(account); - - postGlEntries(account, java.util.List.of(), chargeEntries); - } - - // entity type for savings/current accounts in acc_gl_journal_entry - private static final int ENTITY_TYPE_SAVINGS = 2; - - private void postGlEntries(final CurrentAccount account, - final java.util.List depositTxns, - final java.util.List chargeEntries) { - try { - final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); - if (product == null) return; - - final Integer accountingRule = product.getAccountingType(); - if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { - log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); - return; - } - - final GLAccount cashAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_REFERENCE.getValue()); - final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); - final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); - - if (cashAccount == null || controlAccount == null) { - log.warn("[GL] Missing GL account mappings for product id={} — skipping GL posting", account.getProductId()); - return; - } - - // Deposit uses CA{accountId} as transaction ref (one per activation) - final String depositRef = "CA" + account.getId(); - - // Deposits: DR SAVINGS_REFERENCE (cash in), CR SAVINGS_CONTROL (liability) - if (depositTxns != null) { - for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { - if (txn.getId() == null || txn.getAmount() == null) continue; - postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); - log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); - } - } - - // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() - // so each charge appears as a separate transaction in journal entries - if (chargeEntries != null && feeIncomeAccount != null) { - for (final ChargeGlEntry entry : chargeEntries) { - final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); - if (txn.getId() == null || txn.getAmount() == null) continue; - final String chargeRef = "CAC" + txn.getId(); - postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); - log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); - } - } - } catch (Exception e) { - log.error("[GL] Failed GL posting for current account id={}: {}", account.getId(), e.getMessage(), e); - } - } - - private GLAccount findGlAccount(final Long productId, final int financialAccountType) { - final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = - productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( - productId, PortfolioProductType.SAVING.getValue(), financialAccountType, null); - return mapping != null ? mapping.getGlAccount() : null; - } - - private void postEntry(final CurrentAccount account, final GLAccount glAccount, - final JournalEntryType entryType, final java.math.BigDecimal amount, - final LocalDate date, final String transactionRef) { - final JournalEntry entry = JournalEntry.createNew( - account.getOffice(), null, glAccount, - account.getCurrencyCode(), transactionRef, - false, date, entryType, amount, - "Current Account activation", - ENTITY_TYPE_SAVINGS, account.getId(), - null, null, null, null, null); - journalEntryRepository.save(entry); - } - - private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { - Throwable cause = e; - while (cause != null) { - final String msg = cause.getMessage(); - if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { - throw new PlatformDataIntegrityException( - "error.msg.savings.account.duplicate.externalid", - "Current Account with this External ID already exists.", - "externalId", externalId); - } - cause = cause.getCause(); - } - } - -} +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.savings.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.service.ClientNomineeWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountJointHolder; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountPartner; +import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService; +import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountNumberGenerator; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; +import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings; +import org.apache.fineract.accounting.common.AccountingRuleType; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryType; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException; +import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWritePlatformService { + + private final PlatformSecurityContext context; + private final CurrentAccountRepository currentAccountRepository; + private final CurrentProductRepository currentProductRepository; + private final ClientRepositoryWrapper clientRepository; + private final OfficeRepositoryWrapper officeRepository; + private final CurrentAccountNumberGenerator accountNumberGenerator; + private final ClientNomineeWritePlatformService clientNomineeWritePlatformService; + private final org.apache.fineract.portfolio.savings.domain.FacilityChargeMappingRepository facilityChargeMappingRepository; + private final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository currentAccountTransactionRepository; + private final JournalEntryRepository journalEntryRepository; + private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; + private final CurrentAccountCashGlResolver cashGlResolver; + + @Override + @Transactional + public CommandProcessingResult submitApplication(final JsonCommand command) { + context.authenticatedUser(); + + final JsonObject json = command.parsedJson().getAsJsonObject(); + + final Long clientId = getLong(json, "clientId"); + final Long productId = getLong(json, "productId"); + final Long officeId = getLong(json, "officeId"); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final CurrentProduct product = currentProductRepository.findById(productId) + .orElseThrow(() -> new SavingsProductNotFoundException(productId)); + + if (currentAccountRepository.countActiveByClientAndProduct(clientId, productId) > 0) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.product.for.client", + "Client already has an active Current Account with the same product.", + "productId", productId); + } + final Office office = officeId != null + ? officeRepository.findOneWithNotFoundDetection(officeId) + : client.getOffice(); + + final String dateFormat = getString(json, "dateFormat"); + final String locale = getString(json, "locale"); + + LocalDate submittedOnDate = parseDate(getString(json, "submittedOnDate"), dateFormat, locale); + if (submittedOnDate == null) submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + // accountType (Fineract enum: 1=individual, 2=group) — try int, else default 1 + Integer accountTypeEnum = 1; + // cbsAccountType — descriptive string e.g. "Standard Account" sent as "accountType" + String cbsAccountType = null; + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAccountType = json.get("accountType").getAsString(); + try { + accountTypeEnum = Integer.parseInt(rawAccountType); + } catch (Exception ignored) { + // It's a descriptive CBS account type string, not an enum int + cbsAccountType = rawAccountType; + } + } + + boolean msme = (json.has("msme") && !json.get("msme").isJsonNull()) + ? json.get("msme").getAsBoolean() : product.isMsme(); + boolean autoDebitFacility = (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + ? json.get("autoDebitFacility").getAsBoolean() : product.isAutoDebitFacility(); + boolean balanceSwapOption = (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + ? json.get("balanceSwapOption").getAsBoolean() : product.isBalanceSwapOption(); + + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = CurrentAccount.createNew( + client, productId, office, null, "TEMP", submittedOnDate, accountTypeEnum, + product.currency().getCode(), product.currency().getDigitsAfterDecimal(), + product.currency().getInMultiplesOf(), + product.nominalAnnualInterestRate(), + product.getInterestCompoundingPeriodType(), product.getInterestPostingPeriodType(), + product.getInterestCalculationType(), product.getInterestCalculationDaysInYearType(), + product.isAllowOverdraft(), product.getOverdraftLimit(), + msme, autoDebitFacility, balanceSwapOption, + product.getCustomerSegment(), product.isAutoPost(), + product.getInterestCalculateSelector(), product.getAveragePeriod(), + product.getAvgBalanceCalcPeriod()); + account.setSubmittedOnByUser(user); + + boolean shareLinkingEnabled = (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + ? json.get("shareLinkingEnabled").getAsBoolean() : product.isShareLinkingEnabled(); + account.setShareLinkingEnabled(shareLinkingEnabled); + + // Save CBS account type (descriptive name: "Standard Account", "Basic Account", etc.) + if (cbsAccountType != null) account.setCbsAccountType(cbsAccountType); + + // Copy product-level fields that are not in createNew() + account.setMinRequiredOpeningBalance(product.getMinRequiredOpeningBalance()); + account.setMinRequiredBalance(product.getMinRequiredBalance()); + account.setEnforceMinRequiredBalance(product.isEnforceMinRequiredBalance()); + account.setLockinPeriodFrequency(product.getLockinPeriodFrequency()); + account.setLockinPeriodFrequencyEnum(product.getLockinPeriodFrequencyType()); + account.setNominalAnnualInterestRateOverdraft(product.getNominalAnnualInterestRateOverdraft()); + account.setWithdrawalFeeForTransfer(product.isWithdrawalFeeApplicableForTransfer()); + account.setMinOverdraftForInterestCalculation(product.getMinOverdraftForInterestCalculation()); + account.setFrequencyFixDay(product.getFrequencyFixDay()); + account.setEnableLockinPeriod(product.isEnableLockinPeriod()); + account.setBalanceFrequency(product.getBalanceFrequency()); + account.setAccountClosedZeroBalanceInDayClosing(product.getAccountClosedZeroBalanceInDayClosing()); + account.setValidForPeriod(product.getValidForPeriod()); + account.setOdAccountClosedZeroBalanceInDayClosing(product.isOdAccountClosedZeroBalanceInDayClosing()); + account.setLienAllowed(product.isLienAllowed()); + account.setMaxAllowedLienLimit(product.getMaxAllowedLienLimit()); + account.setMinBalanceForInterestCalculation(product.getMinBalanceForInterestCalculation()); + account.setWithholdTax(product.withHoldTax()); + + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + account.setEstablishmentDate(parseDate(json.get("establishmentDate").getAsString(), dateFormat, locale)); + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + account.setStartOperationDate(parseDate(json.get("startOperationDate").getAsString(), dateFormat, locale)); + } + if (json.has("isNonIndividualClient") && !json.get("isNonIndividualClient").isJsonNull()) { + account.setNonIndividualClient(json.get("isNonIndividualClient").getAsBoolean()); + } + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) { + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + } + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) { + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + } + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) { + account.setIntroducerName(json.get("introducerName").getAsString()); + } + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) { + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + } + // UI may send "introducerClientId" or "introducerId" + Long introducerClientId = getLong(json, "introducerClientId"); + if (introducerClientId == null) introducerClientId = getLong(json, "introducerId"); + if (introducerClientId != null) account.setIntroducerClientId(introducerClientId); + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) { + final String extId = json.get("externalId").getAsString().trim(); + account.setExternalId(extId.isEmpty() ? null : extId); + } + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) { + account.setAccountStatus(json.get("accountStatus").getAsString()); + } + // Override customerSegment from request if provided + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) { + account.setCustomerSegment(json.get("customerSegment").getAsString()); + } + + // Save first to get ID + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + // Generate account number: 00 + GL_CODE(6) + OFFICE_ID(2) + ACCOUNT_ID(5) + final String accountNo = accountNumberGenerator.generate(account); + account.updateAccountNo(accountNo); + + // Save nominees if provided in payload (same transaction) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineesIfPresent(clientId, accountNo, "CB", json.get("nominees")); + } else if (json.has("nominee") && !json.get("nominee").isJsonNull()) { + clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee")); + } + + // Every digital facility starts disabled at account opening, regardless of anything the + // request sends or the product defaults to -- they're switched on deliberately, one at a + // time, later, through the account's own Edit Nominee & Features screen. + account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account)); + + // Joint holders + if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // Partners (non-individual) + if (json.has("partners") && json.get("partners").isJsonArray()) { + assemblePartners(account, json.getAsJsonArray("partners")); + } + + try { + currentAccountRepository.saveAndFlush(account); + } catch (RuntimeException e) { + throwIfDuplicateExternalId(e, account.getExternalId()); + throw e; + } + + log.info("Current Account created successfully: [id={}, accountNo={}, clientId={}, officeId={}, submittedOnDate={}]", + account.getId(), account.getAccountNo(), clientId, office.getId(), submittedOnDate); + + return new CommandProcessingResultBuilder() + .withEntityId(account.getId()) + .withClientId(clientId) + .withOfficeId(office.getId()) + .build(); + } + + @Override + @Transactional + public CommandProcessingResult approveApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate approvedOnDate = parseDate(getString(json, "approvedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (approvedOnDate == null) approvedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.approve(approvedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult undoApproval(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.setStatusEnum(100); + account.setApprovedOnDate(null); + account.setApprovedOnByUser(null); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult activate(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + + account.activate(activatedOnDate, user); + + // Activation posts no transaction of its own -- no opening-balance deposit, no + // digital-facility charges applied or collected, regardless of what the account's facility + // config or product defaults say. Funding and any facility charges happen strictly + // afterwards, through separate transactions a clerk processes once the account is already + // active. + currentAccountRepository.saveAndFlush(account); + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult rejectApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate rejectedOnDate = parseDate(getString(json, "rejectedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (rejectedOnDate == null) rejectedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.reject(rejectedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult withdrawApplication(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate withdrawnOnDate = parseDate(getString(json, "withdrawnOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (withdrawnOnDate == null) withdrawnOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.withdraw(withdrawnOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult closeAccount(final Long accountId, final JsonCommand command) { + final AppUser user = context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + LocalDate closedOnDate = parseDate(getString(json, "closedOnDate"), getString(json, "dateFormat"), getString(json, "locale")); + if (closedOnDate == null) closedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + account.close(closedOnDate, user); + currentAccountRepository.saveAndFlush(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult modifyApplication(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + final JsonObject json = command.parsedJson().getAsJsonObject(); + + if (account.getStatusEnum() != null) { + if (account.getStatusEnum() == 500) { + account.setStatusEnum(100); + account.setRejectedOnDate(null); + account.setRejectedOnByUser(null); + } else if (account.getStatusEnum() == 400) { + account.setStatusEnum(100); + account.setWithdrawnOnDate(null); + account.setWithdrawnOnByUser(null); + } + } + + + if (json.has("externalId") && !json.get("externalId").isJsonNull()) + account.setExternalId(json.get("externalId").getAsString()); + if (json.has("accountStatus") && !json.get("accountStatus").isJsonNull()) + account.setAccountStatus(json.get("accountStatus").getAsString()); + if (json.has("accountType") && !json.get("accountType").isJsonNull()) { + final String rawAt = json.get("accountType").getAsString(); + try { Integer.parseInt(rawAt); } catch (Exception ignored) { + // descriptive CBS type — save it + account.setCbsAccountType(rawAt); + } + } + if (json.has("customerSegment") && !json.get("customerSegment").isJsonNull()) + account.setCustomerSegment(json.get("customerSegment").getAsString()); + if (json.has("modeOfOperation") && !json.get("modeOfOperation").isJsonNull()) + account.setModeOfOperation(json.get("modeOfOperation").getAsString()); + if (json.has("signatoryRights") && !json.get("signatoryRights").isJsonNull()) + account.setSignatoryRights(json.get("signatoryRights").getAsString()); + if (json.has("msme") && !json.get("msme").isJsonNull()) + account.setMsme(json.get("msme").getAsBoolean()); + if (json.has("balanceSwapOption") && !json.get("balanceSwapOption").isJsonNull()) + account.setBalanceSwapOption(json.get("balanceSwapOption").getAsBoolean()); + if (json.has("autoDebitFacility") && !json.get("autoDebitFacility").isJsonNull()) + account.setAutoDebitFacility(json.get("autoDebitFacility").getAsBoolean()); + if (json.has("shareLinkingEnabled") && !json.get("shareLinkingEnabled").isJsonNull()) + account.setShareLinkingEnabled(json.get("shareLinkingEnabled").getAsBoolean()); + if (json.has("introducerClientId") && !json.get("introducerClientId").isJsonNull()) + account.setIntroducerClientId(json.get("introducerClientId").getAsLong()); + if (json.has("introducerName") && !json.get("introducerName").isJsonNull()) + account.setIntroducerName(json.get("introducerName").getAsString()); + if (json.has("introducerCifNumber") && !json.get("introducerCifNumber").isJsonNull()) + account.setIntroducerCifNumber(json.get("introducerCifNumber").getAsString()); + + // dates + final String dateFormat = json.has("dateFormat") && !json.get("dateFormat").isJsonNull() + ? json.get("dateFormat").getAsString() : "dd MMMM yyyy"; + if (json.has("establishmentDate") && !json.get("establishmentDate").isJsonNull()) { + try { + account.setEstablishmentDate(LocalDate.parse(json.get("establishmentDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + if (json.has("startOperationDate") && !json.get("startOperationDate").isJsonNull()) { + try { + account.setStartOperationDate(LocalDate.parse(json.get("startOperationDate").getAsString(), + DateTimeFormatter.ofPattern(dateFormat))); + } catch (Exception ignored) { + // intentionally ignored + } + } + + // facility config + JsonObject fcJson = json.has("accountFacilityConfig") && !json.get("accountFacilityConfig").isJsonNull() + ? json.getAsJsonObject("accountFacilityConfig") : null; + if (fcJson == null && json.has("facilityConfig") && !json.get("facilityConfig").isJsonNull()) + fcJson = json.getAsJsonObject("facilityConfig"); + if (fcJson != null) account.setFacilityConfig(assembleFacilityConfig(account, fcJson)); + + // joint holders — clear and re-add + if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) { + account.getJointHolders().clear(); + assembleJointHolders(account, json.getAsJsonArray("jointHolders")); + } + + // partners — clear and re-add + if (json.has("partners") && json.get("partners").isJsonArray()) { + account.getPartners().clear(); + assemblePartners(account, json.getAsJsonArray("partners")); + } + + currentAccountRepository.saveAndFlush(account); + + // nominees — replace existing with new (done after flush to ensure accountNo is stable) + if (json.has("nominees") && !json.get("nominees").isJsonNull()) { + clientNomineeWritePlatformService.replaceNomineesForAccount( + account.getClient().getId(), account.getAccountNo(), "CB", json.get("nominees")); + } + + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult deleteApplication(final Long accountId) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + currentAccountRepository.delete(account); + return new CommandProcessingResultBuilder().withEntityId(accountId).build(); + } + + @Override + @Transactional + public CommandProcessingResult terminateAccount(final Long accountId, final JsonCommand command) { + context.authenticatedUser(); + final CurrentAccount account = findAccount(accountId); + account.terminate(); + currentAccountRepository.saveAndFlush(account); + + final java.util.Map changes = new java.util.LinkedHashMap<>(); + changes.put("terminateTrue", true); + changes.put("status", 900); + + final Long officeId = account.getOffice() != null ? account.getOffice().getId() : null; + final Long clientId = account.getClient() != null ? account.getClient().getId() : null; + + return new CommandProcessingResultBuilder() + .withEntityId(accountId) + .withOfficeId(officeId) + .withClientId(clientId) + .withSavingsId(accountId) + .with(changes) + .build(); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private CurrentAccount findAccount(Long accountId) { + return currentAccountRepository.findById(accountId) + .orElseThrow(() -> new SavingsAccountNotFoundException(accountId)); + } + + private CurrentAccountFacilityConfig assembleFacilityConfig(CurrentAccount account, JsonObject fcJson) { + CurrentAccountFacilityConfig cfg = account.getFacilityConfig(); + if (cfg == null) { + cfg = CurrentAccountFacilityConfig.createFor(account); + } + cfg.setAtmFacility(getBool(fcJson, "atmFacility")); + cfg.setAtmWithdrawalFacility(getBool(fcJson, "atmWithdrawalFacility")); + cfg.setUpiFacility(getBool(fcJson, "upiFacility")); + cfg.setPosTransactionsFacility(getBool(fcJson, "posTransactionsFacility")); + cfg.setChequeBook(getBool(fcJson, "chequeBook")); + cfg.setImpsFacility(getBool(fcJson, "impsFacility")); + cfg.setSmsAlerts(getBool(fcJson, "smsAlerts")); + cfg.setBbpsFacility(getBool(fcJson, "bbpsFacility")); + cfg.setInsuranceCover(getBool(fcJson, "insuranceCover")); + cfg.setBepgFacility(getBool(fcJson, "bepgFacility")); + cfg.setNeftFacility(getBool(fcJson, "neftFacility")); + cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility")); + cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility")); + cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc")); + cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard")); + cfg.setInternetBanking(getBool(fcJson, "internetBanking")); + cfg.setMobileBanking(getBool(fcJson, "mobileBanking")); + return cfg; + } + + private void assembleJointHolders(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountJointHolder holder = new CurrentAccountJointHolder(); + holder.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) holder.setClientId(cid); + if (jo.has("cifNumber") && !jo.get("cifNumber").isJsonNull()) holder.setCifNumber(jo.get("cifNumber").getAsString()); + if (jo.has("displayName") && !jo.get("displayName").isJsonNull()) holder.setDisplayName(jo.get("displayName").getAsString()); + if (jo.has("signingAuthority") && !jo.get("signingAuthority").isJsonNull()) holder.setSigningAuthority(jo.get("signingAuthority").getAsString()); + if (jo.has("role") && !jo.get("role").isJsonNull()) holder.setRole(jo.get("role").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { holder.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isPrimary") && !jo.get("isPrimary").isJsonNull()) holder.setPrimary(jo.get("isPrimary").getAsBoolean()); + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) holder.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getJointHolders().add(holder); + } + } + + private void assemblePartners(CurrentAccount account, JsonArray arr) { + for (JsonElement el : arr) { + final JsonObject jo = el.getAsJsonObject(); + final CurrentAccountPartner partner = new CurrentAccountPartner(); + partner.setCurrentAccount(account); + Long cid = getLong(jo, "clientId"); + if (cid != null) partner.setClientId(cid); + if (jo.has("clientCif") && !jo.get("clientCif").isJsonNull()) partner.setClientCif(jo.get("clientCif").getAsString()); + if (jo.has("clientName") && !jo.get("clientName").isJsonNull()) partner.setClientName(jo.get("clientName").getAsString()); + if (jo.has("sharePercentage") && !jo.get("sharePercentage").isJsonNull()) { + try { partner.setSharePercentage(jo.get("sharePercentage").getAsBigDecimal()); } catch (Exception ignored) { + // intentionally ignored + } + } + if (jo.has("isMandatory") && !jo.get("isMandatory").isJsonNull()) partner.setMandatory(jo.get("isMandatory").getAsBoolean()); + account.getPartners().add(partner); + } + } + + private Long getLong(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + try { + String val = obj.get(key).getAsString().trim(); + if (val.isEmpty()) return null; + return Long.parseLong(val); + } catch (Exception e) { + return null; + } + } + + private String getString(JsonObject obj, String key) { + if (!obj.has(key) || obj.get(key).isJsonNull()) return null; + String val = obj.get(key).getAsString().trim(); + return val.isEmpty() ? null : val; + } + + private boolean getBool(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull() && obj.get(key).getAsBoolean(); + } + + private LocalDate parseDate(String dateStr, String dateFormat, String locale) { + if (dateStr == null || dateStr.isBlank()) return null; + try { + String fmt = dateFormat != null ? dateFormat : "dd MMMM yyyy"; + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(fmt, + locale != null ? new java.util.Locale(locale) : java.util.Locale.ENGLISH)); + } catch (Exception e) { + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd MMMM yyyy", java.util.Locale.ENGLISH)); + } catch (Exception ex) { + log.warn("Could not parse date: {}", dateStr); + return null; + } + } + } + + // Wrapper to carry a charge transaction together with its charge entity for GL posting + private record ChargeGlEntry( + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn, + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge) {} + + /** + * Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a + * {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL + * posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) -- + * activation itself no longer collects any charges. Returns the resulting balance. + */ + private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account, + final java.util.List charges, + java.math.BigDecimal runningBalance, final LocalDate transactionDate, + final java.util.List chargeEntriesOut) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge charge : charges) { + if (charge.getAmount() == null || charge.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0 || charge.getCharge() == null) { + continue; + } + charge.setAmountPaidDerived(charge.getAmount()); + charge.setAmountOutstandingDerived(java.math.BigDecimal.ZERO); + charge.setPaid(true); + runningBalance = runningBalance.subtract(charge.getAmount()); + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction chargeTxn = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction + .chargeDeduction(account, account.getOffice(), charge.getAmount(), transactionDate); + chargeTxn.setRunningBalanceDerived(runningBalance); + currentAccountTransactionRepository.saveAndFlush(chargeTxn); + chargeEntriesOut.add(new ChargeGlEntry(chargeTxn, charge)); + log.info("[FacilityCharge] Collected charge '{}' {} from account id={}, running balance={}", + charge.getCharge().getName(), charge.getAmount(), account.getId(), runningBalance); + } + return runningBalance; + } + + @Override + @Transactional + public void applyAndCollectFacilityCharges(final Long currentAccountId, final java.util.Set newlyEnabledFacilityTypes) { + if (newlyEnabledFacilityTypes == null || newlyEnabledFacilityTypes.isEmpty()) { + return; + } + final CurrentAccount account = findAccount(currentAccountId); + final java.util.List newCharges = new java.util.ArrayList<>(); + + for (final String facilityType : newlyEnabledFacilityTypes) { + final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType); + // An inactive mapping is treated the same as an unmapped one -- see FacilityChargeAutoApplyService's + // requireMappedCharge for why. + if (mappingOpt.isEmpty() || mappingOpt.get().getCharge() == null || !Boolean.TRUE.equals(mappingOpt.get().getIsActive())) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.not.mapped", + "A charge has not been mapped for the '" + facilityType + + "' facility. Configure it under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge(); + if (charge.isDeleted() || !charge.isActive()) { + throw new GeneralPlatformDomainRuleException("error.msg.facility.charge.deleted", + "The charge mapped to the '" + facilityType + + "' facility has been deleted or deactivated. Update the mapping under Products > Facility Charge Mappings before enabling it.", + facilityType); + } + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge newCharge = + org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge.createFromFacility(account, charge); + account.getCharges().add(newCharge); + newCharges.add(newCharge); + } + + if (newCharges.isEmpty()) { + return; + } + + java.math.BigDecimal currentBalance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() + : java.math.BigDecimal.ZERO; + final java.util.List chargeEntries = new java.util.ArrayList<>(); + currentBalance = collectChargesAndPost(account, newCharges, currentBalance, DateUtils.getBusinessLocalDate(), chargeEntries); + + account.setAccountBalanceDerived(currentBalance); + currentAccountRepository.saveAndFlush(account); + + postGlEntries(account, java.util.List.of(), chargeEntries); + } + + // entity type for savings/current accounts in acc_gl_journal_entry + private static final int ENTITY_TYPE_SAVINGS = 2; + + private void postGlEntries(final CurrentAccount account, + final java.util.List depositTxns, + final java.util.List chargeEntries) { + // Mandatory when the product keeps accounts: a GL that is missing or fails to post throws and rolls the charge + // back with it, so the account is never debited without a journal entry. (It used to log and carry on.) + { + final CurrentProduct product = currentProductRepository.findById(account.getProductId()).orElse(null); + if (product == null) return; + + final Integer accountingRule = product.getAccountingType(); + if (accountingRule == null || accountingRule.equals(AccountingRuleType.NONE.getValue())) { + log.debug("[GL] Product {} accounting=NONE — skipping", account.getProductId()); + return; + } + + // Cash comes from the branch cash GL (Financial Activity mapping); the rest from the account's own current + // product, read under its CURRENT product type. + final GLAccount cashAccount = cashGlResolver.resolve(); + final GLAccount controlAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.SAVINGS_CONTROL.getValue()); + final GLAccount feeIncomeAccount = findGlAccount(account.getProductId(), CashAccountsForSavings.INCOME_FROM_FEES.getValue()); + + final boolean hasDeposits = depositTxns != null && !depositTxns.isEmpty(); + final boolean hasCharges = chargeEntries != null && !chargeEntries.isEmpty(); + if (!hasDeposits && !hasCharges) { + return; + } + if (controlAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.control.gl.not.mapped", + "Current product '" + product.getName() + "' has no Current Control GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasCharges && feeIncomeAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.fee.income.gl.not.mapped", + "Current product '" + product.getName() + "' has no Income From Fees GL mapped. Map it on the product's Accounting tab.", + account.getId()); + } + if (hasDeposits && cashAccount == null) { + throw new GeneralPlatformDomainRuleException("error.msg.current.account.cash.gl.not.mapped", + "No cash GL mapped: set Cash at Main Vault (or Cash at Teller) under Accounting > Financial Activity Mappings.", + account.getId()); + } + + // Deposit uses CA{accountId} as transaction ref (one per activation) + final String depositRef = "CA" + account.getId(); + + // Deposits: DR branch cash GL (cash in), CR SAVINGS_CONTROL (liability) + if (depositTxns != null && cashAccount != null) { + for (final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn : depositTxns) { + if (txn.getId() == null || txn.getAmount() == null) continue; + postEntry(account, cashAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + postEntry(account, controlAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), depositRef); + log.info("[GL] Deposit GL posted for account id={}, amount={}", account.getId(), txn.getAmount()); + } + } + + // Charges: each charge gets its own transaction ref = "CAC" + txn.getId() + // so each charge appears as a separate transaction in journal entries + if (chargeEntries != null && feeIncomeAccount != null) { + for (final ChargeGlEntry entry : chargeEntries) { + final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn = entry.txn(); + if (txn.getId() == null || txn.getAmount() == null) continue; + final String chargeRef = "CAC" + txn.getId(); + postEntry(account, controlAccount, JournalEntryType.DEBIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + postEntry(account, feeIncomeAccount, JournalEntryType.CREDIT, txn.getAmount(), txn.getTransactionDate(), chargeRef); + log.info("[GL] Charge GL posted for account id={}, charge={}, amount={}, ref={}", account.getId(), entry.charge().getCharge().getName(), txn.getAmount(), chargeRef); + } + } + } + } + + private GLAccount findGlAccount(final Long productId, final int financialAccountType) { + final org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping mapping = + productToGLAccountMappingRepository.findByProductIdAndProductTypeAndFinancialAccountTypeAndPaymentTypeId( + productId, PortfolioProductType.CURRENT.getValue(), financialAccountType, null); + return mapping != null ? mapping.getGlAccount() : null; + } + + private void postEntry(final CurrentAccount account, final GLAccount glAccount, + final JournalEntryType entryType, final java.math.BigDecimal amount, + final LocalDate date, final String transactionRef) { + final JournalEntry entry = JournalEntry.createNew( + account.getOffice(), null, glAccount, + account.getCurrencyCode(), transactionRef, + false, date, entryType, amount, + "Current Account activation", + ENTITY_TYPE_SAVINGS, account.getId(), + null, null, null, null, null); + journalEntryRepository.save(entry); + } + + private void throwIfDuplicateExternalId(final RuntimeException e, final String externalId) { + Throwable cause = e; + while (cause != null) { + final String msg = cause.getMessage(); + if (msg != null && (msg.contains("ca_external_id_UNIQUE") || msg.contains("ca_unq_external_id"))) { + throw new PlatformDataIntegrityException( + "error.msg.savings.account.duplicate.externalid", + "Current Account with this External ID already exists.", + "externalId", externalId); + } + cause = cause.getCause(); + } + } + +} 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 eacefbdec..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 @@ -19,8 +19,11 @@ package org.apache.fineract.portfolio.savings.service; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName; import com.google.gson.JsonElement; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Collection; import lombok.RequiredArgsConstructor; @@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.data.DepositAccountData; import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator; import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData; @@ -66,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); @@ -77,13 +89,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De // calculate interest before one day of closure date final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1); final boolean isPreMatureClosure = true; + // Preview-time only -- the real closure re-reads this same box from its own request when it + // actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a + // maker previewing with the box checked and then submitting without it (or vice versa) gets + // exactly what they last confirmed, not what an earlier preview computed. + final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element) + && this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element); + // RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased + // settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox + // above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so + // the preview a maker sees always matches what the actual closure will compute. + final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName, + element); + final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId); + final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived(); if (depositAccountType.isFixedDeposit()) { final FixedDepositAccount fd = (FixedDepositAccount) account; - accountData = FixedDepositAccountData.preClosureDetails( - account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth), - onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas); + fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty); + final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + // calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's + // own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview -- + // the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here + // rather than re-deriving it, to show the maker exactly what applied. + accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions, + paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate()); } else if (depositAccountType.isRecurringDeposit()) { final RecurringDepositAccount rd = (RecurringDepositAccount) account; accountData = RecurringDepositAccountData.preClosureDetails( 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/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 788177150..42e258e8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -28,6 +28,7 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; @@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo private final WorkingDaysRepositoryWrapper workingDaysRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -1003,9 +1008,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); - // Reinvest Principal Only opens a second account and the domain method withdrew the principal out - // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually - // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + // Reinvest Principal Only now renews in place on this same account (see + // DepositAccountDomainServiceJpa#handleFDAccountPayment) -- reinvestedDepositId is never actually + // set any more, but this guard is left as a harmless no-op rather than removed, in case a future + // closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler @@ -1531,11 +1537,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); // handle maturity instructions + // An account opened WITH an explicit maturity instruction (Transfer to Savings or either + // Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the + // renewal grace period never delays this, no matter what the product has configured. Grace + // only matters for an account that was opened with NO instruction at all: that one is never + // touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a + // null closure type), so it just sits matured until a human renews it from the FD Dashboard + // -- see renewMaturedFixedDeposit(), which is where the grace window is actually read. if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); Map changes = new HashMap<>(); final AppUser user = context.authenticatedUser(); Long toSavingsId = fdAccount.getTransferToSavingsAccountId(); + // Reinvest Principal Only needs a target for the leftover interest (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening + // form only collects transferToSavingsId when Transfer to Savings itself was chosen, so + // an account opened straight into Reinvest Principal Only can reach here with none set. + // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. + if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { + toSavingsId = resolveLinkedAccountId(fdAccount.getId()); + } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1546,16 +1567,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo // would treat it as new and post it a second time without this. markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); + // Both reinvest types now renew fdAccount itself in place -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, + // in case a future closure type reintroduces a genuine close-and-reopen. if (changes.get("reinvestedDepositId") != null) { Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); - // activateWithBalance() always returns zero here -- it exists to stop a manually - // opened FD from auto-crediting itself at activation (funding is supposed to be a - // separate, clerk-posted deposit). A reinvestment isn't that: the balance was just - // withdrawn from the matured account and needs to land in the new one as part of - // this same automated closure, so it has to come from what reInvest() actually - // carried over onto the new account, not from that unrelated activation guard. Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); Money activationChargeAmount = getActivationCharge(reinvestAccount); if (activationChargeAmount.isGreaterThanZero()) { @@ -1578,6 +1596,269 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); } + @Transactional + @Override + public Map renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) { + final AppUser user = context.authenticatedUser(); + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet -- nothing to renew."); + } + // The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE + // FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because + // what happens at maturity is now decided here, by staff, at the moment of renewal. + final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType); + if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required", + "Choose Reinvest Principal + Interest or Reinvest Principal Only."); + } + // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the + // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this + // should always be present, but fail clearly rather than silently stranding the interest if not. + final Long linkedSavingsAccountId = resolveLinkedAccountId(fdAccount.getId()); + if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " + + "so the leftover interest has somewhere to go."); + } + + // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition + // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day + // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays); + final boolean withinGrace = !today.isAfter(graceDeadline); + + final Map result = new HashMap<>(); + BigDecimal gapInterestAmount = null; + + if (!withinGrace) { + // Late: the backdated renewal is no longer offered. The gap between the original maturity + // date and today is compensated at the linked Savings account's own product rate (simple + // interest for that stretch) before the FD renews from today, so the new term's principal + // already includes it. + gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today); + } + + final LocalDate renewalDate = withinGrace ? maturityDate : today; + final Map changes = new HashMap<>(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType, + chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null, + withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate", + changes); + markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + // Both reinvest types now renew this same account in place (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- reinvestedDepositId is never + // actually set any more, but this guard is left as a harmless no-op rather than removed, in case + // a future closure type reintroduces a genuine close-and-reopen. + FixedDepositAccount renewedAccount = fdAccount; + if (changes.get("reinvestedDepositId") != null) { + renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), + DepositAccountType.FIXED_DEPOSIT); + } + result.put("renewedAccountId", renewedAccount.getId()); + result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null); + result.put("maturityAmount", renewedAccount.maturityAmount()); + result.put("backdated", withinGrace); + if (gapInterestAmount != null) { + result.put("gapInterestAmount", gapInterestAmount); + } + return result; + } + + @Transactional + @Override + public Map creditGapInterestIfPastGrace(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet."); + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(resolveRenewalGracePeriodDays(fdAccount))); + + final Map result = new HashMap<>(); + result.put("withinGrace", withinGrace); + result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today)); + return result; + } + + /** + * Read-only preview of what a Renew from the FD Dashboard is about to do, shown in the Payment + * dialog before the staff member confirms anything -- the actual current balance, the linked + * account's rate, the gap-day count, and the computed gap-interest amount. The gap interest is paid + * onto the LINKED savings account, not folded into the FD (see {@link #creditGapInterest}), so + * projectedBalance is just the FD's own current balance, unchanged by it -- what actually renews. + * Mirrors {@link #creditGapInterest} exactly but never posts anything; a within-grace account gets + * zero for the gap fields since nothing is owed. + */ + @Transactional(readOnly = true) + @Override + public Map previewFdRenewal(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + final Map result = new HashMap<>(); + if (!fdAccount.isMatured()) { + result.put("isMatured", false); + return result; + } + result.put("isMatured", true); + + final int renewalGracePeriodDays = resolveRenewalGracePeriodDays(fdAccount); + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + final BigDecimal currentBalance = fdAccount.getAccountBalance(); + + result.put("maturityDate", maturityDate.toString()); + result.put("renewalGracePeriodDays", renewalGracePeriodDays); + result.put("withinGrace", withinGrace); + result.put("currentBalance", currentBalance); + + if (withinGrace) { + result.put("gapDays", 0L); + result.put("gapInterestAmount", BigDecimal.ZERO); + result.put("projectedBalance", currentBalance); + return result; + } + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + result.put("gapDays", gapDays); + + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + result.put("linkedAccountId", linkedAccountId); + if (linkedAccountId == null) { + // No target to read a rate from -- the caller (the Payment dialog) shows this as a blocking + // "link a Savings account first" message rather than a number, same as the posting path does. + result.put("gapInterestAmount", null); + result.put("projectedBalance", null); + return result; + } + result.put("linkedAccountNo", resolveLinkedAccountNo(linkedAccountId)); + + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); + result.put("linkedAccountRate", linkedAccountRate); + result.put("gapInterestAmount", gapInterestAmount); + // Paid onto the linked savings account, not this FD -- what renews is still just the FD's own + // current balance. + result.put("projectedBalance", currentBalance); + return result; + } + + /** Null/negative reads as "no grace configured" -- a 0-day window, not a block on renewing. */ + private int resolveRenewalGracePeriodDays(final FixedDepositAccount fdAccount) { + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + return (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) ? 0 : renewalGracePeriodDays; + } + + /** + * The FD's own Deposit Account Link, read fresh via SQL rather than the JPA entity's + * {@code getLinkedAccountId()} -- an account whose link was set at creation through the real + * account-opening command reads back correctly there, but this call site kept returning null for + * an account confirmed to have {@code linked_account_id} set in the database, which every one of + * this method's own gap-interest/reinvest-target callers depends on. Reading it directly sidesteps + * whatever staleness that entity accessor has, rather than trying to chase it down across every + * caller that touches this account within the same request. + */ + private Long resolveLinkedAccountId(final Long savingsAccountId) { + return this.jdbcTemplate.queryForObject("SELECT linked_account_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + } + + private BigDecimal resolveLinkedAccountRate(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject(""" + SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, BigDecimal.class, linkedAccountId); + } + + /** Read fresh off the target account itself, same reasoning as {@link #resolveLinkedAccountId} -- + * shown in the Renewal Preview so staff can see exactly which account the gap interest is about + * to land on, not just that "a linked account" exists. */ + private String resolveLinkedAccountNo(final Long linkedAccountId) { + return this.jdbcTemplate.queryForObject("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, linkedAccountId); + } + + /** Simple interest on the FD's own current balance, for the stretch between its maturity date and + * today, at the linked account's product rate -- the pure calculation, shared by the posting path + * ({@link #creditGapInterest}) and the read-only preview ({@link #previewFdRenewal}) so the number + * shown to staff before they confirm is exactly the number that gets credited. */ + private BigDecimal computeGapInterestAmount(final FixedDepositAccount fdAccount, final BigDecimal linkedAccountRate, + final long gapDays) { + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + return fdAccount.getAccountBalance().multiply(linkedAccountRate, mc).multiply(BigDecimal.valueOf(gapDays), mc) + .divide(BigDecimal.valueOf((long) daysInYear * 100), mc).setScale(2, java.math.RoundingMode.HALF_UP); + } + + /** + * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its + * linked Savings account's product -- credited onto the LINKED SAVINGS ACCOUNT, as a genuine + * INTEREST_POSTING transaction there (not a DEPOSIT, which would wrongly count as cash received in + * the cashier/vault reconciliation), rather than onto the FD itself. This interest was earned at + * the savings account's rate, not the FD's, so it belongs on that account's own ledger -- folding + * it into the FD's balance instead would mean "Re-Invest Maturity Amount" silently carries savings + * interest into the new FD term as if it were FD interest, and "Re-Invest Principal Only" would pay + * it out through the FD's own closure math instead of it already being where it belongs. The FD's + * own balance is left untouched by this call, so whatever the caller does next (a manual renewal, or + * the FD Dashboard's generic Payment command) reads the FD's real maturity balance, with the gap + * interest already sitting separately in the linked account. Shared by {@link #renewMaturedFixedDeposit} + * and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's Renew action now calls the latter as + * a pre-step before submitting the same generic `payment` command the account's own Account Actions + * menu already uses, so every closure type (Transfer to Savings, both Reinvest options, Partial + * Reinvest) is available from the Dashboard too, not just the two this method's sibling endpoint + * originally supported. + */ + private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { + final Long linkedAccountId = resolveLinkedAccountId(fdAccount.getId()); + if (linkedAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings account to it " + + "before renewing late, so the gap-period interest has somewhere to be paid."); + } + final SavingsAccount linkedAccount = this.depositAccountAssembler.assembleFrom(linkedAccountId, DepositAccountType.SAVINGS_DEPOSIT); + final BigDecimal linkedAccountRate = resolveLinkedAccountRate(linkedAccountId); + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + final BigDecimal gapInterestAmount = computeGapInterestAmount(fdAccount, linkedAccountRate, gapDays); + + if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(linkedAccount, existingTransactionIds, existingReversedTransactionIds); + + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(linkedAccount, + linkedAccount.office(), today, Money.of(linkedAccount.getCurrency(), gapInterestAmount), true); + linkedAccount.addTransaction(gapInterestTxn); + linkedAccount.updateSavingsAccountSummary(linkedAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(linkedAccount); + postJournalEntries(linkedAccount, existingTransactionIds, existingReversedTransactionIds); + } + return gapInterestAmount; + } + private void updateExistingTransactionsDetails(SavingsAccount account, Set existingTransactionIds, Set existingReversedTransactionIds) { existingTransactionIds.addAll(account.findExistingTransactionIds()); 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 d431acb5b..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; } } @@ -309,6 +342,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, "); sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, "); sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, "); + sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, "); sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, "); sqlBuilder.append("sp.customer_segment as customerSegment, "); sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, "); @@ -385,6 +419,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead // CBS Custom Fields result.setAccountType(rs.getString("accountType")); result.setFdType(rs.getString("fdType")); + result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays")); result.setProductCode(rs.getString("productCode")); result.setCustomerSegment(rs.getString("customerSegment")); result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java index 7a5c235c8..b04776d50 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -60,13 +60,22 @@ public final class DormancyClock { * "last live" date — a date that has nothing to do with this account, which has never been live * at all. */ - public static final String LAST_LIVE_ON = """ - CASE WHEN sa.activatedon_date IS NULL THEN NULL ELSE - GREATEST( - sa.activatedon_date, - COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k - WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL), - sa.activatedon_date)) - END - """; + public static final String LAST_LIVE_ON = lastLiveOn("sa"); + + /** + * The same rule over any account table aliased {@code alias} that has {@code activatedon_date} and + * {@code client_id} — a current account ({@code m_current_account}) is measured exactly as a savings + * account is. + */ + public static String lastLiveOn(final String alias) { + return """ + CASE WHEN %1$s.activatedon_date IS NULL THEN NULL ELSE + GREATEST( + %1$s.activatedon_date, + COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k + WHERE k.client_id = %1$s.client_id AND k.verified_on IS NOT NULL), + %1$s.activatedon_date)) + END + """.formatted(alias); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java index 895dd1cea..31ad20c6b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -63,6 +64,26 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead public List retrieveDormantAccounts(final Integer subStatus, final String officeName) { this.context.authenticatedUser(); + // Savings and current accounts climb the same ladder (current ones on their own current product's days), so + // the report lists both, longest idle first across the two. + final List accounts = new ArrayList<>(retrieve(subStatus, officeName, false)); + accounts.addAll(retrieve(subStatus, officeName, true)); + accounts.sort(Comparator.comparing(DormantAccountData::getDaysIdle, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(DormantAccountData::getBalance, Comparator.nullsLast(Comparator.reverseOrder()))); + return accounts; + } + + /** + * One kind of account. m_current_account and m_current_product carry the same column names as their savings + * counterparts, so the one query serves both with the tables swapped under the same aliases. DEAF remittances are + * recorded against savings accounts only, so a current account's DEAF columns come back null. + */ + private List retrieve(final Integer subStatus, final String officeName, final boolean current) { + final String accountTable = current ? "m_current_account" : "m_savings_account"; + final String productTable = current ? "m_current_product" : "m_savings_product"; + final String deafJoin = current ? "left join m_deaf_remittance dr on 1 = 0 " + : "left join m_deaf_remittance dr on dr.savings_account_id = sa.id "; + final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON; final StringBuilder sql = new StringBuilder(); @@ -76,13 +97,13 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead sql.append("dr.refunded_on as deafRefundedOn, "); sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, "); sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle "); - sql.append("from m_savings_account sa "); + sql.append("from ").append(accountTable).append(" sa "); sql.append("inner join m_client c on c.id = sa.client_id "); sql.append("inner join m_office o on o.id = c.office_id "); - sql.append("inner join m_savings_product sp on sp.id = sa.product_id "); + sql.append("inner join ").append(productTable).append(" sp on sp.id = sa.product_id "); // Left join: an escheated account has a remittance row only once the money has gone to RBI, // and the absence of one is exactly what the report has to show. - sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id "); + sql.append(deafJoin); // An account belongs on these reports once the ladder has stamped it, or once the days // have earned it — whichever comes first. // @@ -143,11 +164,17 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead // Longest idle first: those are the accounts nearest their next mark. sql.append("order by daysIdle desc, sa.account_balance_derived desc"); - return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray()); + return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(current ? "CURRENT" : "SAVINGS"), params.toArray()); } private static final class DormantAccountMapper implements RowMapper { + private final String accountType; + + DormantAccountMapper(final String accountType) { + this.accountType = accountType; + } + @Override public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException { final BigDecimal balance = rs.getBigDecimal("balance"); @@ -164,7 +191,8 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead final int stamped = rs.getInt("subStatus"); final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle); - return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo")) + return DormantAccountData.builder().accountType(accountType).accountId(rs.getLong("accountId")) + .accountNo(rs.getString("accountNo")) .clientId(rs.getLong("clientId")).clientName(rs.getString("clientName")) .officeName(rs.getString("officeName")).productName(rs.getString("productName")) // A null balance reads as zero rather than as missing data — an escheated account 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 301e3bbcf..053998bf9 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 @@ -402,8 +402,13 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements msg = "Savings product with short name `" + shortName + "` already exists"; param = "shortName"; msgArgs = new Object[] { shortName, dae }; + } else if (checkEx.getMessage().contains("numeric field overflow")) { + msgCode += ".amount.too.large"; + msg = "One of the amount fields entered is too large (Minimum/Maximum Deposit Amount or Pre-Closure Penal Interest). Please enter a realistic value and try again."; + msgArgs = new Object[] { dae }; } else { msgCode += ".unknown.data.integrity.issue"; + msg = "Unknown data integrity issue with savings product. Details: " + checkEx.getMessage(); msgArgs = new Object[] { dae }; } log.error("Error occured.", dae); 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()), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 008486db0..b6c0ce636 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead public List retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize, final Integer status, final Long maxSavingsId) { LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1); + // A product on the daily-interest timetable is paid by the pooled posting instead + // (SavingsPooledInterestPostingService). Posting it here too would pay its members twice. + // FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging + // subquery so a page is never cut short by accounts that are then dropped. String sql = "select " + this.savingAccountMapperForInterestPosting.schema() - + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id "; + + "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? " + + "and not exists (select 1 from m_savings_interest_posting_schedule s " + + "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id "; if (backdatedTxnsAllowedTill) { sql = sql + "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) "; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 1eef688a9..976d2e6ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -297,7 +297,7 @@ public class SavingsConfiguration { ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, WorkingDaysRepositoryWrapper workingDaysRepository, DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - DenominationBreakdownWriter denominationBreakdownWriter + DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate ) { return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, @@ -307,7 +307,7 @@ public class SavingsConfiguration { chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository, - denominationBreakdownWriter); + denominationBreakdownWriter, jdbcTemplate); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java index a78c88407..b5325ad94 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorBankApiResource.java @@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.time.LocalDateTime; import java.util.Collection; -import java.util.List; import java.util.Map; import java.util.function.Supplier; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; -import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; -import org.apache.fineract.infrastructure.core.service.DateUtils; @Path("/v1/sponsorbanks") @Component @@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; public class SponsorBankApiResource { private final SponsorBankRepository sponsorBankRepository; - private final PlatformSecurityContext context; private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final Gson gson = new GsonBuilder().serializeNulls().create(); /** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */ @@ -125,78 +121,19 @@ public class SponsorBankApiResource { return names; } - /** - * Rejects a payable account that cannot serve as one. - * - * A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be - * a liability account, and it has to accept postings. Catching that here means an administrator - * is told while choosing the account, rather than a clerk discovering it when a draft fails. - */ - private void validateDdPayableGlAccount(final Long glAccountId) { - if (glAccountId == null) { - return; - } - final List> rows = this.jdbcTemplate.queryForList( - "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", - glAccountId); - if (rows.isEmpty()) { - throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); - } - final Map gl = rows.get(0); - final String label = gl.get("gl_code") + " · " + gl.get("name"); - // 2 is LIABILITY in Fineract's GLAccountType enum. - if (((Number) gl.get("classification_enum")).intValue() != 2) { - throw new IllegalArgumentException( - "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " - + label + " is not a liability account."); - } - // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. - if (((Number) gl.get("account_usage")).intValue() == 2) { - throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " - + label + "."); - } - // Read through both shapes: the column comes back as a Boolean on some drivers and as a - // 0/1 Number on others, and a plain Boolean cast would throw on the latter. - final Object disabled = gl.get("disabled"); - final boolean isDisabled = disabled instanceof Boolean b ? b - : disabled instanceof Number n && n.intValue() != 0; - if (isDisabled) { - throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); - } - } - @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.") public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + // The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via + // a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see + // CreateSponsorBankCommandHandler. safely() is kept around the call so a validation failure + // still comes back as the same 400 it always has, rather than an unmapped 500. return safely(() -> { - if (request.getIfscCode() != null && !request.getIfscCode().isBlank() - && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { - throw new IllegalArgumentException( - "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); - } - if (request.getName() == null || request.getName().isBlank()) { - throw new IllegalArgumentException("Sponsor Bank Name is required."); - } - validateDdPayableGlAccount(request.getDdPayableGlAccountId()); - final AppUser currentUser = this.context.authenticatedUser(); - // Set field by field rather than through the all-args constructor: that constructor is - // positional, so adding a column silently shifts every argument after it, and the shift - // type-checks whenever two neighbours share a type. - final SponsorBank sponsorBank = new SponsorBank(); - sponsorBank.setName(request.getName().trim()); - sponsorBank.setDescription(request.getDescription()); - sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() - ? request.getIfscCode().trim().toUpperCase() : null); - sponsorBank.setBankCode(request.getBankCode()); - sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() - ? request.getSwiftCode().trim().toUpperCase() : null); - sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); - sponsorBank.setCreatedBy(currentUser); - sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - this.sponsorBankRepository.saveAndFlush(sponsorBank); - return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank() + .withJson(this.gson.toJson(request)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); }); } @@ -214,27 +151,14 @@ public class SponsorBankApiResource { @Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.") public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId, final SponsorBankRequest request) { + // The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via + // a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see + // UpdateSponsorBankCommandHandler. safely() is kept around the call so a validation failure + // still comes back as the same 400 it always has, rather than an unmapped 500. return safely(() -> { - final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) - .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); - if (request.getIfscCode() != null && !request.getIfscCode().isBlank() - && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { - throw new IllegalArgumentException( - "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); - } - validateDdPayableGlAccount(request.getDdPayableGlAccountId()); - if (request.getName() != null && !request.getName().isBlank()) { - sponsorBank.setName(request.getName().trim()); - } - sponsorBank.setDescription(request.getDescription()); - sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() - ? request.getIfscCode().trim().toUpperCase() : null); - sponsorBank.setBankCode(request.getBankCode()); - sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() - ? request.getSwiftCode().trim().toUpperCase() : null); - sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); - this.sponsorBankRepository.saveAndFlush(sponsorBank); - return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId) + .withJson(this.gson.toJson(request)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); }); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java index dfa5cba14..664ba319a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/api/SponsorDDApiResource.java @@ -41,6 +41,9 @@ import java.util.Collection; import java.util.List; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData; @@ -76,6 +79,7 @@ public class SponsorDDApiResource { private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService; private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService; private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PlatformSecurityContext context; private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -539,16 +543,28 @@ public class SponsorDDApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.") public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) { - return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping( - request.getSponsorBankId(), - request.getDdPayableGlAccountId(), - request.getChequeClearingGlAccountId(), - request.getCashGlAccountId(), - request.getChargesIncomeGlAccountId(), - request.getCashSuspenseGlAccountId(), - request.getGstPercentage(), - request.getGstPayableGlAccountId(), - request.getDdLimitAmount())); + // Routed through the generic command-source pipeline (CommandWrapperBuilder -> + // logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains + // Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping) + // is unchanged and now called from that handler instead of directly from here. The JSON is + // built by hand (not Gson-serializing `request` directly) purely to include a "locale" key - + // JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field. + return safely(() -> { + final JsonObject json = new JsonObject(); + json.addProperty("sponsorBankId", request.getSponsorBankId()); + json.addProperty("ddPayableGlAccountId", request.getDdPayableGlAccountId()); + json.addProperty("chequeClearingGlAccountId", request.getChequeClearingGlAccountId()); + json.addProperty("cashGlAccountId", request.getCashGlAccountId()); + json.addProperty("chargesIncomeGlAccountId", request.getChargesIncomeGlAccountId()); + json.addProperty("cashSuspenseGlAccountId", request.getCashSuspenseGlAccountId()); + json.addProperty("gstPercentage", request.getGstPercentage()); + json.addProperty("gstPayableGlAccountId", request.getGstPayableGlAccountId()); + json.addProperty("ddLimitAmount", request.getDdLimitAmount()); + json.addProperty("locale", "en"); + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorDdGlMapping() + .withJson(this.gson.toJson(json)).build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + }); } @POST diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java new file mode 100644 index 000000000..0cc96b316 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/CreateSponsorBankCommandHandler.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SPONSORBANK", action = "CREATE") +public class CreateSponsorBankCommandHandler implements NewCommandSourceHandler { + + private final SponsorBankWritePlatformService sponsorBankWritePlatformService; + + @Autowired + public CreateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) { + this.sponsorBankWritePlatformService = sponsorBankWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SponsorBankRequest request = new SponsorBankRequest(); + request.setName(command.stringValueOfParameterNamed("name")); + request.setDescription(command.stringValueOfParameterNamed("description")); + request.setIfscCode(command.stringValueOfParameterNamed("ifscCode")); + request.setBankCode(command.stringValueOfParameterNamed("bankCode")); + request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode")); + request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId")); + + final CommandProcessingResult result = this.sponsorBankWritePlatformService.createSponsorBank(request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java new file mode 100644 index 000000000..0b5ecc00d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorBankCommandHandler.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SPONSORBANK", action = "UPDATE") +public class UpdateSponsorBankCommandHandler implements NewCommandSourceHandler { + + private final SponsorBankWritePlatformService sponsorBankWritePlatformService; + + @Autowired + public UpdateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) { + this.sponsorBankWritePlatformService = sponsorBankWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SponsorBankRequest request = new SponsorBankRequest(); + request.setName(command.stringValueOfParameterNamed("name")); + request.setDescription(command.stringValueOfParameterNamed("description")); + request.setIfscCode(command.stringValueOfParameterNamed("ifscCode")); + request.setBankCode(command.stringValueOfParameterNamed("bankCode")); + request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode")); + request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId")); + + final CommandProcessingResult result = this.sponsorBankWritePlatformService.updateSponsorBank(command.entityId(), + request); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java new file mode 100644 index 000000000..6960faeae --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/handler/UpdateSponsorDdGlMappingCommandHandler.java @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.handler; + +import java.math.BigDecimal; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * The underlying write ({@link SponsorDDIssueRequestWritePlatformService#upsertGlMapping}) is a + * genuine upsert - one call creates the single {@code m_sponsor_dd_gl_mapping} row the first time and + * updates it every time after - so there is exactly one command/permission here rather than a + * separate CREATE and UPDATE, matching that reality instead of inventing a split the endpoint has + * never had. + */ +@Service +@CommandType(entity = "SPONSORDDGLMAPPING", action = "UPDATE") +public class UpdateSponsorDdGlMappingCommandHandler implements NewCommandSourceHandler { + + private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService; + + @Autowired + public UpdateSponsorDdGlMappingCommandHandler( + final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService) { + this.issueRequestWritePlatformService = issueRequestWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final Long sponsorBankId = command.longValueOfParameterNamed("sponsorBankId"); + final Long ddPayableGlAccountId = command.longValueOfParameterNamed("ddPayableGlAccountId"); + final Long chequeClearingGlAccountId = command.longValueOfParameterNamed("chequeClearingGlAccountId"); + final Long cashGlAccountId = command.longValueOfParameterNamed("cashGlAccountId"); + final Long chargesIncomeGlAccountId = command.longValueOfParameterNamed("chargesIncomeGlAccountId"); + final Long cashSuspenseGlAccountId = command.longValueOfParameterNamed("cashSuspenseGlAccountId"); + final BigDecimal gstPercentage = command.bigDecimalValueOfParameterNamed("gstPercentage"); + final Long gstPayableGlAccountId = command.longValueOfParameterNamed("gstPayableGlAccountId"); + final BigDecimal ddLimitAmount = command.bigDecimalValueOfParameterNamed("ddLimitAmount"); + + final CommandProcessingResult result = this.issueRequestWritePlatformService.upsertGlMapping(sponsorBankId, + ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId, + cashSuspenseGlAccountId, gstPercentage, gstPayableGlAccountId, ddLimitAmount); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(result.getResourceId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java new file mode 100644 index 000000000..9078614c8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformService.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; + +public interface SponsorBankWritePlatformService { + + CommandProcessingResult createSponsorBank(SponsorBankRequest request); + + CommandProcessingResult updateSponsorBank(Long sponsorBankId, SponsorBankRequest request); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java new file mode 100644 index 000000000..04f3fd57e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorBankWritePlatformServiceImpl.java @@ -0,0 +1,147 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sponsordd.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank; +import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Moved verbatim out of {@code SponsorBankApiResource.createSponsorBank()} and + * {@code updateSponsorBank()} so both writes can be invoked from a {@code NewCommandSourceHandler} + * for Maker-Checker support. {@link #validateDdPayableGlAccount(Long)} is a copy of the resource's + * own (now-unused) validator, kept here since both CREATE and UPDATE rely on it. + */ +@Service +public class SponsorBankWritePlatformServiceImpl implements SponsorBankWritePlatformService { + + private final SponsorBankRepository sponsorBankRepository; + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + + @Autowired + public SponsorBankWritePlatformServiceImpl(final SponsorBankRepository sponsorBankRepository, + final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate) { + this.sponsorBankRepository = sponsorBankRepository; + this.context = context; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + @Override + public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) { + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("Sponsor Bank Name is required."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + final AppUser currentUser = this.context.authenticatedUser(); + // Set field by field rather than through the all-args constructor: that constructor is + // positional, so adding a column silently shifts every argument after it, and the shift + // type-checks whenever two neighbours share a type. + final SponsorBank sponsorBank = new SponsorBank(); + sponsorBank.setName(request.getName().trim()); + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + sponsorBank.setCreatedBy(currentUser); + sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + } + + @Transactional + @Override + public CommandProcessingResult updateSponsorBank(final Long sponsorBankId, final SponsorBankRequest request) { + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) + .orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found.")); + if (request.getIfscCode() != null && !request.getIfscCode().isBlank() + && !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) { + throw new IllegalArgumentException( + "IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234)."); + } + validateDdPayableGlAccount(request.getDdPayableGlAccountId()); + if (request.getName() != null && !request.getName().isBlank()) { + sponsorBank.setName(request.getName().trim()); + } + sponsorBank.setDescription(request.getDescription()); + sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank() + ? request.getIfscCode().trim().toUpperCase() : null); + sponsorBank.setBankCode(request.getBankCode()); + sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank() + ? request.getSwiftCode().trim().toUpperCase() : null); + sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId()); + this.sponsorBankRepository.saveAndFlush(sponsorBank); + return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build(); + } + + /** Copied from {@code SponsorBankApiResource} - see that class for the full rationale. */ + private void validateDdPayableGlAccount(final Long glAccountId) { + if (glAccountId == null) { + return; + } + final List> rows = this.jdbcTemplate.queryForList( + "SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?", + glAccountId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("The selected DD Payable GL account does not exist."); + } + final Map gl = rows.get(0); + final String label = gl.get("gl_code") + " · " + gl.get("name"); + // 2 is LIABILITY in Fineract's GLAccountType enum. + if (((Number) gl.get("classification_enum")).intValue() != 2) { + throw new IllegalArgumentException( + "DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. " + + label + " is not a liability account."); + } + // 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header. + if (((Number) gl.get("account_usage")).intValue() == 2) { + throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under " + + label + "."); + } + // Read through both shapes: the column comes back as a Boolean on some drivers and as a + // 0/1 Number on others, and a plain Boolean cast would throw on the latter. + final Object disabled = gl.get("disabled"); + final boolean isDisabled = disabled instanceof Boolean b ? b + : disabled instanceof Number n && n.intValue() != 0; + if (isDisabled) { + throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index cda677df1..54471b2db 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -37,6 +37,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult; import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine; @@ -95,6 +96,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI private final VoucherService voucherService; private final FineractRequestContextHolder fineractRequestContextHolder; private final SponsorDDWritePlatformService sponsorDDWritePlatformService; + private final org.apache.fineract.portfolio.account.service.AccountBookDebitService accountBookDebitService; // ── Maker step ───────────────────────────────────────────────────────── @@ -601,6 +603,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final Long savingsAccountId = request.get("savings_account_id") != null ? ((Number) request.get("savings_account_id")).longValue() : null; final Long currentAccountId = request.get("current_account_id") != null ? ((Number) request.get("current_account_id")).longValue() : null; @@ -612,18 +615,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final BigDecimal effectiveCharges = chargesAmount != null ? chargesAmount : BigDecimal.ZERO; final BigDecimal effectiveGst = gstAmount != null ? gstAmount : BigDecimal.ZERO; final BigDecimal totalWithdrawal = faceValue.add(effectiveCharges).add(effectiveGst); + // A book debit, not a cash withdrawal: it posts no journal entry of its own, so the one + // DD entry below (Dr control / Cr DD Payable + charges + GST) is the only posting. A cash + // withdrawal posted Dr control / Cr Cash-at-Vault for cash that never moved, which then + // had to be hidden by flagging it reversed. if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, defaultPaymentTypeId, note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException("Could not resolve the control GL account for this account's product. Check the product's accounting (GL) mapping."); @@ -659,7 +665,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD issuance - DD No " + ddNumber; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, faceValue, chargesAmount, - chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, gstAmount, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); final CommandProcessingResult issueResult = this.sponsorDDWritePlatformService.issueDemandDraft( leafletId, clientId, walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, @@ -909,20 +916,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI Long resultingCurrentTransactionId = null; final Long drGlAccountId; Long linkedAccountId = null; + boolean linkedAccountIsCurrent = false; if (METHOD_ACCOUNT_DEBIT.equals(paymentMethod)) { final String note = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; + // Book debit, no journal entry of its own -- see approveIssueRequest(). if (savingsAccountId != null) { - final String txnId = executeSavingsWithdrawal(savingsAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingSavingsTransactionId = Long.valueOf(txnId); + resultingSavingsTransactionId = accountBookDebitService.debitSavings(savingsAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId()); drGlAccountId = getSavingsControlGlId(savingsAccountId); - markWithdrawalGlEntriesReversed("S" + txnId); linkedAccountId = savingsAccountId; } else { - final String txnId = executeCurrentAccountWithdrawal(currentAccountId, totalWithdrawal, issuedDate, findDefaultPaymentTypeId(), note); - resultingCurrentTransactionId = Long.valueOf(txnId); + resultingCurrentTransactionId = accountBookDebitService.debitCurrent(currentAccountId, totalWithdrawal, + issuingBranchId, issuedDate, currentUser.getId(), note); drGlAccountId = getCurrentControlGlId(currentAccountId); - markWithdrawalGlEntriesReversed("CAT" + txnId); linkedAccountId = currentAccountId; + linkedAccountIsCurrent = true; } if (drGlAccountId == null) { throw new IllegalStateException( @@ -944,7 +952,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final String comments = "Sponsor DD slip issuance - DD Nos " + ddNumbersSummary; final Long resultingJournalEntryId = postGlOffsetEntry(drGlAccountId, ddPayableGlAccountId, totalFaceValue, totalCharges, - chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId); + chargesGlAccountId, totalGst, gstGlAccountId, issuingBranchId, issuedDate, comments, linkedAccountId, + linkedAccountIsCurrent); for (final Map request : requests) { this.sponsorDDWritePlatformService.issueDemandDraft( @@ -1028,9 +1037,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId, final BigDecimal ddLimitAmount) { - if (ddPayableGlAccountId == null) { - throw new IllegalArgumentException("Sponsor DD Payable GL account is required."); - } + // DD Payable GL is no longer configured on this screen -- it lives on each sponsor bank + // (m_sponsor_bank.dd_payable_gl_account_id) and is the only source DD posting actually reads + // (see findDdPayableGlAccountIdFor*). The column here is kept nullable for backward + // compatibility but must never be required, or a tenant that has never opened this dialog + // (payable NULL in the row) cannot save the charges/GST/cash mapping at all. final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18); final List> existing = this.jdbcTemplate.queryForList( @@ -1055,7 +1066,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "gst_payable_gl_account_id, dd_limit_amount, created_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", new String[] { "id" }); - ps.setLong(1, ddPayableGlAccountId); + if (ddPayableGlAccountId != null) ps.setLong(1, ddPayableGlAccountId); else ps.setNull(1, java.sql.Types.BIGINT); if (chequeClearingGlAccountId != null) ps.setLong(2, chequeClearingGlAccountId); else ps.setNull(2, java.sql.Types.BIGINT); if (cashGlAccountId != null) ps.setLong(3, cashGlAccountId); else ps.setNull(3, java.sql.Types.BIGINT); if (chargesIncomeGlAccountId != null) ps.setLong(4, chargesIncomeGlAccountId); else ps.setNull(4, java.sql.Types.BIGINT); @@ -1582,21 +1593,6 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI return result.getResourceId().toString(); } - /** - * A savings/current withdrawal auto-creates its own Dr SavingsControl / Cr CashVault journal entry — - * but the DD money isn't leaving as physical cash, it's becoming a liability to the sponsor bank. - * Hides that auto-created entry from GL reports (mirrors the same technique the Clerk Disbursement - * module uses for its own "Collection" flow) so only our combined Dr SavingsControl / Cr Sponsor DD - * Payable entry, posted via postGlOffsetEntry() right after this call, is visible. - */ - private void markWithdrawalGlEntriesReversed(final String transactionId) { - // Guarded by reversed = false: if transaction_id text is ever reused, this can only flip - // rows that are currently live, never re-touch an already-reversed unrelated transaction. - this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false", - transactionId); - } - /** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */ private Long getSavingsControlGlId(final Long savingsAccountId) { final List> rows = this.jdbcTemplate.queryForList( @@ -1612,7 +1608,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI final List> rows = this.jdbcTemplate.queryForList( "SELECT apm.gl_account_id FROM m_current_account ca " + "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id " - + "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1", + + "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 2 LIMIT 1", currentAccountId); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); } @@ -1768,15 +1764,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI * liability, so they get their own credit leg rather than being folded into the liability). Resolves * the resulting row ID from acc_gl_journal_entry so it can be linked from m_sponsor_dd_issue_request / * m_sponsor_dd_transaction for audit traceability. When linkedAccountId is given (the client's - * savings/current account for an account-debit DD), the Dr-side row is tagged entity_type_enum=2 / - * entity_id=linkedAccountId so GL reports show the account number instead of a blank "—", matching - * how Fineract's own auto-generated entries display. + * savings/current account for an account-debit DD), the Dr-side row is tagged + * entity_type_enum=SAVING(2) or CURRENT(6) per linkedAccountIsCurrent / entity_id=linkedAccountId so + * GL reports show the account number instead of a blank "—", matching how Fineract's own + * auto-generated entries display -- and so the two id spaces (m_savings_account, m_current_account + * each with their own sequence) can't resolve a same-numbered stranger's account instead. */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { return postGlOffsetEntry(drGlAccountId, crGlAccountId, amount, chargesAmount, chargesGlAccountId, null, null, officeId, - txnDate, comments, linkedAccountId); + txnDate, comments, linkedAccountId, false); } /** @@ -1822,7 +1820,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI /** As above, with an extra optional GST credit leg (GST is charged on chargesAmount only). */ private Long postGlOffsetEntry(final Long drGlAccountId, final Long crGlAccountId, final BigDecimal amount, final BigDecimal chargesAmount, final Long chargesGlAccountId, final BigDecimal gstAmount, final Long gstGlAccountId, - final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId) { + final Long officeId, final LocalDate txnDate, final String comments, final Long linkedAccountId, + final boolean linkedAccountIsCurrent) { final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0; final boolean hasGst = gstAmount != null && gstAmount.compareTo(BigDecimal.ZERO) > 0; @@ -1883,11 +1882,18 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI // posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever // being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly. if (linkedAccountId != null) { + // m_current_account and m_savings_account each keep their own identity sequence, so the same + // numeric id can be a real row in both -- entity_type_enum must say which one linkedAccountId + // names, not assume SAVING(2) always (see linkedAccountIsCurrent's callers: the account-debit + // branch resolves either a savingsAccountId or a currentAccountId into this same parameter). + final int entityType = linkedAccountIsCurrent + ? PortfolioProductType.CURRENT.getValue() + : PortfolioProductType.SAVING.getValue(); this.jdbcTemplate.update( - "UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = (" + "UPDATE acc_gl_journal_entry SET entity_type_enum = ?, entity_id = ? WHERE id = (" + "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false" + " ORDER BY id DESC LIMIT 1)", - linkedAccountId, transactionId); + entityType, linkedAccountId, transactionId); } final List> rows = this.jdbcTemplate.queryForList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index 91a0947b6..c2532e1f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -281,7 +281,9 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe + "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, " + "m.dd_limit_amount " + "from m_sponsor_dd_gl_mapping m " - + "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + // Left join: DD Payable moved to the sponsor bank (0570) and became nullable here (0573), so + // an inner join dropped the whole row and the settings screen reopened blank. + + "left join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id " + "left join acc_gl_account clearing on clearing.id = m.cheque_clearing_gl_account_id " + "left join acc_gl_account cash on cash.id = m.cash_gl_account_id " + "left join acc_gl_account charges on charges.id = m.charges_income_gl_account_id " @@ -310,7 +312,7 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final Long id = rs.getLong("id"); final Long sponsorBankId = rs.getObject("sponsor_bank_id") != null ? rs.getLong("sponsor_bank_id") : null; final String sponsorBankName = rs.getString("sponsor_bank_name"); - final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id"); + final Long ddPayableGlAccountId = rs.getObject("dd_payable_gl_account_id") != null ? rs.getLong("dd_payable_gl_account_id") : null; final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name"); final String ddPayableGlCode = rs.getString("dd_payable_gl_code"); final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java new file mode 100644 index 000000000..8b9eccb6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsConfigApiResource.java @@ -0,0 +1,383 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.api; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Admin screen backing API for TDS on deposit interest: the thresholds, rates and statutory + * references for each financial year, and the GL account deducted tax is credited to. + * + * Every TDS screen computes from what this returns. Nothing about the tax is compiled into the + * frontend any more, so a year that is not configured here is a year the screens refuse to compute + * rather than one they compute on stale figures. + */ +@Path("/v1/tdsconfig") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class TdsConfigApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSION = "TDSCONFIG"; + + private static final Pattern FY_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})$"); + + private static final List REGIMES = List.of("ITA_1961", "ITA_2025"); + private static final List DECLARATION_FORMS = List.of("15G", "15H", "121"); + private static final List FREQUENCIES = List.of("MONTHLY", "QUARTERLY"); + + /** GL roles, in the order the screen presents them. */ + private static final Map GL_ROLES = new LinkedHashMap<>(); + static { + GL_ROLES.put("TDS_PAYABLE", new String[] { "TDS payable", + "Liability account that tax deducted from deposit interest is credited to, and held in until it is " + + "remitted to the Government." }); + } + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── read ─────────────────────────── + + /** + * Open to any signed-in user, not just holders of READ_TDSCONFIG: a teller's Form 121 tab and the + * branch TDS reports compute from these figures. Only the admin screen is gated on the permission. + */ + @GET + public String retrieveAll() { + this.context.authenticatedUser(); + final Map out = new LinkedHashMap<>(); + out.put("years", years()); + out.put("glRoles", glRoles()); + return gson.toJson(out); + } + + /** Liability detail accounts, the only kind tax payable can sit in. */ + @GET + @Path("template") + public String template() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, name, gl_code + FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 AND classification_enum = 2 + ORDER BY gl_code + """); + final List> options = new ArrayList<>(); + for (final Map a : accounts) { + final Map o = new LinkedHashMap<>(); + o.put("id", a.get("id")); + o.put("name", a.get("name")); + o.put("glCode", a.get("gl_code")); + options.add(o); + } + final Map out = new LinkedHashMap<>(); + out.put("glAccountOptions", options); + out.put("regimes", REGIMES); + out.put("declarationForms", DECLARATION_FORMS); + out.put("frequencies", FREQUENCIES); + return gson.toJson(out); + } + + private List> years() { + final List> rows = jdbcTemplate + .queryForList("SELECT * FROM m_tds_fy_config ORDER BY fy_label DESC"); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("fy", r.get("fy_label")); + o.put("regime", r.get("regime")); + o.put("interestTdsSection", r.get("interest_tds_section")); + o.put("thresholdSection", r.get("threshold_section")); + o.put("noPanSection", r.get("no_pan_section")); + o.put("defaultSection", r.get("default_section")); + o.put("declarationForm", r.get("declaration_form")); + o.put("declarationRule", r.get("declaration_rule")); + o.put("declarationSection", r.get("declaration_section")); + o.put("tdsStatementForm", r.get("tds_statement_form")); + o.put("tdsStatementRule", r.get("tds_statement_rule")); + o.put("certificateForm", r.get("certificate_form")); + o.put("declarationStatementFrequency", r.get("declaration_statement_frequency")); + o.put("declarationStatementDueDay", r.get("declaration_statement_due_day")); + o.put("thresholdGeneral", r.get("threshold_general")); + o.put("thresholdSenior", r.get("threshold_senior")); + o.put("ratePanPercentage", r.get("rate_pan_percentage")); + o.put("rateNoPanPercentage", r.get("rate_no_pan_percentage")); + o.put("basicExemption", r.get("basic_exemption")); + o.put("nilTaxCeiling", r.get("nil_tax_ceiling")); + o.put("roundTo", r.get("round_to")); + o.put("lateDeductionPercentage", r.get("late_deduction_percentage")); + o.put("lateDepositPercentage", r.get("late_deposit_percentage")); + o.put("seedDefault", r.get("is_seed_default")); + o.put("updatedOn", r.get("updated_on") == null ? null : String.valueOf(r.get("updated_on"))); + out.add(o); + } + return out; + } + + /** Every role, including one with nothing mapped - the screen must show the gap. */ + private List> glRoles() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.config_key, c.gl_account_id, a.name AS gl_account_name, a.gl_code + FROM m_tds_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + """); + final Map> byKey = new LinkedHashMap<>(); + for (final Map r : rows) { + byKey.put((String) r.get("config_key"), r); + } + final List> out = new ArrayList<>(); + for (final Map.Entry role : GL_ROLES.entrySet()) { + final Map o = new LinkedHashMap<>(); + o.put("configKey", role.getKey()); + o.put("label", role.getValue()[0]); + o.put("description", role.getValue()[1]); + final Map mapped = byKey.get(role.getKey()); + o.put("glAccountId", mapped == null ? null : mapped.get("gl_account_id")); + o.put("glCode", mapped == null ? null : mapped.get("gl_code")); + o.put("glAccountName", mapped == null ? null : mapped.get("gl_account_name")); + out.add(o); + } + return out; + } + + // ─────────────────────────── write ─────────────────────────── + + /** + * Creates or replaces one financial year. The whole row is sent, so a save never leaves a year + * half old and half new. Saving clears the seed flag: the figures are now this bank's own. + */ + @PUT + @Path("year/{fy}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String upsertYear(@PathParam("fy") final String fy, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + validateFy(fy); + + final JsonElement parsed = this.fromJsonHelper.parse(body); + final JsonObject root = parsed.getAsJsonObject(); + + final String regime = oneOf(root, "regime", REGIMES); + final String declarationForm = oneOf(root, "declarationForm", DECLARATION_FORMS); + final String frequency = oneOf(root, "declarationStatementFrequency", FREQUENCIES); + final int dueDay = integer(root, "declarationStatementDueDay", 1, 31); + final int roundTo = integer(root, "roundTo", 1, 100); + + final BigDecimal thresholdGeneral = amount(root, "thresholdGeneral"); + final BigDecimal thresholdSenior = amount(root, "thresholdSenior"); + final BigDecimal basicExemption = amount(root, "basicExemption"); + final BigDecimal nilTaxCeiling = amount(root, "nilTaxCeiling"); + final BigDecimal ratePan = percentage(root, "ratePanPercentage"); + final BigDecimal rateNoPan = percentage(root, "rateNoPanPercentage"); + final BigDecimal lateDeduction = percentage(root, "lateDeductionPercentage"); + final BigDecimal lateDeposit = percentage(root, "lateDepositPercentage"); + + // The no-PAN rate is a penalty rate. Below the ordinary rate it would reward a missing PAN. + if (rateNoPan.compareTo(ratePan) < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.nopan.rate.below.rate", + "The rate without PAN cannot be lower than the rate with PAN."); + } + + final Object[] values = { regime, text(root, "interestTdsSection"), text(root, "thresholdSection"), + text(root, "noPanSection"), text(root, "defaultSection"), declarationForm, text(root, "declarationRule"), + text(root, "declarationSection"), text(root, "tdsStatementForm"), text(root, "tdsStatementRule"), + text(root, "certificateForm"), frequency, dueDay, thresholdGeneral, thresholdSenior, ratePan, rateNoPan, + basicExemption, nilTaxCeiling, roundTo, lateDeduction, lateDeposit, this.context.authenticatedUser().getId(), + fy }; + + final int updated = jdbcTemplate.update(""" + UPDATE m_tds_fy_config + SET regime = ?, interest_tds_section = ?, threshold_section = ?, no_pan_section = ?, + default_section = ?, declaration_form = ?, declaration_rule = ?, declaration_section = ?, + tds_statement_form = ?, tds_statement_rule = ?, certificate_form = ?, + declaration_statement_frequency = ?, declaration_statement_due_day = ?, + threshold_general = ?, threshold_senior = ?, rate_pan_percentage = ?, + rate_no_pan_percentage = ?, basic_exemption = ?, nil_tax_ceiling = ?, round_to = ?, + late_deduction_percentage = ?, late_deposit_percentage = ?, + is_seed_default = false, updated_on = now(), updated_by = ? + WHERE fy_label = ? + """, values); + if (updated == 0) { + jdbcTemplate.update(""" + INSERT INTO m_tds_fy_config + (regime, interest_tds_section, threshold_section, no_pan_section, default_section, + declaration_form, declaration_rule, declaration_section, tds_statement_form, + tds_statement_rule, certificate_form, declaration_statement_frequency, + declaration_statement_due_day, threshold_general, threshold_senior, rate_pan_percentage, + rate_no_pan_percentage, basic_exemption, nil_tax_ceiling, round_to, + late_deduction_percentage, late_deposit_percentage, created_by, fy_label, + is_seed_default, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, now()) + """, values); + } + return retrieveAll(); + } + + /** + * Removes one year. The last year is kept: with none at all every TDS screen stops computing, + * which is a harsher outcome than an administrator deleting the wrong row expects. + */ + @DELETE + @Path("year/{fy}") + public String deleteYear(@PathParam("fy") final String fy) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + final Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tds_fy_config", Integer.class); + if (total != null && total <= 1) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.last.year", + "The only financial year on record cannot be removed - edit it instead."); + } + final int removed = jdbcTemplate.update("DELETE FROM m_tds_fy_config WHERE fy_label = ?", fy); + if (removed == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.year.not.found", + "Financial year " + fy + " is not configured."); + } + return retrieveAll(); + } + + /** Sets, or with a null account clears, the GL account behind one role. */ + @PUT + @Path("gl/{configKey}") + @Consumes({ MediaType.APPLICATION_JSON }) + public String updateGlRole(@PathParam("configKey") final String configKey, final String body) { + this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSION); + if (!GL_ROLES.containsKey(configKey)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.unknown.role", + "'" + configKey + "' is not a known TDS GL role."); + } + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + final Long accountId = root.has("glAccountId") && !root.get("glAccountId").isJsonNull() + ? root.get("glAccountId").getAsLong() + : null; + + if (accountId == null) { + jdbcTemplate.update("DELETE FROM m_tds_gl_config WHERE config_key = ?", configKey); + return retrieveAll(); + } + + final Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM acc_gl_account + WHERE id = ? AND disabled = false AND account_usage = 1 AND classification_enum = 2 + """, Integer.class, accountId); + if (exists == null || exists == 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.gl.account.invalid", + "GL account " + accountId + " was not found, is disabled, is a header, or is not a liability account."); + } + final Long userId = this.context.authenticatedUser().getId(); + final int updated = jdbcTemplate.update( + "UPDATE m_tds_gl_config SET gl_account_id = ?, updated_on = now(), updated_by = ? WHERE config_key = ?", + accountId, userId, configKey); + if (updated == 0) { + jdbcTemplate.update( + "INSERT INTO m_tds_gl_config (config_key, gl_account_id, updated_on, updated_by) VALUES (?, ?, now(), ?)", + configKey, accountId, userId); + } + return retrieveAll(); + } + + // ─────────────────────────── validation ─────────────────────────── + + /** '2026-27': a four-digit opening year followed by the next year's last two digits. */ + private static void validateFy(final String fy) { + final Matcher m = fy == null ? null : FY_PATTERN.matcher(fy); + if (m == null || !m.matches() || (Integer.parseInt(m.group(1)) + 1) % 100 != Integer.parseInt(m.group(2))) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig.fy.invalid", + "Financial year must look like 2026-27."); + } + } + + private static String text(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull()) { + return null; + } + final String value = root.get(name).getAsString().trim(); + return value.isEmpty() ? null : value; + } + + private static String oneOf(final JsonObject root, final String name, final List allowed) { + final String value = text(root, name); + if (value == null || !allowed.contains(value)) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", + name + " must be one of " + allowed + "."); + } + return value; + } + + private static BigDecimal number(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull() || root.get(name).getAsString().isBlank()) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".required", name + " is required."); + } + try { + return root.get(name).getAsBigDecimal(); + } catch (NumberFormatException e) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".invalid", name + " must be a number."); + } + } + + private static BigDecimal amount(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".negative", name + " cannot be negative."); + } + return value; + } + + private static BigDecimal percentage(final JsonObject root, final String name) { + final BigDecimal value = number(root, name); + if (value.signum() < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be between 0 and 100."); + } + return value; + } + + private static int integer(final JsonObject root, final String name, final int min, final int max) { + final BigDecimal value = number(root, name); + if (value.stripTrailingZeros().scale() > 0 || value.intValue() < min || value.intValue() > max) { + throw new PlatformDataIntegrityException("error.msg.tdsconfig." + name + ".out.of.range", + name + " must be a whole number between " + min + " and " + max + "."); + } + return value.intValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java index d3dff7274..29df91a4c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsInterestApiResource.java @@ -52,10 +52,10 @@ public class TdsInterestApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Deposit interest per customer for a financial year", description = "financialYear is the opening calendar year: 2026 means 1 Apr 2026 to 31 Mar 2027. Defaults to the year the business date falls in.") - public String retrieve(@QueryParam("financialYear") final Integer financialYear) { + public String retrieve(@QueryParam("financialYear") final Integer financialYear, @QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); final List deductees = this.readPlatformService - .retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear()); + .retrieveForFinancialYear(financialYear != null ? financialYear : currentFinancialYear(), clientId); return this.toApiJsonSerializer.serialize(deductees); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java new file mode 100644 index 000000000..8591ca7b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/api/TdsRecordsApiResource.java @@ -0,0 +1,490 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.api; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.tds.service.TdsSql; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * The two TDS records the bank keeps by hand: the declarations customers give at the counter, and the + * challans Head Office remits the deducted tax with. + * + *

+ * Both feed the deduction and the reports directly. A declaration stops the deduction from the day it + * is accepted; a challan is what the remittance report reconciles the deducted tax against. + */ +@Path("/v1/tds") +@Component +@RequiredArgsConstructor +@Produces({ MediaType.APPLICATION_JSON }) +public class TdsRecordsApiResource { + + private static final String DECLARATION = "TDSDECLARATION"; + private static final String CHALLAN = "TDSCHALLAN"; + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final Gson gson = new Gson(); + + // ─────────────────────────── declarations ─────────────────────────── + + /** A customer's declarations, every year and revoked ones included, newest year first. */ + @GET + @Path("declarations") + public String declarations(@QueryParam("clientId") final Long clientId, @QueryParam("fy") final String fy) { + final AppUser user = this.context.authenticatedUser(); + if (clientId == null && fy == null) { + throw new PlatformDataIntegrityException("error.msg.tds.declarations.filter.required", "Give a clientId or a fy."); + } + final List params = new ArrayList<>(); + final StringBuilder where = new StringBuilder(" WHERE o.hierarchy LIKE ?"); + params.add(user.getOffice().getHierarchy() + "%"); + if (clientId != null) { + where.append(" AND d.client_id = ?"); + params.add(clientId); + } + if (fy != null) { + where.append(" AND d.fy_label = ?"); + params.add(fy); + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT d.id, d.client_id, c.display_name AS client_name, d.fy_label, d.form_type, d.uin, d.accepted_on, + d.estimated_income, d.is_revoked, d.revoked_on, d.revoke_reason + FROM m_tds_declaration d + JOIN m_client c ON c.id = d.client_id + JOIN m_office o ON o.id = c.office_id + """ + where + " ORDER BY d.fy_label DESC, d.accepted_on DESC", params.toArray()); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("clientId", r.get("client_id")); + o.put("clientName", r.get("client_name")); + o.put("fy", r.get("fy_label")); + o.put("formType", r.get("form_type")); + o.put("uin", r.get("uin")); + o.put("acceptedOn", str(r.get("accepted_on"))); + o.put("estimatedIncome", r.get("estimated_income")); + o.put("revoked", r.get("is_revoked")); + o.put("revokedOn", str(r.get("revoked_on"))); + o.put("revokeReason", r.get("revoke_reason")); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Records a declaration. One live declaration per customer per year; the form must be the one the + * year's configuration names (Form 121 from 2026-27, 15G/15H before), the date must fall in the year + * and not in the future, and the customer must have a PAN - a declaration without one is invalid. + */ + @POST + @Path("declarations") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String createDeclaration(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_" + DECLARATION); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + + final Long clientId = requiredLong(root, "clientId"); + assertInScope(user, clientId); + final String fy = requiredText(root, "fy"); + final String formType = requiredText(root, "formType").toUpperCase(); + final LocalDate acceptedOn = requiredDate(root, "acceptedOn"); + final String uin = optionalText(root, "uin"); + final BigDecimal estimatedIncome = root.has("estimatedIncome") && !root.get("estimatedIncome").isJsonNull() + && !root.get("estimatedIncome").getAsString().isBlank() ? root.get("estimatedIncome").getAsBigDecimal() : null; + + final List> cfg = jdbcTemplate.queryForList("SELECT declaration_form FROM m_tds_fy_config WHERE fy_label = ?", + fy); + if (cfg.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.fy.not.configured", + "Financial year " + fy + " is not set up under Organization › TDS Configuration."); + } + final String yearForm = (String) cfg.get(0).get("declaration_form"); + final boolean merged = "121".equals(yearForm); + if (merged ? !"121".equals(formType) : !("15G".equals(formType) || "15H".equals(formType))) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.year", + merged ? "From " + fy + " the declaration is Form 121." : "For " + fy + " the declaration is Form 15G or 15H."); + } + + final int startYear = Integer.parseInt(fy.substring(0, 4)); + final LocalDate fyStart = LocalDate.of(startYear, 4, 1); + final LocalDate fyEnd = LocalDate.of(startYear + 1, 3, 31); + if (acceptedOn.isBefore(fyStart) || acceptedOn.isAfter(fyEnd)) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.outside.year", + "The acceptance date must fall inside " + fy + "."); + } + if (acceptedOn.isAfter(DateUtils.getBusinessLocalDate())) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.date.in.future", + "A declaration cannot be accepted on a future date."); + } + + final Map person = jdbcTemplate.queryForMap(""" + SELECT c.date_of_birth, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan + FROM m_client c WHERE c.id = ? + """, clientId); + if (!TdsSql.isWellFormedPan((String) person.get("pan"))) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.pan.required", + "A declaration is valid only with the customer's PAN. Record a PAN on the KYC first."); + } + if (!merged) { + final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth"); + final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), fyEnd); + if (senior != "15H".equals(formType)) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.form.wrong.for.age", + senior ? "This customer is 60 or over in " + fy + ", so the form is 15H." : "This customer is under 60 in " + fy + + ", so the form is 15G."); + } + } + + final Integer live = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_tds_declaration WHERE client_id = ? AND fy_label = ? AND is_revoked = false", Integer.class, + clientId, fy); + if (live != null && live > 0) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.duplicate", + "This customer already has a declaration on record for " + fy + ". Revoke it first to replace it."); + } + + jdbcTemplate.update(""" + INSERT INTO m_tds_declaration (client_id, fy_label, form_type, uin, accepted_on, estimated_income, + is_revoked, created_by, created_on) + VALUES (?, ?, ?, ?, ?, ?, false, ?, now()) + """, clientId, fy, formType, uin, acceptedOn, estimatedIncome, user.getId()); + return declarations(clientId, null); + } + + /** + * Withdraws a declaration from a date. Interest credited from then on is deducted again; what was + * paid gross while it stood stays paid gross, because it was lawful at the time. + */ + @POST + @Path("declarations/{id}/revoke") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String revokeDeclaration(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("UPDATE_" + DECLARATION); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + final List owner = jdbcTemplate.queryForList("SELECT client_id FROM m_tds_declaration WHERE id = ? AND is_revoked = false", + Long.class, id); + if (owner.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.declaration.not.found", "No live declaration " + id + "."); + } + assertInScope(user, owner.get(0)); + final String reason = requiredText(root, "reason"); + jdbcTemplate.update(""" + UPDATE m_tds_declaration + SET is_revoked = true, revoked_on = ?, revoke_reason = ?, revoked_by = ? + WHERE id = ? + """, DateUtils.getBusinessLocalDate(), reason, user.getId(), id); + return declarations(owner.get(0), null); + } + + // ─────────────────────────── challans ─────────────────────────── + + /** Every challan entered for the year, reversed ones included so the trail stays visible. */ + @GET + @Path("challans") + public String challans(@QueryParam("fy") final String fy) { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate.queryForList(""" + SELECT ch.id, ch.fy_label, ch.deduction_month, ch.amount, ch.challan_no, ch.bsr_code, ch.deposited_on, + ch.paid_from_gl_account_id, g.gl_code, g.name AS gl_name, ch.journal_transaction_id, ch.remarks, + ch.is_reversed + FROM m_tds_challan ch + JOIN acc_gl_account g ON g.id = ch.paid_from_gl_account_id + WHERE ch.fy_label = ? + ORDER BY ch.deduction_month, ch.deposited_on, ch.id + """, fy); + final List> out = new ArrayList<>(); + for (final Map r : rows) { + final Map o = new LinkedHashMap<>(); + o.put("id", r.get("id")); + o.put("fy", r.get("fy_label")); + o.put("month", r.get("deduction_month")); + o.put("amount", r.get("amount")); + o.put("challanNo", r.get("challan_no")); + o.put("bsrCode", r.get("bsr_code")); + o.put("depositedOn", str(r.get("deposited_on"))); + o.put("paidFromGlAccountId", r.get("paid_from_gl_account_id")); + o.put("paidFrom", r.get("gl_code") + " · " + r.get("gl_name")); + o.put("journalTransactionId", r.get("journal_transaction_id")); + o.put("remarks", r.get("remarks")); + o.put("reversed", r.get("is_reversed")); + out.add(o); + } + return gson.toJson(out); + } + + /** + * Tax actually deducted, month by month, for the year: the TDS debits on savings accounts for FD/RD + * interest received there. This is what the challans must add up to. + */ + @GET + @Path("deducted") + public String deducted(@QueryParam("fy") final String fy) { + this.context.authenticatedUser(); + requireFy(fy); + final List> rows = jdbcTemplate.queryForList(""" + SELECT TO_CHAR(d.deducted_on, 'YYYY-MM') AS month, SUM(d.amount) AS amount, COUNT(*) AS postings + FROM m_tds_deduction d + JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false + WHERE d.fy_label = ? + GROUP BY 1 ORDER BY 1 + """, fy); + // The business date, not the wall clock: whether a challan is overdue is judged on the day the + // books are on. + final Map out = new LinkedHashMap<>(); + out.put("asAt", DateUtils.getBusinessLocalDate().toString()); + out.put("months", rows); + return gson.toJson(out); + } + + /** Accounts the tax can be paid out of: active, postable asset accounts (bank, cash). */ + @GET + @Path("challans/template") + public String challanTemplate() { + this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + CHALLAN); + final List> accounts = jdbcTemplate.queryForList(""" + SELECT id, gl_code AS "glCode", name FROM acc_gl_account + WHERE disabled = false AND account_usage = 1 AND classification_enum = 1 + ORDER BY gl_code + """); + final Map out = new LinkedHashMap<>(); + out.put("paidFromOptions", accounts); + return gson.toJson(out); + } + + /** + * Records a remittance and posts it: Dr TDS payable / Cr the account paid from, at Head Office. + * Only Head Office remits - the bank files one return under one TAN. + */ + @POST + @Path("challans") + @Consumes({ MediaType.APPLICATION_JSON }) + @Transactional + public String createChallan(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_" + CHALLAN); + assertHeadOffice(user); + final JsonObject root = this.fromJsonHelper.parse(body).getAsJsonObject(); + + final String fy = requireFy(requiredText(root, "fy")); + final String month = requiredText(root, "month"); + if (!month.matches("\\d{4}-\\d{2}")) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.month.invalid", "Month must look like 2026-06."); + } + final int startYear = Integer.parseInt(fy.substring(0, 4)); + final LocalDate monthStart = LocalDate.parse(month + "-01"); + if (monthStart.isBefore(LocalDate.of(startYear, 4, 1)) || monthStart.isAfter(LocalDate.of(startYear + 1, 3, 1))) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.month.outside.year", "The month must fall inside " + fy + "."); + } + final BigDecimal amount = root.get("amount").getAsBigDecimal(); + if (amount.signum() <= 0) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.amount.invalid", "Amount must be more than zero."); + } + final String challanNo = requiredText(root, "challanNo"); + final String bsrCode = requiredText(root, "bsrCode"); + if (!bsrCode.matches("\\d{7}")) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.bsr.invalid", "BSR code is 7 digits."); + } + final LocalDate depositedOn = requiredDate(root, "depositedOn"); + if (depositedOn.isAfter(DateUtils.getBusinessLocalDate())) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.date.in.future", "Deposit date cannot be in the future."); + } + final Long paidFrom = requiredLong(root, "paidFromGlAccountId"); + final String remarks = optionalText(root, "remarks"); + + final Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_tds_challan WHERE challan_no = ? AND bsr_code = ? AND deposited_on = ? AND is_reversed = false", + Integer.class, challanNo, bsrCode, depositedOn); + if (dup != null && dup > 0) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.duplicate", "This challan is already on record."); + } + + final Long tdsPayable = tdsPayableGl(); + final long officeId = user.getOffice().getId(); + final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class); + final String txnId = "TDSCH-" + UUID.randomUUID().toString().substring(0, 18); + final String narration = "TDS remitted for " + month + " · challan " + challanNo + " · BSR " + bsrCode; + journalEntry(officeId, currency, tdsPayable, depositedOn, amount, true, txnId, narration, user.getId()); + journalEntry(officeId, currency, paidFrom, depositedOn, amount, false, txnId, narration, user.getId()); + + jdbcTemplate.update(""" + INSERT INTO m_tds_challan (fy_label, deduction_month, amount, challan_no, bsr_code, deposited_on, + paid_from_gl_account_id, office_id, journal_transaction_id, remarks, + is_reversed, created_by, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, now()) + """, fy, month, amount, challanNo, bsrCode, depositedOn, paidFrom, officeId, txnId, remarks, user.getId()); + return challans(fy); + } + + /** Undoes a challan entered in error: the entry is reversed with an opposite one, the row kept. */ + @POST + @Path("challans/{id}/reverse") + @Transactional + public String reverseChallan(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("UPDATE_" + CHALLAN); + assertHeadOffice(user); + final List> rows = jdbcTemplate.queryForList( + "SELECT fy_label, amount, paid_from_gl_account_id, office_id, journal_transaction_id, challan_no FROM m_tds_challan" + + " WHERE id = ? AND is_reversed = false", + id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.not.found", "No live challan " + id + "."); + } + final Map ch = rows.get(0); + final String currency = jdbcTemplate.queryForObject("SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1", String.class); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final String txnId = "TDSCHR-" + UUID.randomUUID().toString().substring(0, 17); + final String narration = "Reversal of TDS challan " + ch.get("challan_no"); + final long officeId = ((Number) ch.get("office_id")).longValue(); + final BigDecimal amount = (BigDecimal) ch.get("amount"); + journalEntry(officeId, currency, tdsPayableGl(), today, amount, false, txnId, narration, user.getId()); + journalEntry(officeId, currency, ((Number) ch.get("paid_from_gl_account_id")).longValue(), today, amount, true, txnId, narration, + user.getId()); + jdbcTemplate.update("UPDATE m_tds_challan SET is_reversed = true WHERE id = ?", id); + return challans((String) ch.get("fy_label")); + } + + // ─────────────────────────── helpers ─────────────────────────── + + /** Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT. */ + private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date, + final BigDecimal amount, final boolean debit, final String reference, final String narration, final Long userId) { + GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum, + amount, description, entity_type_enum, entity_id, is_running_balance_calculated, + reversed, manual_entry, office_running_balance, organization_running_balance, + created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, + last_modified_on_utc, submitted_on_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, false, false, true, 0, 0, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?) + """, officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration, userId, userId, date); + } + + private Long tdsPayableGl() { + final List gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'", + Long.class); + if (gl.isEmpty() || gl.get(0) == null) { + throw new PlatformDataIntegrityException("error.msg.tds.payable.gl.not.configured", + "No TDS payable account is set. Choose one under Organization › TDS Configuration."); + } + return gl.get(0); + } + + private void assertHeadOffice(final AppUser user) { + if (user.getOffice().getParent() != null) { + throw new PlatformDataIntegrityException("error.msg.tds.challan.head.office.only", + "TDS is remitted by Head Office. A branch login cannot record a challan."); + } + } + + /** A user may act only on customers of their own office or the offices below it. */ + private void assertInScope(final AppUser user, final Long clientId) { + final Integer n = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_client c JOIN m_office o ON o.id = c.office_id + WHERE c.id = ? AND o.hierarchy LIKE ? + """, Integer.class, clientId, user.getOffice().getHierarchy() + "%"); + if (n == null || n == 0) { + throw new PlatformDataIntegrityException("error.msg.tds.client.out.of.scope", + "Customer " + clientId + " does not belong to your office."); + } + } + + private static String requireFy(final String fy) { + if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { + throw new PlatformDataIntegrityException("error.msg.tds.fy.invalid", "Financial year must look like 2026-27."); + } + return fy; + } + + private static String str(final Object v) { + return v == null ? null : String.valueOf(v); + } + + private static String optionalText(final JsonObject root, final String name) { + if (!root.has(name) || root.get(name).isJsonNull()) { + return null; + } + final String v = root.get(name).getAsString().trim(); + return v.isEmpty() ? null : v; + } + + private static String requiredText(final JsonObject root, final String name) { + final String v = optionalText(root, name); + if (v == null) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".required", name + " is required."); + } + return v; + } + + private static Long requiredLong(final JsonObject root, final String name) { + try { + return Long.valueOf(requiredText(root, name)); + } catch (NumberFormatException e) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a number."); + } + } + + /** yyyy-MM-dd. */ + private static LocalDate requiredDate(final JsonObject root, final String name) { + try { + return LocalDate.parse(requiredText(root, name)); + } catch (DateTimeParseException e) { + throw new PlatformDataIntegrityException("error.msg.tds." + name + ".invalid", name + " must be a date like 2026-09-15."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java new file mode 100644 index 000000000..487a54a96 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeclarationData.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** A 15G / 15H / Form 121 declaration: one customer, one financial year, from the day it was accepted. */ +public record TdsDeclarationData(Long id, Long clientId, String fy, String formType, String uin, LocalDate acceptedOn, + BigDecimal estimatedIncome, boolean revoked, LocalDate revokedOn, String revokeReason) implements Serializable {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java index 57819017f..5cb684bcb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/data/TdsDeducteeData.java @@ -19,12 +19,13 @@ package org.apache.fineract.portfolio.tds.data; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; /** - * A customer whose deposit interest the branch must consider for deduction under section 194A, - * together with the accounts and the interest actually credited to them. + * A customer whose deposit interest the branch must consider for deduction, together with the + * accounts and the interest actually credited to them. * *

* The section bites on time deposits, so savings accounts are carried here as well but marked by @@ -32,23 +33,31 @@ import java.util.List; * separately. Sending only the fixed and recurring deposits would hide from the branch that the * customer holds a savings balance at all, which is the first thing an officer checks when a * customer disputes a deduction. + * + *

+ * The customer here is the deductee: a minor's deposit is listed under the guardian and a + * joint deposit under its primary holder, because that is whose PAN the tax is deducted against. */ public record TdsDeducteeData(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, - String branchName, String form15Type, List accounts) implements Serializable { + String branchName, List declarations, List accounts) implements Serializable { /** - * One deposit account and the interest posted to it. + * One deposit account, the interest posted to it and the TDS actually deducted from it. * * @param depositTypeCode * Fineract's own code - {@code depositAccountType.fixedDeposit}, * {@code recurringDeposit} or {@code savingsDeposit}. This is what decides whether * the charge applies at all, so it is sent as the platform states it rather than as * a flag this report worked out for itself. + * @param clientId + * the deductee, which is the account holder unless the account is a minor's or joint + * @param holderClientId + * the account's own holder, shown when it differs from the deductee */ - public record TdsAccountData(Long id, String accountNo, String depositTypeCode, String accountStatus, - Long clientId, LocalDate openedOn, List credits) implements Serializable {} + public record TdsAccountData(Long id, String accountNo, String depositTypeCode, Long clientId, Long holderClientId, + String holderName, String accountStatus, LocalDate openedOn, List credits, + List deductions) implements Serializable {} - /** One interest posting: the date it was credited and the amount. */ - public record TdsInterestCreditData(LocalDate date, java.math.BigDecimal amount, String accountNo) - implements Serializable {} + /** One interest posting, or one TDS deduction: the date and the amount. */ + public record TdsInterestCreditData(LocalDate date, BigDecimal amount, String accountNo) implements Serializable {} } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java new file mode 100644 index 000000000..be76fd39d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/SavingsInterestTdsService.java @@ -0,0 +1,389 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.service; + +import jakarta.persistence.EntityManager; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.portfolio.account.domain.AccountTransferType; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.tax.domain.TaxComponent; +import org.apache.fineract.portfolio.tax.domain.TaxComponentRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * TDS on FD/RD interest, tracked and deducted where the interest is received. + * + *

+ * Normally that is the customer's savings account: every time money moves from a fixed or recurring + * deposit into savings, the interest in it is recorded against the deductee (see + * {@link TdsSql#deducteeOf}) and any TDS due is debited from that savings account. The periodic + * interest sweep is all interest; a maturity or closure payout is principal and interest together, so + * only the interest not already received is counted. The FD and RD are not debited. + * + *

+ * When a maturity or closure pays out in cash, by cheque or to a GL account instead, the interest never + * reaches savings, so it is tracked at the payout and the TDS comes out of the payout itself. A full + * reinvestment pays nothing out and is not taxed here. + * + *

+ * The rules, all for one financial year and one deductee: + *

    + *
  1. The FD/RD interest received, across every branch, is added up. The savings account's own interest + * is not - it is outside the charge.
  2. + *
  3. A declaration shields the interest received from the day it is accepted. Interest received + * before it stays liable.
  4. + *
  5. Nothing is deducted while the total stays within the threshold (senior or general).
  6. + *
  7. Once over it, the whole year's interest is liable - not merely the excess - at the PAN rate, or + * the higher rate without a valid PAN.
  8. + *
  9. What is deducted now is that liability less everything already deducted this year, so the credit + * that crosses the threshold catches up the earlier ones, and nothing is ever deducted twice.
  10. + *
+ * + *

+ * The tax is posted as a withhold-tax transaction through a tax component whose credit account is the + * TDS payable account set on Organization › TDS Configuration, so the passbook shows it and the + * account's own accounting writes Dr deposit liability / Cr TDS payable. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SavingsInterestTdsService { + + /** The tax component every TDS deduction is booked through. Kept in step with the GL setting. */ + public static final String TDS_COMPONENT_NAME = "TDS on deposit interest"; + + private final JdbcTemplate jdbcTemplate; + private final TaxComponentRepository taxComponentRepository; + private final EntityManager entityManager; + private final SavingsAccountRepositoryWrapper savingsAccountRepository; + private final SavingsAccountTransactionRepository savingsAccountTransactionRepository; + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + + /** + * Called once a savings-to-savings transfer has been saved. + * + * @param deposit + * the credit on the receiving savings account + */ + public void onTransfer(final SavingsAccount from, final SavingsAccount to, final SavingsAccountTransaction deposit, + final AccountTransferType transferType) { + if (from == null || to == null || deposit == null || deposit.getId() == null || !isTimeDeposit(from)) { + return; + } + final DepositAccountType toType = to.depositAccountType(); + if (toType == null || !toType.isSavingsDeposit()) { + return; + } + + final boolean sweep = transferType != null && transferType.isInterestTransfer(); + final BigDecimal interest = sweep ? deposit.getAmount() + : interestInPayout(from.getId(), postedFromDatabase(from.getId()), deposit.getAmount()); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + return; + } + + final LocalDate date = deposit.getTransactionDate(); + final Year year = Year.of(date); + final Long deductee = deducteeOf(from, year); + if (deductee == null) { + return; + } + + final Long receiptId = insertReceipt(deductee, to.getId(), deposit.getId(), from.getId(), sweep ? "INTEREST" : "PAYOUT", year, + date, interest); + + final Due due = dueFor(deductee, year, date, BigDecimal.ZERO); + if (due == null) { + return; + } + final Set existingIds = new HashSet<>(to.findExistingTransactionIds()); + final Set existingReversedIds = new HashSet<>(to.findExistingReversedTransactionIds()); + final SavingsAccountTransaction tds = to.deductTds(date, due.amount(), Map.of(due.component(), due.amount())); + // Saved on its own first, as Fineract does for every new transaction: saving only the account + // merges it, and the id lands on the merged copy - this instance stays null, which broke both + // the deduction row and the journal entry's transaction reference. + this.savingsAccountTransactionRepository.saveAndFlush(tds); + this.savingsAccountRepository.saveAndFlush(to); + this.journalEntryWritePlatformService.createJournalEntriesForSavings( + to.deriveAccountingBridgeData(to.getCurrency().getCode(), existingIds, existingReversedIds, false, false)); + recordDeduction(deductee, to.getId(), tds.getId(), from.getId(), receiptId, year, date, due); + } + + /** + * Interest paid straight out of an FD/RD at maturity or closure - in cash, by cheque, or to a GL + * account - never reaches a savings account, so it is tracked here instead, and any TDS it makes due + * is taken out of the payout itself. + * + *

+ * Call just before the closing withdrawal is worked out: the deduction is added to the deposit, so a + * withdrawal of the account balance pays out what is left. The deposit is not saved here - the + * closing withdrawal saves it and posts the deduction's accounting along with its own. Then call + * {@link #recordDirectPayout}. + * + * @param kind + * CASH or GL, for the tracking record + * @return what to record once the withdrawal exists, or null when the payout carried no interest + */ + public DirectPayout deductOnDirectPayout(final SavingsAccount deposit, final LocalDate date, final String kind) { + if (deposit == null || deposit.getId() == null || date == null || !isTimeDeposit(deposit)) { + return null; + } + // From memory: the maturity or pre-closure interest posted moments ago is not saved yet. + BigDecimal posted = BigDecimal.ZERO; + for (final SavingsAccountTransaction t : deposit.getTransactions()) { + if (t.isInterestPostingAndNotReversed() && !t.isReversalTransaction()) { + posted = posted.add(t.getAmount()); + } + } + final BigDecimal interest = interestInPayout(deposit.getId(), posted, deposit.getAccountBalance()); + if (interest.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + final Year year = Year.of(date); + final Long deductee = deducteeOf(deposit, year); + if (deductee == null) { + return null; + } + final Due due = dueFor(deductee, year, date, interest); + final SavingsAccountTransaction tds = due == null ? null + : deposit.deductTds(date, due.amount(), Map.of(due.component(), due.amount())); + return new DirectPayout(deposit.getId(), deductee, year, date, interest, kind, due, tds); + } + + /** + * Records the tracking row and the deduction for a direct payout, once the closing withdrawal has been + * saved, and marks the deduction as already posted so the caller's own journal run does not post it a + * second time - the withdrawal posted it. + */ + public void recordDirectPayout(final DirectPayout payout, final SavingsAccountTransaction withdrawal, + final Set alreadyPostedTransactionIds) { + if (payout == null || withdrawal == null || withdrawal.getId() == null) { + return; + } + final Long receiptId = insertReceipt(payout.deductee(), payout.accountId(), withdrawal.getId(), payout.accountId(), payout.kind(), + payout.year(), payout.date(), payout.interest()); + if (payout.tds() != null && payout.tds().getId() != null) { + recordDeduction(payout.deductee(), payout.accountId(), payout.tds().getId(), payout.accountId(), receiptId, payout.year(), + payout.date(), payout.due()); + if (alreadyPostedTransactionIds != null) { + alreadyPostedTransactionIds.add(payout.tds().getId()); + } + } + } + + /** What {@link #deductOnDirectPayout} worked out, held until the withdrawal is saved. */ + public record DirectPayout(Long accountId, Long deductee, Year year, LocalDate date, BigDecimal interest, String kind, Due due, + SavingsAccountTransaction tds) {} + + /** TDS due now, and what it was worked out on. */ + public record Due(BigDecimal amount, BigDecimal ratePercent, BigDecimal liableInterest, TaxComponent component) {} + + /** One Indian financial year. */ + public record Year(LocalDate start, LocalDate end, String label) { + + static Year of(final LocalDate date) { + final int startYear = TdsSql.fyStartYear(date); + return new Year(LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31), TdsSql.fyLabel(startYear)); + } + } + + private static boolean isTimeDeposit(final SavingsAccount account) { + final DepositAccountType type = account.depositAccountType(); + return type != null && (type.isFixedDeposit() || type.isRecurringDeposit()); + } + + private Long deducteeOf(final SavingsAccount deposit, final Year year) { + return jdbcTemplate.queryForObject("SELECT " + TdsSql.deducteeOf("sa") + " FROM m_savings_account sa WHERE sa.id = ?", Long.class, + year.start(), deposit.getId()); + } + + private Long insertReceipt(final Long deductee, final Long receivingAccountId, final Long txnId, final Long sourceAccountId, + final String kind, final Year year, final LocalDate date, final BigDecimal interest) { + return jdbcTemplate.queryForObject(""" + INSERT INTO m_tds_interest_receipt (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, + kind, fy_label, received_on, interest_amount, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, now()) RETURNING id + """, Long.class, deductee, receivingAccountId, txnId, sourceAccountId, kind, year.label(), date, interest); + } + + private void recordDeduction(final Long deductee, final Long accountId, final Long txnId, final Long sourceAccountId, + final Long receiptId, final Year year, final LocalDate date, final Due due) { + jdbcTemplate.update(""" + INSERT INTO m_tds_deduction (deductee_client_id, savings_account_id, savings_transaction_id, source_account_id, receipt_id, + fy_label, deducted_on, amount, rate_percentage, interest_base, created_on) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + """, deductee, accountId, txnId, sourceAccountId, receiptId, year.label(), date, due.amount(), due.ratePercent(), + due.liableInterest()); + } + + /** + * The TDS due for the deductee now, or null when nothing is. + * + * @param extraInterest + * interest received on {@code date} that is not on record yet + */ + private Due dueFor(final Long deductee, final Year year, final LocalDate date, final BigDecimal extraInterest) { + final List> cfgRows = jdbcTemplate.queryForList(""" + SELECT threshold_general, threshold_senior, rate_pan_percentage, rate_no_pan_percentage, round_to + FROM m_tds_fy_config WHERE fy_label = ? + """, year.label()); + if (cfgRows.isEmpty()) { + // Tracked, but not deducted: the TDS screens show the same gap and the administrator fixes it there. + log.warn("No TDS configuration for FY {}; interest for client {} tracked but not taxed", year.label(), deductee); + return null; + } + final Map cfg = cfgRows.get(0); + + final List accepted = jdbcTemplate.queryForList(""" + SELECT accepted_on FROM m_tds_declaration + WHERE client_id = ? AND fy_label = ? AND is_revoked = false + ORDER BY accepted_on + """, LocalDate.class, deductee, year.label()); + final LocalDate shieldedFrom = accepted.isEmpty() ? year.end().plusDays(1) : accepted.get(0); + + final Map totals = jdbcTemplate.queryForMap(""" + SELECT COALESCE(SUM(r.interest_amount), 0) AS interest, + COALESCE(SUM(CASE WHEN r.received_on < ? THEN r.interest_amount END), 0) AS liable + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.deductee_client_id = ? AND r.fy_label = ? AND r.received_on <= ? + """, shieldedFrom, deductee, year.label(), date); + final BigDecimal yearInterest = ((BigDecimal) totals.get("interest")).add(extraInterest); + final BigDecimal liableInterest = ((BigDecimal) totals.get("liable")) + .add(date.isBefore(shieldedFrom) ? extraInterest : BigDecimal.ZERO); + final BigDecimal deducted = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(d.amount), 0) + FROM m_tds_deduction d + JOIN m_savings_account_transaction t ON t.id = d.savings_transaction_id AND t.is_reversed = false + WHERE d.deductee_client_id = ? AND d.fy_label = ? + """, BigDecimal.class, deductee, year.label()); + + final Map person = jdbcTemplate.queryForMap(""" + SELECT c.date_of_birth, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan + FROM m_client c WHERE c.id = ? + """, deductee); + final java.sql.Date dob = (java.sql.Date) person.get("date_of_birth"); + final boolean senior = TdsSql.isSeniorInFy(dob == null ? null : dob.toLocalDate(), year.end()); + final BigDecimal threshold = (BigDecimal) cfg.get(senior ? "threshold_senior" : "threshold_general"); + if (yearInterest.compareTo(threshold) <= 0) { + return null; + } + + final boolean panOk = TdsSql.isWellFormedPan((String) person.get("pan")); + final BigDecimal ratePercent = (BigDecimal) cfg.get(panOk ? "rate_pan_percentage" : "rate_no_pan_percentage"); + final int roundTo = ((Number) cfg.get("round_to")).intValue(); + final BigDecimal liability = TdsSql.roundToMultiple(liableInterest.multiply(ratePercent).movePointLeft(2), roundTo); + final BigDecimal already = deducted == null ? BigDecimal.ZERO : deducted; + final BigDecimal amount = liability.subtract(already); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + log.info("TDS {} due for client {} (FY {}: interest {}, liability {}, already deducted {})", amount, deductee, year.label(), + yearInterest, liability, already); + return new Due(amount, ratePercent, liableInterest, tdsComponent(ratePercent)); + } + + private BigDecimal postedFromDatabase(final Long accountId) { + return jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(amount), 0) FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false + """, BigDecimal.class, accountId, TdsSql.INTEREST_POSTING); + } + + /** + * The interest inside a maturity or closure payout: interest posted on the deposit that has not been + * received already - through the interest sweep or an earlier payout. Capped at the payout. + * + *

+ * On a premature closure the months already swept to savings stand as they were; only the interest + * for the days since is paid out now, and only that is counted. + */ + private BigDecimal interestInPayout(final Long sourceAccountId, final BigDecimal posted, final BigDecimal payout) { + final BigDecimal swept = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(att.amount), 0) + FROM m_account_transfer_transaction att + JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id + WHERE d.from_savings_account_id = ? AND d.transfer_type = ? AND att.is_reversed = false + """, BigDecimal.class, sourceAccountId, AccountTransferType.INTEREST_TRANSFER.getValue()); + final BigDecimal paidOut = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(r.interest_amount), 0) + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.source_account_id = ? AND r.kind <> 'INTEREST' + """, BigDecimal.class, sourceAccountId); + final BigDecimal unpaid = posted.subtract(swept).subtract(paidOut); + if (unpaid.compareTo(BigDecimal.ZERO) <= 0 || payout == null) { + return BigDecimal.ZERO; + } + return unpaid.min(payout); + } + + /** + * The tax component the deduction is booked through, created on first use and re-pointed whenever + * the TDS payable account on the configuration screen changes. + */ + private TaxComponent tdsComponent(final BigDecimal ratePercent) { + final List gl = jdbcTemplate.queryForList("SELECT gl_account_id FROM m_tds_gl_config WHERE config_key = 'TDS_PAYABLE'", + Long.class); + if (gl.isEmpty() || gl.get(0) == null) { + throw new GeneralPlatformDomainRuleException("error.msg.tds.payable.gl.not.configured", + "TDS is due on this interest but no TDS payable account is set. Choose one under Organization › TDS Configuration."); + } + final Long glId = gl.get(0); + final List existing = jdbcTemplate.queryForList("SELECT id FROM m_tax_component WHERE name = ? ORDER BY id", Long.class, + TDS_COMPONENT_NAME); + final Long id; + boolean repointed = false; + if (existing.isEmpty()) { + id = jdbcTemplate.queryForObject(""" + INSERT INTO m_tax_component (name, percentage, credit_account_type_enum, credit_account_id, start_date, + createdby_id, created_date, lastmodifiedby_id, lastmodified_date) + SELECT ?, ?, 2, ?, DATE '2000-01-01', MIN(u.id), now(), MIN(u.id), now() FROM m_appuser u + RETURNING id + """, Long.class, TDS_COMPONENT_NAME, ratePercent, glId); + } else { + id = existing.get(0); + repointed = jdbcTemplate.update("UPDATE m_tax_component SET credit_account_type_enum = 2, credit_account_id = ? WHERE id = ?" + + " AND (credit_account_id IS DISTINCT FROM ? OR credit_account_type_enum IS DISTINCT FROM 2)", glId, id, glId) > 0; + } + final TaxComponent component = taxComponentRepository.findById(id).orElseThrow(); + // Written behind JPA's back, so a copy already in this session would still credit the old account. + if (repointed) { + entityManager.refresh(component); + } + return component; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java index 1576d8ec0..0a446a43d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformService.java @@ -29,6 +29,8 @@ public interface TdsInterestReadPlatformService { * @param financialYear * the Indian financial year as its opening calendar year - 2026 means 1 Apr 2026 to * 31 Mar 2027 + * @param clientId + * one customer only, or null for everyone in the user's scope */ - List retrieveForFinancialYear(int financialYear); + List retrieveForFinancialYear(int financialYear, Long clientId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java index 379a553a0..e6f2c30d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsInterestReadPlatformServiceImpl.java @@ -27,6 +27,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.tds.data.TdsDeclarationData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsAccountData; import org.apache.fineract.portfolio.tds.data.TdsDeducteeData.TdsInterestCreditData; @@ -35,8 +37,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * Reads the interest the bank actually credited, so the deduction is worked out from the ledger - * rather than from a balance and a rate. + * Reads the interest the bank actually credited, and the TDS it actually deducted, so the register is + * worked out from the ledger rather than from a balance and a rate. */ @Service @RequiredArgsConstructor @@ -44,109 +46,150 @@ import org.springframework.transaction.annotation.Transactional; public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatformService { /** - * Interest posting - NOT a deposit, a withdrawal, a fee or a transfer. + * Every account, grouped under the client whose PAN carries its deduction, with what counts for TDS + * inside the year. * *

- * This single condition is what makes the figure an interest figure. A deposit is the - * customer's own money arriving and is not income; a withdrawal and a fee are not income - * either. Only what the bank itself credited as interest can be deducted against, so the - * transaction type is filtered here rather than being inferred later from an amount. - * - * @see org.apache.fineract.portfolio.savings.SavingsAccountTransactionType#INTEREST_POSTING - */ - private static final int INTEREST_POSTING = 3; - - /** - * Every deposit the customer holds, with the interest credited to each inside the year. + * On a fixed or recurring deposit, "interest" is the FD/RD interest that reached the customer's + * savings account (m_tds_interest_receipt) and "deductions" the TDS debited there for it + * (m_tds_deduction) - TDS is tracked and taken where the interest is received, and the deposit + * itself is never debited. Interest still sitting in a deposit is not counted until it is paid out. + * Rows whose savings transaction has been reversed are left out: reversed money was never received. * *

- * Savings accounts are included deliberately. Section 194A does not reach them, and the caller - * keeps their interest out of the aggregate - but the branch still has to see that the customer - * holds one, and the row on screen shows it as exempt rather than leaving it out entirely. + * On a savings account, "interest" is its own interest postings. The charge does not reach them, and + * the caller keeps them out of the aggregate - but the branch still has to see them. * *

- * Reversed postings are excluded: a reversed posting was never income, and leaving it in would - * deduct tax on interest the customer never received. + * Scoped to the signed-in user's office and the offices below it, on the deductee's office. A branch + * login therefore receives only its own customers. Each customer still arrives with every one of + * their deposits, wherever held, because the threshold is tested on the bank as a whole. */ private static final String SQL = """ + WITH acc AS ( + SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.activatedon_date, sa.account_status, + sa.client_id AS holder_id, %s AS deductee_id + FROM m_savings_account sa + WHERE sa.client_id IS NOT NULL + ), + lines AS ( + SELECT t.savings_account_id AS account_id, 'C' AS kind, t.transaction_date AS d, t.amount AS amt + FROM m_savings_account_transaction t + JOIN m_savings_account s ON s.id = t.savings_account_id AND s.deposit_type_enum = 100 + WHERE t.transaction_type_enum = ? AND t.is_reversed = false AND t.transaction_date BETWEEN ? AND ? + UNION ALL + SELECT r.source_account_id, 'C', r.received_on, r.interest_amount + FROM m_tds_interest_receipt r + JOIN m_savings_account_transaction t ON t.id = r.savings_transaction_id AND t.is_reversed = false + WHERE r.received_on BETWEEN ? AND ? + UNION ALL + SELECT dd.source_account_id, 'D', dd.deducted_on, dd.amount + FROM m_tds_deduction dd + JOIN m_savings_account_transaction t ON t.id = dd.savings_transaction_id AND t.is_reversed = false + WHERE dd.deducted_on BETWEEN ? AND ? + ) SELECT c.id AS client_id, c.account_no AS customer_id, c.display_name AS name, c.date_of_birth AS date_of_birth, - k.pan_number AS pan, - k.form15_type AS form15_type, + (SELECT k.pan_number FROM m_client_kyc k + WHERE k.client_id = c.id AND COALESCE(k.is_deleted, false) = false + ORDER BY k.id DESC LIMIT 1) AS pan, o.name AS branch_name, - sa.id AS account_id, - sa.account_no AS account_no, - sa.activatedon_date AS opened_on, - CASE sa.deposit_type_enum + a.id AS account_id, + a.account_no AS account_no, + a.activatedon_date AS opened_on, + a.account_status AS account_status, + a.holder_id AS holder_id, + h.display_name AS holder_name, + CASE a.deposit_type_enum WHEN 100 THEN 'depositAccountType.savingsDeposit' WHEN 200 THEN 'depositAccountType.fixedDeposit' WHEN 300 THEN 'depositAccountType.recurringDeposit' ELSE 'depositAccountType.unknown' END AS deposit_type_code, - t.transaction_date AS credit_date, - t.amount AS credit_amount - FROM m_savings_account sa - JOIN m_client c ON c.id = sa.client_id - LEFT JOIN m_office o ON o.id = c.office_id - LEFT JOIN m_client_kyc k ON k.client_id = c.id - LEFT JOIN m_savings_account_transaction t - ON t.savings_account_id = sa.id - AND t.transaction_type_enum = ? - AND t.is_reversed = false - AND t.transaction_date >= ? - AND t.transaction_date <= ? - WHERE sa.client_id IS NOT NULL - ORDER BY c.display_name, sa.account_no, t.transaction_date - """; + l.kind AS line_kind, + l.d AS txn_date, + l.amt AS txn_amount + FROM acc a + JOIN m_client c ON c.id = a.deductee_id + JOIN m_office o ON o.id = c.office_id + JOIN m_client h ON h.id = a.holder_id + LEFT JOIN lines l ON l.account_id = a.id + WHERE o.hierarchy LIKE ? + AND (CAST(? AS BIGINT) IS NULL OR c.id = CAST(? AS BIGINT)) + ORDER BY c.display_name, a.account_no, l.d + """.formatted(TdsSql.deducteeOf("sa")); private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; @Override - public List retrieveForFinancialYear(final int financialYear) { + public List retrieveForFinancialYear(final int financialYear, final Long onlyClientId) { // The Indian financial year, 1 April to 31 March. final LocalDate from = LocalDate.of(financialYear, 4, 1); final LocalDate to = LocalDate.of(financialYear + 1, 3, 31); + final java.sql.Date f = java.sql.Date.valueOf(from); + final java.sql.Date t = java.sql.Date.valueOf(to); + final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%"; final Map byClient = new LinkedHashMap<>(); jdbcTemplate.query(SQL, rs -> { final long clientId = rs.getLong("client_id"); final Deductee deductee = byClient.computeIfAbsent(clientId, - id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), - readString(rs, "pan"), readDate(rs, "date_of_birth"), readString(rs, "branch_name"), - readString(rs, "form15_type"))); + id -> new Deductee(id, readString(rs, "customer_id"), readString(rs, "name"), readString(rs, "pan"), + readDate(rs, "date_of_birth"), readString(rs, "branch_name"))); final long accountId = rs.getLong("account_id"); - final Account account = deductee.accounts.computeIfAbsent(accountId, id -> new Account(id, - readString(rs, "account_no"), readString(rs, "deposit_type_code"), readDate(rs, "opened_on"))); + final Account account = deductee.accounts.computeIfAbsent(accountId, + id -> new Account(id, readString(rs, "account_no"), readString(rs, "deposit_type_code"), readLong(rs, "holder_id"), + readString(rs, "holder_name"), readString(rs, "account_status"), readDate(rs, "opened_on"))); - // Null where the outer join found no posting inside the year: the account is still - // listed, it simply earned nothing in the window. - final java.sql.Date creditDate = rs.getDate("credit_date"); - if (creditDate != null) { - final BigDecimal amount = rs.getBigDecimal("credit_amount"); - account.credits.add(new TdsInterestCreditData(creditDate.toLocalDate(), amount, account.accountNo)); + // Null where the outer join found nothing inside the year: the account is still listed, it + // simply earned nothing in the window. + final java.sql.Date date = rs.getDate("txn_date"); + if (date != null) { + final BigDecimal amount = rs.getBigDecimal("txn_amount"); + final TdsInterestCreditData line = new TdsInterestCreditData(date.toLocalDate(), amount, account.accountNo); + if ("D".equals(rs.getString("line_kind"))) { + account.deductions.add(line); + } else { + account.credits.add(line); + } } - }, INTEREST_POSTING, java.sql.Date.valueOf(from), java.sql.Date.valueOf(to)); + }, f, TdsSql.INTEREST_POSTING, f, t, f, t, f, t, hierarchy, onlyClientId, onlyClientId); + + final Map> declarations = declarationsFor(TdsSql.fyLabel(financialYear)); final List result = new ArrayList<>(); for (final Deductee d : byClient.values()) { final List accounts = new ArrayList<>(); for (final Account a : d.accounts.values()) { - // accountStatus is the RBI holding pattern (SINGLE / JOINT / MINOR) the caller uses - // to decide whose PAN carries the deduction. Fineract does not model it on the - // savings account, so it is left null and the caller falls back to the account - // holder rather than inventing a joint holder who may not exist. - accounts.add( - new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, null, d.clientId, a.openedOn, a.credits)); + accounts.add(new TdsAccountData(a.id, a.accountNo, a.depositTypeCode, d.clientId, a.holderId, a.holderName, a.accountStatus, + a.openedOn, a.credits, a.deductions)); } result.add(new TdsDeducteeData(d.clientId, d.customerId, d.name, d.pan, d.dateOfBirth, d.branchName, - d.form15Type, accounts)); + declarations.getOrDefault(d.clientId, List.of()), accounts)); } return result; } + private Map> declarationsFor(final String fy) { + final Map> out = new LinkedHashMap<>(); + jdbcTemplate.query(""" + SELECT id, client_id, fy_label, form_type, uin, accepted_on, estimated_income + FROM m_tds_declaration + WHERE fy_label = ? AND is_revoked = false + ORDER BY accepted_on + """, rs -> { + final TdsDeclarationData d = new TdsDeclarationData(rs.getLong("id"), rs.getLong("client_id"), rs.getString("fy_label"), + rs.getString("form_type"), rs.getString("uin"), readDate(rs, "accepted_on"), rs.getBigDecimal("estimated_income"), + false, null, null); + out.computeIfAbsent(d.clientId(), k -> new ArrayList<>()).add(d); + }, fy); + return out; + } + private static String readString(final ResultSet rs, final String column) { try { return rs.getString(column); @@ -155,6 +198,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo } } + private static Long readLong(final ResultSet rs, final String column) { + try { + final long v = rs.getLong(column); + return rs.wasNull() ? null : v; + } catch (final SQLException e) { + throw new IllegalStateException("Could not read column " + column, e); + } + } + private static LocalDate readDate(final ResultSet rs, final String column) { try { final java.sql.Date value = rs.getDate(column); @@ -173,18 +225,15 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo private final String pan; private final LocalDate dateOfBirth; private final String branchName; - private final String form15Type; private final Map accounts = new LinkedHashMap<>(); - private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, - String branchName, String form15Type) { + private Deductee(Long clientId, String customerId, String name, String pan, LocalDate dateOfBirth, String branchName) { this.clientId = clientId; this.customerId = customerId; this.name = name; this.pan = pan; this.dateOfBirth = dateOfBirth; this.branchName = branchName; - this.form15Type = form15Type; } } @@ -193,13 +242,21 @@ public class TdsInterestReadPlatformServiceImpl implements TdsInterestReadPlatfo private final Long id; private final String accountNo; private final String depositTypeCode; + private final Long holderId; + private final String holderName; + private final String accountStatus; private final LocalDate openedOn; private final List credits = new ArrayList<>(); + private final List deductions = new ArrayList<>(); - private Account(Long id, String accountNo, String depositTypeCode, LocalDate openedOn) { + private Account(Long id, String accountNo, String depositTypeCode, Long holderId, String holderName, String accountStatus, + LocalDate openedOn) { this.id = id; this.accountNo = accountNo; this.depositTypeCode = depositTypeCode; + this.holderId = holderId; + this.holderName = holderName; + this.accountStatus = accountStatus; this.openedOn = openedOn; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java new file mode 100644 index 000000000..1080614ff --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/tds/service/TdsSql.java @@ -0,0 +1,95 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.tds.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.regex.Pattern; + +/** + * The TDS rules both the deduction and the report must agree on, kept in one place so the passbook + * and the register cannot drift apart. + */ +public final class TdsSql { + + public static final int INTEREST_POSTING = 3; + public static final int WITHHOLD_TAX = 18; + public static final int FIXED_DEPOSIT = 200; + public static final int RECURRING_DEPOSIT = 300; + + /** A well-formed PAN with a recognised holder type - the same test the screens apply. */ + private static final Pattern PAN = Pattern.compile("^[A-Z]{5}[0-9]{4}[A-Z]$"); + private static final String PAN_HOLDER_TYPES = "ABCFGHJLPT"; + + private TdsSql() {} + + /** + * The client whose PAN carries the deduction for an account, as SQL over the alias given. + * + *

    + *
  • A minor's account follows the guardian - the interest is clubbed into the guardian's income. + * Minor means under 18 on the first day of the year, so one year has one deductee.
  • + *
  • A joint account follows the holder flagged primary - one deposit, one deductee.
  • + *
  • Anything else follows the account holder.
  • + *
+ * + * Takes one bind parameter: the financial year's first day. + */ + public static String deducteeOf(final String sa) { + return """ + (CASE + WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'MINOR' AND %1$s.guardian_client_id IS NOT NULL + AND (%1$s.minor_date_of_birth IS NULL + OR %1$s.minor_date_of_birth > CAST(? AS DATE) - INTERVAL '18 years') + THEN %1$s.guardian_client_id + WHEN UPPER(COALESCE(%1$s.account_status, '')) = 'JOINT' + THEN COALESCE((SELECT MIN(j.client_id) FROM m_savings_account_joint_holders j + WHERE j.savings_account_id = %1$s.id AND j.is_primary = true), %1$s.client_id) + ELSE %1$s.client_id + END)""".formatted(sa); + } + + public static boolean isWellFormedPan(final String pan) { + final String p = pan == null ? "" : pan.trim().toUpperCase(); + return PAN.matcher(p).matches() && PAN_HOLDER_TYPES.indexOf(p.charAt(3)) >= 0; + } + + /** Age reached by the last day of the year: 60 at any point in it makes a senior citizen. */ + public static boolean isSeniorInFy(final LocalDate dateOfBirth, final LocalDate fyEnd) { + return dateOfBirth != null && !dateOfBirth.plusYears(60).isAfter(fyEnd); + } + + public static int fyStartYear(final LocalDate date) { + return date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + } + + /** '2026-27' for any date from 1 Apr 2026 to 31 Mar 2027. */ + public static String fyLabel(final int startYear) { + return startYear + "-" + String.format("%02d", (startYear + 1) % 100); + } + + public static BigDecimal roundToMultiple(final BigDecimal value, final int to) { + if (to <= 1) { + return value.setScale(0, RoundingMode.HALF_UP); + } + final BigDecimal step = BigDecimal.valueOf(to); + return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java index c8c6ff120..cce2ef2d2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/api/SpmApiResource.java @@ -18,7 +18,6 @@ */ package org.apache.fineract.spm.api; -import com.google.gson.Gson; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.spm.data.SurveyData; import org.apache.fineract.spm.domain.Survey; @@ -57,6 +60,8 @@ public class SpmApiResource { private final PlatformSecurityContext securityContext; private final SpmService spmService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @Consumes({ MediaType.APPLICATION_JSON }) @@ -103,12 +108,10 @@ public class SpmApiResource { @Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n" + "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) { - this.securityContext.authenticatedUser(); - final Survey survey = SurveyMapper.map(surveyData, new Survey()); - this.spmService.createSurvey(survey); - return getResponse(survey.getId()); - + public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } @PUT @@ -116,12 +119,10 @@ public class SpmApiResource { @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Transactional - public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) { - this.securityContext.authenticatedUser(); - final Survey surveyToUpdate = this.spmService.findById(id); - final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate); - this.spmService.updateSurvey(survey); - return getResponse(survey.getId()); + public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build(); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } @POST @@ -131,22 +132,17 @@ public class SpmApiResource { @Transactional @Operation(summary = "Deactivate Survey", description = "") @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) - public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) { - this.securityContext.authenticatedUser(); + public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) { + final CommandWrapperBuilder builder = new CommandWrapperBuilder(); + final CommandWrapper commandRequest; if (command != null && command.equalsIgnoreCase("activate")) { - this.spmService.activateSurvey(id); + commandRequest = builder.activateSurvey(id).build(); } else if (command != null && command.equalsIgnoreCase("deactivate")) { - this.spmService.deactivateSurvey(id); + commandRequest = builder.deactivateSurvey(id).build(); } else { throw new UnrecognizedQueryParamException("command", command); } - - } - - private String getResponse(Long id) { - Gson gson = new Gson(); - HashMap response = new HashMap<>(); - response.put("resourceId", id); - return gson.toJson(response); + final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + return this.toApiJsonSerializer.serialize(result); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java new file mode 100644 index 000000000..bae02b9da --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/ActivateSurveyCommandHandler.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.spm.service.SpmService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "ACTIVATE") +public class ActivateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + + @Autowired + public ActivateSurveyCommandHandler(final SpmService spmService) { + this.spmService = spmService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + this.spmService.activateSurvey(command.entityId()); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(command.entityId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java new file mode 100644 index 000000000..06c2e162f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/CreateSurveyCommandHandler.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.spm.data.SurveyData; +import org.apache.fineract.spm.domain.Survey; +import org.apache.fineract.spm.service.SpmService; +import org.apache.fineract.spm.util.SurveyMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "CREATE") +public class CreateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + private final ObjectMapper objectMapper; + + @Autowired + public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) { + this.spmService = spmService; + this.objectMapper = objectMapper; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SurveyData surveyData = readSurveyData(command); + final Survey survey = SurveyMapper.map(surveyData, new Survey()); + this.spmService.createSurvey(survey); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(survey.getId()) // + .build(); + } + + private SurveyData readSurveyData(final JsonCommand command) { + try { + return this.objectMapper.readValue(command.json(), SurveyData.class); + } catch (final JsonProcessingException e) { + throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java new file mode 100644 index 000000000..3d9e4bfdc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/DeactivateSurveyCommandHandler.java @@ -0,0 +1,53 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.spm.service.SpmService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "DEACTIVATE") +public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + + @Autowired + public DeactivateSurveyCommandHandler(final SpmService spmService) { + this.spmService = spmService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + this.spmService.deactivateSurvey(command.entityId()); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(command.entityId()) // + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java new file mode 100644 index 000000000..9452e818f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/spm/handler/UpdateSurveyCommandHandler.java @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.spm.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.spm.data.SurveyData; +import org.apache.fineract.spm.domain.Survey; +import org.apache.fineract.spm.service.SpmService; +import org.apache.fineract.spm.util.SurveyMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "SURVEY", action = "UPDATE") +public class UpdateSurveyCommandHandler implements NewCommandSourceHandler { + + private final SpmService spmService; + private final ObjectMapper objectMapper; + + @Autowired + public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) { + this.spmService = spmService; + this.objectMapper = objectMapper; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + final SurveyData surveyData = readSurveyData(command); + final Survey surveyToUpdate = this.spmService.findById(command.entityId()); + final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate); + this.spmService.updateSurvey(survey); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(survey.getId()) // + .build(); + } + + private SurveyData readSurveyData(final JsonCommand command) { + try { + return this.objectMapper.readValue(command.json(), SurveyData.class); + } catch (final JsonProcessingException e) { + throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage()); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java index 9554f8cdb..17475b1fd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/PermissionReadPlatformServiceImpl.java @@ -97,15 +97,50 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform + " order by p.grouping, coalesce(entity_name, ''), p.code"; } + /** + * Permission codes whose approval workflow is a bespoke, unconditional pending-request mechanism + * (m_user_pending_request / m_share_account_pending_edit / m_current_account_pending_edit / + * m_client_pending_edit) that never reads {@code can_maker_checker} - verified by inspecting every + * write-service on this path (UserPendingWritePlatformServiceImpl, ShareAccountEditWritePlatformServiceJpaRepositoryImpl, + * CurrentAccountEditWritePlatformServiceJpaRepositoryImpl, and the client edit equivalent): none of + * them consult ConfigurationDomainService in any way. Toggling this checkbox on the Configure + * Maker-Checker Tasks screen therefore has zero effect on these tasks in either direction, so they + * are excluded here rather than presented as configurable when they are not. CREATE_USER/UPDATE_USER + * remain governed by the separate global "maker-checker" configuration switch (System -> + * Configurations), not by this per-task flag. + */ + private static final String DEAD_MAKER_CHECKER_CODES = "'CREATE_USER','UPDATE_USER'," + + "'SUBMITEDIT_SHAREACCOUNT','APPROVEEDIT_SHAREACCOUNT','REJECTEDIT_SHAREACCOUNT'," + + "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT'," + + "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'"; + + /* + * Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline + * (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a + * different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen + * (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global + * maker-checker on/off switch and other System -> Configurations settings. If either were flipped + * to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin + * could lock themselves out of ever approving further changes to maker-checker configuration + * itself, with no ordinary way to reverse it. Excluding them here only hides them from this + * screen; it does not touch their can_maker_checker value or their normal enforcement. + */ + private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'"; + public String makerCheckerablePermissionSchema() { /* * get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping, - * the READ permissions and the CHECKER permissions + * the READ permissions and the CHECKER permissions), and exclude permission codes whose approval + * workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as + * codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES + * above) to avoid a self-lockout. */ return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected" + " from m_permission p " + " where " + sqlGenerator.escape("grouping") + " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'" + + " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")" + + " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")" + " order by p.grouping, coalesce(entity_name, ''), p.code"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java index 08d2f1038..e767db338 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingReadPlatformServiceImpl.java @@ -36,7 +36,13 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.domain.JdbcSupport; import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper; import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper; import org.apache.fineract.useradministration.data.UserPendingRequestData; +import org.apache.fineract.useradministration.domain.Role; +import org.apache.fineract.useradministration.domain.RoleRepository; import org.apache.fineract.useradministration.domain.UserPendingRequest; import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper; import org.springframework.dao.EmptyResultDataAccessException; @@ -51,8 +57,13 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo private final JdbcTemplate jdbcTemplate; private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper; + private final OfficeRepositoryWrapper officeRepositoryWrapper; + private final StaffRepositoryWrapper staffRepositoryWrapper; + private final RoleRepository roleRepository; private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson(); + private static final String UNKNOWN_REFERENCE_FORMAT = "Unknown (ID: %s)"; + private static final String SELECT_SQL = """ SELECT r.id AS id, r.user_id AS userId, @@ -115,7 +126,96 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo public UserPendingRequestData retrievePendingRequest(final Long id) { final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id); final String sql = SELECT_SQL + " WHERE r.id = ?"; - return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + final UserPendingRequestData data = this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id); + resolveDisplayNames(data); + return data; + } + + /** + * Resolves office/staff/role ids on the pending-user comparison payload to human-readable names, and folds + * the point-in-time original-side name fields (officeName/staffName/roleNames) into their corresponding + * id-keyed rows so the compare dialog shows one row per field instead of a redundant name-only row. + */ + private void resolveDisplayNames(final UserPendingRequestData data) { + final Map originalValues = data.getOriginalValues(); + final Map proposedValues = data.getProposedValues(); + + // CREATE_USER requests have no original snapshot - nothing to fold on that side. + if (originalValues != null && !originalValues.isEmpty()) { + foldNameIntoId(originalValues, "officeName", "officeId"); + foldNameIntoId(originalValues, "staffName", "staffId"); + if (originalValues.containsKey("roleNames")) { + originalValues.put("roles", sortNames(toStringList(originalValues.get("roleNames")))); + originalValues.remove("roleNames"); + } + } + + if (proposedValues != null && !proposedValues.isEmpty()) { + if (proposedValues.get("officeId") instanceof Number officeIdNum) { + proposedValues.put("officeId", resolveOfficeName(officeIdNum.longValue())); + } + if (proposedValues.get("staffId") instanceof Number staffIdNum) { + proposedValues.put("staffId", resolveStaffName(staffIdNum.longValue())); + } + if (proposedValues.get("roles") instanceof Collection rolesCollection) { + proposedValues.put("roles", sortNames(resolveRoleNames(toStringList(rolesCollection)))); + } + } + } + + private static void foldNameIntoId(final Map values, final String nameKey, final String idKey) { + if (values.containsKey(nameKey)) { + values.put(idKey, values.get(nameKey)); + values.remove(nameKey); + } + } + + private String resolveOfficeName(final Long officeId) { + try { + final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); + return office.getName(); + } catch (final RuntimeException e) { + return String.format(UNKNOWN_REFERENCE_FORMAT, officeId); + } + } + + private String resolveStaffName(final Long staffId) { + try { + final Staff staff = this.staffRepositoryWrapper.findOneWithNotFoundDetection(staffId); + return staff.displayName(); + } catch (final RuntimeException e) { + return String.format(UNKNOWN_REFERENCE_FORMAT, staffId); + } + } + + private List resolveRoleNames(final List roleIds) { + final List names = new ArrayList<>(); + for (final String roleIdStr : roleIds) { + try { + final Long roleId = Long.valueOf(roleIdStr); + final Role role = this.roleRepository.findById(roleId).orElse(null); + names.add(role != null ? role.getName() : String.format(UNKNOWN_REFERENCE_FORMAT, roleId)); + } catch (final NumberFormatException e) { + names.add(roleIdStr); + } + } + return names; + } + + private static List sortNames(final List names) { + final List sorted = new ArrayList<>(names); + sorted.sort(String.CASE_INSENSITIVE_ORDER); + return sorted; + } + + private static List toStringList(final Object value) { + final List result = new ArrayList<>(); + if (value instanceof Collection collection) { + for (final Object item : collection) { + result.add(String.valueOf(item)); + } + } + return result; } private static class UserPendingRowMapper implements RowMapper { diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java index 00c235802..16d140cb1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserPendingWritePlatformServiceImpl.java @@ -162,8 +162,8 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null); originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null); originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null); + originalSnapshot.put("staffName", userToUpdate.getStaff() != null ? userToUpdate.getStaff().displayName() : null); originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires()); - originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser()); final Set roleNames = new HashSet<>(); final Set roleIds = new HashSet<>(); if (userToUpdate.getRoles() != null) { @@ -321,6 +321,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat .orElseThrow(() -> new UserNotFoundException(request.getUserId())); final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson()); + if (parsedElement.isJsonObject()) { + // the stored payload keeps a "[PROTECTED]" placeholder for the password rather than + // the real value; the actual new password is applied below via request.getPasswordHash(), + // so these keys must not reach AppUser.update() or it will try to re-encode the placeholder + final JsonObject payloadObject = parsedElement.getAsJsonObject(); + payloadObject.remove("password"); + payloadObject.remove("repeatPassword"); + } final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper); final Map changes = userToUpdate.update(replay, null, null); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index 852255252..0c4578281 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -32,4 +32,5 @@ + 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 new file mode 100644 index 000000000..950b18571 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0012_seed_default_platform_api_client.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_api_client + WHERE api_key_hash = '9d6d4dfa7a1953e33e0acde4fd775aad11689ec540340c7224796447118a3edd' + + + + + + + + + + + + + + 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 545b6450b..8aa8e172d 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 @@ -116,4 +116,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml new file mode 100644 index 000000000..3f5a9552a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_add_sponsor_dd_gl_mapping_permission.xml @@ -0,0 +1,45 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORDDGLMAPPING' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'UPDATE_SPONSORDDGLMAPPING' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml new file mode 100644 index 000000000..a9bcd6109 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_rbi_reserve_config.xml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_RBIRESERVECONFIG', 'UPDATE_RBIRESERVECONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml new file mode 100644 index 000000000..4bf61c077 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml new file mode 100644 index 000000000..cce65c650 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml new file mode 100644 index 000000000..1006408c2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_sponsor_bank_update_permission.xml @@ -0,0 +1,44 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'UPDATE_SPONSORBANK' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p.id + FROM m_role_permission rp + JOIN m_permission existing ON existing.id = rp.permission_id AND existing.code = 'CREATE_SPONSORBANK' + CROSS JOIN m_permission p + WHERE p.code = 'UPDATE_SPONSORBANK' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml new file mode 100644 index 000000000..0f630d12a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_create_oss_return_permissions.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_OSSRETURN' + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code = 'READ_OSSRETURN' + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code = 'READ_OSSRETURN' + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml new file mode 100644 index 000000000..119cefcc6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml new file mode 100644 index 000000000..fdcbd37d7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_drop_not_null_on_sponsor_dd_mapping_payable.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml new file mode 100644 index 000000000..ac4917332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml new file mode 100644 index 000000000..5abce7bf0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_rbi_reserve_ndtl_weighting.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_rbi_reserve_gl_config + SET liability_class = 'TIME' + WHERE config_key = 'DEMAND_TIME_LIABILITIES' + AND liability_class IS NULL + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_rate WHERE rate_type = 'SAVINGS_DEMAND' + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml new file mode 100644 index 000000000..3f436b0c6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0575_rbi_reserve_custom_roles.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role + ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'MEMO')) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml new file mode 100644 index 000000000..c7584302c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0576_widen_rbi_reserve_rate_type.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml new file mode 100644 index 000000000..95a32eb2d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0577_add_current_product_od_scheme_fields.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml new file mode 100644 index 000000000..09bf7ed47 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0578_grant_cashier_read_codevalue.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_CODE', 'READ_CODEVALUE') + AND LOWER(r.name) LIKE '%cashier%' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml new file mode 100644 index 000000000..aadb1bc9e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0579_current_product_gl_mappings_own_product_type.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id NOT IN (SELECT sp.id FROM m_savings_product sp)) x) + + + + + + + + + + + + UPDATE acc_product_mapping SET product_type = 6 + WHERE id IN (SELECT id FROM ( + SELECT m.id FROM acc_product_mapping m + WHERE m.product_type = 2 + AND m.product_id IN (SELECT cp.id FROM m_current_product cp) + AND m.product_id IN (SELECT sp.id FROM m_savings_product sp) + AND m.financial_account_type <> 26 + AND m.id > (SELECT MAX(g.min_id) FROM ( + SELECT d.product_id, d.financial_account_type, MIN(d.id) AS min_id + FROM acc_product_mapping d + WHERE d.product_type = 2 AND d.payment_type IS NULL AND d.charge_id IS NULL + GROUP BY d.product_id, d.financial_account_type + HAVING COUNT(*) > 1) g + WHERE g.product_id = m.product_id)) x) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml new file mode 100644 index 000000000..499aa2d59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_unique_core_product_gl_mapping.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + SELECT COUNT(*) FROM ( + SELECT 1 FROM acc_product_mapping + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + GROUP BY product_type, product_id, financial_account_type + HAVING COUNT(*) > 1) d + + + + + CREATE UNIQUE INDEX uq_acc_product_mapping_core + ON acc_product_mapping (product_type, product_id, financial_account_type) + WHERE payment_type IS NULL AND charge_id IS NULL AND charge_off_reason_id IS NULL + AND capitalized_income_classification_id IS NULL AND buydown_fee_classification_id IS NULL + AND write_off_reason_id IS NULL + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml new file mode 100644 index 000000000..eb135588b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_form_a_completion.xml @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_MEMO_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORMA_ANNEX%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_RBIFORMAFILING', 'UPDATE_RBIFORMAFILING') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml new file mode 100644 index 000000000..f6e04242e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_create_tds_config.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_tds_fy_config + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_TDSCONFIG', 'UPDATE_TDSCONFIG') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml new file mode 100644 index 000000000..be760cbf3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0582_form_a_saved_report.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_add_loan_originator_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_add_loan_originator_checker_permissions.xml new file mode 100644 index 000000000..9b771834e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_add_loan_originator_checker_permissions.xml @@ -0,0 +1,79 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_LOAN_ORIGINATOR_CHECKER' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, checker.id + FROM m_role_permission rp + JOIN m_permission base ON base.id = rp.permission_id + JOIN m_permission checker ON checker.code = base.code || '_CHECKER' + WHERE base.code IN ('CREATE_LOAN_ORIGINATOR', 'UPDATE_LOAN_ORIGINATOR', 'DELETE_LOAN_ORIGINATOR', + 'ATTACH_LOAN_ORIGINATOR', 'DETACH_LOAN_ORIGINATOR') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml new file mode 100644 index 000000000..50efebc34 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0583_save_rbi_form_a_return_job.xml @@ -0,0 +1,72 @@ + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Save RBI Form A Return' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml new file mode 100644 index 000000000..68e5330ab --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_add_manage_survey_permissions.xml @@ -0,0 +1,68 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_SURVEY' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + JOIN m_permission p ON p.code IN ('CREATE_SURVEY', 'UPDATE_SURVEY', 'ACTIVATE_SURVEY', 'DEACTIVATE_SURVEY') + WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = r.id AND rp2.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml new file mode 100644 index 000000000..650b39c02 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0584_form9_slr_assets.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage; + ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'MEMO')); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key = 'FORMA_SLR_GOLD' + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role + WHERE config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', + 'FORMA_INV_OTHER_APPROVED') + + + + + + + + config_key IN ('FORMA_INV_CENTRAL_GOVT', 'FORMA_INV_STATE_GOVT', 'FORMA_INV_OTHER_APPROVED') + AND role_usage = 'MEMO' + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml new file mode 100644 index 000000000..9cbdd9553 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_add_loan_cob_catchup_permission.xml @@ -0,0 +1,44 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'EXECUTE_LOANCOBCATCHUP' + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + JOIN m_permission p ON p.code = 'EXECUTE_LOANCOBCATCHUP' + WHERE (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = r.id AND rp2.permission_id = p.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_form1_statutory_heads.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_form1_statutory_heads.xml new file mode 100644 index 000000000..1f6cdd26b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0585_form1_statutory_heads.xml @@ -0,0 +1,240 @@ + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml new file mode 100644 index 000000000..150bffec5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_add_system_module_checker_permissions.xml @@ -0,0 +1,107 @@ + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_ACCOUNTNUMBERFORMAT_CHECKER' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, checker.id + FROM m_role_permission rp + JOIN m_permission base ON base.id = rp.permission_id + JOIN m_permission checker ON checker.code = base.code || '_CHECKER' + WHERE base.code IN ('CREATE_ACCOUNTNUMBERFORMAT', 'UPDATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT', + 'CREATE_ENTITYMAPPING', 'UPDATE_ENTITYMAPPING', 'DELETE_ENTITYMAPPING', + 'CREATE_USERAGREEMENT', 'UPDATE_BATCH_BUSINESS_STEP') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp2 + WHERE rp2.role_id = rp.role_id AND rp2.permission_id = checker.id + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml new file mode 100644 index 000000000..9e5c2a7c5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0586_form9_return_heads.xml @@ -0,0 +1,908 @@ + + + + + + + + + + + + + ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage; + ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage + CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'ASSET', 'MEMO')); + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml new file mode 100644 index 000000000..5e2372200 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_fix_current_product_od_scheme_pending_columns.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml new file mode 100644 index 000000000..916a4bc4b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0587_form9_default_mappings.xml @@ -0,0 +1,212 @@ + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_%' + + + + + -- 1(a) Paid up capital - Individuals. Both share accounts: in a co-operative bank the + -- linking share and the ordinary share together ARE the members' paid up capital. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L1_A_CAPITAL_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('300101', '300102'); + + -- 2(a) Statutory Reserves. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_A_STATUTORY_RESERVE', id, 100, now() FROM acc_gl_account + WHERE gl_code = '300902'; + + -- 2(c) Dividend Equalisation Fund. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_C_DIVIDEND_EQUALISATION', id, 100, now() FROM acc_gl_account + WHERE gl_code = '300901'; + + -- 2(e) Bad and Doubtful Debt Reserve. The NPA provision AND the standard-asset + -- provision, because the form's line is the whole reserve held against advances - not + -- the bad-debt account alone. On the filed return this was drawn from, mapping only one + -- of them would have understated the line by three quarters. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('500501', '500502'); + + -- 2(g) Other Funds and Reserves - every other fund the bank carries. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200905'; + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L4_%' + + + + + -- 4(a)(iv) Current deposits from individuals, firms and associations. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_A4_CURRENT_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200102'; + + -- 4(b)(iv) Savings deposits, DEMAND share. + -- + -- Split 11/89 against 5(b)(iv), which is the split the filed return this was drawn from + -- carries. It is NOT the 20/80 Form 1 uses: that return charges its ratios on a + -- differently drawn base, and the two splits are settled separately with the bank's + -- auditors. The two shares here must always total 100, or the return reports less in + -- savings than the bank holds. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', id, 11, now() FROM acc_gl_account + WHERE gl_code = '200101'; + + -- 4(d) Other Demand Liabilities - everything owed on demand that is not a deposit. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L4_D_OTHER_DEMAND', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('200203', '200204', '200401', '200402', '200501', '200502', '200503', + '200504', '200505', '200506', '200901', '200902', '200903', '200904', + '898966'); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L5_%' + + + + + -- 5(a)(iv) Fixed deposits from individuals, firms and associations. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_A4_FD_INDIVIDUALS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200103'; + + -- 5(b)(iv) Savings deposits, TIME share - the remainder of the account. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', id, 89, now() FROM acc_gl_account + WHERE gl_code = '200101'; + + -- 5(d) Cash certificates and recurring deposits. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_D_CASH_CERT_RD', id, 100, now() FROM acc_gl_account + WHERE gl_code = '200201'; + + -- 5(e) Other Time Liabilities - interest accrued on deposits but not yet paid. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L5_E_OTHER_TIME', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('200202', '200301', '200302', '200303'); + + -- 9. Balance of Profit. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L9_BALANCE_OF_PROFIT', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('300903', 'BANKPROFIT01'); + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_A%' + + + + + -- 1. Cash in Hand - vault and teller balances. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A1_CASH_IN_HAND', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100101', '100102'); + + -- 2(a) Balance with the Reserve Bank of India. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A2_A_CUR_RBI', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100203'; + + -- 2(c) Balance with the Banking System - the commercial banks the bank keeps operating + -- accounts with. Co-operative banks have their own lines, which this chart has no + -- accounts for. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A2_C_CUR_BANKING_SYSTEM', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100201', '100202'); + + -- 6(f)(i) Short term advances for other purposes - cash credit, loans against deposits + -- and warehouse receipt finance. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_SHORT', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100301', '100302', '100303'); + + -- 6(f)(ii) Medium term advances - personal loans. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_MEDIUM', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100402'; + + -- 6(f)(iii) Long term advances - loans against property and house loans. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A6_F_OTHER_LONG', id, 100, now() FROM acc_gl_account + WHERE gl_code IN ('100401', '100501'); + + -- 8. Interest Receivable. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A8_INTEREST_RECEIVABLE', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100601'; + + -- 11(i) Other Assets, tangible - advance tax and tax deducted at source recoverable. + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_A11_I_TANGIBLE_ASSETS', id, 100, now() FROM acc_gl_account + WHERE gl_code = '100602'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml new file mode 100644 index 000000000..e7e35d6b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_add_premature_closure_reason.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml new file mode 100644 index 000000000..5d2997caf --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0588_form9_map_dividend_round_off.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + SELECT COUNT(*) FROM m_rbi_reserve_gl_config c + JOIN acc_gl_account a ON a.id = c.gl_account_id + WHERE a.gl_code = 'DIVROUNDOFF01' + + + + + INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on) + SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account + WHERE gl_code = 'DIVROUNDOFF01'; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml new file mode 100644 index 000000000..1e42440c8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0589_savings_interest_posting_report.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Generate Savings Interest Posting Report' + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_SAVINGSINTERESTREPORT', + 'REGENERATE_SAVINGSINTERESTREPORT', 'APPROVE_SAVINGSINTERESTREPORT') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT', + 'APPROVE_SAVINGSINTERESTREPORT') + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT', + 'APPROVE_SAVINGSINTERESTREPORT') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml new file mode 100644 index 000000000..7695a23d6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0590_savings_interest_posting_report_by_branch.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_savings_interest_posting_report r + SET office_id = (SELECT MIN(l.office_id) FROM m_savings_interest_posting_report_line l WHERE l.report_id = r.id) + WHERE r.office_id IS NULL + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml new file mode 100644 index 000000000..15523b7b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0591_cashier_allocation_time_and_deposit_counter_office.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + UPDATE m_cashiers mc + SET created_at = (SELECT CAST(MIN(cs.made_on_date_utc) AS TIMESTAMP) + FROM m_portfolio_command_source cs + WHERE cs.entity_name = 'TELLER' AND cs.action_name = 'ALLOCATECASHIER' + AND cs.subresource_id = mc.id) + + + + + + + + + + + + + + + UPDATE m_cashier_deposit_request r + SET counter_office_id = COALESCE( + (SELECT t.office_id FROM m_appuser au + JOIN m_cashiers c ON c.staff_id = au.staff_id + JOIN m_tellers t ON t.id = c.teller_id + WHERE au.id = r.cashier_id + AND (c.created_at IS NULL OR r.submitted_at IS NULL OR r.submitted_at >= c.created_at) + LIMIT 1), + (SELECT au.office_id FROM m_appuser au WHERE au.id = r.cashier_id), + r.office_id) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml new file mode 100644 index 000000000..ae07901d5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_enable_teller_cashier_maker_checker.xml @@ -0,0 +1,33 @@ + + + + + + + + code IN ( + 'ALLOCATECASHIER_TELLER', + 'ALLOCATECASHTOCASHIER_TELLER', + 'SETTLECASHFROMCASHIER_TELLER', + 'UPDATECASHIERALLOCATION_TELLER' + ) + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml new file mode 100644 index 000000000..4b7beb274 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0592_tds_declarations_and_challans.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission + WHERE code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_role_permission rp + JOIN m_permission p ON p.id = rp.permission_id + WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION', 'CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('CREATE_TDSDECLARATION', 'UPDATE_TDSDECLARATION'); + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE p.code IN ('CREATE_TDSCHALLAN', 'UPDATE_TDSCHALLAN') + AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user'); + + + + + + + + + + INSERT INTO m_tds_declaration (client_id, fy_label, form_type, accepted_on, is_revoked, created_on) + SELECT k.client_id, k.form15_fy, UPPER(k.form15_type), + TO_DATE(SUBSTRING(k.form15_fy FROM 1 FOR 4) || '-04-01', 'YYYY-MM-DD'), false, now() + FROM m_client_kyc k + WHERE k.form15_applicable = true + AND k.form15_fy ~ '^[0-9]{4}-[0-9]{2}$' + AND UPPER(k.form15_type) IN ('15G', '15H', '121') + AND COALESCE(k.is_deleted, false) = false; + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml new file mode 100644 index 000000000..4c9f0dfb4 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0593_tds_interest_receipts_and_deductions.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index edb6c2d02..2758c91da 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -674,7 +674,7 @@ public final class FixedDepositAccountData extends DepositAccountData { public static FixedDepositAccountData preClosureDetails(final Long accountId, BigDecimal maturityAmount, final Collection onAccountClosureOptions, final Collection paymentTypeOptions, - final Collection savingsAccountDatas) { + final Collection savingsAccountDatas, final BigDecimal effectiveInterestRate) { final Long groupId = null; final String groupName = null; @@ -689,7 +689,12 @@ public final class FixedDepositAccountData extends DepositAccountData { final SavingsAccountStatusEnumData status = null; final SavingsAccountApplicationTimelineData timeline = null; final CurrencyData currency = null; - final BigDecimal nominalAnnualInterestRate = null; + // Not the account's stored contracted rate -- the rate actually resolved for THIS premature + // closure preview (see FixedDepositAccount#getEffectiveInterestRateAsFraction, read back + // straight after the calculation by the caller), so a maker can see which of the RBI-default + // actual-period rate, a bank's "use contracted rate" override, and any penalty deduction ended + // up applying, rather than only the final rupee amount. + final BigDecimal nominalAnnualInterestRate = effectiveInterestRate; final EnumOptionData interestPeriodType = null; final EnumOptionData interestPostingPeriodType = null; final EnumOptionData interestCalculationType = null; 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 53c2e9199..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) { @@ -496,6 +499,7 @@ public final class FixedDepositProductData extends DepositProductData { private Boolean accountClosedZeroBalanceInDayClosing; private String balanceFrequency; private Boolean isPendingApproval; + private Integer renewalGracePeriodDays; // Template option setters public void setInterestCalculationTypeOption(Collection v) { this.interestCalculationTypeOption = v; } @@ -543,6 +547,7 @@ public final class FixedDepositProductData extends DepositProductData { public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } + public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; } // CBS field getters public String getProductCode() { return productCode; } @@ -559,6 +564,7 @@ public final class FixedDepositProductData extends DepositProductData { public Long getDaysToInactive() { return daysToInactive; } public Long getDaysToDormancy() { return daysToDormancy; } public Long getDaysToEscheat() { return daysToEscheat; } + public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; } public void copyCbsFieldsFrom(FixedDepositProductData src) { if (src == null) return; @@ -605,6 +611,9 @@ public final class FixedDepositProductData extends DepositProductData { this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; 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/data/SavingsProductAccountingDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java index 772c96b2a..b9dee877d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductAccountingDataValidator.java @@ -49,8 +49,10 @@ public class SavingsProductAccountingDataValidator { .notNull().integerGreaterThanZero(); } + // A current product maps no cash GL: its deposits and withdrawals post cash to the branch cash GL + // (Financial Activity mapping), so Savings Reference is validated there only when supplied. if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && !accountType.equals(DepositAccountType.CURRENT_DEPOSIT))) { final Long savingsReferenceAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()) @@ -64,8 +66,11 @@ public class SavingsProductAccountingDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue()) .value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero(); } + // A current account pays no interest, so its product has no Interest On Savings expense account: + // validated there only when actually supplied. + final boolean paysInterest = !accountType.equals(DepositAccountType.CURRENT_DEPOSIT); if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element) - || ignoreExistenceValidation) { + || (ignoreExistenceValidation && paysInterest)) { final Long interestOnSavingsAccountId = fromApiJsonHelper .extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element); baseDataValidator.reset().parameter(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue()) diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java index 0f3e8922b..1755d4a76 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/CurrentOnlyEditFields.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.savings.domain; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import java.math.BigDecimal; import lombok.Getter; import lombok.Setter; @@ -36,4 +37,19 @@ public class CurrentOnlyEditFields { @Column(name = "od_account_closed_zero_balance_in_day_closing") private Boolean odAccountClosedZeroBalanceInDayClosing; + + @Column(name = "od_min_limit") + private BigDecimal odMinLimit; + + @Column(name = "od_security_type") + private String odSecurityType; + + @Column(name = "od_excess_allowed") + private Boolean odExcessAllowed; + + @Column(name = "od_excess_limit_percent") + private BigDecimal odExcessLimitPercent; + + @Column(name = "od_penal_rate") + private BigDecimal odPenalRate; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index 0490f3d09..136c60a45 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -45,6 +45,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; @@ -118,6 +119,20 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom submitted, final String name, final String rawJson) { + if (!submitted.contains(name)) { + return; + } + if (rawJson == null || rawJson.isBlank()) { + root.add(name, com.google.gson.JsonNull.INSTANCE); + return; + } + root.add(name, com.google.gson.JsonParser.parseString(rawJson)); + } + private static void putNumber(final JsonObject root, final Set submitted, final String name, final Number value) { if (submitted.contains(name)) { root.addProperty(name, value); @@ -771,6 +797,26 @@ public final class ProductPendingEditFieldMapper { f.setOdAccountClosedZeroBalanceInDayClosing(command.booleanObjectValueOfParameterNamed("odAccountClosedZeroBalanceInDayClosing")); present.add("odAccountClosedZeroBalanceInDayClosing"); } + if (command.hasParameter("odMinLimit")) { + f.setOdMinLimit(command.bigDecimalValueOfParameterNamed("odMinLimit")); + present.add("odMinLimit"); + } + if (command.hasParameter("odSecurityType")) { + f.setOdSecurityType(command.stringValueOfParameterNamed("odSecurityType")); + present.add("odSecurityType"); + } + if (command.hasParameter("odExcessAllowed")) { + f.setOdExcessAllowed(command.booleanObjectValueOfParameterNamed("odExcessAllowed")); + present.add("odExcessAllowed"); + } + if (command.hasParameter("odExcessLimitPercent")) { + f.setOdExcessLimitPercent(command.bigDecimalValueOfParameterNamed("odExcessLimitPercent")); + present.add("odExcessLimitPercent"); + } + if (command.hasParameter("odPenalRate")) { + f.setOdPenalRate(command.bigDecimalValueOfParameterNamed("odPenalRate")); + present.add("odPenalRate"); + } return present; } @@ -781,6 +827,11 @@ public final class ProductPendingEditFieldMapper { putString(root, submitted, "avgBalanceCalcPeriod", f.getAvgBalanceCalcPeriod()); putString(root, submitted, "validForPeriod", f.getValidForPeriod()); putBoolean(root, submitted, "odAccountClosedZeroBalanceInDayClosing", f.getOdAccountClosedZeroBalanceInDayClosing()); + putNumber(root, submitted, "odMinLimit", f.getOdMinLimit()); + putString(root, submitted, "odSecurityType", f.getOdSecurityType()); + putBoolean(root, submitted, "odExcessAllowed", f.getOdExcessAllowed()); + putNumber(root, submitted, "odExcessLimitPercent", f.getOdExcessLimitPercent()); + putNumber(root, submitted, "odPenalRate", f.getOdPenalRate()); } public static Set populateShareOnlyFields(final JsonCommand command, final ShareOnlyEditFields f) { @@ -942,6 +993,10 @@ public final class ProductPendingEditFieldMapper { f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount")); present.add("depositAmount"); } + if (command.hasParameter("renewalGracePeriodDays")) { + f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays")); + present.add("renewalGracePeriodDays"); + } return present; } @@ -962,6 +1017,7 @@ public final class ProductPendingEditFieldMapper { if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount()); if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount()); if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount()); + if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays()); } public static Set populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index c441155be..2b48e91c1 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -795,6 +795,29 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return false; } + /** + * Deducts TDS from this savings account on FD/RD interest that has just been credited to it. + * + *

+ * Whether anything is due, and how much, is decided outside the entity - the threshold is tested on + * the customer's interest across the bank and a declaration on record switches it off. This only + * posts the withhold-tax debit and brings the running balances and summary up to date, as any other + * transaction on the account would. + * + * @param taxSplit + * the amount per tax component; the component's credit account is where the accounting + * entry sends the tax + */ + public SavingsAccountTransaction deductTds(final LocalDate date, final BigDecimal amount, final Map taxSplit) { + final SavingsAccountTransaction tds = SavingsAccountTransaction.withHoldTax(this, office(), date, Money.of(this.currency, amount), + taxSplit); + addTransaction(tds); + final LocalDate businessDate = DateUtils.getBusinessLocalDate(); + recalculateDailyBalances(Money.zero(this.currency), DateUtils.isAfter(date, businessDate) ? date : businessDate, false, false); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + return tds; + } + protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { SavingsAccountTransaction postingTransation = null; List trans = getTransactions(); 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/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 979941229..3c18db4df 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -92,5 +92,41 @@ public interface DepositAccountWritePlatformService { void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType); + /** + * Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if + * still within the product's renewal grace window (0 days if none is configured), or from today + * with a gap-period interest credit (at the linked Savings account's product rate) if not. + * {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and + * Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked + * Savings account); the account's own stored maturity instruction, if any, is not consulted. + * Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean), + * gapInterestAmount (BigDecimal, present only when backdated is false). + */ + Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); + + /** + * Credits gap-period interest (simple interest, at the linked Savings account's product rate) onto + * that LINKED SAVINGS ACCOUNT -- not the FD -- if the FD's product's renewal grace window has + * passed; a no-op, returning zero, if still within it. Paid to the linked account because it was + * earned at that account's rate, not the FD's own; the FD's own balance is left untouched, so it + * renews from its real maturity amount regardless of which closure type follows. Called as a + * pre-step before the FD Dashboard's Renew action submits the generic `payment` command (the same + * one the account's own Account Actions menu uses), so every closure type is available from the + * Dashboard, not just the two {@link #renewMaturedFixedDeposit} itself supports. Returns + * {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + */ + Map creditGapInterestIfPastGrace(Long fdAccountId); + + /** + * Read-only preview of what renewing this matured FD right now would do -- shown in the FD + * Dashboard's Payment dialog before staff confirm anything, never posts anything itself. Returns + * isMatured (false short-circuits the rest), maturityDate, renewalGracePeriodDays, withinGrace, + * currentBalance, gapDays, and -- only once past grace -- linkedAccountId, linkedAccountNo, + * linkedAccountRate, gapInterestAmount (paid to the linked account, not this FD) and + * projectedBalance (the FD's own current balance, unchanged by the gap interest -- what actually + * renews). + */ + Map previewFdRenewal(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); } 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); + } + } } } diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml index 95f04b653..b90805f5e 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/module-changelog-master.xml @@ -55,4 +55,5 @@ + diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml new file mode 100644 index 000000000..81efbf517 --- /dev/null +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2033_add_current_product_od_scheme_pending_columns.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +