Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aceea89c5c | ||
|
|
1fd94615a9 | ||
|
|
3d3ce92e93 | ||
|
|
1a914a3de0 | ||
|
|
6e7eb94776 | ||
|
|
6fbc3a85a8 | ||
|
|
949f45c4cd | ||
|
|
6c7c0f8445 | ||
|
|
88157ee9d6 | ||
|
|
760fff8d3e | ||
|
|
d7ff4e4a7b | ||
|
|
7068d65422 | ||
|
|
fd827dec27 | ||
|
|
aa264097e7 | ||
|
|
16c67f6701 | ||
|
|
e846892e9f | ||
|
|
56d98bac8d | ||
|
|
0d6066e0b6 | ||
|
|
4499b8afad | ||
|
|
11a206f42a | ||
|
|
11a0d502be | ||
|
|
2afd4aaf83 | ||
|
|
e5dd6af366 | ||
|
|
ce8bb7e5ca | ||
|
|
82336cbe98 | ||
|
|
9436eab129 | ||
|
|
1408bddf58 | ||
|
|
e02ab51ee1 | ||
|
|
3a37a5f90f | ||
|
|
e7417d16a6 | ||
|
|
af052dcb4f | ||
|
|
6ee3bbe67c | ||
|
|
d8f6c25875 | ||
|
|
cebba87864 | ||
|
|
7eabaf2542 | ||
|
|
9beae3df28 |
+1
-1
@@ -154,7 +154,7 @@ gitVersioning.apply {
|
||||
}
|
||||
}
|
||||
rev {
|
||||
version = '${describe.tag.version.major}.${describe.tag.version.minor.next}.0-SNAPSHOT'
|
||||
version = '1.15.0-SNAPSHOT'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
|
||||
// Income
|
||||
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
|
||||
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
|
||||
|
||||
+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.
|
||||
*
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ public final class GlobalConfigurationConstants {
|
||||
public static final String FORCE_PASSWORD_RESET_DAYS = "force-password-reset-days";
|
||||
public static final String GRACE_ON_PENALTY_POSTING = "grace-on-penalty-posting";
|
||||
public static final String SAVINGS_INTEREST_POSTING_CURRENT_PERIOD_END = "savings-interest-posting-current-period-end";
|
||||
public static final String FD_INTEREST_POSTING_APPROVAL = "fd-interest-posting-approval";
|
||||
public static final String FINANCIAL_YEAR_BEGINNING_MONTH = "financial-year-beginning-month";
|
||||
public static final String MIN_CLIENTS_IN_GROUP = "min-clients-in-group";
|
||||
public static final String MAX_CLIENTS_IN_GROUP = "max-clients-in-group";
|
||||
|
||||
+6
@@ -69,6 +69,12 @@ public interface ConfigurationDomainService {
|
||||
|
||||
boolean isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
|
||||
/**
|
||||
* Whether fixed deposit interest is reported and approved before it is posted, instead of being
|
||||
* posted directly by the nightly job.
|
||||
*/
|
||||
boolean isFixedDepositInterestPostingApprovalEnabled();
|
||||
|
||||
Integer retrieveFinancialYearBeginningMonth();
|
||||
|
||||
Integer retrieveMinAllowedClientsInGroup();
|
||||
|
||||
+2
@@ -43,6 +43,8 @@ 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"), //
|
||||
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit 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"), //
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+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();
|
||||
|
||||
+16
-1
@@ -212,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";
|
||||
@@ -241,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));
|
||||
@@ -379,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;
|
||||
}
|
||||
|
||||
@@ -490,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;
|
||||
}
|
||||
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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 java.util.Locale;
|
||||
|
||||
/**
|
||||
* The deposit scheme a fixed deposit product is sold under, held as {@code m_savings_product.fd_type}.
|
||||
*
|
||||
* <p>
|
||||
* The three schemes differ in where a posting of interest ends up, which is what the interest posting
|
||||
* report has to show and the posting itself has to honour:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #SINGLE} — short deposits, priced from a slab table in days. Interest stays in the
|
||||
* deposit and is paid with the principal at maturity.</li>
|
||||
* <li>{@link #DOUBLE} — deposits of a year or more, priced from a slab table in years. Interest is
|
||||
* compounded back into the deposit, so the maturity value is principal plus interest.</li>
|
||||
* <li>{@link #SPECIAL} — the monthly income scheme. Interest is posted monthly and swept out to the
|
||||
* customer's linked savings account, which is why a special deposit cannot be opened without one;
|
||||
* the deposit itself matures at its principal, the interest having already been paid away.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The database column is a string rather than an enum ordinal, so the names here are the stored
|
||||
* values and must not be renamed. {@link #fromString(String)} is lenient about case and blanks
|
||||
* because the column carries a default of SINGLE and older products predate the column entirely.
|
||||
*/
|
||||
public enum FixedDepositType {
|
||||
|
||||
/** Interest is held in the deposit and paid at maturity. The default for a product that names none. */
|
||||
SINGLE,
|
||||
/** Interest is compounded into the deposit; the maturity value includes it. */
|
||||
DOUBLE,
|
||||
/** Interest is posted monthly and transferred to the linked savings account. */
|
||||
SPECIAL;
|
||||
|
||||
/**
|
||||
* The scheme named by a stored {@code fd_type}, defaulting to {@link #SINGLE}.
|
||||
*
|
||||
* <p>
|
||||
* A null, blank or unrecognised value reads as SINGLE: that is the column's own default, and a
|
||||
* product carrying something else is a misconfiguration that must not stop its customers' interest
|
||||
* from being reported.
|
||||
*/
|
||||
public static FixedDepositType fromString(final String fdType) {
|
||||
if (fdType == null || fdType.isBlank()) {
|
||||
return SINGLE;
|
||||
}
|
||||
for (final FixedDepositType candidate : values()) {
|
||||
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return SINGLE;
|
||||
}
|
||||
|
||||
public static boolean isValid(final String fdType) {
|
||||
if (fdType == null || fdType.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (final FixedDepositType candidate : values()) {
|
||||
if (candidate.name().equalsIgnoreCase(fdType.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether interest leaves the deposit for the linked savings account each time it is posted. */
|
||||
public boolean isPaidOutMonthly() {
|
||||
return this == SPECIAL;
|
||||
}
|
||||
|
||||
/** Whether a posting of interest adds to the deposit's own balance. */
|
||||
public boolean isRetainedInDeposit() {
|
||||
return this != SPECIAL;
|
||||
}
|
||||
|
||||
/** What a customer sees: "FD Special", "FD Double", "FD Single". */
|
||||
public String displayName() {
|
||||
return "FD " + name().charAt(0) + name().substring(1).toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+130
-51
@@ -27,6 +27,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosure;
|
||||
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
|
||||
@@ -42,6 +43,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
|
||||
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
|
||||
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
|
||||
@@ -82,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AccountingProcessorHelper {
|
||||
|
||||
@@ -661,37 +664,78 @@ public class AccountingProcessorHelper {
|
||||
final GLAccount payableAccount = Optional
|
||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
|
||||
.orElse(controlAccount);
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
|
||||
.orElse(payableAccount);
|
||||
|
||||
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
|
||||
// named Payable account if the product has one, or straight against Savings Control if it
|
||||
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
|
||||
// entries for interest until this leg existed, since a product with no Payable/Paid names
|
||||
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
|
||||
// to post in their place.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
|
||||
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
|
||||
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
|
||||
amount, isReversal);
|
||||
|
||||
// The interest expense is not raised again here. It was just raised above, and this posting
|
||||
// only settles that recognised debt onward to where it is actually paid out from -- raising
|
||||
// the expense a second time would put the same interest on the P&L twice.
|
||||
//
|
||||
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
|
||||
// product names no paid account both resolve to the payable and this says nothing, so it is
|
||||
// skipped and the payout comes straight out of the payable.
|
||||
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
||||
if (!payableIsPaid) {
|
||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
// A product that names no payable falls back to Savings Control above. The chain below would
|
||||
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
|
||||
// reverse, the two cancelling: the expense never charged and the member never credited. Such
|
||||
// a product has no liability to draw down, so it keeps the single pair it always posted.
|
||||
if (payableAccount.getId().equals(controlAccount.getId())) {
|
||||
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
|
||||
payableAccount.getId());
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||
amount, isReversal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 3: into the member's own balance.
|
||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
// The interest is charged to expense and raised as a liability first, for whatever part of
|
||||
// it no accrual has already covered.
|
||||
//
|
||||
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
|
||||
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
|
||||
// job never covered - the payable went negative by the interest and the expense netted to
|
||||
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
|
||||
// account cannot hold a negative balance: the bank does not owe less than nothing.
|
||||
//
|
||||
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
|
||||
// payable already carries the interest and this leg writes nothing - the balance below is
|
||||
// enough, and raising it again would double the liability.
|
||||
//
|
||||
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
|
||||
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
|
||||
// debit balance would come back negative and every posting would raise a shortfall larger
|
||||
// than the interest itself.
|
||||
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
|
||||
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
|
||||
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
|
||||
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
|
||||
alreadyAccrued, shortfall, payableIsLiability, isReversal);
|
||||
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
|
||||
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
|
||||
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
|
||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||
shortfall, false);
|
||||
}
|
||||
|
||||
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
|
||||
if (hasSeparatePaidAccount) {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
|
||||
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
|
||||
// Stage 3: Out of the interest-paid account, into the member's control account
|
||||
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
} else {
|
||||
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
|
||||
payableAccount.getName(), controlAccount.getName(), amount);
|
||||
// Payout directly from payable into member's control account
|
||||
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -733,34 +777,69 @@ public class AccountingProcessorHelper {
|
||||
*
|
||||
* @return the account, or null where the product does not configure this name
|
||||
*/
|
||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
||||
if (savingsProductId == null) {
|
||||
/**
|
||||
* What the interest payable account currently holds for this office, on its own liability side.
|
||||
*
|
||||
* <p>
|
||||
* Read so an interest posting can tell an accrual that already ran from one that did not. The
|
||||
* posting chain draws the payable down; where nothing had raised it, the account went negative
|
||||
* and the expense netted to nothing, so the charge never reached the P&L at all. The caller
|
||||
* raises only the shortfall, which is nil when the accrual did run.
|
||||
*
|
||||
* <p>
|
||||
* Credits less debits, because a payable is a liability: the balance is what the bank still
|
||||
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
|
||||
* report, and the caller's arithmetic needs the true figure, not a clamped one.
|
||||
*/
|
||||
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
|
||||
if (payableAccount == null || payableAccount.getId() == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
|
||||
// nothing, and counting either side alone would move the balance by the whole amount.
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ?
|
||||
AND office_id = ?
|
||||
AND currency_code = ?
|
||||
AND (reversed IS NULL OR reversed = false)
|
||||
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
|
||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
|
||||
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
|
||||
return null;
|
||||
}
|
||||
// The column holds an empty string on a product saved with no named mappings, and casting
|
||||
// that to jsonb is an error rather than an empty document - so the cast is done inside a
|
||||
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
|
||||
// clause in any order, so guarding at the same query level as the cast would only work by
|
||||
// luck.
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE m.entry ->> 'accountName' = ?
|
||||
LIMIT 1
|
||||
""", Long.class, savingsProductId, accountName);
|
||||
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
|
||||
for (final String accountName : accountNames) {
|
||||
if (accountName == null) {
|
||||
continue;
|
||||
}
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
|
||||
LIMIT 1
|
||||
""", Long.class, savingsProductId, accountName);
|
||||
if (!found.isEmpty()) {
|
||||
return glAccountRepository.findById(found.get(0)).orElse(null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -84,4 +84,8 @@ public final class LedgerTxnPrefix {
|
||||
// ── Savings ───────────────────────────────────────────────────────────────
|
||||
/** Pooled savings interest posting for one product and date. */
|
||||
public static final String SAVINGS_INTEREST_POOLED = "SAVINT";
|
||||
|
||||
// ── Fixed deposits ────────────────────────────────────────────────────────
|
||||
/** Approved fixed deposit interest posting report, for one product, branch and date. */
|
||||
public static final String FD_INTEREST_POSTED = "FDINT";
|
||||
}
|
||||
|
||||
+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();
|
||||
|
||||
+10
@@ -191,6 +191,16 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
||||
return property.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read on every run of the interest posting job and of the day close, so the two paths can never
|
||||
* both pay the same deposit: with this on the job leaves fixed deposits alone and they are paid
|
||||
* from an approved report, with it off the job pays them directly as it always did.
|
||||
*/
|
||||
@Override
|
||||
public boolean isFixedDepositInterestPostingApprovalEnabled() {
|
||||
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.FD_INTEREST_POSTING_APPROVAL).isEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer retrieveFinancialYearBeginningMonth() {
|
||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||
|
||||
+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()
|
||||
|
||||
+6
@@ -68,6 +68,7 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
||||
private final ExtendedSpringLiquibaseFactory liquibaseFactory;
|
||||
private final TenantDataSourceFactory tenantDataSourceFactory;
|
||||
private final Environment environment;
|
||||
private final TenantInitialUserProvisioner initialUserProvisioner;
|
||||
|
||||
// DO NOT REMOVE! Required for liquibase custom task initialization
|
||||
private final List<CustomTaskChange> customTaskChangesForDependencyInjection;
|
||||
@@ -195,10 +196,15 @@ public class TenantDatabaseUpgradeService implements InitializingBean {
|
||||
// configuration
|
||||
// settings, and we should not use them to control the script order
|
||||
if (databaseStateVerifier.isFirstLiquibaseMigration(tenantDataSource)) {
|
||||
// a Flyway-era database already has its real users; only a brand-new tenant gets the seeded ones
|
||||
final boolean brandNewTenant = !databaseStateVerifier.isFlywayPresent(tenantDataSource);
|
||||
ExtendedSpringLiquibase liquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT,
|
||||
CUSTOM_CHANGELOG_CONTEXT, INITIAL_SWITCH_CONTEXT, tenant.getTenantIdentifier());
|
||||
applyInitialLiquibase(tenantDataSource, liquibase, tenant.getTenantIdentifier(),
|
||||
(ds) -> !databaseStateVerifier.isTenantOnLatestUpgradableVersion(ds));
|
||||
if (brandNewTenant) {
|
||||
initialUserProvisioner.provision(tenantDataSource, tenant.getTenantIdentifier());
|
||||
}
|
||||
}
|
||||
SpringLiquibase tenantLiquibase = liquibaseFactory.create(tenantDataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT,
|
||||
tenant.getTenantIdentifier());
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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.infrastructure.core.service.migration;
|
||||
|
||||
import java.util.Locale;
|
||||
import javax.sql.DataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.useradministration.service.AppUserConstants;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Personalises the three users that the initial Liquibase data seeds into every brand-new tenant database (ids 1-3,
|
||||
* which otherwise ship with the well-known {@code mifos} / {@code system} / {@code interopUser} usernames and the
|
||||
* public default password).
|
||||
*
|
||||
* Runs only right after the very first migration of a tenant that was never on Flyway, so existing tenants are never
|
||||
* touched. The seeded changeSet itself is deliberately left alone: editing it would change its Liquibase checksum and
|
||||
* break validation on every tenant that already ran it.
|
||||
*
|
||||
* Usernames become {@code <tenantIdentifier>_admin} and {@code <tenantIdentifier>_interop}. The system user keeps the
|
||||
* {@link AppUserConstants#SYSTEM_USER_NAME} username because scheduler jobs, COB and other background processes look it
|
||||
* up by that name; only its password is changed. All three share one password: the value of
|
||||
* {@code fineract.tenant.initial-user-password} (env {@code FINERACT_TENANT_INITIAL_USER_PASSWORD}) when set,
|
||||
* otherwise {@link #DEFAULT_PASSWORD}.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TenantInitialUserProvisioner {
|
||||
|
||||
static final String PASSWORD_PROPERTY = "fineract.tenant.initial-user-password";
|
||||
|
||||
private static final long ADMIN_ID = 1L;
|
||||
private static final long SYSTEM_ID = 2L;
|
||||
private static final long INTEROP_ID = 3L;
|
||||
// Meets the "strong" password policy (upper, lower, digit, special, no space, under 50 chars).
|
||||
static final String DEFAULT_PASSWORD = "Cbs@Nexora#2026!Secure";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public void provision(final DataSource dataSource, final String tenantIdentifier) {
|
||||
final JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
final String prefix = usernamePrefix(tenantIdentifier);
|
||||
|
||||
final String configured = environment.getProperty(PASSWORD_PROPERTY);
|
||||
final boolean overridden = configured != null && !configured.isBlank();
|
||||
final String password = overridden ? configured : DEFAULT_PASSWORD;
|
||||
final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
final String encoded = encoder.encode(password);
|
||||
|
||||
update(jdbc, ADMIN_ID, prefix + "_admin", encoded);
|
||||
update(jdbc, SYSTEM_ID, AppUserConstants.SYSTEM_USER_NAME, encoded);
|
||||
update(jdbc, INTEROP_ID, prefix + "_interop", encoded);
|
||||
|
||||
log.warn("Tenant '{}' initial users created: {}_admin, {}, {}_interop (password: {}). Change it after first login.",
|
||||
tenantIdentifier, prefix, AppUserConstants.SYSTEM_USER_NAME, prefix, overridden ? "from " + PASSWORD_PROPERTY : "built-in default");
|
||||
}
|
||||
|
||||
private void update(final JdbcTemplate jdbc, final long id, final String username, final String encodedPassword) {
|
||||
jdbc.update("UPDATE m_appuser SET username = ?, password = ?, last_time_password_updated = CURRENT_DATE WHERE id = ?", username,
|
||||
encodedPassword, id);
|
||||
}
|
||||
|
||||
static String usernamePrefix(final String tenantIdentifier) {
|
||||
final String cleaned = tenantIdentifier.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_").replaceAll("^_+|_+$", "");
|
||||
return cleaned.isEmpty() ? "tenant" : cleaned;
|
||||
}
|
||||
|
||||
}
|
||||
+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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+120
-15
@@ -46,6 +46,10 @@ 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.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -69,10 +73,28 @@ 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 FixedDepositInterestPostingReportService fdInterestPostingReportService;
|
||||
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
@@ -191,8 +213,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
|
||||
@@ -201,27 +223,29 @@ public class BranchDayRegisterApiResource {
|
||||
SELECT COALESCE(SUM(std.total_amount), 0) FROM m_savings_txn_denomination std
|
||||
WHERE std.txn_date = ? AND std.txn_type = 'DEPOSIT'
|
||||
AND """ + CountedCashLedger.COUNTED_ROW + """
|
||||
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 (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.COUNTED_ROW + """
|
||||
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 (std.officer_id IS NULL OR (""" + COUNTER_OFFICE_OF_STD + """
|
||||
) = ?)
|
||||
""", BigDecimal.class, Date.valueOf(businessDate), officeId));
|
||||
result.put("denominationIn", countedIn);
|
||||
result.put("denominationOut", countedOut);
|
||||
|
||||
// What will stop this close, so the screen can say so before the button is pressed rather than
|
||||
// after. The same lists closeDay() refuses on, read-only here.
|
||||
result.put("savingsInterestPending", interestPostingReportService.reportsBlockingDayClose(officeId, businessDate));
|
||||
// One call for the deposits: what is blocking, and the products and amounts behind it, so the
|
||||
// branch sees what it is being asked to approve without leaving the close screen. Pricing a
|
||||
// branch's deposits is the expensive part and is done once.
|
||||
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
|
||||
result.put("fdInterestPending", fdInterest.get("pending"));
|
||||
result.put("fdInterestDue", fdInterest.get("due"));
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@@ -589,6 +613,43 @@ 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.");
|
||||
}
|
||||
|
||||
// ── And its fixed deposit interest reports, on the same grounds ──
|
||||
// Only while deposits are on the approval path at all; with that switched off the posting job
|
||||
// pays them directly and there is nothing here to approve.
|
||||
final List<String> fdInterestPending = fdInterestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
|
||||
if (!fdInterestPending.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.report.pending",
|
||||
"Cannot close the day — this branch's fixed deposit interest posting report is not approved for: "
|
||||
+ String.join(", ", fdInterestPending)
|
||||
+ ". Approve it under Reports → FD 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 +748,45 @@ 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.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── And the branch's approved fixed deposit interest, the same way ──
|
||||
// Separate from the savings run so a deposit product with a GL problem cannot stop savings
|
||||
// interest that has already posted cleanly, and so the refusal names which of the two failed.
|
||||
final List<Long> fdToPost = new ArrayList<>(fdInterestPostingReportService.approvedReportIds(officeId, businessDate));
|
||||
if (isHeadOffice) {
|
||||
fdToPost.addAll(fdInterestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
|
||||
}
|
||||
if (!fdToPost.isEmpty()) {
|
||||
log.info("Day close {}: office {} posting {} approved FD interest report(s)", businessDate, officeId, fdToPost.size());
|
||||
final List<String> fdFailures = fdInterestPostingOrchestrator.postReports(fdToPost);
|
||||
if (!fdFailures.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.branchday.fd.interest.posting.failed",
|
||||
"Cannot close the day — fixed deposit interest could not be posted: " + String.join("; ", fdFailures)
|
||||
+ ". 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 +1104,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 +1160,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 --
|
||||
|
||||
+17
-3
@@ -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
|
||||
@@ -691,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,
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ public class CashReconciliationApiResource {
|
||||
+ "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
|
||||
@@ -123,7 +123,7 @@ public class CashReconciliationApiResource {
|
||||
+ "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)
|
||||
|
||||
+9
-9
@@ -671,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",
|
||||
@@ -721,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 = ? " : "";
|
||||
|
||||
@@ -1171,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
|
||||
@@ -1197,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)
|
||||
@@ -1315,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 ("
|
||||
|
||||
+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;
|
||||
|
||||
+11
-1
@@ -130,9 +130,19 @@ public final class CountedCashLedger {
|
||||
+ " 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(sd.count * sd.denomination_value)"
|
||||
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";
|
||||
}
|
||||
|
||||
+4
-4
@@ -288,7 +288,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " 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)"
|
||||
@@ -312,7 +312,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " 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)"
|
||||
@@ -409,7 +409,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'DEPOSIT'"
|
||||
+ " 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)"
|
||||
@@ -433,7 +433,7 @@ public class DenominationBreakdownWriter {
|
||||
+ " WHERE std.txn_type = 'WITHDRAWAL'"
|
||||
+ " 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);
|
||||
}
|
||||
}
|
||||
+53
-6
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
|
||||
"The generated return must be supplied with the save.");
|
||||
}
|
||||
|
||||
final LocalDate periodStart = localDate(report, "fortnightStart");
|
||||
final LocalDate periodEnd = localDate(report, "fortnightEnd");
|
||||
final BigDecimal ndtl = decimal(report, "ndtlAmount");
|
||||
final BigDecimal required = decimal(report, "requiredAmount");
|
||||
final BigDecimal maintained = decimal(report, "maintainedAmount");
|
||||
final BigDecimal rate = decimal(report, "ratePercentage");
|
||||
// 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");
|
||||
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
|
||||
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_")) {
|
||||
@@ -227,9 +228,15 @@ public class RbiReserveConfigApiResource {
|
||||
*/
|
||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
||||
|
||||
/** The four treatments a head can be given, each naming a branch the returns already have. */
|
||||
/**
|
||||
* 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",
|
||||
"MEMO");
|
||||
"ASSET", "MEMO");
|
||||
|
||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
static {
|
||||
@@ -243,6 +250,27 @@ public class RbiReserveConfigApiResource {
|
||||
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 {
|
||||
@@ -390,11 +418,14 @@ public class RbiReserveConfigApiResource {
|
||||
o.put("label", row.get("label"));
|
||||
o.put("group", group);
|
||||
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
|
||||
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
|
||||
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.
|
||||
o.put("classified", "LIABILITY".equals(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);
|
||||
|
||||
+1486
-24
File diff suppressed because it is too large
Load Diff
+59
-13
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Slf4j
|
||||
public class RbiFormAAutoSaveService {
|
||||
|
||||
private static final String FORM_CODE = "FORM_1";
|
||||
// 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;
|
||||
@@ -89,7 +92,11 @@ public class RbiFormAAutoSaveService {
|
||||
try {
|
||||
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
||||
// saved by hand cannot come out differently.
|
||||
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
|
||||
// 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
|
||||
@@ -98,18 +105,22 @@ public class RbiFormAAutoSaveService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
|
||||
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
|
||||
// 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;
|
||||
}
|
||||
|
||||
final LocalDate periodStart = date(report, "fortnightStart");
|
||||
final LocalDate periodEnd = date(report, "fortnightEnd");
|
||||
BigDecimal average = null;
|
||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||
}
|
||||
// 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
|
||||
@@ -119,9 +130,9 @@ public class RbiFormAAutoSaveService {
|
||||
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), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
|
||||
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
|
||||
"Saved automatically at the close of the fortnight.");
|
||||
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);
|
||||
}
|
||||
@@ -143,6 +154,41 @@ public class RbiFormAAutoSaveService {
|
||||
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;
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+242
-218
@@ -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);
|
||||
}
|
||||
@@ -406,8 +424,8 @@ public class SavingsPooledInterestPostingService {
|
||||
* columns are NOT NULL, so entries are stamped to the system account the scheduler uses.
|
||||
*/
|
||||
private Long systemUserId() {
|
||||
final List<Long> ids = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_appuser WHERE username = 'system' ORDER BY id LIMIT 1", Long.class);
|
||||
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||
return ids.isEmpty() ? 1L : ids.get(0);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -77,10 +77,13 @@ public class DepositAccountTransactionDataValidator {
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
|
||||
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName));
|
||||
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
|
||||
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
|
||||
DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
|
||||
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
|
||||
|
||||
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
|
||||
@@ -245,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
|
||||
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
|
||||
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
|
||||
} else if (accountOnClosureType.isTransferToGl()) {
|
||||
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
|
||||
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
|
||||
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
|
||||
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"transfer.to.gl.not.allowed",
|
||||
"Transfer to a GL account is only supported for fixed-deposit premature closure");
|
||||
}
|
||||
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
|
||||
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
|
||||
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
|
||||
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
|
||||
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
|
||||
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
|
||||
// and is refused here as well -- otherwise a direct API call could still take the cash route.
|
||||
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
|
||||
"withdraw.deposit.not.allowed",
|
||||
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+111
@@ -19,10 +19,14 @@
|
||||
package org.apache.fineract.portfolio.savings.domain;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
@@ -36,6 +40,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants;
|
||||
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
|
||||
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
|
||||
@@ -46,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.portfolio.account.PortfolioAccountType;
|
||||
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
|
||||
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
|
||||
@@ -65,6 +71,7 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
|
||||
@@ -73,6 +80,7 @@ import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
|
||||
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -93,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
|
||||
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
|
||||
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -812,6 +822,18 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
Long savingsTransactionId = null;
|
||||
|
||||
// Maker's own "waive penalty" choice on the Premature Close dialog, not the product's stored
|
||||
// preClosurePenalApplicable config -- this is a one-time override for this closure only, read
|
||||
// fresh here since the preview and the actual submit are two separate requests.
|
||||
final boolean waivePreClosurePenalty = command.hasParameter(waivePreClosurePenaltyParamName)
|
||||
&& command.booleanPrimitiveValueOfParameterNamed(waivePreClosurePenaltyParamName);
|
||||
// RBI-mandated waiver reasons waive the penalty regardless of the manual checkbox above -- see
|
||||
// PrematureClosureReasonType. Enforced here (not just trusted from the client) so the actual
|
||||
// closure always matches what the preview showed for the same reason.
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType
|
||||
.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName));
|
||||
account.setWaivePreClosurePenaltyForThisRequest(waivePreClosurePenalty || prematureClosureReason.isAutoWaived());
|
||||
|
||||
// post interest
|
||||
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth);
|
||||
@@ -831,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
|
||||
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else if (closureType.isTransferToGl()) {
|
||||
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
|
||||
final BigDecimal proceeds = account.getAccountBalance();
|
||||
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
|
||||
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
|
||||
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
|
||||
// net posting as Dr Savings Control / Cr chosen GL.
|
||||
final boolean isInternalTransfer = true;
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
|
||||
isRegularTransaction, isInternalTransfer);
|
||||
savingsTransactionId = withdrawal.getId();
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
|
||||
command.stringValueOfParameterNamed(transferDescriptionParamName));
|
||||
} else {
|
||||
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
|
||||
paymentDetail, false, isRegularTransaction);
|
||||
@@ -848,6 +884,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
|
||||
return savingsTransactionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
|
||||
* Dr the Liability Transfer account / Cr that GL account.
|
||||
* <p>
|
||||
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
|
||||
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
|
||||
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
|
||||
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
|
||||
* reconciles exactly like any other manual entry.
|
||||
*/
|
||||
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
|
||||
final LocalDate closedDate, final String description) {
|
||||
|
||||
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
|
||||
|
||||
final JsonObject debitEntry = new JsonObject();
|
||||
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
|
||||
debitEntry.addProperty("amount", amount);
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debitEntry);
|
||||
|
||||
final JsonObject creditEntry = new JsonObject();
|
||||
creditEntry.addProperty("glAccountId", toGlAccountId);
|
||||
creditEntry.addProperty("amount", amount);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(creditEntry);
|
||||
|
||||
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
|
||||
if (description != null && !description.isBlank()) {
|
||||
comments.append(" -- ").append(description);
|
||||
}
|
||||
|
||||
final String dateFormat = "dd MMMM yyyy";
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", account.officeId());
|
||||
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
|
||||
payload.addProperty("dateFormat", dateFormat);
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", account.getCurrency().getCode());
|
||||
payload.addProperty("comments", comments.toString());
|
||||
payload.add("debits", debits);
|
||||
payload.add("credits", credits);
|
||||
|
||||
final String json = payload.toString();
|
||||
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
|
||||
* to clear it.
|
||||
* <p>
|
||||
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
|
||||
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
|
||||
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
|
||||
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
|
||||
* collapse onto whichever account that mapping names.
|
||||
* <p>
|
||||
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
|
||||
* closure's money somewhere nobody chose.
|
||||
*/
|
||||
private Long liabilityTransferGlAccountId() {
|
||||
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
|
||||
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
if (glAccountIds.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
|
||||
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
|
||||
+ " proceeds cannot be routed to a GL account. Map it first.",
|
||||
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
|
||||
}
|
||||
return glAccountIds.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
|
||||
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
|
||||
|
||||
+100
-22
@@ -58,7 +58,7 @@ import org.apache.fineract.portfolio.interestratechart.service.InterestRateChart
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
|
||||
@@ -84,6 +84,16 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
protected InterestRateChartAssembler chartAssembler;
|
||||
@Transient
|
||||
private ConfigurationDomainService configurationDomainService;
|
||||
/**
|
||||
* Set once per request, immediately before a premature-closure preview or the real closure, from the
|
||||
* maker's own "waive penalty" checkbox on the Premature Close dialog -- never persisted, since it is
|
||||
* a one-time decision for this specific closure rather than a product/account setting. The product's
|
||||
* own {@code accountTermAndPreClosure.isPreClosurePenalApplicable()} configuration is untouched, so
|
||||
* every other premature closure of this same product still applies its penalty as normal; this only
|
||||
* suppresses it for the one request that explicitly asked to waive it.
|
||||
*/
|
||||
@Transient
|
||||
private boolean waivePreClosurePenaltyForThisRequest;
|
||||
|
||||
protected FixedDepositAccount() {
|
||||
//
|
||||
@@ -177,18 +187,17 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
if (this.chart != null) {
|
||||
boolean applyPreMaturePenalty = false;
|
||||
BigDecimal penalInterest = BigDecimal.ZERO;
|
||||
LocalDate depositCloseDate = calculateMaturityDate();
|
||||
// Rate-selection date is always calculateMaturityDate() below -- premature closure prices off
|
||||
// the deposit's own CONTRACTED rate, the same rate the FD was created with and the same one
|
||||
// every regular scheduled interest posting this account ever makes (see this method's 2-arg
|
||||
// overload), so a closure's freshly-recomputed total always matches what the account was
|
||||
// already accruing all along. No separate "actual period held" slab lookup -- deliberately
|
||||
// simple, one rate for the account's whole life.
|
||||
final LocalDate depositCloseDate = calculateMaturityDate();
|
||||
if (isPreMatureClosure) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) {
|
||||
if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) {
|
||||
applyPreMaturePenalty = true;
|
||||
penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest();
|
||||
final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure
|
||||
.depositPreClosureDetail().preClosurePenalInterestOnType();
|
||||
if (preClosurePenalInterestOnType.isWholeTerm()) {
|
||||
depositCloseDate = interestCalculatedUpto();
|
||||
} else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) {
|
||||
depositCloseDate = interestPostingUpToDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +362,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
boolean isUserPosting = false;
|
||||
if (postedAsOnTransactionDates.contains(periodInterval.endDate())) {
|
||||
@@ -439,7 +448,7 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isInterestTransfer = false;
|
||||
final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation());
|
||||
final List<SavingsAccountTransactionDetailsForPostingPeriod> savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList(
|
||||
transactions);
|
||||
transactionsForCurrentTermInterestCalculation(transactions));
|
||||
|
||||
for (final LocalDateInterval periodInterval : postingPeriodIntervals) {
|
||||
final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate());
|
||||
@@ -525,6 +534,12 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
|
||||
this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType);
|
||||
|
||||
// RBI audit trail: record why, not just that, this closure happened -- see
|
||||
// PrematureClosureReasonType. Absent entirely on a request from before this field existed,
|
||||
// which resolves to INVALID/null here rather than a misleading "Normal".
|
||||
final Integer prematureClosureReasonId = command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName);
|
||||
this.accountTermAndPreClosure.updatePrematureClosureReason(PrematureClosureReasonType.fromInt(prematureClosureReasonId));
|
||||
|
||||
/*
|
||||
* // withdraw deposit amount before closing the account final Money transactionAmountMoney =
|
||||
* Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw =
|
||||
@@ -789,6 +804,13 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
|
||||
}
|
||||
|
||||
/** See {@link #waivePreClosurePenaltyForThisRequest}. Call before {@link #calculatePreMatureAmount} (preview)
|
||||
* or {@link #postPreMaturityInterest} (the real closure) -- both read the flag at the same point in the
|
||||
* same interest calculation this waives a part of. */
|
||||
public void setWaivePreClosurePenaltyForThisRequest(final boolean waive) {
|
||||
this.waivePreClosurePenaltyForThisRequest = waive;
|
||||
}
|
||||
|
||||
public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure,
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
|
||||
|
||||
@@ -807,17 +829,22 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
final Integer financialYearBeginningMonth) {
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
|
||||
try {
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
} finally {
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
|
||||
return interestOnMaturity;
|
||||
}
|
||||
|
||||
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
|
||||
@@ -909,12 +936,36 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest already posted for the term currently running, NOT the account's whole lifetime.
|
||||
* <p>
|
||||
* A renewed account keeps every prior term's transactions in {@code this.transactions} -- including
|
||||
* whatever interest was posted (and already paid out, via the renewal's own withdrawal/transfer leg)
|
||||
* for a term that has since closed. Summing all of them here double-counted that stale interest: a
|
||||
* premature closure of the CURRENT term subtracted it from the maturity amount
|
||||
* ({@code accountBalance - interestPostedToDate + interestEarnedTillDate}) even though it was never
|
||||
* part of this term's balance to begin with, silently shortchanging the customer by exactly that
|
||||
* amount -- enough to make a freshly-renewed FD's premature-closure amount come out BELOW its
|
||||
* deposit amount, penalty or no penalty. Scoped to {@link #depositStartDate()} for the same reason
|
||||
* {@link #isRenewedTerm()} and the posting-period calculation already are: interest posted before
|
||||
* the term restarted belongs to a term that has already been settled.
|
||||
*/
|
||||
private Money totalInterestPosted() {
|
||||
final boolean renewedTerm = isRenewedTerm();
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
Money interestPostedToDate = Money.zero(this.currency);
|
||||
for (final SavingsAccountTransaction transaction : this.transactions) {
|
||||
if (transaction.isInterestPostingAndNotReversed()) {
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
if (!transaction.isInterestPostingAndNotReversed()) {
|
||||
continue;
|
||||
}
|
||||
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
|
||||
// previous term's closing interest posting is dated the SAME day the new term starts, so an
|
||||
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
|
||||
// the old term's entire interest subtracted from a maturity amount it was never part of.
|
||||
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
continue;
|
||||
}
|
||||
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
|
||||
}
|
||||
|
||||
return interestPostedToDate;
|
||||
@@ -960,6 +1011,33 @@ public class FixedDepositAccount extends SavingsAccount {
|
||||
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* A renewed term's opening balance is seeded straight to the term's own principal (see
|
||||
* calculateInterestPayable/projectInterestSchedule) -- which is already the balance AS OF
|
||||
* depositStartDate(), i.e. net of whatever transaction closed out the previous term. That closing
|
||||
* transaction is dated the SAME day the new term starts (a renewal is atomic: the old term is closed
|
||||
* and the new one opened in the same stroke), so without this filter it still falls inside the new
|
||||
* term's posting-period walk and gets applied a SECOND time on top of the already-net seed --
|
||||
* understating the very first day's balance by the old term's entire payout, which silently corrupts
|
||||
* that day's interest for as long as the term keeps re-including it (every premature-closure preview,
|
||||
* and any later maturity recalculation). Scoping to transactions strictly after the term start avoids
|
||||
* the double-count; a genuine new transaction can't land on the renewal day itself for an FD anyway.
|
||||
*/
|
||||
private List<SavingsAccountTransaction> transactionsForCurrentTermInterestCalculation(
|
||||
final List<SavingsAccountTransaction> transactions) {
|
||||
if (!isRenewedTerm()) {
|
||||
return transactions;
|
||||
}
|
||||
final LocalDate currentTermStartDate = depositStartDate();
|
||||
final List<SavingsAccountTransaction> scoped = new ArrayList<>();
|
||||
for (final SavingsAccountTransaction transaction : transactions) {
|
||||
if (DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
|
||||
scoped.add(transaction);
|
||||
}
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
private LocalDate interestCalculatedUpto() {
|
||||
LocalDate uptoMaturityDate = calculateMaturityDate();
|
||||
if (uptoMaturityDate != null) {
|
||||
|
||||
+13
-8
@@ -759,17 +759,22 @@ public class RecurringDepositAccount extends SavingsAccount {
|
||||
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
final Integer financialYearBeginningMonth) {
|
||||
final MathContext mc = MathContext.DECIMAL64;
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
|
||||
try {
|
||||
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
Money interestOnMaturity = Money.zero(this.currency);
|
||||
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
for (final PostingPeriod interestPostingPeriod : postingPeriods) {
|
||||
final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned();
|
||||
interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod);
|
||||
}
|
||||
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
|
||||
return interestOnMaturity;
|
||||
} finally {
|
||||
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
|
||||
return interestOnMaturity;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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.fdinterest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Posts approved fixed deposit interest reports, one transaction each.
|
||||
*
|
||||
* <p>
|
||||
* Called as a branch closes its day. A report that cannot be posted does not take the others down
|
||||
* with it: its failure is written to the report, which stays APPROVED, and reported back to the
|
||||
* caller so the close can be refused with a reason the branch can act on. Closing again once the
|
||||
* cause is fixed posts it.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositInterestPostingOrchestrator {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FixedDepositInterestPostingService postingService;
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Posts each report in a transaction of its own.
|
||||
*
|
||||
* @return one message per report that could not be posted, empty where all of them were
|
||||
*/
|
||||
public List<String> postReports(final List<Long> reportIds) {
|
||||
final TransactionTemplate newTransaction = new TransactionTemplate(this.transactionManager);
|
||||
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
|
||||
final List<String> failures = new ArrayList<>();
|
||||
for (final Long reportId : reportIds) {
|
||||
final Map<String, Object> report = this.jdbcTemplate
|
||||
.queryForMap("SELECT fd_product_id, posting_date FROM m_fd_interest_posting_report WHERE id = ?", reportId);
|
||||
final long productId = ((Number) report.get("fd_product_id")).longValue();
|
||||
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
try {
|
||||
final BigDecimal paid = newTransaction.execute(status -> this.postingService.postReport(reportId));
|
||||
log.info("FD interest: report {} for product {} posted {} for {}", reportId, productId, paid, postingDate);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("FD interest: report {} for product {} failed to post for {}", reportId, productId, postingDate, e);
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
this.reportService.recordPostingFailure(reportId, e.getMessage());
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_run
|
||||
SET status = 'FAILED', failure_reason = ?
|
||||
WHERE report_id = ?
|
||||
""", truncate(e.getMessage()), reportId);
|
||||
});
|
||||
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static String truncate(final String reason) {
|
||||
if (reason == null) {
|
||||
return "unknown failure";
|
||||
}
|
||||
return reason.length() > 1000 ? reason.substring(0, 1000) : reason;
|
||||
}
|
||||
}
|
||||
+1011
File diff suppressed because it is too large
Load Diff
+527
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* 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.fdinterest;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Pays an approved fixed deposit interest posting report.
|
||||
*
|
||||
* <p>
|
||||
* Nothing is recomputed here. The report's lines are the authority: each deposit is credited exactly
|
||||
* what was approved for it, and 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>
|
||||
* The ledger entries are the three stages fixed deposit interest has always posted through, raised
|
||||
* once for the whole report rather than once per deposit — the same pooling the savings interest
|
||||
* posting report uses, for the same reason: a bank's interest cost is one figure in its books, not
|
||||
* one line per customer repeated three times over.
|
||||
*
|
||||
* <pre>
|
||||
* Dr Interest Expense (report total) → Cr Fixed Deposit Interest Payable
|
||||
* Dr Interest Payable (report total) → Cr Fixed Deposit Interest Paid
|
||||
* Dr Interest Paid (report total, once)
|
||||
* Cr Savings Control (per deposit, naming the account and its own amount)
|
||||
* </pre>
|
||||
*
|
||||
* A product that names no paid account posts the two-stage form, and one that names neither falls
|
||||
* back to the plain Dr Expense / Cr Control pair — pooled the same way, one expense debit against many
|
||||
* credits. Where one report holds deposits due for more than one period — a deposit owed two unpaid
|
||||
* cycles, or a branch posting a stale cycle alongside today's — each {@code interestPostingDate}
|
||||
* pools on its own: the chain above is raised once per date present on the report, not once overall,
|
||||
* because the narration and the ledger date belong to the cycle, not to the day it happened to post.
|
||||
*
|
||||
* <p>
|
||||
* Where the interest then goes is the product's scheme, and it is not decided here. A
|
||||
* {@code SPECIAL} deposit carries {@code transfer_interest_to_linked_account}, and the existing
|
||||
* transfer-interest-to-savings job sweeps the posted interest to the customer's linked savings
|
||||
* account as it always has; {@code SINGLE} and {@code DOUBLE} keep it in the deposit. Posting is the
|
||||
* same act for all three — the money reaches the customer's deposit — so this service does one thing
|
||||
* and the sweep is left where it already works.
|
||||
*
|
||||
* <p>
|
||||
* A deposit can mature between its report being generated and being posted, and a matured deposit's
|
||||
* last period is settled directly by the maturity job the moment it matures — see
|
||||
* {@code FixedDepositAccount#postMaturityInterest}, which this report's generation step cannot see
|
||||
* coming and does not block on. Posting checks for that first: a line whose deposit already carries an
|
||||
* interest posting for its exact date is linked to what actually paid it rather than paid again, and
|
||||
* its amount is reconciled off the report's approved total rather than failing the whole posting over
|
||||
* money that was never going to move twice.
|
||||
*
|
||||
* <p>
|
||||
* The whole report settles in one transaction: a failure leaves nobody half paid and the report still
|
||||
* APPROVED for the next attempt.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositInterestPostingService {
|
||||
|
||||
private static final int MONEY_SCALE = 2;
|
||||
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||
|
||||
/** m_savings_account_transaction.transaction_type_enum */
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||
/** {@code CashAccountsForSavings.SAVINGS_CONTROL} */
|
||||
private static final int GL_SAVINGS_CONTROL = 2;
|
||||
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense interest is charged to. */
|
||||
private static final int GL_INTEREST_ON_SAVINGS = 3;
|
||||
/** {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product. */
|
||||
private static final int GL_INTEREST_PAYABLE = 17;
|
||||
|
||||
/**
|
||||
* The reserved names a deposit product may map its payable and paid accounts under, most specific
|
||||
* first. The same lists the scheduler reads, so a product configured for the old path needs no
|
||||
* change for the new one.
|
||||
*/
|
||||
private static final String[] PAYABLE_NAMES = { "Fixed Deposit Interest Payable", "Savings Interest Payable",
|
||||
"Recurring Deposit Interest Payable", "Interest Payable", "Interest Payable Account" };
|
||||
private static final String[] PAID_NAMES = { "Fixed Deposit Interest Paid", "Savings Interest Paid",
|
||||
"Recurring Deposit Interest Paid", "Interest Paid", "Interest Paid Account" };
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The payable and paid accounts a product's interest passes through. */
|
||||
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
|
||||
|
||||
/**
|
||||
* One deposit's credit, after its transaction and balances are written but before the pooled ledger
|
||||
* legs are raised — grouped with the others of its {@code interestDate} so the whole group is posted
|
||||
* as one chain.
|
||||
*/
|
||||
private record PaidLine(Long accountId, BigDecimal amount, LocalDate interestDate, Long transactionId) {}
|
||||
|
||||
/**
|
||||
* What one line's posting came to: either a new credit to pool with the rest of its date-group, or
|
||||
* an amount that turned out to be settled already — a deposit that matured, and had this exact
|
||||
* period's interest posted directly by the maturity job, between this report being generated and
|
||||
* being posted. {@code paid} is null in that case, and {@code reconciledAway} carries the amount so
|
||||
* the report's approved total can be reconciled down by it rather than the whole posting failing a
|
||||
* balance check over money that was never going to move twice.
|
||||
*/
|
||||
private record DepositOutcome(PaidLine paid, BigDecimal reconciledAway) {}
|
||||
|
||||
/**
|
||||
* The payable and paid accounts as the product maps them now, read when a report is generated so a
|
||||
* product missing one is refused before anybody approves figures that could never be posted.
|
||||
*
|
||||
* <p>
|
||||
* Unlike savings, neither is required: a deposit product that names no payable posts the plain two
|
||||
* legs it always did, and nulls here say so rather than stopping the report.
|
||||
*/
|
||||
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
|
||||
if (optionalMappedGlAccount(productId, GL_INTEREST_ON_SAVINGS) == null) {
|
||||
final String productName = this.jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
|
||||
productId);
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.gl.not.mapped",
|
||||
"Fixed deposit product '" + productName + "' has no Interest On Savings GL mapped. Set it under the product's"
|
||||
+ " Accounting and generate the report again.");
|
||||
}
|
||||
Long payableGl = optionalMappedGlAccount(productId, GL_INTEREST_PAYABLE);
|
||||
if (payableGl == null) {
|
||||
payableGl = namedGlAccount(productId, PAYABLE_NAMES);
|
||||
}
|
||||
return new PostingGlAccounts(payableGl, namedGlAccount(productId, PAID_NAMES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts one approved report, crediting each deposit exactly what was approved for it.
|
||||
*
|
||||
* <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 date the interest is for.
|
||||
*
|
||||
* @return the amount distributed
|
||||
*/
|
||||
@Transactional
|
||||
public BigDecimal postReport(final long reportId) {
|
||||
final List<Map<String, Object>> reports = this.jdbcTemplate.queryForList("""
|
||||
SELECT id, fd_product_id, fd_type, office_id, posting_date, status, total_interest, payable_gl_account_id,
|
||||
paid_gl_account_id
|
||||
FROM m_fd_interest_posting_report
|
||||
WHERE id = ?
|
||||
FOR UPDATE
|
||||
""", reportId);
|
||||
if (reports.isEmpty()) {
|
||||
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " does not exist");
|
||||
}
|
||||
final Map<String, Object> report = reports.get(0);
|
||||
if (!FixedDepositInterestPostingReportService.APPROVED.equals(report.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Fixed deposit interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
|
||||
}
|
||||
final long productId = ((Number) report.get("fd_product_id")).longValue();
|
||||
final long officeId = ((Number) report.get("office_id")).longValue();
|
||||
final LocalDate interestFor = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal approved = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
|
||||
final List<Map<String, Object>> lines = this.jdbcTemplate.queryForList("""
|
||||
SELECT l.id AS line_id, l.fd_account_id, l.interest_posting_date, l.interest_amount, l.fd_transaction_id,
|
||||
sa.currency_code
|
||||
FROM m_fd_interest_posting_report_line l
|
||||
JOIN m_savings_account sa ON sa.id = l.fd_account_id
|
||||
WHERE l.report_id = ?
|
||||
ORDER BY l.interest_posting_date, l.fd_account_id
|
||||
""", reportId);
|
||||
if (lines.isEmpty()) {
|
||||
throw new IllegalStateException("Fixed deposit interest posting report " + reportId + " has no deposits to pay");
|
||||
}
|
||||
|
||||
final long runId = openRun(reportId, productId, officeId, interestFor, lines.size());
|
||||
|
||||
final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS);
|
||||
final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL);
|
||||
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.FD_INTEREST_POSTED, postedOn, reportId);
|
||||
|
||||
// Each deposit's transaction and balances are settled first; the ledger is raised after, pooled
|
||||
// by the date its interest belongs to, so a report spanning two cycles raises two chains rather
|
||||
// than crediting one period's money under the other's date.
|
||||
final Map<LocalDate, List<PaidLine>> byDate = new LinkedHashMap<>();
|
||||
BigDecimal distributed = BigDecimal.ZERO;
|
||||
BigDecimal reconciledAway = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> line : lines) {
|
||||
final DepositOutcome outcome = payDeposit(line, officeId, postedOn);
|
||||
if (outcome.paid() != null) {
|
||||
byDate.computeIfAbsent(outcome.paid().interestDate(), key -> new ArrayList<>()).add(outcome.paid());
|
||||
distributed = distributed.add(outcome.paid().amount());
|
||||
}
|
||||
reconciledAway = reconciledAway.add(outcome.reconciledAway());
|
||||
}
|
||||
|
||||
// A line reconciled away was never going to move twice: its amount is taken off what this
|
||||
// posting is expected to distribute rather than failing the whole report over money that is
|
||||
// already in the customer's deposit by another, older path. What was actually paid here still
|
||||
// has to add up exactly to what is left.
|
||||
final BigDecimal expected = approved.subtract(reconciledAway);
|
||||
if (expected.compareTo(distributed) != 0) {
|
||||
throw new IllegalStateException(
|
||||
String.format("Fixed deposit interest posting report %d did not balance: approved %s, reconciled away %s,"
|
||||
+ " distributed %s", reportId, approved, reconciledAway, distributed));
|
||||
}
|
||||
|
||||
for (final Map.Entry<LocalDate, List<PaidLine>> group : byDate.entrySet()) {
|
||||
postPooledLedgerLegs(officeId, currency, group.getKey(), postedOn, group.getValue(), reference, expenseGl, payableGl, paidGl,
|
||||
controlGl);
|
||||
}
|
||||
|
||||
completeReport(reportId, productId, interestFor, approved, distributed, runId, lines.size());
|
||||
return distributed;
|
||||
}
|
||||
|
||||
/**
|
||||
* One deposit's interest transaction and balances, settled without raising any ledger entry — the
|
||||
* caller pools this with the rest of its date-group before the GL is touched.
|
||||
*
|
||||
* <p>
|
||||
* The transaction carries the date the interest engine gave the period, not the day the branch is
|
||||
* closing. That is not presentation: the engine decides a period has already been paid by looking
|
||||
* for an interest transaction on exactly that date, so a posting written under the close date would
|
||||
* be invisible to it and the same period would be computed and paid again at the next run. The
|
||||
* close date is kept as the submitted-on stamp, which is what the scheduler has always done.
|
||||
*
|
||||
* <p>
|
||||
* Checked first against the deposit's own transactions for one this report did not put there: a
|
||||
* deposit can mature between its report being generated and being posted, and the maturity job
|
||||
* settles a matured deposit's last period itself, directly, the moment it matures — see
|
||||
* {@code FixedDepositAccount#postMaturityInterest}. Where that has already happened for this exact
|
||||
* date, the line is linked to what actually paid it rather than paying it again.
|
||||
*/
|
||||
private DepositOutcome payDeposit(final Map<String, Object> line, final long officeId, final LocalDate postedOn) {
|
||||
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||
final long accountId = ((Number) line.get("fd_account_id")).longValue();
|
||||
final long lineId = ((Number) line.get("line_id")).longValue();
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return new DepositOutcome(null, BigDecimal.ZERO);
|
||||
}
|
||||
final LocalDate interestDate = ((java.sql.Date) line.get("interest_posting_date")).toLocalDate();
|
||||
|
||||
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
|
||||
// payment, so this holds even where one deposit has two periods on one report.
|
||||
if (line.get("fd_transaction_id") != null) {
|
||||
throw new IllegalStateException("Fixed deposit " + accountId + " was already paid by this report");
|
||||
}
|
||||
|
||||
final Long existing = findExistingInterestPosting(accountId, interestDate);
|
||||
if (existing != null) {
|
||||
log.warn(
|
||||
"FD interest report line {}: deposit {} already has an interest posting for {} (transaction {}) not raised by this"
|
||||
+ " report — most likely matured in the meantime. Linking to it instead of posting again.",
|
||||
lineId, accountId, interestDate, existing);
|
||||
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", existing, lineId);
|
||||
return new DepositOutcome(null, amount);
|
||||
}
|
||||
|
||||
// The balance as it stands now, not as the report showed it: money may have moved since the
|
||||
// report was generated, and the transaction's running balance has to follow the deposit.
|
||||
final BigDecimal currentBalance = this.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, interestDate, postedOn, amount, newBalance);
|
||||
|
||||
// Earned is only ever raised to what has now been posted, never added to: the engine may
|
||||
// already have counted this interest as earned, and adding on top of that doubles it. Screens
|
||||
// read unposted interest as earned - posted, so earned must not fall below posted either.
|
||||
//
|
||||
// interest_posted_till_date is what takes the deposit out of the next report's candidates; the
|
||||
// interest transaction itself is what stops the engine recomputing the same period.
|
||||
this.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) + ?),
|
||||
interest_posted_till_date = GREATEST(COALESCE(interest_posted_till_date, ?), ?)
|
||||
WHERE id = ?
|
||||
""", amount, amount, amount, interestDate, interestDate, accountId);
|
||||
|
||||
this.jdbcTemplate.update("UPDATE m_fd_interest_posting_report_line SET fd_transaction_id = ? WHERE id = ?", transactionId,
|
||||
lineId);
|
||||
|
||||
return new DepositOutcome(new PaidLine(accountId, amount, interestDate, transactionId), BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
/** A non-reversed interest posting this deposit already carries for this exact date, if any. */
|
||||
private Long findExistingInterestPosting(final long accountId, final LocalDate interestDate) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_type_enum = ? AND transaction_date = ? AND is_reversed = false
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", Long.class, accountId, INTEREST_POSTING, interestDate);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* One date-group's chain, pooled: the report's cost moves through the fewer-or-more intermediate
|
||||
* accounts the product's mappings support as one figure, and only the last leg — crediting each
|
||||
* deposit — is raised per account. The group's own total is what moves, not the report's, so two
|
||||
* periods on one report each carry their own entries under their own date.
|
||||
*/
|
||||
private void postPooledLedgerLegs(final long officeId, final String currency, final LocalDate interestDate, final LocalDate postedOn,
|
||||
final List<PaidLine> group, final String reference, final Long expenseGl, final Long payableGl, final Long paidGl,
|
||||
final Long controlGl) {
|
||||
final String narration = "Fixed deposit interest for " + interestDate;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final PaidLine paid : group) {
|
||||
total = total.add(paid.amount());
|
||||
}
|
||||
|
||||
final boolean hasPayable = payableGl != null && payableGl != 0 && !payableGl.equals(controlGl);
|
||||
final boolean hasPaid = paidGl != null && paidGl != 0 && !paidGl.equals(payableGl);
|
||||
|
||||
// The chain of pooled accounts the group's cost passes through before it is split: Expense, then
|
||||
// Payable and Paid where the product maps them — each consecutive pair raised once for the whole
|
||||
// total. The last account in the chain is what Stage 2 debits before crediting each deposit.
|
||||
final List<Long> chain = new ArrayList<>();
|
||||
chain.add(expenseGl);
|
||||
if (hasPayable) {
|
||||
chain.add(payableGl);
|
||||
if (hasPaid) {
|
||||
chain.add(paidGl);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < chain.size() - 1; i++) {
|
||||
journalEntry(officeId, currency, chain.get(i), interestDate, postedOn, total, true, reference, null, null, narration);
|
||||
journalEntry(officeId, currency, chain.get(i + 1), interestDate, postedOn, total, false, reference, null, null, narration);
|
||||
}
|
||||
|
||||
// Stage 2: one debit for the group's total, and under it a credit to each deposit. 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, chain.get(chain.size() - 1), interestDate, postedOn, total, true, reference, null, null,
|
||||
narration);
|
||||
for (final PaidLine paid : group) {
|
||||
journalEntry(officeId, currency, controlGl, interestDate, postedOn, paid.amount(), false, reference, paid.accountId(),
|
||||
paid.transactionId(), narration);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeReport(final long reportId, final long productId, final LocalDate interestFor, final BigDecimal approved,
|
||||
final BigDecimal distributed, final long runId, final int accountCount) {
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_run
|
||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
|
||||
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", approved, distributed, runId);
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_fd_interest_posting_report
|
||||
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
|
||||
WHERE id = ?
|
||||
""", runId, reportId);
|
||||
log.info("FD interest posting: report {} (product {}, for {}) paid {} across {} deposits", reportId, productId, interestFor,
|
||||
distributed, 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 postingDate,
|
||||
final int accountCount) {
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO m_fd_interest_posting_run
|
||||
(report_id, fd_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
|
||||
""", reportId, productId, officeId, postingDate, accountCount);
|
||||
return this.jdbcTemplate.queryForObject("SELECT id FROM m_fd_interest_posting_run WHERE report_id = ?", Long.class, reportId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param interestDate
|
||||
* the period the interest is for, which is the transaction's own date
|
||||
* @param postedOn
|
||||
* the business date it is being written on, kept as the submitted-on stamp
|
||||
*/
|
||||
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate interestDate,
|
||||
final LocalDate postedOn, final BigDecimal amount, final BigDecimal runningBalance) {
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_account_transaction
|
||||
(savings_account_id, office_id, is_reversed, is_reversal, is_lien_transaction,
|
||||
transaction_type_enum, transaction_date, amount, running_balance_derived,
|
||||
submitted_on_date, created_by, last_modified_by, created_on_utc, last_modified_on_utc)
|
||||
VALUES (?, ?, false, false, false, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", accountId, officeId, INTEREST_POSTING, interestDate, amount, runningBalance, postedOn, systemUserId(),
|
||||
systemUserId());
|
||||
return this.jdbcTemplate.queryForObject("""
|
||||
SELECT id FROM m_savings_account_transaction
|
||||
WHERE savings_account_id = ? AND transaction_date = ? AND transaction_type_enum = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""", Long.class, accountId, interestDate, INTEREST_POSTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* One journal entry leg. Fineract's {@code type_enum} is 2 = DEBIT, 1 = CREDIT — {@code
|
||||
* JournalEntryType} declares {@code CREDIT(1)} first.
|
||||
*
|
||||
* <p>
|
||||
* Dated the period the interest is for, as the scheduler dates its own legs, with the business date
|
||||
* kept as the submitted-on stamp.
|
||||
*/
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate interestDate,
|
||||
final LocalDate postedOn, final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||
final Long transactionId, final String narration) {
|
||||
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
|
||||
this.jdbcTemplate.update("""
|
||||
INSERT INTO acc_gl_journal_entry
|
||||
(office_id, account_id, currency_code, transaction_id, entry_date, transaction_date, type_enum,
|
||||
amount, description, entity_type_enum, entity_id, is_running_balance_calculated,
|
||||
savings_transaction_id, reversed, manual_entry, office_running_balance,
|
||||
organization_running_balance, created_by, last_modified_by, created_date, lastmodified_date,
|
||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
""", officeId, glAccountId, currency, reference, interestDate, interestDate, debit ? 2 : 1, amount, narration,
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId, systemUserId(), systemUserId(),
|
||||
postedOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user these rows are attributed to. A day close has a user, but the nightly retry does not and
|
||||
* the audit columns are NOT NULL, so entries are stamped to the system account either way — as the
|
||||
* scheduler's own postings always were.
|
||||
*/
|
||||
private Long systemUserId() {
|
||||
// id 2 is the seeded system user; its username is per-tenant now, so it is found by id
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList("SELECT id FROM m_appuser WHERE id = 2", Long.class);
|
||||
return ids.isEmpty() ? 1L : ids.get(0);
|
||||
}
|
||||
|
||||
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final Long found = optionalMappedGlAccount(productId, financialAccountType);
|
||||
if (found == null) {
|
||||
throw new IllegalStateException("Fixed deposit product " + productId + " has no GL account mapped for financial account type "
|
||||
+ financialAccountType + ". Set the product's Accounting tab before its interest can be posted.");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT gl_account_id FROM acc_product_mapping
|
||||
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||
LIMIT 1
|
||||
""", Long.class, productId, financialAccountType);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account a product maps under one of the reserved names in {@code other_gl_mappings}, tried
|
||||
* in order of preference. The column holds an empty string on a product saved with no named
|
||||
* mappings and casting that to jsonb is an error, so the cast sits inside a subquery that has
|
||||
* already filtered those rows out.
|
||||
*/
|
||||
private Long namedGlAccount(final long productId, final String... accountNames) {
|
||||
for (final String accountName : accountNames) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("""
|
||||
SELECT gl.id
|
||||
FROM (SELECT other_gl_mappings::jsonb AS doc
|
||||
FROM m_savings_product
|
||||
WHERE id = ?
|
||||
AND other_gl_mappings IS NOT NULL
|
||||
AND other_gl_mappings <> ''
|
||||
AND other_gl_mappings LIKE '{%') sp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|
||||
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
|
||||
) AS m(entry)
|
||||
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
|
||||
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
|
||||
LIMIT 1
|
||||
""", Long.class, productId, accountName);
|
||||
if (!found.isEmpty()) {
|
||||
return found.get(0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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.fdinterest.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.configuration.domain.ConfigurationDomainService;
|
||||
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.fdinterest.FixedDepositInterestPostingReportService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The fixed deposit 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 interest reaches a customer's deposit.
|
||||
*/
|
||||
@Path("/v1/fixeddepositinterestpostingreports")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class FixedDepositInterestPostingReportApiResource {
|
||||
|
||||
private static final String RESOURCE = "FDINTERESTREPORT";
|
||||
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
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 = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson
|
||||
.toJson(this.reportService.list(parseDate(postingDate), status, productId, officeId, scope, user).stream()
|
||||
.map(this::plain).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is looking: their branch, whether it is the head office (which gets the Other branches tab),
|
||||
* and whether deposits are on the approval path at all — with the switch off every screen here is
|
||||
* empty by design, and the UI should say so rather than look broken.
|
||||
*/
|
||||
@GET
|
||||
@Path("/context")
|
||||
public String userContext() {
|
||||
final AppUser user = this.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", this.reportService.isHeadOffice(officeId));
|
||||
result.put("approvalEnabled", this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled());
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Branch and product pairs with deposit interest due by the date and no report yet. */
|
||||
@GET
|
||||
@Path("/due-without-report")
|
||||
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||
return this.gson.toJson(this.reportService.dueWithoutReport(date, user, scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops a branch closing the business day: its own deposit reports due and not yet approved,
|
||||
* and the products and amounts behind them. 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 = this.context.authenticatedUser();
|
||||
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
|
||||
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||
final Map<String, Object> view = this.reportService.dayCloseInterestView(officeId, businessDate);
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<String> blocking = (List<String>) view.get("pending");
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", businessDate.toString());
|
||||
result.put("canClose", blocking.isEmpty());
|
||||
result.put("pendingProducts", blocking);
|
||||
result.put("due", view.get("due"));
|
||||
return this.gson.toJson(result);
|
||||
}
|
||||
|
||||
/** What each deposit has earned and not been paid, as if posted today. View only — nothing is saved. */
|
||||
@GET
|
||||
@Path("/preview")
|
||||
public String preview(@QueryParam("officeId") final Long officeId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson.toJson(this.reportService.preview(officeId, user).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/** The head office's view: per branch and product, deposits 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 = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
if (!this.reportService.isHeadOffice(user.getOffice().getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.summary.head.office.only",
|
||||
"The branch-wise fixed deposit interest summary is for the head office.");
|
||||
}
|
||||
return this.gson
|
||||
.toJson(this.reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit interest posted without this report ever seeing it — chiefly a deposit maturing before
|
||||
* its posting cycle completed, whose last period a different, older path settles directly. Here so
|
||||
* a branch can see every rupee of deposit interest it has paid, approved or not.
|
||||
*/
|
||||
@GET
|
||||
@Path("/outside-approval")
|
||||
public String outsideApproval(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("officeId") final Long officeId, @QueryParam("scope") final String scope) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasReadPermission(RESOURCE);
|
||||
return this.gson.toJson(
|
||||
this.reportService.outsideApproval(parseDate(fromDate), parseDate(toDate), officeId, user, scope).stream()
|
||||
.map(this::plain).toList());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{reportId}")
|
||||
public String detail(@PathParam("reportId") final Long reportId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final Map<String, Object> report = plain(this.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 this.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.fdinterestreport.unknown.command", "Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
|
||||
final var root = this.fromJsonHelper.parse(body);
|
||||
final Long productId = this.fromJsonHelper.extractLongNamed("productId", root);
|
||||
final Long officeIdParam = this.fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String postingDate = this.fromJsonHelper.extractStringNamed("postingDate", root);
|
||||
if (productId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.fdinterestreport.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 = this.reportService.regenerate(productId, officeId, date, user);
|
||||
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.GENERATED));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code ?command=approve}. A user of the report's branch who did not generate it 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.fdinterestreport.unknown.command", "Unsupported command: " + command);
|
||||
}
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
|
||||
this.reportService.approve(reportId, user);
|
||||
return this.gson.toJson(Map.of("reportId", reportId, "status", FixedDepositInterestPostingReportService.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.fdinterestreport.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;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.fdinterest.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 FixedDepositInterestPostingReportConfig {
|
||||
|
||||
@Autowired
|
||||
private JobRepository jobRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@Bean
|
||||
protected Step fdInterestPostingReportStep(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
|
||||
return new StepBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.tasklet(fdInterestPostingReportTasklet, transactionManager).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job fdInterestPostingReportJob(final FixedDepositInterestPostingReportTasklet fdInterestPostingReportTasklet) {
|
||||
return new JobBuilder(JobName.FD_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||
.start(fdInterestPostingReportStep(fdInterestPostingReportTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.fdinterest.jobs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingReportService;
|
||||
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 fixed deposit product
|
||||
* whose interest falls due, so a branch can review and approve it before it closes the day.
|
||||
*
|
||||
* <p>
|
||||
* Does nothing while deposits are off the approval path — the posting job is paying them directly,
|
||||
* and a report generated alongside it would be approved against money that has already moved.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FixedDepositInterestPostingReportTasklet implements Tasklet {
|
||||
|
||||
private final FixedDepositInterestPostingReportService reportService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) throws Exception {
|
||||
if (!this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()) {
|
||||
log.info("FD interest posting report: skipped — fixed deposit interest is posted directly by the posting job");
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
this.reportService.generateDue(DateUtils.getBusinessLocalDate());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
}
|
||||
+38
-7
@@ -19,8 +19,11 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.closedOnDateParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.prematureClosureReasonIdParamName;
|
||||
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -32,6 +35,7 @@ import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
|
||||
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountData;
|
||||
import org.apache.fineract.portfolio.savings.data.DepositAccountTransactionDataValidator;
|
||||
import org.apache.fineract.portfolio.savings.data.FixedDepositAccountData;
|
||||
@@ -66,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
|
||||
|
||||
DepositAccountData accountData = null;
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
|
||||
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
|
||||
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
|
||||
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
|
||||
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
|
||||
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
|
||||
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
|
||||
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
|
||||
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
|
||||
DepositAccountOnClosureType.TRANSFER_TO_GL }
|
||||
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
|
||||
@@ -77,13 +89,32 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
|
||||
// calculate interest before one day of closure date
|
||||
final LocalDate interestCalculatedToDate = preMaturityDate.minusDays(1);
|
||||
final boolean isPreMatureClosure = true;
|
||||
// Preview-time only -- the real closure re-reads this same box from its own request when it
|
||||
// actually happens (see DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure), so a
|
||||
// maker previewing with the box checked and then submitting without it (or vice versa) gets
|
||||
// exactly what they last confirmed, not what an earlier preview computed.
|
||||
final boolean waivePreClosurePenalty = this.fromJsonHelper.parameterExists(waivePreClosurePenaltyParamName, element)
|
||||
&& this.fromJsonHelper.extractBooleanNamed(waivePreClosurePenaltyParamName, element);
|
||||
// RBI-mandated waiver reasons (premature extension, conversion to another TD, deceased
|
||||
// settlement, nominee/legal heir closure) waive the penalty regardless of the manual checkbox
|
||||
// above -- see PrematureClosureReasonType. This is enforced here, not just on the client, so
|
||||
// the preview a maker sees always matches what the actual closure will compute.
|
||||
final Integer prematureClosureReasonId = this.fromJsonHelper.extractIntegerSansLocaleNamed(prematureClosureReasonIdParamName,
|
||||
element);
|
||||
final PrematureClosureReasonType prematureClosureReason = PrematureClosureReasonType.fromInt(prematureClosureReasonId);
|
||||
final boolean effectiveWaivePenalty = waivePreClosurePenalty || prematureClosureReason.isAutoWaived();
|
||||
|
||||
if (depositAccountType.isFixedDeposit()) {
|
||||
final FixedDepositAccount fd = (FixedDepositAccount) account;
|
||||
accountData = FixedDepositAccountData.preClosureDetails(
|
||||
account.getId(), fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
|
||||
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
|
||||
fd.setWaivePreClosurePenaltyForThisRequest(effectiveWaivePenalty);
|
||||
final BigDecimal maturityAmount = fd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
// calculatePreMatureAmount() (via getEffectiveInterestRateAsFraction()) leaves the account's
|
||||
// own nominalAnnualInterestRate set to whichever rate it just resolved for THIS preview --
|
||||
// the deposit's own contracted rate, minus any pre-closure penalty -- so read it back here
|
||||
// rather than re-deriving it, to show the maker exactly what applied.
|
||||
accountData = FixedDepositAccountData.preClosureDetails(account.getId(), maturityAmount, onAccountClosureOptions,
|
||||
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
|
||||
accountData = RecurringDepositAccountData.preClosureDetails(
|
||||
|
||||
+5
-1
@@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
|
||||
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
|
||||
depositAccountType.getValue());
|
||||
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
|
||||
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
|
||||
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
|
||||
// normal Close and Payment screens -- would only get it refused by validateClosing.
|
||||
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
|
||||
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
|
||||
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
|
||||
.toArray(DepositAccountOnClosureType[]::new));
|
||||
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
|
||||
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
|
||||
|
||||
+34
-1
@@ -19,10 +19,13 @@
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingEnumerations;
|
||||
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
}
|
||||
}
|
||||
|
||||
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
|
||||
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
|
||||
|
||||
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
|
||||
}
|
||||
|
||||
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
|
||||
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
|
||||
if (!element.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
|
||||
}
|
||||
|
||||
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
|
||||
|
||||
private final String schemaSql;
|
||||
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
|
||||
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
|
||||
sqlBuilder.append("sp.accounting_type as accountingType, ");
|
||||
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
|
||||
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
|
||||
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
|
||||
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
|
||||
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
|
||||
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
|
||||
}
|
||||
|
||||
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
|
||||
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
|
||||
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
|
||||
taxGroupData);
|
||||
final String otherGlMappingsJson = rs.getString("otherGlMappings");
|
||||
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
|
||||
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
return false;
|
||||
}
|
||||
|
||||
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
|
||||
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
|
||||
}
|
||||
|
||||
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
|
||||
return savingsAccountData.isTransferInterestToLinkedAccount()
|
||||
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
|
||||
}
|
||||
|
||||
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
|
||||
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
if (interestTransferWithdrawalIds.isEmpty()) {
|
||||
return transactions;
|
||||
}
|
||||
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
|
||||
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
|
||||
|
||||
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
|
||||
|
||||
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
|
||||
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
|
||||
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
|
||||
orderedNonInterestPostingTransactions.add(transaction);
|
||||
}
|
||||
}
|
||||
@@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
|
||||
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
|
||||
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
|
||||
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
|
||||
savingsAccountData);
|
||||
// loop over transactions in reverse
|
||||
LocalDate endOfBalanceDate = interestPostingUpToDate;
|
||||
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
|
||||
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
|
||||
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
|
||||
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
|
||||
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
|
||||
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
|
||||
|
||||
+75
-2
@@ -36,6 +36,7 @@ import java.util.List;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.accounting.common.AccountingRuleType;
|
||||
import org.apache.fineract.accounting.glaccount.data.GLAccountData;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
|
||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
@@ -109,16 +110,24 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
||||
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
|
||||
/** m_savings_account.status_enum for an active account. */
|
||||
private static final Integer ACTIVE_STATUS = 300;
|
||||
/** m_savings_product.deposit_type_enum for a fixed deposit. */
|
||||
private static final Integer DEPOSIT_TYPE_FIXED = 200;
|
||||
|
||||
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
|
||||
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||
final SavingsDailyInterestService savingsDailyInterestService) {
|
||||
final SavingsDailyInterestService savingsDailyInterestService,
|
||||
final ConfigurationDomainService configurationDomainService) {
|
||||
this.context = context;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.sqlGenerator = sqlGenerator;
|
||||
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
||||
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||
this.configurationDomainService = configurationDomainService;
|
||||
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
|
||||
this.transactionsMapper = new SavingsAccountTransactionsMapper();
|
||||
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
|
||||
@@ -436,8 +445,23 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
|
||||
final Integer status, final Long maxSavingsId) {
|
||||
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
||||
// A product on the daily-interest timetable is paid by the pooled posting instead
|
||||
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
|
||||
// subquery so a page is never cut short by accounts that are then dropped.
|
||||
//
|
||||
// Fixed deposits come out on the same grounds once their interest goes through an approved
|
||||
// report (FixedDepositInterestPostingReportService): the report's figures are posted when the
|
||||
// branch closes its day, and this job running over the same deposits would pay them twice.
|
||||
// Recurring deposits are not part of that gate and still post here either way.
|
||||
final String fixedDepositFilter = this.configurationDomainService.isFixedDepositInterestPostingApprovalEnabled()
|
||||
? "and a.deposit_type_enum <> " + DEPOSIT_TYPE_FIXED + " "
|
||||
: "";
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||
+ "where s.savings_product_id = a.product_id and s.is_active = true) " + fixedDepositFilter
|
||||
+ "limit ?) b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
@@ -455,6 +479,55 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
return savingsAccountDataList;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed deposits of one branch, and optionally one product, whose interest may be due on the
|
||||
* posting date — the candidates an interest posting report is drawn from.
|
||||
*
|
||||
* <p>
|
||||
* "May be" is deliberate. A deposit is selected here on the same cheap test the posting job has
|
||||
* always used, that its interest has not been posted past the day before, and whether a posting
|
||||
* period has actually ended is then settled by the interest engine, which is the only thing that
|
||||
* knows a product's compounding and posting periods. A candidate the engine finds nothing for
|
||||
* simply contributes no line to the report.
|
||||
*
|
||||
* <p>
|
||||
* Unpaged, unlike the posting job: a report covers one branch and one product and is read in full
|
||||
* before anybody approves it, so there is no page for a deposit to fall off the end of.
|
||||
*/
|
||||
@Override
|
||||
public List<SavingsAccountData> retrieveFixedDepositsDueForInterestPosting(final boolean backdatedTxnsAllowedTill,
|
||||
final LocalDate postingDate, final Long officeId, final Long productId) {
|
||||
final StringBuilder candidates = new StringBuilder("select a.id from m_savings_account a ")
|
||||
.append("left join m_client ic on ic.id = a.client_id ")
|
||||
.append("left join m_group ig on ig.id = a.group_id ")
|
||||
.append("where a.status_enum = ? and a.deposit_type_enum = ? ")
|
||||
.append("and (a.interest_posted_till_date is null or a.interest_posted_till_date <= ?) ");
|
||||
final List<Object> params = new ArrayList<>(List.of(ACTIVE_STATUS, DEPOSIT_TYPE_FIXED, postingDate.minusDays(1)));
|
||||
if (officeId != null) {
|
||||
candidates.append("and coalesce(ic.office_id, ig.office_id) = ? ");
|
||||
params.add(officeId);
|
||||
}
|
||||
if (productId != null) {
|
||||
candidates.append("and a.product_id = ? ");
|
||||
params.add(productId);
|
||||
}
|
||||
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema() + "join (" + candidates
|
||||
+ ") b on b.id = sa.id ";
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
sql = sql
|
||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||
}
|
||||
sql = sql + " order by sa.id, tr.transaction_date, tr." + CREATED_DATE_DB_FIELD + ", tr.created_date, tr.id";
|
||||
|
||||
final List<SavingsAccountData> deposits = this.jdbcTemplate.query(sql, this.savingAccountMapperForInterestPosting, // NOSONAR
|
||||
params.toArray());
|
||||
for (final SavingsAccountData deposit : deposits) {
|
||||
this.savingAccountAssembler.assembleSavings(deposit);
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
|
||||
private static final class SavingAccountMapperForInterestPosting implements ResultSetExtractor<List<SavingsAccountData>> {
|
||||
|
||||
private final String schemaSql;
|
||||
|
||||
+2
-2
@@ -430,9 +430,9 @@ public class SavingsConfiguration {
|
||||
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
|
||||
// @Lazy because the accrual service depends on beans from this same package: resolving
|
||||
// both eagerly closes a cycle and the context fails to start.
|
||||
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
|
||||
@Lazy SavingsDailyInterestService savingsDailyInterestService, ConfigurationDomainService configurationDomainService) {
|
||||
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
|
||||
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
|
||||
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService, configurationDomainService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+18
-94
@@ -34,23 +34,19 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
@Path("/v1/sponsorbanks")
|
||||
@Component
|
||||
@@ -59,8 +55,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
public class SponsorBankApiResource {
|
||||
|
||||
private final SponsorBankRepository sponsorBankRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
/** Mirrors SponsorDDApiResource.safely() - see that class for the full rationale. */
|
||||
@@ -125,78 +121,19 @@ public class SponsorBankApiResource {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a payable account that cannot serve as one.
|
||||
*
|
||||
* A DD payable is money the bank owes the sponsor until the draft is presented, so it has to be
|
||||
* a liability account, and it has to accept postings. Catching that here means an administrator
|
||||
* is told while choosing the account, rather than a clerk discovering it when a draft fails.
|
||||
*/
|
||||
private void validateDdPayableGlAccount(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
|
||||
glAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
|
||||
}
|
||||
final Map<String, Object> gl = rows.get(0);
|
||||
final String label = gl.get("gl_code") + " · " + gl.get("name");
|
||||
// 2 is LIABILITY in Fineract's GLAccountType enum.
|
||||
if (((Number) gl.get("classification_enum")).intValue() != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
|
||||
+ label + " is not a liability account.");
|
||||
}
|
||||
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
|
||||
if (((Number) gl.get("account_usage")).intValue() == 2) {
|
||||
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
|
||||
+ label + ".");
|
||||
}
|
||||
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
|
||||
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
|
||||
final Object disabled = gl.get("disabled");
|
||||
final boolean isDisabled = disabled instanceof Boolean b ? b
|
||||
: disabled instanceof Number n && n.intValue() != 0;
|
||||
if (isDisabled) {
|
||||
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create a Sponsor Bank", description = "Creates a new Sponsor Bank.")
|
||||
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
|
||||
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
|
||||
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
|
||||
// CreateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
|
||||
// still comes back as the same 400 it always has, rather than an unmapped 500.
|
||||
return safely(() -> {
|
||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
|
||||
}
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Sponsor Bank Name is required.");
|
||||
}
|
||||
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
// Set field by field rather than through the all-args constructor: that constructor is
|
||||
// positional, so adding a column silently shifts every argument after it, and the shift
|
||||
// type-checks whenever two neighbours share a type.
|
||||
final SponsorBank sponsorBank = new SponsorBank();
|
||||
sponsorBank.setName(request.getName().trim());
|
||||
sponsorBank.setDescription(request.getDescription());
|
||||
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
? request.getIfscCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setBankCode(request.getBankCode());
|
||||
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
|
||||
? request.getSwiftCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
|
||||
sponsorBank.setCreatedBy(currentUser);
|
||||
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
this.sponsorBankRepository.saveAndFlush(sponsorBank);
|
||||
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSponsorBank()
|
||||
.withJson(this.gson.toJson(request)).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,27 +151,14 @@ public class SponsorBankApiResource {
|
||||
@Operation(summary = "Update a Sponsor Bank", description = "Updates a sponsor bank's details and the DD Payable GL account its drafts are credited to.")
|
||||
public CommandProcessingResult updateSponsorBank(@PathParam("sponsorBankId") final Long sponsorBankId,
|
||||
final SponsorBankRequest request) {
|
||||
// The actual validation/persistence now lives in SponsorBankWritePlatformService, invoked via
|
||||
// a NewCommandSourceHandler so the write goes through the generic Maker-Checker gate - see
|
||||
// UpdateSponsorBankCommandHandler. safely() is kept around the call so a validation failure
|
||||
// still comes back as the same 400 it always has, rather than an unmapped 500.
|
||||
return safely(() -> {
|
||||
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
|
||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
|
||||
}
|
||||
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
|
||||
if (request.getName() != null && !request.getName().isBlank()) {
|
||||
sponsorBank.setName(request.getName().trim());
|
||||
}
|
||||
sponsorBank.setDescription(request.getDescription());
|
||||
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
? request.getIfscCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setBankCode(request.getBankCode());
|
||||
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
|
||||
? request.getSwiftCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
|
||||
this.sponsorBankRepository.saveAndFlush(sponsorBank);
|
||||
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorBank(sponsorBankId)
|
||||
.withJson(this.gson.toJson(request)).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+26
-10
@@ -41,6 +41,9 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDBatchIssueResult;
|
||||
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDCashierDepositOptionData;
|
||||
@@ -76,6 +79,7 @@ public class SponsorDDApiResource {
|
||||
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
|
||||
private final SponsorDDRevokeRequestWritePlatformService revokeRequestWritePlatformService;
|
||||
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final PlatformSecurityContext context;
|
||||
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
@@ -539,16 +543,28 @@ public class SponsorDDApiResource {
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Configure a Sponsor DD GL mapping", description = "Creates or updates the DD Payable / cheque-clearing GL accounts for a sponsor bank / payment type.")
|
||||
public CommandProcessingResult upsertGlMapping(final GlMappingRequest request) {
|
||||
return safely(() -> this.issueRequestWritePlatformService.upsertGlMapping(
|
||||
request.getSponsorBankId(),
|
||||
request.getDdPayableGlAccountId(),
|
||||
request.getChequeClearingGlAccountId(),
|
||||
request.getCashGlAccountId(),
|
||||
request.getChargesIncomeGlAccountId(),
|
||||
request.getCashSuspenseGlAccountId(),
|
||||
request.getGstPercentage(),
|
||||
request.getGstPayableGlAccountId(),
|
||||
request.getDdLimitAmount()));
|
||||
// Routed through the generic command-source pipeline (CommandWrapperBuilder ->
|
||||
// logCommandSource() -> UpdateSponsorDdGlMappingCommandHandler) so this genuinely gains
|
||||
// Maker-Checker support; the write itself (issueRequestWritePlatformService.upsertGlMapping)
|
||||
// is unchanged and now called from that handler instead of directly from here. The JSON is
|
||||
// built by hand (not Gson-serializing `request` directly) purely to include a "locale" key -
|
||||
// JsonCommand's BigDecimal extractor requires one, and GlMappingRequest has no such field.
|
||||
return safely(() -> {
|
||||
final JsonObject json = new JsonObject();
|
||||
json.addProperty("sponsorBankId", request.getSponsorBankId());
|
||||
json.addProperty("ddPayableGlAccountId", request.getDdPayableGlAccountId());
|
||||
json.addProperty("chequeClearingGlAccountId", request.getChequeClearingGlAccountId());
|
||||
json.addProperty("cashGlAccountId", request.getCashGlAccountId());
|
||||
json.addProperty("chargesIncomeGlAccountId", request.getChargesIncomeGlAccountId());
|
||||
json.addProperty("cashSuspenseGlAccountId", request.getCashSuspenseGlAccountId());
|
||||
json.addProperty("gstPercentage", request.getGstPercentage());
|
||||
json.addProperty("gstPayableGlAccountId", request.getGstPayableGlAccountId());
|
||||
json.addProperty("ddLimitAmount", request.getDdLimitAmount());
|
||||
json.addProperty("locale", "en");
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSponsorDdGlMapping()
|
||||
.withJson(this.gson.toJson(json)).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@POST
|
||||
|
||||
+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.sponsordd.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SPONSORBANK", action = "CREATE")
|
||||
public class CreateSponsorBankCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SponsorBankWritePlatformService sponsorBankWritePlatformService;
|
||||
|
||||
@Autowired
|
||||
public CreateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) {
|
||||
this.sponsorBankWritePlatformService = sponsorBankWritePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final SponsorBankRequest request = new SponsorBankRequest();
|
||||
request.setName(command.stringValueOfParameterNamed("name"));
|
||||
request.setDescription(command.stringValueOfParameterNamed("description"));
|
||||
request.setIfscCode(command.stringValueOfParameterNamed("ifscCode"));
|
||||
request.setBankCode(command.stringValueOfParameterNamed("bankCode"));
|
||||
request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode"));
|
||||
request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId"));
|
||||
|
||||
final CommandProcessingResult result = this.sponsorBankWritePlatformService.createSponsorBank(request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(result.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+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.portfolio.sponsordd.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorBankWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SPONSORBANK", action = "UPDATE")
|
||||
public class UpdateSponsorBankCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SponsorBankWritePlatformService sponsorBankWritePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateSponsorBankCommandHandler(final SponsorBankWritePlatformService sponsorBankWritePlatformService) {
|
||||
this.sponsorBankWritePlatformService = sponsorBankWritePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final SponsorBankRequest request = new SponsorBankRequest();
|
||||
request.setName(command.stringValueOfParameterNamed("name"));
|
||||
request.setDescription(command.stringValueOfParameterNamed("description"));
|
||||
request.setIfscCode(command.stringValueOfParameterNamed("ifscCode"));
|
||||
request.setBankCode(command.stringValueOfParameterNamed("bankCode"));
|
||||
request.setSwiftCode(command.stringValueOfParameterNamed("swiftCode"));
|
||||
request.setDdPayableGlAccountId(command.longValueOfParameterNamed("ddPayableGlAccountId"));
|
||||
|
||||
final CommandProcessingResult result = this.sponsorBankWritePlatformService.updateSponsorBank(command.entityId(),
|
||||
request);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(result.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.handler;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The underlying write ({@link SponsorDDIssueRequestWritePlatformService#upsertGlMapping}) is a
|
||||
* genuine upsert - one call creates the single {@code m_sponsor_dd_gl_mapping} row the first time and
|
||||
* updates it every time after - so there is exactly one command/permission here rather than a
|
||||
* separate CREATE and UPDATE, matching that reality instead of inventing a split the endpoint has
|
||||
* never had.
|
||||
*/
|
||||
@Service
|
||||
@CommandType(entity = "SPONSORDDGLMAPPING", action = "UPDATE")
|
||||
public class UpdateSponsorDdGlMappingCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
|
||||
|
||||
@Autowired
|
||||
public UpdateSponsorDdGlMappingCommandHandler(
|
||||
final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService) {
|
||||
this.issueRequestWritePlatformService = issueRequestWritePlatformService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final Long sponsorBankId = command.longValueOfParameterNamed("sponsorBankId");
|
||||
final Long ddPayableGlAccountId = command.longValueOfParameterNamed("ddPayableGlAccountId");
|
||||
final Long chequeClearingGlAccountId = command.longValueOfParameterNamed("chequeClearingGlAccountId");
|
||||
final Long cashGlAccountId = command.longValueOfParameterNamed("cashGlAccountId");
|
||||
final Long chargesIncomeGlAccountId = command.longValueOfParameterNamed("chargesIncomeGlAccountId");
|
||||
final Long cashSuspenseGlAccountId = command.longValueOfParameterNamed("cashSuspenseGlAccountId");
|
||||
final BigDecimal gstPercentage = command.bigDecimalValueOfParameterNamed("gstPercentage");
|
||||
final Long gstPayableGlAccountId = command.longValueOfParameterNamed("gstPayableGlAccountId");
|
||||
final BigDecimal ddLimitAmount = command.bigDecimalValueOfParameterNamed("ddLimitAmount");
|
||||
|
||||
final CommandProcessingResult result = this.issueRequestWritePlatformService.upsertGlMapping(sponsorBankId,
|
||||
ddPayableGlAccountId, chequeClearingGlAccountId, cashGlAccountId, chargesIncomeGlAccountId,
|
||||
cashSuspenseGlAccountId, gstPercentage, gstPayableGlAccountId, ddLimitAmount);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(result.getResourceId()) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+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.sponsordd.service;
|
||||
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
|
||||
|
||||
public interface SponsorBankWritePlatformService {
|
||||
|
||||
CommandProcessingResult createSponsorBank(SponsorBankRequest request);
|
||||
|
||||
CommandProcessingResult updateSponsorBank(Long sponsorBankId, SponsorBankRequest request);
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sponsordd.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.sponsordd.api.SponsorBankApiResource.SponsorBankRequest;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBank;
|
||||
import org.apache.fineract.portfolio.sponsordd.domain.SponsorBankRepository;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Moved verbatim out of {@code SponsorBankApiResource.createSponsorBank()} and
|
||||
* {@code updateSponsorBank()} so both writes can be invoked from a {@code NewCommandSourceHandler}
|
||||
* for Maker-Checker support. {@link #validateDdPayableGlAccount(Long)} is a copy of the resource's
|
||||
* own (now-unused) validator, kept here since both CREATE and UPDATE rely on it.
|
||||
*/
|
||||
@Service
|
||||
public class SponsorBankWritePlatformServiceImpl implements SponsorBankWritePlatformService {
|
||||
|
||||
private final SponsorBankRepository sponsorBankRepository;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
public SponsorBankWritePlatformServiceImpl(final SponsorBankRepository sponsorBankRepository,
|
||||
final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate) {
|
||||
this.sponsorBankRepository = sponsorBankRepository;
|
||||
this.context = context;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult createSponsorBank(final SponsorBankRequest request) {
|
||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
|
||||
}
|
||||
if (request.getName() == null || request.getName().isBlank()) {
|
||||
throw new IllegalArgumentException("Sponsor Bank Name is required.");
|
||||
}
|
||||
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
// Set field by field rather than through the all-args constructor: that constructor is
|
||||
// positional, so adding a column silently shifts every argument after it, and the shift
|
||||
// type-checks whenever two neighbours share a type.
|
||||
final SponsorBank sponsorBank = new SponsorBank();
|
||||
sponsorBank.setName(request.getName().trim());
|
||||
sponsorBank.setDescription(request.getDescription());
|
||||
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
? request.getIfscCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setBankCode(request.getBankCode());
|
||||
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
|
||||
? request.getSwiftCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
|
||||
sponsorBank.setCreatedBy(currentUser);
|
||||
sponsorBank.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
this.sponsorBankRepository.saveAndFlush(sponsorBank);
|
||||
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult updateSponsorBank(final Long sponsorBankId, final SponsorBankRequest request) {
|
||||
final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sponsor bank not found."));
|
||||
if (request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
&& !request.getIfscCode().trim().toUpperCase().matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"IFSC Code must be 11 characters: 4 letters, then '0', then 6 alphanumeric characters (e.g. SBIN0001234).");
|
||||
}
|
||||
validateDdPayableGlAccount(request.getDdPayableGlAccountId());
|
||||
if (request.getName() != null && !request.getName().isBlank()) {
|
||||
sponsorBank.setName(request.getName().trim());
|
||||
}
|
||||
sponsorBank.setDescription(request.getDescription());
|
||||
sponsorBank.setIfscCode(request.getIfscCode() != null && !request.getIfscCode().isBlank()
|
||||
? request.getIfscCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setBankCode(request.getBankCode());
|
||||
sponsorBank.setSwiftCode(request.getSwiftCode() != null && !request.getSwiftCode().isBlank()
|
||||
? request.getSwiftCode().trim().toUpperCase() : null);
|
||||
sponsorBank.setDdPayableGlAccountId(request.getDdPayableGlAccountId());
|
||||
this.sponsorBankRepository.saveAndFlush(sponsorBank);
|
||||
return new CommandProcessingResultBuilder().withEntityId(sponsorBank.getId()).build();
|
||||
}
|
||||
|
||||
/** Copied from {@code SponsorBankApiResource} - see that class for the full rationale. */
|
||||
private void validateDdPayableGlAccount(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT gl_code, name, classification_enum, account_usage, disabled FROM acc_gl_account WHERE id = ?",
|
||||
glAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("The selected DD Payable GL account does not exist.");
|
||||
}
|
||||
final Map<String, Object> gl = rows.get(0);
|
||||
final String label = gl.get("gl_code") + " · " + gl.get("name");
|
||||
// 2 is LIABILITY in Fineract's GLAccountType enum.
|
||||
if (((Number) gl.get("classification_enum")).intValue() != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"DD Payable must be a LIABILITY account — a draft is money owed to the sponsor bank until it is presented. "
|
||||
+ label + " is not a liability account.");
|
||||
}
|
||||
// 2 is HEADER in Fineract's GLAccountUsage enum; nothing can be posted to a header.
|
||||
if (((Number) gl.get("account_usage")).intValue() == 2) {
|
||||
throw new IllegalArgumentException("DD Payable must be a detail account, not a header. Pick a postable account under "
|
||||
+ label + ".");
|
||||
}
|
||||
// Read through both shapes: the column comes back as a Boolean on some drivers and as a
|
||||
// 0/1 Number on others, and a plain Boolean cast would throw on the latter.
|
||||
final Object disabled = gl.get("disabled");
|
||||
final boolean isDisabled = disabled instanceof Boolean b ? b
|
||||
: disabled instanceof Number n && n.intValue() != 0;
|
||||
if (isDisabled) {
|
||||
throw new IllegalArgumentException("The selected DD Payable GL account (" + label + ") is disabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
package org.apache.fineract.spm.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
@@ -37,10 +36,14 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.spm.data.SurveyData;
|
||||
import org.apache.fineract.spm.domain.Survey;
|
||||
@@ -57,6 +60,8 @@ public class SpmApiResource {
|
||||
|
||||
private final PlatformSecurityContext securityContext;
|
||||
private final SpmService spmService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -103,12 +108,10 @@ public class SpmApiResource {
|
||||
@Operation(summary = "Create a Survey", description = "Adds a new survey to collect client related data.\n" + "\n"
|
||||
+ "Mandatory Fields\n" + "\n" + "countryCode, key, name, questions, responses, sequenceNo, text, description")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public String createSurvey(@Parameter(description = "Create survey") final SurveyData surveyData) {
|
||||
this.securityContext.authenticatedUser();
|
||||
final Survey survey = SurveyMapper.map(surveyData, new Survey());
|
||||
this.spmService.createSurvey(survey);
|
||||
return getResponse(survey.getId());
|
||||
|
||||
public String createSurvey(@Parameter(description = "Create survey") final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createSurvey().withJson(apiRequestBodyAsJson).build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@@ -116,12 +119,10 @@ public class SpmApiResource {
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Transactional
|
||||
public String editSurvey(@PathParam("id") final Long id, final SurveyData surveyData) {
|
||||
this.securityContext.authenticatedUser();
|
||||
final Survey surveyToUpdate = this.spmService.findById(id);
|
||||
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
|
||||
this.spmService.updateSurvey(survey);
|
||||
return getResponse(survey.getId());
|
||||
public String editSurvey(@PathParam("id") final Long id, final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().updateSurvey(id).withJson(apiRequestBodyAsJson).build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -131,22 +132,17 @@ public class SpmApiResource {
|
||||
@Transactional
|
||||
@Operation(summary = "Deactivate Survey", description = "")
|
||||
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
|
||||
public void activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||
this.securityContext.authenticatedUser();
|
||||
public String activateOrDeactivateSurvey(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||
final CommandWrapperBuilder builder = new CommandWrapperBuilder();
|
||||
final CommandWrapper commandRequest;
|
||||
if (command != null && command.equalsIgnoreCase("activate")) {
|
||||
this.spmService.activateSurvey(id);
|
||||
commandRequest = builder.activateSurvey(id).build();
|
||||
} else if (command != null && command.equalsIgnoreCase("deactivate")) {
|
||||
this.spmService.deactivateSurvey(id);
|
||||
commandRequest = builder.deactivateSurvey(id).build();
|
||||
} else {
|
||||
throw new UnrecognizedQueryParamException("command", command);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private String getResponse(Long id) {
|
||||
Gson gson = new Gson();
|
||||
HashMap<String, Object> response = new HashMap<>();
|
||||
response.put("resourceId", id);
|
||||
return gson.toJson(response);
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
}
|
||||
}
|
||||
|
||||
+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.spm.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.spm.service.SpmService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SURVEY", action = "ACTIVATE")
|
||||
public class ActivateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SpmService spmService;
|
||||
|
||||
@Autowired
|
||||
public ActivateSurveyCommandHandler(final SpmService spmService) {
|
||||
this.spmService = spmService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
this.spmService.activateSurvey(command.entityId());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(command.entityId()) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.spm.handler;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.spm.data.SurveyData;
|
||||
import org.apache.fineract.spm.domain.Survey;
|
||||
import org.apache.fineract.spm.service.SpmService;
|
||||
import org.apache.fineract.spm.util.SurveyMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SURVEY", action = "CREATE")
|
||||
public class CreateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SpmService spmService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public CreateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
|
||||
this.spmService = spmService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final SurveyData surveyData = readSurveyData(command);
|
||||
final Survey survey = SurveyMapper.map(surveyData, new Survey());
|
||||
this.spmService.createSurvey(survey);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(survey.getId()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
private SurveyData readSurveyData(final JsonCommand command) {
|
||||
try {
|
||||
return this.objectMapper.readValue(command.json(), SurveyData.class);
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.spm.handler;
|
||||
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.spm.service.SpmService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SURVEY", action = "DEACTIVATE")
|
||||
public class DeactivateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SpmService spmService;
|
||||
|
||||
@Autowired
|
||||
public DeactivateSurveyCommandHandler(final SpmService spmService) {
|
||||
this.spmService = spmService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
this.spmService.deactivateSurvey(command.entityId());
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(command.entityId()) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.spm.handler;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.apache.fineract.commands.annotation.CommandType;
|
||||
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.spm.data.SurveyData;
|
||||
import org.apache.fineract.spm.domain.Survey;
|
||||
import org.apache.fineract.spm.service.SpmService;
|
||||
import org.apache.fineract.spm.util.SurveyMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@CommandType(entity = "SURVEY", action = "UPDATE")
|
||||
public class UpdateSurveyCommandHandler implements NewCommandSourceHandler {
|
||||
|
||||
private final SpmService spmService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
public UpdateSurveyCommandHandler(final SpmService spmService, final ObjectMapper objectMapper) {
|
||||
this.spmService = spmService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public CommandProcessingResult processCommand(final JsonCommand command) {
|
||||
|
||||
final SurveyData surveyData = readSurveyData(command);
|
||||
final Survey surveyToUpdate = this.spmService.findById(command.entityId());
|
||||
final Survey survey = SurveyMapper.map(surveyData, surveyToUpdate);
|
||||
this.spmService.updateSurvey(survey);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(survey.getId()) //
|
||||
.build();
|
||||
}
|
||||
|
||||
private SurveyData readSurveyData(final JsonCommand command) {
|
||||
try {
|
||||
return this.objectMapper.readValue(command.json(), SurveyData.class);
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.survey.invalid.json", "Invalid survey payload: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -97,15 +97,50 @@ public class PermissionReadPlatformServiceImpl implements PermissionReadPlatform
|
||||
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission codes whose approval workflow is a bespoke, unconditional pending-request mechanism
|
||||
* (m_user_pending_request / m_share_account_pending_edit / m_current_account_pending_edit /
|
||||
* m_client_pending_edit) that never reads {@code can_maker_checker} - verified by inspecting every
|
||||
* write-service on this path (UserPendingWritePlatformServiceImpl, ShareAccountEditWritePlatformServiceJpaRepositoryImpl,
|
||||
* CurrentAccountEditWritePlatformServiceJpaRepositoryImpl, and the client edit equivalent): none of
|
||||
* them consult ConfigurationDomainService in any way. Toggling this checkbox on the Configure
|
||||
* Maker-Checker Tasks screen therefore has zero effect on these tasks in either direction, so they
|
||||
* are excluded here rather than presented as configurable when they are not. CREATE_USER/UPDATE_USER
|
||||
* remain governed by the separate global "maker-checker" configuration switch (System ->
|
||||
* Configurations), not by this per-task flag.
|
||||
*/
|
||||
private static final String DEAD_MAKER_CHECKER_CODES = "'CREATE_USER','UPDATE_USER',"
|
||||
+ "'SUBMITEDIT_SHAREACCOUNT','APPROVEEDIT_SHAREACCOUNT','REJECTEDIT_SHAREACCOUNT',"
|
||||
+ "'SUBMITEDIT_CURRENTACCOUNT','APPROVEEDIT_CURRENTACCOUNT','REJECTEDIT_CURRENTACCOUNT',"
|
||||
+ "'SUBMITEDIT_CLIENT','APPROVEEDIT_CLIENT','REJECTEDIT_CLIENT'";
|
||||
|
||||
/*
|
||||
* Unlike DEAD_MAKER_CHECKER_CODES above, these codes ARE read by the live maker-checker pipeline
|
||||
* (CommandSourceService.isMakerCheckerEnabledForTask()) - they are excluded from this screen for a
|
||||
* different reason: UPDATE_PERMISSION governs this very "Configure Maker Checker Tasks" screen
|
||||
* (toggling which tasks require maker-checker), and UPDATE_CONFIGURATION governs the global
|
||||
* maker-checker on/off switch and other System -> Configurations settings. If either were flipped
|
||||
* to can_maker_checker=true and the approving checker.user lacked CHECKER_SUPER_USER, an admin
|
||||
* could lock themselves out of ever approving further changes to maker-checker configuration
|
||||
* itself, with no ordinary way to reverse it. Excluding them here only hides them from this
|
||||
* screen; it does not touch their can_maker_checker value or their normal enforcement.
|
||||
*/
|
||||
private static final String SELF_REFERENTIAL_MAKER_CHECKER_CODES = "'UPDATE_PERMISSION','UPDATE_CONFIGURATION'";
|
||||
|
||||
public String makerCheckerablePermissionSchema() {
|
||||
/*
|
||||
* get all 'Maker-Checkerable' permissions - Maintenance permissions (i.e. exclude the 'special' grouping,
|
||||
* the READ permissions and the CHECKER permissions
|
||||
* the READ permissions and the CHECKER permissions), and exclude permission codes whose approval
|
||||
* workflow does not actually read can_maker_checker (see DEAD_MAKER_CHECKER_CODES above), as well as
|
||||
* codes that control maker-checker configuration itself (see SELF_REFERENTIAL_MAKER_CHECKER_CODES
|
||||
* above) to avoid a self-lockout.
|
||||
*/
|
||||
|
||||
return "select p.grouping, p.code, p.entity_name as entityName, p.action_name as actionName, p.can_maker_checker as selected"
|
||||
+ " from m_permission p " + " where " + sqlGenerator.escape("grouping")
|
||||
+ " != 'special' and code not like 'READ_%' and code not like '%\\_CHECKER'"
|
||||
+ " and code not in (" + DEAD_MAKER_CHECKER_CODES + ")"
|
||||
+ " and code not in (" + SELF_REFERENTIAL_MAKER_CHECKER_CODES + ")"
|
||||
+ " order by p.grouping, coalesce(entity_name, ''), p.code";
|
||||
}
|
||||
|
||||
|
||||
+101
-1
@@ -36,7 +36,13 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.Page;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
import org.apache.fineract.organisation.staff.domain.Staff;
|
||||
import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
|
||||
import org.apache.fineract.useradministration.data.UserPendingRequestData;
|
||||
import org.apache.fineract.useradministration.domain.Role;
|
||||
import org.apache.fineract.useradministration.domain.RoleRepository;
|
||||
import org.apache.fineract.useradministration.domain.UserPendingRequest;
|
||||
import org.apache.fineract.useradministration.domain.UserPendingRequestRepositoryWrapper;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
@@ -51,8 +57,13 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final UserPendingRequestRepositoryWrapper pendingRequestRepositoryWrapper;
|
||||
private final OfficeRepositoryWrapper officeRepositoryWrapper;
|
||||
private final StaffRepositoryWrapper staffRepositoryWrapper;
|
||||
private final RoleRepository roleRepository;
|
||||
private final Gson gson = GoogleGsonSerializerHelper.createSimpleGson();
|
||||
|
||||
private static final String UNKNOWN_REFERENCE_FORMAT = "Unknown (ID: %s)";
|
||||
|
||||
private static final String SELECT_SQL = """
|
||||
SELECT r.id AS id,
|
||||
r.user_id AS userId,
|
||||
@@ -115,7 +126,96 @@ public class UserPendingReadPlatformServiceImpl implements UserPendingReadPlatfo
|
||||
public UserPendingRequestData retrievePendingRequest(final Long id) {
|
||||
final UserPendingRequest request = this.pendingRequestRepositoryWrapper.findOneWithNotFoundDetection(id);
|
||||
final String sql = SELECT_SQL + " WHERE r.id = ?";
|
||||
return this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
|
||||
final UserPendingRequestData data = this.jdbcTemplate.queryForObject(sql, new UserPendingDetailRowMapper(), id);
|
||||
resolveDisplayNames(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves office/staff/role ids on the pending-user comparison payload to human-readable names, and folds
|
||||
* the point-in-time original-side name fields (officeName/staffName/roleNames) into their corresponding
|
||||
* id-keyed rows so the compare dialog shows one row per field instead of a redundant name-only row.
|
||||
*/
|
||||
private void resolveDisplayNames(final UserPendingRequestData data) {
|
||||
final Map<String, Object> originalValues = data.getOriginalValues();
|
||||
final Map<String, Object> proposedValues = data.getProposedValues();
|
||||
|
||||
// CREATE_USER requests have no original snapshot - nothing to fold on that side.
|
||||
if (originalValues != null && !originalValues.isEmpty()) {
|
||||
foldNameIntoId(originalValues, "officeName", "officeId");
|
||||
foldNameIntoId(originalValues, "staffName", "staffId");
|
||||
if (originalValues.containsKey("roleNames")) {
|
||||
originalValues.put("roles", sortNames(toStringList(originalValues.get("roleNames"))));
|
||||
originalValues.remove("roleNames");
|
||||
}
|
||||
}
|
||||
|
||||
if (proposedValues != null && !proposedValues.isEmpty()) {
|
||||
if (proposedValues.get("officeId") instanceof Number officeIdNum) {
|
||||
proposedValues.put("officeId", resolveOfficeName(officeIdNum.longValue()));
|
||||
}
|
||||
if (proposedValues.get("staffId") instanceof Number staffIdNum) {
|
||||
proposedValues.put("staffId", resolveStaffName(staffIdNum.longValue()));
|
||||
}
|
||||
if (proposedValues.get("roles") instanceof Collection<?> rolesCollection) {
|
||||
proposedValues.put("roles", sortNames(resolveRoleNames(toStringList(rolesCollection))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void foldNameIntoId(final Map<String, Object> values, final String nameKey, final String idKey) {
|
||||
if (values.containsKey(nameKey)) {
|
||||
values.put(idKey, values.get(nameKey));
|
||||
values.remove(nameKey);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveOfficeName(final Long officeId) {
|
||||
try {
|
||||
final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
return office.getName();
|
||||
} catch (final RuntimeException e) {
|
||||
return String.format(UNKNOWN_REFERENCE_FORMAT, officeId);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveStaffName(final Long staffId) {
|
||||
try {
|
||||
final Staff staff = this.staffRepositoryWrapper.findOneWithNotFoundDetection(staffId);
|
||||
return staff.displayName();
|
||||
} catch (final RuntimeException e) {
|
||||
return String.format(UNKNOWN_REFERENCE_FORMAT, staffId);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveRoleNames(final List<String> roleIds) {
|
||||
final List<String> names = new ArrayList<>();
|
||||
for (final String roleIdStr : roleIds) {
|
||||
try {
|
||||
final Long roleId = Long.valueOf(roleIdStr);
|
||||
final Role role = this.roleRepository.findById(roleId).orElse(null);
|
||||
names.add(role != null ? role.getName() : String.format(UNKNOWN_REFERENCE_FORMAT, roleId));
|
||||
} catch (final NumberFormatException e) {
|
||||
names.add(roleIdStr);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static List<String> sortNames(final List<String> names) {
|
||||
final List<String> sorted = new ArrayList<>(names);
|
||||
sorted.sort(String.CASE_INSENSITIVE_ORDER);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static List<String> toStringList(final Object value) {
|
||||
final List<String> result = new ArrayList<>();
|
||||
if (value instanceof Collection<?> collection) {
|
||||
for (final Object item : collection) {
|
||||
result.add(String.valueOf(item));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static class UserPendingRowMapper implements RowMapper<UserPendingRequestData> {
|
||||
|
||||
+39
-2
@@ -40,6 +40,7 @@ import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRu
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.serialization.GoogleGsonSerializerHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.PlatformEmailService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
|
||||
@@ -82,6 +83,7 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final UserDataValidator fromApiJsonDeserializer;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final PlatformEmailService emailService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -162,8 +164,8 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
originalSnapshot.put("officeId", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getId() : null);
|
||||
originalSnapshot.put("officeName", userToUpdate.getOffice() != null ? userToUpdate.getOffice().getName() : null);
|
||||
originalSnapshot.put("staffId", userToUpdate.getStaff() != null ? userToUpdate.getStaff().getId() : null);
|
||||
originalSnapshot.put("staffName", userToUpdate.getStaff() != null ? userToUpdate.getStaff().displayName() : null);
|
||||
originalSnapshot.put("passwordNeverExpires", userToUpdate.getPasswordNeverExpires());
|
||||
originalSnapshot.put("isSelfServiceUser", userToUpdate.isSelfServiceUser());
|
||||
final Set<String> roleNames = new HashSet<>();
|
||||
final Set<Long> roleIds = new HashSet<>();
|
||||
if (userToUpdate.getRoles() != null) {
|
||||
@@ -293,13 +295,40 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
appUser.updatePasswordResetRequired(true);
|
||||
}
|
||||
|
||||
// Set pre-hashed password
|
||||
// AppUser.fromJson() already generated a random plaintext password here when the maker ticked
|
||||
// "send password to email" (it ignores whatever password the maker typed in that case) -- the
|
||||
// field still holds that plaintext at this point, since nothing has encoded it yet. If the
|
||||
// maker instead supplied their own password, request.getPasswordHash() carries the bcrypt
|
||||
// hash submitCreateUser() produced from it; that plaintext was redacted to "[PROTECTED]"
|
||||
// immediately and is gone for good, so there is nothing to email in that case.
|
||||
final boolean sendPasswordToEmail = Boolean.TRUE.equals(replay.booleanObjectValueOfParameterNamed("sendPasswordToEmail"));
|
||||
final String unencodedPassword = appUser.getPassword();
|
||||
|
||||
if (request.getPasswordHash() != null) {
|
||||
// Maker-chosen password: already hashed, used as-is.
|
||||
appUser.updatePassword(request.getPasswordHash());
|
||||
} else {
|
||||
// Auto-generated password (the normal "send password to email" case): this service's own
|
||||
// generic PasswordEncoder, not the salted PlatformPasswordEncoder the live create-user flow
|
||||
// uses -- consistent with how the maker-chosen-password branch above and submitCreateUser()
|
||||
// already hash a password in this file. Encoding it here is not optional: AppUser's
|
||||
// constructor stores whatever string it is given verbatim (see AppUser(Office, User, ...)),
|
||||
// so skipping this would persist the plaintext password straight into m_appuser.password.
|
||||
appUser.updatePassword(this.passwordEncoder.encode(unencodedPassword));
|
||||
}
|
||||
|
||||
this.appUserRepository.saveAndFlush(appUser);
|
||||
|
||||
// Mirrors JpaUserDomainService.create(): the welcome email the live create-user flow sends is
|
||||
// the one thing this parallel, pending-request flow never did -- sendPasswordToEmail was
|
||||
// accepted and stored in the payload but nothing here ever acted on it, so a user created this
|
||||
// way never received their password. Only fires for the auto-generated password: there is no
|
||||
// plaintext to send for a maker-chosen one, per the comment above.
|
||||
if (sendPasswordToEmail && request.getPasswordHash() == null) {
|
||||
this.emailService.sendToUserAccount(userOffice.getName(), appUser.getFirstname(), appUser.getEmail(), appUser.getUsername(),
|
||||
unencodedPassword);
|
||||
}
|
||||
|
||||
// MFA Enrollment
|
||||
final String otpDeliveryMethod = replay.stringValueOfParameterNamed("otpDeliveryMethod");
|
||||
if (otpDeliveryMethod != null) {
|
||||
@@ -321,6 +350,14 @@ public class UserPendingWritePlatformServiceImpl implements UserPendingWritePlat
|
||||
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
|
||||
|
||||
final JsonElement parsedElement = this.fromApiJsonHelper.parse(request.getPayloadJson());
|
||||
if (parsedElement.isJsonObject()) {
|
||||
// the stored payload keeps a "[PROTECTED]" placeholder for the password rather than
|
||||
// the real value; the actual new password is applied below via request.getPasswordHash(),
|
||||
// so these keys must not reach AppUser.update() or it will try to re-encode the placeholder
|
||||
final JsonObject payloadObject = parsedElement.getAsJsonObject();
|
||||
payloadObject.remove("password");
|
||||
payloadObject.remove("repeatPassword");
|
||||
}
|
||||
final JsonCommand replay = JsonCommand.fromJsonElement(request.getUserId(), parsedElement, this.fromApiJsonHelper);
|
||||
|
||||
final Map<String, Object> changes = userToUpdate.update(replay, null, null);
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user