Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7ff4e4a7b | ||
|
|
7068d65422 | ||
|
|
fd827dec27 | ||
|
|
aa264097e7 | ||
|
|
16c67f6701 | ||
|
|
e846892e9f | ||
|
|
56d98bac8d | ||
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
e02ab51ee1 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 | ||
|
|
24d3bf83bb | ||
|
|
af052dcb4f | ||
|
|
b12268e740 | ||
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
2155c94161 | ||
|
|
87f3e591ce | ||
|
|
1e903232f0 | ||
|
|
2c49d6f637 | ||
|
|
331c553164 | ||
|
|
3fb008a22a | ||
|
|
8b2fb052d4 | ||
|
|
cebba87864 | ||
|
|
ea39c8e82a | ||
|
|
32411c924b | ||
|
|
f696499102 | ||
|
|
7eabaf2542 | ||
|
|
4b9093007f | ||
|
|
9beae3df28 | ||
|
|
d2e13cc7b9 | ||
|
|
ecfe8aa76a | ||
|
|
a7cb09f15c | ||
|
|
26d4c241c7 | ||
|
|
dbc2737ada | ||
|
|
f457b9b8c7 | ||
|
|
23ee4f1063 | ||
|
|
bc38216ca3 |
+2
@@ -38,6 +38,8 @@ public interface ProductToGLAccountMappingReadPlatformService {
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForSavingsProduct(Long savingsProductId, Integer accountingType);
|
||||
|
||||
Map<String, Object> fetchAccountMappingDetailsForCurrentProduct(Long currentProductId, Integer accountingType);
|
||||
|
||||
List<PaymentTypeToGLAccountMapper> fetchPaymentTypeToFundSourceMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
List<ChargeToGLAccountMapper> fetchFeeToIncomeAccountMappingsForSavingsProduct(Long savingsProductId);
|
||||
|
||||
+16
@@ -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<String, Object> fetchAccountMappingDetailsForCurrentProduct(final Long currentProductId, final Integer accountingType) {
|
||||
final List<ProductToGLAccountMapping> 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<PaymentTypeToGLAccountMapper> 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);
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+2
@@ -33,4 +33,6 @@ public interface TellerRepository extends JpaRepository<Teller, Long>, JpaSpecif
|
||||
Collection<Teller> findTellerByOfficeId(Long officeId);
|
||||
|
||||
long countByDeletedFalse();
|
||||
|
||||
long countByOfficeIdAndDeletedFalse(Long officeId);
|
||||
}
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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.
|
||||
*
|
||||
|
||||
+10
-20
@@ -137,7 +137,16 @@ public class CommandSourceService {
|
||||
String permission = commandSource.getPermissionCode();
|
||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||
// isApprovedByChecker is only ever true here for a genuine checker approval
|
||||
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
|
||||
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
|
||||
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
|
||||
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
|
||||
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
|
||||
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
|
||||
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
|
||||
// auto-approve their own submission just by holding it.
|
||||
if (isApprovedByChecker) {
|
||||
commandSource.markAsChecked(user);
|
||||
} else {
|
||||
if (commandSource.isSanitized()) {
|
||||
@@ -151,25 +160,6 @@ public class CommandSourceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||
if (!user.isCheckerSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||
return;
|
||||
|
||||
+111
@@ -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";
|
||||
@@ -3390,6 +3462,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";
|
||||
@@ -4730,4 +4809,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;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-8
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandProcessingService processAndLogCommandService;
|
||||
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
|
||||
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
|
||||
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
|
||||
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
|
||||
// live, independently-configurable global switch (System -> Configurations); deleting this field,
|
||||
// and by extension deciding that switch should stop existing altogether, is a separate decision
|
||||
// from closing this specific self-approval loophole.
|
||||
private final ConfigurationDomainService configurationService;
|
||||
|
||||
@Override
|
||||
@@ -123,14 +129,20 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
|
||||
AppUser appUser = this.context.authenticatedUser();
|
||||
String permissionCode = commandSource.getPermissionCode();
|
||||
appUser.validateHasCheckerPermissionTo(permissionCode);
|
||||
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
}
|
||||
// Maker-identity rule: the user who submitted a command may never be the one who approves
|
||||
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
|
||||
// permission still lets its holder action a DIFFERENT user's request without needing the
|
||||
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
|
||||
// longer lets them action their own) and not overridable by the enable-same-maker-checker
|
||||
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
|
||||
// configuration is left in place and unused for this decision rather than removed, since
|
||||
// deleting a still-configurable global setting is a separate decision from this one).
|
||||
AppUser maker = commandSource.getMaker();
|
||||
if (maker == null) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
|
||||
}
|
||||
if (Objects.equals(appUser.getId(), maker.getId())) {
|
||||
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
|
||||
}
|
||||
return commandSource;
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+12
-11
@@ -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<CurrencyUpdateRequest> 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<CurrencyUpdateResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -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<String> 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();
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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;
|
||||
}
|
||||
|
||||
+19
-29
@@ -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<PaymentTypeData> 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<PaymentTypeCreateResponse> 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<PaymentTypeUpdateResponse> 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<PaymentTypeDeleteResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return jsonSerializer.serialize(result);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-17
@@ -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<PaymentTypeCreateRequest, PaymentTypeCreateResponse> {
|
||||
@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<PaymentTypeCreateRequest> command) {
|
||||
return paymentTypeWriteService.createPaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeCreateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeCreateResponse fallback(Command<PaymentTypeCreateRequest> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+25
-17
@@ -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<PaymentTypeDeleteRequest, PaymentTypeDeleteResponse> {
|
||||
@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<PaymentTypeDeleteRequest> command) {
|
||||
return paymentTypeWriteService.deletePaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeDeleteCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeDeleteResponse fallback(Command<PaymentTypeDeleteRequest> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+35
-17
@@ -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<PaymentTypeUpdateRequest, PaymentTypeUpdateResponse> {
|
||||
@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<PaymentTypeUpdateRequest> command) {
|
||||
return paymentTypeWriteService.updatePaymentType(command.getPayload());
|
||||
@Autowired
|
||||
public PaymentTypeUpdateCommandHandler(final PaymentTypeWriteService paymentTypeWriteService) {
|
||||
this.paymentTypeWriteService = paymentTypeWriteService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public PaymentTypeUpdateResponse fallback(Command<PaymentTypeUpdateRequest> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-1
@@ -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();
|
||||
|
||||
+25
-3
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
|
||||
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
|
||||
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
|
||||
public static final String tenureInMonthsParamName = "tenureInMonths";
|
||||
public static final String tenureInDaysParamName = "tenureInDays";
|
||||
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
|
||||
|
||||
// Preclosure parameters
|
||||
@@ -142,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";
|
||||
@@ -208,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";
|
||||
@@ -237,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<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
|
||||
@@ -247,11 +262,13 @@ public final class DepositsApiConstants {
|
||||
|
||||
private static final Set<String> 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<String> 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<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
|
||||
@@ -373,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;
|
||||
}
|
||||
|
||||
@@ -484,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;
|
||||
}
|
||||
|
||||
@@ -511,6 +532,7 @@ public final class DepositsApiConstants {
|
||||
fixedDepositInterestCalculationData.add(principalAmountParamName);
|
||||
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
|
||||
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
|
||||
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
|
||||
// Exact calendar-based calculation params
|
||||
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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";
|
||||
|
||||
+10
@@ -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<String, Object> 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());
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+7
-1
@@ -38,6 +38,12 @@ public interface CurrentAccountRepository extends JpaRepository<CurrentAccount,
|
||||
@Query("SELECT ca FROM CurrentAccount ca WHERE ca.client.id = :clientId AND ca.statusEnum != 600")
|
||||
List<CurrentAccount> 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);
|
||||
}
|
||||
|
||||
+15
-1
@@ -46,7 +46,7 @@ public class CurrentAccountTransaction extends AbstractPersistableCustom<Long> {
|
||||
@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<Long> {
|
||||
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) {
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+112
-16
@@ -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<String> 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<String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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<Long> {
|
||||
@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<Long> {
|
||||
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<Long> {
|
||||
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; }
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
|
||||
+115
@@ -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}.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<String, Integer> 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<String, Object> write(final Long productId, final JsonCommand command, final Integer accountingRule) {
|
||||
final Map<String, Object> 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<String, Integer> 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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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;
|
||||
|
||||
+6
-6
@@ -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<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService
|
||||
.updateSavingsProductToGLAccountMapping(product.getId(), command, accountingTypeChanged, product.getAccountingType(),
|
||||
DepositAccountType.SAVINGS_DEPOSIT);
|
||||
final Map<String, Object> 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()) //
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
|
||||
"glAccountId", glAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to draw more out of a holding account than it holds.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1746
-1618
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -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 ")
|
||||
|
||||
+12
@@ -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:
|
||||
|
||||
+3
@@ -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<LoanCOBCatchUpService> 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();
|
||||
|
||||
+49
-8
@@ -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()
|
||||
|
||||
+20
-14
@@ -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<String, Object> 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
|
||||
|
||||
+6
-1
@@ -130,7 +130,12 @@ public class TellerManagementReadPlatformServiceImpl implements TellerManagement
|
||||
|
||||
@Override
|
||||
public Collection<TellerData> 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
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+12
-15
@@ -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<WorkingDaysUpdateRequest> 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<WorkingDaysUpdateResponse> response = commandPipeline.send(command);
|
||||
|
||||
return response.get();
|
||||
return toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
|
||||
+29
-21
@@ -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<WorkingDaysUpdateRequest, WorkingDaysUpdateResponse> {
|
||||
@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<WorkingDaysUpdateRequest> command) {
|
||||
WorkingDaysUpdateRequest request = command.getPayload();
|
||||
Map<String, Object> 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<String, Object> changes = this.workingDaysWritePlatformService.updateWorkingDays(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.with(changes) //
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+152
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<Map<String, Object>> 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);
|
||||
}
|
||||
}
|
||||
+26
-21
@@ -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;
|
||||
@@ -100,6 +101,7 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
private final CurrentAccountRepository currentAccountRepository;
|
||||
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
|
||||
private final AccountingProcessorHelper accountingProcessorHelper;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -472,7 +474,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 +519,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 +547,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 +683,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 +703,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 +715,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 +735,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 +744,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 +757,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 +792,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 +803,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);
|
||||
}
|
||||
}
|
||||
|
||||
+141
-140
@@ -1,140 +1,141 @@
|
||||
/**
|
||||
* 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.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) {
|
||||
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
|
||||
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
|
||||
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
|
||||
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
|
||||
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper, kycRestrictionGuard);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
+80
-17
@@ -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 ──────────────────────────────
|
||||
@@ -191,8 +209,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 +218,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);
|
||||
@@ -589,6 +599,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<String> 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<String> 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 +723,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<Long> 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<String> 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
|
||||
@@ -1004,6 +1062,9 @@ public class BranchDayRegisterApiResource {
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
final Map<String, Object> 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 +1118,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 --
|
||||
|
||||
+170
-16
@@ -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);
|
||||
|
||||
@@ -418,13 +429,26 @@ public class CashierDepositRequestApiResource {
|
||||
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
|
||||
amount, ptId, txnDateStr, description, true);
|
||||
} else {
|
||||
// Determine real account type by checking which table owns this ID.
|
||||
// Determine real account type by checking which table/column owns this ID.
|
||||
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
|
||||
// even for current accounts (submitted before the frontend fix).
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
// 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.
|
||||
// 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);
|
||||
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
case "RECURRING_DEPOSIT" ->
|
||||
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
};
|
||||
if (!isCurrentAccount && txnId != null) {
|
||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||
@@ -571,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.");
|
||||
}
|
||||
|
||||
@@ -666,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<Long> 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<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, account_no, account_type, savings_account_id,
|
||||
@@ -718,6 +757,64 @@ public class CashierDepositRequestApiResource {
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeFixedDepositDeposit(
|
||||
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 date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.fixedDepositAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeRecurringDepositDeposit(
|
||||
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 date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.recurringAccountDeposit(accountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
|
||||
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
|
||||
@@ -798,17 +895,65 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
|
||||
* rather than the request's own account_type string. FD and RD accounts live in the same
|
||||
* 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, final String expectedAccountNo) {
|
||||
if (accountId == null) {
|
||||
return "SAVINGS";
|
||||
}
|
||||
if (isCurrentAccountById(accountId, expectedAccountNo)) {
|
||||
return "CURRENT";
|
||||
}
|
||||
final List<Integer> depositTypes = jdbcTemplate.queryForList(
|
||||
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
|
||||
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
|
||||
if (Integer.valueOf(200).equals(depositType)) {
|
||||
return "FIXED_DEPOSIT";
|
||||
}
|
||||
if (Integer.valueOf(300).equals(depositType)) {
|
||||
return "RECURRING_DEPOSIT";
|
||||
}
|
||||
return "SAVINGS";
|
||||
}
|
||||
|
||||
// Parses a Fineract transaction id string to Long.
|
||||
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
|
||||
private Long parseTxnIdToLong(final String txnId) {
|
||||
@@ -884,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);
|
||||
@@ -917,10 +1067,14 @@ 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.
|
||||
txnDate != null && !txnDate.isBlank() ? Date.valueOf(txnDate) : null,
|
||||
cashierId, cashierUsername, paymentTypeName
|
||||
);
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+13
-36
@@ -60,34 +60,11 @@ public class CashReportApiResource {
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
@@ -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 = ? " : "";
|
||||
|
||||
@@ -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)
|
||||
@@ -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 ("
|
||||
|
||||
+22
-8
@@ -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<Map<String, Object>> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+14
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+172
-108
@@ -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<GlEntry> collectionGlEntries = new ArrayList<>();
|
||||
final List<GlEntry> collectionCreditGlEntries = new ArrayList<>();
|
||||
final List<GlEntry> 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<GlEntry> 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<Map<String, Object>> 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<String, Object> 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<GlEntry> 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<GlEntry> 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<GlEntry> 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<Map<String, Object>> 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();
|
||||
}
|
||||
|
||||
+42
@@ -709,6 +709,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 +973,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.
|
||||
*
|
||||
* <p>
|
||||
* 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_transaction
|
||||
WHERE share_account_id = ? 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;
|
||||
|
||||
+22
-4
@@ -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;
|
||||
|
||||
+55
-2
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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.
|
||||
*
|
||||
|
||||
+59
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <ul>
|
||||
* <li>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;
|
||||
* <li>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.
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* <b>The transaction is found by id and account number together.</b> 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.
|
||||
*
|
||||
* <p>
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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)"
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -26,4 +26,6 @@ public interface FundWritePlatformService {
|
||||
CommandProcessingResult createFund(JsonCommand command);
|
||||
|
||||
CommandProcessingResult updateFund(Long fundId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult deleteFund(Long fundId, JsonCommand command);
|
||||
}
|
||||
|
||||
+41
@@ -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.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-63
@@ -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() {
|
||||
|
||||
+16
-104
@@ -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<String, Object> toResult(final Map<String, Object> row) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("bankName", row.get("bank_name"));
|
||||
|
||||
+62
@@ -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();
|
||||
}
|
||||
}
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+103
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
+850
@@ -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).
|
||||
*
|
||||
* <h3>What these returns are</h3>
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* <h3>Where every figure comes from</h3>
|
||||
*
|
||||
* 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:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Balance-sheet roles</b> (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.</li>
|
||||
* <li><b>Earnings roles</b> (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.</li>
|
||||
* <li><b>Counts</b> (depositors, borrowers, members) come from the portfolio tables, since a
|
||||
* headcount has no ledger balance to read.</li>
|
||||
* </ul>
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* <h3>Blank is not zero</h3>
|
||||
*
|
||||
* 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<Integer> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> baseResponse(final String returnCode, final String returnName, final Quarter quarter,
|
||||
final Long officeId) {
|
||||
final Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("returnCode", returnCode);
|
||||
response.put("returnName", returnName);
|
||||
response.put("reportingFrequency", "Quarterly");
|
||||
|
||||
final Map<String, Object> profile = institutionProfile();
|
||||
final List<Map<String, Object>> 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<String, Object> institutionProfile() {
|
||||
final List<Map<String, Object>> 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<Map<String, Object>> signatories(final Map<String, Object> profile, final Quarter quarter) {
|
||||
final Map<String, Object> 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<String> names = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class,
|
||||
officeId);
|
||||
return names.isEmpty() ? "Office " + officeId : names.get(0);
|
||||
}
|
||||
|
||||
private static Map<String, Object> info(final String label, final String value) {
|
||||
final Map<String, Object> 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<String> 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<String> 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<String> 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<Map<String, Object>> accounts = new ArrayList<>();
|
||||
final List<String> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<Object> 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<String, Object> 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<String, String> 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<Integer> 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<Object> 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<Object> 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<Object> 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<String, Object> balanceLine(final String label, final String roleKey, final RoleFigures figures) {
|
||||
return line(label, figures.closing(roleKey));
|
||||
}
|
||||
|
||||
private Map<String, Object> movementLine(final String label, final String roleKey, final RoleFigures figures) {
|
||||
return line(label, figures.movement(roleKey));
|
||||
}
|
||||
|
||||
private static Map<String, Object> line(final String label, final RoleFigure figure) {
|
||||
final Map<String, Object> 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<String, Object> derivedLine(final String label, final String roleKey,
|
||||
final BigDecimal rupees) {
|
||||
final Map<String, Object> 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<String, Object> countLine(final String label, final Long count) {
|
||||
final Map<String, Object> 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<String, Object> subtotal(final String label, final List<Map<String, Object>> section,
|
||||
final String... ofRoles) {
|
||||
final Map<String, Object> 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<Map<String, Object>> section, final String label) {
|
||||
for (final Map<String, Object> 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<Map<String, Object>> section, final String... roles) {
|
||||
final Set<String> wanted = new LinkedHashSet<>(Arrays.asList(roles));
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+352
@@ -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<String> 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<Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> 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<Map<String, Object>> 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<String, Object> row = rows.get(0);
|
||||
final Map<String, Object> 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<String, Object> toSummary(final Map<String, Object> row) {
|
||||
final Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* 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<String, String> 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<String, String> 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<String, String> ROLE_GROUPS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> ROLE_GROUP_LABELS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> 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");
|
||||
if ("SLR_INVESTMENTS".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 9 (SLR) — maintained");
|
||||
} else if ("DEMAND_TIME_LIABILITIES".equals(role)) {
|
||||
ROLE_USAGE.put(role, "Form 1 & Form 9 — base");
|
||||
} else {
|
||||
ROLE_USAGE.put(role, "Form 1 (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<String> 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<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
||||
"ASSET", "MEMO");
|
||||
|
||||
private static final Map<String, String> 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<String, String> 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<String> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> options = new ArrayList<>();
|
||||
for (final Map<String, Object> a : accounts) {
|
||||
final Map<String, Object> 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<Map<String, Object>> rates() {
|
||||
final List<Map<String, Object>> 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<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Map<String, Object> 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<Map<String, Object>> glRoles() {
|
||||
final List<Map<String, Object>> 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<String, List<Map<String, Object>>> byKey = new LinkedHashMap<>();
|
||||
for (final String key : knownRoleKeys()) {
|
||||
byKey.put(key, new ArrayList<>());
|
||||
}
|
||||
for (final Map<String, Object> 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<Map<String, Object>> bucket = byKey.get(key);
|
||||
if (bucket == null) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, Object> 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<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map.Entry<String, String> role : ROLE_DESCRIPTIONS.entrySet()) {
|
||||
final Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String> knownRoleKeys() {
|
||||
final java.util.Set<String> keys = new java.util.LinkedHashSet<>(ROLE_DESCRIPTIONS.keySet());
|
||||
for (final Map<String, Object> 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<Mapping> 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<String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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();
|
||||
}
|
||||
}
|
||||
+2400
File diff suppressed because it is too large
Load Diff
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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.
|
||||
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), 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<Map<String, Object>> 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);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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<String, Object> accountingMappings = accountMappingReadPlatformService
|
||||
.fetchAccountMappingDetailsForSavingsProduct(productId, savingProductData.accountingRuleTypeId());
|
||||
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = accountMappingReadPlatformService
|
||||
.fetchPaymentTypeToFundSourceMappingsForSavingsProduct(productId);
|
||||
Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = accountMappingReadPlatformService
|
||||
.fetchFeeToIncomeAccountMappingsForSavingsProduct(productId);
|
||||
Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = accountMappingReadPlatformService
|
||||
.fetchPenaltyToIncomeAccountMappingsForSavingsProduct(productId);
|
||||
.fetchAccountMappingDetailsForCurrentProduct(productId, savingProductData.accountingRuleTypeId());
|
||||
final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings = null;
|
||||
final Collection<ChargeToGLAccountMapper> feeToGLAccountMappings = null;
|
||||
final Collection<ChargeToGLAccountMapper> penaltyToGLAccountMappings = null;
|
||||
savingProductData = SavingsProductData.withAccountingDetails(savingProductData, accountingMappings,
|
||||
paymentChannelToFundSourceMappings, feeToGLAccountMappings, penaltyToGLAccountMappings);
|
||||
}
|
||||
|
||||
+4
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
|
||||
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
|
||||
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
|
||||
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
|
||||
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
|
||||
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
|
||||
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
|
||||
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
|
||||
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
|
||||
HashMap request = new HashMap<>();
|
||||
request.put("annualInterestRate", annualInterestRate);
|
||||
request.put("tenureInMonths", tenureInMonths);
|
||||
if (tenureInDays != null) {
|
||||
request.put("tenureInDays", tenureInDays);
|
||||
}
|
||||
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
|
||||
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
|
||||
request.put("principalAmount", principalAmount);
|
||||
|
||||
+66
@@ -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<String, Object> result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+102
@@ -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<Map<String, Object>> 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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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<String, Object> result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+60
@@ -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<String, Object> result = this.depositAccountWritePlatformService.previewFdRenewal(accountId);
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
}
|
||||
+81
-70
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<String> failures = postApprovedReports(businessDate);
|
||||
accrualService.accrueForDate(businessDate);
|
||||
enrolNewProducts();
|
||||
|
||||
final List<Map<String, Object>> 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<String> failures = new java.util.ArrayList<>();
|
||||
|
||||
for (final Map<String, Object> 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<String> 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<String> postReports(final List<Long> reportIds) {
|
||||
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
|
||||
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
final List<String> failures = new java.util.ArrayList<>();
|
||||
for (final Long reportId : reportIds) {
|
||||
final Map<String, Object> 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.
|
||||
*
|
||||
|
||||
+24
-4
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
|
||||
carriedInterest = backfill.cumulativeInterest();
|
||||
} else {
|
||||
final Map<String, Object> 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
|
||||
|
||||
+677
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<Long> 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<Long> generated = new ArrayList<>();
|
||||
final List<String> 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<Map<String, Object>> 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<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
|
||||
final Set<String> 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<String, Object> 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<Long> 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<Long> 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<Long> 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<Map<String, Object>> 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<Object> 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<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||
final Map<String, Object> 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<String, Object> detail(final Long reportId) {
|
||||
final List<Map<String, Object>> 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<String, Object> 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.
|
||||
*
|
||||
* <p>
|
||||
* A branch user sees their own branch; a head office user every branch, or one with officeId.
|
||||
*/
|
||||
public List<Map<String, Object>> 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<Object> 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<Map<String, Object>> 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<Object> 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.
|
||||
*
|
||||
* <p>
|
||||
* {@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<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user, final String scope) {
|
||||
final List<Map<String, Object>> 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<String, Object> 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<Long> 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<String> 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<Object> 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<String> 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<DueKey> 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<Object> 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<Long> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+240
-216
@@ -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:
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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:
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>
|
||||
* 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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) {
|
||||
|
||||
+214
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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<String, Object> 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<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
final Map<String, Object> 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<String, Object> report = plain(reportService.detail(reportId));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> lines = (List<Map<String, Object>>) 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<String, Object> plain(final Map<String, Object> row) {
|
||||
final Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
|
||||
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
|
||||
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
|
||||
.notLessThanMin(BigDecimal.valueOf(0));
|
||||
|
||||
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
|
||||
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
|
||||
// an addition, so only one of the two needs to be a positive value.
|
||||
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
|
||||
if (tenureInDays != null && tenureInDays > 0) {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
|
||||
} else {
|
||||
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
|
||||
}
|
||||
|
||||
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
|
||||
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user